diff --git a/.gitignore b/.gitignore index 5322f0926..0252fb581 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ backends/advanced-backend/data/speaker_model_cache/ *.bin *.sqlite3 *checkpoints + +extras/openmemory-mcp/data/* +.env.backup.* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 21507cafd..a5e14a77a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -481,7 +481,9 @@ The sophisticated in-house memory implementation with full control and customiza - Individual fact storage (no JSON blobs) - Smart deduplication algorithms - Intelligent memory updates (ADD/UPDATE/DELETE decisions) -- Direct Qdrant vector storage +- **Semantic search** with relevance threshold filtering +- **Memory count API** with total count tracking from native Qdrant +- Direct Qdrant vector storage with accurate similarity scoring - Custom memory prompts and processing - No external dependencies @@ -518,10 +520,12 @@ Thin client that delegates all memory processing to external OpenMemory MCP serv | **Processing** | Custom LLM extraction | Delegates to OpenMemory | | **Deduplication** | Custom algorithms | OpenMemory handles | | **Vector Storage** | Direct Qdrant | OpenMemory handles | +| **Search Features** | Semantic search with threshold filtering | Semantic search with relevance scoring | +| **Memory Count** | Native Qdrant count API | Varies by OpenMemory support | | **Dependencies** | Qdrant + MongoDB | External OpenMemory server | | **Customization** | Full control | Limited to OpenMemory features | | **Cross-client** | Friend-Lite only | Works with Claude Desktop, Cursor, etc | -| **Web UI** | Friend-Lite WebUI | OpenMemory UI + Friend-Lite WebUI | +| **Web UI** | Friend-Lite WebUI with advanced search | OpenMemory UI + Friend-Lite WebUI | | **Memory Format** | Individual facts | OpenMemory format | | **Setup Complexity** | Medium | High (external server required) | @@ -640,6 +644,7 @@ Project includes `.cursor/rules/always-plan-first.mdc` requiring understanding b ### Memory & Conversation Debugging - **GET /api/admin/memories**: All memories across all users with debug stats (Admin only) - **GET /api/memories/unfiltered**: User's memories without filtering +- **GET /api/memories/search**: Semantic memory search with relevance scoring - **GET /api/conversations**: User's conversations with transcripts - **GET /api/conversations/{audio_uuid}**: Specific conversation details diff --git a/Docs/init-system.md b/Docs/init-system.md new file mode 100644 index 000000000..30eaf6a6d --- /dev/null +++ b/Docs/init-system.md @@ -0,0 +1,207 @@ +# Friend-Lite Initialization System + +## Overview + +Friend-Lite uses a unified initialization system with clean separation of concerns: + +- **Configuration** (`init.py`) - Set up service configurations, API keys, and .env files +- **Service Management** (`services.py`) - Start, stop, and manage running services + +The root orchestrator handles service selection and delegates configuration to individual service scripts. In general, setup scripts only configure and do not start services automatically. Exceptions: `extras/asr-services` and `extras/openmemory-mcp` are startup scripts. This prevents unnecessary resource usage and gives you control over when services actually run. + +## Architecture + +### Root Orchestrator +- **Location**: `/init.py` +- **Purpose**: Service selection and delegation only +- **Does NOT**: Handle service-specific configuration or duplicate setup logic + +### Service Scripts +- **Backend**: `backends/advanced/init.py` - Complete Python-based interactive setup +- **Speaker Recognition**: `extras/speaker-recognition/setup.sh` - Simple bash setup +- **ASR Services**: `extras/asr-services/setup.sh` - Service startup script +- **OpenMemory MCP**: `extras/openmemory-mcp/setup.sh` - External server startup + +## Usage + +### Orchestrated Setup (Recommended) +Set up multiple services together with automatic URL coordination: + +```bash +# From project root +uv run --with-requirements setup-requirements.txt python init.py +``` + +The orchestrator will: +1. Show service status and availability +2. Let you select which services to configure +3. Automatically pass service URLs between services +4. Display next steps for starting services + +### Individual Service Setup +Each service can be configured independently: + +```bash +# Advanced Backend only +cd backends/advanced +uv run --with-requirements setup-requirements.txt python init.py + +# Speaker Recognition only +cd extras/speaker-recognition +./setup.sh + +# ASR Services only +cd extras/asr-services +./setup.sh + +# OpenMemory MCP only +cd extras/openmemory-mcp +./setup.sh +``` + +## Service Details + +### Advanced Backend +- **Interactive setup** for authentication, LLM, transcription, and memory providers +- **Accepts arguments**: `--speaker-service-url`, `--parakeet-asr-url` +- **Generates**: Complete `.env` file with all required configuration +- **Default ports**: Backend (8000), WebUI (5173) + +### Speaker Recognition +- **Prompts for**: Hugging Face token, compute mode (cpu/gpu) +- **Service port**: 8085 +- **WebUI port**: 5173 +- **Requires**: HF_TOKEN for pyannote models + +### ASR Services +- **Starts**: Parakeet ASR service via Docker Compose +- **Service port**: 8767 +- **Purpose**: Offline speech-to-text processing +- **No configuration required** + +### OpenMemory MCP +- **Starts**: External OpenMemory MCP server +- **Service port**: 8765 +- **WebUI**: Available at http://localhost:8765 +- **Purpose**: Cross-client memory compatibility + +## Automatic URL Coordination + +When using the orchestrated setup, service URLs are automatically configured: + +| Service Selected | Backend Gets Configured With | +|----------------------|-------------------------------------------------------------------| +| Speaker Recognition | `SPEAKER_SERVICE_URL=http://host.docker.internal:8085` | +| ASR Services | `PARAKEET_ASR_URL=http://host.docker.internal:8767` | + +This eliminates the need to manually configure service URLs when running services on the same machine. +Note (Linux): If `host.docker.internal` is unavailable, add `extra_hosts: - "host.docker.internal:host-gateway"` to the relevant services in `docker-compose.yml`. + +## Key Benefits + +✅ **No Unnecessary Building** - Services are only started when you explicitly request them +✅ **Resource Efficient** - Parakeet ASR won't start if you're using cloud transcription +✅ **Clean Separation** - Configuration vs service management are separate concerns +✅ **Unified Control** - Single command to start/stop all services +✅ **Selective Starting** - Choose which services to run based on your current needs + +## Service URLs + +### Default Service Endpoints +- **Backend API**: http://localhost:8000 +- **Backend WebUI**: http://localhost:5173 +- **Speaker Recognition**: http://localhost:8085 +- **Speaker Recognition WebUI**: http://localhost:5173 +- **Parakeet ASR**: http://localhost:8767 +- **OpenMemory MCP**: http://localhost:8765 + +### Container-to-Container Communication +Services use `host.docker.internal` for inter-container communication: +- `http://host.docker.internal:8085` - Speaker Recognition +- `http://host.docker.internal:8767` - Parakeet ASR +- `http://host.docker.internal:8765` - OpenMemory MCP + +## Service Management + +Friend-Lite now separates **configuration** from **service lifecycle management**: + +### Unified Service Management +Use the `services.py` script for all service operations: + +```bash +# Start all configured services +uv run --with-requirements setup-requirements.txt python services.py start --all --build + +# Start specific services +uv run --with-requirements setup-requirements.txt python services.py start backend speaker-recognition + +# Check service status +uv run --with-requirements setup-requirements.txt python services.py status + +# Stop all services +uv run --with-requirements setup-requirements.txt python services.py stop --all + +# Stop specific services +uv run --with-requirements setup-requirements.txt python services.py stop asr-services openmemory-mcp +``` + +### Manual Service Management +You can also manage services individually: + +```bash +# Advanced Backend +cd backends/advanced && docker compose up --build -d + +# Speaker Recognition +cd extras/speaker-recognition && docker compose up --build -d + +# ASR Services (only if using offline transcription) +cd extras/asr-services && docker compose up --build -d + +# OpenMemory MCP (only if using openmemory_mcp provider) +cd extras/openmemory-mcp && docker compose up --build -d +``` + +## Configuration Files + +### Generated Files +- `backends/advanced/.env` - Backend configuration with all services +- `extras/speaker-recognition/.env` - Speaker service configuration +- All services backup existing `.env` files automatically + +### Required Dependencies +- **Root**: `setup-requirements.txt` (rich>=13.0.0) +- **Backend**: `setup-requirements.txt` (rich>=13.0.0, pyyaml>=6.0.0) +- **Extras**: No additional setup dependencies required + +## Troubleshooting + +### Common Issues +- **Port conflicts**: Check if services are already running on default ports +- **Permission errors**: Ensure scripts are executable (`chmod +x setup.sh`) +- **Missing dependencies**: Install uv and ensure setup-requirements.txt dependencies available +- **Service startup failures**: Check Docker is running and has sufficient resources + +### Service Health Checks +```bash +# Backend health +curl http://localhost:8000/health + +# Speaker Recognition health +curl http://localhost:8085/health + +# ASR service health +curl http://localhost:8767/health +``` + +### Logs and Debugging +```bash +# View service logs +docker compose logs [service-name] + +# Backend logs +cd backends/advanced && docker compose logs friend-backend + +# Speaker Recognition logs +cd extras/speaker-recognition && docker compose logs speaker-service +``` \ No newline at end of file diff --git a/Docs/ports-and-access.md b/Docs/ports-and-access.md new file mode 100644 index 000000000..d9b51cf35 --- /dev/null +++ b/Docs/ports-and-access.md @@ -0,0 +1,138 @@ +# Friend-Lite Port Configuration & User Journey + +## User Journey: Git Clone to Running Services + +### 1. Clone & Setup +```bash +git clone +cd friend-lite + +# Configure all services +uv run --with-requirements setup-requirements.txt python init.py + +# Start all configured services +uv run --with-requirements setup-requirements.txt python services.py start --all --build +``` + +### 2. Service Access Points + +## HTTP Mode (Default - No SSL Required) + +| Service | API Port | Web UI Port | Access URL | +|---------|----------|-------------|------------| +| **Advanced Backend** | 8000 | 5173 | http://localhost:8000 (API)
http://localhost:5173 (Dashboard) | +| **Speaker Recognition** | 8085 | 5174 | http://localhost:8085 (API)
http://localhost:5174 (WebUI) | +| **Parakeet ASR** | 8767 | - | http://localhost:8767 (API) | +| **OpenMemory MCP** | 8765 | 8765 | http://localhost:8765 (API + WebUI) | + +**🌐 Main Dashboard**: http://localhost:5173 +**🎤 Speaker Recognition**: http://localhost:5174 +**❌ No microphone access** - browsers require HTTPS for microphone + +--- + +## HTTPS Mode (For Microphone Access) + +| Service | HTTP Port | HTTPS Port | Access URL | Microphone Access | +|---------|-----------|------------|------------|-------------------| +| **Advanced Backend** | 80→443 | 443 | https://localhost/ (Main)
https://localhost/api/ (API) | ✅ Yes | +| **Speaker Recognition** | 8081→8444 | 8444 | https://localhost:8444/ (Main)
https://localhost:8444/api/ (API) | ✅ Yes | + +**🌐 Main Dashboard**: https://localhost/ (Advanced Backend with SSL) +**🎤 Speaker Recognition**: https://localhost:8444/ (Speaker Recognition with SSL) +**✅ Full microphone access** - both services secured with SSL + +### Port Details (HTTPS Mode) +- **Advanced Backend nginx**: Ports 80 (HTTP redirect) + 443 (HTTPS) +- **Speaker Recognition nginx**: Ports 8081 (HTTP redirect) + 8444 (HTTPS) +- **No port conflicts** - different port ranges for each service + +--- + +## Why Two Modes? + +### HTTP Mode (Default) +✅ **Simple setup** - No SSL certificates needed +✅ **Development friendly** - Quick start for testing +❌ **No microphone access** - Browsers require HTTPS for microphone + +### HTTPS Mode (Advanced) +✅ **Microphone access** - Browsers allow mic access over HTTPS +✅ **Production ready** - Secure for real deployments +❌ **Complex setup** - Requires SSL certificate generation + +--- + +## Configuration Files + +### Speaker Recognition Modes + +The speaker recognition service supports both modes via configuration: + +**HTTP Mode (.env)**: +```bash +REACT_UI_PORT=5174 +REACT_UI_HTTPS=false +``` + +**HTTPS Mode (.env)**: +```bash +REACT_UI_PORT=5175 # Internal HTTPS port +REACT_UI_HTTPS=true +# nginx provides external access on ports 8081 (HTTP) and 8444 (HTTPS) +``` + +--- + +## Service Management Commands + +```bash +# Check what's running +uv run --with-requirements setup-requirements.txt python services.py status + +# Start all services +uv run --with-requirements setup-requirements.txt python services.py start --all --build + +# Start only specific services +uv run --with-requirements setup-requirements.txt python services.py start backend speaker-recognition + +# Stop all services +uv run --with-requirements setup-requirements.txt python services.py stop --all +``` + +--- + +## Microphone Access Requirements + +For **speaker recognition** and **live audio features** to work: + +1. **Local development**: Use HTTP mode, access via `http://localhost:5174` + - Some browsers allow localhost microphone access over HTTP + +2. **Production/Remote access**: Use HTTPS mode, access via `https://localhost:8444` + - All browsers require HTTPS for microphone access over network + +3. **Mixed setup**: Keep backend on HTTP, only enable HTTPS for speaker recognition when needed + +--- + +## Port Conflict Resolution + +If you encounter port conflicts: + +1. **Check running services**: `uv run --with-requirements setup-requirements.txt python services.py status` +2. **Stop conflicting services**: `uv run --with-requirements setup-requirements.txt python services.py stop --all` +3. **Change ports in .env files** if needed +4. **Restart services**: `uv run --with-requirements setup-requirements.txt python services.py start --all` + +--- + +## Summary: Default User Experience + +After `git clone` and running init + services: + +🌐 **Main Application**: http://localhost:5173 +🎤 **Speaker Recognition**: http://localhost:5174 (HTTP) or https://localhost:8444 (HTTPS) +🔧 **Backend API**: http://localhost:8000 +📝 **ASR Service**: http://localhost:8767 +🧠 **Memory Service**: http://localhost:8765 \ No newline at end of file diff --git a/README.md b/README.md index 6c2997b3d..29d526f63 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ ## Quick Start -**Quick start:** -1. Go to `backends/advanced/` for the main application -2. Copy `.env.template` to `.env` and configure your API keys -3. Run `docker compose up --build -d` -4. Visit `http://localhost:3000` for the web interface +**Interactive setup (recommended):** +1. From the project root, run `uv run --with-requirements setup-requirements.txt python init.py` to configure all services with guided prompts +2. Run `python services.py start --all --build` to start all configured services +3. Visit `http://localhost:5173` for the React web dashboard -**Documentation:** See `CLAUDE.md` and `backends/advanced/Docs/` +**Manual setup (alternative):** +1. Go to `backends/advanced/` and copy `.env.template` to `.env` +2. Configure your API keys and service settings manually +3. Start with `docker compose up --build -d` + +**Documentation:** See `CLAUDE.md` and `Docs/init-system.md` for detailed setup guide ## Overview Friend-Lite provides essential components for developers working with OMI-compatible audio devices: @@ -41,6 +45,9 @@ Friend-Lite supports AI-powered personal systems through multiple OMI-compatible **Core Features:** - **Advanced memory system** with pluggable providers (Friend-Lite native or OpenMemory MCP) - **Memory extraction** from conversations with individual fact storage +- **Semantic memory search** with relevance threshold filtering and live results +- **Memory count display** with total count tracking from native providers +- **Speaker-based memory filtering** to control processing based on participant presence - **Action item detection** and tracking - **Home automation** integration - **Multi-device support** for comprehensive audio capture @@ -70,6 +77,7 @@ DevKit2 streams audio via Bluetooth using OPUS codec. The processing pipeline in - LLM-based conversation analysis (OpenAI or local Ollama) - **Dual memory system**: Friend-Lite native or OpenMemory MCP integration - Enhanced memory extraction with individual fact storage +- **Semantic search** with relevance scoring and threshold filtering - Smart deduplication and memory updates (ADD/UPDATE/DELETE) - Action item detection @@ -112,19 +120,21 @@ Choose one based on your needs: --- -#### **Advanced Backend** (`backends/advanced-backend/`) **RECOMMENDED** +#### **Advanced Backend** (`backends/advanced/`) **RECOMMENDED** **Use case:** Production use, full feature set **Features:** - Audio processing pipeline with real-time WebSocket support - **Pluggable memory system**: Choose between Friend-Lite native or OpenMemory MCP - Enhanced memory extraction with individual fact storage (no generic fallbacks) +- **Semantic memory search** with relevance threshold filtering and total count display +- **Speaker-based memory filtering**: Optional control over processing based on participant presence - Smart memory updates with LLM-driven action proposals (ADD/UPDATE/DELETE) - Speaker recognition and enrollment - Action items extraction from conversations - Audio cropping (removes silence, keeps speech) - Conversation management with session timeouts -- Web UI for management and monitoring +- Modern React web UI with live recording and advanced search - Multiple ASR options (Deepgram API + offline ASR) - MongoDB for structured data storage - RESTful API for all operations @@ -204,9 +214,9 @@ Backends and ASR services use standardized audio streaming: ## For Production Use 1. Use **Advanced Backend** for full features -2. Set up the complete stack: MongoDB + Qdrant + Ollama -3. Access the Web UI for conversation management -4. Configure speaker enrollment for multi-user scenarios +2. Run the orchestrated setup: `uv run --with-requirements setup-requirements.txt python init.py` +3. Start all services: `python services.py start --all --build` +4. Access the Web UI at http://localhost:5173 for conversation management ## For OMI Users 1. Use **OMI-Webhook-Compatible Backend** for easy migration @@ -231,10 +241,9 @@ Backends and ASR services use standardized audio streaming: ### Single Machine (Recommended for beginners) 1. **Clone the repository** -2. **Use Advanced Backend**: `cd backends/advanced-backend` -3. **Configure .env**: Copy `.env.template` to `.env` and set required values -4. **Start services**: `docker compose up --build -d` -5. **Access WebUI**: `http://localhost:3000` (React) or `http://localhost:8501` (Streamlit) +2. **Run interactive setup**: `uv run --with-requirements setup-requirements.txt python init.py` +3. **Start all services**: `python services.py start --all --build` +4. **Access WebUI**: `http://localhost:5173` for the React web dashboard ### Distributed Setup (Advanced users with multiple machines) 1. **GPU Machine**: Deploy LLM services (Ollama, ASR, Speaker Recognition) @@ -251,7 +260,7 @@ Backends and ASR services use standardized audio streaming: 2. **Backend Machine**: Deploy lightweight services ```bash - cd backends/advanced-backend + cd backends/advanced # Configure distributed services in .env OLLAMA_BASE_URL=http://[gpu-machine-tailscale-ip]:11434 diff --git a/backends/advanced/.dockerignore b/backends/advanced/.dockerignore index 95c32edd0..0ea8a946e 100644 --- a/backends/advanced/.dockerignore +++ b/backends/advanced/.dockerignore @@ -11,4 +11,7 @@ !diarization_config.json.template !diarization_config.json !uv.lock -!webui \ No newline at end of file +!webui +!ssl +!nginx.conf +!nginx.conf.template \ No newline at end of file diff --git a/backends/advanced/Dockerfile b/backends/advanced/Dockerfile index e6319c102..947c8abec 100644 --- a/backends/advanced/Dockerfile +++ b/backends/advanced/Dockerfile @@ -33,8 +33,10 @@ RUN uv sync --extra deepgram # Copy all application code COPY . . -# Copy memory config (created by init.sh from template) -COPY memory_config.yaml ./ +# Copy configuration files if they exist, otherwise they will be created from templates at runtime +# The files are expected to exist, but we handle the case where they don't gracefully +COPY memory_config.yaml* ./ +COPY diarization_config.json* ./ # Run the application diff --git a/backends/advanced/Docs/UI.md b/backends/advanced/Docs/UI.md index 859290568..dff5a5c8d 100644 --- a/backends/advanced/Docs/UI.md +++ b/backends/advanced/Docs/UI.md @@ -1,14 +1,18 @@ -# Streamlit Web Dashboard Documentation +# React Web Dashboard Documentation ## Overview -The Friend-Lite web dashboard provides a comprehensive interface for managing conversations, memories, users, and system debugging. Built with Streamlit, it offers real-time access to audio processing pipelines and administrative functions. +The Friend-Lite web dashboard provides a comprehensive interface for managing conversations, memories, users, and system debugging. Built with modern React and TypeScript, it offers real-time access to audio processing pipelines, advanced memory search, and administrative functions. + +> **Note**: This documentation covers the modern React interface located in `./webui/`. The legacy Streamlit interface has been moved to `src/_webui_original/` for reference. ## Access & Authentication ### Dashboard URL -- **Local**: `http://localhost:8501` -- **Audio**: Configure `BACKEND_PUBLIC_URL` environment variable to point to some public URL of your server. Your BROWSER must be able to access this URL. +- **HTTP**: `http://localhost:5173` (development) or `http://localhost:3000` (production) +- **HTTPS**: `https://localhost/` (with HTTPS configuration via `init-https.sh`) +- **Live Recording**: Available at `/live-record` page for real-time audio streaming +- **Network Access**: Configure `BACKEND_PUBLIC_URL` for remote device access via Tailscale/LAN ### Authentication Methods 1. **Email/Password Login**: Standard authentication via backend API @@ -36,15 +40,29 @@ The Friend-Lite web dashboard provides a comprehensive interface for managing co - Advanced filtering and search capabilities ### 2. Memories Tab -**Purpose**: Browse and search extracted conversation memories - -**Features**: -- Semantic search across all memories +**Purpose**: Browse and search extracted conversation memories with advanced filtering + +**Core Search Features**: +- **Text Search**: Traditional keyword-based memory filtering +- **Semantic Search Button**: AI-powered contextual memory search with relevance scoring +- **Dual-layer Filtering**: Combine semantic results with text search for precise filtering +- **Relevance Threshold Slider**: 0-100% threshold to filter semantic results by confidence +- **Active Filter Indicators**: Visual feedback showing active semantic filters with clear buttons +- **Memory Count Display**: Shows "X of Y memories" with total count from native providers + +**Advanced Features**: +- **Live Filtering**: Real-time results as threshold slider moves +- **Reset Functionality**: Clear all filters and return to full memory list - Memory categorization and tagging - Temporal filtering and sorting - Memory source tracking (which conversation) - Export capabilities +**UI Improvements**: +- **Search Input Enhancement**: Semantic search button integrated into search field +- **Visual Feedback**: Loading states, relevance scores, and filter status indicators +- **Responsive Design**: Works across desktop and mobile devices + **Admin Features**: - **Admin Debug Section**: Load and view all user memories for debugging - **System-wide Memory View**: Access all memories across users diff --git a/backends/advanced/Docs/architecture.md b/backends/advanced/Docs/architecture.md index 9c8bdbed2..78435ef1e 100644 --- a/backends/advanced/Docs/architecture.md +++ b/backends/advanced/Docs/architecture.md @@ -4,7 +4,7 @@ ## System Overview -Friend-Lite is a comprehensive real-time conversation processing system that captures audio streams, performs speech-to-text transcription, and extracts memories. The system features a FastAPI backend with WebSocket audio streaming, a Streamlit web dashboard for management, and complete user authentication with role-based access control. +Friend-Lite is a comprehensive real-time conversation processing system that captures audio streams, performs speech-to-text transcription, and extracts memories. The system features a FastAPI backend with WebSocket audio streaming, a modern React web dashboard with advanced search capabilities, and complete user authentication with role-based access control. **Core Implementation**: The complete system is implemented in `src/advanced_omi_backend/main.py` with supporting services in dedicated modules, using a modular router/controller architecture pattern. @@ -189,11 +189,14 @@ graph TB > 📖 **Read more**: [Authentication Architecture](./auth.md) for complete authentication system details -#### Streamlit Dashboard (`webui/streamlit_app.py`) -- **User-Friendly Interface**: Complete web-based management interface -- **Authentication Integration**: Login with backend JWT tokens or Google OAuth +#### React Dashboard (`webui/`) +- **Modern Interface**: Complete React/TypeScript web-based management interface +- **Advanced Memory Search**: Semantic search with relevance threshold filtering and live results +- **Memory Count Display**: Total count tracking with dual-layer filtering capabilities +- **Authentication Integration**: Login with backend JWT tokens and user management - **Real-Time Monitoring**: Live client status and conversation management -- **Data Management**: User, conversation, and memory interfaces +- **Live Recording**: Real-time audio recording with WebSocket streaming (HTTPS) +- **Data Management**: User, conversation, and memory interfaces with enhanced UX - **Audio Playback**: Smart audio player with original/cropped audio options - **System Health**: Visual service status and configuration display @@ -452,7 +455,7 @@ The `close_conversation()` method in ClientState now delegates to ConversationMa 3. **Manual API Call** - `/api/conversations/{client_id}/close` endpoint - User-initiated closure - - UI button in Streamlit dashboard + - UI button in React dashboard 4. **Conversation Timeout** (Not shown in diagram) - 1.5 minute silence detection @@ -580,7 +583,7 @@ stateDiagram-v2 graph LR subgraph "Docker Network" Backend[friend-backend
uv + FastAPI] - Streamlit[streamlit
Dashboard UI] + WebUI[webui
React Dashboard] Proxy[nginx
Load Balancer] Mongo[mongo:4.4.18
Primary Database] Qdrant[qdrant
Vector Store] @@ -596,12 +599,12 @@ graph LR AudioClient[Audio Client
Mobile/Desktop] end - WebBrowser -->|Port 8501| Streamlit - WebBrowser -->|Port 80| Proxy + WebBrowser -->|Port 5173 (dev)| WebUI + WebBrowser -->|Port 80 (prod)| Proxy AudioClient -->|Port 8000| Backend Proxy --> Backend - Proxy --> Streamlit + Proxy --> WebUI Backend --> Mongo Backend --> Qdrant Backend -.->|Optional| Ollama @@ -617,17 +620,19 @@ graph LR - **Health Checks**: Automated readiness and liveness probes - **Environment**: All configuration via environment variables -#### Streamlit Container (`streamlit`) -- **Purpose**: Web dashboard interface -- **Dependencies**: Streamlit, requests, pandas for data visualization -- **Backend Integration**: HTTP API client with authentication +#### React WebUI Container (`webui`) +- **Purpose**: Modern React-based web dashboard interface +- **Dependencies**: React, TypeScript, Vite for development, Nginx for production +- **Backend Integration**: HTTP API client with JWT authentication - **Configuration**: Backend URL configuration for API calls +- **Development**: Vite dev server on port 5173 +- **Production**: Nginx serving built React app on port 80 #### Infrastructure Containers - **MongoDB 4.4.18**: Primary data storage with persistence - **Qdrant Latest**: Vector database for memory embeddings - **Neo4j 5.15**: Graph database for memory relationships and entity connections -- **Nginx Alpine**: Reverse proxy and load balancing +- **Nginx Alpine**: Reverse proxy and load balancing (serves React app in production, proxies API calls to backend) ## Detailed Data Flow Architecture @@ -640,7 +645,7 @@ graph LR flowchart TB %% External Clients Client[📱 Audio Client
Mobile/Desktop/HAVPE] - WebUI[🌐 Web Dashboard
Streamlit Interface] + WebUI[🌐 Web Dashboard
React Web UI] %% Authentication Gateway subgraph "🔐 Authentication Layer" diff --git a/backends/advanced/Docs/memories.md b/backends/advanced/Docs/memories.md index 60c856696..06aa3f600 100644 --- a/backends/advanced/Docs/memories.md +++ b/backends/advanced/Docs/memories.md @@ -566,13 +566,27 @@ def consolidate_memories(self, user_id: str, time_window_hours: int = 24): ## API Endpoints -The memory service exposes these endpoints: +The memory service exposes these endpoints with enhanced search capabilities: -- `GET /api/memories` - Get user memories (keyed by database user_id) -- `GET /api/memories/search?query={query}` - Search memories (user-scoped) +- `GET /api/memories` - Get user memories with total count support (keyed by database user_id) +- `GET /api/memories/search?query={query}&limit={limit}` - **Semantic memory search** with relevance scoring (user-scoped) +- `GET /api/memories/unfiltered` - User's memories without filtering for debugging - `DELETE /api/memories/{memory_id}` - Delete specific memory (requires authentication) - `GET /api/memories/admin` - Admin view of all memories across all users (superuser only) +### Enhanced Search Features + +**Semantic Search (`/api/memories/search`)**: +- **Relevance Scoring**: Returns similarity scores from vector database (0.0-1.0 range) +- **Configurable Limits**: Supports `limit` parameter for result count control +- **User Scoped**: Results automatically filtered by authenticated user +- **Vector-based**: Uses embeddings for contextual understanding beyond keyword matching + +**Memory Count API**: +- **Friend-Lite Provider**: Native Qdrant count API provides accurate total counts +- **OpenMemory MCP Provider**: Count support varies by OpenMemory implementation +- **Response Format**: `{"memories": [...], "total_count": 42}` when supported + ### Admin Endpoints #### All Memories Endpoint (`/api/memories/admin`) diff --git a/backends/advanced/Docs/quickstart.md b/backends/advanced/Docs/quickstart.md index 9684eec78..307c8fdce 100644 --- a/backends/advanced/Docs/quickstart.md +++ b/backends/advanced/Docs/quickstart.md @@ -10,8 +10,9 @@ At the moment, the basic functionalities are: - Audio transcription - **Advanced memory system** with pluggable providers (Friend-Lite native or OpenMemory MCP) - **Enhanced memory extraction** with individual fact storage and smart updates +- **Semantic memory search** with relevance threshold filtering and live results - Action item extraction -- Modern React web dashboard with live recording +- Modern React web dashboard with live recording and advanced search features - Comprehensive user management with JWT authentication **Core Implementation**: See `src/advanced_omi_backend/main.py` for the complete FastAPI application and WebSocket handling. @@ -19,27 +20,99 @@ At the moment, the basic functionalities are: ## Prerequisites - Docker and Docker Compose -- (Optional) Deepgram API key for high-quality cloud transcription -- (Optional) Ollama for local LLM processing (memory extraction) -- (Optional) Wyoming ASR for offline speech-to-text processing +- API keys for your chosen providers (see setup script) ## Quick Start -### 0. Quick Testing (Optional) -To verify your setup, run the integration test suite: +### Step 1: Interactive Setup (Recommended) + +Run the interactive setup wizard to configure all services with guided prompts: ```bash -# Ensure .env file has DEEPGRAM_API_KEY and OPENAI_API_KEY configured -./run-test.sh +cd backends/advanced +./init.sh +``` -# Alternative: Manual test with detailed logging -source .env && export DEEPGRAM_API_KEY OPENAI_API_KEY && uv run pytest tests/test_integration.py -vv -s --log-cli-level=INFO --log-cli-format='%(asctime)s - %(levelname)s - %(message)s' +**The setup wizard will guide you through:** +- **Authentication**: Admin email/password setup +- **Transcription Provider**: Choose Deepgram, Mistral, or Offline (Parakeet) +- **LLM Provider**: Choose OpenAI or Ollama for memory extraction +- **Memory Provider**: Choose Friend-Lite Native or OpenMemory MCP +- **Optional Services**: Speaker Recognition and other extras +- **Network Configuration**: Ports and host settings + +**Example flow:** ``` -This end-to-end test demonstrates the complete audio processing pipeline. +🚀 Friend-Lite Interactive Setup +=============================================== + +► Authentication Setup +---------------------- +Admin email [admin@example.com]: john@company.com +Admin password (min 8 chars): ******** + +► Speech-to-Text Configuration +------------------------------- +Choose your transcription provider: + 1) Deepgram (recommended - high quality, requires API key) + 2) Mistral (Voxtral models - requires API key) + 3) Offline (Parakeet ASR - requires GPU, runs locally) + 4) None (skip transcription setup) +Enter choice (1-4) [1]: 1 + +Get your API key from: https://console.deepgram.com/ +Deepgram API key: dg_xxxxxxxxxxxxx + +► LLM Provider Configuration +---------------------------- +Choose your LLM provider for memory extraction: + 1) OpenAI (GPT-4, GPT-3.5 - requires API key) + 2) Ollama (local models - requires Ollama server) + 3) Skip (no memory extraction) +Enter choice (1-3) [1]: 1 +``` + +### Step 2: HTTPS Setup (Optional) +For microphone access and secure connections, set up HTTPS: +```bash +cd backends/advanced +./setup-https.sh 100.83.66.30 # Your Tailscale/network IP +``` -### 1. Environment Setup +This creates SSL certificates and configures nginx for secure access. + +### Step 3: Start the System + +**Start all services:** +```bash +cd backends/advanced +docker compose up --build -d +``` + +This starts: +- **Backend API**: `http://localhost:8000` +- **Web Dashboard**: `http://localhost:5173` +- **MongoDB**: `localhost:27017` +- **Qdrant**: `localhost:6333` -Copy the `.env.template` file to `.env` and configure the required values: +### Step 4: Optional Services + +**If you configured optional services during setup, start them:** + +```bash +# OpenMemory MCP (if selected) +cd ../../extras/openmemory-mcp && docker compose up -d + +# Parakeet ASR (if selected for offline transcription) +cd ../../extras/asr-services && docker compose up parakeet -d + +# Speaker Recognition (if enabled) +cd ../../extras/speaker-recognition && docker compose up --build -d +``` + +### Manual Configuration (Alternative) + +If you prefer manual configuration, copy the `.env.template` file to `.env` and configure the required values: **Required Environment Variables:** ```bash @@ -96,26 +169,17 @@ PARAKEET_ASR_URL=http://host.docker.internal:8080 - If not set, system falls back to offline ASR service - The system requires either online API keys or offline ASR service configuration -### 2. Start the System +### Testing Your Setup (Optional) -**Recommended: Docker Compose** +After configuration, verify everything works with the integration test suite: ```bash -cd backends/advanced -docker compose up --build -d -``` - -This starts: -- **Backend API**: `http://localhost:8000` -- **Web Dashboard**: `http://localhost:3000` -- **MongoDB**: `localhost:27017` -- **Qdrant**: `localhost:6333` -- (optional) **Ollama**: # commented out - -**Implementation**: See `docker-compose.yml` for complete service configuration and `src/advanced_omi_backend/main.py` for FastAPI application setup. - -### 3. Optional: Start ASR Service +./run-test.sh -For self-hosted speech recognition, see instructions in `extras/asr-services/`: +# Alternative: Manual test with detailed logging +source .env && export DEEPGRAM_API_KEY OPENAI_API_KEY && \ + uv run pytest tests/test_integration.py -vv -s --log-cli-level=INFO +``` +This end-to-end test validates the complete audio processing pipeline. ## Using the System @@ -129,9 +193,11 @@ For self-hosted speech recognition, see instructions in `extras/asr-services/`: ### Dashboard Features - **Conversations**: View audio recordings, transcripts, and cropped audio -- **Memories**: Search extracted conversation memories +- **Memories**: Advanced memory search with semantic search, relevance threshold filtering, and memory count display +- **Live Recording**: Real-time audio recording with WebSocket streaming (HTTPS required) - **User Management**: Create/delete users and their data - **Client Management**: View active connections and close conversations +- **System Monitoring**: Debug tools and system health monitoring ### Audio Client Connection diff --git a/backends/advanced/README.md b/backends/advanced/README.md index ec7aedeb3..eefbf54bb 100644 --- a/backends/advanced/README.md +++ b/backends/advanced/README.md @@ -16,10 +16,34 @@ A FastAPI backend with pluggable memory providers, real-time audio processing, a Modern React-based web dashboard located in `./webui/` with: - Live audio recording and real-time streaming - Chat interface with conversation management -- Memory search and management +- **Advanced memory search** with semantic search and relevance threshold filtering +- **Memory count display** showing total memories with live filtering +- **Dual-layer filtering** combining semantic and text search - System monitoring and debugging tools -### Quick Start (HTTP) +### Quick Start + +#### 1. Interactive Setup (Recommended) +```bash +# Run interactive setup wizard +./init.sh +``` + +**The setup wizard guides you through:** +- **Authentication**: Admin email/password setup with secure keys +- **Transcription Provider**: Choose between Deepgram, Mistral, or Offline (Parakeet) +- **LLM Provider**: Choose between OpenAI (recommended) or Ollama for memory extraction +- **Memory Provider**: Choose between Friend-Lite Native or OpenMemory MCP +- **Optional Services**: Speaker Recognition, network configuration +- **API Keys**: Prompts for all required keys with helpful links + +**HTTPS Setup (Optional):** +```bash +# For microphone access and secure connections +./setup-https.sh your-tailscale-ip +``` + +#### 2. Start Services (HTTP) ```bash # Start with hot reload development server docker compose up --build -d @@ -27,13 +51,13 @@ docker compose up --build -d - **Web Dashboard**: http://localhost:5173 -### HTTPS Setup (Required for Microphone Access) +#### 3. HTTPS Setup (Optional - For Microphone Access) -For network access and microphone features, set up HTTPS: +For network access and microphone features, HTTPS can be configured during initialization or separately: ```bash -# Initialize HTTPS with your Tailscale/network IP -./init.sh 100.83.66.30 # Replace with your IP +# If not done during init.sh, run HTTPS setup +./init-https.sh 100.83.66.30 # Replace with your IP # Start with HTTPS proxy docker compose --profile https up --build -d diff --git a/backends/advanced/SETUP_SCRIPTS.md b/backends/advanced/SETUP_SCRIPTS.md new file mode 100644 index 000000000..0e1817282 --- /dev/null +++ b/backends/advanced/SETUP_SCRIPTS.md @@ -0,0 +1,160 @@ +# Setup Scripts Guide + +This document explains the different setup scripts available in Friend-Lite and when to use each one. + +## Script Overview + +| Script | Purpose | When to Use | +|--------|---------|-------------| +| `init.py` | **Main interactive setup wizard** | **Recommended for all users** - First time setup with guided configuration (located at repo root) | +| `setup-https.sh` | HTTPS certificate generation | **Optional** - When you need secure connections for microphone access | + +## Main Setup Script: `init.py` + +**Purpose**: Interactive wizard that configures all services with guided prompts. + +### What it does: +- ✅ **Authentication Setup**: Admin email/password with secure key generation +- ✅ **Transcription Provider Selection**: Choose between Deepgram, Mistral, or Offline (Parakeet) +- ✅ **LLM Provider Configuration**: Choose between OpenAI (recommended) or Ollama +- ✅ **Memory Provider Setup**: Choose between Friend-Lite Native or OpenMemory MCP +- ✅ **API Key Collection**: Prompts for required keys with helpful links to obtain them +- ✅ **Optional Services**: Speaker Recognition, network configuration +- ✅ **Configuration Validation**: Creates complete .env with all settings + +### Usage: +```bash +# From repository root +python backends/advanced/init.py +``` + +### Example Flow: +``` +🚀 Friend-Lite Interactive Setup +=============================================== + +► Authentication Setup +---------------------- +Admin email [admin@example.com]: john@company.com +Admin password (min 8 chars): ******** +✅ Admin account configured + +► Speech-to-Text Configuration +------------------------------- +Choose your transcription provider: + 1) Deepgram (recommended - high quality, requires API key) + 2) Mistral (Voxtral models - requires API key) + 3) Offline (Parakeet ASR - requires GPU, runs locally) + 4) None (skip transcription setup) +Enter choice (1-4) [1]: 1 + +Get your API key from: https://console.deepgram.com/ +Deepgram API key: dg_xxxxxxxxxxxxx +✅ Deepgram configured + +► LLM Provider Configuration +---------------------------- +Choose your LLM provider for memory extraction: + 1) OpenAI (GPT-4, GPT-3.5 - requires API key) + 2) Ollama (local models - requires Ollama server) + 3) Skip (no memory extraction) +Enter choice (1-3) [1]: 1 + +Get your API key from: https://platform.openai.com/api-keys +OpenAI API key: sk-xxxxxxxxxxxxx +OpenAI model [gpt-4o-mini]: gpt-4o-mini +✅ OpenAI configured + +...continues through all configuration sections... + +► Configuration Summary +----------------------- +✅ Admin Account: john@company.com +✅ Transcription: deepgram +✅ LLM Provider: openai +✅ Memory Provider: friend_lite +✅ Backend URL: http://localhost:8000 +✅ Dashboard URL: http://localhost:5173 + +► Next Steps +------------ +1. Start the main services: + docker compose up --build -d + +2. Access the dashboard: + http://localhost:5173 + +Setup complete! 🎉 +``` + +## HTTPS Setup Script: `setup-https.sh` + +**Purpose**: Generate SSL certificates and configure nginx for secure HTTPS access. + +### When needed: +- **Microphone access** from browsers (HTTPS required) +- **Remote access** via Tailscale or network +- **Production deployments** requiring secure connections + +### Usage: +```bash +cd backends/advanced +./setup-https.sh 100.83.66.30 # Your Tailscale or network IP +``` + +### What it does: +- Generates self-signed SSL certificates for your IP +- Configures nginx proxy for HTTPS access +- Sets up docker-compose HTTPS profile +- Provides HTTPS URLs for dashboard access + +### After HTTPS setup: +```bash +# Start with HTTPS profile +docker compose --profile https up --build -d + +# Access via HTTPS +https://localhost/ +https://100.83.66.30/ # Your configured IP +``` + + +## Recommended Setup Flow + +### New Users (Recommended): +1. **Run main setup**: `python backends/advanced/init.py` +2. **Start services**: `docker compose up --build -d` +3. **Optional HTTPS**: `./setup-https.sh your-ip` (if needed) + +### Manual Configuration (Advanced): +1. **Copy template**: `cp .env.template .env` +2. **Edit manually**: Configure all providers and keys +3. **Start services**: `docker compose up --build -d` + +## Script Locations + +Setup scripts are located as follows: +``` +. # Project root +├── init.py # Main interactive setup wizard (repo root) +└── backends/advanced/ + ├── setup-https.sh # HTTPS certificate generation + ├── .env.template # Environment template + └── docker-compose.yml +``` + +## Getting Help + +- **Setup Issues**: See `Docs/quickstart.md` for detailed documentation +- **Configuration**: See `MEMORY_PROVIDERS.md` for provider comparisons +- **Troubleshooting**: Check `CLAUDE.md` for common issues +- **HTTPS Problems**: Ensure your IP is accessible and not behind firewall + +## Key Benefits of New Setup + +✅ **No more guessing**: Interactive prompts guide you through every choice +✅ **API key validation**: Links provided to obtain required keys +✅ **Provider selection**: Choose best services for your needs +✅ **Complete configuration**: Creates working .env with all settings +✅ **Next steps guidance**: Clear instructions for starting services +✅ **No manual editing**: Reduces errors from manual .env editing \ No newline at end of file diff --git a/backends/advanced/docker-compose.yml b/backends/advanced/docker-compose.yml index ffd887035..dfe1553d4 100644 --- a/backends/advanced/docker-compose.yml +++ b/backends/advanced/docker-compose.yml @@ -42,8 +42,9 @@ services: - OPENMEMORY_USER_ID=${OPENMEMORY_USER_ID:-openmemory} - OPENMEMORY_TIMEOUT=${OPENMEMORY_TIMEOUT:-30} depends_on: - qdrant: - condition: service_started + # You may not want qdrant if you are using openmemory_mcp + # qdrant: + # condition: service_started mongo: condition: service_started # neo4j-mem0: @@ -112,8 +113,6 @@ services: condition: service_healthy webui: condition: service_started - profiles: - - https restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "-k", "https://localhost/health"] diff --git a/backends/advanced/init.sh b/backends/advanced/init-https.sh similarity index 86% rename from backends/advanced/init.sh rename to backends/advanced/init-https.sh index f3a4498c3..b8a53b052 100755 --- a/backends/advanced/init.sh +++ b/backends/advanced/init-https.sh @@ -70,6 +70,18 @@ else echo " 2. Add: CORS_ORIGINS=https://localhost,https://localhost:443,https://127.0.0.1,https://$TAILSCALE_IP" fi +# Create memory_config.yaml from template if it doesn't exist +echo "" +echo "📄 Step 4: Checking memory configuration..." +if [ ! -f "memory_config.yaml" ] && [ -f "memory_config.yaml.template" ]; then + cp memory_config.yaml.template memory_config.yaml + echo "✅ memory_config.yaml created from template" +elif [ -f "memory_config.yaml" ]; then + echo "✅ memory_config.yaml already exists" +else + echo "⚠️ Warning: memory_config.yaml.template not found" +fi + echo "" echo "🎉 Initialization complete!" echo "" diff --git a/backends/advanced/init.py b/backends/advanced/init.py new file mode 100644 index 000000000..357764301 --- /dev/null +++ b/backends/advanced/init.py @@ -0,0 +1,532 @@ +#!/usr/bin/env python3 +""" +Friend-Lite Advanced Backend Interactive Setup Script +Interactive configuration for all services and API keys +""" + +import argparse +import getpass +import os +import secrets +import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +from rich import print as rprint +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Confirm, Prompt +from rich.text import Text + + +class FriendLiteSetup: + def __init__(self, args=None): + self.console = Console() + self.config: Dict[str, Any] = {} + self.args = args or argparse.Namespace() + + # Check if we're in the right directory + if not Path("pyproject.toml").exists() or not Path("src").exists(): + self.console.print("[red][ERROR][/red] Please run this script from the backends/advanced directory") + sys.exit(1) + + def print_header(self, title: str): + """Print a colorful header""" + self.console.print() + panel = Panel( + Text(title, style="cyan bold"), + style="cyan", + expand=False + ) + self.console.print(panel) + self.console.print() + + def print_section(self, title: str): + """Print a section header""" + self.console.print() + self.console.print(f"[magenta]► {title}[/magenta]") + self.console.print("[magenta]" + "─" * len(f"► {title}") + "[/magenta]") + + def prompt_value(self, prompt: str, default: str = "") -> str: + """Prompt for a value with optional default""" + try: + # Always provide a default to avoid EOF issues + return Prompt.ask(prompt, default=default) + except EOFError: + self.console.print(f"Using default: {default}") + return default + + def prompt_password(self, prompt: str) -> str: + """Prompt for password (hidden input)""" + while True: + try: + password = getpass.getpass(f"{prompt}: ") + if len(password) >= 8: + return password + self.console.print("[yellow][WARNING][/yellow] Password must be at least 8 characters") + except (EOFError, KeyboardInterrupt): + # For non-interactive environments, generate a secure password + self.console.print("[yellow][WARNING][/yellow] Non-interactive environment detected") + password = f"admin-{secrets.token_hex(8)}" + self.console.print(f"Generated secure password: {password}") + return password + + def prompt_choice(self, prompt: str, choices: Dict[str, str], default: str = "1") -> str: + """Prompt for a choice from options""" + self.console.print(prompt) + for key, desc in choices.items(): + self.console.print(f" {key}) {desc}") + self.console.print() + + while True: + try: + choice = Prompt.ask("Enter choice", default=default) + if choice in choices: + return choice + self.console.print(f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]") + except EOFError: + self.console.print(f"Using default choice: {default}") + return default + + def backup_existing_env(self): + """Backup existing .env file""" + env_path = Path(".env") + if env_path.exists(): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = f".env.backup.{timestamp}" + shutil.copy2(env_path, backup_path) + self.console.print(f"[blue][INFO][/blue] Backed up existing .env file to {backup_path}") + + def setup_authentication(self): + """Configure authentication settings""" + self.print_section("Authentication Setup") + self.console.print("Configure admin account for the dashboard") + self.console.print() + + self.config["ADMIN_EMAIL"] = self.prompt_value("Admin email", "admin@example.com") + self.config["ADMIN_PASSWORD"] = self.prompt_password("Admin password (min 8 chars)") + self.config["AUTH_SECRET_KEY"] = secrets.token_hex(32) + + self.console.print("[green][SUCCESS][/green] Admin account configured") + + def setup_transcription(self): + """Configure transcription provider""" + self.print_section("Speech-to-Text Configuration") + + choices = { + "1": "Deepgram (recommended - high quality, requires API key)", + "2": "Mistral (Voxtral models - requires API key)", + "3": "Offline (Parakeet ASR - requires GPU, runs locally)", + "4": "None (skip transcription setup)" + } + + choice = self.prompt_choice("Choose your transcription provider:", choices, "1") + + if choice == "1": + self.console.print("[blue][INFO][/blue] Deepgram selected") + self.console.print("Get your API key from: https://console.deepgram.com/") + + api_key = self.prompt_value("Deepgram API key (leave empty to skip)", "") + if api_key: + self.config["TRANSCRIPTION_PROVIDER"] = "deepgram" + self.config["DEEPGRAM_API_KEY"] = api_key + self.console.print("[green][SUCCESS][/green] Deepgram configured") + else: + self.console.print("[yellow][WARNING][/yellow] No API key provided - transcription will not work") + + elif choice == "2": + self.config["TRANSCRIPTION_PROVIDER"] = "mistral" + self.console.print("[blue][INFO][/blue] Mistral selected") + self.console.print("Get your API key from: https://console.mistral.ai/") + + api_key = self.prompt_value("Mistral API key (leave empty to skip)", "") + model = self.prompt_value("Mistral model", "voxtral-mini-2507") + + if api_key: + self.config["MISTRAL_API_KEY"] = api_key + self.config["MISTRAL_MODEL"] = model + self.console.print("[green][SUCCESS][/green] Mistral configured") + else: + self.console.print("[yellow][WARNING][/yellow] No API key provided - transcription will not work") + + elif choice == "3": + self.config["TRANSCRIPTION_PROVIDER"] = "offline" + self.console.print("[blue][INFO][/blue] Offline Parakeet ASR selected") + parakeet_url = self.prompt_value("Parakeet ASR URL", "http://host.docker.internal:8767") + self.config["PARAKEET_ASR_URL"] = parakeet_url + self.console.print("[yellow][WARNING][/yellow] Remember to start Parakeet service: cd ../../extras/asr-services && docker compose up parakeet") + + elif choice == "4": + self.console.print("[blue][INFO][/blue] Skipping transcription setup") + + def setup_llm(self): + """Configure LLM provider""" + self.print_section("LLM Provider Configuration") + + choices = { + "1": "OpenAI (GPT-4, GPT-3.5 - requires API key)", + "2": "Ollama (local models - requires Ollama server)", + "3": "Skip (no memory extraction)" + } + + choice = self.prompt_choice("Choose your LLM provider for memory extraction:", choices, "1") + + if choice == "1": + self.config["LLM_PROVIDER"] = "openai" + self.console.print("[blue][INFO][/blue] OpenAI selected") + self.console.print("Get your API key from: https://platform.openai.com/api-keys") + + api_key = self.prompt_value("OpenAI API key (leave empty to skip)", "") + model = self.prompt_value("OpenAI model", "gpt-4o-mini") + base_url = self.prompt_value("OpenAI base URL (for proxies/compatible APIs)", "https://api.openai.com/v1") + + if api_key: + self.config["OPENAI_API_KEY"] = api_key + self.config["OPENAI_MODEL"] = model + self.config["OPENAI_BASE_URL"] = base_url + self.console.print("[green][SUCCESS][/green] OpenAI configured") + else: + self.console.print("[yellow][WARNING][/yellow] No API key provided - memory extraction will not work") + + elif choice == "2": + self.config["LLM_PROVIDER"] = "ollama" + self.console.print("[blue][INFO][/blue] Ollama selected") + + base_url = self.prompt_value("Ollama server URL", "http://host.docker.internal:11434") + model = self.prompt_value("Ollama model", "llama3.2") + + self.config["OLLAMA_BASE_URL"] = base_url + self.config["OLLAMA_MODEL"] = model + self.console.print("[green][SUCCESS][/green] Ollama configured") + self.console.print("[yellow][WARNING][/yellow] Make sure Ollama is running and the model is pulled") + + elif choice == "3": + self.console.print("[blue][INFO][/blue] Skipping LLM setup - memory extraction disabled") + + def setup_memory(self): + """Configure memory provider""" + self.print_section("Memory Storage Configuration") + + choices = { + "1": "Friend-Lite Native (Qdrant + custom extraction)", + "2": "OpenMemory MCP (cross-client compatible, external server)" + } + + choice = self.prompt_choice("Choose your memory storage backend:", choices, "1") + + if choice == "1": + self.config["MEMORY_PROVIDER"] = "friend_lite" + self.console.print("[blue][INFO][/blue] Friend-Lite Native memory provider selected") + + qdrant_url = self.prompt_value("Qdrant URL", "qdrant") + self.config["QDRANT_BASE_URL"] = qdrant_url + self.console.print("[green][SUCCESS][/green] Friend-Lite memory provider configured") + + elif choice == "2": + self.config["MEMORY_PROVIDER"] = "openmemory_mcp" + self.console.print("[blue][INFO][/blue] OpenMemory MCP selected") + + mcp_url = self.prompt_value("OpenMemory MCP server URL", "http://host.docker.internal:8765") + client_name = self.prompt_value("OpenMemory client name", "friend_lite") + user_id = self.prompt_value("OpenMemory user ID", "openmemory") + + self.config["OPENMEMORY_MCP_URL"] = mcp_url + self.config["OPENMEMORY_CLIENT_NAME"] = client_name + self.config["OPENMEMORY_USER_ID"] = user_id + self.console.print("[green][SUCCESS][/green] OpenMemory MCP configured") + self.console.print("[yellow][WARNING][/yellow] Remember to start OpenMemory: cd ../../extras/openmemory-mcp && docker compose up -d") + + def setup_optional_services(self): + """Configure optional services""" + self.print_section("Optional Services") + + # Check if speaker service URL provided via args + if hasattr(self.args, 'speaker_service_url') and self.args.speaker_service_url: + self.config["SPEAKER_SERVICE_URL"] = self.args.speaker_service_url + self.console.print(f"[green][SUCCESS][/green] Speaker Recognition configured via args: {self.args.speaker_service_url}") + else: + try: + enable_speaker = Confirm.ask("Enable Speaker Recognition?", default=False) + except EOFError: + self.console.print("Using default: No") + enable_speaker = False + + if enable_speaker: + speaker_url = self.prompt_value("Speaker Recognition service URL", "http://host.docker.internal:8001") + self.config["SPEAKER_SERVICE_URL"] = speaker_url + self.console.print("[green][SUCCESS][/green] Speaker Recognition configured") + self.console.print("[blue][INFO][/blue] Start with: cd ../../extras/speaker-recognition && docker compose up -d") + + # Check if ASR service URL provided via args + if hasattr(self.args, 'parakeet_asr_url') and self.args.parakeet_asr_url: + self.config["PARAKEET_ASR_URL"] = self.args.parakeet_asr_url + self.console.print(f"[green][SUCCESS][/green] Parakeet ASR configured via args: {self.args.parakeet_asr_url}") + + def setup_network(self): + """Configure network settings""" + self.print_section("Network Configuration") + + self.config["BACKEND_PUBLIC_PORT"] = self.prompt_value("Backend port", "8000") + self.config["WEBUI_PORT"] = self.prompt_value("Web UI port", "5173") + + def setup_https(self): + """Configure HTTPS settings for microphone access""" + # Check if HTTPS configuration provided via command line + if hasattr(self.args, 'enable_https') and self.args.enable_https: + enable_https = True + server_ip = getattr(self.args, 'server_ip', 'localhost') + self.console.print(f"[green][SUCCESS][/green] HTTPS configured via command line: {server_ip}") + else: + # Interactive configuration + self.print_section("HTTPS Configuration (Optional)") + + try: + enable_https = Confirm.ask("Enable HTTPS for microphone access?", default=False) + except EOFError: + self.console.print("Using default: No") + enable_https = False + + if enable_https: + self.console.print("[blue][INFO][/blue] HTTPS enables microphone access in browsers") + self.console.print("[blue][INFO][/blue] For distributed deployments, use your Tailscale IP (e.g., 100.64.1.2)") + self.console.print("[blue][INFO][/blue] For local-only access, use 'localhost'") + server_ip = self.prompt_value("Server IP/Domain for SSL certificate (Tailscale IP or localhost)", "localhost") + + if enable_https: + + # Generate SSL certificates + self.console.print("[blue][INFO][/blue] Generating SSL certificates...") + # Use path relative to this script's directory + script_dir = Path(__file__).parent + ssl_script = script_dir / "ssl" / "generate-ssl.sh" + if ssl_script.exists(): + try: + # Run from the backend directory so paths work correctly + subprocess.run([str(ssl_script), server_ip], check=True, cwd=str(script_dir), timeout=180) + self.console.print("[green][SUCCESS][/green] SSL certificates generated") + except subprocess.TimeoutExpired: + self.console.print("[yellow][WARNING][/yellow] SSL certificate generation timed out after 3 minutes") + except subprocess.CalledProcessError: + self.console.print("[yellow][WARNING][/yellow] SSL certificate generation failed") + else: + self.console.print(f"[yellow][WARNING][/yellow] SSL script not found at {ssl_script}") + + # Generate nginx.conf from template + self.console.print("[blue][INFO][/blue] Creating nginx configuration...") + nginx_template = script_dir / "nginx.conf.template" + if nginx_template.exists(): + try: + with open(nginx_template, 'r') as f: + nginx_content = f.read() + + # Replace TAILSCALE_IP with server_ip + nginx_content = nginx_content.replace('TAILSCALE_IP', server_ip) + + with open('nginx.conf', 'w') as f: + f.write(nginx_content) + + self.console.print(f"[green][SUCCESS][/green] nginx.conf created for: {server_ip}") + self.config["HTTPS_ENABLED"] = "true" + self.config["SERVER_IP"] = server_ip + + except Exception as e: + self.console.print(f"[yellow][WARNING][/yellow] nginx.conf generation failed: {e}") + else: + self.console.print("[yellow][WARNING][/yellow] nginx.conf.template not found") + else: + self.config["HTTPS_ENABLED"] = "false" + + def generate_env_file(self): + """Generate the .env file from configuration""" + env_content = f"""# ============================================================================= +# Friend-Lite Advanced Backend Configuration +# Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +# ============================================================================= + +# Authentication +AUTH_SECRET_KEY={self.config.get('AUTH_SECRET_KEY', '')} +ADMIN_EMAIL={self.config.get('ADMIN_EMAIL', '')} +ADMIN_PASSWORD={self.config.get('ADMIN_PASSWORD', '')} + +# Transcription Provider +TRANSCRIPTION_PROVIDER={self.config.get('TRANSCRIPTION_PROVIDER', '')} +DEEPGRAM_API_KEY={self.config.get('DEEPGRAM_API_KEY', '')} +MISTRAL_API_KEY={self.config.get('MISTRAL_API_KEY', '')} +MISTRAL_MODEL={self.config.get('MISTRAL_MODEL', '')} + +# LLM Provider +LLM_PROVIDER={self.config.get('LLM_PROVIDER', '')} +OPENAI_API_KEY={self.config.get('OPENAI_API_KEY', '')} +OPENAI_MODEL={self.config.get('OPENAI_MODEL', '')} +OPENAI_BASE_URL={self.config.get('OPENAI_BASE_URL', '')} +OLLAMA_BASE_URL={self.config.get('OLLAMA_BASE_URL', '')} +OLLAMA_MODEL={self.config.get('OLLAMA_MODEL', '')} +# Memory Provider +MEMORY_PROVIDER={self.config.get('MEMORY_PROVIDER', 'friend_lite')} +QDRANT_BASE_URL={self.config.get('QDRANT_BASE_URL', 'qdrant')} +OPENMEMORY_MCP_URL={self.config.get('OPENMEMORY_MCP_URL', '')} +OPENMEMORY_CLIENT_NAME={self.config.get('OPENMEMORY_CLIENT_NAME', '')} +OPENMEMORY_USER_ID={self.config.get('OPENMEMORY_USER_ID', '')} + +# Optional Services +SPEAKER_SERVICE_URL={self.config.get('SPEAKER_SERVICE_URL', '')} +PARAKEET_ASR_URL={self.config.get('PARAKEET_ASR_URL', '')} + +# Network Configuration +BACKEND_PUBLIC_PORT={self.config.get('BACKEND_PUBLIC_PORT', '8000')} +WEBUI_PORT={self.config.get('WEBUI_PORT', '5173')} + +# Database +MONGODB_URI=mongodb://mongo:27017 +DATABASE_NAME=friend_db + +# CORS (supports Tailscale IPs automatically) +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Logging +LOG_LEVEL=INFO +""" + + # Create .env file with secure permissions (owner read/write only) + env_path = ".env" + fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode=0o600) + with os.fdopen(fd, 'w') as f: + f.write(env_content) + + self.console.print("[green][SUCCESS][/green] .env file created successfully with secure permissions") + + def copy_config_templates(self): + """Copy other configuration files""" + if not Path("memory_config.yaml").exists() and Path("memory_config.yaml.template").exists(): + shutil.copy2("memory_config.yaml.template", "memory_config.yaml") + self.console.print("[green][SUCCESS][/green] memory_config.yaml created") + + if not Path("diarization_config.json").exists() and Path("diarization_config.json.template").exists(): + shutil.copy2("diarization_config.json.template", "diarization_config.json") + self.console.print("[green][SUCCESS][/green] diarization_config.json created") + + def show_summary(self): + """Show configuration summary""" + self.print_section("Configuration Summary") + self.console.print() + + self.console.print(f"✅ Admin Account: {self.config.get('ADMIN_EMAIL', 'Not configured')}") + self.console.print(f"✅ Transcription: {self.config.get('TRANSCRIPTION_PROVIDER', 'Not configured')}") + self.console.print(f"✅ LLM Provider: {self.config.get('LLM_PROVIDER', 'Not configured')}") + self.console.print(f"✅ Memory Provider: {self.config.get('MEMORY_PROVIDER', 'friend_lite')}") + # Auto-determine URLs based on HTTPS configuration + if self.config.get('HTTPS_ENABLED') == 'true': + server_ip = self.config.get('SERVER_IP', 'localhost') + self.console.print(f"✅ Backend URL: https://{server_ip}/") + self.console.print(f"✅ Dashboard URL: https://{server_ip}/") + else: + backend_port = self.config.get('BACKEND_PUBLIC_PORT', '8000') + webui_port = self.config.get('WEBUI_PORT', '5173') + self.console.print(f"✅ Backend URL: http://localhost:{backend_port}") + self.console.print(f"✅ Dashboard URL: http://localhost:{webui_port}") + + def show_next_steps(self): + """Show next steps""" + self.print_section("Next Steps") + self.console.print() + + self.console.print("1. Start the main services:") + self.console.print(" [cyan]docker compose up --build -d[/cyan]") + self.console.print() + + # Auto-determine URLs for next steps + if self.config.get('HTTPS_ENABLED') == 'true': + server_ip = self.config.get('SERVER_IP', 'localhost') + self.console.print("2. Access the dashboard:") + self.console.print(f" [cyan]https://{server_ip}/[/cyan]") + self.console.print() + self.console.print("3. Check service health:") + self.console.print(f" [cyan]curl -k https://{server_ip}/health[/cyan]") + else: + webui_port = self.config.get('WEBUI_PORT', '5173') + backend_port = self.config.get('BACKEND_PUBLIC_PORT', '8000') + self.console.print("2. Access the dashboard:") + self.console.print(f" [cyan]http://localhost:{webui_port}[/cyan]") + self.console.print() + self.console.print("3. Check service health:") + self.console.print(f" [cyan]curl http://localhost:{backend_port}/health[/cyan]") + + if self.config.get("MEMORY_PROVIDER") == "openmemory_mcp": + self.console.print() + self.console.print("4. Start OpenMemory MCP:") + self.console.print(" [cyan]cd ../../extras/openmemory-mcp && docker compose up -d[/cyan]") + + if self.config.get("TRANSCRIPTION_PROVIDER") == "offline": + self.console.print() + self.console.print("5. Start Parakeet ASR:") + self.console.print(" [cyan]cd ../../extras/asr-services && docker compose up parakeet -d[/cyan]") + + def run(self): + """Run the complete setup process""" + self.print_header("🚀 Friend-Lite Interactive Setup") + self.console.print("This wizard will help you configure Friend-Lite with all necessary services.") + self.console.print("We'll ask for your API keys and preferences step by step.") + self.console.print() + + try: + # Backup existing config + self.backup_existing_env() + + # Run setup steps + self.setup_authentication() + self.setup_transcription() + self.setup_llm() + self.setup_memory() + self.setup_optional_services() + self.setup_network() + self.setup_https() + + # Generate files + self.print_header("Configuration Complete!") + self.generate_env_file() + self.copy_config_templates() + + # Show results + self.show_summary() + self.show_next_steps() + + self.console.print() + self.console.print("[green][SUCCESS][/green] Setup complete! 🎉") + self.console.print() + self.console.print("For detailed documentation, see:") + self.console.print(" • Docs/quickstart.md") + self.console.print(" • MEMORY_PROVIDERS.md") + self.console.print(" • CLAUDE.md") + + except KeyboardInterrupt: + self.console.print() + self.console.print("[yellow]Setup cancelled by user[/yellow]") + sys.exit(0) + except Exception as e: + self.console.print(f"[red][ERROR][/red] Setup failed: {e}") + sys.exit(1) + + +def main(): + """Main entry point""" + parser = argparse.ArgumentParser(description="Friend-Lite Advanced Backend Setup") + parser.add_argument("--speaker-service-url", + help="Speaker Recognition service URL (default: prompt user)") + parser.add_argument("--parakeet-asr-url", + help="Parakeet ASR service URL (default: prompt user)") + parser.add_argument("--enable-https", action="store_true", + help="Enable HTTPS configuration (default: prompt user)") + parser.add_argument("--server-ip", + help="Server IP/domain for SSL certificate (default: prompt user)") + + args = parser.parse_args() + + setup = FriendLiteSetup(args) + setup.run() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backends/advanced/pyproject.toml b/backends/advanced/pyproject.toml index d366403da..369039a68 100644 --- a/backends/advanced/pyproject.toml +++ b/backends/advanced/pyproject.toml @@ -51,6 +51,11 @@ line-length = 100 [tool.uv.sources] mem0ai = { git = "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/AnkushMalaker/mem0.git", rev = "async-client-unbound-var-fix" } +[tool.pytest.ini_options] +markers = [ + "integration: marks tests as integration tests", +] + [dependency-groups] dev = [ "black>=25.1.0", diff --git a/backends/advanced/run-test.sh b/backends/advanced/run-test.sh index 96d2ea1ac..a53a81fe1 100755 --- a/backends/advanced/run-test.sh +++ b/backends/advanced/run-test.sh @@ -67,6 +67,20 @@ fi print_info "DEEPGRAM_API_KEY length: ${#DEEPGRAM_API_KEY}" print_info "OPENAI_API_KEY length: ${#OPENAI_API_KEY}" +# Ensure memory_config.yaml exists +if [ ! -f "memory_config.yaml" ] && [ -f "memory_config.yaml.template" ]; then + print_info "Creating memory_config.yaml from template..." + cp memory_config.yaml.template memory_config.yaml + print_success "memory_config.yaml created" +fi + +# Ensure diarization_config.json exists +if [ ! -f "diarization_config.json" ] && [ -f "diarization_config.json.template" ]; then + print_info "Creating diarization_config.json from template..." + cp diarization_config.json.template diarization_config.json + print_success "diarization_config.json created" +fi + # Install dependencies with uv print_info "Installing dependencies with uv..." uv sync --dev --group test diff --git a/backends/advanced/setup-https.sh b/backends/advanced/setup-https.sh new file mode 100755 index 000000000..39fac9b2f --- /dev/null +++ b/backends/advanced/setup-https.sh @@ -0,0 +1,356 @@ +#!/bin/bash +set -e + +# Friend-Lite Advanced Backend Initialization Script +# Comprehensive setup for all configuration files and optional services + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_header() { + echo "" + echo -e "${CYAN}===============================================${NC}" + echo -e "${CYAN}$1${NC}" + echo -e "${CYAN}===============================================${NC}" + echo "" +} + +# Reusable backup helper function +backup_with_timestamp() { + local filepath="$1" + + # Verify the file exists + if [ ! -f "$filepath" ]; then + print_error "Cannot backup '$filepath': file does not exist" + return 1 + fi + + # Generate timestamp (POSIX-safe fallback if needed) + local timestamp + if command -v date >/dev/null 2>&1; then + timestamp=$(date +%Y%m%d_%H%M%S 2>/dev/null) || timestamp=$(date +%Y%m%d_%H%M%S) + else + # POSIX fallback - use current time in seconds since epoch + timestamp="$(date +%s)" + fi + + local backup_path="${filepath}.${timestamp}.backup" + + # Create the backup + if cp "$filepath" "$backup_path"; then + echo "$backup_path" + return 0 + else + print_error "Failed to create backup of '$filepath'" + return 1 + fi +} + +# Check if we're in the right directory +if [ ! -f "pyproject.toml" ] || [ ! -d "src" ]; then + print_error "Please run this script from the backends/advanced directory" + exit 1 +fi + +print_header "Friend-Lite Advanced Backend Initialization" +echo "This script will help you set up the Friend-Lite backend with all necessary configurations." +echo "" + +# Function to prompt yes/no +prompt_yes_no() { + local prompt="$1" + local default="$2" + local response + + if [ "$default" = "y" ]; then + prompt="$prompt [Y/n]: " + else + prompt="$prompt [y/N]: " + fi + + read -p "$prompt" response + response=${response:-$default} + + if [[ "$response" =~ ^[Yy]$ ]]; then + return 0 + else + return 1 + fi +} + +# Step 1: Handle .env file +print_header "Step 1: Environment Configuration" +if [ -f ".env" ]; then + print_info ".env file already exists" + if prompt_yes_no "Do you want to update it from template?" "n"; then + backup_path=$(backup_with_timestamp ".env") + if [ $? -eq 0 ]; then + print_info "Backed up existing .env to $backup_path" + cp .env.template .env + print_success ".env created from template" + print_warning "Please edit .env to add your API keys and configuration" + else + print_error "Failed to backup .env file, aborting update" + fi + fi +else + if [ -f ".env.template" ]; then + cp .env.template .env + print_success ".env file created from template" + print_warning "IMPORTANT: Edit .env file to add your API keys:" + echo " - DEEPGRAM_API_KEY (for speech-to-text)" + echo " - OPENAI_API_KEY (for memory extraction)" + echo " - ADMIN_EMAIL and ADMIN_PASSWORD" + echo "" + if prompt_yes_no "Would you like to edit .env now?" "y"; then + ${EDITOR:-nano} .env + fi + else + print_error ".env.template not found!" + exit 1 + fi +fi + +# Step 2: Memory configuration +print_header "Step 2: Memory Configuration" +if [ -f "memory_config.yaml" ]; then + print_info "memory_config.yaml already exists" + if prompt_yes_no "Do you want to reset it from template?" "n"; then + backup_path=$(backup_with_timestamp "memory_config.yaml") + if [ $? -eq 0 ]; then + print_info "Backed up existing memory_config.yaml to $backup_path" + cp memory_config.yaml.template memory_config.yaml + print_success "memory_config.yaml reset from template" + else + print_error "Failed to backup memory_config.yaml file, aborting reset" + fi + fi +else + if [ -f "memory_config.yaml.template" ]; then + cp memory_config.yaml.template memory_config.yaml + print_success "memory_config.yaml created from template" + else + print_error "memory_config.yaml.template not found!" + exit 1 + fi +fi + +# Step 3: Diarization configuration +print_header "Step 3: Diarization Configuration" +if [ -f "diarization_config.json" ]; then + print_info "diarization_config.json already exists" + if prompt_yes_no "Do you want to reset it from template?" "n"; then + backup_path=$(backup_with_timestamp "diarization_config.json") + if [ $? -eq 0 ]; then + print_info "Backed up existing diarization_config.json to $backup_path" + cp diarization_config.json.template diarization_config.json + print_success "diarization_config.json reset from template" + else + print_error "Failed to backup diarization_config.json file, aborting reset" + fi + fi +else + if [ -f "diarization_config.json.template" ]; then + cp diarization_config.json.template diarization_config.json + print_success "diarization_config.json created from template" + else + print_error "diarization_config.json.template not found!" + exit 1 + fi +fi + +# Step 4: HTTPS Setup (optional) +print_header "Step 4: HTTPS Configuration (Optional)" +echo "HTTPS is required for:" +echo " - Microphone access from browsers" +echo " - Remote access via network/Tailscale" +echo " - Secure WebSocket connections" +echo "" + +if prompt_yes_no "Do you want to set up HTTPS?" "n"; then + if [ -f "init-https.sh" ]; then + echo "" + print_info "Please enter your Tailscale IP or network IP" + print_info "Example: 100.83.66.30" + read -p "IP Address: " TAILSCALE_IP + + if [ -n "$TAILSCALE_IP" ]; then + ./init-https.sh "$TAILSCALE_IP" + HTTPS_ENABLED=true + else + print_warning "Skipping HTTPS setup - no IP provided" + HTTPS_ENABLED=false + fi + else + print_warning "init-https.sh not found, skipping HTTPS setup" + HTTPS_ENABLED=false + fi +else + print_info "Skipping HTTPS setup" + HTTPS_ENABLED=false +fi + +# Step 5: Optional Services +print_header "Step 5: Optional Services (extras/)" + +echo "Configure additional services from extras/:" +echo "" + +# Helper function to update or add environment variable in .env file +update_env_var() { + local key=$1 + local value=$2 + + # Use Python to safely update the .env file + python3 -c " +import sys +import re + +key = '$key' +value = '$value' +env_file = '.env' + +# Read existing .env file +try: + with open(env_file, 'r') as f: + lines = f.readlines() +except FileNotFoundError: + lines = [] + +# Check if key exists (uncommented) +updated = False +for i, line in enumerate(lines): + # Skip comments + if line.strip().startswith('#'): + continue + # Check for existing key + if re.match(f'^\\s*{re.escape(key)}=', line): + lines[i] = f'{key}={value}\\n' + updated = True + break + +# If not found, append to end +if not updated: + if lines and not lines[-1].endswith('\\n'): + lines.append('\\n') + lines.append(f'{key}={value}\\n') + +# Write back to file +with open(env_file, 'w') as f: + f.writelines(lines) +" +} + +# OpenMemory MCP (Memory Provider) +if prompt_yes_no "Use OpenMemory MCP for memory management? (cross-client compatible)" "n"; then + update_env_var "MEMORY_PROVIDER" "openmemory_mcp" + print_success "Configured for OpenMemory MCP" + OPENMEMORY_ENABLED=true +else + OPENMEMORY_ENABLED=false +fi + +# Parakeet ASR (Offline Transcription) +if prompt_yes_no "Use Parakeet for offline transcription? (requires GPU)" "n"; then + update_env_var "PARAKEET_ASR_URL" "http://host.docker.internal:8767" + print_success "Configured for Parakeet ASR" + PARAKEET_ENABLED=true +else + PARAKEET_ENABLED=false +fi + +# Speaker Recognition +if prompt_yes_no "Enable Speaker Recognition service?" "n"; then + update_env_var "SPEAKER_SERVICE_URL" "http://host.docker.internal:8001" + print_success "Configured for Speaker Recognition" + SPEAKER_ENABLED=true +else + SPEAKER_ENABLED=false +fi + +# Step 6: Summary and Next Steps +print_header "Setup Complete!" + +echo "Configuration Summary:" +echo "----------------------" +echo "✅ Environment file (.env) configured" +echo "✅ Memory configuration (memory_config.yaml) ready" +echo "✅ Diarization configuration (diarization_config.json) ready" + +if [ "$HTTPS_ENABLED" = true ]; then + echo "✅ HTTPS configured with SSL certificates" +fi + +echo "" +echo "Next Steps:" +echo "-----------" + +if [ "$HTTPS_ENABLED" = true ]; then + echo "1. Start the services with HTTPS:" + echo " ${CYAN}docker compose --profile https up --build -d${NC}" + echo "" + echo "2. Access the dashboard:" + echo " 🌐 https://localhost/" + echo " 🌐 https://$TAILSCALE_IP/" +else + echo "1. Start the services:" + echo " ${CYAN}docker compose up --build -d${NC}" + echo "" + echo "2. Access the dashboard:" + echo " 🌐 http://localhost:5173" +fi + +echo "" +echo "3. Check service health:" +echo " ${CYAN}curl http://localhost:8000/health${NC}" + +echo "" +if [ "$OPENMEMORY_ENABLED" = true ] || [ "$PARAKEET_ENABLED" = true ] || [ "$SPEAKER_ENABLED" = true ]; then + echo "Start Optional Services:" + echo "------------------------" + + if [ "$OPENMEMORY_ENABLED" = true ]; then + echo "OpenMemory MCP:" + echo " ${CYAN}cd ../../extras/openmemory-mcp && docker compose up -d${NC}" + fi + + if [ "$PARAKEET_ENABLED" = true ]; then + echo "Parakeet ASR:" + echo " ${CYAN}cd ../../extras/asr-services && docker compose up parakeet -d${NC}" + fi + + if [ "$SPEAKER_ENABLED" = true ]; then + echo "Speaker Recognition:" + echo " ${CYAN}cd ../../extras/speaker-recognition && docker compose up --build -d${NC}" + fi +fi + +echo "" +echo "For more information, see:" +echo " - Docs/quickstart.md" +echo " - Docs/memory-configuration-guide.md" +echo " - MEMORY_PROVIDERS.md" + +echo "" +print_success "Initialization complete! 🎉" \ No newline at end of file diff --git a/backends/advanced/setup-requirements.txt b/backends/advanced/setup-requirements.txt new file mode 100644 index 000000000..89d23f8d8 --- /dev/null +++ b/backends/advanced/setup-requirements.txt @@ -0,0 +1,3 @@ +# Dependencies for interactive setup script +rich>=13.0.0 +pyyaml>=6.0.0 \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py index 19ada8026..e5f576c27 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py @@ -27,8 +27,16 @@ async def get_memories(user: User, limit: int, user_id: Optional[str] = None): # Execute memory retrieval directly (now async) memories = await memory_service.get_all_memories(target_user_id, limit) + + # Get total count (service returns None on failure) + total_count = await memory_service.count_memories(target_user_id) - return {"memories": memories, "count": len(memories), "user_id": target_user_id} + return { + "memories": memories, + "count": len(memories), + "total_count": total_count, + "user_id": target_user_id + } except Exception as e: audio_logger.error(f"Error fetching memories: {e}", exc_info=True) @@ -66,7 +74,7 @@ async def get_memories_with_transcripts(user: User, limit: int, user_id: Optiona ) -async def search_memories(query: str, user: User, limit: int, user_id: Optional[str] = None): +async def search_memories(query: str, user: User, limit: int, score_threshold: float = 0.0, user_id: Optional[str] = None): """Search memories by text query. Users can only search their own memories, admins can search all or filter by user.""" try: memory_service = get_memory_service() @@ -77,7 +85,7 @@ async def search_memories(query: str, user: User, limit: int, user_id: Optional[ target_user_id = user_id # Execute search directly (now async) - search_results = await memory_service.search_memories(query, target_user_id, limit) + search_results = await memory_service.search_memories(query, target_user_id, limit, score_threshold) return { "query": query, diff --git a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py index 14bba5958..97c46e891 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py @@ -6,9 +6,11 @@ import io import json import logging +import os import shutil import time import wave +from datetime import UTC, datetime from pathlib import Path import numpy as np @@ -30,9 +32,14 @@ async def get_current_metrics(): """Get current system metrics.""" try: + # Get memory provider configuration + memory_provider = os.getenv("MEMORY_PROVIDER", "friend_lite").lower() + # Get basic system metrics metrics = { "timestamp": int(time.time()), + "memory_provider": memory_provider, + "memory_provider_supports_threshold": memory_provider == "friend_lite", } return metrics @@ -912,3 +919,142 @@ async def save_diarization_settings(settings: dict): return JSONResponse( status_code=500, content={"error": f"Failed to save settings: {str(e)}"} ) + + +async def get_speaker_configuration(user: User): + """Get current user's primary speakers configuration.""" + try: + return { + "primary_speakers": user.primary_speakers, + "user_id": user.user_id, + "status": "success" + } + except Exception as e: + logger.error(f"Error getting speaker configuration for user {user.user_id}: {e}") + return JSONResponse( + status_code=500, content={"error": f"Failed to get speaker configuration: {str(e)}"} + ) + + +async def update_speaker_configuration(user: User, primary_speakers: list[dict]): + """Update current user's primary speakers configuration.""" + try: + # Validate speaker data format + for speaker in primary_speakers: + if not isinstance(speaker, dict): + return JSONResponse( + status_code=400, content={"error": "Each speaker must be a dictionary"} + ) + + required_fields = ["speaker_id", "name", "user_id"] + for field in required_fields: + if field not in speaker: + return JSONResponse( + status_code=400, content={"error": f"Missing required field: {field}"} + ) + + # Enforce server-side user_id and add timestamp to each speaker + for speaker in primary_speakers: + speaker["user_id"] = user.user_id # Override client-supplied user_id + speaker["selected_at"] = datetime.now(UTC).isoformat() + + # Update user model + user.primary_speakers = primary_speakers + await user.save() + + logger.info(f"Updated primary speakers configuration for user {user.user_id}: {len(primary_speakers)} speakers") + + return { + "message": "Primary speakers configuration updated successfully", + "primary_speakers": primary_speakers, + "count": len(primary_speakers), + "status": "success" + } + + except Exception as e: + logger.error(f"Error updating speaker configuration for user {user.user_id}: {e}") + return JSONResponse( + status_code=500, content={"error": f"Failed to update speaker configuration: {str(e)}"} + ) + + +async def get_enrolled_speakers(user: User): + """Get enrolled speakers from speaker recognition service.""" + try: + from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient + + # Initialize speaker recognition client + speaker_client = SpeakerRecognitionClient() + + if not speaker_client.enabled: + return { + "speakers": [], + "service_available": False, + "message": "Speaker recognition service is not configured or disabled", + "status": "success" + } + + # Get enrolled speakers - using hardcoded user_id=1 for now (as noted in speaker_recognition_client.py) + speakers = await speaker_client.get_enrolled_speakers(user_id="1") + + return { + "speakers": speakers.get("speakers", []) if speakers else [], + "service_available": True, + "message": "Successfully retrieved enrolled speakers", + "status": "success" + } + + except Exception as e: + logger.error(f"Error getting enrolled speakers for user {user.user_id}: {e}") + return { + "speakers": [], + "service_available": False, + "message": f"Failed to retrieve speakers: {str(e)}", + "status": "error" + } + + +async def get_speaker_service_status(): + """Check speaker recognition service health status.""" + try: + from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient + + # Initialize speaker recognition client + speaker_client = SpeakerRecognitionClient() + + if not speaker_client.enabled: + return { + "service_available": False, + "healthy": False, + "message": "Speaker recognition service is not configured or disabled", + "status": "disabled" + } + + # Perform health check + health_result = await speaker_client.health_check() + + if health_result: + return { + "service_available": True, + "healthy": True, + "message": "Speaker recognition service is healthy", + "service_url": speaker_client.service_url, + "status": "healthy" + } + else: + return { + "service_available": False, + "healthy": False, + "message": "Speaker recognition service is not responding", + "service_url": speaker_client.service_url, + "status": "unhealthy" + } + + except Exception as e: + logger.error(f"Error checking speaker service status: {e}") + return { + "service_available": False, + "healthy": False, + "message": f"Health check failed: {str(e)}", + "status": "error" + } diff --git a/backends/advanced/src/advanced_omi_backend/main.py b/backends/advanced/src/advanced_omi_backend/main.py index f04ace2ff..ae2433533 100644 --- a/backends/advanced/src/advanced_omi_backend/main.py +++ b/backends/advanced/src/advanced_omi_backend/main.py @@ -28,12 +28,13 @@ # Import Beanie for user management from beanie import init_beanie from dotenv import load_dotenv -from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, Query, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from friend_lite.decoder import OmiOpusDecoder from motor.motor_asyncio import AsyncIOMotorClient +from pymongo.errors import ConnectionFailure, PyMongoError from wyoming.audio import AudioChunk from wyoming.client import AsyncTcpClient @@ -379,6 +380,103 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) + +############################################################################### +# GLOBAL EXCEPTION HANDLERS +############################################################################### + +@app.exception_handler(ConnectionFailure) +@app.exception_handler(PyMongoError) +async def database_exception_handler(request: Request, exc: Exception): + """Handle database connection failures and return structured error response.""" + logger.error(f"Database connection error: {type(exc).__name__}: {exc}") + return JSONResponse( + status_code=500, + content={ + "detail": "Unable to connect to server. Please check your connection and try again.", + "error_type": "connection_failure", + "error_category": "database" + } + ) + + +@app.exception_handler(ConnectionError) +async def connection_exception_handler(request: Request, exc: ConnectionError): + """Handle general connection errors and return structured error response.""" + logger.error(f"Connection error: {exc}") + return JSONResponse( + status_code=500, + content={ + "detail": "Unable to connect to server. Please check your connection and try again.", + "error_type": "connection_failure", + "error_category": "network" + } + ) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + """Handle HTTP exceptions with structured error response.""" + # For authentication failures (401), add error_type + if exc.status_code == 401: + return JSONResponse( + status_code=exc.status_code, + content={ + "detail": exc.detail, + "error_type": "authentication_failure" + }, + headers=getattr(exc, "headers", None), + ) + + # For other HTTP exceptions, return as-is + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=getattr(exc, "headers", None), + ) + + +############################################################################### +# HEALTH CHECK ENDPOINTS +############################################################################### + +@app.get("/api/auth/health") +async def auth_health_check(): + """Pre-flight health check for authentication service connectivity.""" + try: + # Test database connectivity + await mongo_client.admin.command("ping") + + # Test memory service if available + if memory_service: + try: + await asyncio.wait_for(memory_service.test_connection(), timeout=2.0) + memory_status = "ok" + except Exception as e: + logger.warning(f"Memory service health check failed: {e}") + memory_status = "degraded" + else: + memory_status = "unavailable" + + return { + "status": "ok", + "database": "ok", + "memory_service": memory_status, + "timestamp": int(time.time()) + } + except Exception as e: + logger.error(f"Auth health check failed: {e}") + return JSONResponse( + status_code=500, + content={ + "status": "error", + "detail": "Service connectivity check failed", + "error_type": "connection_failure", + "timestamp": int(time.time()) + } + ) + + app.mount("/audio", StaticFiles(directory=CHUNK_DIR), name="audio") # Add authentication routers @@ -906,6 +1004,11 @@ async def health_check(): overall_healthy = True critical_services_healthy = True + + # Get configuration once at the start + memory_provider = os.getenv("MEMORY_PROVIDER", "friend_lite") + speaker_service_url = os.getenv("SPEAKER_SERVICE_URL") + openmemory_mcp_url = os.getenv("OPENMEMORY_MCP_URL") # Check MongoDB (critical service) try: @@ -961,34 +1064,55 @@ async def health_check(): } overall_healthy = False - # Check mem0 (depends on Ollama and Qdrant) - try: - # Test memory service connection with timeout - test_success = await memory_service.test_connection() - if test_success: - health_status["services"]["mem0"] = { - "status": "✅ Connected", - "healthy": True, + # Check memory service (provider-dependent) + if memory_provider == "friend_lite": + try: + # Test Friend-Lite memory service connection with timeout + test_success = await asyncio.wait_for(memory_service.test_connection(), timeout=8.0) + if test_success: + health_status["services"]["memory_service"] = { + "status": "✅ Friend-Lite Memory Connected", + "healthy": True, + "provider": "friend_lite", + "critical": False, + } + else: + health_status["services"]["memory_service"] = { + "status": "⚠️ Friend-Lite Memory Test Failed", + "healthy": False, + "provider": "friend_lite", + "critical": False, + } + overall_healthy = False + except asyncio.TimeoutError: + health_status["services"]["memory_service"] = { + "status": "⚠️ Friend-Lite Memory Timeout (8s) - Check Qdrant", + "healthy": False, + "provider": "friend_lite", "critical": False, } - else: - health_status["services"]["mem0"] = { - "status": "⚠️ Connection Test Failed", + overall_healthy = False + except Exception as e: + health_status["services"]["memory_service"] = { + "status": f"⚠️ Friend-Lite Memory Failed: {str(e)}", "healthy": False, + "provider": "friend_lite", "critical": False, } overall_healthy = False - except asyncio.TimeoutError: - health_status["services"]["mem0"] = { - "status": "⚠️ Connection Test Timeout (60s) - Depends on Ollama/Qdrant", - "healthy": False, + elif memory_provider == "openmemory_mcp": + # OpenMemory MCP check is handled separately above + health_status["services"]["memory_service"] = { + "status": "✅ Using OpenMemory MCP", + "healthy": True, + "provider": "openmemory_mcp", "critical": False, } - overall_healthy = False - except Exception as e: - health_status["services"]["mem0"] = { - "status": f"⚠️ Connection Test Failed: {str(e)} - Check Ollama/Qdrant services", + else: + health_status["services"]["memory_service"] = { + "status": f"❌ Unknown memory provider: {memory_provider}", "healthy": False, + "provider": memory_provider, "critical": False, } overall_healthy = False @@ -1033,7 +1157,6 @@ async def health_check(): overall_healthy = False # Check Speaker Recognition service (non-critical - optional feature) - speaker_service_url = os.getenv("SPEAKER_SERVICE_URL") if speaker_service_url: try: # Make a health check request to the speaker service @@ -1073,6 +1196,50 @@ async def health_check(): } overall_healthy = False + # Check OpenMemory MCP service (if configured) + if memory_provider == "openmemory_mcp" and openmemory_mcp_url: + try: + # Make a health check request to the OpenMemory MCP service + async with aiohttp.ClientSession() as session: + async with session.get( + f"{openmemory_mcp_url}/docs", timeout=aiohttp.ClientTimeout(total=5) + ) as response: + if response.status == 200: + health_status["services"]["openmemory_mcp"] = { + "status": "✅ Connected", + "healthy": True, + "url": openmemory_mcp_url, + "provider": "openmemory_mcp", + "critical": False, + } + else: + health_status["services"]["openmemory_mcp"] = { + "status": f"⚠️ Unhealthy: HTTP {response.status}", + "healthy": False, + "url": openmemory_mcp_url, + "provider": "openmemory_mcp", + "critical": False, + } + overall_healthy = False + except asyncio.TimeoutError: + health_status["services"]["openmemory_mcp"] = { + "status": "⚠️ Connection Timeout (5s)", + "healthy": False, + "url": openmemory_mcp_url, + "provider": "openmemory_mcp", + "critical": False, + } + overall_healthy = False + except Exception as e: + health_status["services"]["openmemory_mcp"] = { + "status": f"⚠️ Connection Failed: {str(e)}", + "healthy": False, + "url": openmemory_mcp_url, + "provider": "openmemory_mcp", + "critical": False, + } + overall_healthy = False + # Track health check results in debug tracker try: # Can add health check tracking to debug tracker if needed diff --git a/backends/advanced/src/advanced_omi_backend/memory/base.py b/backends/advanced/src/advanced_omi_backend/memory/base.py index d1891a093..65d39d750 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/base.py +++ b/backends/advanced/src/advanced_omi_backend/memory/base.py @@ -102,7 +102,8 @@ async def search_memories( self, query: str, user_id: str, - limit: int = 10 + limit: int = 10, + score_threshold: float = 0.0 ) -> List[MemoryEntry]: """Search memories using semantic similarity. @@ -110,6 +111,7 @@ async def search_memories( query: Search query text user_id: User identifier to filter memories limit: Maximum number of results to return + score_threshold: Minimum similarity score (0.0 = no threshold) Returns: List of matching MemoryEntry objects ordered by relevance @@ -133,6 +135,20 @@ async def get_all_memories( """ pass + async def count_memories(self, user_id: str) -> Optional[int]: + """Count total number of memories for a user. + + This is an optional method that providers can implement for efficient + counting. Returns None if the provider doesn't support counting. + + Args: + user_id: User identifier + + Returns: + Total count of memories for the user, or None if not supported + """ + return None + @abstractmethod async def delete_memory(self, memory_id: str) -> bool: """Delete a specific memory by ID. @@ -279,7 +295,8 @@ async def search_memories( self, query_embedding: List[float], user_id: str, - limit: int + limit: int, + score_threshold: float = 0.0 ) -> List[MemoryEntry]: """Search memories using vector similarity. @@ -287,6 +304,7 @@ async def search_memories( query_embedding: Query vector for similarity search user_id: User identifier to filter results limit: Maximum number of results to return + score_threshold: Minimum similarity score (0.0 = no threshold) Returns: List of matching MemoryEntry objects with similarity scores @@ -306,6 +324,20 @@ async def get_memories(self, user_id: str, limit: int) -> List[MemoryEntry]: """ pass + async def count_memories(self, user_id: str) -> Optional[int]: + """Count total number of memories for a user. + + Default implementation returns None to indicate counting is unsupported. + Vector stores should override this method to provide efficient counting if supported. + + Args: + user_id: User identifier + + Returns: + Total count of memories for the user, or None if counting is not supported by this store + """ + return None + @abstractmethod async def update_memory( self, diff --git a/backends/advanced/src/advanced_omi_backend/memory/compat_service.py b/backends/advanced/src/advanced_omi_backend/memory/compat_service.py index 430d2fab0..e3cb9827a 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/compat_service.py +++ b/backends/advanced/src/advanced_omi_backend/memory/compat_service.py @@ -134,6 +134,21 @@ async def get_all_memories(self, user_id: str, limit: int = 100) -> List[Dict[st for memory in memories ] + async def count_memories(self, user_id: str) -> Optional[int]: + """Count total number of memories for a user. + + Args: + user_id: User identifier + + Returns: + Total count of memories for the user, or None if not supported + """ + if not self._initialized: + await self.initialize() + + # Delegate to the core service + return await self._service.count_memories(user_id) + async def get_all_memories_unfiltered(self, user_id: str, limit: int = 100) -> List[Dict[str, Any]]: """Get all memories without filtering - same as get_all_memories in new implementation. @@ -146,13 +161,14 @@ async def get_all_memories_unfiltered(self, user_id: str, limit: int = 100) -> L """ return await self.get_all_memories(user_id, limit) - async def search_memories(self, query: str, user_id: str, limit: int = 10) -> List[Dict[str, Any]]: + async def search_memories(self, query: str, user_id: str, limit: int = 10, score_threshold: float = 0.0) -> List[Dict[str, Any]]: """Search memories using semantic similarity - returns dict format for compatibility. Args: query: Search query text user_id: User identifier to filter memories limit: Maximum number of results to return + score_threshold: Minimum similarity score (0.0 = no threshold) Returns: List of memory dictionaries in legacy format ordered by relevance @@ -160,7 +176,7 @@ async def search_memories(self, query: str, user_id: str, limit: int = 10) -> Li if not self._initialized: await self.initialize() - memories = await self._service.search_memories(query, user_id, limit) + memories = await self._service.search_memories(query, user_id, limit, score_threshold) # Convert MemoryEntry objects to dict format for compatibility with normalized content return [ diff --git a/backends/advanced/src/advanced_omi_backend/memory/memory_service.py b/backends/advanced/src/advanced_omi_backend/memory/memory_service.py index f7285a8e5..dc5bc21e2 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/memory_service.py +++ b/backends/advanced/src/advanced_omi_backend/memory/memory_service.py @@ -212,7 +212,7 @@ async def add_memory( memory_logger.error(f"❌ Add memory failed for {source_id}: {e}") raise e - async def search_memories(self, query: str, user_id: str, limit: int = 10) -> List[MemoryEntry]: + async def search_memories(self, query: str, user_id: str, limit: int = 10, score_threshold: float = 0.0) -> List[MemoryEntry]: """Search memories using semantic similarity. Generates an embedding for the query and searches the vector store @@ -222,6 +222,7 @@ async def search_memories(self, query: str, user_id: str, limit: int = 10) -> Li query: Search query text user_id: User identifier to filter memories limit: Maximum number of results to return + score_threshold: Minimum similarity score (0.0 = no threshold) Returns: List of matching MemoryEntry objects ordered by relevance @@ -238,7 +239,7 @@ async def search_memories(self, query: str, user_id: str, limit: int = 10) -> Li # Search in vector store results = await self.vector_store.search_memories( - query_embeddings[0], user_id, limit + query_embeddings[0], user_id, limit, score_threshold ) memory_logger.info(f"🔍 Found {len(results)} memories for query '{query}' (user: {user_id})") @@ -272,6 +273,28 @@ async def get_all_memories(self, user_id: str, limit: int = 100) -> List[MemoryE memory_logger.error(f"Get all memories failed: {e}") return [] + async def count_memories(self, user_id: str) -> Optional[int]: + """Count total number of memories for a user. + + Uses the vector store's native count capabilities. + + Args: + user_id: User identifier + + Returns: + Total count of memories for the user, or None if not supported + """ + if not self._initialized: + await self.initialize() + + try: + count = await self.vector_store.count_memories(user_id) + memory_logger.info(f"🔢 Total {count} memories for user {user_id}") + return count + except Exception as e: + memory_logger.error(f"Count memories failed: {e}") + return None + async def delete_memory(self, memory_id: str) -> bool: """Delete a specific memory by ID. diff --git a/backends/advanced/src/advanced_omi_backend/memory/providers/openmemory_mcp_service.py b/backends/advanced/src/advanced_omi_backend/memory/providers/openmemory_mcp_service.py index 1b853c3f6..11231903b 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/providers/openmemory_mcp_service.py +++ b/backends/advanced/src/advanced_omi_backend/memory/providers/openmemory_mcp_service.py @@ -181,7 +181,8 @@ async def search_memories( self, query: str, user_id: str, - limit: int = 10 + limit: int = 10, + score_threshold: float = 0.0 ) -> List[MemoryEntry]: """Search memories using semantic similarity. @@ -192,6 +193,7 @@ async def search_memories( query: Search query text user_id: User identifier to filter memories limit: Maximum number of results to return + score_threshold: Minimum similarity score (ignored - OpenMemory MCP server controls filtering) Returns: List of matching MemoryEntry objects ordered by relevance diff --git a/backends/advanced/src/advanced_omi_backend/memory/providers/vector_stores.py b/backends/advanced/src/advanced_omi_backend/memory/providers/vector_stores.py index 0b60eec7b..a3d041008 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/providers/vector_stores.py +++ b/backends/advanced/src/advanced_omi_backend/memory/providers/vector_stores.py @@ -6,12 +6,23 @@ Vector stores handle storage, retrieval, and similarity search of memory embeddings. """ -import time import logging +import time import uuid -from typing import List, Dict, Any +from typing import Any, Dict, List + +from qdrant_client import AsyncQdrantClient +from qdrant_client.models import ( + Distance, + FieldCondition, + Filter, + FilterSelector, + MatchValue, + PointStruct, + VectorParams, +) -from ..base import VectorStoreBase, MemoryEntry +from ..base import MemoryEntry, VectorStoreBase memory_logger = logging.getLogger("memory_service") @@ -51,9 +62,6 @@ async def initialize(self) -> None: RuntimeError: If initialization fails """ try: - from qdrant_client import AsyncQdrantClient - from qdrant_client.models import Distance, VectorParams - self.client = AsyncQdrantClient(host=self.host, port=self.port) # Check if collection exists and get its info @@ -112,8 +120,6 @@ async def initialize(self) -> None: async def add_memories(self, memories: List[MemoryEntry]) -> List[str]: """Add memories to Qdrant.""" try: - from qdrant_client.models import PointStruct - points = [] for memory in memories: if memory.embedding: @@ -141,11 +147,16 @@ async def add_memories(self, memories: List[MemoryEntry]) -> List[str]: memory_logger.error(f"Qdrant add memories failed: {e}") return [] - async def search_memories(self, query_embedding: List[float], user_id: str, limit: int) -> List[MemoryEntry]: - """Search memories in Qdrant.""" + async def search_memories(self, query_embedding: List[float], user_id: str, limit: int, score_threshold: float = 0.0) -> List[MemoryEntry]: + """Search memories in Qdrant with configurable similarity threshold filtering. + + Args: + query_embedding: Query vector for similarity search + user_id: User identifier to filter results + limit: Maximum number of results to return + score_threshold: Minimum similarity score (0.0 = no threshold, 1.0 = exact match) + """ try: - from qdrant_client.models import Filter, FieldCondition, MatchValue - # Filter by user_id search_filter = Filter( must=[ @@ -156,12 +167,20 @@ async def search_memories(self, query_embedding: List[float], user_id: str, limi ] ) - results = await self.client.search( - collection_name=self.collection_name, - query_vector=query_embedding, - query_filter=search_filter, - limit=limit - ) + # Apply similarity threshold if provided + # For cosine similarity, scores range from -1 to 1, where 1 is most similar + search_params = { + "collection_name": self.collection_name, + "query_vector": query_embedding, + "query_filter": search_filter, + "limit": limit + } + + if score_threshold > 0.0: + search_params["score_threshold"] = score_threshold + memory_logger.debug(f"Using similarity threshold: {score_threshold}") + + results = await self.client.search(**search_params) memories = [] for result in results: @@ -169,12 +188,17 @@ async def search_memories(self, query_embedding: List[float], user_id: str, limi id=str(result.id), content=result.payload.get("content", ""), metadata=result.payload.get("metadata", {}), - # Convert Qdrant cosine distance to similarity [0..1] - score=(1.0 - result.score) if result.score is not None else None, + # Qdrant returns similarity scores directly (higher = more similar) + score=result.score if result.score is not None else None, created_at=result.payload.get("created_at") ) memories.append(memory) + # Log similarity scores for debugging + score_str = f"{result.score:.3f}" if result.score is not None else "None" + memory_logger.debug(f"Retrieved memory with score {score_str}: {result.payload.get('content', '')[:50]}...") + threshold_msg = f"threshold {score_threshold}" if score_threshold > 0.0 else "no threshold" + memory_logger.info(f"Found {len(memories)} memories with {threshold_msg} for user {user_id}") return memories except Exception as e: @@ -184,8 +208,6 @@ async def search_memories(self, query_embedding: List[float], user_id: str, limi async def get_memories(self, user_id: str, limit: int) -> List[MemoryEntry]: """Get all memories for a user from Qdrant.""" try: - from qdrant_client.models import Filter, FieldCondition, MatchValue - # Filter by user_id search_filter = Filter( must=[ @@ -248,8 +270,6 @@ async def delete_memory(self, memory_id: str) -> bool: async def delete_user_memories(self, user_id: str) -> int: """Delete all memories for a user from Qdrant.""" try: - from qdrant_client.models import Filter, FieldCondition, MatchValue, FilterSelector - # First count memories to delete memories = await self.get_memories(user_id, limit=10000) count = len(memories) @@ -297,8 +317,6 @@ async def update_memory( ) -> bool: """Update (upsert) an existing memory in Qdrant.""" try: - from qdrant_client.models import PointStruct - payload = { "content": new_content, "metadata": new_metadata, @@ -335,6 +353,32 @@ async def update_memory( memory_logger.error(f"Qdrant update memory failed: {e}") return False + async def count_memories(self, user_id: str) -> int: + """Count total number of memories for a user in Qdrant using native count API.""" + try: + + search_filter = Filter( + must=[ + FieldCondition( + key="metadata.user_id", + match=MatchValue(value=user_id) + ) + ] + ) + + # Use Qdrant's native count API (documented in qdrant/qdrant/docs) + # Count operation: CountPoints -> CountResponse with count result + result = await self.client.count( + collection_name=self.collection_name, + count_filter=search_filter + ) + + return result.count + + except Exception as e: + memory_logger.error(f"Qdrant count memories failed: {e}") + return 0 + diff --git a/backends/advanced/src/advanced_omi_backend/processors.py b/backends/advanced/src/advanced_omi_backend/processors.py index 365b4fd06..cbedc117f 100644 --- a/backends/advanced/src/advanced_omi_backend/processors.py +++ b/backends/advanced/src/advanced_omi_backend/processors.py @@ -19,6 +19,7 @@ from advanced_omi_backend.audio_cropping_utils import ( _process_audio_cropping_with_relative_timestamps, ) +from advanced_omi_backend.users import get_user_by_id from advanced_omi_backend.client_manager import get_client_manager from advanced_omi_backend.database import AudioChunksRepository from advanced_omi_backend.memory import get_memory_service @@ -159,6 +160,77 @@ async def start(self): logger.info("All processors started successfully") + async def _should_process_memory(self, user_id: str, audio_uuid: str) -> tuple[bool, str]: + """ + Determine if memory processing should proceed based on primary speakers configuration. + + Implements graceful degradation: + - No primary speakers configured → Process all (True) + - Speaker service unavailable → Process all (True) + - No speakers identified → Process all (True) + - Primary speakers found → Process (True) + - Only non-primary speakers → Skip (False) + + Args: + user_id: User ID to check primary speakers configuration + audio_uuid: Audio UUID to check transcript speakers + + Returns: + Tuple of (should_process: bool, reason: str) + """ + try: + # Get user's primary speaker configuration + user = await get_user_by_id(user_id) + if not user or not user.primary_speakers: + return True, "No primary speakers configured - processing all conversations" + + audio_logger.info(f"🔍 Checking primary speakers filter for {audio_uuid} - user has {len(user.primary_speakers)} primary speakers configured") + + # Get transcript with speaker identification + chunk = await self.repository.get_chunk_by_audio_uuid(audio_uuid) + if not chunk or not chunk.get('transcript'): + return True, "No transcript data available - processing conversation" + + # Extract speakers from transcript segments (normalized for comparison) + transcript_speakers = set() + transcript_speaker_originals = {} # Keep original names for logging + total_segments = 0 + identified_segments = 0 + + for segment in chunk['transcript']: + total_segments += 1 + if 'identified_as' in segment and segment['identified_as'] and segment['identified_as'] != 'Unknown': + original_name = segment['identified_as'] + normalized_name = original_name.strip().lower() + transcript_speakers.add(normalized_name) + transcript_speaker_originals[normalized_name] = original_name + identified_segments += 1 + + if not transcript_speakers: + return True, f"No speakers identified in transcript ({identified_segments}/{total_segments} segments) - processing conversation" + + # Check if any primary speakers are present (normalized comparison) + primary_speaker_names = {ps['name'].strip().lower() for ps in user.primary_speakers} + primary_speaker_originals = {ps['name'].strip().lower(): ps['name'] for ps in user.primary_speakers} + found_primary_speakers_normalized = transcript_speakers.intersection(primary_speaker_names) + + if found_primary_speakers_normalized: + # Convert back to original names for display + found_primary_originals = [primary_speaker_originals[name] for name in found_primary_speakers_normalized] + audio_logger.info(f"✅ Primary speakers found in conversation: {found_primary_originals} - processing memory") + return True, f"Primary speakers detected: {', '.join(found_primary_originals)}" + else: + # Show original names in logs + transcript_originals = [transcript_speaker_originals[name] for name in transcript_speakers] + primary_originals = [primary_speaker_originals[name] for name in primary_speaker_names] + audio_logger.info(f"❌ No primary speakers found - transcript speakers: {transcript_originals}, primary speakers: {primary_originals} - skipping memory processing") + return False, f"Only non-primary speakers found: {', '.join(transcript_originals)}" + + except Exception as e: + # On any error, default to processing (fail-safe) + audio_logger.warning(f"Error checking primary speakers filter for {audio_uuid}: {e} - defaulting to process conversation") + return True, f"Error in speaker filtering: {str(e)} - processing conversation as fallback" + async def shutdown(self): """Shutdown all processors gracefully.""" logger.info("Shutting down processors...") @@ -876,6 +948,31 @@ async def _process_memory_item(self, item: MemoryProcessingItem): # Debug tracking removed for cleaner architecture + # Check if memory processing should proceed based on primary speakers configuration + should_process, filter_reason = await self._should_process_memory(item.user_id, item.audio_uuid) + audio_logger.info(f"🎯 Speaker filter decision for {item.audio_uuid}: {filter_reason}") + + if not should_process: + # Update memory processing status to skipped + await self.repository.update_memory_processing_status( + item.audio_uuid, "SKIPPED", error_message=filter_reason + ) + + # Track completion + self.track_processing_stage( + item.client_id, + "memory", + "completed", + { + "audio_uuid": item.audio_uuid, + "status": "skipped", + "reason": filter_reason, + "completed_at": time.time(), + }, + ) + audio_logger.info(f"⏭️ Skipped memory processing for {item.audio_uuid}: {filter_reason}") + return None + # Lazy import memory service if self.memory_service is None: audio_logger.info(f"🔧 Initializing memory service for {item.audio_uuid}...") diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/memory_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/memory_routes.py index 424ca9514..4d71ce6d4 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/memory_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/memory_routes.py @@ -43,10 +43,11 @@ async def search_memories( query: str = Query(..., description="Search query"), current_user: User = Depends(current_active_user), limit: int = Query(default=20, ge=1, le=100), + score_threshold: float = Query(default=0.0, ge=0.0, le=1.0, description="Minimum similarity score (0.0 = no threshold)"), user_id: Optional[str] = Query(default=None, description="User ID filter (admin only)"), ): - """Search memories by text query. Users can only search their own memories, admins can search all or filter by user.""" - return await memory_controller.search_memories(query, current_user, limit, user_id) + """Search memories by text query with configurable similarity threshold. Users can only search their own memories, admins can search all or filter by user.""" + return await memory_controller.search_memories(query, current_user, limit, score_threshold, user_id) @router.delete("/{memory_id}") diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py index 85e9982a1..782697a56 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/system_routes.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, File, Query, UploadFile -from advanced_omi_backend.auth import current_superuser +from advanced_omi_backend.auth import current_active_user, current_superuser from advanced_omi_backend.controllers import system_controller from advanced_omi_backend.users import User @@ -101,3 +101,30 @@ async def save_diarization_settings( ): """Save diarization settings. Admin only.""" return await system_controller.save_diarization_settings(settings) + + +@router.get("/speaker-configuration") +async def get_speaker_configuration(current_user: User = Depends(current_active_user)): + """Get current user's primary speakers configuration.""" + return await system_controller.get_speaker_configuration(current_user) + + +@router.post("/speaker-configuration") +async def update_speaker_configuration( + primary_speakers: list[dict], + current_user: User = Depends(current_active_user) +): + """Update current user's primary speakers configuration.""" + return await system_controller.update_speaker_configuration(current_user, primary_speakers) + + +@router.get("/enrolled-speakers") +async def get_enrolled_speakers(current_user: User = Depends(current_active_user)): + """Get enrolled speakers from speaker recognition service.""" + return await system_controller.get_enrolled_speakers(current_user) + + +@router.get("/speaker-service-status") +async def get_speaker_service_status(current_user: User = Depends(current_superuser)): + """Check speaker recognition service health status. Admin only.""" + return await system_controller.get_speaker_service_status() diff --git a/backends/advanced/src/advanced_omi_backend/speaker_recognition_client.py b/backends/advanced/src/advanced_omi_backend/speaker_recognition_client.py index dd7e9495a..d8e601769 100644 --- a/backends/advanced/src/advanced_omi_backend/speaker_recognition_client.py +++ b/backends/advanced/src/advanced_omi_backend/speaker_recognition_client.py @@ -319,3 +319,82 @@ def _process_diarization_result( except Exception as e: logger.error(f"Error processing diarization result: {e}") return {} + + async def get_enrolled_speakers(self, user_id: Optional[str] = None) -> Dict: + """ + Get enrolled speakers from the speaker recognition service. + + Args: + user_id: Optional user ID to filter speakers (for future user isolation) + + Returns: + Dictionary containing speakers list and metadata + """ + if not self.enabled: + return {"speakers": []} + + try: + logger.info(f"Getting enrolled speakers from service: {self.service_url}") + + async with aiohttp.ClientSession() as session: + # Use the /speakers endpoint - currently no user filtering in speaker service + async with session.get( + f"{self.service_url}/speakers", + timeout=aiohttp.ClientTimeout(total=10), + ) as response: + if response.status != 200: + logger.warning( + f"Speaker service returned status {response.status}: {await response.text()}" + ) + return {"speakers": []} + + result = await response.json() + speakers = result.get("speakers", []) + logger.info(f"Retrieved {len(speakers)} enrolled speakers from service") + return result + + except aiohttp.ClientError as e: + logger.warning(f"Failed to connect to speaker recognition service: {e}") + return {"speakers": []} + except Exception as e: + logger.error(f"Error getting enrolled speakers: {e}") + return {"speakers": []} + + async def health_check(self) -> bool: + """ + Check if the speaker recognition service is healthy and responding. + + Returns: + True if service is healthy, False otherwise + """ + if not self.enabled: + return False + + try: + logger.debug(f"Performing health check on speaker service: {self.service_url}") + + async with aiohttp.ClientSession() as session: + # Use the /health endpoint if available, otherwise try a simple endpoint + health_endpoints = ["/health", "/speakers"] + + for endpoint in health_endpoints: + try: + async with session.get( + f"{self.service_url}{endpoint}", + timeout=aiohttp.ClientTimeout(total=5), + ) as response: + if response.status == 200: + logger.debug(f"Speaker service health check passed via {endpoint}") + return True + else: + logger.debug(f"Health check endpoint {endpoint} returned {response.status}") + except Exception as endpoint_error: + logger.debug(f"Health check failed for {endpoint}: {endpoint_error}") + continue + + logger.warning("All health check endpoints failed") + return False + + except Exception as e: + logger.error(f"Error during speaker service health check: {e}") + return False diff --git a/backends/advanced/src/advanced_omi_backend/transcription_providers.py b/backends/advanced/src/advanced_omi_backend/transcription_providers.py index 53c48eaaa..662fc57c7 100644 --- a/backends/advanced/src/advanced_omi_backend/transcription_providers.py +++ b/backends/advanced/src/advanced_omi_backend/transcription_providers.py @@ -773,5 +773,14 @@ def get_transcription_provider( ) logger.info(f"Using Parakeet transcription provider in {mode} mode") return ParakeetProvider(parakeet_url) + + elif provider_name == "offline": + # "offline" is an alias for Parakeet ASR + if not parakeet_url: + raise RuntimeError( + "Offline transcription provider requested but PARAKEET_ASR_URL not configured" + ) + logger.info(f"Using offline Parakeet transcription provider in {mode} mode") + return ParakeetProvider(parakeet_url) else: return None diff --git a/backends/advanced/src/advanced_omi_backend/users.py b/backends/advanced/src/advanced_omi_backend/users.py index 14cd2ba1f..b016c68cb 100644 --- a/backends/advanced/src/advanced_omi_backend/users.py +++ b/backends/advanced/src/advanced_omi_backend/users.py @@ -23,6 +23,7 @@ class UserRead(BaseUser[PydanticObjectId]): display_name: Optional[str] = None registered_clients: dict[str, dict] = Field(default_factory=dict) + primary_speakers: list[dict] = Field(default_factory=list) class UserUpdate(BaseUserUpdate): @@ -37,6 +38,8 @@ class User(BeanieBaseUser, Document): display_name: Optional[str] = None # Client tracking for audio devices registered_clients: dict[str, dict] = Field(default_factory=dict) + # Speaker processing filter configuration + primary_speakers: list[dict] = Field(default_factory=list) @property def user_id(self) -> str: diff --git a/backends/advanced/ssl/generate-ssl.sh b/backends/advanced/ssl/generate-ssl.sh new file mode 100755 index 000000000..fb83db5b9 --- /dev/null +++ b/backends/advanced/ssl/generate-ssl.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -e + +# Generate self-signed SSL certificate for Friend-Lite Advanced Backend +# Supports localhost and custom Tailscale IP + +TAILSCALE_IP="$1" + +if [ -z "$TAILSCALE_IP" ]; then + echo "Usage: $0 " + echo "Example: $0 100.83.66.30" + exit 1 +fi + +# Validate IP format +if ! echo "$TAILSCALE_IP" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' > /dev/null; then + echo "Error: Invalid IP format. Expected format: xxx.xxx.xxx.xxx" + exit 1 +fi + +echo "🔐 Generating SSL certificate for localhost and $TAILSCALE_IP" + +# Determine the output directory - we should be in backends/advanced when running +SSL_DIR="ssl" +if [ -d "$SSL_DIR" ]; then + # We're in the parent directory + OUTPUT_DIR="$SSL_DIR" +else + # We're already in the ssl directory + OUTPUT_DIR="." +fi + +# Create certificate configuration with Subject Alternative Names +cat > $OUTPUT_DIR/server.conf << EOF +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +C = US +ST = CA +L = SF +O = Dev +CN = localhost + +[v3_req] +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth, clientAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +DNS.2 = *.localhost +IP.1 = 127.0.0.1 +IP.2 = $TAILSCALE_IP +EOF + +# Generate private key +openssl genrsa -out $OUTPUT_DIR/server.key 2048 + +# Generate certificate signing request +openssl req -new -key $OUTPUT_DIR/server.key -out $OUTPUT_DIR/server.csr -config $OUTPUT_DIR/server.conf + +# Generate self-signed certificate +openssl x509 -req -in $OUTPUT_DIR/server.csr -signkey $OUTPUT_DIR/server.key -out $OUTPUT_DIR/server.crt -days 365 -extensions v3_req -extfile $OUTPUT_DIR/server.conf + +# Clean up +rm $OUTPUT_DIR/server.csr $OUTPUT_DIR/server.conf + +# Set appropriate permissions +chmod 600 $OUTPUT_DIR/server.key +chmod 644 $OUTPUT_DIR/server.crt + +echo "✅ SSL certificate generated successfully" +echo " - Certificate: $OUTPUT_DIR/server.crt" +echo " - Private key: $OUTPUT_DIR/server.key" +echo " - Valid for: localhost, *.localhost, 127.0.0.1, $TAILSCALE_IP" +echo "" +echo "Certificate Details:" +openssl x509 -in $OUTPUT_DIR/server.crt -text -noout | grep -A 1 "Subject Alternative Name" || echo " (Certificate generated successfully)" \ No newline at end of file diff --git a/backends/advanced/webui/src/contexts/AuthContext.tsx b/backends/advanced/webui/src/contexts/AuthContext.tsx index 1811bc341..6515412fb 100644 --- a/backends/advanced/webui/src/contexts/AuthContext.tsx +++ b/backends/advanced/webui/src/contexts/AuthContext.tsx @@ -11,7 +11,7 @@ interface User { interface AuthContextType { user: User | null token: string | null - login: (email: string, password: string) => Promise + login: (email: string, password: string) => Promise<{success: boolean, error?: string, errorType?: string}> logout: () => void isLoading: boolean isAdmin: boolean @@ -58,7 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { initAuth() }, []) - const login = async (email: string, password: string): Promise => { + const login = async (email: string, password: string): Promise<{success: boolean, error?: string, errorType?: string}> => { try { const response = await authApi.login(email, password) @@ -70,10 +70,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { const userResponse = await authApi.getMe() setUser(userResponse.data) - return true - } catch (error) { + return { success: true } + } catch (error: any) { console.error('Login failed:', error) - return false + + // Parse structured error response from backend + let errorMessage = 'Login failed. Please try again.' + let errorType = 'unknown' + + if (error.response?.data) { + const errorData = error.response.data + errorMessage = errorData.detail || errorMessage + errorType = errorData.error_type || errorType + } else if (error.code === 'ERR_NETWORK') { + errorMessage = 'Unable to connect to server. Please check your connection and try again.' + errorType = 'connection_failure' + } + + return { + success: false, + error: errorMessage, + errorType: errorType + } } } diff --git a/backends/advanced/webui/src/pages/LoginPage.tsx b/backends/advanced/webui/src/pages/LoginPage.tsx index 98777717d..717bb61d4 100644 --- a/backends/advanced/webui/src/pages/LoginPage.tsx +++ b/backends/advanced/webui/src/pages/LoginPage.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react' import { Navigate } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' +import { BACKEND_URL } from '../services/api' import { Music, Eye, EyeOff } from 'lucide-react' export default function LoginPage() { @@ -22,9 +23,29 @@ export default function LoginPage() { setIsLoading(true) setError('') - const success = await login(email, password) - if (!success) { - setError('Invalid email or password') + // Pre-flight connection check + try { + const healthUrl = BACKEND_URL ? `${BACKEND_URL}/api/auth/health` : '/api/auth/health' + const healthResponse = await fetch(healthUrl) + if (!healthResponse.ok) { + throw new Error('Health check failed') + } + } catch (healthError) { + setError('Unable to connect to server. Please check your connection and try again.') + setIsLoading(false) + return + } + + const result = await login(email, password) + if (!result.success) { + // Show specific error message based on error type + if (result.errorType === 'connection_failure') { + setError('Unable to connect to server. Please check your connection and try again.') + } else if (result.errorType === 'authentication_failure') { + setError('Invalid email or password') + } else { + setError(result.error || 'Login failed. Please try again.') + } } setIsLoading(false) } diff --git a/backends/advanced/webui/src/pages/Memories.tsx b/backends/advanced/webui/src/pages/Memories.tsx index e197843f8..c99089ccc 100644 --- a/backends/advanced/webui/src/pages/Memories.tsx +++ b/backends/advanced/webui/src/pages/Memories.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react' -import { Brain, Search, RefreshCw, Trash2, Calendar, Tag } from 'lucide-react' -import { memoriesApi } from '../services/api' +import { Brain, Search, RefreshCw, Trash2, Calendar, Tag, X, Target } from 'lucide-react' +import { memoriesApi, systemApi } from '../services/api' import { useAuth } from '../contexts/AuthContext' +import '../styles/slider.css' interface Memory { id: string @@ -22,9 +23,33 @@ export default function Memories() { const [error, setError] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [showUnfiltered, setShowUnfiltered] = useState(false) + const [totalCount, setTotalCount] = useState(null) + + // Semantic search state + const [semanticResults, setSemanticResults] = useState([]) + const [isSemanticFilterActive, setIsSemanticFilterActive] = useState(false) + const [semanticQuery, setSemanticQuery] = useState('') + const [semanticLoading, setSemanticLoading] = useState(false) + const [relevanceThreshold, setRelevanceThreshold] = useState(0) // 0-100 percentage + + // System configuration state + const [memoryProviderSupportsThreshold, setMemoryProviderSupportsThreshold] = useState(false) const { user } = useAuth() + const loadSystemConfig = async () => { + try { + const response = await systemApi.getMetrics() + const supports = response.data.memory_provider_supports_threshold || false + setMemoryProviderSupportsThreshold(supports) + console.log('🔧 Memory provider supports threshold:', supports) + } catch (err: any) { + console.error('❌ Failed to load system config:', err) + // Default to false if we can't determine + setMemoryProviderSupportsThreshold(false) + } + } + const loadMemories = async () => { if (!user?.id) return @@ -38,7 +63,9 @@ export default function Memories() { // Handle the API response structure const memoriesData = response.data.memories || response.data || [] + const totalCount = response.data.total_count console.log('🧠 Processed memories data:', memoriesData) + console.log('🧠 Total count:', totalCount) // Log first few memories to inspect structure if (memoriesData.length > 0) { @@ -47,6 +74,8 @@ export default function Memories() { } setMemories(Array.isArray(memoriesData) ? memoriesData : []) + // Store total count in state for display + setTotalCount(totalCount) setError(null) } catch (err: any) { console.error('❌ Memory loading error:', err) @@ -56,22 +85,85 @@ export default function Memories() { } } + useEffect(() => { + loadSystemConfig() + }, []) + useEffect(() => { loadMemories() }, [user?.id, showUnfiltered]) + // Semantic search handlers + const handleSemanticSearch = async () => { + if (!searchQuery.trim() || !user?.id) return + + try { + setSemanticLoading(true) + + // Use current threshold for server-side filtering if memory provider supports it + const thresholdToUse = memoryProviderSupportsThreshold + ? relevanceThreshold + : undefined + + const response = await memoriesApi.search( + searchQuery.trim(), + user.id, + 50, + thresholdToUse + ) + + console.log('🔍 Search response:', response.data) + console.log('🎯 Used threshold:', thresholdToUse) + + setSemanticResults(response.data.results || []) + setSemanticQuery(searchQuery.trim()) + setIsSemanticFilterActive(true) + setSearchQuery('') // Clear search box + setError(null) + } catch (err: any) { + setError(err.message || 'Semantic search failed') + } finally { + setSemanticLoading(false) + } + } + + const clearSemanticFilter = () => { + setIsSemanticFilterActive(false) + setSemanticResults([]) + setSemanticQuery('') + setSearchQuery('') + setRelevanceThreshold(0) // Reset threshold + } + const handleDeleteMemory = async (memoryId: string) => { if (!confirm('Are you sure you want to delete this memory?')) return try { await memoriesApi.delete(memoryId) + // Remove from both regular memories and semantic results if present setMemories(memories.filter(m => m.id !== memoryId)) + if (isSemanticFilterActive) { + setSemanticResults(semanticResults.filter(m => m.id !== memoryId)) + } } catch (err: any) { setError(err.message || 'Failed to delete memory') } } - const filteredMemories = memories.filter(memory => + // Update filtering logic with client-side threshold filtering after search + const currentMemories = isSemanticFilterActive ? semanticResults : memories + + // Apply relevance threshold filter (client-side for all providers after search) + const thresholdFilteredMemories = isSemanticFilterActive && relevanceThreshold > 0 + ? currentMemories.filter(memory => { + if (!memory.score) return true // If no score, show it + const relevancePercentage = memory.score * 100 + return relevancePercentage >= relevanceThreshold + }) + : currentMemories + + // Apply text search filter + const filteredMemories = thresholdFilteredMemories.filter(memory => memory.memory.toLowerCase().includes(searchQuery.toLowerCase()) || (memory.category?.toLowerCase() || '').includes(searchQuery.toLowerCase()) ) @@ -184,25 +276,130 @@ export default function Memories() { {/* Search */} {memories.length > 0 && ( -
- - setSearchQuery(e.target.value)} - placeholder="Search memories..." - className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search memories..." + className="w-full pl-10 pr-32 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + onKeyPress={(e) => e.key === 'Enter' && handleSemanticSearch()} + /> + +
+ + {/* Initial Search Threshold Slider - Show for Friend-Lite provider */} + {memoryProviderSupportsThreshold && ( +
+
+ +
+ + {relevanceThreshold}% + +
+
+
+ setRelevanceThreshold(Number(e.target.value))} + className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer slider" + style={{ + ['--progress' as any]: `${relevanceThreshold}%`, + background: `linear-gradient(to right, #3B82F6 0%, #3B82F6 ${relevanceThreshold}%, #E5E7EB ${relevanceThreshold}%, #E5E7EB 100%)` + }} + disabled={semanticLoading} + /> + +
+

+ {isSemanticFilterActive ? ( + relevanceThreshold > 0 ? + `Filtering loaded results: showing memories with ≥ ${relevanceThreshold}% relevance` : + 'Showing all loaded results' + ) : ( + relevanceThreshold > 0 ? + `Next search will filter server-side: memories with ≥ ${relevanceThreshold}% relevance` : + 'Next search will return all results (no server-side filtering)' + )} +

+
+ )}
)}
+ {/* Semantic Filter Indicator */} + {isSemanticFilterActive && ( +
+
+
+
+ + + Semantic search active: "{semanticQuery}" + +
+ +
+
+
+ )} + {/* Status Messages */} - {user && memories.length > 0 && ( + {user && (memories.length > 0 || isSemanticFilterActive) && (

- Showing {showUnfiltered ? 'unfiltered' : 'filtered'} memories ({memories.length} found) + {isSemanticFilterActive ? ( + relevanceThreshold > 0 ? ( + searchQuery ? ( + `Showing ${filteredMemories.length} of ${semanticResults.length} semantic matches (filtered by ≥${relevanceThreshold}% relevance + "${searchQuery}")` + ) : ( + `Showing ${thresholdFilteredMemories.length} of ${semanticResults.length} semantic matches (filtered by ≥${relevanceThreshold}% relevance)` + ) + ) : ( + searchQuery ? ( + `Showing ${filteredMemories.length} of ${semanticResults.length} semantic matches (filtered by "${searchQuery}")` + ) : ( + `Showing all ${semanticResults.length} semantic matches for "${semanticQuery}"` + ) + ) + ) : ( + totalCount !== null ? ( + `Showing ${memories.length} of ${totalCount} ${showUnfiltered ? 'unfiltered' : 'filtered'} memories` + ) : ( + `Showing ${showUnfiltered ? 'unfiltered' : 'filtered'} memories (${memories.length} found)` + ) + )}

@@ -228,8 +425,29 @@ export default function Memories() {

- Found {filteredMemories.length} memories - {searchQuery && ` matching "${searchQuery}"`} + {isSemanticFilterActive ? ( + relevanceThreshold > 0 ? ( + searchQuery ? ( + `Relevance filtered (≥${relevanceThreshold}%) + text filtered: ${filteredMemories.length} results` + ) : ( + `Relevance filtered (≥${relevanceThreshold}%): ${thresholdFilteredMemories.length} results` + ) + ) : ( + searchQuery ? ( + `Found ${filteredMemories.length} semantic results matching "${searchQuery}"` + ) : ( + `Found ${filteredMemories.length} semantic matches` + ) + ) + ) : ( + searchQuery ? ( + `Found ${filteredMemories.length} memories matching "${searchQuery}"` + ) : totalCount !== null ? ( + `Showing ${filteredMemories.length} of ${totalCount} memories` + ) : ( + `Found ${filteredMemories.length} memories` + ) + )}

@@ -254,8 +472,14 @@ export default function Memories() {
)} - {memory.score && ( - + {memory.score != null && isSemanticFilterActive && ( +
+ + Relevance: {(memory.score * 100).toFixed(1)}% +
+ )} + {memory.score != null && !isSemanticFilterActive && ( + Score: {memory.score.toFixed(3)} )} @@ -306,11 +530,26 @@ export default function Memories() {

- {searchQuery - ? `No memories found matching "${searchQuery}"` - : `No memories found` - } + {isSemanticFilterActive ? ( + searchQuery ? ( + `No semantic results found matching "${searchQuery}"` + ) : ( + `No semantic matches found for "${semanticQuery}"` + ) + ) : ( + searchQuery + ? `No memories found matching "${searchQuery}"` + : `No memories found` + )}

+ {isSemanticFilterActive && ( + + )}
)} diff --git a/backends/advanced/webui/src/pages/System.tsx b/backends/advanced/webui/src/pages/System.tsx index 97be52722..b399ad5bc 100644 --- a/backends/advanced/webui/src/pages/System.tsx +++ b/backends/advanced/webui/src/pages/System.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import { Settings, RefreshCw, CheckCircle, XCircle, AlertCircle, Activity, Users, Database, Server, Volume2 } from 'lucide-react' -import { systemApi } from '../services/api' +import { Settings, RefreshCw, CheckCircle, XCircle, AlertCircle, Activity, Users, Database, Server, Volume2, Mic } from 'lucide-react' +import { systemApi, speakerApi } from '../services/api' import { useAuth } from '../contexts/AuthContext' interface HealthData { @@ -152,6 +152,19 @@ export default function System() { } } + const getServiceDisplayName = (service: string) => { + const displayNames: Record = { + 'mongodb': 'MONGODB', + 'audioai': 'AUDIOAI', + 'mem0': 'MEM0', + 'memory_service': 'MEMORY SERVICE', + 'speech_to_text': 'SPEECH TO TEXT', + 'speaker_recognition': 'SPEAKER RECOGNITION', + 'openmemory_mcp': 'OPENMEMORY MCP' + } + return displayNames[service] || service.replace('_', ' ').toUpperCase() + } + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString() } @@ -240,14 +253,26 @@ export default function System() {
{getStatusIcon(status.healthy)} - {service.replace('_', ' ').toUpperCase()} + {getServiceDisplayName(service)}
- {status.message && ( - - {status.message} - - )} +
+ {status.message && ( + + {status.message} + + )} + {(status as any).status && ( + + {(status as any).status} + + )} + {(status as any).provider && ( + + ({(status as any).provider}) + + )} +
))} @@ -437,6 +462,9 @@ export default function System() { + {/* Speaker Configuration */} + + {/* Active Clients */}

@@ -516,4 +544,219 @@ export default function System() { )}

) +} + +// Speaker Configuration Component +function SpeakerConfiguration() { + const [speakerServiceStatus, setSpeakerServiceStatus] = useState(null) + const [enrolledSpeakers, setEnrolledSpeakers] = useState([]) + const [primarySpeakers, setPrimarySpeakers] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const { user } = useAuth() + + useEffect(() => { + loadSpeakerData() + }, []) + + const loadSpeakerData = async () => { + setLoading(true) + try { + // Load current configuration and enrolled speakers in parallel + const [configResponse, speakersResponse, statusResponse] = await Promise.allSettled([ + speakerApi.getSpeakerConfiguration(), + speakerApi.getEnrolledSpeakers(), + user?.is_superuser ? speakerApi.getSpeakerServiceStatus() : Promise.resolve({ data: null }) + ]) + + if (configResponse.status === 'fulfilled') { + setPrimarySpeakers(configResponse.value.data.primary_speakers || []) + } + + if (speakersResponse.status === 'fulfilled') { + setEnrolledSpeakers(speakersResponse.value.data.speakers || []) + } + + if (statusResponse.status === 'fulfilled' && statusResponse.value.data) { + setSpeakerServiceStatus(statusResponse.value.data) + } + + } catch (error) { + console.error('Error loading speaker data:', error) + setMessage('Failed to load speaker configuration') + } finally { + setLoading(false) + } + } + + const togglePrimarySpeaker = (speaker: any) => { + const isSelected = primarySpeakers.some(ps => ps.speaker_id === speaker.id) + + if (isSelected) { + setPrimarySpeakers(prev => prev.filter(ps => ps.speaker_id !== speaker.id)) + } else { + setPrimarySpeakers(prev => [...prev, { + speaker_id: speaker.id, + name: speaker.name, + user_id: speaker.user_id + }]) + } + } + + const saveSpeakerConfiguration = async () => { + setSaving(true) + setMessage('') + + try { + await speakerApi.updateSpeakerConfiguration(primarySpeakers) + setMessage(`✅ Saved! ${primarySpeakers.length} primary speakers configured.`) + + // Auto-hide success message after 3 seconds + setTimeout(() => setMessage(''), 3000) + } catch (error: any) { + console.error('Error saving speaker configuration:', error) + setMessage(`❌ Failed to save: ${error.response?.data?.error || error.message}`) + } finally { + setSaving(false) + } + } + + const resetConfiguration = () => { + setPrimarySpeakers([]) + setMessage('Configuration reset. Click Save to apply changes.') + } + + // Don't show the section if speaker service is explicitly disabled or unavailable + const shouldShowSection = speakerServiceStatus !== null || enrolledSpeakers.length > 0 || loading + + if (!shouldShowSection) { + return null + } + + return ( +
+

