diff --git a/README.md b/README.md index 34027891..a93dcd55 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Chronicle +# Chronicle (fork from https://github.com/chronicler-ai/chronicle) Self-hostable AI system that captures audio/video data from OMI devices and other sources to generate memories, action items, and contextual insights about your conversations and daily interactions. diff --git a/backends/advanced/src/advanced_omi_backend/app_factory.py b/backends/advanced/src/advanced_omi_backend/app_factory.py index 7ccda184..3fd78ac2 100644 --- a/backends/advanced/src/advanced_omi_backend/app_factory.py +++ b/backends/advanced/src/advanced_omi_backend/app_factory.py @@ -37,6 +37,7 @@ from advanced_omi_backend.routers.modules.websocket_routes import router as websocket_router from advanced_omi_backend.services.audio_service import get_audio_stream_service from advanced_omi_backend.task_manager import init_task_manager, get_task_manager +from advanced_omi_backend.services.mcp_server import setup_mcp_server logger = logging.getLogger(__name__) application_logger = logging.getLogger("audio_processing") @@ -205,6 +206,10 @@ def create_app() -> FastAPI: tags=["users"], ) + # Setup MCP server for conversation access + setup_mcp_server(app) + logger.info("MCP server configured for conversation access") + # Mount static files LAST (mounts are catch-all patterns) CHUNK_DIR = Path("/app/audio_chunks") app.mount("/audio", StaticFiles(directory=CHUNK_DIR), name="audio") diff --git a/backends/advanced/src/advanced_omi_backend/models/user.py b/backends/advanced/src/advanced_omi_backend/models/user.py index b0ced195..7998c5b3 100644 --- a/backends/advanced/src/advanced_omi_backend/models/user.py +++ b/backends/advanced/src/advanced_omi_backend/models/user.py @@ -25,6 +25,8 @@ 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) + api_key: Optional[str] = None + api_key_created_at: Optional[datetime] = None class UserUpdate(BaseUserUpdate): @@ -62,6 +64,9 @@ class User(BeanieBaseUser, Document): registered_clients: dict[str, dict] = Field(default_factory=dict) # Speaker processing filter configuration primary_speakers: list[dict] = Field(default_factory=list) + # API key for MCP access + api_key: Optional[str] = None + api_key_created_at: Optional[datetime] = None class Settings: name = "users" # Collection name in MongoDB - standardized from "fastapi_users" diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/user_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/user_routes.py index 12ed5c63..233ddd68 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/user_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/user_routes.py @@ -5,10 +5,12 @@ """ import logging +import secrets +from datetime import UTC, datetime -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException -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 user_controller from advanced_omi_backend.users import User, UserCreate, UserUpdate @@ -44,3 +46,42 @@ async def delete_user( ): """Delete a user and optionally their associated data. Admin only.""" return await user_controller.delete_user(user_id, delete_conversations, delete_memories) + + +@router.post("/me/api-key") +async def generate_api_key(current_user: User = Depends(current_active_user)): + """Generate a new API key for the current user.""" + try: + # Generate a secure random API key (32 bytes = 64 hex characters) + new_api_key = secrets.token_urlsafe(32) + + # Update user with new API key + current_user.api_key = new_api_key + current_user.api_key_created_at = datetime.now(UTC) + await current_user.save() + + logger.info(f"Generated new API key for user {current_user.id}") + + return { + "api_key": new_api_key, + "created_at": current_user.api_key_created_at.isoformat() + } + except Exception as e: + logger.error(f"Failed to generate API key for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to generate API key") + + +@router.delete("/me/api-key") +async def revoke_api_key(current_user: User = Depends(current_active_user)): + """Revoke the current user's API key.""" + try: + current_user.api_key = None + current_user.api_key_created_at = None + await current_user.save() + + logger.info(f"Revoked API key for user {current_user.id}") + + return {"status": "success", "message": "API key revoked"} + except Exception as e: + logger.error(f"Failed to revoke API key for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to revoke API key") diff --git a/backends/advanced/src/advanced_omi_backend/services/mcp_server.py b/backends/advanced/src/advanced_omi_backend/services/mcp_server.py new file mode 100644 index 00000000..27288599 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/mcp_server.py @@ -0,0 +1,532 @@ +""" +MCP Server for Friend-Lite conversations. + +This module implements an MCP (Model Context Protocol) server that provides +conversation access tools for LLMs to retrieve conversation data, transcripts, +and audio files. + +Key features: +- List conversations with filtering and pagination +- Get detailed conversation data including transcripts and segments +- Access conversation audio files as resources +- User-scoped access with proper authentication +""" + +import base64 +import contextvars +import json +import logging +from pathlib import Path +from typing import Optional, List + +from fastapi import FastAPI, Request +from fastapi.routing import APIRouter +from mcp.server.fastmcp import FastMCP +from mcp.server.sse import SseServerTransport + +from advanced_omi_backend.config import CHUNK_DIR +from advanced_omi_backend.models.conversation import Conversation +from advanced_omi_backend.models.user import User + +logger = logging.getLogger(__name__) + +# Initialize MCP +mcp = FastMCP("friend-lite-conversations") + +# Context variables for user_id +user_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("user_id") + +# Create a router for MCP endpoints +mcp_router = APIRouter(prefix="/mcp") + +# Initialize SSE transport +sse = SseServerTransport("/mcp/messages/") + + +async def resolve_user_identifier(identifier: str) -> Optional[str]: + """ + Resolve a user identifier (email or user_id) to a user_id. + + Args: + identifier: Either an email address or a MongoDB ObjectId string + + Returns: + User ID string if found, None otherwise + """ + try: + # First try to find by email (case-insensitive) + user = await User.find_one(User.email == identifier.lower()) + if user: + logger.info(f"Resolved email '{identifier}' to user_id: {user.id}") + return str(user.id) + + # If not found by email, assume it's already a user_id + # Verify it exists + from bson import ObjectId + try: + user = await User.find_one(User.id == ObjectId(identifier)) + if user: + logger.info(f"Verified user_id: {identifier}") + return str(user.id) + except: + pass + + logger.warning(f"Could not resolve user identifier: {identifier}") + return None + except Exception as e: + logger.error(f"Error resolving user identifier '{identifier}': {e}") + return None + + +@mcp.tool(description="List all conversations. Returns conversation_id, title, summary, created_at, client_id, segment_count, memory_count, and has_audio. Supports date filtering and pagination.") +async def list_conversations( + limit: int = 20, + offset: int = 0, + order_by: str = "created_at_desc", + start_date: Optional[str] = None, + end_date: Optional[str] = None +) -> str: + """ + List conversations with optional date filtering. + + Args: + limit: Maximum number of conversations to return (default: 20, max: 100) + offset: Number of conversations to skip for pagination (default: 0) + order_by: Sort order - "created_at_desc" (newest first) or "created_at_asc" (oldest first) + start_date: Optional ISO 8601 date string (e.g., "2025-01-01T00:00:00Z") - filter conversations after this date + end_date: Optional ISO 8601 date string (e.g., "2025-12-31T23:59:59Z") - filter conversations before this date + + Returns: + JSON string with list of conversations and pagination info + """ + uid = user_id_var.get(None) + if not uid: + return json.dumps({"error": "user_id not provided"}, indent=2) + + try: + # Validate and limit parameters + limit = min(max(1, limit), 100) # Clamp between 1 and 100 + offset = max(0, offset) + + # Build base query + # If uid is "all", return all conversations (temporary for development) + # In the future, this will filter by speaker identity + if uid == "all": + query = Conversation.find_all() + else: + query = Conversation.find(Conversation.user_id == uid) + + # Apply date filtering if provided + from datetime import datetime + + if start_date: + try: + start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + query = query.find(Conversation.start_datetime >= start_dt) + except ValueError as e: + logger.warning(f"Invalid start_date format: {start_date}, error: {e}") + return json.dumps({"error": f"Invalid start_date format: {start_date}. Use ISO 8601 format."}, indent=2) + + if end_date: + try: + end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + query = query.find(Conversation.start_datetime <= end_dt) + except ValueError as e: + logger.warning(f"Invalid end_date format: {end_date}, error: {e}") + return json.dumps({"error": f"Invalid end_date format: {end_date}. Use ISO 8601 format."}, indent=2) + + # Get total count with same filters + total_count = await query.count() + + # Apply sorting + if order_by == "created_at_asc": + query = query.sort(Conversation.start_datetime) + else: # Default to newest first + query = query.sort(-Conversation.start_datetime) + + # Apply pagination + conversations = await query.skip(offset).limit(limit).to_list() + + # Format conversations for response + formatted_convs = [] + for conv in conversations: + + formatted_convs.append({ + "conversation_id": conv.conversation_id, + "title": conv.title, + "summary": conv.summary, + "start_datetime": conv.start_datetime.isoformat(), + "end_datetime": conv.end_datetime.isoformat() if conv.end_datetime else None, + "segment_count": len(conv.segments), + "memory_count": conv.memory_count, + "client_id": conv.client_id, + }) + + + result = { + "conversations": formatted_convs, + "pagination": { + "total": total_count, + "limit": limit, + "offset": offset, + "returned": len(formatted_convs), + "has_more": (offset + len(formatted_convs)) < total_count + } + } + + return json.dumps(result, indent=2) + + except Exception as e: + logger.exception(f"Error listing conversations: {e}") + return json.dumps({"error": f"Failed to list conversations: {str(e)}"}, indent=2) + + +@mcp.tool(description="Get detailed information about a specific conversation including full transcript, speaker segments, memories, and version history. Use the conversation_id from list_conversations.") +async def get_conversation(conversation_id: str) -> str: + """ + Get detailed conversation data. + + Args: + conversation_id: The unique conversation identifier + + Returns: + JSON string with complete conversation details + """ + uid = user_id_var.get(None) + if not uid: + return json.dumps({"error": "user_id not provided"}, indent=2) + + try: + # Find the conversation + conversation = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + + if not conversation: + return json.dumps({"error": f"Conversation '{conversation_id}' not found"}, indent=2) + + # Verify ownership (skip if uid is "all" for development) + if uid != "all" and conversation.user_id != uid: + return json.dumps({"error": "Access forbidden - conversation belongs to another user"}, indent=2) + + # Format conversation data with explicit fields + conv_data = { + # Core identifiers + "conversation_id": conversation.conversation_id, + "audio_uuid": conversation.audio_uuid, + "user_id": conversation.user_id, + "client_id": conversation.client_id, + + # Metadata + "start_datetime": conversation.start_datetime.isoformat(), + "end_datetime": conversation.end_datetime.isoformat() if conversation.end_datetime else None, + "title": conversation.title, + "summary": conversation.summary, + # "detailed_summary": conversation.detailed_summary, + + # Transcript data + "transcript": conversation.transcript, + + # Memory data + "memory_count": conversation.memory_count, + + # Audio paths + "has_audio": bool(conversation.audio_path), + "has_cropped_audio": bool(conversation.cropped_audio_path), + + # Version information + "active_transcript_version": conversation.active_transcript_version, + "active_memory_version": conversation.active_memory_version, + "transcript_versions_count": len(conversation.transcript_versions), + "memory_versions_count": len(conversation.memory_versions) + } + + return json.dumps(conv_data, indent=2) + + except Exception as e: + logger.exception(f"Error getting conversation {conversation_id}: {e}") + return json.dumps({"error": f"Failed to get conversation: {str(e)}"}, indent=2) + + +@mcp.tool(description="Get speaker segments from a conversation. Returns detailed timing and speaker information for each segment of the transcript.") +async def get_segments_from_conversation(conversation_id: str) -> str: + """ + Get speaker segments from a conversation. + + Args: + conversation_id: The unique conversation identifier + + Returns: + JSON string with speaker segments including timing and text + """ + uid = user_id_var.get(None) + if not uid: + return json.dumps({"error": "user_id not provided"}, indent=2) + + try: + # Find the conversation + conversation = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + + if not conversation: + return json.dumps({"error": f"Conversation '{conversation_id}' not found"}, indent=2) + + # Verify ownership (skip if uid is "all" for development) + if uid != "all" and conversation.user_id != uid: + return json.dumps({"error": "Access forbidden - conversation belongs to another user"}, indent=2) + + # Format segments + segments_data = { + "conversation_id": conversation_id, + "segment_count": len(conversation.segments), + "segments": [ + { + "start": seg.start, + "end": seg.end, + "duration": seg.end - seg.start, + "text": seg.text, + "speaker": seg.speaker, + "confidence": seg.confidence + } for seg in conversation.segments + ] + } + + return json.dumps(segments_data, indent=2) + + except Exception as e: + logger.exception(f"Error getting segments for conversation {conversation_id}: {e}") + return json.dumps({"error": f"Failed to get segments: {str(e)}"}, indent=2) + + +@mcp.resource(uri="conversation://{conversation_id}/audio", name="Conversation Audio", description="Get the audio file for a conversation") +async def get_conversation_audio(conversation_id: str) -> str: + """ + Get audio file for a conversation. + + Args: + conversation_id: The unique conversation identifier + + Returns: + Base64-encoded audio data with metadata + """ + uid = user_id_var.get(None) + if not uid: + return json.dumps({"error": "user_id not provided"}, indent=2) + + try: + # Default to regular audio (not cropped) + audio_type = "audio" + + # Find the conversation + conversation = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + + if not conversation: + return json.dumps({"error": f"Conversation '{conversation_id}' not found"}, indent=2) + + # Verify ownership (skip if uid is "all" for development) + if uid != "all" and conversation.user_id != uid: + return json.dumps({"error": "Access forbidden - conversation belongs to another user"}, indent=2) + + # Get the appropriate audio path + if audio_type == "cropped_audio": + audio_path = conversation.cropped_audio_path + if not audio_path: + return json.dumps({"error": "No cropped audio available for this conversation"}, indent=2) + else: # Default to regular audio + audio_path = conversation.audio_path + if not audio_path: + return json.dumps({"error": "No audio file available for this conversation"}, indent=2) + + # Resolve full path + full_path = CHUNK_DIR / audio_path + + if not full_path.exists(): + return json.dumps({"error": f"Audio file not found at path: {audio_path}"}, indent=2) + + # Read and encode audio file + with open(full_path, "rb") as f: + audio_data = f.read() + + audio_base64 = base64.b64encode(audio_data).decode('utf-8') + + result = { + "conversation_id": conversation_id, + "audio_type": audio_type, + "file_path": str(audio_path), + "file_size_bytes": len(audio_data), + "mime_type": "audio/wav", # Friend-Lite stores audio as WAV + "audio_base64": audio_base64 + } + + return json.dumps(result, indent=2) + + except Exception as e: + logger.exception(f"Error getting audio for conversation {conversation_id}: {e}") + return json.dumps({"error": f"Failed to get audio: {str(e)}"}, indent=2) + + +@mcp.resource(uri="conversation://{conversation_id}/cropped_audio", name="Conversation Cropped Audio", description="Get the cropped (speech-only) audio file for a conversation") +async def get_conversation_cropped_audio(conversation_id: str) -> str: + """ + Get cropped audio file for a conversation. + + Args: + conversation_id: The unique conversation identifier + + Returns: + Base64-encoded cropped audio data with metadata + """ + uid = user_id_var.get(None) + if not uid: + return json.dumps({"error": "user_id not provided"}, indent=2) + + try: + # Find the conversation + conversation = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + + if not conversation: + return json.dumps({"error": f"Conversation '{conversation_id}' not found"}, indent=2) + + # Verify ownership (skip if uid is "all" for development) + if uid != "all" and conversation.user_id != uid: + return json.dumps({"error": "Access forbidden - conversation belongs to another user"}, indent=2) + + # Get cropped audio path + audio_path = conversation.cropped_audio_path + if not audio_path: + return json.dumps({"error": "No cropped audio available for this conversation"}, indent=2) + + # Resolve full path + full_path = CHUNK_DIR / audio_path + + if not full_path.exists(): + return json.dumps({"error": f"Audio file not found at path: {audio_path}"}, indent=2) + + # Read and encode audio file + with open(full_path, "rb") as f: + audio_data = f.read() + + audio_base64 = base64.b64encode(audio_data).decode('utf-8') + + result = { + "conversation_id": conversation_id, + "audio_type": "cropped_audio", + "file_path": str(audio_path), + "file_size_bytes": len(audio_data), + "mime_type": "audio/wav", + "audio_base64": audio_base64 + } + + return json.dumps(result, indent=2) + + except Exception as e: + logger.exception(f"Error getting cropped audio for conversation {conversation_id}: {e}") + return json.dumps({"error": f"Failed to get cropped audio: {str(e)}"}, indent=2) + + +@mcp_router.get("/conversations/sse") +async def handle_sse(request: Request): + """ + Handle SSE connections with Bearer token authentication. + + The access token should be provided in the Authorization header: + Authorization: Bearer + + Note: For development, this bypasses user authentication and returns all conversations. + In the future, this will validate speaker identity from conversations. + """ + from fastapi.responses import JSONResponse + + # Extract access token from Authorization header + auth_header = request.headers.get("authorization") + if not auth_header: + logger.error("No Authorization header provided") + return JSONResponse( + status_code=401, + content={"error": "Authorization header required. Use: Authorization: Bearer "} + ) + + # Parse Bearer token + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + logger.error(f"Invalid Authorization header format: {auth_header}") + return JSONResponse( + status_code=401, + content={"error": "Invalid Authorization header. Use format: Authorization: Bearer "} + ) + + access_token = parts[1] + if not access_token: + logger.error("Empty access token") + return JSONResponse( + status_code=401, + content={"error": "Access token cannot be empty"} + ) + + # For now, use "all" as the user_id to bypass filtering + # This will be replaced with speaker-based permissions later + logger.info(f"MCP connection established with access token: {access_token[:min(8, len(access_token))]}...") + user_token = user_id_var.set("all") + + try: + # Handle SSE connection + async with sse.connect_sse( + request.scope, + request.receive, + request._send, + ) as (read_stream, write_stream): + await mcp._mcp_server.run( + read_stream, + write_stream, + mcp._mcp_server.create_initialization_options(), + ) + finally: + # Clean up context variables + user_id_var.reset(user_token) + + +@mcp_router.post("/messages/") +async def handle_get_message(request: Request): + return await handle_post_message(request) + + +@mcp_router.post("/conversations/sse/{user_id}/messages/") +async def handle_post_message_with_user(request: Request): + return await handle_post_message(request) + + +async def handle_post_message(request: Request): + """Handle POST messages for SSE""" + try: + body = await request.body() + + # Create a simple receive function that returns the body + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + # Create a simple send function that does nothing + async def send(message): + return {} + + # Call handle_post_message with the correct arguments + await sse.handle_post_message(request.scope, receive, send) + + # Return a success response + return {"status": "ok"} + finally: + pass + + +def setup_mcp_server(app: FastAPI): + """Setup MCP server with the FastAPI application""" + mcp._mcp_server.name = "friend-lite-conversations" + + # Include MCP router in the FastAPI app + app.include_router(mcp_router) + + logger.info("Friend-Lite MCP server initialized with conversation tools") diff --git a/backends/advanced/webui/package-lock.json b/backends/advanced/webui/package-lock.json index ead72812..1090d0bb 100644 --- a/backends/advanced/webui/package-lock.json +++ b/backends/advanced/webui/package-lock.json @@ -32,7 +32,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.4.32", - "sass-embedded": "^1.83.0", + "sass-embedded": "^1.80.7", "tailwindcss": "^3.3.0", "typescript": "^5.2.2", "vite": "^5.0.8" diff --git a/backends/advanced/webui/package.json b/backends/advanced/webui/package.json index b933d8db..250df867 100644 --- a/backends/advanced/webui/package.json +++ b/backends/advanced/webui/package.json @@ -34,7 +34,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.4.32", - "sass-embedded": "^1.83.0", + "sass-embedded": "^1.80.7", "tailwindcss": "^3.3.0", "typescript": "^5.2.2", "vite": "^5.0.8" diff --git a/backends/advanced/webui/src/App.tsx b/backends/advanced/webui/src/App.tsx index fca59623..4c9add41 100644 --- a/backends/advanced/webui/src/App.tsx +++ b/backends/advanced/webui/src/App.tsx @@ -13,6 +13,7 @@ import System from './pages/System' import Upload from './pages/Upload' import Queue from './pages/Queue' import LiveRecord from './pages/LiveRecord' +import Settings from './pages/Settings' import ProtectedRoute from './components/auth/ProtectedRoute' import { ErrorBoundary, PageErrorBoundary } from './components/ErrorBoundary' @@ -89,6 +90,11 @@ function App() { } /> + + + + } /> diff --git a/backends/advanced/webui/src/components/layout/Layout.tsx b/backends/advanced/webui/src/components/layout/Layout.tsx index 5995f823..83a161ab 100644 --- a/backends/advanced/webui/src/components/layout/Layout.tsx +++ b/backends/advanced/webui/src/components/layout/Layout.tsx @@ -15,10 +15,11 @@ export default function Layout() { { path: '/memories', label: 'Memories', icon: Brain }, { path: '/timeline', label: 'Timeline', icon: Calendar }, { path: '/users', label: 'User Management', icon: Users }, + { path: '/settings', label: 'Settings', icon: Settings }, ...(isAdmin ? [ { path: '/upload', label: 'Upload Audio', icon: Upload }, { path: '/queue', label: 'Queue Management', icon: Layers }, - { path: '/system', label: 'System State', icon: Settings }, + { path: '/system', label: 'System State', icon: Shield }, ] : []), ] diff --git a/backends/advanced/webui/src/contexts/AuthContext.tsx b/backends/advanced/webui/src/contexts/AuthContext.tsx index 7745e871..97a5b42c 100644 --- a/backends/advanced/webui/src/contexts/AuthContext.tsx +++ b/backends/advanced/webui/src/contexts/AuthContext.tsx @@ -7,6 +7,8 @@ interface User { name: string email: string is_superuser: boolean + api_key?: string + api_key_created_at?: string } interface AuthContextType { diff --git a/backends/advanced/webui/src/pages/Settings.tsx b/backends/advanced/webui/src/pages/Settings.tsx new file mode 100644 index 00000000..cb73f655 --- /dev/null +++ b/backends/advanced/webui/src/pages/Settings.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from 'react' +import { Settings as SettingsIcon, Key, Copy, Trash2, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { settingsApi } from '../services/api' + +export default function Settings() { + const { user } = useAuth() + const [apiKey, setApiKey] = useState(null) + const [apiKeyCreatedAt, setApiKeyCreatedAt] = useState(null) + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + loadApiKeyInfo() + }, [user]) + + const loadApiKeyInfo = () => { + if (user?.api_key) { + setApiKey(user.api_key) + setApiKeyCreatedAt(user.api_key_created_at || null) + } + } + + const generateApiKey = async () => { + try { + setLoading(true) + setMessage(null) + + const response = await settingsApi.generateApiKey() + + setApiKey(response.data.api_key) + setApiKeyCreatedAt(response.data.created_at) + setMessage({ type: 'success', text: 'API key generated successfully!' }) + + // Auto-hide success message after 3 seconds + setTimeout(() => setMessage(null), 3000) + } catch (error: any) { + console.error('Failed to generate API key:', error) + setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to generate API key' }) + } finally { + setLoading(false) + } + } + + const revokeApiKey = async () => { + if (!confirm('Are you sure you want to revoke your API key? This will break any existing integrations using this key.')) { + return + } + + try { + setLoading(true) + setMessage(null) + + await settingsApi.revokeApiKey() + + setApiKey(null) + setApiKeyCreatedAt(null) + setMessage({ type: 'success', text: 'API key revoked successfully' }) + + // Auto-hide success message after 3 seconds + setTimeout(() => setMessage(null), 3000) + } catch (error: any) { + console.error('Failed to revoke API key:', error) + setMessage({ type: 'error', text: error.response?.data?.detail || 'Failed to revoke API key' }) + } finally { + setLoading(false) + } + } + + const copyToClipboard = async () => { + if (!apiKey) return + + try { + await navigator.clipboard.writeText(apiKey) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('Failed to copy:', error) + } + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString() + } + + return ( +
+ {/* Header */} +
+ +

