diff --git a/backends/advanced/init.py b/backends/advanced/init.py index 8d2e51f6..4e345ec1 100644 --- a/backends/advanced/init.py +++ b/backends/advanced/init.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import Any, Dict -from dotenv import set_key +from dotenv import get_key, set_key from rich.console import Console from rich.panel import Panel from rich.prompt import Confirm, Prompt @@ -100,6 +100,26 @@ def backup_existing_env(self): shutil.copy2(env_path, backup_path) self.console.print(f"[blue][INFO][/blue] Backed up existing .env file to {backup_path}") + def read_existing_env_value(self, key: str) -> str: + """Read a value from existing .env file""" + env_path = Path(".env") + if not env_path.exists(): + return None + + value = get_key(str(env_path), key) + # get_key returns None if key doesn't exist or value is empty + return value if value else None + + def mask_api_key(self, key: str, show_chars: int = 5) -> str: + """Mask API key showing only first and last few characters""" + if not key or len(key) <= show_chars * 2: + return key + + # Remove quotes if present + key_clean = key.strip("'\"") + + return f"{key_clean[:show_chars]}{'*' * min(15, len(key_clean) - show_chars * 2)}{key_clean[-show_chars:]}" + def setup_authentication(self): """Configure authentication settings""" self.print_section("Authentication Setup") @@ -128,8 +148,17 @@ def setup_transcription(self): 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)", "") + + # Check for existing API key + existing_key = self.read_existing_env_value("DEEPGRAM_API_KEY") + if existing_key and existing_key not in ['your_deepgram_api_key_here', 'your-deepgram-key-here']: + masked_key = self.mask_api_key(existing_key) + prompt_text = f"Deepgram API key ({masked_key}) [press Enter to reuse, or enter new]" + api_key_input = self.prompt_value(prompt_text, "") + api_key = api_key_input if api_key_input else existing_key + else: + 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 @@ -141,10 +170,19 @@ def setup_transcription(self): 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)", "") + + # Check for existing API key + existing_key = self.read_existing_env_value("MISTRAL_API_KEY") + if existing_key and existing_key not in ['your_mistral_api_key_here', 'your-mistral-key-here']: + masked_key = self.mask_api_key(existing_key) + prompt_text = f"Mistral API key ({masked_key}) [press Enter to reuse, or enter new]" + api_key_input = self.prompt_value(prompt_text, "") + api_key = api_key_input if api_key_input else existing_key + else: + 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 @@ -178,11 +216,20 @@ def setup_llm(self): 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)", "") + + # Check for existing API key + existing_key = self.read_existing_env_value("OPENAI_API_KEY") + if existing_key and existing_key not in ['your_openai_api_key_here', 'your-openai-key-here']: + masked_key = self.mask_api_key(existing_key) + prompt_text = f"OpenAI API key ({masked_key}) [press Enter to reuse, or enter new]" + api_key_input = self.prompt_value(prompt_text, "") + api_key = api_key_input if api_key_input else existing_key + else: + 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 diff --git a/backends/advanced/ssl/generate-ssl.sh b/backends/advanced/ssl/generate-ssl.sh index fb83db5b..efc5d8c2 100755 --- a/backends/advanced/ssl/generate-ssl.sh +++ b/backends/advanced/ssl/generate-ssl.sh @@ -2,24 +2,26 @@ set -e # Generate self-signed SSL certificate for Friend-Lite Advanced Backend -# Supports localhost and custom Tailscale IP +# Supports localhost, IP addresses, and domain names -TAILSCALE_IP="$1" +SERVER_ADDRESS="$1" -if [ -z "$TAILSCALE_IP" ]; then - echo "Usage: $0 " +if [ -z "$SERVER_ADDRESS" ]; then + echo "Usage: $0 " echo "Example: $0 100.83.66.30" + echo "Example: $0 myserver.tailxxxxx.ts.net" 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 +# Detect if it's an IP address or domain name +if echo "$SERVER_ADDRESS" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' > /dev/null; then + IS_IP=true + echo "šŸ” Generating SSL certificate for localhost and IP: $SERVER_ADDRESS" +else + IS_IP=false + echo "šŸ” Generating SSL certificate for localhost and domain: $SERVER_ADDRESS" 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 @@ -53,9 +55,15 @@ subjectAltName = @alt_names DNS.1 = localhost DNS.2 = *.localhost IP.1 = 127.0.0.1 -IP.2 = $TAILSCALE_IP EOF +# Add custom address as either IP or DNS +if [ "$IS_IP" = true ]; then + echo "IP.2 = $SERVER_ADDRESS" >> $OUTPUT_DIR/server.conf +else + echo "DNS.3 = $SERVER_ADDRESS" >> $OUTPUT_DIR/server.conf +fi + # Generate private key openssl genrsa -out $OUTPUT_DIR/server.key 2048 @@ -75,7 +83,7 @@ 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 " - Valid for: localhost, *.localhost, 127.0.0.1, $SERVER_ADDRESS" 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/extras/local-omi-bt/start.sh b/extras/local-omi-bt/start.sh index c59c44e2..6fd8947e 100755 --- a/extras/local-omi-bt/start.sh +++ b/extras/local-omi-bt/start.sh @@ -1,2 +1,2 @@ #!/bin/bash -uv run --with-requirements requirements.txt python connect-laptop.py +uv run --with-requirements requirements.txt python connect-omi.py diff --git a/extras/speaker-recognition/.env.template b/extras/speaker-recognition/.env.template index bc264b44..2ba3a1c7 100644 --- a/extras/speaker-recognition/.env.template +++ b/extras/speaker-recognition/.env.template @@ -13,6 +13,11 @@ HF_TOKEN=your_huggingface_token_here # This controls which dependency group is installed in the Docker image COMPUTE_MODE=cpu +# PyTorch CUDA version (only used when COMPUTE_MODE=gpu) +# Options: cu121 (CUDA 12.1), cu126 (CUDA 12.6), cu128 (CUDA 12.8) +# Should match your system's CUDA version (check with: nvidia-smi) +PYTORCH_CUDA_VERSION=cu126 + # Speaker recognition similarity threshold (0.0-1.0) # Lower values = less strict identification, higher values = more strict # Typical range: 0.1-0.3 for ECAPA-TDNN models diff --git a/extras/speaker-recognition/Dockerfile b/extras/speaker-recognition/Dockerfile index 40fccc5d..0fc84b84 100644 --- a/extras/speaker-recognition/Dockerfile +++ b/extras/speaker-recognition/Dockerfile @@ -1,9 +1,9 @@ FROM python:3.12-bookworm -ARG CUDA_VERSION +ARG PYTORCH_CUDA_VERSION # Capture build arg as environment variable for runtime -ENV CUDA_VERSION=${CUDA_VERSION} +ENV PYTORCH_CUDA_VERSION=${PYTORCH_CUDA_VERSION} # Install system dependencies including PortAudio for pyaudio RUN apt-get update && apt-get install -y \ @@ -27,8 +27,8 @@ COPY pyproject.toml ./ RUN mkdir -p src/simple_speaker_recognition COPY src/simple_speaker_recognition/__init__.py src/simple_speaker_recognition/ -# Install dependencies and package -RUN uv sync --no-dev --extra ${CUDA_VERSION} +# Install dependencies and package +RUN uv sync --no-dev --extra ${PYTORCH_CUDA_VERSION} # Create directories RUN mkdir -p /app/audio_chunks /app/debug /app/data /models @@ -42,4 +42,4 @@ EXPOSE 8085 # Run the service # Use shell form to allow environment variable expansion -CMD uv run --extra ${CUDA_VERSION} --no-dev simple-speaker-service \ No newline at end of file +CMD uv run --extra ${PYTORCH_CUDA_VERSION} --no-dev simple-speaker-service \ No newline at end of file diff --git a/extras/speaker-recognition/docker-compose-test.yml b/extras/speaker-recognition/docker-compose-test.yml index a4b13036..0cc6d8d2 100644 --- a/extras/speaker-recognition/docker-compose-test.yml +++ b/extras/speaker-recognition/docker-compose-test.yml @@ -5,7 +5,7 @@ services: context: . dockerfile: Dockerfile args: - CUDA_VERSION: ${COMPUTE_MODE:-cpu} + PYTORCH_CUDA_VERSION: ${COMPUTE_MODE:-cpu} image: speaker-recognition:test ports: # Map host test port (default 8086) to container port 8085 diff --git a/extras/speaker-recognition/docker-compose.yml b/extras/speaker-recognition/docker-compose.yml index 52f2a80a..9118e732 100644 --- a/extras/speaker-recognition/docker-compose.yml +++ b/extras/speaker-recognition/docker-compose.yml @@ -8,7 +8,7 @@ services: context: . dockerfile: Dockerfile args: - CUDA_VERSION: ${CUDA_VERSION:-cpu} + PYTORCH_CUDA_VERSION: ${PYTORCH_CUDA_VERSION:-cpu} image: speaker-recognition:latest env_file: - .env @@ -46,7 +46,7 @@ services: context: . dockerfile: Dockerfile args: - CUDA_VERSION: ${CUDA_VERSION:-cu121} + PYTORCH_CUDA_VERSION: ${PYTORCH_CUDA_VERSION:-cu121} deploy: resources: reservations: @@ -108,3 +108,4 @@ services: networks: default: name: friend-network + external: true diff --git a/extras/speaker-recognition/init.py b/extras/speaker-recognition/init.py index 86797ed1..a5354394 100755 --- a/extras/speaker-recognition/init.py +++ b/extras/speaker-recognition/init.py @@ -65,6 +65,27 @@ def prompt_password(self, prompt: str) -> str: self.console.print("[red][ERROR][/red] Token is required for speaker recognition") sys.exit(1) + def read_existing_env_value(self, key: str) -> str: + """Read a value from existing .env file""" + env_path = Path(".env") + if not env_path.exists(): + return None + + from dotenv import get_key + value = get_key(str(env_path), key) + # get_key returns None if key doesn't exist or value is empty + return value if value else None + + def mask_api_key(self, key: str, show_chars: int = 5) -> str: + """Mask API key showing only first and last few characters""" + if not key or len(key) <= show_chars * 2: + return key + + # Remove quotes if present + key_clean = key.strip("'\"") + + return f"{key_clean[:show_chars]}{'*' * min(15, len(key_clean) - show_chars * 2)}{key_clean[-show_chars:]}" + def prompt_choice(self, prompt: str, choices: Dict[str, str], default: str = "1") -> str: """Prompt for a choice from options""" self.console.print(prompt) @@ -103,9 +124,65 @@ def setup_hf_token(self): self.config["HF_TOKEN"] = self.args.hf_token self.console.print("[green][SUCCESS][/green] HF Token configured from command line") else: - hf_token = self.prompt_password("HF Token") - self.config["HF_TOKEN"] = hf_token - self.console.print("[green][SUCCESS][/green] HF Token configured") + # Check for existing token + existing_token = self.read_existing_env_value("HF_TOKEN") + if existing_token and existing_token not in ['your_huggingface_token_here', 'your-hf-token-here']: + masked_token = self.mask_api_key(existing_token) + self.console.print(f"[blue][INFO][/blue] Found existing token: {masked_token}") + try: + reuse = Confirm.ask("Use existing HF Token?", default=True) + except EOFError: + reuse = True + + if reuse: + self.config["HF_TOKEN"] = existing_token + self.console.print("[green][SUCCESS][/green] HF Token configured (reused)") + else: + hf_token = self.prompt_password("HF Token") + self.config["HF_TOKEN"] = hf_token + self.console.print("[green][SUCCESS][/green] HF Token configured") + else: + hf_token = self.prompt_password("HF Token") + self.config["HF_TOKEN"] = hf_token + self.console.print("[green][SUCCESS][/green] HF Token configured") + + def detect_cuda_version(self) -> str: + """Detect system CUDA version from nvidia-smi""" + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + # Try to get CUDA version from nvidia-smi + result = subprocess.run( + ["nvidia-smi"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + output = result.stdout + # Parse CUDA Version from nvidia-smi output + # Format: "CUDA Version: 12.6" + import re + match = re.search(r'CUDA Version:\s*(\d+)\.(\d+)', output) + if match: + major, minor = match.groups() + cuda_ver = f"{major}.{minor}" + + # Map to available PyTorch CUDA versions + if cuda_ver >= "12.8": + return "cu128" + elif cuda_ver >= "12.6": + return "cu126" + elif cuda_ver >= "12.1": + return "cu121" + except (subprocess.SubprocessError, FileNotFoundError): + pass + return "cu121" # Default fallback def setup_compute_mode(self): """Configure compute mode (CPU/GPU)""" @@ -125,14 +202,44 @@ def setup_compute_mode(self): self.config["COMPUTE_MODE"] = compute_mode - # Set CUDA_VERSION for Docker build + # Set PYTORCH_CUDA_VERSION for Docker build if compute_mode == "cpu": - self.config["CUDA_VERSION"] = "cpu" + self.config["PYTORCH_CUDA_VERSION"] = "cpu" else: - # Default to cu121 for GPU mode (can be overridden in .env) - self.config["CUDA_VERSION"] = "cu121" + # Detect system CUDA version and suggest as default + detected_cuda = self.detect_cuda_version() + + # Map to default choice number + cuda_to_choice = { + "cu121": "1", + "cu126": "2", + "cu128": "3" + } + default_choice = cuda_to_choice.get(detected_cuda, "2") + + self.console.print() + self.console.print(f"[blue][INFO][/blue] Detected CUDA version: {detected_cuda}") + self.console.print() + + cuda_choices = { + "1": "CUDA 12.1 (cu121)", + "2": "CUDA 12.6 (cu126)", + "3": "CUDA 12.8 (cu128)" + } + cuda_choice = self.prompt_choice( + "Choose CUDA version for PyTorch:", + cuda_choices, + default_choice + ) + + choice_to_cuda = { + "1": "cu121", + "2": "cu126", + "3": "cu128" + } + self.config["PYTORCH_CUDA_VERSION"] = choice_to_cuda[cuda_choice] - self.console.print(f"[blue][INFO][/blue] Using {compute_mode.upper()} mode") + self.console.print(f"[blue][INFO][/blue] Using {compute_mode.upper()} mode with PyTorch CUDA version: {self.config['PYTORCH_CUDA_VERSION']}") def setup_deepgram(self): """Configure Deepgram API key if provided""" diff --git a/extras/speaker-recognition/ssl/generate-ssl.sh b/extras/speaker-recognition/ssl/generate-ssl.sh index b76bf83a..c1e832c5 100755 --- a/extras/speaker-recognition/ssl/generate-ssl.sh +++ b/extras/speaker-recognition/ssl/generate-ssl.sh @@ -2,24 +2,26 @@ set -e # Generate self-signed SSL certificate for Friend-Lite Advanced Backend -# Supports localhost and custom Tailscale IP +# Supports localhost, IP addresses, and domain names -TAILSCALE_IP="$1" +SERVER_ADDRESS="$1" -if [ -z "$TAILSCALE_IP" ]; then - echo "Usage: $0 " +if [ -z "$SERVER_ADDRESS" ]; then + echo "Usage: $0 " echo "Example: $0 100.83.66.30" + echo "Example: $0 kraken.tail168755.ts.net" 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 +# Detect if it's an IP address or domain name +if echo "$SERVER_ADDRESS" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' > /dev/null; then + IS_IP=true + echo "Generating SSL certificate for localhost and IP: $SERVER_ADDRESS" +else + IS_IP=false + echo "Generating SSL certificate for localhost and domain: $SERVER_ADDRESS" 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 @@ -53,9 +55,15 @@ subjectAltName = @alt_names DNS.1 = localhost DNS.2 = *.localhost IP.1 = 127.0.0.1 -IP.2 = $TAILSCALE_IP EOF +# Add custom address as either IP or DNS +if [ "$IS_IP" = true ]; then + echo "IP.2 = $SERVER_ADDRESS" >> $OUTPUT_DIR/server.conf +else + echo "DNS.3 = $SERVER_ADDRESS" >> $OUTPUT_DIR/server.conf +fi + # Generate private key openssl genrsa -out $OUTPUT_DIR/server.key 2048 @@ -72,10 +80,10 @@ rm $OUTPUT_DIR/server.csr $OUTPUT_DIR/server.conf chmod 600 $OUTPUT_DIR/server.key chmod 644 $OUTPUT_DIR/server.crt -echo "āœ… SSL certificate generated successfully" +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 " - Valid for: localhost, *.localhost, 127.0.0.1, $SERVER_ADDRESS" echo "" echo "Certificate Details:" openssl x509 -in $OUTPUT_DIR/server.crt -text -noout | grep -A 1 "Subject Alternative Name" || echo " (Certificate generated successfully)" diff --git a/start.sh b/start.sh new file mode 100755 index 00000000..44ba6f2c --- /dev/null +++ b/start.sh @@ -0,0 +1 @@ +uv run --with-requirements setup-requirements.txt python services.py start --all --build diff --git a/wizard.py b/wizard.py index 4d37fadd..cfc5b861 100755 --- a/wizard.py +++ b/wizard.py @@ -26,6 +26,31 @@ def read_env_value(env_file_path, key): # get_key returns None if key doesn't exist or value is empty return value if value else None +def is_placeholder(value, *placeholder_variants): + """ + Check if a value is a placeholder by normalizing both the value and placeholders. + Treats 'your-key-here' and 'your_key_here' as equivalent. + + Args: + value: The value to check + placeholder_variants: One or more placeholder strings to check against + + Returns: + True if value matches any placeholder variant (after normalization) + """ + if not value: + return True + + # Normalize by replacing hyphens with underscores + normalized_value = value.replace('-', '_').lower() + + for placeholder in placeholder_variants: + normalized_placeholder = placeholder.replace('-', '_').lower() + if normalized_value == normalized_placeholder: + return True + + return False + SERVICES = { 'backend': { 'advanced': { @@ -154,26 +179,30 @@ def run_service_setup(service_name, selected_services, https_enabled=False, serv # Pass Deepgram API key from backend if available backend_env_path = 'backends/advanced/.env' deepgram_key = read_env_value(backend_env_path, 'DEEPGRAM_API_KEY') - if deepgram_key and deepgram_key != 'your_deepgram_api_key_here': + if deepgram_key and not is_placeholder(deepgram_key, 'your_deepgram_api_key_here', 'your-deepgram-api-key-here'): cmd.extend(['--deepgram-api-key', deepgram_key]) + console.print("[blue][INFO][/blue] Found existing DEEPGRAM_API_KEY from backend config, reusing") # Pass HF Token from existing speaker recognition .env if available speaker_env_path = 'extras/speaker-recognition/.env' hf_token = read_env_value(speaker_env_path, 'HF_TOKEN') - if hf_token and hf_token != 'your_huggingface_token_here': + if hf_token and not is_placeholder(hf_token, 'your_huggingface_token_here', 'your-huggingface-token-here'): cmd.extend(['--hf-token', hf_token]) + console.print("[blue][INFO][/blue] Found existing HF_TOKEN, reusing") # Pass compute mode from existing .env if available compute_mode = read_env_value(speaker_env_path, 'COMPUTE_MODE') if compute_mode in ['cpu', 'gpu']: cmd.extend(['--compute-mode', compute_mode]) + console.print(f"[blue][INFO][/blue] Found existing COMPUTE_MODE ({compute_mode}), reusing") # For openmemory-mcp, try to pass OpenAI API key from backend if available if service_name == 'openmemory-mcp': backend_env_path = 'backends/advanced/.env' openai_key = read_env_value(backend_env_path, 'OPENAI_API_KEY') - if openai_key and openai_key != 'your-openai-key-here': + if openai_key and not is_placeholder(openai_key, 'your_openai_api_key_here', 'your-openai-api-key-here', 'your_openai_key_here', 'your-openai-key-here'): cmd.extend(['--openai-api-key', openai_key]) + console.print("[blue][INFO][/blue] Found existing OPENAI_API_KEY from backend config, reusing") console.print(f"\nšŸ”§ [bold]Setting up {service_name}...[/bold]") diff --git a/wizard.sh b/wizard.sh new file mode 100755 index 00000000..02942349 --- /dev/null +++ b/wizard.sh @@ -0,0 +1 @@ +uv run --with-requirements setup-requirements.txt wizard.py