+ + Speaker Processing Filter + {speakerServiceStatus && ( + + {speakerServiceStatus.healthy ? 'Service Available' : 'Service Unavailable'} + + )} +

+ +

+ Select primary speakers for memory processing. Only conversations where these speakers are detected will have memories extracted. + Leave empty to process all conversations. +

+ + {/* Service Status Info */} + {speakerServiceStatus && !speakerServiceStatus.healthy && ( +
+
+ +
+

Speaker Service Unavailable

+

+ {speakerServiceStatus.message}. Speaker filtering will be disabled until service is available. +

+
+
+
+ )} + + {/* Loading State */} + {loading && ( +
+ + Loading speaker data... +
+ )} + + {/* No Speakers Available */} + {!loading && enrolledSpeakers.length === 0 && ( +
+ +

+ No enrolled speakers found. Enroll speakers in the speaker recognition service to configure primary users. +

+
+ )} + + {/* Speaker Selection */} + {!loading && enrolledSpeakers.length > 0 && ( +
+ {/* Current Configuration */} +
+ + Primary speakers selected: {primarySpeakers.length} + + +
+ + {/* Speaker List */} +
+ {enrolledSpeakers.map((speaker) => { + const isSelected = primarySpeakers.some(ps => ps.speaker_id === speaker.id) + return ( +
togglePrimarySpeaker(speaker)} + > +
+
+ {isSelected && } +
+
+
{speaker.name}
+
+ {speaker.audio_sample_count || 0} samples +
+
+
+
+ ) + })} +
+ + {/* Save Button */} +
+
+ {message && ( +

+ {message} +

+ )} +
+ +
+
+ )} +
+ ) } \ No newline at end of file diff --git a/backends/advanced/webui/src/services/api.ts b/backends/advanced/webui/src/services/api.ts index 42e7d420d..19f778d0c 100644 --- a/backends/advanced/webui/src/services/api.ts +++ b/backends/advanced/webui/src/services/api.ts @@ -79,6 +79,15 @@ export const conversationsApi = { export const memoriesApi = { getAll: (userId?: string) => api.get('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/api/memories', { params: userId ? { user_id: userId } : {} }), getUnfiltered: (userId?: string) => api.get('/api/memories/unfiltered', { params: userId ? { user_id: userId } : {} }), + search: (query: string, userId?: string, limit: number = 20, scoreThreshold?: number) => + api.get('/api/memories/search', { + params: { + query, + ...(userId && { user_id: userId }), + limit, + ...(scoreThreshold !== undefined && { score_threshold: scoreThreshold / 100 }) // Convert percentage to decimal + } + }), delete: (id: string) => api.delete(`/api/memories/${id}`), } @@ -150,4 +159,19 @@ export const chatApi = { body: JSON.stringify(requestBody) }) } +} + +export const speakerApi = { + // Get current user's speaker configuration + getSpeakerConfiguration: () => api.get('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/api/speaker-configuration'), + + // Update current user's speaker configuration + updateSpeakerConfiguration: (primarySpeakers: Array<{speaker_id: string, name: string, user_id: number}>) => + api.post('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/api/speaker-configuration', primarySpeakers), + + // Get enrolled speakers from speaker recognition service + getEnrolledSpeakers: () => api.get('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/api/enrolled-speakers'), + + // Check speaker service status (admin only) + getSpeakerServiceStatus: () => api.get('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/api/speaker-service-status'), } \ No newline at end of file diff --git a/backends/advanced/webui/src/styles/slider.css b/backends/advanced/webui/src/styles/slider.css new file mode 100644 index 000000000..0fca88d99 --- /dev/null +++ b/backends/advanced/webui/src/styles/slider.css @@ -0,0 +1,78 @@ +/* Custom slider styles for relevance threshold */ +input[type="range"].slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 8px; + border-radius: 5px; + outline: none; + opacity: 0.9; + transition: opacity 0.2s; +} + +input[type="range"].slider:hover { + opacity: 1; +} + +/* Webkit browsers (Chrome, Safari, etc.) */ +input[type="range"].slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: #3B82F6; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: transform 0.2s, box-shadow 0.2s; +} + +input[type="range"].slider::-webkit-slider-thumb:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.5); +} + +/* Firefox */ +input[type="range"].slider::-moz-range-thumb { + width: 20px; + height: 20px; + border: none; + border-radius: 50%; + background: #3B82F6; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: transform 0.2s, box-shadow 0.2s; +} + +input[type="range"].slider::-moz-range-thumb:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.5); +} + +/* Track styling for gradient effect */ +input[type="range"].slider::-webkit-slider-runnable-track { + width: 100%; + height: 8px; + cursor: pointer; + border-radius: 5px; +} + +input[type="range"].slider::-moz-range-track { + width: 100%; + height: 8px; + cursor: pointer; + border-radius: 5px; +} + +/* Dark mode adjustments */ +.dark input[type="range"].slider { + background: linear-gradient(to right, #3B82F6 var(--progress), #374151 var(--progress)); +} + +.dark input[type="range"].slider::-webkit-slider-thumb { + background: #60A5FA; +} + +.dark input[type="range"].slider::-moz-range-thumb { + background: #60A5FA; +} \ No newline at end of file diff --git a/extras/asr-services/.env.template b/extras/asr-services/.env.template new file mode 100644 index 000000000..f68d87e66 --- /dev/null +++ b/extras/asr-services/.env.template @@ -0,0 +1,9 @@ +# ASR Services Configuration +# Copy this file to .env and configure as needed + +# Parakeet ASR Model Selection +PARAKEET_MODEL=nvidia/parakeet-tdt-0.6b-v3 + +# Service Port Configuration +PARAKEET_HOST_PORT=8767 +PARAKEET_CONTAINER_PORT=8765 \ No newline at end of file diff --git a/extras/asr-services/docker-compose.yml b/extras/asr-services/docker-compose.yml index 9e6c63c72..5c90357b0 100644 --- a/extras/asr-services/docker-compose.yml +++ b/extras/asr-services/docker-compose.yml @@ -5,7 +5,7 @@ services: dockerfile: Dockerfile_Parakeet image: parakeet-asr:latest ports: - - "8080:8765" # Host port 8080 -> Container port 8765 + - "${PARAKEET_HOST_PORT:-8767}:${PARAKEET_CONTAINER_PORT:-8765}" volumes: # Persist Hugging Face cache (models + tokenizer) between restarts - ./model_cache:/models diff --git a/extras/asr-services/setup.sh b/extras/asr-services/setup.sh new file mode 100755 index 000000000..cbf7fc08c --- /dev/null +++ b/extras/asr-services/setup.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +echo "🎤 Offline ASR Setup" +echo "===================" + +# Create .env file if it doesn't exist +if [ ! -f ".env" ]; then + echo "📄 Creating .env file from template..." + cp .env.template .env + echo "✅ .env file created" +else + echo "ℹ️ .env file already exists, using existing configuration" +fi + +echo "✅ Parakeet ASR configured!" +echo "📁 Configuration saved to .env" +echo "" +echo "🚀 To start: docker compose up --build -d parakeet-asr" +echo " 📝 Service will be available at: http://host.docker.internal:8767" +echo "" +echo "💡 Only start if you want to use offline ASR instead of cloud providers" \ No newline at end of file diff --git a/extras/openmemory-mcp/docker-compose.yml b/extras/openmemory-mcp/docker-compose.yml index 70ec30766..4107cf4a1 100644 --- a/extras/openmemory-mcp/docker-compose.yml +++ b/extras/openmemory-mcp/docker-compose.yml @@ -5,7 +5,7 @@ services: ports: - "6335:6333" # Different port to avoid conflict with main Qdrant volumes: - - mem0_storage:/qdrant/storage + - ./data/mem0_storage:/qdrant/storage restart: unless-stopped # OpenMemory MCP Server (built from local cache) @@ -41,7 +41,4 @@ services: depends_on: - openmemory-mcp profiles: - - ui # Only starts when --profile ui is used - -volumes: - mem0_storage: \ No newline at end of file + - ui # Only starts when --profile ui is used \ No newline at end of file diff --git a/extras/openmemory-mcp/setup.sh b/extras/openmemory-mcp/setup.sh new file mode 100755 index 000000000..38edd3631 --- /dev/null +++ b/extras/openmemory-mcp/setup.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Enable strict error handling +set -euo pipefail + +echo "🧠 OpenMemory MCP Setup" +echo "======================" + +# Check if already configured +if [ -f ".env" ]; then + echo "⚠️ .env already exists. Backing up..." + cp .env .env.backup.$(date +%Y%m%d_%H%M%S) +fi + +# Start from template - check existence first +if [ ! -r ".env.template" ]; then + echo "Error: .env.template not found or not readable" >&2 + exit 1 +fi + +# Copy template and set secure permissions +if ! cp .env.template .env; then + echo "Error: Failed to copy .env.template to .env" >&2 + exit 1 +fi + +# Set restrictive permissions (owner read/write only) +chmod 600 .env + +# Clone the custom fork of mem0 with OpenMemory fixes +echo "" +echo "📦 Setting up custom mem0 fork with OpenMemory..." +if [ -d "cache/mem0" ]; then + echo " Removing existing mem0 directory..." + rm -rf cache/mem0 +fi + +echo " Cloning mem0 fork from AnkushMalaker/mem0..." +mkdir -p cache +git clone https://github.com/AnkushMalaker/mem0.git cache/mem0 +cd cache/mem0 +echo " Checking out fix/get-endpoint branch..." +git checkout fix/get-endpoint +cd ../.. + +echo "✅ Custom mem0 fork ready with OpenMemory improvements" + +# Prompt for OpenAI API key with validation +echo "" +echo "🔑 OpenAI API Key (required for memory extraction)" +echo "Get yours from: https://platform.openai.com/api-keys" +while true; do + read -s -r -p "OpenAI API Key: " OPENAI_API_KEY + echo # Print newline after silent input + if [ -n "$OPENAI_API_KEY" ]; then + break + fi + echo "Error: OpenAI API Key cannot be empty. Please try again." +done + +# Update .env file safely using awk - replace existing line or append if missing +temp_file=$(mktemp) +awk -v key="$OPENAI_API_KEY" ' + /^OPENAI_API_KEY=/ { print "OPENAI_API_KEY=" key; found=1; next } + { print } + END { if (!found) print "OPENAI_API_KEY=" key } +' .env > "$temp_file" +mv "$temp_file" .env + +echo "" +echo "✅ OpenMemory MCP configured!" +echo "📁 Configuration saved to .env" +echo "" +echo "🚀 To start: docker compose up --build -d" +echo "🌐 MCP Server: http://localhost:8765" +echo "📱 Web Interface: http://localhost:8765" +echo "🔧 UI (optional): docker compose --profile ui up -d" +echo "" +echo "💡 Note: Using custom mem0 fork from AnkushMalaker/mem0:fix/get-endpoint" \ No newline at end of file diff --git a/extras/speaker-recognition/.env.template b/extras/speaker-recognition/.env.template index 71ec5cc26..ed93c9f35 100644 --- a/extras/speaker-recognition/.env.template +++ b/extras/speaker-recognition/.env.template @@ -25,7 +25,7 @@ SPEAKER_SERVICE_URL=http://speaker-service:8085 # React Web UI Configuration REACT_UI_HOST=0.0.0.0 -REACT_UI_PORT=5173 +REACT_UI_PORT=5174 REACT_UI_HTTPS=false # Optional: External Services diff --git a/extras/speaker-recognition/scripts/enroll_speaker.py b/extras/speaker-recognition/scripts/enroll_speaker.py index c18314e33..f73e4634f 100755 --- a/extras/speaker-recognition/scripts/enroll_speaker.py +++ b/extras/speaker-recognition/scripts/enroll_speaker.py @@ -193,7 +193,7 @@ def download_youtube_audio(url: str, start: Optional[float] = None, end: Optiona '-c', 'copy', segment_path, '-y' ] - subprocess.run(cmd, capture_output=True) + subprocess.run(cmd, capture_output=True, check=True, timeout=60) os.unlink(output_path) return segment_path diff --git a/extras/speaker-recognition/setup.sh b/extras/speaker-recognition/setup.sh new file mode 100755 index 000000000..7c2554352 --- /dev/null +++ b/extras/speaker-recognition/setup.sh @@ -0,0 +1,165 @@ +#!/bin/bash + +# Enable strict error handling +set -euo pipefail + +# Error trap for better diagnostics +trap 'echo "Error on line $LINENO"; exit 1' ERR + +# Parse command line arguments +ENABLE_HTTPS=false +SERVER_IP="" + +while [[ $# -gt 0 ]]; do + case $1 in + --enable-https) + ENABLE_HTTPS=true + shift + ;; + --server-ip) + SERVER_IP="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" + exit 1 + ;; + esac +done + +echo "🗣️ Speaker Recognition Setup" +echo "==============================" + +# Check if already configured +if [ -f ".env" ]; then + echo "⚠️ .env already exists. Backing up..." + cp .env ".env.backup.$(date +%Y%m%d_%H%M%S)" +fi + +# Start from template - check existence first +if [ ! -r ".env.template" ]; then + echo "Error: .env.template not found or not readable" >&2 + exit 1 +fi + +# Copy template and set secure permissions +if ! cp .env.template .env; then + echo "Error: Failed to copy .env.template to .env" >&2 + exit 1 +fi + +# Set restrictive permissions (owner read/write only) +chmod 600 .env + +# Prompt for required settings +echo "" +echo "🔑 Hugging Face Token (required for pyannote models)" +echo "Get yours from: https://huggingface.co/settings/tokens" +read -s -r -p "HF Token: " HF_TOKEN +echo # Print newline after silent input + +# Validate token is not empty +if [ -z "$HF_TOKEN" ]; then + echo "Error: HF token is required" >&2 + exit 1 +fi + +echo "" +echo "🖥️ Compute Mode" +echo " 1) CPU-only (works everywhere)" +echo " 2) GPU acceleration (requires NVIDIA+CUDA)" +read -p "Enter choice [1-2] (1): " COMPUTE_CHOICE +COMPUTE_CHOICE=${COMPUTE_CHOICE:-1} + +if [ "$COMPUTE_CHOICE" = "2" ]; then + COMPUTE_MODE="gpu" +else + COMPUTE_MODE="cpu" +fi + +# HTTPS Configuration +if [ "$ENABLE_HTTPS" = true ]; then + # Use command line arguments + HTTPS_MODE="https" + if [ -z "$SERVER_IP" ]; then + SERVER_IP="localhost" + fi + echo "✅ HTTPS configured via command line: $SERVER_IP" +else + # Interactive configuration + echo "" + echo "🔒 HTTPS Configuration (required for microphone access)" + echo " 1) HTTP mode (development, localhost only)" + echo " 2) HTTPS mode with SSL (production, remote access, microphone access)" + read -p "Enter choice [1-2] (1): " HTTPS_CHOICE + HTTPS_CHOICE=${HTTPS_CHOICE:-1} + + if [ "$HTTPS_CHOICE" = "2" ]; then + HTTPS_MODE="https" + else + HTTPS_MODE="http" + fi +fi + +# Update .env file +sed -i "s|HF_TOKEN=.*|HF_TOKEN=$HF_TOKEN|" .env +sed -i "s|COMPUTE_MODE=.*|COMPUTE_MODE=$COMPUTE_MODE|" .env + +if [ "$HTTPS_MODE" = "https" ]; then + # Get SERVER_IP if not already set (from command line) + if [ -z "$SERVER_IP" ]; then + echo "" + echo "🌐 Server Configuration for HTTPS" + echo "Enter your server IP/domain for SSL certificate" + echo "Examples: localhost, 192.168.1.100, your-domain.com" + read -p "Server IP/Domain (localhost): " SERVER_IP + SERVER_IP=${SERVER_IP:-localhost} + fi + + # Update .env for HTTPS mode + sed -i "s|REACT_UI_HTTPS=.*|REACT_UI_HTTPS=true|" .env + sed -i "s|REACT_UI_PORT=.*|REACT_UI_PORT=5175|" .env + + # Generate SSL certificates + echo "" + echo "📄 Generating SSL certificates..." + if [ -f "ssl/generate-ssl.sh" ]; then + ./ssl/generate-ssl.sh "$SERVER_IP" + echo "✅ SSL certificates generated" + else + echo "⚠️ ssl/generate-ssl.sh not found, SSL setup skipped" + fi + + # Create nginx.conf from template + echo "📄 Creating nginx configuration..." + if [ -f "nginx.conf.template" ]; then + sed "s/TAILSCALE_IP/$SERVER_IP/g" nginx.conf.template > nginx.conf + echo "✅ nginx.conf created for: $SERVER_IP" + else + echo "⚠️ nginx.conf.template not found, nginx config skipped" + fi + + echo "" + echo "✅ Speaker Recognition configured (HTTPS mode)!" + echo "📁 Configuration saved to .env" + echo "🔒 HTTPS configured for: $SERVER_IP" + echo "" + echo "🚀 To start: docker compose up --build -d" + echo "🌐 HTTPS Access: https://localhost:8444/" + echo "🌐 HTTP Redirect: http://localhost:8081/ → HTTPS" + echo "📱 Service API: https://localhost:8444/api/" + echo "💡 Accept SSL certificate in browser" +else + # HTTP Configuration + sed -i "s|REACT_UI_HTTPS=.*|REACT_UI_HTTPS=false|" .env + sed -i "s|REACT_UI_PORT=.*|REACT_UI_PORT=5174|" .env + + echo "" + echo "✅ Speaker Recognition configured (HTTP mode)!" + echo "📁 Configuration saved to .env" + echo "" + echo "🚀 To start: docker compose up --build -d speaker-service web-ui" + echo "📱 Service API: http://localhost:8085" + echo "📱 Web Interface: http://localhost:5174" + echo "⚠️ Note: Microphone access may not work over HTTP on remote connections" +fi \ No newline at end of file diff --git a/extras/speaker-recognition/webui/src/pages/Enrollment.tsx b/extras/speaker-recognition/webui/src/pages/Enrollment.tsx index b0d951272..df732c559 100644 --- a/extras/speaker-recognition/webui/src/pages/Enrollment.tsx +++ b/extras/speaker-recognition/webui/src/pages/Enrollment.tsx @@ -90,12 +90,13 @@ export default function Enrollment() { const calculateSessionQuality = useCallback((audioFiles: EnrollmentAudio[]): 'excellent' | 'good' | 'fair' | 'poor' => { if (audioFiles.length === 0) return 'poor' - const totalDuration = audioFiles.reduce((sum, audio) => sum + audio.duration, 0) + const totalDurationMs = audioFiles.reduce((sum, audio) => sum + audio.duration, 0) + const totalDurationSeconds = totalDurationMs / 1000 // Convert to seconds for quality thresholds const avgSNR = audioFiles.reduce((sum, audio) => sum + audio.snr, 0) / audioFiles.length - if (totalDuration >= 60 && avgSNR >= 30 && audioFiles.length >= 5) return 'excellent' - if (totalDuration >= 30 && avgSNR >= 20 && audioFiles.length >= 3) return 'good' - if (totalDuration >= 15 && avgSNR >= 15 && audioFiles.length >= 2) return 'fair' + if (totalDurationSeconds >= 60 && avgSNR >= 30 && audioFiles.length >= 5) return 'excellent' + if (totalDurationSeconds >= 30 && avgSNR >= 20 && audioFiles.length >= 3) return 'good' + if (totalDurationSeconds >= 15 && avgSNR >= 15 && audioFiles.length >= 2) return 'fair' return 'poor' }, []) @@ -113,7 +114,15 @@ export default function Enrollment() { })) if (currentSession?.id === sessionId) { - setCurrentSession(prev => prev ? { ...prev, ...updates } : null) + setCurrentSession(prev => { + if (!prev) return null + const updatedSession = { ...prev, ...updates } + if (updates.audioFiles) { + updatedSession.totalDuration = updates.audioFiles.reduce((sum, audio) => sum + audio.duration, 0) + updatedSession.quality = calculateSessionQuality(updates.audioFiles) + } + return updatedSession + }) } }, [currentSession, calculateSessionQuality]) @@ -175,9 +184,9 @@ export default function Enrollment() { setIsRecording(true) setRecordingTime(0) - // Start timer immediately + // Start timer immediately (track milliseconds for formatDuration compatibility) recordingIntervalRef.current = setInterval(() => { - setRecordingTime(prev => prev + 1) + setRecordingTime(prev => prev + 1000) }, 1000) mediaRecorder.start(250) // Increased interval for better stability @@ -274,7 +283,7 @@ export default function Enrollment() { id: Math.random().toString(36), name: `Recording ${new Date().toLocaleTimeString()}`, blob: processedBlob, // Use the converted blob for backend submission - duration: audioBuffer.duration, + duration: audioBuffer.duration * 1000, // Convert seconds to milliseconds snr, quality, source: 'recording' @@ -325,7 +334,7 @@ export default function Enrollment() { id: Math.random().toString(36), name: file.name, blob: file, - duration: audioBuffer.duration, + duration: audioBuffer.duration * 1000, // Convert seconds to milliseconds snr, quality, source: 'upload' diff --git a/init.py b/init.py new file mode 100755 index 000000000..0bd22f561 --- /dev/null +++ b/init.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Friend-Lite Root Setup Orchestrator +Handles service selection and delegation only - no configuration duplication +""" + +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +from rich import print as rprint +from rich.console import Console +from rich.prompt import Confirm + +console = Console() + +SERVICES = { + 'backend': { + 'advanced': { + 'path': 'backends/advanced', + 'cmd': ['uv', 'run', '--with-requirements', 'setup-requirements.txt', 'python', 'init.py'], + 'description': 'Advanced AI backend with full feature set', + 'required': True + } + }, + 'extras': { + 'speaker-recognition': { + 'path': 'extras/speaker-recognition', + 'cmd': ['./setup.sh'], + 'description': 'Speaker identification and enrollment' + }, + 'asr-services': { + 'path': 'extras/asr-services', + 'cmd': ['./setup.sh'], + 'description': 'Offline speech-to-text (Parakeet)' + }, + 'openmemory-mcp': { + 'path': 'extras/openmemory-mcp', + 'cmd': ['./setup.sh'], + 'description': 'OpenMemory MCP server' + } + } +} + +def check_service_exists(service_name, service_config): + """Check if service directory and script exist""" + service_path = Path(service_config['path']) + if not service_path.exists(): + return False, f"Directory {service_path} does not exist" + + # For advanced backend, check if init.py exists + if service_name == 'advanced': + script_path = service_path / 'init.py' + if not script_path.exists(): + return False, f"Script {script_path} does not exist" + else: + # For extras, check if setup.sh exists + script_path = service_path / 'setup.sh' + if not script_path.exists(): + return False, f"Script {script_path} does not exist (will be created in Phase 2)" + + return True, "OK" + +def select_services(): + """Let user select which services to setup""" + console.print("🚀 [bold cyan]Friend-Lite Service Setup[/bold cyan]") + console.print("Select which services to configure:\n") + + selected = [] + + # Backend is required + console.print("📱 [bold]Backend (Required):[/bold]") + console.print(" ✅ Advanced Backend - Full AI features") + selected.append('advanced') + + # Optional extras + console.print("\n🔧 [bold]Optional Services:[/bold]") + for service_name, service_config in SERVICES['extras'].items(): + # Check if service exists + exists, msg = check_service_exists(service_name, service_config) + if not exists: + console.print(f" ⏸️ {service_config['description']} - [dim]{msg}[/dim]") + continue + + try: + enable_service = Confirm.ask(f" Setup {service_config['description']}?", default=False) + except EOFError: + console.print("Using default: No") + enable_service = False + + if enable_service: + selected.append(service_name) + + return selected + +def cleanup_unselected_services(selected_services): + """Backup and remove .env files from services that weren't selected""" + + all_services = list(SERVICES['backend'].keys()) + list(SERVICES['extras'].keys()) + + for service_name in all_services: + if service_name not in selected_services: + if service_name == 'advanced': + service_path = Path(SERVICES['backend'][service_name]['path']) + else: + service_path = Path(SERVICES['extras'][service_name]['path']) + + env_file = service_path / '.env' + if env_file.exists(): + # Create backup with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_file = service_path / f'.env.backup.{timestamp}.unselected' + env_file.rename(backup_file) + console.print(f"🧹 [dim]Backed up {service_name} configuration to {backup_file.name} (service not selected)[/dim]") + +def run_service_setup(service_name, selected_services, https_enabled=False, server_ip=None): + """Execute individual service setup script""" + if service_name == 'advanced': + service = SERVICES['backend'][service_name] + + # For advanced backend, pass URLs of other selected services and HTTPS config + cmd = service['cmd'].copy() + if 'speaker-recognition' in selected_services: + cmd.extend(['--speaker-service-url', 'http://host.docker.internal:8085']) + if 'asr-services' in selected_services: + cmd.extend(['--parakeet-asr-url', 'http://host.docker.internal:8767']) + + # Add HTTPS configuration + if https_enabled and server_ip: + cmd.extend(['--enable-https', '--server-ip', server_ip]) + + else: + service = SERVICES['extras'][service_name] + cmd = service['cmd'].copy() + + # Add HTTPS configuration for services that support it + if service_name == 'speaker-recognition' and https_enabled and server_ip: + cmd.extend(['--enable-https', '--server-ip', server_ip]) + + console.print(f"\n🔧 [bold]Setting up {service_name}...[/bold]") + + # Check if service exists before running + exists, msg = check_service_exists(service_name, service) + if not exists: + console.print(f"❌ {service_name} setup failed: {msg}") + return False + + try: + result = subprocess.run( + cmd, + cwd=service['path'], + check=True, + capture_output=True, + text=True, + timeout=300 # 5 minute timeout for service setup + ) + + console.print(f"✅ {service_name} setup completed") + return True + + except FileNotFoundError as e: + console.print(f"❌ {service_name} setup failed: {e}") + return False + except subprocess.TimeoutExpired as e: + console.print(f"❌ {service_name} setup timed out after {e.timeout} seconds") + if e.stdout: + console.print(f" stdout: {e.stdout.decode()}") + if e.stderr: + console.print(f" stderr: {e.stderr.decode()}") + return False + except subprocess.CalledProcessError as e: + console.print(f"❌ {service_name} setup failed with exit code {e.returncode}") + if e.stdout: + console.print(f" stdout: {e.stdout.decode()}") + if e.stderr: + console.print(f" stderr: {e.stderr.decode()}") + return False + except Exception as e: + console.print(f"❌ {service_name} setup failed: {e}") + return False + +def show_service_status(): + """Show which services are available""" + console.print("\n📋 [bold]Service Status:[/bold]") + + # Check backend + exists, msg = check_service_exists('advanced', SERVICES['backend']['advanced']) + status = "✅" if exists else "❌" + console.print(f" {status} Advanced Backend - {msg}") + + # Check extras + for service_name, service_config in SERVICES['extras'].items(): + exists, msg = check_service_exists(service_name, service_config) + status = "✅" if exists else "⏸️" + console.print(f" {status} {service_config['description']} - {msg}") + +def main(): + """Main orchestration logic""" + console.print("🎉 [bold green]Welcome to Friend-Lite![/bold green]\n") + + # Show what's available + show_service_status() + + # Service Selection + selected_services = select_services() + + if not selected_services: + console.print("\n[yellow]No services selected. Exiting.[/yellow]") + return + + # HTTPS Configuration (for services that need it) + https_enabled = False + server_ip = None + + # Check if we have services that benefit from HTTPS + https_services = {'advanced', 'speaker-recognition'} + needs_https = bool(https_services.intersection(selected_services)) + + if needs_https: + console.print("\n🔒 [bold cyan]HTTPS Configuration[/bold cyan]") + console.print("HTTPS enables microphone access in browsers and secure connections") + + try: + https_enabled = Confirm.ask("Enable HTTPS for selected services?", default=False) + except EOFError: + console.print("Using default: No") + https_enabled = False + + if https_enabled: + console.print("\n[blue][INFO][/blue] For distributed deployments, use your Tailscale IP") + console.print("[blue][INFO][/blue] For local-only access, use 'localhost'") + console.print("Examples: localhost, 100.64.1.2, your-domain.com") + + while True: + try: + server_ip = console.input("Server IP/Domain for SSL certificates [localhost]: ").strip() + if not server_ip: + server_ip = "localhost" + break + except EOFError: + server_ip = "localhost" + break + + console.print(f"[green]✅[/green] HTTPS configured for: {server_ip}") + + # Pure Delegation - Run Each Service Setup + console.print(f"\n📋 [bold]Setting up {len(selected_services)} services...[/bold]") + + # Clean up .env files from unselected services (creates backups) + cleanup_unselected_services(selected_services) + + success_count = 0 + failed_services = [] + + for service in selected_services: + if run_service_setup(service, selected_services, https_enabled, server_ip): + success_count += 1 + else: + failed_services.append(service) + + # Final Summary + console.print(f"\n🎊 [bold green]Setup Complete![/bold green]") + console.print(f"✅ {success_count}/{len(selected_services)} services configured successfully") + + if failed_services: + console.print(f"❌ Failed services: {', '.join(failed_services)}") + + # Next Steps + console.print("\n📖 [bold]Next Steps:[/bold]") + + # Service Management Commands + console.print("1. Start all configured services:") + console.print(" [cyan]uv run --with-requirements setup-requirements.txt python services.py start --all --build[/cyan]") + console.print("") + console.print("2. Or start individual services:") + + configured_services = [] + if 'advanced' in selected_services and 'advanced' not in failed_services: + configured_services.append("backend") + if 'speaker-recognition' in selected_services and 'speaker-recognition' not in failed_services: + configured_services.append("speaker-recognition") + if 'asr-services' in selected_services and 'asr-services' not in failed_services: + configured_services.append("asr-services") + if 'openmemory-mcp' in selected_services and 'openmemory-mcp' not in failed_services: + configured_services.append("openmemory-mcp") + + if configured_services: + service_list = " ".join(configured_services) + console.print(f" [cyan]uv run --with-requirements setup-requirements.txt python services.py start {service_list}[/cyan]") + + console.print("") + console.print("3. Check service status:") + console.print(" [cyan]uv run --with-requirements setup-requirements.txt python services.py status[/cyan]") + + console.print("") + console.print("4. Stop services when done:") + console.print(" [cyan]uv run --with-requirements setup-requirements.txt python services.py stop --all[/cyan]") + + console.print(f"\n🚀 [bold]Enjoy Friend-Lite![/bold]") + + # Show individual service usage + console.print(f"\n💡 [dim]Tip: You can also setup services individually:[/dim]") + console.print(f"[dim] cd backends/advanced && uv run --with-requirements setup-requirements.txt python init.py[/dim]") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/init.sh b/init.sh deleted file mode 100644 index 966dbc08a..000000000 --- a/init.sh +++ /dev/null @@ -1,419 +0,0 @@ -#!/bin/bash - -# Friend-Lite Interactive Setup Script -# This script helps you configure Friend-Lite by asking a few key questions -# and setting up the environment files and Docker services. - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -PURPLE='\033[0;35m' -NC='\033[0m' # No Color - -print_header() { - echo -e "${BLUE}================================================================${NC}" - echo -e "${BLUE}$1${NC}" - echo -e "${BLUE}================================================================${NC}" - echo -} - -print_info() { - echo -e "${BLUE}ℹ️ $1${NC}" -} - -print_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -print_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -print_error() { - echo -e "${RED}❌ $1${NC}" -} - -# Generate secure random string -generate_secret() { - openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | base64 | tr -d "=+/" | cut -c1-64 -} - -# Validate email format -validate_email() { - if [[ $1 =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then - return 0 - else - return 1 - fi -} - -# Validate URL format -validate_url() { - if [[ $1 =~ ^https?:// ]]; then - return 0 - else - return 1 - fi -} - -# Welcome message -clear -print_header "🎉 Welcome to Friend-Lite Setup!" - -cat << 'EOF' -This script will help you configure Friend-Lite, an AI-powered personal system -that captures audio from OMI-compatible devices and extracts: - -📝 Memories and conversations -🎯 Action items and tasks -🏠 Home automation triggers -🧠 Personal insights and patterns - -Let's get you set up with a few quick questions... -EOF - -echo -read -p "Press Enter to continue..." -echo - -# Backend selection -print_header "🖥️ Backend Selection" -echo "Choose your backend:" -echo "1. Advanced Backend (Recommended) - Full features with memory extraction" -echo "2. Simple Backend - Basic audio processing only" -echo - -while true; do - read -p "Select backend [1-2] (default: 1): " backend_choice - backend_choice=${backend_choice:-1} - - case $backend_choice in - 1) - BACKEND_DIR="backends/advanced" - BACKEND_NAME="Advanced Backend" - break - ;; - 2) - BACKEND_DIR="backends/simple-backend" - BACKEND_NAME="Simple Backend" - print_warning "Simple backend has limited features (no memory extraction)" - break - ;; - *) - print_error "Please select 1 or 2" - ;; - esac -done - -print_success "Selected: $BACKEND_NAME" -echo - -# LLM Provider Configuration -print_header "🤖 AI Language Model Configuration" -echo "Choose your LLM provider:" -echo "1. OpenAI (Recommended) - GPT-4o models, reliable API" -echo "2. Ollama - Local/self-hosted models, privacy-focused" -echo - -while true; do - read -p "Select LLM provider [1-2] (default: 1): " llm_choice - llm_choice=${llm_choice:-1} - - case $llm_choice in - 1) - LLM_PROVIDER="openai" - break - ;; - 2) - LLM_PROVIDER="ollama" - break - ;; - *) - print_error "Please select 1 or 2" - ;; - esac -done - -# Configure chosen LLM provider -if [[ $LLM_PROVIDER == "openai" ]]; then - print_info "OpenAI Configuration (sets LLM_PROVIDER=openai)" - echo - while true; do - read -p "Enter your OpenAI API key (sets OPENAI_API_KEY): " openai_key - if [[ ${#openai_key} -ge 20 && $openai_key == sk-* ]]; then - OPENAI_API_KEY="$openai_key" - break - else - print_error "Please enter a valid OpenAI API key (starts with 'sk-')" - fi - done - - read -p "OpenAI model (sets OPENAI_MODEL) [gpt-4o-mini]: " openai_model - OPENAI_MODEL=${openai_model:-gpt-4o-mini} - - OPENAI_BASE_URL="https://api.openai.com/v1" -else - print_info "Ollama Configuration (sets LLM_PROVIDER=ollama)" - echo - while true; do - read -p "Enter Ollama base URL (sets OPENAI_BASE_URL): " ollama_url - if validate_url "$ollama_url"; then - OPENAI_BASE_URL="$ollama_url" - break - else - print_error "Please enter a valid URL (e.g., http://localhost:11434/v1)" - fi - done - - read -p "Ollama model (sets OPENAI_MODEL) [llama3.1:latest]: " ollama_model - OPENAI_MODEL=${ollama_model:-llama3.1:latest} - - # Ollama uses dummy API key - OPENAI_API_KEY="dummy" -fi - -print_success "LLM provider configured: $LLM_PROVIDER with model $OPENAI_MODEL" -echo - -# Speech-to-Text Configuration -print_header "🎤 Speech-to-Text Configuration" -echo "Choose your transcription provider:" -echo "1. Deepgram API (Recommended) - High accuracy, cloud-based" -echo "2. Custom ASR Service - Self-hosted transcription" -echo "3. Skip - Configure later" -echo - -while true; do - read -p "Select transcription [1-3] (default: 1): " asr_choice - asr_choice=${asr_choice:-1} - - case $asr_choice in - 1) - TRANSCRIPTION_PROVIDER="deepgram" - while true; do - read -p "Enter Deepgram API key (sets DEEPGRAM_API_KEY): " deepgram_key - if [[ ${#deepgram_key} -ge 20 ]]; then - DEEPGRAM_API_KEY="$deepgram_key" - break - else - print_error "Please enter a valid Deepgram API key" - fi - done - break - ;; - 2) - TRANSCRIPTION_PROVIDER="parakeet" - read -p "Enter ASR service URL (sets PARAKEET_ASR_URL) [http://localhost:8767]: " asr_url - PARAKEET_ASR_URL=${asr_url:-http://localhost:8767} - break - ;; - 3) - print_warning "Transcription provider will need to be configured later" - break - ;; - *) - print_error "Please select 1, 2, or 3" - ;; - esac -done - -echo - -# Admin Configuration -print_header "👤 Admin Account Configuration" -while true; do - read -p "Admin email address (sets ADMIN_EMAIL): " admin_email - if validate_email "$admin_email"; then - ADMIN_EMAIL="$admin_email" - break - else - print_error "Please enter a valid email address" - fi -done - -print_info "Generating secure admin password and JWT secret (sets ADMIN_PASSWORD, AUTH_SECRET_KEY)..." -ADMIN_PASSWORD=$(generate_secret | cut -c1-20) -AUTH_SECRET_KEY=$(generate_secret) - -print_success "Admin credentials configured" -echo " 📧 Email: $ADMIN_EMAIL" -echo " 🔑 Password: $ADMIN_PASSWORD" -print_warning "Save this password - it won't be shown again!" -echo - -# Network Configuration -print_header "🌐 Network Configuration" -print_info "Configuring network access (sets HOST_IP, CORS_ORIGINS)" - -# Auto-detect IP -if command -v ip >/dev/null 2>&1; then - LOCAL_IP=$(ip route get 8.8.8.8 | awk '{print $7; exit}' 2>/dev/null || echo "localhost") -else - LOCAL_IP="localhost" -fi - -read -p "Backend host IP (sets HOST_IP) [${LOCAL_IP}]: " host_ip -HOST_IP=${host_ip:-$LOCAL_IP} - -BACKEND_PORT="8000" -WEBUI_PORT="3000" -CORS_ORIGINS="http://${HOST_IP}:${WEBUI_PORT},http://localhost:${WEBUI_PORT},http://127.0.0.1:${WEBUI_PORT}" - -print_success "Network configured for access at http://${HOST_IP}:${WEBUI_PORT}" -echo - -# Configuration Summary -print_header "📋 Configuration Summary" -echo "Backend: $BACKEND_NAME" -echo "LLM Provider: $LLM_PROVIDER ($OPENAI_MODEL)" -if [[ -n $DEEPGRAM_API_KEY ]]; then - echo "Speech-to-Text: Deepgram API" -elif [[ -n $PARAKEET_ASR_URL ]]; then - echo "Speech-to-Text: Custom ASR ($PARAKEET_ASR_URL)" -else - echo "Speech-to-Text: Not configured" -fi -echo "Admin Email: $ADMIN_EMAIL" -echo "Access URL: http://${HOST_IP}:${WEBUI_PORT}" -echo - -read -p "Continue with setup? [Y/n]: " confirm -if [[ $confirm == "n" || $confirm == "N" ]]; then - print_info "Setup cancelled" - exit 0 -fi - -# File Creation -print_header "📁 Creating Configuration Files" - -# Navigate to backend directory -cd "$BACKEND_DIR" - -# Create .env file -print_info "Creating .env file with all configured environment variables..." -cat > .env << EOF -# Friend-Lite Configuration -# Generated by init.sh on $(date) - -# Authentication (sets AUTH_SECRET_KEY, ADMIN_PASSWORD, ADMIN_EMAIL) -AUTH_SECRET_KEY=$AUTH_SECRET_KEY -ADMIN_PASSWORD=$ADMIN_PASSWORD -ADMIN_EMAIL=$ADMIN_EMAIL - -# LLM Configuration (sets LLM_PROVIDER, OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL) -LLM_PROVIDER=$LLM_PROVIDER -OPENAI_API_KEY=$OPENAI_API_KEY -OPENAI_BASE_URL=$OPENAI_BASE_URL -OPENAI_MODEL=$OPENAI_MODEL - -EOF - -# Add transcription config if configured -if [[ -n $DEEPGRAM_API_KEY ]]; then - cat >> .env << EOF -# Speech-to-Text Configuration (sets DEEPGRAM_API_KEY, TRANSCRIPTION_PROVIDER) -DEEPGRAM_API_KEY=$DEEPGRAM_API_KEY -TRANSCRIPTION_PROVIDER=$TRANSCRIPTION_PROVIDER - -EOF -elif [[ -n $PARAKEET_ASR_URL ]]; then - cat >> .env << EOF -# Speech-to-Text Configuration (sets PARAKEET_ASR_URL, TRANSCRIPTION_PROVIDER) -PARAKEET_ASR_URL=$PARAKEET_ASR_URL -TRANSCRIPTION_PROVIDER=$TRANSCRIPTION_PROVIDER - -EOF -fi - -# Add standard database and network config -cat >> .env << EOF -# Database Configuration -MONGODB_URI=mongodb://mongo:27017 -QDRANT_BASE_URL=qdrant - -# Network Configuration (sets HOST_IP, BACKEND_PUBLIC_PORT, WEBUI_PORT, CORS_ORIGINS) -HOST_IP=$HOST_IP -BACKEND_PUBLIC_PORT=$BACKEND_PORT -WEBUI_PORT=$WEBUI_PORT -CORS_ORIGINS=$CORS_ORIGINS -EOF - -print_success ".env file created" - -# Copy memory config for advanced backend -if [[ $BACKEND_DIR == "backends/advanced" ]]; then - if [[ -f "memory_config.yaml.template" ]]; then - print_info "Creating memory_config.yaml..." - cp memory_config.yaml.template memory_config.yaml - print_success "memory_config.yaml created from template" - fi -fi - -# Docker Setup -print_header "🐳 Starting Docker Services" -print_info "Building and starting services..." - -# Start Docker services -if docker compose up --build -d; then - print_success "Docker services started successfully!" - echo - - print_info "Waiting for services to be ready..." - sleep 10 - - # Check service health - if curl -s http://localhost:$BACKEND_PORT/health >/dev/null 2>&1; then - print_success "Backend service is healthy" - else - print_warning "Backend may still be starting up" - fi - -else - print_error "Failed to start Docker services" - print_info "You can try running 'docker compose up --build -d' manually" - exit 1 -fi - -# Final Success Message -print_header "🎉 Setup Complete!" - -cat << EOF -Friend-Lite is now running! Here's how to access it: - -🌐 Web Interface: http://${HOST_IP}:${WEBUI_PORT} -🔧 API Endpoint: http://${HOST_IP}:${BACKEND_PORT} - -👤 Admin Login: - Email: ${ADMIN_EMAIL} - Password: ${ADMIN_PASSWORD} - -📖 Next Steps: - 1. Open the web interface and log in - 2. Connect your OMI device via the mobile app - 3. Start recording conversations! - -📚 Documentation: - - See CLAUDE.md for developer information - - Visit backends/advanced/Docs/ for detailed guides - -🧪 Testing: - Run './run-test.sh' to test the full pipeline - -🔧 Configuration: - - Edit .env to modify settings - - Run 'docker compose restart' after changes - - Use 'docker compose logs' to view service logs - -EOF - -if [[ -n $DEEPGRAM_API_KEY && -n $OPENAI_API_KEY ]]; then - echo "🧪 To test the full pipeline:" - echo " cd backends/advanced && ./run-test.sh" - echo -fi - -print_success "Enjoy using Friend-Lite! 🚀" \ No newline at end of file diff --git a/services.py b/services.py new file mode 100755 index 000000000..95966c003 --- /dev/null +++ b/services.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Friend-Lite Service Management +Start, stop, and manage configured services +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +from rich import print as rprint +from rich.console import Console +from rich.table import Table + +console = Console() + +SERVICES = { + 'backend': { + 'path': 'backends/advanced', + 'compose_file': 'docker-compose.yml', + 'description': 'Advanced Backend + WebUI', + 'ports': ['8000', '5173'] + }, + 'speaker-recognition': { + 'path': 'extras/speaker-recognition', + 'compose_file': 'docker-compose.yml', + 'description': 'Speaker Recognition Service', + 'ports': ['8085', '5174/8444'] + }, + 'asr-services': { + 'path': 'extras/asr-services', + 'compose_file': 'docker-compose.yml', + 'description': 'Parakeet ASR Service', + 'ports': ['8767'] + }, + 'openmemory-mcp': { + 'path': 'extras/openmemory-mcp', + 'compose_file': 'docker-compose.yml', + 'description': 'OpenMemory MCP Server', + 'ports': ['8765'] + } +} + +def check_service_configured(service_name): + """Check if service is configured (has .env file)""" + service = SERVICES[service_name] + service_path = Path(service['path']) + + # Backend uses advanced init, others use .env + if service_name == 'backend': + return (service_path / '.env').exists() + else: + return (service_path / '.env').exists() + +def run_compose_command(service_name, command, build=False): + """Run docker compose command for a service""" + service = SERVICES[service_name] + service_path = Path(service['path']) + + if not service_path.exists(): + console.print(f"[red]❌ Service directory not found: {service_path}[/red]") + return False + + compose_file = service_path / service['compose_file'] + if not compose_file.exists(): + console.print(f"[red]❌ Docker compose file not found: {compose_file}[/red]") + return False + + cmd = ['docker', 'compose'] + if command == 'up': + cmd.extend(['up', '-d']) + if build: + cmd.append('--build') + elif command == 'down': + cmd.extend(['down']) + elif command == 'restart': + cmd.extend(['restart']) + elif command == 'status': + cmd.extend(['ps']) + + try: + # For commands that need real-time output (build), stream to console + if build and command == 'up': + console.print(f"[dim]Building {service_name} containers...[/dim]") + process = subprocess.Popen( + cmd, + cwd=service_path, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + + # Simply stream all output with coloring + all_output = [] + + if process.stdout is None: + raise RuntimeError("Process stdout is None - unable to read command output") + for line in process.stdout: + line = line.rstrip() + if not line: + continue + + # Store for error context + all_output.append(line) + + # Print with appropriate coloring + if 'error' in line.lower() or 'failed' in line.lower(): + console.print(f" [red]{line}[/red]") + elif 'Successfully' in line or 'Started' in line or 'Created' in line: + console.print(f" [green]{line}[/green]") + elif 'Building' in line or 'Creating' in line: + console.print(f" [cyan]{line}[/cyan]") + elif 'warning' in line.lower(): + console.print(f" [yellow]{line}[/yellow]") + else: + console.print(f" [dim]{line}[/dim]") + + # Wait for process to complete + process.wait() + + # If build failed, show error summary + if process.returncode != 0: + console.print(f"\n[red]❌ Build failed for {service_name}[/red]") + return False + + return True + else: + # For non-build commands, run silently unless there's an error + result = subprocess.run( + cmd, + cwd=service_path, + capture_output=True, + text=True, + check=False, + timeout=120 # 2 minute timeout for service status checks + ) + + if result.returncode == 0: + return True + else: + console.print(f"[red]❌ Command failed[/red]") + if result.stderr: + console.print("[red]Error output:[/red]") + # Show all error output + for line in result.stderr.splitlines(): + console.print(f" [dim]{line}[/dim]") + return False + + except subprocess.TimeoutExpired: + console.print(f"[red]❌ Command timed out after 2 minutes for {service_name}[/red]") + return False + except Exception as e: + console.print(f"[red]❌ Error running command: {e}[/red]") + return False + +def start_services(services, build=False): + """Start specified services""" + console.print(f"🚀 [bold]Starting {len(services)} services...[/bold]") + + success_count = 0 + for service_name in services: + if service_name not in SERVICES: + console.print(f"[red]❌ Unknown service: {service_name}[/red]") + continue + + if not check_service_configured(service_name): + console.print(f"[yellow]⚠️ {service_name} not configured, skipping[/yellow]") + continue + + console.print(f"\n🔧 Starting {service_name}...") + if run_compose_command(service_name, 'up', build): + console.print(f"[green]✅ {service_name} started[/green]") + success_count += 1 + else: + console.print(f"[red]❌ Failed to start {service_name}[/red]") + + console.print(f"\n[green]🎉 {success_count}/{len(services)} services started successfully[/green]") + +def stop_services(services): + """Stop specified services""" + console.print(f"🛑 [bold]Stopping {len(services)} services...[/bold]") + + success_count = 0 + for service_name in services: + if service_name not in SERVICES: + console.print(f"[red]❌ Unknown service: {service_name}[/red]") + continue + + console.print(f"\n🔧 Stopping {service_name}...") + if run_compose_command(service_name, 'down'): + console.print(f"[green]✅ {service_name} stopped[/green]") + success_count += 1 + else: + console.print(f"[red]❌ Failed to stop {service_name}[/red]") + + console.print(f"\n[green]🎉 {success_count}/{len(services)} services stopped successfully[/green]") + +def show_status(): + """Show status of all services""" + console.print("📊 [bold]Service Status:[/bold]\n") + + table = Table() + table.add_column("Service", style="cyan") + table.add_column("Configured", justify="center") + table.add_column("Description", style="dim") + table.add_column("Ports", style="green") + + for service_name, service_info in SERVICES.items(): + configured = "✅" if check_service_configured(service_name) else "❌" + ports = ", ".join(service_info['ports']) + table.add_row( + service_name, + configured, + service_info['description'], + ports + ) + + console.print(table) + + console.print("\n💡 [dim]Use 'python services.py start --all' to start all configured services[/dim]") + +def main(): + parser = argparse.ArgumentParser(description="Friend-Lite Service Management") + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Start command + start_parser = subparsers.add_parser('start', help='Start services') + start_parser.add_argument('services', nargs='*', + help='Services to start: backend, speaker-recognition, asr-services, openmemory-mcp (or use --all)') + start_parser.add_argument('--all', action='store_true', help='Start all configured services') + start_parser.add_argument('--build', action='store_true', help='Build images before starting') + + # Stop command + stop_parser = subparsers.add_parser('stop', help='Stop services') + stop_parser.add_argument('services', nargs='*', + help='Services to stop: backend, speaker-recognition, asr-services, openmemory-mcp (or use --all)') + stop_parser.add_argument('--all', action='store_true', help='Stop all services') + + # Status command + subparsers.add_parser('status', help='Show service status') + + args = parser.parse_args() + + if not args.command: + show_status() + return + + if args.command == 'status': + show_status() + + elif args.command == 'start': + if args.all: + services = [s for s in SERVICES.keys() if check_service_configured(s)] + elif args.services: + # Validate service names + invalid_services = [s for s in args.services if s not in SERVICES] + if invalid_services: + console.print(f"[red]❌ Invalid service names: {', '.join(invalid_services)}[/red]") + console.print(f"Available services: {', '.join(SERVICES.keys())}") + return + services = args.services + else: + console.print("[red]❌ No services specified. Use --all or specify service names.[/red]") + return + + start_services(services, args.build) + + elif args.command == 'stop': + if args.all: + services = list(SERVICES.keys()) + elif args.services: + # Validate service names + invalid_services = [s for s in args.services if s not in SERVICES] + if invalid_services: + console.print(f"[red]❌ Invalid service names: {', '.join(invalid_services)}[/red]") + console.print(f"Available services: {', '.join(SERVICES.keys())}") + return + services = args.services + else: + console.print("[red]❌ No services specified. Use --all or specify service names.[/red]") + return + + stop_services(services) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/setup-requirements.txt b/setup-requirements.txt new file mode 100644 index 000000000..78ba74206 --- /dev/null +++ b/setup-requirements.txt @@ -0,0 +1,2 @@ +# Dependencies for Friend-Lite root setup orchestrator +rich>=13.0.0 \ No newline at end of file