+ Settings +

+
+ + {/* Message Display */} + {message && ( +
+
+ {message.type === 'success' ? ( + + ) : ( + + )} +

+ {message.text} +

+
+
+ )} + + {/* API Keys Section */} +
+
+
+ +

+ API Keys +

+
+
+ +

+ API keys allow you to access Friend-Lite conversations via the MCP (Model Context Protocol) server. + Use this key to connect LLM clients like Claude Desktop, Cursor, or Windsurf. +

+ + {/* Current API Key Display */} + {apiKey ? ( +
+
+
+ + Current API Key + + {apiKeyCreatedAt && ( + + Created: {formatDate(apiKeyCreatedAt)} + + )} +
+ +
+ + {apiKey} + + +
+ +
+

+ MCP Server URL: http://your-server:8000/mcp/conversations/sse +
+ Authorization Header: Bearer {apiKey} +

+
+
+ +
+ + + +
+
+ ) : ( +
+ +

+ No API key generated yet +

+ +
+ )} + + {/* Usage Instructions */} +
+

+ How to use your API key +

+
+

1. Use the MCP inspector to test your connection: http://localhost:6274

+

2. Configure your MCP client with the server URL and your API key in the Authorization header

+

3. Your API key provides access to all your conversations via the MCP protocol

+

4. Keep your API key secure - it provides full access to your conversation data

+
+
+
+
+ ) +} diff --git a/backends/advanced/webui/src/services/api.ts b/backends/advanced/webui/src/services/api.ts index 0d988a9d..c4a5f1af 100644 --- a/backends/advanced/webui/src/services/api.ts +++ b/backends/advanced/webui/src/services/api.ts @@ -258,14 +258,22 @@ export const chatApi = { export const speakerApi = { // Get current user's speaker configuration getSpeakerConfiguration: () => api.get('/api/speaker-configuration'), - + // Update current user's speaker configuration - updateSpeakerConfiguration: (primarySpeakers: Array<{speaker_id: string, name: string, user_id: number}>) => + updateSpeakerConfiguration: (primarySpeakers: Array<{speaker_id: string, name: string, user_id: number}>) => api.post('/api/speaker-configuration', primarySpeakers), - - // Get enrolled speakers from speaker recognition service + + // Get enrolled speakers from speaker recognition service getEnrolledSpeakers: () => api.get('/api/enrolled-speakers'), - + // Check speaker service status (admin only) getSpeakerServiceStatus: () => api.get('/api/speaker-service-status'), +} + +export const settingsApi = { + // Generate new API key for current user + generateApiKey: () => api.post('/api/users/me/api-key'), + + // Revoke current user's API key + revokeApiKey: () => api.delete('/api/users/me/api-key'), } \ No newline at end of file diff --git a/tests/setup/test_env.py b/tests/setup/test_env.py index 929e83e2..7e3ca983 100644 --- a/tests/setup/test_env.py +++ b/tests/setup/test_env.py @@ -1,25 +1,26 @@ # Test Environment Configuration import os from pathlib import Path +from dotenv import load_dotenv # Load .env file from backends/advanced directory if it exists # This allows tests to work when run from VSCode or command line -def load_env_file(): - """Load environment variables from .env file if it exists.""" - # Look for .env in backends/advanced directory - env_file = Path(__file__).parent.parent.parent / "backends" / "advanced" / ".env" - if env_file.exists(): - with open(env_file) as f: - for line in f: - line = line.strip() - if line and not line.startswith('#') and '=' in line: - key, value = line.split('=', 1) - # Only set if not already in environment (CI takes precedence) - if key not in os.environ: - os.environ[key] = value +# def load_env_file(): +# """Load environment variables from .env file if it exists.""" +# # Look for .env in backends/advanced directory +# env_file = Path(__file__).parent.parent.parent / "backends" / "advanced" / ".env" +# if env_file.exists(): +# with open(env_file) as f: +# for line in f: +# line = line.strip() +# if line and not line.startswith('#') and '=' in line: +# key, value = line.split('=', 1) +# # Only set if not already in environment (CI takes precedence) +# if key not in os.environ: +# os.environ[key] = value # Load .env file (CI environment variables take precedence) -load_env_file() +# load_env_file() # Load .env from backends/advanced directory to get COMPOSE_PROJECT_NAME backend_env_path = Path(__file__).resolve().parents[2] / "backends" / "advanced" / ".env"