diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ffffaa52 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "extras/mycelia"] + path = extras/mycelia + url = https://github.com/mycelia-tech/mycelia diff --git a/Makefile b/Makefile index 1a5a3829..3d03a180 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ export $(shell sed 's/=.*//' config.env | grep -v '^\s*$$' | grep -v '^\s*\#') SCRIPTS_DIR := scripts K8S_SCRIPTS_DIR := $(SCRIPTS_DIR)/k8s -.PHONY: help menu setup-k8s setup-infrastructure setup-rbac setup-storage-pvc config config-docker config-k8s config-all clean deploy deploy-docker deploy-k8s deploy-k8s-full deploy-infrastructure deploy-apps check-infrastructure check-apps build-backend up-backend down-backend k8s-status k8s-cleanup k8s-purge audio-manage test-robot test-robot-integration test-robot-unit test-robot-endpoints test-robot-specific test-robot-clean +.PHONY: help menu setup-k8s setup-infrastructure setup-rbac setup-storage-pvc config config-docker config-k8s config-all clean deploy deploy-docker deploy-k8s deploy-k8s-full deploy-infrastructure deploy-apps check-infrastructure check-apps build-backend up-backend down-backend k8s-status k8s-cleanup k8s-purge audio-manage mycelia-sync-status mycelia-sync-all mycelia-sync-user mycelia-check-orphans mycelia-reassign-orphans test-robot test-robot-integration test-robot-unit test-robot-endpoints test-robot-specific test-robot-clean # Default target .DEFAULT_GOAL := menu @@ -57,6 +57,13 @@ menu: ## Show interactive menu (default) @echo " check-apps ๐Ÿ” Check application services" @echo " clean ๐Ÿงน Clean up generated files" @echo + @echo "๐Ÿ”„ Mycelia Sync:" + @echo " mycelia-sync-status ๐Ÿ“Š Show Mycelia OAuth sync status" + @echo " mycelia-sync-all ๐Ÿ”„ Sync all Friend-Lite users to Mycelia" + @echo " mycelia-sync-user ๐Ÿ‘ค Sync specific user (EMAIL=user@example.com)" + @echo " mycelia-check-orphans ๐Ÿ” Find orphaned Mycelia objects" + @echo " mycelia-reassign-orphans โ™ป๏ธ Reassign orphans (EMAIL=admin@example.com)" + @echo @echo "Current configuration:" @echo " DOMAIN: $(DOMAIN)" @echo " DEPLOYMENT_MODE: $(DEPLOYMENT_MODE)" @@ -101,6 +108,13 @@ help: ## Show detailed help for all targets @echo "๐ŸŽต AUDIO MANAGEMENT:" @echo " audio-manage Interactive audio file management" @echo + @echo "๐Ÿ”„ MYCELIA SYNC:" + @echo " mycelia-sync-status Show Mycelia OAuth sync status for all users" + @echo " mycelia-sync-all Sync all Friend-Lite users to Mycelia OAuth" + @echo " mycelia-sync-user Sync specific user (EMAIL=user@example.com)" + @echo " mycelia-check-orphans Find Mycelia objects without Friend-Lite owner" + @echo " mycelia-reassign-orphans Reassign orphaned objects (EMAIL=admin@example.com)" + @echo @echo "๐Ÿงช ROBOT FRAMEWORK TESTING:" @echo " test-robot Run all Robot Framework tests" @echo " test-robot-integration Run integration tests only" @@ -333,6 +347,42 @@ audio-manage: ## Interactive audio file management @echo "๐ŸŽต Starting audio file management..." @$(SCRIPTS_DIR)/manage-audio-files.sh +# ======================================== +# MYCELIA SYNC +# ======================================== + +mycelia-sync-status: ## Show Mycelia OAuth sync status for all users + @echo "๐Ÿ“Š Checking Mycelia OAuth sync status..." + @cd backends/advanced && uv run python scripts/sync_friendlite_mycelia.py --status + +mycelia-sync-all: ## Sync all Friend-Lite users to Mycelia OAuth + @echo "๐Ÿ”„ Syncing all Friend-Lite users to Mycelia OAuth..." + @echo "โš ๏ธ This will create OAuth credentials for users without them" + @read -p "Continue? (y/N): " confirm && [ "$$confirm" = "y" ] || exit 1 + @cd backends/advanced && uv run python scripts/sync_friendlite_mycelia.py --sync-all + +mycelia-sync-user: ## Sync specific user to Mycelia OAuth (usage: make mycelia-sync-user EMAIL=user@example.com) + @echo "๐Ÿ‘ค Syncing specific user to Mycelia OAuth..." + @if [ -z "$(EMAIL)" ]; then \ + echo "โŒ EMAIL parameter is required. Usage: make mycelia-sync-user EMAIL=user@example.com"; \ + exit 1; \ + fi + @cd backends/advanced && uv run python scripts/sync_friendlite_mycelia.py --email $(EMAIL) + +mycelia-check-orphans: ## Find Mycelia objects without Friend-Lite owner + @echo "๐Ÿ” Checking for orphaned Mycelia objects..." + @cd backends/advanced && uv run python scripts/sync_friendlite_mycelia.py --check-orphans + +mycelia-reassign-orphans: ## Reassign orphaned objects to user (usage: make mycelia-reassign-orphans EMAIL=admin@example.com) + @echo "โ™ป๏ธ Reassigning orphaned Mycelia objects..." + @if [ -z "$(EMAIL)" ]; then \ + echo "โŒ EMAIL parameter is required. Usage: make mycelia-reassign-orphans EMAIL=admin@example.com"; \ + exit 1; \ + fi + @echo "โš ๏ธ This will reassign all orphaned objects to: $(EMAIL)" + @read -p "Continue? (y/N): " confirm && [ "$$confirm" = "y" ] || exit 1 + @cd backends/advanced && uv run python scripts/sync_friendlite_mycelia.py --reassign-orphans --target-email $(EMAIL) + # ======================================== # TESTING TARGETS # ======================================== diff --git a/README-K8S.md b/README-K8S.md index 161836af..3e282a33 100644 --- a/README-K8S.md +++ b/README-K8S.md @@ -266,15 +266,20 @@ friend-lite/ 1. **Clone Repository** ```bash - # Clone Friend-Lite repository - git clone https://github.com/yourusername/friend-lite.git + # Clone Friend-Lite repository with submodules + git clone --recursive https://github.com/yourusername/friend-lite.git cd friend-lite - + + # If you already cloned without --recursive, initialize submodules: + # git submodule update --init --recursive + # Verify template files are present ls -la skaffold.env.template ls -la backends/advanced/.env.template ``` + > **Note:** The `--recursive` flag downloads the optional Mycelia submodule (an alternative memory backend with timeline visualization). Most deployments use the default Friend-Lite memory system and don't need Mycelia. + 2. **Install Required Tools** **kubectl** (required for Skaffold and Helm): diff --git a/backends/advanced/.env.template b/backends/advanced/.env.template index 01724f19..60d2c99e 100644 --- a/backends/advanced/.env.template +++ b/backends/advanced/.env.template @@ -99,8 +99,8 @@ QDRANT_BASE_URL=qdrant # MEMORY PROVIDER CONFIGURATION # ======================================== -# Memory Provider: "friend_lite" (default) or "openmemory_mcp" -# +# Memory Provider: "friend_lite" (default), "openmemory_mcp", or "mycelia" +# # Friend-Lite (default): In-house memory system with full control # - Custom LLM-powered extraction with individual fact storage # - Smart deduplication and memory updates (ADD/UPDATE/DELETE) @@ -113,6 +113,13 @@ QDRANT_BASE_URL=qdrant # - Web UI at http://localhost:8765 # - Requires external server setup # +# Mycelia: Full-featured personal memory timeline +# - Voice, screenshots, and text capture +# - Timeline UI with waveform playback +# - Conversation extraction and semantic search +# - OAuth federation for cross-instance sharing +# - Requires Mycelia server setup (extras/mycelia) +# # See MEMORY_PROVIDERS.md for detailed comparison MEMORY_PROVIDER=friend_lite @@ -128,6 +135,19 @@ MEMORY_PROVIDER=friend_lite # OPENMEMORY_USER_ID=openmemory # OPENMEMORY_TIMEOUT=30 +# ---------------------------------------- +# Mycelia Configuration +# (Only needed if MEMORY_PROVIDER=mycelia) +# ---------------------------------------- +# First start Mycelia: +# cd extras/mycelia && docker compose up -d redis mongo mongo-search +# cd extras/mycelia/backend && deno task dev +# +# IMPORTANT: JWT_SECRET in Mycelia backend/.env must match AUTH_SECRET_KEY above +# MYCELIA_URL=http://host.docker.internal:5173 +# MYCELIA_DB=mycelia # Database name (use mycelia_test for test environment) +# MYCELIA_TIMEOUT=30 + # ======================================== # OPTIONAL FEATURES # ======================================== diff --git a/backends/advanced/docker-compose-test.yml b/backends/advanced/docker-compose-test.yml index 029d0238..1dde7c55 100644 --- a/backends/advanced/docker-compose-test.yml +++ b/backends/advanced/docker-compose-test.yml @@ -38,6 +38,8 @@ services: - MEMORY_PROVIDER=${MEMORY_PROVIDER:-friend_lite} - OPENMEMORY_MCP_URL=${OPENMEMORY_MCP_URL:-http://host.docker.internal:8765} - OPENMEMORY_USER_ID=${OPENMEMORY_USER_ID:-openmemory} + - MYCELIA_URL=http://mycelia-backend-test:5173 + - MYCELIA_DB=mycelia_test # Disable speaker recognition in test environment to prevent segment duplication - DISABLE_SPEAKER_RECOGNITION=false - SPEAKER_SERVICE_URL=https://localhost:8085 @@ -146,6 +148,8 @@ services: - MEMORY_PROVIDER=${MEMORY_PROVIDER:-friend_lite} - OPENMEMORY_MCP_URL=${OPENMEMORY_MCP_URL:-http://host.docker.internal:8765} - OPENMEMORY_USER_ID=${OPENMEMORY_USER_ID:-openmemory} + - MYCELIA_URL=http://mycelia-backend-test:5173 + - MYCELIA_DB=mycelia_test - DISABLE_SPEAKER_RECOGNITION=false - SPEAKER_SERVICE_URL=https://localhost:8085 # Set low inactivity timeout for tests (2 seconds instead of 60) @@ -163,6 +167,60 @@ services: condition: service_started restart: unless-stopped + # Mycelia - AI memory and timeline service (test environment) + mycelia-backend-test: + build: + context: ../../extras/mycelia/backend + dockerfile: Dockerfile.simple + ports: + - "5100:5173" # Test backend port + environment: + # Shared JWT secret for Friend-Lite authentication (test key) + - JWT_SECRET=test-jwt-signing-key-for-integration-tests + - SECRET_KEY=test-jwt-signing-key-for-integration-tests + # MongoDB connection (test database) + - MONGO_URL=mongodb://mongo-test:27017 + - MONGO_DB=mycelia_test + - DATABASE_NAME=mycelia_test + # Redis connection (ioredis uses individual host/port, not URL) + - REDIS_HOST=redis-test + - REDIS_PORT=6379 + volumes: + - ../../extras/mycelia/backend/app:/app/app # Mount source for development + depends_on: + mongo-test: + condition: service_healthy + redis-test: + condition: service_started + healthcheck: + test: ["CMD", "deno", "eval", "fetch('http://localhost:5173/health').then(r => r.ok ? Deno.exit(0) : Deno.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped + profiles: + - mycelia + + mycelia-frontend-test: + build: + context: ../../extras/mycelia + dockerfile: frontend/Dockerfile.simple + args: + - VITE_API_URL=http://localhost:5100 + ports: + - "3002:8080" # Nginx serves on 8080 internally + environment: + - VITE_API_URL=http://localhost:5100 + volumes: + - ../../extras/mycelia/frontend/src:/app/src # Mount source for development + depends_on: + mycelia-backend-test: + condition: service_healthy + restart: unless-stopped + profiles: + - mycelia + # caddy: # image: caddy:2-alpine # ports: diff --git a/backends/advanced/scripts/create_mycelia_api_key.py b/backends/advanced/scripts/create_mycelia_api_key.py new file mode 100755 index 00000000..b12d81ed --- /dev/null +++ b/backends/advanced/scripts/create_mycelia_api_key.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Create a proper Mycelia API key (not OAuth client) for Friend-Lite user.""" + +import base64 +import os +import sys +import secrets +import hashlib +from pymongo import MongoClient +from bson import ObjectId +from datetime import datetime + +# MongoDB configuration +MONGO_URL = os.getenv("MONGO_URL", "mongodb://localhost:27018") +MYCELIA_DB = os.getenv("MYCELIA_DB", os.getenv("DATABASE_NAME", "mycelia_test")) + +# User ID from JWT or argument +USER_ID = os.getenv("USER_ID", "692c7727c7b16bdf58d23cd1") # test user + + +def hash_api_key_with_salt(api_key: str, salt: bytes) -> str: + """Hash API key with salt (matches Mycelia's hashApiKey function).""" + # SHA256(salt + apiKey) in base64 + h = hashlib.sha256() + h.update(salt) + h.update(api_key.encode('utf-8')) + return base64.b64encode(h.digest()).decode('utf-8') # Use base64 like Mycelia + + +def main(): + print(f"๐Ÿ“Š MongoDB Configuration:") + print(f" URL: {MONGO_URL}") + print(f" Database: {MYCELIA_DB}\n") + + print("๐Ÿ” Creating Mycelia API Key\n") + + # Generate API key in Mycelia format: mycelia_{random_base64url} + random_part = secrets.token_urlsafe(32) + api_key = f"mycelia_{random_part}" + + # Generate salt (32 bytes) + salt = secrets.token_bytes(32) + + # Hash the API key with salt + hashed_key = hash_api_key_with_salt(api_key, salt) + + # Open prefix (first 16 chars for fast lookup) + open_prefix = api_key[:16] + + print(f"โœ… Generated API Key:") + print(f" Key: {api_key}") + print(f" Open Prefix: {open_prefix}") + print(f" Owner: {USER_ID}\n") + + # Connect to MongoDB + client = MongoClient(MONGO_URL) + db = client[MYCELIA_DB] + api_keys = db["api_keys"] + + # Check for existing active keys for this user + existing = api_keys.find_one({"owner": USER_ID, "isActive": True}) + if existing: + print(f"โ„น๏ธ Existing active API key found: {existing['_id']}") + print(f" Deactivating old key...\n") + api_keys.update_one( + {"_id": existing["_id"]}, + {"$set": {"isActive": False}} + ) + + # Create API key document (matches Mycelia's format) + api_key_doc = { + "hashedKey": hashed_key, # Note: hashedKey, not hash! + "salt": base64.b64encode(salt).decode('utf-8'), # Store as base64 like Mycelia + "owner": USER_ID, + "name": "Friend-Lite Integration", + "policies": [ + { + "resource": "**", + "action": "*", + "effect": "allow" + } + ], + "openPrefix": open_prefix, + "createdAt": datetime.now(), + "isActive": True, + } + + # Insert into database + result = api_keys.insert_one(api_key_doc) + client_id = str(result.inserted_id) + + print(f"๐ŸŽ‰ API Key Created Successfully!") + print(f" Client ID: {client_id}") + print(f" API Key: {api_key}") + print(f"\n" + "=" * 70) + print("๐Ÿ“‹ MYCELIA CONFIGURATION (Test Environment)") + print("=" * 70) + print(f"\n1๏ธโƒฃ Configure Mycelia Frontend Settings:") + print(f" โ€ข Go to: http://localhost:3002/settings") + print(f" โ€ข API Endpoint: http://localhost:5100") + print(f" โ€ข Client ID: {client_id}") + print(f" โ€ข Client Secret: {api_key}") + print(f" โ€ข Click 'Save' and then 'Test Token'") + print(f"\nโœ… This API key uses the proper Mycelia format with salt!") + print("=" * 70 + "\n") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backends/advanced/scripts/sync_friendlite_mycelia.py b/backends/advanced/scripts/sync_friendlite_mycelia.py new file mode 100644 index 00000000..c7051f2c --- /dev/null +++ b/backends/advanced/scripts/sync_friendlite_mycelia.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +""" +Sync Friend-Lite users with Mycelia OAuth credentials. + +This script helps migrate existing Friend-Lite installations to use Mycelia, +or sync existing Mycelia installations with Friend-Lite users. + +Usage: + # Dry run (preview changes) + python scripts/sync_friendlite_mycelia.py --dry-run + + # Sync all users + python scripts/sync_friendlite_mycelia.py --sync-all + + # Sync specific user + python scripts/sync_friendlite_mycelia.py --email admin@example.com + + # Check for orphaned Mycelia objects + python scripts/sync_friendlite_mycelia.py --check-orphans + + # Reassign orphaned objects to a user + python scripts/sync_friendlite_mycelia.py --reassign-orphans --target-email admin@example.com + +Environment Variables: + MONGODB_URI or MONGO_URL - MongoDB connection string + MYCELIA_DB - Mycelia database name (default: mycelia) +""" + +import os +import sys +import argparse +import secrets +import hashlib +import base64 +from datetime import datetime +from typing import List, Dict, Tuple, Optional +from pymongo import MongoClient +from bson import ObjectId + +# Add parent directory to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + + +class FriendLiteMyceliaSync: + """Sync Friend-Lite users with Mycelia OAuth credentials.""" + + def __init__(self, mongo_url: str, mycelia_db: str, friendlite_db: str): + self.mongo_url = mongo_url + self.mycelia_db = mycelia_db + self.friendlite_db = friendlite_db + self.client = MongoClient(mongo_url) + + print(f"๐Ÿ“Š Connected to MongoDB:") + print(f" URL: {mongo_url}") + print(f" Friend-Lite DB: {friendlite_db}") + print(f" Mycelia DB: {mycelia_db}\n") + + def _hash_api_key_with_salt(self, api_key: str, salt: bytes) -> str: + """Hash API key with salt (matches Mycelia's implementation).""" + h = hashlib.sha256() + h.update(salt) + h.update(api_key.encode('utf-8')) + return base64.b64encode(h.digest()).decode('utf-8') + + def get_all_friendlite_users(self) -> List[Dict]: + """Get all users from Friend-Lite database.""" + db = self.client[self.friendlite_db] + users = list(db["users"].find({})) + return users + + def get_all_mycelia_objects(self) -> List[Dict]: + """Get all objects from Mycelia database.""" + db = self.client[self.mycelia_db] + objects = list(db["objects"].find({})) + return objects + + def get_mycelia_api_key_for_user(self, user_id: str) -> Optional[Dict]: + """Check if user already has a Mycelia API key.""" + db = self.client[self.mycelia_db] + api_key = db["api_keys"].find_one({ + "owner": user_id, + "isActive": True + }) + return api_key + + def create_mycelia_api_key(self, user_id: str, user_email: str, dry_run: bool = False) -> Tuple[str, str]: + """Create a Mycelia API key for a Friend-Lite user.""" + # Generate API key + random_part = secrets.token_urlsafe(32) + api_key = f"mycelia_{random_part}" + salt = secrets.token_bytes(32) + hashed_key = self._hash_api_key_with_salt(api_key, salt) + open_prefix = api_key[:16] + + api_key_doc = { + "hashedKey": hashed_key, + "salt": base64.b64encode(salt).decode('utf-8'), + "owner": user_id, + "name": f"Friend-Lite Auto ({user_email})", + "policies": [{"resource": "**", "action": "*", "effect": "allow"}], + "openPrefix": open_prefix, + "createdAt": datetime.utcnow(), + "isActive": True, + } + + if dry_run: + print(f" [DRY RUN] Would create API key with owner={user_id}") + return "dry-run-client-id", "dry-run-api-key" + + db = self.client[self.mycelia_db] + result = db["api_keys"].insert_one(api_key_doc) + client_id = str(result.inserted_id) + + # Update Friend-Lite user document + fl_db = self.client[self.friendlite_db] + fl_db["users"].update_one( + {"_id": ObjectId(user_id)}, + { + "$set": { + "mycelia_oauth": { + "client_id": client_id, + "created_at": datetime.utcnow(), + "synced": True + } + } + } + ) + + return client_id, api_key + + def sync_user(self, user: Dict, dry_run: bool = False) -> bool: + """Sync a single user to Mycelia OAuth.""" + user_id = str(user["_id"]) + user_email = user.get("email", "unknown") + + # Check if already synced + existing = self.get_mycelia_api_key_for_user(user_id) + if existing: + print(f"โœ“ {user_email:40} Already synced (Client ID: {existing['_id']})") + return False + + # Create new API key + try: + client_id, api_key = self.create_mycelia_api_key(user_id, user_email, dry_run) + + if dry_run: + print(f"โ†’ {user_email:40} [DRY RUN] Would create OAuth credentials") + else: + print(f"โœ“ {user_email:40} Created OAuth credentials") + print(f" Client ID: {client_id}") + print(f" Client Secret: {api_key}") + + return True + except Exception as e: + print(f"โœ— {user_email:40} Failed: {e}") + return False + + def sync_all_users(self, dry_run: bool = False): + """Sync all Friend-Lite users to Mycelia OAuth.""" + users = self.get_all_friendlite_users() + + print(f"{'='*80}") + print(f"SYNC ALL USERS") + print(f"{'='*80}") + print(f"Found {len(users)} Friend-Lite users\n") + + if dry_run: + print("๐Ÿ” DRY RUN MODE - No changes will be made\n") + + synced_count = 0 + for user in users: + if self.sync_user(user, dry_run): + synced_count += 1 + + print(f"\n{'='*80}") + if dry_run: + print(f"DRY RUN SUMMARY: Would sync {synced_count} users") + else: + print(f"SUMMARY: Synced {synced_count} new users, {len(users) - synced_count} already synced") + print(f"{'='*80}\n") + + def check_orphaned_objects(self): + """Find Mycelia objects with userId not matching any Friend-Lite user.""" + users = self.get_all_friendlite_users() + user_ids = {str(user["_id"]) for user in users} + + objects = self.get_all_mycelia_objects() + + print(f"{'='*80}") + print(f"ORPHANED OBJECTS CHECK") + print(f"{'='*80}") + print(f"Friend-Lite users: {len(user_ids)}") + print(f"Mycelia objects: {len(objects)}\n") + + orphaned = [] + user_object_counts = {} + + for obj in objects: + obj_user_id = obj.get("userId") + if obj_user_id: + # Count objects per user + user_object_counts[obj_user_id] = user_object_counts.get(obj_user_id, 0) + 1 + + # Check if orphaned + if obj_user_id not in user_ids: + orphaned.append(obj) + + # Display object distribution + print("Object distribution by userId:") + for user_id, count in sorted(user_object_counts.items(), key=lambda x: x[1], reverse=True): + status = "โœ“" if user_id in user_ids else "โœ— ORPHANED" + print(f" {user_id}: {count:4} objects {status}") + + # Display orphaned objects + if orphaned: + print(f"\nโš ๏ธ Found {len(orphaned)} orphaned objects:") + for obj in orphaned[:10]: # Show first 10 + obj_id = obj.get("_id") + obj_name = obj.get("name", "Unnamed")[:50] + obj_user_id = obj.get("userId") + print(f" {obj_id} - {obj_name} (userId: {obj_user_id})") + + if len(orphaned) > 10: + print(f" ... and {len(orphaned) - 10} more") + else: + print("\nโœ“ No orphaned objects found!") + + print(f"{'='*80}\n") + return orphaned + + def reassign_orphaned_objects(self, target_email: str, dry_run: bool = False): + """Reassign all orphaned objects to a specific Friend-Lite user.""" + # Get target user + fl_db = self.client[self.friendlite_db] + target_user = fl_db["users"].find_one({"email": target_email}) + + if not target_user: + print(f"โœ— User with email '{target_email}' not found in Friend-Lite") + return + + target_user_id = str(target_user["_id"]) + print(f"Target user: {target_email} (ID: {target_user_id})\n") + + # Find orphaned objects + users = self.get_all_friendlite_users() + user_ids = {str(user["_id"]) for user in users} + objects = self.get_all_mycelia_objects() + + orphaned = [obj for obj in objects if obj.get("userId") and obj.get("userId") not in user_ids] + + if not orphaned: + print("โœ“ No orphaned objects to reassign") + return + + print(f"{'='*80}") + print(f"REASSIGN ORPHANED OBJECTS") + print(f"{'='*80}") + print(f"Found {len(orphaned)} orphaned objects") + + if dry_run: + print("๐Ÿ” DRY RUN MODE - No changes will be made\n") + else: + print(f"Will reassign to: {target_email}\n") + + mycelia_db = self.client[self.mycelia_db] + + for obj in orphaned: + obj_id = obj["_id"] + old_user_id = obj.get("userId") + obj_name = obj.get("name", "Unnamed")[:50] + + if dry_run: + print(f"โ†’ [DRY RUN] Would reassign: {obj_name}") + print(f" From: {old_user_id} โ†’ To: {target_user_id}") + else: + result = mycelia_db["objects"].update_one( + {"_id": obj_id}, + {"$set": {"userId": target_user_id}} + ) + if result.modified_count > 0: + print(f"โœ“ Reassigned: {obj_name}") + else: + print(f"โœ— Failed to reassign: {obj_name}") + + print(f"\n{'='*80}") + if dry_run: + print(f"DRY RUN SUMMARY: Would reassign {len(orphaned)} objects to {target_email}") + else: + print(f"SUMMARY: Reassigned {len(orphaned)} objects to {target_email}") + print(f"{'='*80}\n") + + def display_sync_status(self): + """Display current sync status.""" + users = self.get_all_friendlite_users() + + print(f"{'='*80}") + print(f"SYNC STATUS") + print(f"{'='*80}\n") + + synced_count = 0 + unsynced_count = 0 + + print(f"{'Email':<40} {'User ID':<30} {'Status'}") + print(f"{'-'*40} {'-'*30} {'-'*20}") + + for user in users: + user_id = str(user["_id"]) + user_email = user.get("email", "unknown") + + existing = self.get_mycelia_api_key_for_user(user_id) + if existing: + status = f"โœ“ Synced (Client ID: {existing['_id']})" + synced_count += 1 + else: + status = "โœ— Not synced" + unsynced_count += 1 + + print(f"{user_email:<40} {user_id:<30} {status}") + + print(f"\n{'='*80}") + print(f"Total users: {len(users)}") + print(f"Synced: {synced_count}") + print(f"Not synced: {unsynced_count}") + print(f"{'='*80}\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Sync Friend-Lite users with Mycelia OAuth credentials", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + + parser.add_argument("--dry-run", action="store_true", help="Preview changes without making them") + parser.add_argument("--sync-all", action="store_true", help="Sync all Friend-Lite users") + parser.add_argument("--email", type=str, help="Sync specific user by email") + parser.add_argument("--check-orphans", action="store_true", help="Check for orphaned Mycelia objects") + parser.add_argument("--reassign-orphans", action="store_true", help="Reassign orphaned objects to target user") + parser.add_argument("--target-email", type=str, help="Target user email for reassigning orphans") + parser.add_argument("--status", action="store_true", help="Display current sync status") + + args = parser.parse_args() + + # Get configuration from environment + mongo_url = os.getenv("MONGODB_URI") or os.getenv("MONGO_URL", "mongodb://localhost:27017") + + # Extract database name from MONGODB_URI if present + if "/" in mongo_url and mongo_url.count("/") >= 3: + friendlite_db = mongo_url.split("/")[-1].split("?")[0] or "friend-lite" + else: + friendlite_db = "friend-lite" + + mycelia_db = os.getenv("MYCELIA_DB", os.getenv("DATABASE_NAME", "mycelia")) + + # Create sync service + sync = FriendLiteMyceliaSync(mongo_url, mycelia_db, friendlite_db) + + # Execute requested action + if args.status: + sync.display_sync_status() + elif args.sync_all: + sync.sync_all_users(dry_run=args.dry_run) + elif args.email: + fl_db = sync.client[friendlite_db] + user = fl_db["users"].find_one({"email": args.email}) + if user: + sync.sync_user(user, dry_run=args.dry_run) + else: + print(f"โœ— User with email '{args.email}' not found") + elif args.check_orphans: + sync.check_orphaned_objects() + elif args.reassign_orphans: + if not args.target_email: + print("โœ— --target-email required for --reassign-orphans") + sys.exit(1) + sync.reassign_orphaned_objects(args.target_email, dry_run=args.dry_run) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/backends/advanced/src/advanced_omi_backend/app_factory.py b/backends/advanced/src/advanced_omi_backend/app_factory.py index 52a48093..65b1adbf 100644 --- a/backends/advanced/src/advanced_omi_backend/app_factory.py +++ b/backends/advanced/src/advanced_omi_backend/app_factory.py @@ -30,7 +30,7 @@ register_client_to_user, ) from advanced_omi_backend.client_manager import get_client_manager -from advanced_omi_backend.memory import get_memory_service, shutdown_memory_service +from advanced_omi_backend.services.memory import get_memory_service, shutdown_memory_service from advanced_omi_backend.middleware.app_middleware import setup_middleware from advanced_omi_backend.routers.api_router import router as api_router from advanced_omi_backend.routers.modules.health_routes import router as health_router @@ -73,6 +73,13 @@ async def lifespan(app: FastAPI): application_logger.error(f"Failed to create admin user: {e}") # Don't raise here as this is not critical for startup + # Sync admin user with Mycelia OAuth (if using Mycelia memory provider) + try: + from advanced_omi_backend.services.mycelia_sync import sync_admin_on_startup + await sync_admin_on_startup() + except Exception as e: + application_logger.error(f"Failed to sync admin with Mycelia OAuth: {e}") + # Don't raise here as this is not critical for startup # Initialize Redis connection for RQ try: diff --git a/backends/advanced/src/advanced_omi_backend/auth.py b/backends/advanced/src/advanced_omi_backend/auth.py index a39637f1..d06e9e68 100644 --- a/backends/advanced/src/advanced_omi_backend/auth.py +++ b/backends/advanced/src/advanced_omi_backend/auth.py @@ -3,8 +3,10 @@ import logging import os import re +from datetime import datetime, timedelta from typing import Literal, Optional, overload +import jwt from beanie import PydanticObjectId from dotenv import load_dotenv from fastapi import Depends, Request @@ -22,6 +24,9 @@ load_dotenv() +# JWT configuration +JWT_LIFETIME_SECONDS = 86400 # 24 hours + @overload def _verify_configured(var_name: str, *, optional: Literal[False] = False) -> str: ... @@ -82,7 +87,7 @@ async def get_user_manager(user_db=Depends(get_user_db)): # Transport configurations cookie_transport = CookieTransport( - cookie_max_age=86400, # 24 hours (matches JWT lifetime) + cookie_max_age=JWT_LIFETIME_SECONDS, # Matches JWT lifetime cookie_secure=COOKIE_SECURE, # Set to False in development if not using HTTPS cookie_httponly=True, cookie_samesite="lax", @@ -94,8 +99,40 @@ async def get_user_manager(user_db=Depends(get_user_db)): def get_jwt_strategy() -> JWTStrategy: """Get JWT strategy for token generation and validation.""" return JWTStrategy( - secret=SECRET_KEY, lifetime_seconds=86400 - ) # 24 hours for device compatibility + secret=SECRET_KEY, lifetime_seconds=JWT_LIFETIME_SECONDS + ) + + +def generate_jwt_for_user(user_id: str, user_email: str) -> str: + """Generate a JWT token for a user to authenticate with external services. + + This function creates a JWT token that can be used to authenticate with + services that share the same AUTH_SECRET_KEY, such as Mycelia. + + Args: + user_id: User's unique identifier (MongoDB ObjectId as string) + user_email: User's email address + + Returns: + JWT token string valid for 24 hours + + Example: + >>> token = generate_jwt_for_user("507f1f77bcf86cd799439011", "user@example.com") + >>> # Use token to call Mycelia API + """ + # Create JWT payload matching Friend-Lite's standard format + payload = { + "sub": user_id, # Subject = user ID + "email": user_email, + "iss": "friend-lite", # Issuer + "aud": "friend-lite", # Audience + "exp": datetime.utcnow() + timedelta(seconds=JWT_LIFETIME_SECONDS), + "iat": datetime.utcnow(), # Issued at + } + + # Sign the token with the same secret key + token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") + return token # Authentication backends diff --git a/backends/advanced/src/advanced_omi_backend/chat_service.py b/backends/advanced/src/advanced_omi_backend/chat_service.py index 812f8af0..4ec5ecff 100644 --- a/backends/advanced/src/advanced_omi_backend/chat_service.py +++ b/backends/advanced/src/advanced_omi_backend/chat_service.py @@ -22,7 +22,7 @@ from advanced_omi_backend.database import get_database from advanced_omi_backend.llm_client import get_llm_client -from advanced_omi_backend.memory import get_memory_service +from advanced_omi_backend.services.memory import get_memory_service from advanced_omi_backend.users import User logger = logging.getLogger(__name__) diff --git a/backends/advanced/src/advanced_omi_backend/config.py b/backends/advanced/src/advanced_omi_backend/config.py index ceebcad0..4fe83d60 100644 --- a/backends/advanced/src/advanced_omi_backend/config.py +++ b/backends/advanced/src/advanced_omi_backend/config.py @@ -30,9 +30,9 @@ # Default speech detection settings DEFAULT_SPEECH_DETECTION_SETTINGS = { - "min_words": 5, # Minimum words to create conversation - "min_confidence": 0.5, # Word confidence threshold (unified) - "min_duration": 2.0, # Minimum speech duration (seconds) + "min_words": 10, # Minimum words to create conversation (increased from 5) + "min_confidence": 0.7, # Word confidence threshold (increased from 0.5) + "min_duration": 10.0, # Minimum speech duration in seconds (increased from 2.0) } # Default conversation stop settings @@ -137,6 +137,7 @@ def get_speech_detection_settings(): return { "min_words": int(os.getenv("SPEECH_DETECTION_MIN_WORDS", DEFAULT_SPEECH_DETECTION_SETTINGS["min_words"])), "min_confidence": float(os.getenv("SPEECH_DETECTION_MIN_CONFIDENCE", DEFAULT_SPEECH_DETECTION_SETTINGS["min_confidence"])), + "min_duration": float(os.getenv("SPEECH_DETECTION_MIN_DURATION", DEFAULT_SPEECH_DETECTION_SETTINGS["min_duration"])), } 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 e5f576c2..f52167de 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py @@ -8,7 +8,8 @@ from fastapi.responses import JSONResponse -from advanced_omi_backend.memory import get_memory_service +from advanced_omi_backend.services.memory import get_memory_service +from advanced_omi_backend.services.memory.base import MemoryEntry from advanced_omi_backend.users import User logger = logging.getLogger(__name__) @@ -27,13 +28,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) + # Convert MemoryEntry objects to dicts for JSON serialization + memories_dicts = [mem.to_dict() for mem in memories] + return { - "memories": memories, - "count": len(memories), + "memories": memories_dicts, + "count": len(memories), "total_count": total_count, "user_id": target_user_id } @@ -87,9 +91,12 @@ async def search_memories(query: str, user: User, limit: int, score_threshold: f # Execute search directly (now async) search_results = await memory_service.search_memories(query, target_user_id, limit, score_threshold) + # Convert MemoryEntry objects to dicts for JSON serialization + results_dicts = [result.to_dict() for result in search_results] + return { "query": query, - "results": search_results, + "results": results_dicts, "count": len(search_results), "user_id": target_user_id, } @@ -111,12 +118,14 @@ async def delete_memory(memory_id: str, user: User): # Check if memory belongs to current user user_memories = await memory_service.get_all_memories(user.user_id, 1000) - memory_ids = [str(mem.get("id", mem.get("memory_id", ""))) for mem in user_memories] + # MemoryEntry is a dataclass, access id attribute directly + memory_ids = [str(mem.id) for mem in user_memories] if memory_id not in memory_ids: return JSONResponse(status_code=404, content={"message": "Memory not found"}) - # Delete the memory - success = await memory_service.delete_memory(memory_id) + # Delete the memory (pass user_id and user_email for Mycelia authentication) + audio_logger.info(f"Deleting memory {memory_id} for user_id={user.user_id}, email={user.email}") + success = await memory_service.delete_memory(memory_id, user_id=user.user_id, user_email=user.email) if success: return JSONResponse(content={"message": f"Memory {memory_id} deleted successfully"}) @@ -157,6 +166,46 @@ async def get_memories_unfiltered(user: User, limit: int, user_id: Optional[str] ) +async def add_memory(content: str, user: User, source_id: Optional[str] = None): + """Add a memory directly from content text. Extracts structured memories from the provided content.""" + try: + memory_service = get_memory_service() + + # Use source_id or generate a unique one + memory_source_id = source_id or f"manual_{user.user_id}_{int(asyncio.get_event_loop().time())}" + + # Extract memories from content + success, memory_ids = await memory_service.add_memory( + transcript=content, + client_id=f"{user.user_id[:8]}-manual", + source_id=memory_source_id, + user_id=user.user_id, + user_email=user.email, + allow_update=False, + db_helper=None + ) + + if success: + return { + "success": True, + "memory_ids": memory_ids, + "count": len(memory_ids), + "source_id": memory_source_id, + "message": f"Successfully created {len(memory_ids)} memory/memories" + } + else: + return JSONResponse( + status_code=500, + content={"success": False, "message": "Failed to create memories"} + ) + + except Exception as e: + audio_logger.error(f"Error adding memory: {e}", exc_info=True) + return JSONResponse( + status_code=500, content={"success": False, "message": f"Error adding memory: {str(e)}"} + ) + + async def get_all_memories_admin(user: User, limit: int): """Get all memories across all users for admin review. Admin only.""" try: @@ -204,3 +253,30 @@ async def get_all_memories_admin(user: User, limit: int): return JSONResponse( status_code=500, content={"message": f"Error fetching admin memories: {str(e)}"} ) + + +async def get_memory_by_id(memory_id: str, user: User, user_id: Optional[str] = None): + """Get a single memory by ID. Users can only access their own memories, admins can access any.""" + try: + memory_service = get_memory_service() + + # Determine which user's memory to fetch + target_user_id = user.user_id + if user.is_superuser and user_id: + target_user_id = user_id + + # Get the specific memory + memory = await memory_service.get_memory(memory_id, target_user_id) + + if memory: + # Convert MemoryEntry to dict for JSON serialization + memory_dict = memory.to_dict() + return {"memory": memory_dict} + else: + return JSONResponse(status_code=404, content={"message": "Memory not found"}) + + except Exception as e: + audio_logger.error(f"Error fetching memory {memory_id}: {e}", exc_info=True) + return JSONResponse( + status_code=500, content={"message": f"Error fetching memory: {str(e)}"} + ) 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 5bc0b35d..9341cc59 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py @@ -442,22 +442,22 @@ async def reload_memory_config(): async def delete_all_user_memories(user: User): """Delete all memories for the current user.""" try: - from advanced_omi_backend.memory import get_memory_service - + from advanced_omi_backend.services.memory import get_memory_service + memory_service = get_memory_service() - + # Delete all memories for the user deleted_count = await memory_service.delete_all_user_memories(user.user_id) - + logger.info(f"Deleted {deleted_count} memories for user {user.user_id}") - + return { "message": f"Successfully deleted {deleted_count} memories", "deleted_count": deleted_count, "user_id": user.user_id, "status": "success" } - + except Exception as e: logger.error(f"Error deleting all memories for user {user.user_id}: {e}") return JSONResponse( @@ -465,3 +465,97 @@ async def delete_all_user_memories(user: User): ) +# Memory Provider Configuration Functions + +async def get_memory_provider(): + """Get current memory provider configuration.""" + try: + current_provider = os.getenv("MEMORY_PROVIDER", "friend_lite").lower() + + # Get available providers + available_providers = ["friend_lite", "openmemory_mcp", "mycelia"] + + return { + "current_provider": current_provider, + "available_providers": available_providers, + "status": "success" + } + + except Exception as e: + logger.error(f"Error getting memory provider: {e}") + return JSONResponse( + status_code=500, content={"error": f"Failed to get memory provider: {str(e)}"} + ) + + +async def set_memory_provider(provider: str): + """Set memory provider and update .env file.""" + try: + # Validate provider + provider = provider.lower().strip() + valid_providers = ["friend_lite", "openmemory_mcp", "mycelia"] + + if provider not in valid_providers: + return JSONResponse( + status_code=400, + content={"error": f"Invalid provider '{provider}'. Valid providers: {', '.join(valid_providers)}"} + ) + + # Path to .env file (assuming we're running from backends/advanced/) + env_path = os.path.join(os.getcwd(), ".env") + + if not os.path.exists(env_path): + return JSONResponse( + status_code=404, + content={"error": f".env file not found at {env_path}"} + ) + + # Read current .env file + with open(env_path, 'r') as file: + lines = file.readlines() + + # Update or add MEMORY_PROVIDER line + provider_found = False + updated_lines = [] + + for line in lines: + if line.strip().startswith("MEMORY_PROVIDER="): + updated_lines.append(f"MEMORY_PROVIDER={provider}\n") + provider_found = True + else: + updated_lines.append(line) + + # If MEMORY_PROVIDER wasn't found, add it + if not provider_found: + updated_lines.append(f"\n# Memory Provider Configuration\nMEMORY_PROVIDER={provider}\n") + + # Create backup + backup_path = f"{env_path}.bak" + shutil.copy2(env_path, backup_path) + logger.info(f"Created .env backup at {backup_path}") + + # Write updated .env file + with open(env_path, 'w') as file: + file.writelines(updated_lines) + + # Update environment variable for current process + os.environ["MEMORY_PROVIDER"] = provider + + logger.info(f"Updated MEMORY_PROVIDER to '{provider}' in .env file") + + return { + "message": f"Memory provider updated to '{provider}'. Please restart the backend service for changes to take effect.", + "provider": provider, + "env_path": env_path, + "backup_created": True, + "requires_restart": True, + "status": "success" + } + + except Exception as e: + logger.error(f"Error setting memory provider: {e}") + return JSONResponse( + status_code=500, content={"error": f"Failed to set memory provider: {str(e)}"} + ) + + diff --git a/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py index ba7dd753..a1b9c140 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py @@ -16,7 +16,7 @@ ) from advanced_omi_backend.client_manager import get_user_clients_all from advanced_omi_backend.database import db, users_col -from advanced_omi_backend.memory import get_memory_service +from advanced_omi_backend.services.memory import get_memory_service from advanced_omi_backend.models.conversation import Conversation from advanced_omi_backend.users import User, UserCreate, UserUpdate diff --git a/backends/advanced/src/advanced_omi_backend/memory/__init__.py b/backends/advanced/src/advanced_omi_backend/memory/__init__.py deleted file mode 100644 index 1fcc786a..00000000 --- a/backends/advanced/src/advanced_omi_backend/memory/__init__.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Memory service package. - -This package provides memory management functionality with support for -multiple LLM providers and vector stores for the Omi backend. - -The memory service handles extraction, storage, and retrieval of memories -from user conversations and interactions. - -Architecture: -- base.py: Abstract base classes and interfaces -- memory_service.py: Core implementation -- compat_service.py: Backward compatibility wrapper -- providers/: LLM and vector store implementations -- config.py: Configuration management -""" - -import logging - -memory_logger = logging.getLogger("memory_service") - -# Initialize core functions to None -get_memory_service = None -MemoryService = None -shutdown_memory_service = None -test_new_memory_service = None -migrate_from_mem0 = None - -memory_logger.info("๐Ÿ†• Using NEW memory service implementation") -try: - from .compat_service import ( - MemoryService, - get_memory_service, - migrate_from_mem0, - shutdown_memory_service, - ) - - # Also import core implementation for direct access - from .memory_service import MemoryService as CoreMemoryService - test_new_memory_service = None # Will be implemented if needed -except ImportError as e: - memory_logger.error(f"Failed to import new memory service: {e}") - raise - -# Also export the new architecture components for direct access when needed -try: - from .base import LLMProviderBase, MemoryEntry, MemoryServiceBase, VectorStoreBase - from .config import MemoryProvider # New memory provider enum - from .config import create_openmemory_config # New OpenMemory config function - from .config import ( - LLMProvider, - MemoryConfig, - VectorStoreProvider, - build_memory_config_from_env, - create_ollama_config, - create_openai_config, - create_qdrant_config, - ) - from .providers import OpenMemoryMCPService # New complete memory service - from .providers import MCPClient, MCPError, OpenAIProvider, QdrantVectorStore - from .service_factory import create_memory_service - from .service_factory import get_memory_service as get_core_memory_service - from .service_factory import get_service_info as get_core_service_info - from .service_factory import reset_memory_service - from .service_factory import shutdown_memory_service as shutdown_core_memory_service - - # Keep backward compatibility alias - AbstractMemoryService = CoreMemoryService -except ImportError as e: - memory_logger.warning(f"Some advanced memory service components not available: {e}") - MemoryServiceBase = None - LLMProviderBase = None - VectorStoreBase = None - AbstractMemoryService = None - MemoryConfig = None - LLMProvider = None - VectorStoreProvider = None - MemoryProvider = None - build_memory_config_from_env = None - create_openai_config = None - create_ollama_config = None - create_qdrant_config = None - create_openmemory_config = None - MemoryEntry = None - OpenAIProvider = None - QdrantVectorStore = None - OpenMemoryMCPService = None - MCPClient = None - MCPError = None - get_core_memory_service = None - create_memory_service = None - shutdown_core_memory_service = None - reset_memory_service = None - get_core_service_info = None - -__all__ = [ - # Main interface (compatible with legacy) - "get_memory_service", - "MemoryService", - "shutdown_memory_service", - - # New service specific (may be None if not available) - "test_new_memory_service", - "migrate_from_mem0", - "CoreMemoryService", - - # Base classes (new architecture) - "MemoryServiceBase", - "LLMProviderBase", - "VectorStoreBase", - - # Advanced components (may be None if not available) - "AbstractMemoryService", # Backward compatibility alias - "MemoryConfig", - "MemoryEntry", - "LLMProvider", - "VectorStoreProvider", - "MemoryProvider", # New enum - "build_memory_config_from_env", - "create_openai_config", - "create_ollama_config", - "create_qdrant_config", - "create_openmemory_config", # New function - "OpenAIProvider", - "QdrantVectorStore", - - # Complete memory service implementations - "OpenMemoryMCPService", - - # MCP client components - "MCPClient", - "MCPError", - - # Service factory functions - "get_core_memory_service", - "create_memory_service", - "shutdown_core_memory_service", - "reset_memory_service", - "get_core_service_info" -] - -def get_service_info(): - """Get information about which service is currently active.""" - return { - "active_service": "new", # Always use new service - "new_service_available": CoreMemoryService is not None, - "legacy_service_available": True, # Assume always available - "base_classes_available": MemoryServiceBase is not None, - "core_service_available": CoreMemoryService is not None - } \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/memory/compat_service.py b/backends/advanced/src/advanced_omi_backend/memory/compat_service.py deleted file mode 100644 index 3814f29e..00000000 --- a/backends/advanced/src/advanced_omi_backend/memory/compat_service.py +++ /dev/null @@ -1,460 +0,0 @@ -"""Compatibility service for backward compatibility. - -This module provides a drop-in replacement for the original mem0-based -memory service, maintaining the same interface while using the new -architecture internally. -""" - -import json -import logging -import os -from typing import Any, Dict, List, Optional, Tuple - -from .config import build_memory_config_from_env -from .memory_service import MemoryService as CoreMemoryService - -memory_logger = logging.getLogger("memory_service") - - -class MemoryService: - """Drop-in replacement for the original mem0-based MemoryService. - - This class provides backward compatibility by wrapping the new - CoreMemoryService with the same interface as the original service. - It handles data format conversion and maintains compatibility with - existing code. - - Attributes: - _service: Internal CoreMemoryService instance - _initialized: Whether the service has been initialized - """ - - def __init__(self): - """Initialize the compatibility memory service.""" - self._service: Optional[CoreMemoryService] = None - self._initialized = False - - async def initialize(self): - """Initialize the memory service. - - Raises: - RuntimeError: If initialization fails - """ - if self._initialized: - return - - try: - config = build_memory_config_from_env() - self._service = CoreMemoryService(config) - await self._service.initialize() - self._initialized = True - memory_logger.info("โœ… Memory service initialized successfully") - except Exception as e: - memory_logger.error(f"Failed to initialize memory service: {e}") - raise - - async def add_memory( - self, - transcript: str, - client_id: str, - source_id: str, - user_id: str, - user_email: str, - allow_update: bool = False, - db_helper=None, - ) -> Tuple[bool, List[str]]: - """Add memory from transcript - compatible with original interface. - - Args: - transcript: Raw transcript text to extract memories from - client_id: Client identifier - source_id: Unique identifier for the source (audio session, chat session, etc.) - user_id: User identifier - user_email: User email address - allow_update: Whether to allow updating existing memories - db_helper: Optional database helper for tracking relationships - - Returns: - Tuple of (success: bool, created_memory_ids: List[str]) - """ - if not self._initialized: - await self.initialize() - - # Ensure service is initialized if it's not the internal CoreMemoryService - if hasattr(self._service, 'initialize') and hasattr(self._service, '_initialized'): - if not self._service._initialized: - await self._service.initialize() - - return await self._service.add_memory( - transcript=transcript, - client_id=client_id, - source_id=source_id, - user_id=user_id, - user_email=user_email, - allow_update=allow_update, - db_helper=db_helper - ) - - def _normalize_memory_content(self, content: str, metadata: Dict[str, Any]) -> str: - """Return memory content as-is since individual facts are now stored separately. - - Args: - content: Memory content from the provider - metadata: Memory metadata (not used) - - Returns: - Content as-is (no normalization needed) - """ - return content - - async def get_all_memories(self, user_id: str, limit: int = 100) -> List[Dict[str, Any]]: - """Get all memories for a user - returns dict format for compatibility. - - Args: - user_id: User identifier - limit: Maximum number of memories to return - - Returns: - List of memory dictionaries in legacy format - """ - if not self._initialized: - await self.initialize() - - memories = await self._service.get_all_memories(user_id, limit) - - # Convert MemoryEntry objects to dict format for compatibility with normalized content - return [ - { - "id": memory.id, - "memory": self._normalize_memory_content(memory.content, memory.metadata), - "metadata": memory.metadata, - "created_at": memory.created_at, - "score": memory.score - } - 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. - - Args: - user_id: User identifier - limit: Maximum number of memories to return - - Returns: - List of memory dictionaries in legacy format - """ - return await self.get_all_memories(user_id, limit) - - 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 - """ - if not self._initialized: - await self.initialize() - - memories = await self._service.search_memories(query, user_id, limit, score_threshold) - - # Convert MemoryEntry objects to dict format for compatibility with normalized content - return [ - { - "id": memory.id, - "memory": self._normalize_memory_content(memory.content, memory.metadata), - "metadata": memory.metadata, - "created_at": memory.created_at, - "score": memory.score - } - for memory in memories - ] - - async def delete_all_user_memories(self, user_id: str) -> int: - """Delete all memories for a user and return count. - - Args: - user_id: User identifier - - Returns: - Number of memories that were deleted - """ - if not self._initialized: - await self.initialize() - - return await self._service.delete_all_user_memories(user_id) - - async def delete_memory(self, memory_id: str) -> bool: - """Delete a specific memory by ID. - - Args: - memory_id: Unique identifier of the memory to delete - - Returns: - True if successfully deleted, False otherwise - """ - if not self._initialized: - await self.initialize() - - return await self._service.delete_memory(memory_id) - - async def get_all_memories_debug(self, limit: int = 200) -> List[Dict[str, Any]]: - """Get all memories across all users for admin debugging. - - Args: - limit: Maximum number of memories to return - - Returns: - List of memory dictionaries with user context for debugging - """ - if not self._initialized: - await self.initialize() - - # Import User model to get all users - try: - from advanced_omi_backend.users import User - except ImportError: - memory_logger.error("Cannot import User model for debug function") - return [] - - all_memories = [] - users = await User.find_all().to_list() - - for user in users: - user_id = str(user.id) - try: - user_memories = await self.get_all_memories(user_id) - - # Add user context for debugging - for memory in user_memories: - memory_entry = { - **memory, - "user_id": user_id, - "owner_email": user.email, - "collection": "omi_memories" - } - all_memories.append(memory_entry) - - # Respect limit - if len(all_memories) >= limit: - break - - except Exception as e: - memory_logger.warning(f"Error getting memories for user {user_id}: {e}") - continue - - return all_memories[:limit] - - async def get_memories_with_transcripts(self, user_id: str, limit: int = 100) -> List[Dict[str, Any]]: - """Get memories with their source transcripts using database relationship. - - Args: - user_id: User identifier - limit: Maximum number of memories to return - - Returns: - List of enriched memory dictionaries with transcript information - """ - if not self._initialized: - await self.initialize() - - # Get memories first - memories = await self.get_all_memories(user_id, limit) - - # Import Conversation model - try: - from advanced_omi_backend.models.conversation import Conversation - except ImportError: - memory_logger.error("Cannot import Conversation model") - return memories # Return memories without transcript enrichment - - # Extract source IDs for bulk query - source_ids = [] - for memory in memories: - metadata = memory.get("metadata", {}) - source_id = metadata.get("source_id") or metadata.get("audio_uuid") # Backward compatibility - if source_id: - source_ids.append(source_id) - - # Bulk query for conversations (support both old audio_uuid and new source_id) - conversations_list = await Conversation.find( - Conversation.audio_uuid.in_(source_ids) - ).to_list() - - conversations_by_id = {} - for conv in conversations_list: - conversations_by_id[conv.audio_uuid] = conv - - enriched_memories = [] - - for memory in memories: - enriched_memory = { - "memory_id": memory.get("id", "unknown"), - "memory_text": memory.get("memory", ""), - "created_at": memory.get("created_at", ""), - "metadata": memory.get("metadata", {}), - "source_id": None, - "transcript": None, - "client_id": None, - "user_email": None, - "compression_ratio": 0, - "transcript_length": 0, - "memory_length": 0, - } - - # Extract source_id from memory metadata (with backward compatibility) - metadata = memory.get("metadata", {}) - source_id = metadata.get("source_id") or metadata.get("audio_uuid") - - if source_id: - enriched_memory["source_id"] = source_id - enriched_memory["client_id"] = metadata.get("client_id") - enriched_memory["user_email"] = metadata.get("user_email") - - # Get transcript from bulk-loaded conversations - conversation = conversations_by_id.get(source_id) - if conversation: - transcript_segments = conversation.segments - if transcript_segments: - full_transcript = " ".join( - segment.text - for segment in transcript_segments - if segment.text - ) - - if full_transcript.strip(): - enriched_memory["transcript"] = full_transcript - enriched_memory["transcript_length"] = len(full_transcript) - - memory_text = enriched_memory["memory_text"] - enriched_memory["memory_length"] = len(memory_text) - - # Calculate compression ratio - if len(full_transcript) > 0: - enriched_memory["compression_ratio"] = round( - (len(memory_text) / len(full_transcript)) * 100, 1 - ) - - enriched_memories.append(enriched_memory) - - return enriched_memories - - async def test_connection(self) -> bool: - """Test memory service connection. - - Returns: - True if connection successful, False otherwise - """ - try: - if not self._initialized: - await self.initialize() - return await self._service.test_connection() - except Exception as e: - memory_logger.error(f"Connection test failed: {e}") - return False - - def shutdown(self): - """Shutdown the memory service and clean up resources.""" - if self._service: - self._service.shutdown() - self._initialized = False - self._service = None - memory_logger.info("Memory service shut down") - - -# Global service instance - maintains compatibility with original code -_memory_service = None - - -def get_memory_service() -> MemoryService: - """Get the global memory service instance. - - Returns: - Global MemoryService instance (singleton pattern), wrapped for compatibility - """ - global _memory_service - if _memory_service is None: - # Use the new service factory to create the appropriate service - from .service_factory import get_memory_service as get_core_service - - core_service = get_core_service() - - # If it's already a compat service, use it directly - if isinstance(core_service, MemoryService): - _memory_service = core_service - else: - # Wrap core service with compat layer - _memory_service = MemoryService() - _memory_service._service = core_service - _memory_service._initialized = True - - return _memory_service - - -def shutdown_memory_service(): - """Shutdown the global memory service and clean up resources.""" - global _memory_service - if _memory_service: - _memory_service.shutdown() - _memory_service = None - - # Also shutdown the core service - from .service_factory import shutdown_memory_service as shutdown_core_service - shutdown_core_service() - - -# Migration helper functions -async def migrate_from_mem0(): - """Helper function to migrate existing mem0 data to new format. - - This is a placeholder for migration logic. Actual implementation - would depend on the specific mem0 setup and data format. - - Raises: - RuntimeError: If migration fails - """ - memory_logger.info("๐Ÿ”„ Starting migration from mem0 to new memory service") - - try: - # Initialize new memory service - new_service = get_memory_service() - await new_service.initialize() - - # Get all users - try: - from advanced_omi_backend.users import User - users = await User.find_all().to_list() - except ImportError: - memory_logger.error("Cannot import User model for migration") - return - - # Migration steps would go here: - # 1. For each user, get their mem0 memories (if accessible) - # 2. Convert to new format - # 3. Store in new system - - memory_logger.info("โœ… Migration completed successfully") - - except Exception as e: - memory_logger.error(f"โŒ Migration failed: {e}") - raise \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/memory/providers/__init__.py b/backends/advanced/src/advanced_omi_backend/memory/providers/__init__.py deleted file mode 100644 index 59ded58e..00000000 --- a/backends/advanced/src/advanced_omi_backend/memory/providers/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Memory service providers package. - -This package contains implementations of LLM providers, vector stores, -and complete memory service implementations for the memory service architecture. -""" - -from ..base import LLMProviderBase, VectorStoreBase, MemoryEntry -from .llm_providers import OpenAIProvider -from .vector_stores import QdrantVectorStore - -# Import complete memory service implementations -try: - from .openmemory_mcp_service import OpenMemoryMCPService -except ImportError: - OpenMemoryMCPService = None - -try: - from .mcp_client import MCPClient, MCPError -except ImportError: - MCPClient = None - MCPError = None - -__all__ = [ - # Base classes - "LLMProviderBase", - "VectorStoreBase", - "MemoryEntry", - - # LLM providers - "OpenAIProvider", - - # Vector stores - "QdrantVectorStore", - - # Complete memory service implementations - "OpenMemoryMCPService", - - # MCP client components - "MCPClient", - "MCPError", -] \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/models/conversation.py b/backends/advanced/src/advanced_omi_backend/models/conversation.py index 7caf8a55..55c31244 100644 --- a/backends/advanced/src/advanced_omi_backend/models/conversation.py +++ b/backends/advanced/src/advanced_omi_backend/models/conversation.py @@ -30,6 +30,7 @@ class MemoryProvider(str, Enum): """Supported memory providers.""" FRIEND_LITE = "friend_lite" OPENMEMORY_MCP = "openmemory_mcp" + MYCELIA = "mycelia" class ConversationStatus(str, Enum): """Conversation processing status.""" diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/health_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/health_routes.py index 37913c48..06e0da1e 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/health_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/health_routes.py @@ -18,7 +18,7 @@ from advanced_omi_backend.controllers.queue_controller import redis_conn from advanced_omi_backend.client_manager import get_client_manager from advanced_omi_backend.llm_client import async_health_check -from advanced_omi_backend.memory import get_memory_service +from advanced_omi_backend.services.memory import get_memory_service from advanced_omi_backend.services.transcription import get_transcription_provider # Create router @@ -273,6 +273,42 @@ async def health_check(): "provider": "openmemory_mcp", "critical": False, } + elif memory_provider == "mycelia": + # Mycelia memory service check + try: + # Test Mycelia 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": "โœ… Mycelia Memory Connected", + "healthy": True, + "provider": "mycelia", + "critical": False, + } + else: + health_status["services"]["memory_service"] = { + "status": "โš ๏ธ Mycelia Memory Test Failed", + "healthy": False, + "provider": "mycelia", + "critical": False, + } + overall_healthy = False + except asyncio.TimeoutError: + health_status["services"]["memory_service"] = { + "status": "โš ๏ธ Mycelia Memory Timeout (8s) - Check Mycelia service", + "healthy": False, + "provider": "mycelia", + "critical": False, + } + overall_healthy = False + except Exception as e: + health_status["services"]["memory_service"] = { + "status": f"โš ๏ธ Mycelia Memory Failed: {str(e)}", + "healthy": False, + "provider": "mycelia", + "critical": False, + } + overall_healthy = False else: health_status["services"]["memory_service"] = { "status": f"โŒ Unknown memory provider: {memory_provider}", 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 4d71ce6d..93ad0f6b 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 @@ -7,7 +7,8 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Body +from pydantic import BaseModel from advanced_omi_backend.auth import current_active_user, current_superuser from advanced_omi_backend.controllers import memory_controller @@ -18,6 +19,12 @@ router = APIRouter(prefix="/memories", tags=["memories"]) +class AddMemoryRequest(BaseModel): + """Request model for adding a memory.""" + content: str + source_id: Optional[str] = None + + @router.get("") async def get_memories( current_user: User = Depends(current_active_user), @@ -50,6 +57,15 @@ async def search_memories( return await memory_controller.search_memories(query, current_user, limit, score_threshold, user_id) +@router.post("") +async def add_memory( + request: AddMemoryRequest, + current_user: User = Depends(current_active_user) +): + """Add a memory directly from content text. The service will extract structured memories from the provided content.""" + return await memory_controller.add_memory(request.content, current_user, request.source_id) + + @router.delete("/{memory_id}") async def delete_memory(memory_id: str, current_user: User = Depends(current_active_user)): """Delete a memory by ID. Users can only delete their own memories, admins can delete any.""" @@ -70,3 +86,13 @@ async def get_memories_unfiltered( async def get_all_memories_admin(current_user: User = Depends(current_superuser), limit: int = 200): """Get all memories across all users for admin review. Admin only.""" return await memory_controller.get_all_memories_admin(current_user, limit) + + +@router.get("/{memory_id}") +async def get_memory_by_id( + memory_id: str, + current_user: User = Depends(current_active_user), + user_id: Optional[str] = Query(default=None, description="User ID filter (admin only)"), +): + """Get a single memory by ID. Users can only access their own memories, admins can access any.""" + return await memory_controller.get_memory_by_id(memory_id, current_user, user_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 3c97bd55..10587b5c 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 @@ -126,3 +126,20 @@ async def cleanup_stuck_stream_workers(request: Request, current_user: User = De async def cleanup_old_sessions(request: Request, max_age_seconds: int = 3600, current_user: User = Depends(current_superuser)): """Clean up old session tracking metadata. Admin only.""" return await session_controller.cleanup_old_sessions(request, max_age_seconds) + + +# Memory Provider Configuration Endpoints + +@router.get("/admin/memory/provider") +async def get_memory_provider(current_user: User = Depends(current_superuser)): + """Get current memory provider configuration. Admin only.""" + return await system_controller.get_memory_provider() + + +@router.post("/admin/memory/provider") +async def set_memory_provider( + provider: str = Body(..., embed=True), + current_user: User = Depends(current_superuser) +): + """Set memory provider and restart backend services. Admin only.""" + return await system_controller.set_memory_provider(provider) diff --git a/backends/advanced/src/advanced_omi_backend/services/audio_stream/producer.py b/backends/advanced/src/advanced_omi_backend/services/audio_stream/producer.py index 95bf25e1..66b0acf7 100644 --- a/backends/advanced/src/advanced_omi_backend/services/audio_stream/producer.py +++ b/backends/advanced/src/advanced_omi_backend/services/audio_stream/producer.py @@ -7,7 +7,7 @@ import redis.asyncio as redis -from advanced_omi_backend.models.transcription import TranscriptionProvider +from advanced_omi_backend.services.transcription.base import TranscriptionProvider logger = logging.getLogger(__name__) diff --git a/backends/advanced/src/advanced_omi_backend/memory/README.md b/backends/advanced/src/advanced_omi_backend/services/memory/README.md similarity index 100% rename from backends/advanced/src/advanced_omi_backend/memory/README.md rename to backends/advanced/src/advanced_omi_backend/services/memory/README.md diff --git a/backends/advanced/src/advanced_omi_backend/services/memory/__init__.py b/backends/advanced/src/advanced_omi_backend/services/memory/__init__.py new file mode 100644 index 00000000..c2413ff2 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/memory/__init__.py @@ -0,0 +1,30 @@ +"""Memory service package. + +This package provides memory management functionality with support for +multiple memory providers (Friend-Lite, Mycelia, OpenMemory MCP). + +The memory service handles extraction, storage, and retrieval of memories +from user conversations and interactions. + +Architecture: +- base.py: Abstract base classes and interfaces +- config.py: Configuration management +- service_factory.py: Provider selection and instantiation +- providers/friend_lite.py: Friend-Lite native provider (LLM + Qdrant) +- providers/mycelia.py: Mycelia backend provider +- providers/openmemory_mcp.py: OpenMemory MCP provider +- providers/llm_providers.py: LLM implementations (OpenAI, Ollama) +- providers/vector_stores.py: Vector store implementations (Qdrant) +""" + +import logging + +memory_logger = logging.getLogger("memory_service") + +# Import the main interface functions from service_factory +from .service_factory import get_memory_service, shutdown_memory_service + +__all__ = [ + "get_memory_service", + "shutdown_memory_service", +] diff --git a/backends/advanced/src/advanced_omi_backend/memory/base.py b/backends/advanced/src/advanced_omi_backend/services/memory/base.py similarity index 78% rename from backends/advanced/src/advanced_omi_backend/memory/base.py rename to backends/advanced/src/advanced_omi_backend/services/memory/base.py index 65d39d75..e88e42d4 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/base.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/base.py @@ -49,6 +49,19 @@ def __post_init__(self): if self.created_at is None: self.created_at = str(int(time.time())) + def to_dict(self) -> Dict[str, Any]: + """Convert MemoryEntry to dictionary for JSON serialization.""" + return { + "id": self.id, + "memory": self.content, # Frontend expects 'memory' key + "content": self.content, # Also provide 'content' for consistency + "metadata": self.metadata, + "embedding": self.embedding, + "score": self.score, + "created_at": self.created_at, + "user_id": self.metadata.get("user_id") # Extract user_id from metadata + } + class MemoryServiceBase(ABC): """Abstract base class defining the core memory service interface. @@ -137,25 +150,68 @@ async def get_all_memories( 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 - + + async def get_memory(self, memory_id: str, user_id: Optional[str] = None) -> Optional[MemoryEntry]: + """Get a specific memory by ID. + + This is an optional method that providers can implement for fetching + individual memories. Returns None if the provider doesn't support it + or the memory is not found. + + Args: + memory_id: Unique identifier of the memory to retrieve + user_id: Optional user ID for authentication/filtering + + Returns: + MemoryEntry object if found, None otherwise + """ + return None + + async def update_memory( + self, + memory_id: str, + content: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + user_id: Optional[str] = None, + user_email: Optional[str] = None + ) -> bool: + """Update a specific memory's content and/or metadata. + + This is an optional method that providers can implement for updating + existing memories. Returns False if not supported or update fails. + + Args: + memory_id: Unique identifier of the memory to update + content: New content for the memory (if None, content is not updated) + metadata: New metadata to merge with existing (if None, metadata is not updated) + user_id: Optional user ID for authentication + user_email: Optional user email for authentication + + Returns: + True if update succeeded, False otherwise + """ + return False + @abstractmethod - async def delete_memory(self, memory_id: str) -> bool: + async def delete_memory(self, memory_id: str, user_id: Optional[str] = None, user_email: Optional[str] = None) -> bool: """Delete a specific memory by ID. - + Args: memory_id: Unique identifier of the memory to delete - + user_id: Optional user ID for authentication (required for Mycelia provider) + user_email: Optional user email for authentication (required for Mycelia provider) + Returns: True if successfully deleted, False otherwise """ @@ -190,6 +246,27 @@ def shutdown(self) -> None: """ pass + def __init__(self): + """Initialize base memory service state. + + Subclasses should call super().__init__() in their constructors. + """ + self._initialized = False + + async def _ensure_initialized(self) -> None: + """Ensure the memory service is initialized before use. + + This method provides lazy initialization - it will automatically + call initialize() the first time it's needed. This is critical + for services used in RQ workers where the service instance is + created in one process but used in another. + + This should be called at the start of any method that requires + the service to be initialized (e.g., add_memory, search_memories). + """ + if not self._initialized: + await self.initialize() + class LLMProviderBase(ABC): """Abstract base class for LLM provider implementations. diff --git a/backends/advanced/src/advanced_omi_backend/memory/config.py b/backends/advanced/src/advanced_omi_backend/services/memory/config.py similarity index 85% rename from backends/advanced/src/advanced_omi_backend/memory/config.py rename to backends/advanced/src/advanced_omi_backend/services/memory/config.py index 99e79d38..3946deae 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/config.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/config.py @@ -36,6 +36,7 @@ class MemoryProvider(Enum): """Supported memory service providers.""" FRIEND_LITE = "friend_lite" # Default sophisticated implementation OPENMEMORY_MCP = "openmemory_mcp" # OpenMemory MCP backend + MYCELIA = "mycelia" # Mycelia memory backend @dataclass @@ -48,6 +49,7 @@ class MemoryConfig: vector_store_config: Dict[str, Any] = None embedder_config: Dict[str, Any] = None openmemory_config: Dict[str, Any] = None # Configuration for OpenMemory MCP + mycelia_config: Dict[str, Any] = None # Configuration for Mycelia extraction_prompt: str = None extraction_enabled: bool = True timeout_seconds: int = 1200 @@ -122,6 +124,23 @@ def create_openmemory_config( } +def create_mycelia_config( + api_url: str = "http://localhost:8080", + api_key: str = None, + timeout: int = 30, + **kwargs +) -> Dict[str, Any]: + """Create Mycelia configuration.""" + config = { + "api_url": api_url, + "timeout": timeout, + } + if api_key: + config["api_key"] = api_key + config.update(kwargs) + return config + + def build_memory_config_from_env() -> MemoryConfig: """Build memory configuration from environment variables and YAML config.""" try: @@ -140,14 +159,45 @@ def build_memory_config_from_env() -> MemoryConfig: user_id=os.getenv("OPENMEMORY_USER_ID", "default"), timeout=int(os.getenv("OPENMEMORY_TIMEOUT", "30")) ) - + memory_logger.info(f"๐Ÿ”ง Memory config: Provider=OpenMemory MCP, URL={openmemory_config['server_url']}") - + return MemoryConfig( memory_provider=memory_provider_enum, openmemory_config=openmemory_config, timeout_seconds=int(os.getenv("OPENMEMORY_TIMEOUT", "30")) ) + + # For Mycelia provider, build mycelia_config + llm_config (for temporal extraction) + if memory_provider_enum == MemoryProvider.MYCELIA: + mycelia_config = create_mycelia_config( + api_url=os.getenv("MYCELIA_URL", "http://localhost:5173"), + timeout=int(os.getenv("MYCELIA_TIMEOUT", "30")) + ) + + # Build LLM config for temporal extraction (Mycelia provider uses OpenAI directly) + openai_api_key = os.getenv("OPENAI_API_KEY") + if not openai_api_key: + memory_logger.warning("OPENAI_API_KEY not set - temporal extraction will be disabled") + llm_config = None + else: + model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + llm_config = create_openai_config( + api_key=openai_api_key, + model=model, + base_url=base_url + ) + memory_logger.info(f"๐Ÿ”ง Mycelia temporal extraction: LLM={model}") + + memory_logger.info(f"๐Ÿ”ง Memory config: Provider=Mycelia, URL={mycelia_config['api_url']}") + + return MemoryConfig( + memory_provider=memory_provider_enum, + mycelia_config=mycelia_config, + llm_config=llm_config, + timeout_seconds=int(os.getenv("MYCELIA_TIMEOUT", "30")) + ) # For Friend-Lite provider, use existing complex configuration # Import config loader diff --git a/backends/advanced/src/advanced_omi_backend/memory/prompts.py b/backends/advanced/src/advanced_omi_backend/services/memory/prompts.py similarity index 71% rename from backends/advanced/src/advanced_omi_backend/memory/prompts.py rename to backends/advanced/src/advanced_omi_backend/services/memory/prompts.py index f655752e..4b41a51a 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/prompts.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/prompts.py @@ -5,10 +5,13 @@ 2. Updating memory with new facts (DEFAULT_UPDATE_MEMORY_PROMPT) 3. Answering questions from memory (MEMORY_ANSWER_PROMPT) 4. Procedural memory for task tracking (PROCEDURAL_MEMORY_SYSTEM_PROMPT) +5. Temporal and entity extraction (get_temporal_entity_extraction_prompt()) """ -from datetime import datetime +from datetime import datetime, timedelta import json +from typing import List, Optional +from pydantic import BaseModel, Field MEMORY_ANSWER_PROMPT = """ You are an expert at answering questions based on the provided memories. Your task is to provide accurate and concise answers to the questions by leveraging the information given in the memories. @@ -383,3 +386,171 @@ def get_update_memory_messages(retrieved_old_memory_dict, response_content, cust Do not return anything except the JSON format. """ + + +# ===== Temporal and Entity Extraction ===== + +class TimeRange(BaseModel): + """Represents a time range with start and end timestamps.""" + start: datetime = Field(description="ISO 8601 timestamp when the event/activity starts") + end: datetime = Field(description="ISO 8601 timestamp when the event/activity ends") + name: Optional[str] = Field(default=None, description="Optional name/label for this time range (e.g., 'wedding ceremony', 'party')") + + +class TemporalEntity(BaseModel): + """Structured temporal and entity information extracted from a memory fact.""" + isEvent: bool = Field(description="Whether this memory describes a scheduled event or time-bound activity") + isPerson: bool = Field(description="Whether this memory is primarily about a person or people") + isPlace: bool = Field(description="Whether this memory is primarily about a location or place") + isPromise: bool = Field(description="Whether this memory contains a commitment, promise, or agreement") + isRelationship: bool = Field(description="Whether this memory describes a relationship between people") + entities: List[str] = Field(default_factory=list, description="List of people, places, or things mentioned (e.g., ['John', 'Botanical Gardens', 'wedding'])") + timeRanges: List[TimeRange] = Field(default_factory=list, description="List of time ranges if this is a temporal memory") + emoji: Optional[str] = Field(default=None, description="Single emoji that best represents this memory") + + +def build_temporal_extraction_prompt(current_date: datetime) -> str: + """Build the temporal extraction prompt with the current date context.""" + return f"""You are an expert at extracting temporal and entity information from memory facts. + +Your task is to analyze a memory fact and extract structured information in JSON format: +1. **Entity Types**: Determine if the memory is about events, people, places, promises, or relationships +2. **Temporal Information**: Extract and resolve any time references to actual ISO 8601 timestamps +3. **Named Entities**: List all people, places, and things mentioned +4. **Representation**: Choose a single emoji that captures the essence of the memory + +You must return a valid JSON object with the following structure. + +**Current Date Context:** +- Today's date: {current_date.strftime("%Y-%m-%d")} +- Current time: {current_date.strftime("%H:%M:%S")} +- Day of week: {current_date.strftime("%A")} + +**Time Resolution Guidelines:** + +Relative Time References: +- "tomorrow" โ†’ Add 1 day to current date +- "next week" โ†’ Add 7 days to current date +- "in X days/weeks/months" โ†’ Add X time units to current date +- "yesterday" โ†’ Subtract 1 day from current date + +Time of Day: +- "4pm" or "16:00" โ†’ Use current date with that time +- "tomorrow at 4pm" โ†’ Use tomorrow's date at 16:00 +- "morning" โ†’ 09:00 on the referenced day +- "afternoon" โ†’ 14:00 on the referenced day +- "evening" โ†’ 18:00 on the referenced day +- "night" โ†’ 21:00 on the referenced day + +Duration Estimation (when only start time is mentioned): +- Events like "wedding", "meeting", "party" โ†’ Default 2 hours duration +- "lunch", "dinner", "breakfast" โ†’ Default 1 hour duration +- "class", "workshop" โ†’ Default 1.5 hours duration +- "appointment", "call" โ†’ Default 30 minutes duration + +**Entity Type Guidelines:** + +- **isEvent**: True for scheduled activities, appointments, meetings, parties, ceremonies, classes, etc. +- **isPerson**: True when the primary focus is on a person (e.g., "Met John", "Sarah is my friend") +- **isPlace**: True when the primary focus is a location (e.g., "Botanical Gardens is beautiful", "Favorite restaurant is...") +- **isPromise**: True for commitments, promises, or agreements (e.g., "I'll call you tomorrow", "We agreed to meet") +- **isRelationship**: True for statements about relationships (e.g., "John is my brother", "We're getting married") + +**Examples:** + +Input: "I'm getting married in one week! It's going to be at 4pm at the botanical gardens." +Output: +{{ + "isEvent": true, + "isPerson": false, + "isPlace": false, + "isPromise": false, + "isRelationship": true, + "entities": ["botanical gardens", "wedding"], + "timeRanges": [ + {{ + "start": "{(current_date.replace(hour=16, minute=0, second=0) + timedelta(days=7)).isoformat()}", + "end": "{(current_date.replace(hour=18, minute=0, second=0) + timedelta(days=7)).isoformat()}", + "name": "wedding ceremony" + }} + ], + "emoji": "๐Ÿ’’" +}} + +Input: "Had a meeting with John at 3pm to discuss the new project" +Output: +{{ + "isEvent": true, + "isPerson": true, + "isPlace": false, + "isPromise": false, + "isRelationship": false, + "entities": ["John", "new project", "meeting"], + "timeRanges": [ + {{ + "start": "{current_date.replace(hour=15, minute=0, second=0).isoformat()}", + "end": "{current_date.replace(hour=16, minute=0, second=0).isoformat()}", + "name": "meeting" + }} + ], + "emoji": "๐Ÿค" +}} + +Input: "My favorite restaurant is Giovanni's Italian Kitchen" +Output: +{{ + "isEvent": false, + "isPerson": false, + "isPlace": true, + "isPromise": false, + "isRelationship": false, + "entities": ["Giovanni's Italian Kitchen", "restaurant"], + "timeRanges": [], + "emoji": "๐Ÿ" +}} + +Input: "I love hiking in the mountains" +Output: +{{ + "isEvent": false, + "isPerson": false, + "isPlace": false, + "isPromise": false, + "isRelationship": false, + "entities": ["mountains", "hiking"], + "timeRanges": [], + "emoji": "๐Ÿ”๏ธ" +}} + +Input: "Tomorrow I need to call Sarah about the party at 2pm" +Output: +{{ + "isEvent": true, + "isPerson": true, + "isPlace": false, + "isPromise": true, + "isRelationship": false, + "entities": ["Sarah", "party", "call"], + "timeRanges": [ + {{ + "start": "{(current_date.replace(hour=14, minute=0, second=0) + timedelta(days=1)).isoformat()}", + "end": "{(current_date.replace(hour=14, minute=30, second=0) + timedelta(days=1)).isoformat()}", + "name": "call Sarah" + }} + ], + "emoji": "๐Ÿ“ž" +}} + +**Instructions:** +- Return structured data following the TemporalEntity schema +- Convert all temporal references to ISO 8601 format +- Be conservative: if there's no temporal information, leave timeRanges empty +- Multiple tags can be true (e.g., isEvent and isPerson both true for "meeting with John") +- Extract all meaningful entities (people, places, things) mentioned in the fact +- Choose an emoji that best represents the core meaning of the memory +""" + + +def get_temporal_entity_extraction_prompt() -> str: + """Get the temporal entity extraction prompt with current date/time.""" + return build_temporal_extraction_prompt(datetime.now()) diff --git a/backends/advanced/src/advanced_omi_backend/services/memory/providers/__init__.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/__init__.py new file mode 100644 index 00000000..43d438cf --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/__init__.py @@ -0,0 +1,27 @@ +"""Memory service provider implementations. + +This package contains all memory service provider implementations: +- friend_lite: Friend-Lite native implementation with LLM + vector store +- openmemory_mcp: OpenMemory MCP backend integration +- mycelia: Mycelia backend integration +- llm_providers: LLM provider implementations (OpenAI, Ollama) +- vector_stores: Vector store implementations (Qdrant) +- mcp_client: MCP client utilities +""" + +from .friend_lite import MemoryService as FriendLiteMemoryService +from .openmemory_mcp import OpenMemoryMCPService +from .mycelia import MyceliaMemoryService +from .llm_providers import OpenAIProvider +from .vector_stores import QdrantVectorStore +from .mcp_client import MCPClient, MCPError + +__all__ = [ + "FriendLiteMemoryService", + "OpenMemoryMCPService", + "MyceliaMemoryService", + "OpenAIProvider", + "QdrantVectorStore", + "MCPClient", + "MCPError", +] diff --git a/backends/advanced/src/advanced_omi_backend/memory/memory_service.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/friend_lite.py similarity index 98% rename from backends/advanced/src/advanced_omi_backend/memory/memory_service.py rename to backends/advanced/src/advanced_omi_backend/services/memory/providers/friend_lite.py index 6460aa25..a0974e21 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/memory_service.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/friend_lite.py @@ -11,15 +11,11 @@ import uuid from typing import Any, List, Optional, Tuple -from .base import MemoryEntry, MemoryServiceBase -from .config import LLMProvider as LLMProviderEnum -from .config import MemoryConfig, VectorStoreProvider -from .providers import ( - LLMProviderBase, - OpenAIProvider, - QdrantVectorStore, - VectorStoreBase, -) +from ..base import LLMProviderBase, MemoryEntry, MemoryServiceBase, VectorStoreBase +from ..config import LLMProvider as LLMProviderEnum +from ..config import MemoryConfig, VectorStoreProvider +from .llm_providers import OpenAIProvider +from .vector_stores import QdrantVectorStore memory_logger = logging.getLogger("memory_service") @@ -49,10 +45,10 @@ def __init__(self, config: MemoryConfig): Args: config: MemoryConfig instance with provider settings """ + super().__init__() self.config = config self.llm_provider: Optional[LLMProviderBase] = None self.vector_store: Optional[VectorStoreBase] = None - self._initialized = False async def initialize(self) -> None: """Initialize the memory service and all its components. @@ -133,8 +129,7 @@ async def add_memory( Raises: asyncio.TimeoutError: If processing exceeds timeout """ - if not self._initialized: - await self.initialize() + await self._ensure_initialized() try: # Skip empty transcripts @@ -295,7 +290,7 @@ async def count_memories(self, user_id: str) -> Optional[int]: memory_logger.error(f"Count memories failed: {e}") return None - async def delete_memory(self, memory_id: str) -> bool: + async def delete_memory(self, memory_id: str, user_id: Optional[str] = None, user_email: Optional[str] = None) -> bool: """Delete a specific memory by ID. Args: diff --git a/backends/advanced/src/advanced_omi_backend/memory/providers/llm_providers.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/llm_providers.py similarity index 100% rename from backends/advanced/src/advanced_omi_backend/memory/providers/llm_providers.py rename to backends/advanced/src/advanced_omi_backend/services/memory/providers/llm_providers.py diff --git a/backends/advanced/src/advanced_omi_backend/memory/providers/mcp_client.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/mcp_client.py similarity index 79% rename from backends/advanced/src/advanced_omi_backend/memory/providers/mcp_client.py rename to backends/advanced/src/advanced_omi_backend/services/memory/providers/mcp_client.py index 7942a17a..15226971 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/providers/mcp_client.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/mcp_client.py @@ -6,7 +6,7 @@ import logging import uuid -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional import httpx memory_logger = logging.getLogger("memory_service") @@ -28,18 +28,20 @@ class MCPClient: client: HTTP client instance """ - def __init__(self, server_url: str, client_name: str = "friend_lite", user_id: str = "default", timeout: int = 30): + def __init__(self, server_url: str, client_name: str = "friend_lite", user_id: str = "default", user_email: str = "", timeout: int = 30): """Initialize client for OpenMemory. - + Args: server_url: Base URL of the OpenMemory server client_name: Client identifier (used as app name) user_id: User identifier for memory isolation + user_email: User email address for user metadata timeout: HTTP request timeout in seconds """ self.server_url = server_url.rstrip('/') self.client_name = client_name self.user_id = user_id + self.user_email = user_email self.timeout = timeout # Use custom CA certificate if available @@ -107,18 +109,20 @@ async def add_memories(self, text: str) -> List[str]: memory_logger.error("No apps found in OpenMemory - cannot create memory") raise MCPError("No apps found in OpenMemory") - # Use REST API endpoint for creating memories (trailing slash required) + # Use REST API endpoint for creating memories + # The 'app' field can be either app name (string) or app UUID response = await self.client.post( f"{self.server_url}/api/v1/memories/", json={ "user_id": self.user_id, "text": text, + "app": self.client_name, # Use app name (OpenMemory accepts name or UUID) "metadata": { "source": "friend_lite", - "client": self.client_name + "client": self.client_name, + "user_email": self.user_email }, - "infer": True, - "app_id": app_id # Use app_id to avoid duplicate name issues + "infer": True } ) response.raise_for_status() @@ -334,12 +338,102 @@ async def delete_all_memories(self) -> int: return result.get("deleted_count", len(memory_ids)) return len(memory_ids) - + except Exception as e: memory_logger.error(f"Error deleting all memories: {e}") return 0 - - async def delete_memory(self, memory_id: str) -> bool: + + async def get_memory(self, memory_id: str) -> Optional[Dict[str, Any]]: + """Get a specific memory by ID. + + Args: + memory_id: ID of the memory to retrieve + + Returns: + Memory dictionary if found, None otherwise + """ + try: + # Use the memories endpoint with specific ID + response = await self.client.get( + f"{self.server_url}/api/v1/memories/{memory_id}", + params={"user_id": self.user_id} + ) + + if response.status_code == 404: + memory_logger.warning(f"Memory not found: {memory_id}") + return None + + response.raise_for_status() + result = response.json() + + # Format memory for Friend-Lite + if isinstance(result, dict): + return { + "id": result.get("id", memory_id), + "content": result.get("content", "") or result.get("text", ""), + "metadata": result.get("metadata_", {}) or result.get("metadata", {}), + "created_at": result.get("created_at"), + } + + return None + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + memory_logger.error(f"HTTP error getting memory: {e}") + return None + except Exception as e: + memory_logger.error(f"Error getting memory: {e}") + return None + + async def update_memory( + self, + memory_id: str, + content: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> bool: + """Update a specific memory's content and/or metadata. + + Args: + memory_id: ID of the memory to update + content: New content for the memory (if None, content is not updated) + metadata: New metadata to merge with existing (if None, metadata is not updated) + + Returns: + True if update succeeded, False otherwise + """ + try: + # Build update payload + update_data: Dict[str, Any] = {"user_id": self.user_id} + + if content is not None: + update_data["text"] = content + + if metadata is not None: + update_data["metadata"] = metadata + + if len(update_data) == 1: # Only user_id + memory_logger.warning("No update data provided") + return False + + # Use PUT to update memory + response = await self.client.put( + f"{self.server_url}/api/v1/memories/{memory_id}", + json=update_data + ) + + response.raise_for_status() + memory_logger.info(f"โœ… Updated OpenMemory memory: {memory_id}") + return True + + except httpx.HTTPStatusError as e: + memory_logger.error(f"HTTP error updating memory: {e.response.status_code}") + return False + except Exception as e: + memory_logger.error(f"Error updating memory: {e}") + return False + + async def delete_memory(self, memory_id: str, user_id: Optional[str] = None, user_email: Optional[str] = None) -> bool: """Delete a specific memory by ID. Args: diff --git a/backends/advanced/src/advanced_omi_backend/services/memory/providers/mycelia.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/mycelia.py new file mode 100644 index 00000000..a4d271bb --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/mycelia.py @@ -0,0 +1,868 @@ +"""Mycelia memory service implementation. + +This module provides a concrete implementation of the MemoryServiceBase interface +that uses Mycelia as the backend for all memory operations. +""" + +import asyncio +import json +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +import httpx + +from advanced_omi_backend.auth import generate_jwt_for_user +from advanced_omi_backend.users import User + +from ..base import MemoryEntry, MemoryServiceBase +from ..config import MemoryConfig +from ..prompts import ( + FACT_RETRIEVAL_PROMPT, + TemporalEntity, + get_temporal_entity_extraction_prompt, +) +from .llm_providers import _get_openai_client + +memory_logger = logging.getLogger("memory_service") + + +def strip_markdown_json(content: str) -> str: + """Strip markdown code block wrapper from JSON content. + + Handles formats like: + - ```json\n{...}\n``` + - ```\n{...}\n``` + - {... } (plain JSON, returned as-is) + """ + content = content.strip() + if content.startswith("```"): + # Remove opening ```json or ``` + first_newline = content.find("\n") + if first_newline != -1: + content = content[first_newline + 1 :] + # Remove closing ``` + if content.endswith("```"): + content = content[:-3] + content = content.strip() + return content + + +class MyceliaMemoryService(MemoryServiceBase): + """Memory service implementation using Mycelia backend. + + This class implements the MemoryServiceBase interface by delegating memory + operations to a Mycelia server using JWT authentication from Friend-Lite. + + Args: + api_url: Mycelia API endpoint URL + timeout: Request timeout in seconds + **kwargs: Additional configuration parameters + """ + + def __init__(self, config: MemoryConfig): + """Initialize Mycelia memory service. + + Args: + config: MemoryConfig object containing mycelia_config and llm_config + """ + super().__init__() + self.config = config + self.mycelia_config = config.mycelia_config or {} + self.api_url = self.mycelia_config.get("api_url", "http://localhost:8080").rstrip("/") + self.timeout = self.mycelia_config.get("timeout", 30) + self._client: Optional[httpx.AsyncClient] = None + + # Store LLM config for temporal extraction + self.llm_config = config.llm_config or {} + + memory_logger.info(f"๐Ÿ„ Initializing Mycelia memory service at {self.api_url}") + + async def initialize(self) -> None: + """Initialize Mycelia client and verify connection.""" + try: + # Initialize HTTP client + self._client = httpx.AsyncClient( + base_url=self.api_url, + timeout=self.timeout, + headers={"Content-Type": "application/json"}, + ) + + # Test connection directly (without calling test_connection to avoid recursion) + try: + response = await self._client.get("/health") + if response.status_code != 200: + raise RuntimeError(f"Health check failed with status {response.status_code}") + except httpx.HTTPError as e: + raise RuntimeError(f"Failed to connect to Mycelia service: {e}") + + self._initialized = True + memory_logger.info("โœ… Mycelia memory service initialized successfully") + + except Exception as e: + memory_logger.error(f"โŒ Failed to initialize Mycelia service: {e}") + raise RuntimeError(f"Mycelia initialization failed: {e}") + + async def _get_user_jwt(self, user_id: str, user_email: Optional[str] = None) -> str: + """Get JWT token for a user (with optional user lookup). + + Args: + user_id: User ID + user_email: Optional user email (will lookup if not provided) + + Returns: + JWT token string + + Raises: + ValueError: If user not found + """ + # If email not provided, lookup user + if not user_email: + user = await User.get(user_id) + if not user: + raise ValueError(f"User {user_id} not found") + user_email = user.email + + return generate_jwt_for_user(user_id, user_email) + + @staticmethod + def _extract_bson_id(raw_id: Any) -> str: + """Extract ID from Mycelia BSON format {"$oid": "..."} or plain string.""" + if isinstance(raw_id, dict) and "$oid" in raw_id: + return raw_id["$oid"] + return str(raw_id) + + @staticmethod + def _extract_bson_date(date_obj: Any) -> Any: + """Extract date from Mycelia BSON format {"$date": "..."} or plain value.""" + if isinstance(date_obj, dict) and "$date" in date_obj: + return date_obj["$date"] + return date_obj + + def _mycelia_object_to_memory_entry(self, obj: Dict, user_id: str) -> MemoryEntry: + """Convert Mycelia object to MemoryEntry. + + Args: + obj: Mycelia object from API + user_id: User ID for metadata + + Returns: + MemoryEntry object with full Mycelia metadata including temporal and semantic fields + """ + memory_id = self._extract_bson_id(obj.get("_id", "")) + memory_content = obj.get("details", "") + + # Build metadata with all Mycelia fields + metadata = { + "user_id": user_id, + "name": obj.get("name", ""), + "aliases": obj.get("aliases", []), + "created_at": self._extract_bson_date(obj.get("createdAt")), + "updated_at": self._extract_bson_date(obj.get("updatedAt")), + # Semantic flags + "isPerson": obj.get("isPerson", False), + "isEvent": obj.get("isEvent", False), + "isPromise": obj.get("isPromise", False), + "isRelationship": obj.get("isRelationship", False), + } + + # Add icon if present + if "icon" in obj and obj["icon"]: + metadata["icon"] = obj["icon"] + + # Add temporal information if present + if "timeRanges" in obj and obj["timeRanges"]: + # Convert BSON dates in timeRanges to ISO strings for JSON serialization + time_ranges = [] + for tr in obj["timeRanges"]: + time_range = { + "start": self._extract_bson_date(tr.get("start")), + "end": self._extract_bson_date(tr.get("end")), + } + if "name" in tr: + time_range["name"] = tr["name"] + time_ranges.append(time_range) + metadata["timeRanges"] = time_ranges + + return MemoryEntry( + id=memory_id, + content=memory_content, + metadata=metadata, + created_at=self._extract_bson_date(obj.get("createdAt")), + ) + + async def _call_resource(self, action: str, jwt_token: str, **params) -> Dict[str, Any]: + """Call Mycelia objects resource with JWT authentication. + + Args: + action: Action to perform (create, list, get, delete, etc.) + jwt_token: User's JWT token from Friend-Lite + **params: Additional parameters for the action + + Returns: + Response data from Mycelia + + Raises: + RuntimeError: If API call fails + """ + if not self._client: + raise RuntimeError("Mycelia client not initialized") + + try: + response = await self._client.post( + "/api/resource/tech.mycelia.objects", + json={"action": action, **params}, + headers={"Authorization": f"Bearer {jwt_token}"}, + ) + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as e: + memory_logger.exception( + f"Mycelia API error: {e.response.status_code} - {e.response.text}" + ) + raise RuntimeError(f"Mycelia API error: {e.response.status_code}") from e + except Exception as e: + memory_logger.exception(f"Failed to call Mycelia resource: {e}") + raise RuntimeError(f"Mycelia API call failed: {e}") from e + + async def _extract_memories_via_llm( + self, + transcript: str, + ) -> List[str]: + """Extract memories from transcript using OpenAI directly. + + Args: + transcript: Raw transcript text + + Returns: + List of extracted memory facts + + Raises: + RuntimeError: If LLM call fails + """ + if not self.llm_config: + memory_logger.warning("No LLM config available for fact extraction") + return [] + + try: + # Get OpenAI client using Friend-Lite's utility + client = _get_openai_client( + api_key=self.llm_config.get("api_key"), + base_url=self.llm_config.get("base_url", "https://api.openai.com/v1"), + is_async=True, + ) + + # Call OpenAI for memory extraction + response = await client.chat.completions.create( + model=self.llm_config.get("model", "gpt-4o-mini"), + messages=[ + {"role": "system", "content": FACT_RETRIEVAL_PROMPT}, + {"role": "user", "content": transcript}, + ], + response_format={"type": "json_object"}, + temperature=0.1, + ) + + content = response.choices[0].message.content + + if not content: + memory_logger.warning("LLM returned empty content") + return [] + + # Parse JSON response to extract facts + try: + # Strip markdown wrapper if present (just in case) + json_content = strip_markdown_json(content) + facts_data = json.loads(json_content) + facts = facts_data.get("facts", []) + memory_logger.info(f"๐Ÿง  Extracted {len(facts)} facts from transcript via OpenAI") + return facts + except json.JSONDecodeError as e: + memory_logger.error(f"Failed to parse LLM response as JSON: {e}") + memory_logger.error(f"LLM response was: {content[:300]}") + return [] + + except Exception as e: + memory_logger.error(f"Failed to extract memories via OpenAI: {e}") + raise RuntimeError(f"OpenAI memory extraction failed: {e}") from e + + async def _extract_temporal_entity_via_llm( + self, + fact: str, + ) -> Optional[TemporalEntity]: + """Extract temporal and entity information from a fact using OpenAI directly. + + Args: + fact: Memory fact text + + Returns: + TemporalEntity with extracted information, or None if extraction fails + """ + if not self.llm_config: + memory_logger.warning("No LLM config available for temporal extraction") + return None + + try: + # Get OpenAI client using Friend-Lite's utility + client = _get_openai_client( + api_key=self.llm_config.get("api_key"), + base_url=self.llm_config.get("base_url", "https://api.openai.com/v1"), + is_async=True, + ) + + # Call OpenAI with structured output request + response = await client.chat.completions.create( + model=self.llm_config.get("model", "gpt-4o-mini"), + messages=[ + {"role": "system", "content": get_temporal_entity_extraction_prompt()}, + { + "role": "user", + "content": f"Extract temporal and entity information from this memory fact:\n\n{fact}", + }, + ], + response_format={"type": "json_object"}, + temperature=0.1, + ) + + content = response.choices[0].message.content + + if not content: + memory_logger.warning("LLM returned empty content for temporal extraction") + return None + + # Parse JSON response and validate with Pydantic + try: + # Strip markdown wrapper if present (just in case) + json_content = strip_markdown_json(content) + temporal_data = json.loads(json_content) + + # Convert timeRanges to proper format if present + if "timeRanges" in temporal_data: + for time_range in temporal_data["timeRanges"]: + if isinstance(time_range["start"], str): + time_range["start"] = datetime.fromisoformat( + time_range["start"].replace("Z", "+00:00") + ) + if isinstance(time_range["end"], str): + time_range["end"] = datetime.fromisoformat( + time_range["end"].replace("Z", "+00:00") + ) + + temporal_entity = TemporalEntity(**temporal_data) + memory_logger.info( + f"โœ… Temporal extraction: isEvent={temporal_entity.isEvent}, timeRanges={len(temporal_entity.timeRanges)}, entities={temporal_entity.entities}" + ) + return temporal_entity + + except json.JSONDecodeError as e: + memory_logger.error(f"โŒ Failed to parse temporal extraction JSON: {e}") + memory_logger.error(f"Content (first 300 chars): {content[:300]}") + return None + except Exception as e: + memory_logger.error(f"Failed to validate temporal entity: {e}") + memory_logger.error(f"Data: {content[:300] if content else 'None'}") + return None + + except Exception as e: + memory_logger.error(f"Failed to extract temporal data via OpenAI: {e}") + # Don't fail the entire memory creation if temporal extraction fails + return None + + async def add_memory( + self, + transcript: str, + client_id: str, + source_id: str, + user_id: str, + user_email: str, + allow_update: bool = False, + db_helper: Any = None, + ) -> Tuple[bool, List[str]]: + """Add memories from transcript using Mycelia. + + Args: + transcript: Raw transcript text to extract memories from + client_id: Client identifier + source_id: Unique identifier for the source (audio session, chat session, etc.) + user_id: User identifier + user_email: User email address + allow_update: Whether to allow updating existing memories + db_helper: Optional database helper for tracking relationships + + Returns: + Tuple of (success: bool, created_memory_ids: List[str]) + """ + # Ensure service is initialized (lazy initialization for RQ workers) + await self._ensure_initialized() + + try: + # Generate JWT token for this user + jwt_token = await self._get_user_jwt(user_id, user_email) + + # Extract memories from transcript using OpenAI + memory_logger.info(f"Extracting memories from transcript via OpenAI...") + extracted_facts = await self._extract_memories_via_llm(transcript) + + if not extracted_facts: + memory_logger.warning("No memories extracted from transcript") + return (False, []) + + # Create Mycelia objects for each extracted fact + memory_ids = [] + for fact in extracted_facts: + fact_preview = fact[:50] + ("..." if len(fact) > 50 else "") + + # Extract temporal and entity information + temporal_entity = await self._extract_temporal_entity_via_llm(fact) + + # Build object data with temporal/entity information if available + if temporal_entity: + # Convert timeRanges from Pydantic models to dict format for Mycelia API + time_ranges = [] + for tr in temporal_entity.timeRanges: + time_range_dict = { + "start": ( + tr.start.isoformat() if isinstance(tr.start, datetime) else tr.start + ), + "end": tr.end.isoformat() if isinstance(tr.end, datetime) else tr.end, + } + if tr.name: + time_range_dict["name"] = tr.name + time_ranges.append(time_range_dict) + + # Use emoji in name if available, otherwise use default + name_prefix = temporal_entity.emoji if temporal_entity.emoji else "Memory:" + + object_data = { + "name": f"{name_prefix} {fact_preview}", + "details": fact, + "aliases": [source_id, client_id] + + temporal_entity.entities, # Include extracted entities + "isPerson": temporal_entity.isPerson, + "isPromise": temporal_entity.isPromise, + "isEvent": temporal_entity.isEvent, + "isRelationship": temporal_entity.isRelationship, + # Note: userId is auto-injected by Mycelia from JWT + } + + # Add timeRanges if temporal information was extracted + if time_ranges: + object_data["timeRanges"] = time_ranges + + # Add emoji icon if available + if temporal_entity.emoji: + object_data["icon"] = {"text": temporal_entity.emoji} + + memory_logger.info( + f"๐Ÿ“… Temporal extraction: isEvent={temporal_entity.isEvent}, timeRanges={len(time_ranges)}, entities={len(temporal_entity.entities)}" + ) + else: + # Fallback to basic object without temporal data + object_data = { + "name": f"Memory: {fact_preview}", + "details": fact, + "aliases": [source_id, client_id], + "isPerson": False, + "isPromise": False, + "isEvent": False, + "isRelationship": False, + } + memory_logger.warning(f"โš ๏ธ No temporal data extracted for fact: {fact_preview}") + + result = await self._call_resource( + action="create", jwt_token=jwt_token, object=object_data + ) + + memory_id = result.get("insertedId") + if memory_id: + memory_logger.info( + f"โœ… Created Mycelia memory object: {memory_id} - {fact_preview}" + ) + memory_ids.append(memory_id) + else: + memory_logger.error(f"Failed to create memory fact: {fact}") + + if memory_ids: + memory_logger.info( + f"โœ… Created {len(memory_ids)} Mycelia memory objects from {len(extracted_facts)} facts" + ) + return (True, memory_ids) + else: + memory_logger.error("No Mycelia memory objects were created") + return (False, []) + + except Exception as e: + memory_logger.error(f"Failed to add memory via Mycelia: {e}") + return (False, []) + + async def search_memories( + self, query: str, user_id: str, limit: int = 10, score_threshold: float = 0.0 + ) -> List[MemoryEntry]: + """Search memories using Mycelia semantic search. + + 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 matching MemoryEntry objects ordered by relevance + """ + if not self._initialized: + await self.initialize() + + try: + # Generate JWT token for this user + jwt_token = await self._get_user_jwt(user_id) + + # Search using Mycelia's list action with searchTerm option + result = await self._call_resource( + action="list", + jwt_token=jwt_token, + filters={}, # Auto-scoped by userId in Mycelia + options={ + "searchTerm": query, + "limit": limit, + "sort": {"updatedAt": -1}, # Most recent first + }, + ) + + # Convert Mycelia objects to MemoryEntry objects + memories = [] + for i, obj in enumerate(result): + # Calculate a simple relevance score (0-1) based on position + # (Mycelia doesn't provide semantic similarity scores yet) + score = 1.0 - (i * 0.1) # Decaying score + if score < score_threshold: + continue + + entry = self._mycelia_object_to_memory_entry(obj, user_id) + entry.score = score # Override score + memories.append(entry) + + return memories + + except Exception as e: + memory_logger.error(f"Failed to search memories via Mycelia: {e}") + return [] + + async def get_all_memories(self, user_id: str, limit: int = 100) -> List[MemoryEntry]: + """Get all memories for a user from Mycelia. + + Args: + user_id: User identifier + limit: Maximum number of memories to return + + Returns: + List of MemoryEntry objects for the user + """ + if not self._initialized: + await self.initialize() + + try: + # Generate JWT token for this user + jwt_token = await self._get_user_jwt(user_id) + + # List all objects for this user (auto-scoped by Mycelia) + result = await self._call_resource( + action="list", + jwt_token=jwt_token, + filters={}, # Auto-scoped by userId + options={"limit": limit, "sort": {"updatedAt": -1}}, # Most recent first + ) + + # Convert Mycelia objects to MemoryEntry objects + memories = [self._mycelia_object_to_memory_entry(obj, user_id) for obj in result] + return memories + + except Exception as e: + memory_logger.error(f"Failed to get memories via Mycelia: {e}") + return [] + + async def count_memories(self, user_id: str) -> Optional[int]: + """Count 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() + + try: + # Generate JWT token for this user + jwt_token = await self._get_user_jwt(user_id) + + # Use Mycelia's mongo resource to count objects for this user + if not self._client: + raise RuntimeError("Mycelia client not initialized") + + response = await self._client.post( + "/api/resource/tech.mycelia.mongo", + json={"action": "count", "collection": "objects", "query": {"userId": user_id}}, + headers={"Authorization": f"Bearer {jwt_token}"}, + ) + response.raise_for_status() + return response.json() + + except Exception as e: + memory_logger.error(f"Failed to count memories via Mycelia: {e}") + return None + + async def get_memory( + self, memory_id: str, user_id: Optional[str] = None + ) -> Optional[MemoryEntry]: + """Get a specific memory by ID from Mycelia. + + Args: + memory_id: Unique identifier of the memory to retrieve + user_id: Optional user identifier for authentication + + Returns: + MemoryEntry object if found, None otherwise + """ + if not self._initialized: + await self.initialize() + + try: + # Need user ID for JWT authentication + if not user_id: + memory_logger.error("User ID required for Mycelia get_memory operation") + return None + + # Generate JWT token for this user + jwt_token = await self._get_user_jwt(user_id) + + # Get the object by ID (auto-scoped by userId in Mycelia) + result = await self._call_resource(action="get", jwt_token=jwt_token, id=memory_id) + + if result: + return self._mycelia_object_to_memory_entry(result, user_id) + else: + memory_logger.warning(f"Memory not found with ID: {memory_id}") + return None + + except Exception as e: + memory_logger.error(f"Failed to get memory via Mycelia: {e}") + return None + + async def update_memory( + self, + memory_id: str, + content: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + user_id: Optional[str] = None, + user_email: Optional[str] = None, + ) -> bool: + """Update a specific memory's content and/or metadata in Mycelia. + + Args: + memory_id: Unique identifier of the memory to update + content: New content for the memory (updates 'details' field) + metadata: New metadata to merge with existing + user_id: Optional user ID for authentication + user_email: Optional user email for authentication + + Returns: + True if update succeeded, False otherwise + """ + if not self._initialized: + await self.initialize() + + try: + # Need user ID for JWT authentication + if not user_id: + memory_logger.error("User ID required for Mycelia update_memory operation") + return False + + # Generate JWT token for this user + jwt_token = await self._get_user_jwt(user_id, user_email) + + # Build update object + update_data: Dict[str, Any] = {} + + if content is not None: + update_data["details"] = content + + if metadata: + # Extract specific metadata fields that Mycelia supports + if "name" in metadata: + update_data["name"] = metadata["name"] + if "aliases" in metadata: + update_data["aliases"] = metadata["aliases"] + if "isPerson" in metadata: + update_data["isPerson"] = metadata["isPerson"] + if "isPromise" in metadata: + update_data["isPromise"] = metadata["isPromise"] + if "isEvent" in metadata: + update_data["isEvent"] = metadata["isEvent"] + if "isRelationship" in metadata: + update_data["isRelationship"] = metadata["isRelationship"] + if "timeRanges" in metadata: + update_data["timeRanges"] = metadata["timeRanges"] + if "icon" in metadata: + update_data["icon"] = metadata["icon"] + + if not update_data: + memory_logger.warning("No update data provided") + return False + + # Update the object (auto-scoped by userId in Mycelia) + result = await self._call_resource( + action="update", jwt_token=jwt_token, id=memory_id, object=update_data + ) + + updated_count = result.get("modifiedCount", 0) + if updated_count > 0: + memory_logger.info(f"โœ… Updated Mycelia memory object: {memory_id}") + return True + else: + memory_logger.warning(f"No memory updated with ID: {memory_id}") + return False + + except Exception as e: + memory_logger.error(f"Failed to update memory via Mycelia: {e}") + return False + + async def delete_memory( + self, memory_id: str, user_id: Optional[str] = None, user_email: Optional[str] = None + ) -> bool: + """Delete a specific memory from Mycelia. + + Args: + memory_id: Unique identifier of the memory to delete + user_id: Optional user identifier for authentication + user_email: Optional user email for authentication + + Returns: + True if successfully deleted, False otherwise + """ + try: + # Need user credentials for JWT - if not provided, we can't delete + if not user_id: + memory_logger.error("User ID required for Mycelia delete operation") + return False + + # Generate JWT token for this user + jwt_token = await self._get_user_jwt(user_id, user_email) + + # Delete the object (auto-scoped by userId in Mycelia) + result = await self._call_resource(action="delete", jwt_token=jwt_token, id=memory_id) + + deleted_count = result.get("deletedCount", 0) + if deleted_count > 0: + memory_logger.info(f"โœ… Deleted Mycelia memory object: {memory_id}") + return True + else: + memory_logger.warning(f"No memory deleted with ID: {memory_id}") + return False + + except Exception as e: + memory_logger.error(f"Failed to delete memory via Mycelia: {e}") + return False + + async def delete_all_user_memories(self, user_id: str) -> int: + """Delete all memories for a user from Mycelia. + + Args: + user_id: User identifier + + Returns: + Number of memories that were deleted + """ + try: + # Generate JWT token for this user + jwt_token = await self._get_user_jwt(user_id) + + # First, get all memory IDs for this user + result = await self._call_resource( + action="list", + jwt_token=jwt_token, + filters={}, # Auto-scoped by userId + options={"limit": 10000}, # Large limit to get all + ) + + # Delete each memory individually + deleted_count = 0 + for obj in result: + memory_id = self._extract_bson_id(obj.get("_id", "")) + if await self.delete_memory(memory_id, user_id): + deleted_count += 1 + + memory_logger.info(f"โœ… Deleted {deleted_count} Mycelia memories for user {user_id}") + return deleted_count + + except Exception as e: + memory_logger.error(f"Failed to delete user memories via Mycelia: {e}") + return 0 + + async def test_connection(self) -> bool: + """Test connection to Mycelia service. + + Returns: + True if connection is healthy, False otherwise + """ + try: + if not self._initialized: + await self.initialize() + + if not self._client: + return False + + # Test connection by hitting a lightweight endpoint + response = await self._client.get("/health") + return response.status_code == 200 + + except Exception as e: + memory_logger.error(f"Mycelia connection test failed: {e}") + return False + + async def aclose(self) -> None: + """Asynchronously close Mycelia client and cleanup resources.""" + memory_logger.info("Closing Mycelia memory service") + if self._client: + try: + await self._client.aclose() + memory_logger.info("โœ… Mycelia HTTP client closed successfully") + except Exception as e: + memory_logger.error(f"Error closing Mycelia HTTP client: {e}") + self._initialized = False + + def shutdown(self) -> None: + """Shutdown Mycelia client and cleanup resources (sync wrapper).""" + memory_logger.info("Shutting down Mycelia memory service") + + if self._client: + try: + # Try to get the current event loop + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # No running loop + loop = None + + if loop and loop.is_running(): + # If we're in an async context, schedule the close operation on the running loop + memory_logger.info( + "Running event loop detected. Scheduling aclose() on the current loop." + ) + try: + # Schedule the coroutine to run on the existing loop + asyncio.ensure_future(self.aclose(), loop=loop) + memory_logger.info("โœ… Close operation scheduled on running event loop") + except Exception as e: + memory_logger.error(f"Error scheduling close on running loop: {e}") + else: + # No running loop, safe to use run_until_complete + try: + asyncio.get_event_loop().run_until_complete(self.aclose()) + except Exception as e: + memory_logger.error(f"Error during shutdown: {e}") + except Exception as e: + memory_logger.error(f"Unexpected error during shutdown: {e}") + + self._initialized = False diff --git a/backends/advanced/src/advanced_omi_backend/memory/providers/openmemory_mcp_service.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/openmemory_mcp.py similarity index 81% rename from backends/advanced/src/advanced_omi_backend/memory/providers/openmemory_mcp_service.py rename to backends/advanced/src/advanced_omi_backend/services/memory/providers/openmemory_mcp.py index d5f8acd9..d18be16a 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/providers/openmemory_mcp_service.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/openmemory_mcp.py @@ -47,30 +47,26 @@ def __init__( user_id: Optional[str] = None, timeout: Optional[int] = None, ): - self.server_url = server_url or os.getenv("OPENMEMORY_MCP_URL", "http://localhost:8765") - self.client_name = client_name or os.getenv("OPENMEMORY_CLIENT_NAME", "friend_lite") - self.user_id = user_id or os.getenv("OPENMEMORY_USER_ID", "default") - self.timeout = int(timeout or os.getenv("OPENMEMORY_TIMEOUT", "30")) """Initialize OpenMemory MCP service as a thin client. - + This service delegates all memory processing to the OpenMemory MCP server: - Memory extraction (OpenMemory handles internally) - - Deduplication (OpenMemory handles internally) + - Deduplication (OpenMemory handles internally) - Vector storage (OpenMemory handles internally) - User isolation via ACL (OpenMemory handles internally) - + Args: server_url: URL of the OpenMemory MCP server (default: http://localhost:8765) client_name: Client identifier for OpenMemory MCP user_id: User identifier for memory isolation via OpenMemory ACL timeout: HTTP request timeout in seconds """ - self.server_url = server_url - self.client_name = client_name - self.user_id = user_id - self.timeout = timeout + super().__init__() + self.server_url = server_url or os.getenv("OPENMEMORY_MCP_URL", "http://localhost:8765") + self.client_name = client_name or os.getenv("OPENMEMORY_CLIENT_NAME", "friend_lite") + self.user_id = user_id or os.getenv("OPENMEMORY_USER_ID", "default") + self.timeout = int(timeout or os.getenv("OPENMEMORY_TIMEOUT", "30")) self.mcp_client: Optional[MCPClient] = None - self._initialized = False async def initialize(self) -> None: """Initialize the OpenMemory MCP service. @@ -138,8 +134,7 @@ async def add_memory( Raises: MCPError: If MCP server communication fails """ - if not self._initialized: - await self.initialize() + await self._ensure_initialized() try: # Skip empty transcripts @@ -149,19 +144,22 @@ async def add_memory( # Update MCP client user context for this operation original_user_id = self.mcp_client.user_id - self.mcp_client.user_id = self.user_id # Use configured user ID - + original_user_email = self.mcp_client.user_email + self.mcp_client.user_id = user_id # Use the actual Friend-Lite user's ID + self.mcp_client.user_email = user_email # Use the actual user's email + try: # Thin client approach: Send raw transcript to OpenMemory MCP server # OpenMemory handles: extraction, deduplication, vector storage, ACL enriched_transcript = f"[Source: {source_id}, Client: {client_id}] {transcript}" - - memory_logger.info(f"Delegating memory processing to OpenMemory MCP for {source_id}") + + memory_logger.info(f"Delegating memory processing to OpenMemory MCP for user {user_id}, source {source_id}") memory_ids = await self.mcp_client.add_memories(text=enriched_transcript) - + finally: - # Restore original user_id + # Restore original user context self.mcp_client.user_id = original_user_id + self.mcp_client.user_email = original_user_email # Update database relationships if helper provided if memory_ids and db_helper: @@ -208,24 +206,24 @@ async def search_memories( # Update MCP client user context for this operation original_user_id = self.mcp_client.user_id - self.mcp_client.user_id = self.user_id # Use configured user ID - + self.mcp_client.user_id = user_id # Use the actual Friend-Lite user's ID + try: results = await self.mcp_client.search_memory( query=query, limit=limit ) - + # Convert MCP results to MemoryEntry objects memory_entries = [] for result in results: memory_entry = self._mcp_result_to_memory_entry(result, user_id) if memory_entry: memory_entries.append(memory_entry) - + memory_logger.info(f"๐Ÿ” Found {len(memory_entries)} memories for query '{query}' (user: {user_id})") return memory_entries - + except MCPError as e: memory_logger.error(f"Search memories failed: {e}") return [] @@ -258,21 +256,21 @@ async def get_all_memories( # Update MCP client user context for this operation original_user_id = self.mcp_client.user_id - self.mcp_client.user_id = self.user_id # Use configured user ID - + self.mcp_client.user_id = user_id # Use the actual Friend-Lite user's ID + try: results = await self.mcp_client.list_memories(limit=limit) - + # Convert MCP results to MemoryEntry objects memory_entries = [] for result in results: memory_entry = self._mcp_result_to_memory_entry(result, user_id) if memory_entry: memory_entries.append(memory_entry) - + memory_logger.info(f"๐Ÿ“š Retrieved {len(memory_entries)} memories for user {user_id}") return memory_entries - + except MCPError as e: memory_logger.error(f"Get all memories failed: {e}") return [] @@ -282,8 +280,90 @@ async def get_all_memories( finally: # Restore original user_id self.mcp_client.user_id = original_user_id - - async def delete_memory(self, memory_id: str) -> bool: + + async def get_memory(self, memory_id: str, user_id: Optional[str] = None) -> Optional[MemoryEntry]: + """Get a specific memory by ID. + + Args: + memory_id: Unique identifier of the memory to retrieve + user_id: Optional user identifier for filtering + + Returns: + MemoryEntry object if found, None otherwise + """ + if not self._initialized: + await self.initialize() + + # Update MCP client user context for this operation + original_user_id = self.mcp_client.user_id + self.mcp_client.user_id = user_id or self.user_id # Use the actual Friend-Lite user's ID + + try: + result = await self.mcp_client.get_memory(memory_id) + + if not result: + memory_logger.warning(f"Memory not found: {memory_id}") + return None + + # Convert MCP result to MemoryEntry + memory_entry = self._mcp_result_to_memory_entry(result, user_id or self.user_id) + if memory_entry: + memory_logger.info(f"๐Ÿ“– Retrieved memory {memory_id}") + return memory_entry + + except Exception as e: + memory_logger.error(f"Failed to get memory: {e}") + return None + finally: + # Restore original user_id + self.mcp_client.user_id = original_user_id + + async def update_memory( + self, + memory_id: str, + content: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + user_id: Optional[str] = None, + user_email: Optional[str] = None + ) -> bool: + """Update a specific memory's content and/or metadata. + + Args: + memory_id: Unique identifier of the memory to update + content: New content for the memory (if None, content is not updated) + metadata: New metadata to merge with existing (if None, metadata is not updated) + user_id: Optional user ID (not used by OpenMemory MCP) + user_email: Optional user email (not used by OpenMemory MCP) + + Returns: + True if update succeeded, False otherwise + """ + if not self._initialized: + await self.initialize() + + # Update MCP client user context for this operation + original_user_id = self.mcp_client.user_id + self.mcp_client.user_id = user_id or self.user_id # Use the actual Friend-Lite user's ID + + try: + success = await self.mcp_client.update_memory( + memory_id=memory_id, + content=content, + metadata=metadata + ) + + if success: + memory_logger.info(f"โœ๏ธ Updated memory {memory_id} via MCP") + return success + + except Exception as e: + memory_logger.error(f"Failed to update memory: {e}") + return False + finally: + # Restore original user_id + self.mcp_client.user_id = original_user_id + + async def delete_memory(self, memory_id: str, user_id: Optional[str] = None, user_email: Optional[str] = None) -> bool: """Delete a specific memory by ID. Args: diff --git a/backends/advanced/src/advanced_omi_backend/memory/providers/vector_stores.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py similarity index 98% rename from backends/advanced/src/advanced_omi_backend/memory/providers/vector_stores.py rename to backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py index a3d04100..cf153472 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/providers/vector_stores.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py @@ -9,7 +9,7 @@ import logging import time import uuid -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from qdrant_client import AsyncQdrantClient from qdrant_client.models import ( @@ -240,7 +240,7 @@ async def get_memories(self, user_id: str, limit: int) -> List[MemoryEntry]: memory_logger.error(f"Qdrant get memories failed: {e}") return [] - async def delete_memory(self, memory_id: str) -> bool: + async def delete_memory(self, memory_id: str, user_id: Optional[str] = None, user_email: Optional[str] = None) -> bool: """Delete a specific memory from Qdrant.""" try: # Convert memory_id to proper format for Qdrant diff --git a/backends/advanced/src/advanced_omi_backend/memory/service_factory.py b/backends/advanced/src/advanced_omi_backend/services/memory/service_factory.py similarity index 88% rename from backends/advanced/src/advanced_omi_backend/memory/service_factory.py rename to backends/advanced/src/advanced_omi_backend/services/memory/service_factory.py index df2a23c9..37922186 100644 --- a/backends/advanced/src/advanced_omi_backend/memory/service_factory.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/service_factory.py @@ -38,21 +38,34 @@ def create_memory_service(config: MemoryConfig) -> MemoryServiceBase: if config.memory_provider == MemoryProvider.FRIEND_LITE: # Use the sophisticated Friend-Lite implementation - from .memory_service import MemoryService as FriendLiteMemoryService + from .providers.friend_lite import MemoryService as FriendLiteMemoryService return FriendLiteMemoryService(config) - + elif config.memory_provider == MemoryProvider.OPENMEMORY_MCP: # Use OpenMemory MCP implementation try: - from .providers.openmemory_mcp_service import OpenMemoryMCPService + from .providers.openmemory_mcp import OpenMemoryMCPService except ImportError as e: raise RuntimeError(f"OpenMemory MCP service not available: {e}") - + if not config.openmemory_config: raise ValueError("OpenMemory configuration is required for OPENMEMORY_MCP provider") - + return OpenMemoryMCPService(**config.openmemory_config) - + + elif config.memory_provider == MemoryProvider.MYCELIA: + # Use Mycelia implementation + try: + from .providers.mycelia import MyceliaMemoryService + except ImportError as e: + raise RuntimeError(f"Mycelia memory service not available: {e}") + + if not config.mycelia_config: + raise ValueError("Mycelia configuration is required for MYCELIA provider") + + # Pass the full config so Mycelia can access llm_config + return MyceliaMemoryService(config) + else: raise ValueError(f"Unsupported memory provider: {config.memory_provider}") diff --git a/backends/advanced/src/advanced_omi_backend/memory/update_memory_utils.py b/backends/advanced/src/advanced_omi_backend/services/memory/update_memory_utils.py similarity index 100% rename from backends/advanced/src/advanced_omi_backend/memory/update_memory_utils.py rename to backends/advanced/src/advanced_omi_backend/services/memory/update_memory_utils.py diff --git a/backends/advanced/src/advanced_omi_backend/memory/utils.py b/backends/advanced/src/advanced_omi_backend/services/memory/utils.py similarity index 100% rename from backends/advanced/src/advanced_omi_backend/memory/utils.py rename to backends/advanced/src/advanced_omi_backend/services/memory/utils.py diff --git a/backends/advanced/src/advanced_omi_backend/services/mycelia_sync.py b/backends/advanced/src/advanced_omi_backend/services/mycelia_sync.py new file mode 100644 index 00000000..5b1a64b6 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/services/mycelia_sync.py @@ -0,0 +1,243 @@ +""" +Mycelia OAuth Synchronization Service. + +This module synchronizes Friend-Lite users with Mycelia OAuth API keys, +ensuring that when users access Mycelia directly, they use credentials +that map to their Friend-Lite user ID. +""" + +import base64 +import hashlib +import logging +import os +import secrets +from datetime import datetime +from typing import Optional, Tuple + +from bson import ObjectId +from pymongo import MongoClient + +logger = logging.getLogger(__name__) + + +class MyceliaSyncService: + """Synchronize Friend-Lite users with Mycelia OAuth API keys.""" + + def __init__(self): + """Initialize the sync service.""" + # MongoDB configuration + # MONGODB_URI format: mongodb://host:port/database_name + self.mongo_url = os.getenv( + "MONGODB_URI", os.getenv("MONGO_URL", "mongodb://localhost:27017") + ) + + # Determine Mycelia database from environment + # Test environment uses mycelia_test, production uses mycelia + self.mycelia_db = os.getenv("MYCELIA_DB", os.getenv("DATABASE_NAME", "mycelia")) + + # Friend-Lite database - extract from MONGODB_URI or use default + # Test env: test_db, Production: friend-lite + if "/" in self.mongo_url and self.mongo_url.count("/") >= 3: + # Extract database name from mongodb://host:port/database + self.friendlite_db = self.mongo_url.split("/")[-1].split("?")[0] or "friend-lite" + else: + self.friendlite_db = "friend-lite" + + logger.info( + f"MyceliaSyncService initialized: {self.mongo_url}, Mycelia DB: {self.mycelia_db}, Friend-Lite DB: {self.friendlite_db}" + ) + + def _hash_api_key_with_salt(self, api_key: str, salt: bytes) -> str: + """Hash API key with salt (matches Mycelia's implementation).""" + h = hashlib.sha256() + h.update(salt) + h.update(api_key.encode("utf-8")) + return base64.b64encode(h.digest()).decode("utf-8") + + def _create_mycelia_api_key(self, user_id: str, user_email: str) -> Tuple[str, str]: + """ + Create a Mycelia API key for a Friend-Lite user. + + Args: + user_id: Friend-Lite user ID (MongoDB ObjectId as string) + user_email: User email address + + Returns: + Tuple of (client_id, api_key) + """ + # Generate API key in Mycelia format + random_part = secrets.token_urlsafe(32) + api_key = f"mycelia_{random_part}" + + # Generate salt + salt = secrets.token_bytes(32) + + # Hash the API key + hashed_key = self._hash_api_key_with_salt(api_key, salt) + + # Open prefix for fast lookup + open_prefix = api_key[:16] + + # Connect to Mycelia database + client = MongoClient(self.mongo_url) + db = client[self.mycelia_db] + api_keys_collection = db["api_keys"] + + # Check if user already has an active API key + existing = api_keys_collection.find_one( + {"owner": user_id, "isActive": True, "name": f"Friend-Lite Auto ({user_email})"} + ) + + if existing: + logger.info(f"User {user_email} already has Mycelia API key: {existing['_id']}") + # Return existing credentials (we can't retrieve the original API key) + # User will need to use the stored credentials + return str(existing["_id"]), None + + # Create new API key document + api_key_doc = { + "hashedKey": hashed_key, + "salt": base64.b64encode(salt).decode("utf-8"), + "owner": user_id, # CRITICAL: owner = Friend-Lite user ID + "name": f"Friend-Lite Auto ({user_email})", + "policies": [{"resource": "**", "action": "*", "effect": "allow"}], + "openPrefix": open_prefix, + "createdAt": datetime.utcnow(), + "isActive": True, + } + + # Insert into Mycelia database + result = api_keys_collection.insert_one(api_key_doc) + client_id = str(result.inserted_id) + + logger.info(f"โœ… Created Mycelia API key for {user_email}: {client_id}") + + return client_id, api_key + + def sync_user_to_mycelia(self, user_id: str, user_email: str) -> Optional[Tuple[str, str]]: + """ + Sync a Friend-Lite user to Mycelia OAuth. + + Args: + user_id: Friend-Lite user ID + user_email: User email + + Returns: + Tuple of (client_id, api_key) or None if sync fails + """ + try: + # Create Mycelia API key + client_id, api_key = self._create_mycelia_api_key(user_id, user_email) + + # Store credentials in Friend-Lite user document (if new key was created) + if api_key: + client = MongoClient(self.mongo_url) + db = client[self.friendlite_db] + users_collection = db["users"] + + users_collection.update_one( + {"_id": ObjectId(user_id)}, + { + "$set": { + "mycelia_oauth": { + "client_id": client_id, + "created_at": datetime.utcnow(), + "synced": True, + } + } + }, + ) + + logger.info(f"โœ… Synced {user_email} with Mycelia OAuth") + return client_id, api_key + else: + logger.info(f"โ„น๏ธ {user_email} already synced with Mycelia") + return client_id, None + + except Exception as e: + logger.error(f"Failed to sync {user_email} to Mycelia: {e}", exc_info=True) + return None + + def sync_admin_user(self) -> Optional[Tuple[str, str]]: + """ + Sync the admin user on startup. + + Returns: + Tuple of (client_id, api_key) if new key created, or None + """ + try: + admin_email = os.getenv("ADMIN_EMAIL") + if not admin_email: + logger.warning("ADMIN_EMAIL not set, skipping Mycelia sync") + return None + + # Get admin user from Friend-Lite database + client = MongoClient(self.mongo_url) + db = client[self.friendlite_db] + users_collection = db["users"] + + admin_user = users_collection.find_one({"email": admin_email}) + if not admin_user: + logger.warning(f"Admin user {admin_email} not found in database") + return None + + user_id = str(admin_user["_id"]) + + # Sync to Mycelia + result = self.sync_user_to_mycelia(user_id, admin_email) + + if result: + client_id, api_key = result + if api_key: + # Credentials created successfully - don't log them + logger.info("=" * 70) + logger.info("๐Ÿ”‘ MYCELIA OAUTH CREDENTIALS CREATED") + logger.info("=" * 70) + logger.info(f"User: {admin_email}") + logger.info(f"Client ID: {client_id}") + logger.info("") + logger.info("๐Ÿ” To retrieve credentials for Mycelia configuration:") + logger.info(" cd backends/advanced/scripts") + logger.info(" python create_mycelia_api_key.py") + logger.info("") + logger.info( + "๐Ÿ“ This will display the API key needed for Mycelia frontend setup" + ) + logger.info("=" * 70) + + return result + + except Exception as e: + logger.error(f"Failed to sync admin user: {e}", exc_info=True) + return None + + +# Global instance +_sync_service: Optional[MyceliaSyncService] = None + + +def get_mycelia_sync_service() -> MyceliaSyncService: + """Get or create the global Mycelia sync service instance.""" + global _sync_service + if _sync_service is None: + _sync_service = MyceliaSyncService() + return _sync_service + + +async def sync_admin_on_startup(): + """Run admin user sync on application startup.""" + logger.info("๐Ÿ”„ Starting Mycelia OAuth synchronization...") + + # Check if Mycelia sync is enabled + memory_provider = os.getenv("MEMORY_PROVIDER", "friend_lite") + if memory_provider != "mycelia": + logger.info("Mycelia sync skipped (MEMORY_PROVIDER != mycelia)") + return + + sync_service = get_mycelia_sync_service() + result = sync_service.sync_admin_user() + + if result: + logger.info("โœ… Mycelia OAuth sync completed") + else: + logger.warning("โš ๏ธ Mycelia OAuth sync completed with warnings") diff --git a/backends/advanced/src/advanced_omi_backend/services/transcription/__init__.py b/backends/advanced/src/advanced_omi_backend/services/transcription/__init__.py index 9036aa61..06d5b57f 100644 --- a/backends/advanced/src/advanced_omi_backend/services/transcription/__init__.py +++ b/backends/advanced/src/advanced_omi_backend/services/transcription/__init__.py @@ -10,7 +10,7 @@ import os from typing import Optional -from advanced_omi_backend.models.transcription import BaseTranscriptionProvider +from .base import BaseTranscriptionProvider from advanced_omi_backend.services.transcription.deepgram import ( DeepgramProvider, DeepgramStreamingProvider, diff --git a/backends/advanced/src/advanced_omi_backend/models/transcription.py b/backends/advanced/src/advanced_omi_backend/services/transcription/base.py similarity index 100% rename from backends/advanced/src/advanced_omi_backend/models/transcription.py rename to backends/advanced/src/advanced_omi_backend/services/transcription/base.py diff --git a/backends/advanced/src/advanced_omi_backend/services/transcription/deepgram.py b/backends/advanced/src/advanced_omi_backend/services/transcription/deepgram.py index e9261955..ee7e23fa 100644 --- a/backends/advanced/src/advanced_omi_backend/services/transcription/deepgram.py +++ b/backends/advanced/src/advanced_omi_backend/services/transcription/deepgram.py @@ -13,7 +13,7 @@ import httpx import websockets -from advanced_omi_backend.models.transcription import ( +from .base import ( BatchTranscriptionProvider, StreamingTranscriptionProvider, ) diff --git a/backends/advanced/src/advanced_omi_backend/services/transcription/parakeet.py b/backends/advanced/src/advanced_omi_backend/services/transcription/parakeet.py index 5b11e094..97b5b751 100644 --- a/backends/advanced/src/advanced_omi_backend/services/transcription/parakeet.py +++ b/backends/advanced/src/advanced_omi_backend/services/transcription/parakeet.py @@ -17,7 +17,7 @@ from easy_audio_interfaces.audio_interfaces import AudioChunk from easy_audio_interfaces.filesystem import LocalFileSink -from advanced_omi_backend.models.transcription import ( +from .base import ( BatchTranscriptionProvider, StreamingTranscriptionProvider, ) diff --git a/backends/advanced/src/advanced_omi_backend/utils/conversation_utils.py b/backends/advanced/src/advanced_omi_backend/utils/conversation_utils.py index 416c1fb1..b2cddf4c 100644 --- a/backends/advanced/src/advanced_omi_backend/utils/conversation_utils.py +++ b/backends/advanced/src/advanced_omi_backend/utils/conversation_utils.py @@ -52,8 +52,9 @@ def analyze_speech(transcript_data: dict) -> dict: Analyze transcript for meaningful speech to determine if conversation should be created. Uses configurable thresholds from environment: - - SPEECH_DETECTION_MIN_WORDS (default: 5) - - SPEECH_DETECTION_MIN_CONFIDENCE (default: 0.5) + - SPEECH_DETECTION_MIN_WORDS (default: 10) + - SPEECH_DETECTION_MIN_CONFIDENCE (default: 0.7) + - SPEECH_DETECTION_MIN_DURATION (default: 10.0) Args: transcript_data: Dictionary with: @@ -99,6 +100,16 @@ def analyze_speech(transcript_data: dict) -> dict: speech_end = valid_words[-1].get("end", 0) speech_duration = speech_end - speech_start + # Check minimum duration threshold + min_duration = settings.get("min_duration", 10.0) + if speech_duration < min_duration: + return { + "has_speech": False, + "reason": f"Speech too short ({speech_duration:.1f}s < {min_duration}s)", + "word_count": len(valid_words), + "duration": speech_duration, + } + return { "has_speech": True, "word_count": len(valid_words), diff --git a/backends/advanced/src/advanced_omi_backend/utils/job_utils.py b/backends/advanced/src/advanced_omi_backend/utils/job_utils.py new file mode 100644 index 00000000..6200af82 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/utils/job_utils.py @@ -0,0 +1,44 @@ +""" +Job utility functions for RQ workers. + +This module provides common utilities for long-running RQ jobs. +""" + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +async def check_job_alive(redis_client, current_job) -> bool: + """ + Check if current RQ job still exists in Redis. + + Long-running jobs should call this periodically to detect zombie state + (when the job has been deleted from Redis but the worker is still running). + + Args: + redis_client: Async Redis client + current_job: RQ job instance from get_current_job() + + Returns: + False if job is zombie (caller should exit), True otherwise + + Example: + from rq import get_current_job + from advanced_omi_backend.utils.job_utils import check_job_alive + + current_job = get_current_job() + + while True: + # Check for zombie state each iteration + if not await check_job_alive(redis_client, current_job): + break + # ... do work ... + """ + if current_job: + job_exists = await redis_client.exists(f"rq:job:{current_job.id}") + if not job_exists: + logger.error(f"๐ŸงŸ Zombie job detected - job {current_job.id} deleted from Redis, exiting") + return False + return True diff --git a/backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py index 7fc3f323..56df7149 100644 --- a/backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py +++ b/backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py @@ -260,7 +260,18 @@ async def audio_streaming_persistence_job( max_empty_reads = 3 # Exit after 3 consecutive empty reads (deterministic check) conversation_count = 0 + # Get current job for zombie detection + from rq import get_current_job + from advanced_omi_backend.utils.job_utils import check_job_alive + current_job = get_current_job() + while True: + # Check if job still exists in Redis (detect zombie state) + if not await check_job_alive(redis_client, current_job): + if file_sink: + await file_sink.close() + break + # Check timeout if time.time() - start_time > max_runtime: logger.warning(f"โฑ๏ธ Timeout reached for audio persistence {session_id}") diff --git a/backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py index 8bc6a205..1d4bd985 100644 --- a/backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py +++ b/backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py @@ -281,6 +281,11 @@ async def open_conversation_job( logger.info("๐Ÿงช Test mode: Waiting for audio queue to drain before timeout") while True: + # Check if job still exists in Redis (detect zombie state) + from advanced_omi_backend.utils.job_utils import check_job_alive + if not await check_job_alive(redis_client, current_job): + break + # Check if session is finalizing (set by producer when recording stops) if not finalize_received: status = await redis_client.hget(session_key, "status") diff --git a/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py index fe4b1c19..439cdf4c 100644 --- a/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py +++ b/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py @@ -8,23 +8,20 @@ import time import uuid from datetime import UTC, datetime -from typing import Dict, Any +from typing import Any, Dict -from advanced_omi_backend.models.job import JobPriority, BaseRQJob, async_job from advanced_omi_backend.controllers.queue_controller import ( - memory_queue, JOB_RESULT_TTL, + memory_queue, ) +from advanced_omi_backend.models.job import BaseRQJob, JobPriority, async_job +from advanced_omi_backend.services.memory.base import MemoryEntry logger = logging.getLogger(__name__) @async_job(redis=True, beanie=True) -async def process_memory_job( - conversation_id: str, - *, - redis_client=None -) -> Dict[str, Any]: +async def process_memory_job(conversation_id: str, *, redis_client=None) -> Dict[str, Any]: """ RQ job function for memory extraction and processing from conversations. @@ -46,14 +43,16 @@ async def process_memory_job( Dict with processing results """ from advanced_omi_backend.models.conversation import Conversation - from advanced_omi_backend.memory import get_memory_service + from advanced_omi_backend.services.memory import get_memory_service from advanced_omi_backend.users import get_user_by_id start_time = time.time() logger.info(f"๐Ÿ”„ Starting memory processing for conversation {conversation_id}") # Get conversation data - conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + conversation_model = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) if not conversation_model: logger.warning(f"No conversation found for {conversation_id}") return {"success": False, "error": "Conversation not found"} @@ -69,7 +68,9 @@ async def process_memory_job( logger.warning(f"Could not find user {user_id}") user_email = "" - logger.info(f"๐Ÿ”„ Processing memory for conversation {conversation_id}, client={client_id}, user={user_id}") + logger.info( + f"๐Ÿ”„ Processing memory for conversation {conversation_id}, client={client_id}, user={user_id}" + ) # Extract conversation text from transcript segments full_conversation = "" @@ -103,17 +104,19 @@ async def process_memory_job( for segment in conversation_model.segments: # Handle both dict and object segments if isinstance(segment, dict): - identified_as = segment.get('identified_as') + identified_as = segment.get("identified_as") else: - identified_as = getattr(segment, 'identified_as', None) + identified_as = getattr(segment, "identified_as", None) - if identified_as and identified_as != 'Unknown': + if identified_as and identified_as != "Unknown": transcript_speakers.add(identified_as.strip().lower()) - primary_speaker_names = {ps['name'].strip().lower() for ps in user.primary_speakers} + primary_speaker_names = {ps["name"].strip().lower() for ps in user.primary_speakers} if transcript_speakers and not transcript_speakers.intersection(primary_speaker_names): - logger.info(f"Skipping memory - no primary speakers found in conversation {conversation_id}") + logger.info( + f"Skipping memory - no primary speakers found in conversation {conversation_id}" + ) return {"success": True, "skipped": True, "reason": "No primary speakers"} # Process memory @@ -132,7 +135,9 @@ async def process_memory_job( if success and created_memory_ids: # Add memory version to conversation - conversation_model = await Conversation.find_one(Conversation.conversation_id == conversation_id) + conversation_model = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) if conversation_model: processing_time = time.time() - start_time @@ -142,7 +147,6 @@ async def process_memory_job( # Determine memory provider from memory service memory_provider = conversation_model.MemoryProvider.FRIEND_LITE # Default try: - from advanced_omi_backend.memory import get_memory_service memory_service_obj = get_memory_service() provider_name = memory_service_obj.__class__.__name__ if "OpenMemory" in provider_name: @@ -161,14 +165,17 @@ async def process_memory_job( provider=memory_provider, processing_time_seconds=processing_time, metadata={"memory_ids": created_memory_ids}, - set_as_active=True + set_as_active=True, ) await conversation_model.save() - logger.info(f"โœ… Completed memory processing for conversation {conversation_id} - created {len(created_memory_ids)} memories in {processing_time:.2f}s") + logger.info( + f"โœ… Completed memory processing for conversation {conversation_id} - created {len(created_memory_ids)} memories in {processing_time:.2f}s" + ) # Update job metadata with memory information from rq import get_current_job + current_job = get_current_job() if current_job: if not current_job.meta: @@ -180,20 +187,50 @@ async def process_memory_job( for memory_id in created_memory_ids[:5]: # Limit to first 5 for display memory_entry = await memory_service.get_memory(memory_id, user_id) if memory_entry: - memory_details.append({ - "memory_id": memory_id, - "text": memory_entry.get("text", "")[:200] # First 200 chars - }) + # Handle different return types from memory service + memory_text: str + if isinstance(memory_entry, MemoryEntry): + # MemoryEntry object with content attribute + memory_text = memory_entry.content + elif isinstance(memory_entry, dict): + # Dictionary with "content" key + if "content" in memory_entry: + memory_text = memory_entry["content"] + else: + logger.error( + f"Dict memory entry missing 'content' key for {memory_id}: {list(memory_entry.keys())}" + ) + raise ValueError( + f"Dict memory entry missing 'content' key for memory {memory_id}" + ) + elif isinstance(memory_entry, str): + # String content directly + memory_text = memory_entry + else: + # Unexpected type + logger.error( + f"Unexpected memory entry type for {memory_id}: {type(memory_entry).__name__}" + ) + raise TypeError( + f"Unexpected memory entry type: {type(memory_entry).__name__}" + ) + + # Truncate to 200 chars + memory_details.append( + {"memory_id": memory_id, "text": memory_text[:200]} + ) except Exception as e: logger.warning(f"Failed to fetch memory details for UI: {e}") - current_job.meta.update({ - "conversation_id": conversation_id, - "memories_created": len(created_memory_ids), - "memory_ids": created_memory_ids[:5], # Store first 5 IDs - "memory_details": memory_details, - "processing_time": processing_time - }) + current_job.meta.update( + { + "conversation_id": conversation_id, + "memories_created": len(created_memory_ids), + "memory_ids": created_memory_ids[:5], # Store first 5 IDs + "memory_details": memory_details, + "processing_time": processing_time, + } + ) current_job.save_meta() # NOTE: Listening jobs are restarted by open_conversation_job (not here) @@ -203,7 +240,7 @@ async def process_memory_job( return { "success": True, "memories_created": len(created_memory_ids), - "processing_time": processing_time + "processing_time": processing_time, } else: # No memories created - still successful @@ -217,7 +254,7 @@ def enqueue_memory_processing( user_id: str, user_email: str, conversation_id: str, - priority: JobPriority = JobPriority.NORMAL + priority: JobPriority = JobPriority.NORMAL, ): """ Enqueue a memory processing job. @@ -226,9 +263,9 @@ def enqueue_memory_processing( """ timeout_mapping = { JobPriority.URGENT: 3600, # 60 minutes - JobPriority.HIGH: 2400, # 40 minutes + JobPriority.HIGH: 2400, # 40 minutes JobPriority.NORMAL: 1800, # 30 minutes - JobPriority.LOW: 900 # 15 minutes + JobPriority.LOW: 900, # 15 minutes } job = memory_queue.enqueue( @@ -237,7 +274,7 @@ def enqueue_memory_processing( job_timeout=timeout_mapping.get(priority, 1800), result_ttl=JOB_RESULT_TTL, job_id=f"memory_{conversation_id[:8]}", - description=f"Process memory for conversation {conversation_id[:8]}" + description=f"Process memory for conversation {conversation_id[:8]}", ) logger.info(f"๐Ÿ“ฅ RQ: Enqueued memory job {job.id} for conversation {conversation_id}") diff --git a/backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py index 9690f286..c423fb0f 100644 --- a/backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py +++ b/backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py @@ -18,6 +18,7 @@ JOB_RESULT_TTL, REDIS_URL, ) +from advanced_omi_backend.utils.conversation_utils import analyze_speech, mark_conversation_deleted logger = logging.getLogger(__name__) @@ -28,7 +29,7 @@ async def apply_speaker_recognition( words: list, segments: list, user_id: str, - conversation_id: str = None + conversation_id: str = None, ) -> list: """ Apply speaker recognition to segments using the speaker recognition service. @@ -54,27 +55,28 @@ async def apply_speaker_recognition( logger.info(f"๐ŸŽค Speaker recognition disabled, using original speaker labels") return segments - logger.info(f"๐ŸŽค Speaker recognition enabled, identifying speakers{f' for {conversation_id}' if conversation_id else ''}...") + logger.info( + f"๐ŸŽค Speaker recognition enabled, identifying speakers{f' for {conversation_id}' if conversation_id else ''}..." + ) # Prepare transcript data with word-level timings - transcript_data = { - "text": transcript_text, - "words": words - } + transcript_data = {"text": transcript_text, "words": words} # Call speaker recognition service to match and identify speakers speaker_result = await speaker_client.diarize_identify_match( - audio_path=audio_path, - transcript_data=transcript_data, - user_id=user_id + audio_path=audio_path, transcript_data=transcript_data, user_id=user_id ) if not speaker_result or "segments" not in speaker_result: - logger.info(f"๐ŸŽค Speaker recognition returned no segments, keeping original transcription segments") + logger.info( + f"๐ŸŽค Speaker recognition returned no segments, keeping original transcription segments" + ) return segments speaker_identified_segments = speaker_result["segments"] - logger.info(f"๐ŸŽค Speaker recognition returned {len(speaker_identified_segments)} identified segments") + logger.info( + f"๐ŸŽค Speaker recognition returned {len(speaker_identified_segments)} identified segments" + ) logger.info(f"๐ŸŽค Original segments: {len(segments)}") # Create time-based speaker mapping @@ -97,11 +99,15 @@ def get_speaker_at_time(timestamp: float, speaker_segments: list) -> str: original_speaker = seg.speaker seg.speaker = identified_speaker updated_count += 1 - logger.debug(f"๐ŸŽค Segment [{seg.start:.1f}-{seg.end:.1f}] '{original_speaker}' -> '{identified_speaker}'") + logger.debug( + f"๐ŸŽค Segment [{seg.start:.1f}-{seg.end:.1f}] '{original_speaker}' -> '{identified_speaker}'" + ) # Ensure segments remain sorted by start time segments.sort(key=lambda s: s.start) - logger.info(f"๐ŸŽค Updated {updated_count}/{len(segments)} segments with speaker identifications") + logger.info( + f"๐ŸŽค Updated {updated_count}/{len(segments)} segments with speaker identifications" + ) return segments @@ -109,6 +115,7 @@ def get_speaker_at_time(timestamp: float, speaker_segments: list) -> str: logger.warning(f"โš ๏ธ Speaker recognition failed: {speaker_error}") logger.warning(f"Continuing with original transcription speaker labels") import traceback + logger.debug(traceback.format_exc()) return segments @@ -121,7 +128,7 @@ async def transcribe_full_audio_job( version_id: str, trigger: str = "reprocess", *, - redis_client=None + redis_client=None, ) -> Dict[str, Any]: """ RQ job function for transcribing full audio to text (transcription only, no speaker recognition). @@ -149,7 +156,9 @@ async def transcribe_full_audio_job( from advanced_omi_backend.services.transcription import get_transcription_provider from advanced_omi_backend.models.conversation import Conversation - logger.info(f"๐Ÿ”„ RQ: Starting transcript processing for conversation {conversation_id} (trigger: {trigger})") + logger.info( + f"๐Ÿ”„ RQ: Starting transcript processing for conversation {conversation_id} (trigger: {trigger})" + ) start_time = time.time() @@ -176,14 +185,12 @@ async def transcribe_full_audio_job( raise FileNotFoundError(f"Audio file not found: {actual_audio_path}") # Load audio data - with open(audio_file_path, 'rb') as f: + with open(audio_file_path, "rb") as f: audio_data = f.read() # Transcribe the audio (assume 16kHz sample rate) transcription_result = await provider.transcribe( - audio_data=audio_data, - sample_rate=16000, - diarize=True + audio_data=audio_data, sample_rate=16000, diarize=True ) # Extract results @@ -191,14 +198,89 @@ async def transcribe_full_audio_job( segments = transcription_result.get("segments", []) words = transcription_result.get("words", []) - logger.info(f"๐Ÿ“Š Transcription complete: {len(transcript_text)} chars, {len(segments)} segments, {len(words)} words") + logger.info( + f"๐Ÿ“Š Transcription complete: {len(transcript_text)} chars, {len(segments)} segments, {len(words)} words" + ) + + # Validate meaningful speech BEFORE any further processing + transcript_data = {"text": transcript_text, "words": words} + speech_analysis = analyze_speech(transcript_data) + + if not speech_analysis.get("has_speech", False): + logger.warning( + f"โš ๏ธ Transcription found no meaningful speech for conversation {conversation_id}: " + f"{speech_analysis.get('reason', 'unknown')}" + ) + + # Mark conversation as deleted + await mark_conversation_deleted( + conversation_id=conversation_id, + deletion_reason="no_meaningful_speech_batch_transcription", + ) + + # Cancel all dependent jobs (cropping, speaker recognition, memory, title/summary) + from rq import get_current_job + from rq.job import Job + + current_job = get_current_job() + if current_job: + # Get all jobs that depend on this transcription job + from advanced_omi_backend.controllers.queue_controller import redis_conn + + # Find dependent jobs by searching for jobs with this job as dependency + try: + # Cancel jobs based on conversation_id pattern + job_patterns = [ + f"crop_{conversation_id[:12]}", + f"speaker_{conversation_id[:12]}", + f"memory_{conversation_id[:12]}", + f"title_summary_{conversation_id[:12]}", + ] + + cancelled_jobs = [] + for job_id in job_patterns: + try: + dependent_job = Job.fetch(job_id, connection=redis_conn) + if dependent_job and dependent_job.get_status() in [ + "queued", + "deferred", + "scheduled", + ]: + dependent_job.cancel() + cancelled_jobs.append(job_id) + logger.info(f"โœ… Cancelled dependent job: {job_id}") + except Exception as e: + logger.debug(f"Job {job_id} not found or already completed: {e}") + + if cancelled_jobs: + logger.info( + f"๐Ÿšซ Cancelled {len(cancelled_jobs)} dependent jobs due to no meaningful speech" + ) + except Exception as cancel_error: + logger.warning(f"Failed to cancel some dependent jobs: {cancel_error}") + + # Return early with failure status + return { + "success": False, + "conversation_id": conversation_id, + "error": "no_meaningful_speech", + "reason": speech_analysis.get("reason"), + "word_count": speech_analysis.get("word_count", 0), + "duration": speech_analysis.get("duration", 0.0), + "deleted": True, + } + + logger.info( + f"โœ… Meaningful speech validated: {speech_analysis.get('word_count')} words, " + f"{speech_analysis.get('duration', 0):.1f}s" + ) # Calculate processing time (transcription only) processing_time = time.time() - start_time # Convert segments to SpeakerSegment objects speaker_segments = [] - + if segments: # Use provided segments for seg in segments: @@ -211,16 +293,16 @@ async def transcribe_full_audio_job( end=seg.get("end", 0), text=seg.get("text", ""), speaker=speaker_name, - confidence=seg.get("confidence") + confidence=seg.get("confidence"), ) ) elif transcript_text: - # NOTE: Parakeet falls here. + # NOTE: Parakeet falls here. # If no segments but we have text, create a single segment from the full transcript # Calculate duration from words if available, otherwise estimate from audio start_time_seg = 0.0 end_time_seg = 0.0 - + if words: # Use word timestamps if available start_times = [w.get("start", 0) for w in words if "start" in w] @@ -233,17 +315,19 @@ async def transcribe_full_audio_job( # Estimate duration: assume ~150 words per minute, or use audio file duration # For now, use a default duration if we can't calculate it end_time_seg = len(transcript_text.split()) * 0.4 # Rough estimate: 0.4s per word - + speaker_segments.append( Conversation.SpeakerSegment( start=start_time_seg, end=end_time_seg if end_time_seg > start_time_seg else start_time_seg + 1.0, text=transcript_text, speaker="Unknown", - confidence=None + confidence=None, ) ) - logger.info(f"๐Ÿ“Š Created single segment from transcript text (no segments returned by provider)") + logger.info( + f"๐Ÿ“Š Created single segment from transcript text (no segments returned by provider)" + ) logger.info(f"๐Ÿ“Š Created {len(speaker_segments)} speaker segments") @@ -257,10 +341,7 @@ async def transcribe_full_audio_job( "segment_count": len(segments), "word_count": len(words), "words": words, # Store words for speaker recognition job to read - "speaker_recognition": { - "enabled": False, - "reason": "handled_by_separate_job" - } + "speaker_recognition": {"enabled": False, "reason": "handled_by_separate_job"}, } conversation.add_transcript_version( @@ -268,10 +349,10 @@ async def transcribe_full_audio_job( transcript=transcript_text, segments=speaker_segments, provider=Conversation.TranscriptProvider(provider_normalized), - model=getattr(provider, 'model', 'unknown'), + model=getattr(provider, "model", "unknown"), processing_time_seconds=processing_time, metadata=metadata, - set_as_active=True + set_as_active=True, ) # Generate title and summary from transcript using LLM @@ -293,38 +374,48 @@ async def transcribe_full_audio_job( llm_response = await async_generate(prompt, temperature=0.7) # Parse LLM response - lines = llm_response.strip().split('\n') + lines = llm_response.strip().split("\n") title = None summary = None for line in lines: - if line.startswith('Title:'): - title = line.replace('Title:', '').strip() - elif line.startswith('Summary:'): - summary = line.replace('Summary:', '').strip() + if line.startswith("Title:"): + title = line.replace("Title:", "").strip() + elif line.startswith("Summary:"): + summary = line.replace("Summary:", "").strip() # Use LLM-generated title/summary if valid, otherwise fallback if title and len(title) > 0: conversation.title = title[:50] + "..." if len(title) > 50 else title else: # Fallback to first sentence if LLM didn't provide title - first_sentence = transcript_text.split('.')[0].strip() - conversation.title = first_sentence[:50] + "..." if len(first_sentence) > 50 else first_sentence + first_sentence = transcript_text.split(".")[0].strip() + conversation.title = ( + first_sentence[:50] + "..." if len(first_sentence) > 50 else first_sentence + ) if summary and len(summary) > 0: conversation.summary = summary[:150] + "..." if len(summary) > 150 else summary else: # Fallback to truncated transcript if LLM didn't provide summary - conversation.summary = transcript_text[:150] + "..." if len(transcript_text) > 150 else transcript_text + conversation.summary = ( + transcript_text[:150] + "..." if len(transcript_text) > 150 else transcript_text + ) - logger.info(f"โœ… Generated title: '{conversation.title}', summary: '{conversation.summary}'") + logger.info( + f"โœ… Generated title: '{conversation.title}', summary: '{conversation.summary}'" + ) except Exception as llm_error: logger.warning(f"โš ๏ธ LLM title/summary generation failed: {llm_error}") # Fallback to simple truncation - first_sentence = transcript_text.split('.')[0].strip() - conversation.title = first_sentence[:50] + "..." if len(first_sentence) > 50 else first_sentence - conversation.summary = transcript_text[:150] + "..." if len(transcript_text) > 150 else transcript_text + first_sentence = transcript_text.split(".")[0].strip() + conversation.title = ( + first_sentence[:50] + "..." if len(first_sentence) > 50 else first_sentence + ) + conversation.summary = ( + transcript_text[:150] + "..." if len(transcript_text) > 150 else transcript_text + ) else: conversation.title = "Empty Conversation" conversation.summary = "No speech detected" @@ -332,22 +423,27 @@ async def transcribe_full_audio_job( # Save the updated conversation await conversation.save() - logger.info(f"โœ… Transcript processing completed for {conversation_id} in {processing_time:.2f}s") + logger.info( + f"โœ… Transcript processing completed for {conversation_id} in {processing_time:.2f}s" + ) # Update job metadata with title and summary for UI display from rq import get_current_job + current_job = get_current_job() if current_job: if not current_job.meta: current_job.meta = {} - current_job.meta.update({ - "conversation_id": conversation_id, - "title": conversation.title, - "summary": conversation.summary, - "transcript_length": len(transcript_text), - "word_count": len(words), - "processing_time": processing_time - }) + current_job.meta.update( + { + "conversation_id": conversation_id, + "title": conversation.title, + "summary": conversation.summary, + "transcript_length": len(transcript_text), + "word_count": len(words), + "processing_time": processing_time, + } + ) current_job.save_meta() return { @@ -360,17 +456,13 @@ async def transcribe_full_audio_job( "words": words, # Needed by speaker recognition "provider": provider_name, "processing_time_seconds": processing_time, - "trigger": trigger + "trigger": trigger, } @async_job(redis=True, beanie=True) async def stream_speech_detection_job( - session_id: str, - user_id: str, - client_id: str, - *, - redis_client=None + session_id: str, user_id: str, client_id: str, *, redis_client=None ) -> Dict[str, Any]: """ Listen for meaningful speech, optionally check for enrolled speakers, then start conversation. @@ -413,23 +505,33 @@ async def stream_speech_detection_job( # Check if speaker filtering is enabled speaker_filter_enabled = os.getenv("RECORD_ONLY_ENROLLED_SPEAKERS", "false").lower() == "true" - logger.info(f"๐Ÿ“Š Conversation #{conversation_count + 1}, Speaker filter: {'enabled' if speaker_filter_enabled else 'disabled'}") + logger.info( + f"๐Ÿ“Š Conversation #{conversation_count + 1}, Speaker filter: {'enabled' if speaker_filter_enabled else 'disabled'}" + ) # Update job metadata to show status if current_job: if not current_job.meta: current_job.meta = {} - current_job.meta.update({ - "status": "listening_for_speech", - "session_id": session_id, - "audio_uuid": session_id, - "client_id": client_id, - "session_level": True # Mark as session-level job - }) + current_job.meta.update( + { + "status": "listening_for_speech", + "session_id": session_id, + "audio_uuid": session_id, + "client_id": client_id, + "session_level": True, # Mark as session-level job + } + ) current_job.save_meta() # Main loop: Listen for speech while True: + # Check if job still exists in Redis (detect zombie state) + from advanced_omi_backend.utils.job_utils import check_job_alive + + if not await check_job_alive(redis_client, current_job): + break + # Exit conditions session_status = await redis_client.hget(session_key, "status") if session_status and session_status.decode() in ["complete", "closed"]: @@ -464,16 +566,11 @@ async def stream_speech_detection_job( # Add session event for speech detected from datetime import datetime + await redis_client.hset( - session_key, - "last_event", - f"speech_detected:{datetime.utcnow().isoformat()}" - ) - await redis_client.hset( - session_key, - "speech_detected_at", - datetime.utcnow().isoformat() + session_key, "last_event", f"speech_detected:{datetime.utcnow().isoformat()}" ) + await redis_client.hset(session_key, "speech_detected_at", datetime.utcnow().isoformat()) # Step 2: If speaker filter enabled, check for enrolled speakers identified_speakers = [] @@ -483,15 +580,9 @@ async def stream_speech_detection_job( # Add session event for speaker check starting await redis_client.hset( - session_key, - "last_event", - f"speaker_check_starting:{datetime.utcnow().isoformat()}" - ) - await redis_client.hset( - session_key, - "speaker_check_status", - "checking" + session_key, "last_event", f"speaker_check_starting:{datetime.utcnow().isoformat()}" ) + await redis_client.hset(session_key, "speaker_check_status", "checking") from .speaker_jobs import check_enrolled_speakers_job # Enqueue speaker check as a separate trackable job @@ -504,7 +595,7 @@ async def stream_speech_detection_job( result_ttl=600, job_id=f"speaker-check_{session_id[:12]}_{conversation_count}", description=f"Speaker check for conversation #{conversation_count+1}", - meta={'audio_uuid': session_id, 'client_id': client_id} + meta={"audio_uuid": session_id, "client_id": client_id}, ) # Poll for result (with timeout) @@ -518,8 +609,11 @@ async def stream_speech_detection_job( speaker_check_job.refresh() except Exception as e: from rq.exceptions import NoSuchJobError + if isinstance(e, NoSuchJobError): - logger.warning(f"โš ๏ธ Speaker check job disappeared from Redis (likely completed quickly), assuming not enrolled") + logger.warning( + f"โš ๏ธ Speaker check job disappeared from Redis (likely completed quickly), assuming not enrolled" + ) break else: raise @@ -534,18 +628,16 @@ async def stream_speech_detection_job( await redis_client.hset( session_key, "last_event", - f"speaker_check_complete:{datetime.utcnow().isoformat()}" + f"speaker_check_complete:{datetime.utcnow().isoformat()}", ) await redis_client.hset( session_key, "speaker_check_status", - "enrolled" if enrolled_present else "not_enrolled" + "enrolled" if enrolled_present else "not_enrolled", ) if identified_speakers: await redis_client.hset( - session_key, - "identified_speakers", - ",".join(identified_speakers) + session_key, "identified_speakers", ",".join(identified_speakers) ) break elif speaker_check_job.is_failed: @@ -555,38 +647,36 @@ async def stream_speech_detection_job( await redis_client.hset( session_key, "last_event", - f"speaker_check_failed:{datetime.utcnow().isoformat()}" - ) - await redis_client.hset( - session_key, - "speaker_check_status", - "failed" + f"speaker_check_failed:{datetime.utcnow().isoformat()}", ) + await redis_client.hset(session_key, "speaker_check_status", "failed") break await asyncio.sleep(poll_interval) waited += poll_interval else: # Timeout - assume not enrolled - logger.warning(f"โฑ๏ธ Speaker check timed out after {max_wait}s, assuming not enrolled") + logger.warning( + f"โฑ๏ธ Speaker check timed out after {max_wait}s, assuming not enrolled" + ) enrolled_present = False # Update session event for speaker check timeout await redis_client.hset( session_key, "last_event", - f"speaker_check_timeout:{datetime.utcnow().isoformat()}" - ) - await redis_client.hset( - session_key, - "speaker_check_status", - "timeout" + f"speaker_check_timeout:{datetime.utcnow().isoformat()}", ) + await redis_client.hset(session_key, "speaker_check_status", "timeout") # Log speaker check result but proceed with conversation regardless if enrolled_present: - logger.info(f"โœ… Enrolled speaker(s) found: {', '.join(identified_speakers) if identified_speakers else 'Unknown'}") + logger.info( + f"โœ… Enrolled speaker(s) found: {', '.join(identified_speakers) if identified_speakers else 'Unknown'}" + ) else: - logger.info(f"โ„น๏ธ No enrolled speakers found, but proceeding with conversation anyway") + logger.info( + f"โ„น๏ธ No enrolled speakers found, but proceeding with conversation anyway" + ) # Step 3: Start conversation and EXIT speech_detected_at = time.time() @@ -608,7 +698,7 @@ async def stream_speech_detection_job( result_ttl=JOB_RESULT_TTL, # Use configured TTL (24 hours) instead of 10 minutes job_id=f"open-conv_{session_id[:12]}_{conversation_count}", description=f"Conversation #{conversation_count+1} for {session_id[:12]}", - meta={'audio_uuid': session_id, 'client_id': client_id} + meta={"audio_uuid": session_id, "client_id": client_id}, ) # Track the job @@ -620,17 +710,19 @@ async def stream_speech_detection_job( current_job.meta = {} # Remove session_level flag now that conversation is starting - current_job.meta.pop('session_level', None) - - current_job.meta.update({ - "conversation_job_id": open_job.id, - "speaker_check_job_id": speaker_check_job.id if speaker_check_job else None, - "detected_speakers": identified_speakers, - "speech_detected_at": datetime.fromtimestamp(speech_detected_at).isoformat(), - "session_id": session_id, - "audio_uuid": session_id, # For job grouping - "client_id": client_id # For job grouping - }) + current_job.meta.pop("session_level", None) + + current_job.meta.update( + { + "conversation_job_id": open_job.id, + "speaker_check_job_id": speaker_check_job.id if speaker_check_job else None, + "detected_speakers": identified_speakers, + "speech_detected_at": datetime.fromtimestamp(speech_detected_at).isoformat(), + "session_id": session_id, + "audio_uuid": session_id, # For job grouping + "client_id": client_id, # For job grouping + } + ) current_job.save_meta() logger.info(f"โœ… Started conversation job {open_job.id}, exiting speech detection") @@ -641,7 +733,7 @@ async def stream_speech_detection_job( "client_id": client_id, "conversation_job_id": open_job.id, "speech_detected_at": datetime.fromtimestamp(speech_detected_at).isoformat(), - "runtime_seconds": time.time() - start_time + "runtime_seconds": time.time() - start_time, } # Session ended without speech @@ -651,7 +743,5 @@ async def stream_speech_detection_job( "user_id": user_id, "client_id": client_id, "no_speech_detected": True, - "runtime_seconds": time.time() - start_time + "runtime_seconds": time.time() - start_time, } - - diff --git a/backends/advanced/webui/package-lock.json b/backends/advanced/webui/package-lock.json index bde3b515..39cfba6b 100644 --- a/backends/advanced/webui/package-lock.json +++ b/backends/advanced/webui/package-lock.json @@ -10,14 +10,20 @@ "dependencies": { "axios": "^1.6.2", "clsx": "^2.0.0", + "d3": "^7.8.5", + "frappe-gantt": "^0.6.1", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.0" + "react-router-dom": "^6.20.0", + "react-vertical-timeline-component": "^3.6.0" }, "devDependencies": { + "@types/d3": "^7.4.3", + "@types/frappe-gantt": "^0.9.0", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", + "@types/react-vertical-timeline-component": "^3.3.6", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", @@ -26,6 +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", "tailwindcss": "^3.3.0", "typescript": "^5.2.2", "vite": "^5.0.8" @@ -360,6 +367,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.1.tgz", + "integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1024,6 +1038,316 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1376,59 +1700,367 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", "dev": true, "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "dev": true, "license": "MIT" }, - "node_modules/@types/react": { - "version": "18.3.23", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", - "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", "dev": true, "license": "MIT", "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" + "@types/d3-array": "*", + "@types/geojson": "*" } }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } + "license": "MIT" }, - "node_modules/@types/semver": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", - "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", "dev": true, "license": "MIT" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/frappe-gantt": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@types/frappe-gantt/-/frappe-gantt-0.9.0.tgz", + "integrity": "sha512-n00ElvRvJ1/+HkJwt57yjnTtAM7FcH/pEV9LbRCy3+hR39TY6l0mQuy4o909uxvw97aCNhQjNh8J8xACKJ2G3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-vertical-timeline-component": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/react-vertical-timeline-component/-/react-vertical-timeline-component-3.3.6.tgz", + "integrity": "sha512-OUvyPXRjXvUD/SNLO0CW0GbIxVF32Ios5qHecMSfw6kxnK1cPULD9NV80EuqZ3WmS/s6BgbcwmN8k4ISb3akhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { @@ -1893,6 +2525,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "dev": true, + "license": "MIT/X11" + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2002,6 +2641,12 @@ "node": ">= 6" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2031,6 +2676,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2102,97 +2754,530 @@ "dev": true, "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", "dependencies": { - "ms": "^2.1.3" + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=12" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", "engines": { - "node": ">=0.4.0" + "node": ">=12" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } }, - "node_modules/dir-glob": { + "node_modules/d3-chord": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", "dependencies": { - "path-type": "^4.0.0" + "d3-path": "1 - 3" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", "dependencies": { - "esutils": "^2.0.2" + "d3-array": "^3.2.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=12" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "delaunator": "5" }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, "license": "MIT" @@ -2709,6 +3794,12 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/frappe-gantt": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/frappe-gantt/-/frappe-gantt-0.6.1.tgz", + "integrity": "sha512-1cSU9vLbwypjzaxnCfnEE03Xr3HlAV2S8dRtjxw62o+amkx1A8bBIFd2jp84mcDdTCM77Ij4LzZBslAKZB8oMg==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2945,10 +4036,22 @@ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, "node_modules/ignore": { @@ -2961,6 +4064,13 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3007,6 +4117,15 @@ "dev": true, "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3135,111 +4254,388 @@ "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.294.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", + "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "bin": { - "jsesc": "bin/jsesc" + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=6" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", "dependencies": { - "json-buffer": "3.0.1" + "wrappy": "1" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, "engines": { - "node": ">=14" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "p-limit": "^3.0.2" }, "engines": { "node": ">=10" @@ -3248,815 +4644,1030 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "callsites": "^3.0.0" }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": ">=6" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/lucide-react": { - "version": "0.294.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", - "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true, "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, "engines": { - "node": ">=8.6" + "node": ">=0.10.0" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 6" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">= 0.6" + "node": "^10 || ^12 || >=14" } }, - "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=14.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "postcss": "^8.0.0" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "dev": true, "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "postcss-selector-parser": "^6.1.1" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true, "license": "MIT" }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, "engines": { "node": ">=0.10.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, + "node_modules/react-intersection-observer": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-8.34.0.tgz", + "integrity": "sha512-TYKh52Zc0Uptp5/b4N91XydfSGKubEhgZRtcg1rhTKABXijc4Sdr1uTp5lJ8TN27jwUsdXxjHXtHa0kPj704sw==", "license": "MIT", - "engines": { - "node": ">= 6" + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0|| ^18.0.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, "engines": { - "node": ">= 0.8.0" + "node": ">=0.10.0" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "@remix-run/router": "1.23.0" }, "engines": { - "node": ">=10" + "node": ">=14.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": ">=16.8" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" }, "engines": { - "node": ">=10" + "node": ">=14.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "node_modules/react-vertical-timeline-component": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/react-vertical-timeline-component/-/react-vertical-timeline-component-3.6.0.tgz", + "integrity": "sha512-l9zulqjIGlRuaQeplGzV4r/tG2RYBpYt84Il8w4IxnJze2cDIGI04MKo3F7f1sHT0Sih1ohEFts8UV23AJS15Q==", + "hasInstallScript": true, "license": "MIT", "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" + "classnames": "^2.2.6", + "prop-types": "^15.7.2", + "react-intersection-observer": "^8.26.2" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "pify": "^2.3.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8.10.0" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/path-type": { + "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "iojs": ">=1.0.0", + "node": ">=0.10.0" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", + "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", "dev": true, "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, "engines": { - "node": ">= 6" + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.2", + "@rollup/rollup-android-arm64": "4.46.2", + "@rollup/rollup-darwin-arm64": "4.46.2", + "@rollup/rollup-darwin-x64": "4.46.2", + "@rollup/rollup-freebsd-arm64": "4.46.2", + "@rollup/rollup-freebsd-x64": "4.46.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", + "@rollup/rollup-linux-arm-musleabihf": "4.46.2", + "@rollup/rollup-linux-arm64-gnu": "4.46.2", + "@rollup/rollup-linux-arm64-musl": "4.46.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", + "@rollup/rollup-linux-ppc64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-musl": "4.46.2", + "@rollup/rollup-linux-s390x-gnu": "4.46.2", + "@rollup/rollup-linux-x64-gnu": "4.46.2", + "@rollup/rollup-linux-x64-musl": "4.46.2", + "@rollup/rollup-win32-arm64-msvc": "4.46.2", + "@rollup/rollup-win32-ia32-msvc": "4.46.2", + "@rollup/rollup-win32-x64-msvc": "4.46.2", + "fsevents": "~2.3.2" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "type": "github", + "url": "https://github.com/sponsors/feross" }, { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "type": "patreon", + "url": "https://www.patreon.com/feross" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "consulting", + "url": "https://feross.org/support" } ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "queue-microtask": "^1.2.2" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.95.1.tgz", + "integrity": "sha512-uPoDh5NIEZV4Dp5GBodkmNY9tSQfXY02pmCcUo+FR1P+x953HGkpw+vV28D4IqYB6f8webZtwoSaZaiPtpTeMg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" }, "engines": { "node": ">=14.0.0" }, - "peerDependencies": { - "postcss": "^8.0.0" + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "node_modules/sass-embedded": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.95.1.tgz", + "integrity": "sha512-l086+s40Z0qP7ckj4T+rI/7tZcwAfcKCG9ah9A808yINWOxZFv0kO0u/UHhR4G9Aimeyax/JNvqh8RE7z1wngg==", "dev": true, "license": "MIT", "dependencies": { - "camelcase-css": "^2.0.1" + "@bufbuild/protobuf": "^2.5.0", + "buffer-builder": "^0.2.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" }, - "engines": { - "node": "^12 || ^14 || >= 16" + "bin": { + "sass": "dist/bin/sass.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "engines": { + "node": ">=16.0.0" }, - "peerDependencies": { - "postcss": "^8.4.21" + "optionalDependencies": { + "sass-embedded-all-unknown": "1.95.1", + "sass-embedded-android-arm": "1.95.1", + "sass-embedded-android-arm64": "1.95.1", + "sass-embedded-android-riscv64": "1.95.1", + "sass-embedded-android-x64": "1.95.1", + "sass-embedded-darwin-arm64": "1.95.1", + "sass-embedded-darwin-x64": "1.95.1", + "sass-embedded-linux-arm": "1.95.1", + "sass-embedded-linux-arm64": "1.95.1", + "sass-embedded-linux-musl-arm": "1.95.1", + "sass-embedded-linux-musl-arm64": "1.95.1", + "sass-embedded-linux-musl-riscv64": "1.95.1", + "sass-embedded-linux-musl-x64": "1.95.1", + "sass-embedded-linux-riscv64": "1.95.1", + "sass-embedded-linux-x64": "1.95.1", + "sass-embedded-unknown-all": "1.95.1", + "sass-embedded-win32-arm64": "1.95.1", + "sass-embedded-win32-x64": "1.95.1" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.95.1.tgz", + "integrity": "sha512-ObGM3xSHEK2fu89GusvAdk1hId3D1R03CyQ6/AVTFSrcBFav1a3aWUmBWtImzf5LsVzliRnlAPPS6+rT/Ghb1A==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.95.1" } }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/sass-embedded-android-arm": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.95.1.tgz", + "integrity": "sha512-siaN1TVEjhBP4QJ5UlDBRhyKmMbFhbdcyHj0B4hIuNcinuVprP6tH1NT0NkHvkXh2egBmTvjzZgJ1ySsCB32JA==", + "cpu": [ + "arm" ], + "dev": true, "license": "MIT", - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } + "node": ">=14.0.0" } }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/sass-embedded-android-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.95.1.tgz", + "integrity": "sha512-E+3vZXhUOVHFiSITH2g53/ynxTG4zz8vTVrXGAKkZQwSe6aCO22uc1Pah23F3jOrDNF/YLrsyp82T/CIIczK3w==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" + "node": ">=14.0.0" } }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "node_modules/sass-embedded-android-riscv64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.95.1.tgz", + "integrity": "sha512-UcPcr5JXVtInD+/XE+2DhwPsALUdRAHyippnnAP6MtdaT3+AnqqvzSVy9Gb6SKyeqEk4YxPmIlQpZCVODDT4eA==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=4" + "node": ">=14.0.0" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "node_modules/sass-embedded-android-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.95.1.tgz", + "integrity": "sha512-sW/TO+B0Wq9VDTa7YiO74DW4iF9jEYds+9yslaHtc69r/Ch+Zj+ZB6HeJysfmen91zn5CLJDGrnTSrIk+/COfQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.95.1.tgz", + "integrity": "sha512-SWTCwszlBzjin35T2OiGZSDRbC/sqg5Mjepih18lelELrz14eB9LcFTZeiqDfdnwx6qQqPWj2VufCpExr8jElA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.8.0" + "node": ">=14.0.0" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/sass-embedded-darwin-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.95.1.tgz", + "integrity": "sha512-0GZEgkE1e8E2h97lUtwgZbKHrJYmRE/KhWQBHv6ZueAto8DJcAFNFrIQiQoRJjraE6QTaw6ahSvc1YJ7gL4OQA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6" + "node": ">=14.0.0" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/sass-embedded-linux-arm": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.95.1.tgz", + "integrity": "sha512-zUAm/rztm5Uyy+DSs408VJg404siVgUuZyqId4tFwkPNC5WRKu25Z8bFMriyGaE4YfEqbNwFV07C16mJoGeVOA==", + "cpu": [ + "arm" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" ], - "license": "MIT" + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "node_modules/sass-embedded-linux-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.95.1.tgz", + "integrity": "sha512-MQxa+qVX7Os2rMpJ/AvhWup+1cS0JieQgCfi9cz1Zckn4zaUhg35+m2FQhfKvzv4afeW5bubTMOQeTRMQujbXw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">=14.0.0" } }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.95.1.tgz", + "integrity": "sha512-gNdaGmM3nZ0jkFNmyXWyNlXZPdaMP+7n5Mk3yGFGShqRt/6T/bHh5SkyNnU2ZdP1z7R9poPItJhULrZJ42ETeA==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" } }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.95.1.tgz", + "integrity": "sha512-8lD5vHGzBjBRCMIr9CXCyjmy8Q1q+H4ygcYCIm/aPNYhrm9uPOzJfs8hv9kDRgRAASFkcPGlFw8tDH4QqiJ5wg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">=14.0.0" } }, - "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.95.1.tgz", + "integrity": "sha512-WjKfHxnFc/jOL5QtmgYuiWCc4616V15DkpE+7z41JWEawRXku6w++w7AR+Zx/jbz93FZ/AsZp27IS3XUt80u3Q==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" } }, - "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.95.1.tgz", + "integrity": "sha512-3U6994SRUUmC8mPvSG/vNLUo2ZcGv3jHuPoBywTbJhGQI8gq0hef1MY8TU5mvtj9DhQYlah6MYktM4YrOQgqcQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.95.1.tgz", + "integrity": "sha512-CJ0tEEQnfpJEMCQrdubLsmuVc/c66EgaCAO0ZgSJ/KpxBKF3O1lHN6e1UErRf6VO0rh8ExAOh75po12Vu849Og==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "pify": "^2.3.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/sass-embedded-linux-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.95.1.tgz", + "integrity": "sha512-nGnzrEpZZOsGOwrRVyX4t15M8ijZWhc4e4lLpOqaPm+lv23HFncfY05WxU5bRj0KAknrkeTM2IX/6veP2aeUdA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8.10.0" + "node": ">=14.0.0" } }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "node_modules/sass-embedded-unknown-all": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.95.1.tgz", + "integrity": "sha512-bhywAcadVQoCotD4gVmyMBi2SENPvyLFPrXf33VK5mY487Nf/g5SgGCUuGmfTsbns4NBwbwR7PA/1fnJmeMtdA==", "dev": true, "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "sass": "1.95.1" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/sass-embedded-win32-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.95.1.tgz", + "integrity": "sha512-RWWODCthWdMVODoq98lyIk9R56mgGJ4TFUjD9LSCe7fAYD/tiTkUabE4AUzkZqknQSYr0n0Q2uy7POSDIKvhVg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=4" + "node": ">=14.0.0" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/sass-embedded-win32-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.95.1.tgz", + "integrity": "sha512-jotHgOQnCb1XdjK0fhsyuhsfox7Y5EkrOc4h2caEpRcNCnsPTBZHqhuc8Lnw8HbKIhwKYkqWhexkjgz62MShhg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">=14.0.0" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "glob": "^7.1.3" + "has-flag": "^4.0.0" }, - "bin": { - "rimraf": "bin.js" + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" + "readdirp": "^4.0.1" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">= 14.16.0" }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", - "fsevents": "~2.3.2" + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/scheduler": { @@ -4333,6 +5944,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -4434,6 +6068,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4522,6 +6163,13 @@ "dev": true, "license": "MIT" }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.19", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", diff --git a/backends/advanced/webui/package.json b/backends/advanced/webui/package.json index 17894a86..120bbe9c 100644 --- a/backends/advanced/webui/package.json +++ b/backends/advanced/webui/package.json @@ -12,14 +12,20 @@ "dependencies": { "axios": "^1.6.2", "clsx": "^2.0.0", + "d3": "^7.8.5", + "frappe-gantt": "^0.6.1", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.0" + "react-router-dom": "^6.20.0", + "react-vertical-timeline-component": "^3.6.0" }, "devDependencies": { + "@types/d3": "^7.4.3", + "@types/frappe-gantt": "^0.9.0", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", + "@types/react-vertical-timeline-component": "^3.3.6", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", @@ -28,6 +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", "tailwindcss": "^3.3.0", "typescript": "^5.2.2", "vite": "^5.0.8" diff --git a/backends/advanced/webui/public/frappe-gantt.css b/backends/advanced/webui/public/frappe-gantt.css new file mode 100644 index 00000000..73d5781b --- /dev/null +++ b/backends/advanced/webui/public/frappe-gantt.css @@ -0,0 +1 @@ +:root{--g-arrow-color: #1f2937;--g-bar-color: #fff;--g-bar-border: #fff;--g-tick-color-thick: #ededed;--g-tick-color: #f3f3f3;--g-actions-background: #f3f3f3;--g-border-color: #ebeff2;--g-text-muted: #7c7c7c;--g-text-light: #fff;--g-text-dark: #171717;--g-progress-color: #dbdbdb;--g-handle-color: #37352f;--g-weekend-label-color: #dcdce4;--g-expected-progress: #c4c4e9;--g-header-background: #fff;--g-row-color: #fdfdfd;--g-row-border-color: #c7c7c7;--g-today-highlight: #37352f;--g-popup-actions: #ebeff2;--g-weekend-highlight-color: #f7f7f7}.gantt-container{line-height:14.5px;position:relative;overflow:auto;font-size:12px;height:var(--gv-grid-height);width:100%;border-radius:8px}.gantt-container .popup-wrapper{position:absolute;top:0;left:0;background:#fff;box-shadow:0 10px 24px -3px #0003;padding:10px;border-radius:5px;width:max-content;z-index:1000}.gantt-container .popup-wrapper .title{margin-bottom:2px;color:var(--g-text-dark);font-size:.85rem;font-weight:650;line-height:15px}.gantt-container .popup-wrapper .subtitle{color:var(--g-text-dark);font-size:.8rem;margin-bottom:5px}.gantt-container .popup-wrapper .details{color:var(--g-text-muted);font-size:.7rem}.gantt-container .popup-wrapper .actions{margin-top:10px;margin-left:3px}.gantt-container .popup-wrapper .action-btn{border:none;padding:5px 8px;background-color:var(--g-popup-actions);border-right:1px solid var(--g-text-light)}.gantt-container .popup-wrapper .action-btn:hover{background-color:brightness(97%)}.gantt-container .popup-wrapper .action-btn:first-child{border-top-left-radius:4px;border-bottom-left-radius:4px}.gantt-container .popup-wrapper .action-btn:last-child{border-right:none;border-top-right-radius:4px;border-bottom-right-radius:4px}.gantt-container .grid-header{height:calc(var(--gv-lower-header-height) + var(--gv-upper-header-height) + 10px);background-color:var(--g-header-background);position:sticky;top:0;left:0;border-bottom:1px solid var(--g-row-border-color);z-index:1000}.gantt-container .lower-text,.gantt-container .upper-text{text-anchor:middle}.gantt-container .upper-header{height:var(--gv-upper-header-height)}.gantt-container .lower-header{height:var(--gv-lower-header-height)}.gantt-container .lower-text{font-size:12px;position:absolute;width:calc(var(--gv-column-width) * .8);height:calc(var(--gv-lower-header-height) * .8);margin:0 calc(var(--gv-column-width) * .1);align-content:center;text-align:center;color:var(--g-text-muted)}.gantt-container .upper-text{position:absolute;width:fit-content;font-weight:500;font-size:14px;color:var(--g-text-dark);height:calc(var(--gv-lower-header-height) * .66)}.gantt-container .current-upper{position:sticky;left:0!important;padding-left:17px;background:#fff}.gantt-container .side-header{position:sticky;top:0;right:0;float:right;z-index:1000;line-height:20px;font-weight:400;width:max-content;margin-left:auto;padding-right:10px;padding-top:10px;background:var(--g-header-background);display:flex}.gantt-container .side-header *{transition-property:background-color;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;background-color:var(--g-actions-background);border-radius:.5rem;border:none;padding:5px 8px;color:var(--g-text-dark);font-size:14px;letter-spacing:.02em;font-weight:420;box-sizing:content-box;margin-right:5px}.gantt-container .side-header *:last-child{margin-right:0}.gantt-container .side-header *:hover{filter:brightness(97.5%)}.gantt-container .side-header select{width:60px;padding-top:2px;padding-bottom:2px}.gantt-container .side-header select:focus{outline:none}.gantt-container .date-range-highlight{background-color:var(--g-progress-color);border-radius:12px;height:calc(var(--gv-lower-header-height) - 6px);top:calc(var(--gv-upper-header-height) + 5px);position:absolute}.gantt-container .current-highlight{position:absolute;background:var(--g-today-highlight);width:1px;z-index:999}.gantt-container .current-ball-highlight{position:absolute;background:var(--g-today-highlight);z-index:1001;border-radius:50%}.gantt-container .current-date-highlight{background:var(--g-today-highlight);color:var(--g-text-light);border-radius:5px}.gantt-container .holiday-label{position:absolute;top:0;left:0;opacity:0;z-index:1000;background:--g-weekend-label-color;border-radius:5px;padding:2px 5px}.gantt-container .holiday-label.show{opacity:100}.gantt-container .extras{position:sticky;left:0}.gantt-container .extras .adjust{position:absolute;left:8px;top:calc(var(--gv-grid-height) - 60px);background-color:#000000b3;color:#fff;border:none;padding:8px;border-radius:3px}.gantt-container .hide{display:none}.gantt{user-select:none;-webkit-user-select:none;position:absolute}.gantt .grid-background{fill:none}.gantt .grid-row{fill:var(--g-row-color)}.gantt .row-line{stroke:var(--g-border-color)}.gantt .tick{stroke:var(--g-tick-color);stroke-width:.4}.gantt .tick.thick{stroke:var(--g-tick-color-thick);stroke-width:.7}.gantt .arrow{fill:none;stroke:var(--g-arrow-color);stroke-width:1.5}.gantt .bar-wrapper .bar{fill:var(--g-bar-color);stroke:var(--g-bar-border);stroke-width:0;transition:stroke-width .3s ease}.gantt .bar-progress{fill:var(--g-progress-color);border-radius:4px}.gantt .bar-expected-progress{fill:var(--g-expected-progress)}.gantt .bar-invalid{fill:transparent;stroke:var(--g-bar-border);stroke-width:1;stroke-dasharray:5}:is(.gantt .bar-invalid)~.bar-label{fill:var(--g-text-light)}.gantt .bar-label{fill:var(--g-text-dark);dominant-baseline:central;font-family:Helvetica;font-size:13px;font-weight:400}.gantt .bar-label.big{fill:var(--g-text-dark);text-anchor:start}.gantt .handle{fill:var(--g-handle-color);opacity:0;transition:opacity .3s ease}.gantt .handle.active,.gantt .handle.visible{cursor:ew-resize;opacity:1}.gantt .handle.progress{fill:var(--g-text-muted)}.gantt .bar-wrapper{cursor:pointer}.gantt .bar-wrapper .bar{outline:1px solid var(--g-row-border-color);border-radius:3px}.gantt .bar-wrapper:hover .bar{transition:transform .3s ease}.gantt .bar-wrapper:hover .date-range-highlight{display:block} diff --git a/backends/advanced/webui/src/App.tsx b/backends/advanced/webui/src/App.tsx index 39605087..6f7f3e72 100644 --- a/backends/advanced/webui/src/App.tsx +++ b/backends/advanced/webui/src/App.tsx @@ -4,8 +4,10 @@ import { ThemeProvider } from './contexts/ThemeContext' import Layout from './components/layout/Layout' import LoginPage from './pages/LoginPage' import Chat from './pages/Chat' -import Conversations from './pages/Conversations' -import Memories from './pages/Memories' +import ConversationsRouter from './pages/ConversationsRouter' +import MemoriesRouter from './pages/MemoriesRouter' +import MemoryDetail from './pages/MemoryDetail' +import TimelineRouter from './pages/TimelineRouter' import Users from './pages/Users' import System from './pages/System' import Upload from './pages/Upload' @@ -31,7 +33,7 @@ function App() { }> - + } /> - + + + } /> + + } /> - + + + } /> + + } /> { const audioContextRef = useRef(null) const analyserRef = useRef(null) const processorRef = useRef(null) - const durationIntervalRef = useRef() - const keepAliveIntervalRef = useRef() + const durationIntervalRef = useRef>() + const keepAliveIntervalRef = useRef>() const audioProcessingStartedRef = useRef(false) const chunkCountRef = useRef(0) // Note: Legacy message queue code removed as it was unused diff --git a/backends/advanced/webui/src/hooks/useD3Zoom.ts b/backends/advanced/webui/src/hooks/useD3Zoom.ts new file mode 100644 index 00000000..87f16735 --- /dev/null +++ b/backends/advanced/webui/src/hooks/useD3Zoom.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import * as d3 from 'd3' + +interface UseD3ZoomOptions { + onZoom?: (transform: d3.ZoomTransform) => void + scaleExtent?: [number, number] + wheelDelta?: (event: WheelEvent) => number +} + +export function useD3Zoom(options: UseD3ZoomOptions = {}) { + const { + onZoom, + scaleExtent = [0.5, 5], + wheelDelta = (event) => -event.deltaY * 0.002 + } = options + + const svgRef = useRef(null) + const [transform, setTransform] = useState(d3.zoomIdentity) + const initializedRef = useRef(false) + + const handleZoom = useCallback( + (event: d3.D3ZoomEvent) => { + const t = event.transform + setTransform(t) + onZoom?.(t) + + // Synchronize zoom across all zoomable SVG elements + d3.selectAll('.zoomable').each(function (this: SVGSVGElement) { + const svg = d3.select(this) + const node = svg.node() + + // Skip the source element + if (!node || node.contains(event.sourceEvent?.target as Element)) { + return + } + + svg.property('__zoom', t) + }) + }, + [onZoom] + ) + + const zoomBehavior = useMemo( + () => + d3.zoom() + .scaleExtent(scaleExtent) + .on('zoom', handleZoom) + .wheelDelta(wheelDelta) + .touchable(() => true) + .filter((event: Event) => { + const mouseEvent = event as MouseEvent + if (event.type === 'dblclick') return false + if (mouseEvent.button && mouseEvent.button !== 0) return false + return true + }), + [handleZoom, scaleExtent, wheelDelta] + ) + + // Set initial transform once on mount + useEffect(() => { + if (!svgRef.current || initializedRef.current) return + + const svg = d3.select(svgRef.current) + svg.property('__zoom', d3.zoomIdentity) + initializedRef.current = true + }, []) + + // Setup zoom behavior (only when zoomBehavior changes) + useEffect(() => { + if (!svgRef.current) return + + const svg = d3.select(svgRef.current) + const node = svg.node() + + if (node) { + node.style.touchAction = 'none' + node.style.webkitUserSelect = 'none' + node.style.userSelect = 'none' + } + + svg.call(zoomBehavior as any) + + return () => { + svg.on('.zoom', null) + } + }, [zoomBehavior]) + + return { + svgRef, + transform, + zoomBehavior + } +} diff --git a/backends/advanced/webui/src/hooks/useSimpleAudioRecording.ts b/backends/advanced/webui/src/hooks/useSimpleAudioRecording.ts index 268544c7..e0a1badc 100644 --- a/backends/advanced/webui/src/hooks/useSimpleAudioRecording.ts +++ b/backends/advanced/webui/src/hooks/useSimpleAudioRecording.ts @@ -58,8 +58,8 @@ export const useSimpleAudioRecording = (): SimpleAudioRecordingReturn => { const audioContextRef = useRef(null) const analyserRef = useRef(null) const processorRef = useRef(null) - const durationIntervalRef = useRef() - const keepAliveIntervalRef = useRef() + const durationIntervalRef = useRef>() + const keepAliveIntervalRef = useRef>() const chunkCountRef = useRef(0) const audioProcessingStartedRef = useRef(false) diff --git a/backends/advanced/webui/src/pages/ConversationsRouter.tsx b/backends/advanced/webui/src/pages/ConversationsRouter.tsx new file mode 100644 index 00000000..c7e6e95c --- /dev/null +++ b/backends/advanced/webui/src/pages/ConversationsRouter.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react' +import Conversations from './Conversations' +import ConversationsTimeline from './ConversationsTimeline' + +export default function ConversationsRouter() { + const [activeTab, setActiveTab] = useState<'classic' | 'timeline'>('classic') + + return ( +
+ {/* Tab Navigation */} +
+ +
+ + {/* Content */} + {activeTab === 'classic' ? ( + + ) : ( + + )} +
+ ) +} diff --git a/backends/advanced/webui/src/pages/ConversationsTimeline.tsx b/backends/advanced/webui/src/pages/ConversationsTimeline.tsx new file mode 100644 index 00000000..5c3f748f --- /dev/null +++ b/backends/advanced/webui/src/pages/ConversationsTimeline.tsx @@ -0,0 +1,321 @@ +import { useState, useEffect } from 'react' +import { MessageSquare, RefreshCw, User, Clock, ChevronDown, ChevronUp } from 'lucide-react' +import { VerticalTimeline, VerticalTimelineElement } from 'react-vertical-timeline-component' +import 'react-vertical-timeline-component/style.min.css' +import { conversationsApi } from '../services/api' + +interface Conversation { + conversation_id?: string + audio_uuid: string + title?: string + summary?: string + detailed_summary?: string + created_at?: string + client_id: string + segment_count?: number + memory_count?: number + audio_path?: string + cropped_audio_path?: string + duration_seconds?: number + has_memory?: boolean + transcript?: string + segments?: Array<{ + text: string + speaker: string + start: number + end: number + confidence?: number + }> + active_transcript_version?: string + active_memory_version?: string + transcript_version_count?: number + memory_version_count?: number + deleted?: boolean + deletion_reason?: string + deleted_at?: string +} + +interface ConversationCardProps { + conversation: Conversation + formatDuration: (seconds: number) => string +} + +function ConversationCard({ conversation, formatDuration }: ConversationCardProps) { + const [isExpanded, setIsExpanded] = useState(false) + + return ( +
+ {/* Card Header - Always visible */} +
setIsExpanded(!isExpanded)} + > +
+

+ {conversation.title || 'Conversation'} +

+ {isExpanded ? ( + + ) : ( + + )} +
+ + {conversation.summary && ( +

+ {conversation.summary} +

+ )} + +
+ + + {conversation.client_id} + + {conversation.segment_count !== undefined && ( + + {conversation.segment_count} segments + + )} + {conversation.memory_count !== undefined && conversation.memory_count > 0 && ( + + {conversation.memory_count} memories + + )} + {conversation.duration_seconds && ( + + + {formatDuration(conversation.duration_seconds)} + + )} + {conversation.deleted && ( + + Failed: {conversation.deletion_reason || 'Unknown'} + + )} +
+
+ + {/* Expanded Details */} + {isExpanded && ( +
+ {/* Detailed Summary */} + {conversation.detailed_summary && ( +
+

Detailed Summary

+

{conversation.detailed_summary}

+
+ )} + + {/* Transcript */} + {conversation.transcript && ( +
+

Transcript

+
+ {conversation.transcript} +
+
+ )} + + {/* Segments */} + {conversation.segments && conversation.segments.length > 0 && ( +
+

Segments ({conversation.segments.length})

+
+ {conversation.segments.map((segment, idx) => ( +
+
+ {segment.speaker} + + {Math.floor(segment.start)}s - {Math.floor(segment.end)}s + +
+

{segment.text}

+ {segment.confidence && ( + + Confidence: {(segment.confidence * 100).toFixed(1)}% + + )} +
+ ))} +
+
+ )} + + {/* Metadata */} +
+ {conversation.conversation_id && ( +
+ ID:{' '} + {conversation.conversation_id.slice(0, 8)}... +
+ )} + {conversation.audio_uuid && ( +
+ Audio UUID:{' '} + {conversation.audio_uuid.slice(0, 8)}... +
+ )} + {conversation.active_transcript_version && ( +
+ Transcript Version:{' '} + {conversation.active_transcript_version} +
+ )} + {conversation.transcript_version_count && ( +
+ Total Versions:{' '} + {conversation.transcript_version_count} +
+ )} +
+ + {/* Audio Paths */} + {(conversation.audio_path || conversation.cropped_audio_path) && ( +
+ {conversation.audio_path && ( +
+ Audio:{' '} + {conversation.audio_path} +
+ )} + {conversation.cropped_audio_path && ( +
+ Cropped:{' '} + {conversation.cropped_audio_path} +
+ )} +
+ )} +
+ )} +
+ ) +} + +export default function ConversationsTimeline() { + const [conversations, setConversations] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadConversations = async () => { + try { + setLoading(true) + const response = await conversationsApi.getAll() + const conversationsList = response.data.conversations || [] + setConversations(conversationsList) + setError(null) + } catch (err: any) { + setError(err.message || 'Failed to load conversations') + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadConversations() + }, []) + + const formatDate = (timestamp: number | string): Date => { + if (typeof timestamp === 'string') { + const isoString = timestamp.endsWith('Z') || timestamp.includes('+') || timestamp.includes('T') && timestamp.split('T')[1].includes('-') + ? timestamp + : timestamp + 'Z' + return new Date(isoString) + } + if (timestamp === 0) { + return new Date() + } + return new Date(timestamp * 1000) + } + + const formatDuration = (seconds: number) => { + const minutes = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${minutes}:${secs.toString().padStart(2, '0')}` + } + + if (loading) { + return ( +
+
+ Loading conversations... +
+ ) + } + + if (error) { + return ( +
+
{error}
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +

+ Conversations Timeline +

+
+ +
+ + {/* Timeline */} + {conversations.length === 0 ? ( +
+ +

No conversations found

+
+ ) : ( + + {conversations.map((conv) => { + const date = formatDate(conv.created_at || '') + + return ( + } + contentStyle={{ + background: conv.deleted ? '#fee2e2' : '#fff', + color: '#1f2937', + boxShadow: '0 3px 0 #ddd' + }} + contentArrowStyle={{ borderRight: '7px solid #fff' }} + > + + + ) + })} + + )} +
+ ) +} diff --git a/backends/advanced/webui/src/pages/FrappeGanttTimeline.tsx b/backends/advanced/webui/src/pages/FrappeGanttTimeline.tsx new file mode 100644 index 00000000..6765cf98 --- /dev/null +++ b/backends/advanced/webui/src/pages/FrappeGanttTimeline.tsx @@ -0,0 +1,723 @@ +import { useState, useEffect, useRef } from 'react' +import { Calendar, RefreshCw, AlertCircle, ZoomIn, ZoomOut } from 'lucide-react' +import Gantt from 'frappe-gantt' +import { memoriesApi } from '../services/api' +import { useAuth } from '../contexts/AuthContext' + +interface TimeRange { + start: string + end: string + name?: string +} + +interface MemoryWithTimeRange { + id: string + content: string + created_at: string + metadata?: { + name?: string + timeRanges?: TimeRange[] + isPerson?: boolean + isEvent?: boolean + isPlace?: boolean + } +} + +interface GanttTask { + id: string + name: string + start: string + end: string + progress: number + custom_class?: string +} + +export default function FrappeGanttTimeline() { + const [memories, setMemories] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [useDemoData, setUseDemoData] = useState(false) + const [currentViewMode, setCurrentViewMode] = useState('Week') + const [zoomScale, setZoomScale] = useState(1) // CSS transform scale: 0.5 = 50%, 1 = 100%, 2 = 200% + const ganttContainerRef = useRef(null) + const ganttInstance = useRef(null) + const scrollContainerRef = useRef(null) + const isDragging = useRef(false) + const startX = useRef(0) + const scrollLeft = useRef(0) + const { user } = useAuth() + + // HTML escape function to prevent XSS attacks + const escapeHtml = (unsafe: string): string => { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + // Demo data for testing the Timeline visualization - spans multiple years + const getDemoMemories = (): MemoryWithTimeRange[] => { + return [ + { + id: 'demo-graduation', + content: 'College graduation ceremony and celebration dinner with family.', + created_at: '2024-05-20T14:00:00', + metadata: { + name: 'College Graduation', + isEvent: true, + timeRanges: [ + { + name: 'Graduation Ceremony', + start: '2024-05-20T14:00:00', + end: '2024-05-20T17:00:00' + }, + { + name: 'Celebration Dinner', + start: '2024-05-20T19:00:00', + end: '2024-05-20T22:00:00' + } + ] + } + }, + { + id: 'demo-wedding', + content: "Sarah and Tom's wedding was a beautiful celebration. The ceremony started at 3 PM, followed by a reception that lasted until midnight.", + created_at: '2025-06-15T15:00:00', + metadata: { + name: "Sarah & Tom's Wedding", + isEvent: true, + timeRanges: [ + { + name: 'Wedding Ceremony', + start: '2025-06-15T15:00:00', + end: '2025-06-15T16:30:00' + }, + { + name: 'Reception', + start: '2025-06-15T18:00:00', + end: '2025-06-16T00:00:00' + } + ] + } + }, + { + id: 'demo-conference', + content: 'Tech conference with keynote presentations and networking sessions throughout the day.', + created_at: '2025-09-20T09:00:00', + metadata: { + name: 'Tech Conference 2025', + isEvent: true, + timeRanges: [ + { + name: 'Morning Keynote', + start: '2025-09-20T09:00:00', + end: '2025-09-20T11:00:00' + }, + { + name: 'Workshops', + start: '2025-09-20T13:00:00', + end: '2025-09-20T17:00:00' + } + ] + } + }, + { + id: 'demo-vacation', + content: 'Week-long vacation at the beach house with family.', + created_at: '2026-07-01T14:00:00', + metadata: { + name: 'Summer Vacation 2026', + isPlace: true, + timeRanges: [ + { + name: 'Beach House Stay', + start: '2026-07-01T14:00:00', + end: '2026-07-07T12:00:00' + } + ] + } + }, + { + id: 'demo-reunion', + content: 'Family reunion at the old homestead with extended family gathering.', + created_at: '2026-12-25T12:00:00', + metadata: { + name: 'Family Reunion', + isEvent: true, + timeRanges: [ + { + name: 'Christmas Gathering', + start: '2026-12-25T12:00:00', + end: '2026-12-25T20:00:00' + } + ] + } + } + ] + } + + const loadMemories = async () => { + if (!user?.id) return + + try { + setLoading(true) + setError(null) + const response = await memoriesApi.getAll(user.id) + + // Extract memories from response + const memoriesData = response.data.memories || response.data || [] + + // Filter memories that have timeRanges + const memoriesWithTime = memoriesData.filter((m: MemoryWithTimeRange) => + m.metadata?.timeRanges && m.metadata.timeRanges.length > 0 + ) + + console.log('๐Ÿ“… Timeline: Total memories:', memoriesData.length) + console.log('๐Ÿ“… Timeline: Memories with timeRanges:', memoriesWithTime.length) + if (memoriesWithTime.length > 0) { + console.log('๐Ÿ“… Timeline: First memory with timeRange:', memoriesWithTime[0]) + } + + setMemories(memoriesWithTime) + } catch (err: any) { + console.error('โŒ Timeline loading error:', err) + setError(err.message || 'Failed to load timeline data') + } finally { + setLoading(false) + } + } + + const convertMemoriesToGanttTasks = (memories: MemoryWithTimeRange[]): GanttTask[] => { + const tasks: GanttTask[] = [] + + memories.forEach((memory) => { + const timeRanges = memory.metadata?.timeRanges || [] + + timeRanges.forEach((range, index) => { + // Get the task name from the range name, memory metadata name, or content preview + const taskName = range.name || + memory.metadata?.name || + memory.content.substring(0, 50) + (memory.content.length > 50 ? '...' : '') + + // Determine custom class based on memory type + let customClass = 'default' + if (memory.metadata?.isEvent) customClass = 'event' + else if (memory.metadata?.isPerson) customClass = 'person' + else if (memory.metadata?.isPlace) customClass = 'place' + + tasks.push({ + id: `${memory.id}-${index}`, + name: taskName, + start: range.start, + end: range.end, + progress: 100, // All memories are completed events + custom_class: customClass + }) + }) + }) + + return tasks + } + + useEffect(() => { + if (!useDemoData) { + loadMemories() + } else { + setMemories(getDemoMemories()) + } + }, [user?.id, useDemoData]) + + useEffect(() => { + const displayMemories = useDemoData ? getDemoMemories() : memories + + if (!ganttContainerRef.current || displayMemories.length === 0) { + return + } + + // Convert memories to Gantt tasks + const tasks = convertMemoriesToGanttTasks(displayMemories) + + if (tasks.length === 0) { + return + } + + console.log('๐Ÿ“Š Creating Gantt chart with tasks:', tasks) + + try { + // Clear existing Gantt instance + if (ganttInstance.current) { + ganttContainerRef.current.innerHTML = '' + } + + // Create new Gantt instance with type assertion for custom_popup_html + ganttInstance.current = new Gantt(ganttContainerRef.current, tasks, { + view_mode: currentViewMode, + bar_height: 30, + bar_corner_radius: 3, + arrow_curve: 5, + padding: 18, + date_format: 'YYYY-MM-DD', + language: 'en', + custom_popup_html: (task: any) => { + // Extract memoryId from task.id (format: "memoryId-index") + // Use lastIndexOf to handle memory IDs that contain dashes (e.g., UUIDs) + const lastDashIndex = task.id.lastIndexOf('-') + const memoryId = lastDashIndex !== -1 ? task.id.slice(0, lastDashIndex) : task.id + + // Find memory using exact equality instead of prefix matching + const memory = displayMemories.find(m => m.id === memoryId) + const startDate = new Date(task._start) + const endDate = new Date(task._end) + const formatOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + } + return ` + + ` + } + } as any) + + console.log('โœ… Gantt chart created successfully') + + // Add year labels to the timeline header + setTimeout(() => { + try { + const container = ganttContainerRef.current?.querySelector('.gantt-container') + if (!container) return + + // Find all unique years from tasks + const years = new Set() + tasks.forEach(task => { + const startYear = new Date(task.start).getFullYear() + const endYear = new Date(task.end).getFullYear() + years.add(startYear) + if (startYear !== endYear) years.add(endYear) + }) + + const sortedYears = Array.from(years).sort() + if (sortedYears.length <= 1) return // No need for year labels if single year + + // Get the upper header div element (HTML, not SVG) + const upperHeader = container.querySelector('.upper-header') + if (!upperHeader) return + + // Add year labels as HTML divs in a simple row at the top + sortedYears.forEach((year, index) => { + const yearLabel = document.createElement('div') + yearLabel.className = 'year-label' + yearLabel.textContent = String(year) + yearLabel.style.position = 'absolute' + yearLabel.style.left = `${20 + (index * 70)}px` // Simple horizontal spacing + yearLabel.style.top = '2px' + yearLabel.style.fontSize = '18px' + yearLabel.style.fontWeight = '700' + yearLabel.style.color = '#2563eb' // Blue color + yearLabel.style.padding = '2px 8px' + yearLabel.style.backgroundColor = '#eff6ff' + yearLabel.style.borderRadius = '4px' + yearLabel.style.zIndex = '10' + + upperHeader.appendChild(yearLabel) + }) + + } catch (error) { + console.warn('Failed to add year labels:', error) + } + }, 150) // Small delay to ensure DOM is fully rendered + } catch (err) { + console.error('โŒ Error creating Gantt chart:', err) + setError('Failed to create timeline visualization') + } + + return () => { + if (ganttInstance.current && ganttContainerRef.current) { + ganttContainerRef.current.innerHTML = '' + ganttInstance.current = null + } + } + }, [memories, useDemoData, currentViewMode]) + + // Drag-to-scroll functionality + useEffect(() => { + const container = scrollContainerRef.current + if (!container) return + + const handleMouseDown = (e: MouseEvent) => { + // Only start drag if not clicking on interactive elements + const target = e.target as HTMLElement + if (target.closest('.bar-wrapper') || target.closest('button')) { + return + } + + isDragging.current = true + startX.current = e.pageX + scrollLeft.current = container.scrollLeft + container.style.cursor = 'grabbing' + e.preventDefault() + } + + const handleMouseLeave = () => { + isDragging.current = false + container.style.cursor = 'grab' + } + + const handleMouseUp = () => { + isDragging.current = false + container.style.cursor = 'grab' + } + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging.current) return + e.preventDefault() + const x = e.pageX + const walk = (x - startX.current) * 1.5 // Scroll speed multiplier + container.scrollLeft = scrollLeft.current - walk + } + + // Add event listeners with capture phase for better control + container.addEventListener('mousedown', handleMouseDown, true) + container.addEventListener('mouseleave', handleMouseLeave) + container.addEventListener('mouseup', handleMouseUp) + container.addEventListener('mousemove', handleMouseMove) + + return () => { + container.removeEventListener('mousedown', handleMouseDown, true) + container.removeEventListener('mouseleave', handleMouseLeave) + container.removeEventListener('mouseup', handleMouseUp) + container.removeEventListener('mousemove', handleMouseMove) + } + }, []) + + // Mousewheel zoom functionality + useEffect(() => { + const container = scrollContainerRef.current + if (!container) return + + const viewModeOrder = ['Quarter Day', 'Half Day', 'Day', 'Week', 'Month'] + + const handleWheel = (e: WheelEvent) => { + // Only zoom when Ctrl or Cmd is pressed + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + e.stopPropagation() + + const currentIndex = viewModeOrder.indexOf(currentViewMode) + + if (e.deltaY < 0) { + // Zoom in (scroll up = more detailed view) + if (currentIndex > 0) { + setCurrentViewMode(viewModeOrder[currentIndex - 1]) + } + } else if (e.deltaY > 0) { + // Zoom out (scroll down = less detailed view) + if (currentIndex < viewModeOrder.length - 1) { + setCurrentViewMode(viewModeOrder[currentIndex + 1]) + } + } + } + // If no modifier keys, let the browser handle normal horizontal scrolling + } + + container.addEventListener('wheel', handleWheel, { passive: false }) + + return () => { + container.removeEventListener('wheel', handleWheel) + } + }, [currentViewMode]) + + const viewModes = [ + { value: 'Quarter Day', label: 'Quarter Day' }, + { value: 'Half Day', label: 'Half Day' }, + { value: 'Day', label: 'Day' }, + { value: 'Week', label: 'Week' }, + { value: 'Month', label: 'Month' } + ] + + const changeViewMode = (mode: string) => { + setCurrentViewMode(mode) + } + + const zoomIn = () => { + setZoomScale(prev => { + const newScale = Math.min(prev + 0.25, 3) // Max 300% + // Store scroll position ratio before zoom + if (scrollContainerRef.current) { + const container = scrollContainerRef.current + const scrollRatio = (container.scrollLeft + container.clientWidth / 2) / container.scrollWidth + + // After state update, restore relative scroll position + setTimeout(() => { + if (scrollContainerRef.current) { + const newScrollLeft = scrollRatio * scrollContainerRef.current.scrollWidth - container.clientWidth / 2 + scrollContainerRef.current.scrollLeft = newScrollLeft + } + }, 0) + } + return newScale + }) + } + + const zoomOut = () => { + setZoomScale(prev => { + const newScale = Math.max(prev - 0.25, 0.5) // Min 50% + // Store scroll position ratio before zoom + if (scrollContainerRef.current) { + const container = scrollContainerRef.current + const scrollRatio = (container.scrollLeft + container.clientWidth / 2) / container.scrollWidth + + // After state update, restore relative scroll position + setTimeout(() => { + if (scrollContainerRef.current) { + const newScrollLeft = scrollRatio * scrollContainerRef.current.scrollWidth - container.clientWidth / 2 + scrollContainerRef.current.scrollLeft = newScrollLeft + } + }, 0) + } + return newScale + }) + } + + if (loading) { + return ( +
+
+

Timeline

+
+
+
+ + Loading timeline data... +
+
+
+ ) + } + + if (error) { + return ( +
+
+

Timeline

+
+
+
+ + {error} +
+
+
+ ) + } + + if (memories.length === 0 && !useDemoData) { + return ( +
+
+

Timeline

+
+ + +
+
+
+ +
+

No Timeline Events

+

+ No memories with time information found. Create memories with dates and times to see them on the timeline. +

+

+ Click "Show Demo" to see how the timeline works with sample data. +

+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Timeline (Frappe Gantt) {useDemoData && (Demo Mode)}

+

+ {useDemoData ? getDemoMemories().length : memories.length} {(useDemoData ? getDemoMemories().length : memories.length) === 1 ? 'event' : 'events'} with time information +

+
+
+ {/* Demo mode toggle */} + {useDemoData ? ( + + ) : ( + + )} + {/* Zoom controls */} +
+ +
+ {Math.round(zoomScale * 100)}% +
+ +
+ {/* View mode selector */} +
+ + +
+ +
+
+ + {/* Gantt Chart Container */} +
+ {/* Scrollable Gantt Chart */} +
+
+
+ + {/* Instructions - Fixed, not scrolling */} +
+ ๐Ÿ’ก Drag to scroll horizontally + ๐Ÿ” Hold Ctrl/Cmd + Scroll to zoom in/out +
+ + {/* Legend - Fixed, not scrolling */} +
+
+
+ Event +
+
+
+ Person +
+
+
+ Place +
+
+
+ + {/* Add custom styles for Gantt chart colors */} + +
+ ) +} diff --git a/backends/advanced/webui/src/pages/Memories.tsx b/backends/advanced/webui/src/pages/Memories.tsx index 7ad3bf59..0c4973b6 100644 --- a/backends/advanced/webui/src/pages/Memories.tsx +++ b/backends/advanced/webui/src/pages/Memories.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' import { Brain, Search, RefreshCw, Trash2, Calendar, Tag, X, Target } from 'lucide-react' import { memoriesApi, systemApi } from '../services/api' import { useAuth } from '../contexts/AuthContext' @@ -18,24 +19,25 @@ interface Memory { } export default function Memories() { + const navigate = useNavigate() const [memories, setMemories] = useState([]) const [loading, setLoading] = useState(false) 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 [memoryProvider, setMemoryProvider] = useState('') - + const { user } = useAuth() const loadSystemConfig = async () => { @@ -59,24 +61,24 @@ export default function Memories() { try { setLoading(true) - const response = showUnfiltered + const response = showUnfiltered ? await memoriesApi.getUnfiltered(user.id) : await memoriesApi.getAll(user.id) - + console.log('๐Ÿง  Memories API response:', response.data) - + // 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) { console.log('๐Ÿง  First memory object:', memoriesData[0]) console.log('๐Ÿง  Memory fields:', Object.keys(memoriesData[0])) } - + setMemories(Array.isArray(memoriesData) ? memoriesData : []) // Store total count in state for display setTotalCount(totalCount) @@ -100,25 +102,25 @@ export default function Memories() { // 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 + const thresholdToUse = memoryProviderSupportsThreshold + ? relevanceThreshold : undefined - + const response = await memoriesApi.search( - searchQuery.trim(), - user.id, - 50, + 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) @@ -156,7 +158,7 @@ export default function Memories() { // 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 => { @@ -165,7 +167,7 @@ export default function Memories() { return relevancePercentage >= relevanceThreshold }) : currentMemories - + // Apply text search filter const filteredMemories = thresholdFilteredMemories.filter(memory => memory.memory.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -175,7 +177,7 @@ export default function Memories() { const formatDate = (dateInput: string | number) => { // Handle both timestamp numbers and date strings let date: Date - + if (typeof dateInput === 'number') { // Unix timestamp - multiply by 1000 if needed date = dateInput > 1e10 ? new Date(dateInput) : new Date(dateInput * 1000) @@ -192,20 +194,20 @@ export default function Memories() { } else { date = new Date(dateInput) } - + // Check if date is valid if (isNaN(date.getTime())) { console.warn('Invalid date:', dateInput) return 'Invalid Date' } - + return date.toLocaleString() } const getCategoryColor = (category: string) => { const colors = { 'personal': 'bg-blue-100 text-blue-800', - 'work': 'bg-green-100 text-green-800', + 'work': 'bg-green-100 text-green-800', 'health': 'bg-red-100 text-red-800', 'entertainment': 'bg-purple-100 text-purple-800', 'education': 'bg-yellow-100 text-yellow-800', @@ -218,7 +220,7 @@ export default function Memories() { const renderMemoryText = (content: string) => { // Handle multi-line content (bullet points from backend normalization) const lines = content.split('\n').filter(line => line.trim()) - + if (lines.length > 1) { return (
@@ -230,7 +232,7 @@ export default function Memories() {
) } - + // Single line content return (

@@ -298,7 +300,7 @@ export default function Memories() { onChange={(e) => 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()} + onKeyDown={(e) => e.key === 'Enter' && handleSemanticSearch()} />

- - {/* Memory Content */} -
- {renderMemoryContent(memory)} -
- - {/* Metadata */} - {memory.metadata && ( -
-
- - View metadata - -
-                        {JSON.stringify(memory.metadata, null, 2)}
-                      
-
-
- )} ))} @@ -550,7 +574,7 @@ export default function Memories() { `No semantic matches found for "${semanticQuery}"` ) ) : ( - searchQuery + searchQuery ? `No memories found matching "${searchQuery}"` : `No memories found` )} @@ -567,4 +591,4 @@ export default function Memories() { )} ) -} \ No newline at end of file +} diff --git a/backends/advanced/webui/src/pages/MemoriesRouter.tsx b/backends/advanced/webui/src/pages/MemoriesRouter.tsx new file mode 100644 index 00000000..b39663f9 --- /dev/null +++ b/backends/advanced/webui/src/pages/MemoriesRouter.tsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react' +import { useAuth } from '../contexts/AuthContext' +import Memories from './Memories' + +/** + * Memories page wrapper that stores JWT for cross-origin Mycelia access. + * Always displays Friend-Lite native Memories component (backend proxies to provider). + */ +export default function MemoriesRouter() { + const { token } = useAuth() + + useEffect(() => { + // Store JWT in localStorage for potential direct Mycelia access + if (token) { + localStorage.setItem('mycelia_jwt_token', token) + } + }, [token]) + + // Always show the native Memories page (works for all providers) + // Friend-Lite backend will proxy to Mycelia when needed + return +} diff --git a/backends/advanced/webui/src/pages/MemoryDetail.tsx b/backends/advanced/webui/src/pages/MemoryDetail.tsx new file mode 100644 index 00000000..ed65cf8a --- /dev/null +++ b/backends/advanced/webui/src/pages/MemoryDetail.tsx @@ -0,0 +1,331 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { ArrowLeft, Calendar, Tag, Trash2, RefreshCw } from 'lucide-react' +import { memoriesApi } from '../services/api' +import { useAuth } from '../contexts/AuthContext' + +interface Memory { + id: string + memory: string + category?: string + created_at: string + updated_at: string + user_id: string + score?: number + metadata?: { + name?: string + timeRanges?: Array<{ + start: string + end: string + name?: string + }> + isPerson?: boolean + isEvent?: boolean + isPlace?: boolean + extractedWith?: { + model: string + timestamp: string + } + [key: string]: any + } + hash?: string + role?: string +} + +export default function MemoryDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { user } = useAuth() + const [memory, setMemory] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadMemory = async () => { + if (!user?.id || !id) { + console.log('โญ๏ธ MemoryDetail: Missing user or id', { userId: user?.id, memoryId: id }) + return + } + + try { + console.log('๐Ÿ” MemoryDetail: Loading memory', id) + setLoading(true) + setError(null) + const response = await memoriesApi.getById(id, user.id) + const memoryData = response.data.memory + console.log('๐Ÿ“ฆ MemoryDetail: Loaded memory', memoryData?.id) + + if (memoryData) { + setMemory(memoryData) + } else { + setError('Memory not found') + } + } catch (err: any) { + console.error('โŒ Failed to load memory:', err) + if (err.response?.status === 404) { + setError('Memory not found') + } else { + setError(err.message || 'Failed to load memory') + } + } finally { + setLoading(false) + } + } + + const handleDelete = async () => { + if (!memory || !id) return + + const confirmed = window.confirm('Are you sure you want to delete this memory?') + if (!confirmed) return + + try { + await memoriesApi.delete(id) + navigate('/memories') + } catch (err: any) { + console.error('โŒ Failed to delete memory:', err) + alert('Failed to delete memory: ' + (err.message || 'Unknown error')) + } + } + + useEffect(() => { + loadMemory() + }, [id, user?.id]) + + const formatDate = (dateString: string) => { + try { + return new Date(dateString).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } catch { + return dateString + } + } + + const getMemoryTypeIcon = () => { + if (memory?.metadata?.isEvent) return '๐Ÿ“…' + if (memory?.metadata?.isPerson) return '๐Ÿ‘ค' + if (memory?.metadata?.isPlace) return '๐Ÿ“' + return '๐Ÿง ' + } + + const getMemoryTypeLabel = () => { + if (memory?.metadata?.isEvent) return 'Event' + if (memory?.metadata?.isPerson) return 'Person' + if (memory?.metadata?.isPlace) return 'Place' + return 'Memory' + } + + if (loading) { + return ( +
+
+ +
+
+ + Loading memory... +
+
+ ) + } + + if (error || !memory) { + return ( +
+
+ +
+
+

+ {error || 'Memory not found'} +

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+ + +
+ + {/* Main Content */} +
+ {/* Left Column - Memory Content */} +
+ {/* Memory Card */} +
+
+
{getMemoryTypeIcon()}
+
+
+ + {getMemoryTypeLabel()} + + {memory.category && ( + + + {memory.category} + + )} +
+ {memory.metadata?.name && ( +

+ {memory.metadata.name} +

+ )} +

+ {memory.memory} +

+
+
+
+ + {/* Time Ranges */} + {memory.metadata?.timeRanges && memory.metadata.timeRanges.length > 0 && ( +
+

+ + Time Ranges +

+
+ {memory.metadata.timeRanges.map((range, index) => ( +
+ +
+ {range.name && ( +
+ {range.name} +
+ )} +
+
Start: {formatDate(range.start)}
+
End: {formatDate(range.end)}
+
+
+
+ ))} +
+
+ )} +
+ + {/* Right Column - Metadata */} +
+ {/* Metadata Card */} +
+

+ Metadata +

+
+
+
Created:
+
+ {formatDate(memory.created_at)} +
+
+
+
Updated:
+
+ {formatDate(memory.updated_at)} +
+
+ {memory.score !== undefined && memory.score !== null && ( +
+
Score:
+
+ {memory.score.toFixed(3)} +
+
+ )} + {memory.hash && ( +
+
Hash:
+
+ {memory.hash.substring(0, 12)}... +
+
+ )} +
+
+ + {/* Extraction Metadata */} + {memory.metadata?.extractedWith && ( +
+

+ Extraction +

+
+
+
Model:
+
+ {memory.metadata.extractedWith.model} +
+
+
+
Time:
+
+ {formatDate(memory.metadata.extractedWith.timestamp)} +
+
+
+
+ )} + + {/* Additional Metadata */} + {memory.metadata && Object.keys(memory.metadata).filter(key => + !['name', 'timeRanges', 'isPerson', 'isEvent', 'isPlace', 'extractedWith'].includes(key) + ).length > 0 && ( +
+

+ Additional Data +

+
+ {Object.entries(memory.metadata) + .filter(([key]) => !['name', 'timeRanges', 'isPerson', 'isEvent', 'isPlace', 'extractedWith'].includes(key)) + .map(([key, value]) => ( +
+
{key}:
+
+ {typeof value === 'object' ? JSON.stringify(value) : String(value)} +
+
+ ))} +
+
+ )} +
+
+
+ ) +} diff --git a/backends/advanced/webui/src/pages/MyceliaTimeline.tsx b/backends/advanced/webui/src/pages/MyceliaTimeline.tsx new file mode 100644 index 00000000..482c7f6a --- /dev/null +++ b/backends/advanced/webui/src/pages/MyceliaTimeline.tsx @@ -0,0 +1,467 @@ +import { useState, useEffect, useRef } from 'react' +import { Calendar, RefreshCw, AlertCircle } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import * as d3 from 'd3' +import { memoriesApi } from '../services/api' +import { useAuth } from '../contexts/AuthContext' + +interface TimeRange { + start: string + end: string + name?: string +} + +interface MemoryWithTimeRange { + id: string + content: string + created_at: string + metadata?: { + name?: string + timeRanges?: TimeRange[] + isPerson?: boolean + isEvent?: boolean + isPlace?: boolean + } +} + +interface TimelineTask { + id: string + name: string + start: Date + end: Date + color: string + type: 'event' | 'person' | 'place' +} + +export default function MyceliaTimeline() { + const [memories, setMemories] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [useDemoData, setUseDemoData] = useState(false) + const svgRef = useRef(null) + const containerRef = useRef(null) + const tooltipRef = useRef(null) + const [dimensions, setDimensions] = useState({ width: 1000, height: 400 }) + const { user } = useAuth() + const navigate = useNavigate() + + // Demo data + const getDemoMemories = (): MemoryWithTimeRange[] => { + return [ + { + id: 'demo-wedding', + content: "Sarah and Tom's wedding ceremony and reception", + created_at: '2025-12-07T15:00:00', + metadata: { + name: "Wedding", + isEvent: true, + timeRanges: [ + { + name: 'Ceremony', + start: '2025-12-07T15:00:00', + end: '2025-12-07T16:30:00' + }, + { + name: 'Reception', + start: '2025-12-07T18:00:00', + end: '2025-12-07T23:00:00' + } + ] + } + }, + { + id: 'demo-conference', + content: 'Tech conference with keynote and workshops', + created_at: '2026-01-15T09:00:00', + metadata: { + name: 'Tech Conference', + isEvent: true, + timeRanges: [ + { + name: 'Keynote', + start: '2026-01-15T09:00:00', + end: '2026-01-15T11:00:00' + } + ] + } + } + ] + } + + const loadMemories = async () => { + if (!user?.id) return + + try { + setLoading(true) + setError(null) + const response = await memoriesApi.getAll(user.id) + const memoriesData = response.data.memories || response.data || [] + const memoriesWithTime = memoriesData.filter((m: MemoryWithTimeRange) => + m.metadata?.timeRanges && m.metadata.timeRanges.length > 0 + ) + setMemories(memoriesWithTime) + } catch (err: any) { + setError(err.message || 'Failed to load timeline data') + } finally { + setLoading(false) + } + } + + const convertToTasks = (memories: MemoryWithTimeRange[]): TimelineTask[] => { + const tasks: TimelineTask[] = [] + memories.forEach((memory) => { + const timeRanges = memory.metadata?.timeRanges || [] + timeRanges.forEach((range, index) => { + let type: 'event' | 'person' | 'place' = 'event' + let color = '#3b82f6' + + if (memory.metadata?.isEvent) { + type = 'event' + color = '#3b82f6' + } else if (memory.metadata?.isPerson) { + type = 'person' + color = '#10b981' + } else if (memory.metadata?.isPlace) { + type = 'place' + color = '#f59e0b' + } + + tasks.push({ + id: `${memory.id}-${index}`, + name: range.name || memory.metadata?.name || memory.content.substring(0, 30), + start: new Date(range.start), + end: new Date(range.end), + color, + type + }) + }) + }) + return tasks + } + + useEffect(() => { + if (!useDemoData) { + loadMemories() + } else { + setMemories(getDemoMemories()) + } + }, [user?.id, useDemoData]) + + // Handle container resize + useEffect(() => { + if (!containerRef.current) return + const resizeObserver = new ResizeObserver(([entry]) => { + setDimensions({ + width: entry.contentRect.width, + height: 400 + }) + }) + resizeObserver.observe(containerRef.current) + return () => resizeObserver.disconnect() + }, []) + + // D3 visualization + useEffect(() => { + if (!svgRef.current || memories.length === 0) return + + const tasks = convertToTasks(useDemoData ? getDemoMemories() : memories) + if (tasks.length === 0) return + + const svg = d3.select(svgRef.current) + svg.selectAll('*').remove() + + const margin = { top: 60, right: 40, bottom: 60, left: 150 } + const width = dimensions.width - margin.left - margin.right + const height = dimensions.height - margin.top - margin.bottom + + // Find time range + const allDates = tasks.flatMap(t => [t.start, t.end]) + const minDate = d3.min(allDates)! + const maxDate = d3.max(allDates)! + + // Create scales + const xScale = d3.scaleTime() + .domain([minDate, maxDate]) + .range([0, width]) + + const yScale = d3.scaleBand() + .domain(tasks.map(t => t.id)) + .range([0, height]) + .padding(0.3) + + // Create main group + const g = svg.append('g') + .attr('transform', `translate(${margin.left},${margin.top})`) + .attr('class', 'zoomable') + + // Add axes + const xAxis = d3.axisBottom(xScale) + .ticks(6) + .tickFormat(d3.timeFormat('%b %d, %Y') as any) + + g.append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0,${height})`) + .call(xAxis) + .selectAll('text') + .style('fill', 'currentColor') + + // Add task bars + const bars = g.append('g') + .attr('class', 'bars') + .selectAll('rect') + .data(tasks) + .enter() + + // Bar background with click and hover + bars.append('rect') + .attr('x', (d: TimelineTask) => xScale(d.start)) + .attr('y', (d: TimelineTask) => yScale(d.id)!) + .attr('width', (d: TimelineTask) => Math.max(2, xScale(d.end) - xScale(d.start))) + .attr('height', yScale.bandwidth()) + .attr('fill', (d: TimelineTask) => d.color) + .attr('rx', 4) + .style('opacity', 0.8) + .style('cursor', 'pointer') + .on('mouseover', function(this: SVGRectElement, event: MouseEvent, d: TimelineTask) { + d3.select(this).style('opacity', 1) + + // Show tooltip + if (tooltipRef.current) { + const tooltip = d3.select(tooltipRef.current) + const startDate = d.start.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + const endDate = d.end.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + + // Build tooltip using DOM APIs to prevent XSS + tooltip + .style('opacity', 1) + .style('left', `${event.pageX + 10}px`) + .style('top', `${event.pageY - 10}px`) + .html('') // Clear existing content + + // Add title (user-controlled content via textContent) + tooltip + .append('div') + .attr('class', 'font-semibold text-sm mb-1') + .text(d.name) // Safe: uses textContent, not innerHTML + + // Add details container + const detailsDiv = tooltip + .append('div') + .attr('class', 'text-xs text-gray-600 dark:text-gray-300') + + // Add start date + const startDiv = detailsDiv.append('div') + startDiv.append('strong').text('Start: ') + startDiv.append('span').text(startDate) // Safe: uses textContent + + // Add end date + const endDiv = detailsDiv.append('div') + endDiv.append('strong').text('End: ') + endDiv.append('span').text(endDate) // Safe: uses textContent + + // Add static click instruction + detailsDiv + .append('div') + .attr('class', 'mt-1 text-blue-600 dark:text-blue-400') + .text('Click to view memory') + } + }) + .on('mouseout', function(this: SVGRectElement) { + d3.select(this).style('opacity', 0.8) + + // Hide tooltip + if (tooltipRef.current) { + d3.select(tooltipRef.current).style('opacity', 0) + } + }) + .on('click', function(this: SVGRectElement, event: MouseEvent, d: TimelineTask) { + event.stopPropagation() + // Extract memory ID from task ID (format: "memory-id-rangeIndex") + // Use lastIndexOf to handle memory IDs that contain dashes (e.g., UUIDs) + const lastDashIndex = d.id.lastIndexOf('-') + const memoryId = lastDashIndex !== -1 ? d.id.slice(0, lastDashIndex) : d.id + + if (memoryId) { + navigate(`/memories/${memoryId}`) + } + }) + + // Add labels + g.append('g') + .attr('class', 'labels') + .selectAll('text') + .data(tasks) + .enter() + .append('text') + .attr('x', -10) + .attr('y', (d: TimelineTask) => yScale(d.id)! + yScale.bandwidth() / 2) + .attr('dy', '0.35em') + .attr('text-anchor', 'end') + .text((d: TimelineTask) => d.name) + .style('fill', 'currentColor') + .style('font-size', '12px') + + // Zoom behavior + const zoom = d3.zoom() + .scaleExtent([0.5, 5]) + .on('zoom', (event: d3.D3ZoomEvent) => { + const transform = event.transform + + // Update x scale + const newXScale = transform.rescaleX(xScale) + + // Update axis + g.select('.x-axis').call( + d3.axisBottom(newXScale) + .ticks(6) + .tickFormat(d3.timeFormat('%b %d, %Y') as any) as any + ) + + // Update bars + g.selectAll('.bars rect') + .attr('x', (d: TimelineTask) => newXScale(d.start)) + .attr('width', (d: TimelineTask) => Math.max(2, newXScale(d.end) - newXScale(d.start))) + }) + + svg.call(zoom as any) + + }, [memories, dimensions, useDemoData]) + + if (loading) { + return ( +
+
+
+ + Loading timeline data... +
+
+
+ ) + } + + if (error) { + return ( +
+
+
+ + {error} +
+
+
+ ) + } + + return ( +
+ {/* Tooltip */} +
+ + {/* Header */} +
+
+

+ + Timeline (Mycelia D3) +

+

+ Interactive D3-based timeline with smooth pan and zoom โ€ข Click events to view details +

+
+
+ {useDemoData ? ( + + ) : ( + + )} + +
+
+ + {/* Timeline */} + {memories.length === 0 && !useDemoData ? ( +
+ +
+

No Timeline Events

+

+ No memories with time information found. Try the demo to see how it works. +

+
+
+ ) : ( +
+
+ +
+ +
+ ๐Ÿ’ก Scroll to zoom, drag to pan + ๐Ÿ–ฑ๏ธ Click bars to view memory details + ๐Ÿ‘† Hover for info +
+ +
+
+
+ Event +
+
+
+ Person +
+
+
+ Place +
+
+
+ )} +
+ ) +} diff --git a/backends/advanced/webui/src/pages/System.tsx b/backends/advanced/webui/src/pages/System.tsx index 3ca54a59..c722ada9 100644 --- a/backends/advanced/webui/src/pages/System.tsx +++ b/backends/advanced/webui/src/pages/System.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Settings, RefreshCw, CheckCircle, XCircle, AlertCircle, Activity, Users, Database, Server, Volume2, Mic } from 'lucide-react' +import { Settings, RefreshCw, CheckCircle, XCircle, AlertCircle, Activity, Users, Database, Server, Volume2, Mic, Brain } from 'lucide-react' import { systemApi, speakerApi } from '../services/api' import { useAuth } from '../contexts/AuthContext' import MemorySettings from '../components/MemorySettings' @@ -64,6 +64,11 @@ export default function System() { max_speakers: 6 }) const [diarizationLoading, setDiarizationLoading] = useState(false) + const [currentProvider, setCurrentProvider] = useState('') + const [availableProviders, setAvailableProviders] = useState([]) + const [selectedProvider, setSelectedProvider] = useState('') + const [providerLoading, setProviderLoading] = useState(false) + const [providerMessage, setProviderMessage] = useState('') const { isAdmin } = useAuth() @@ -120,6 +125,46 @@ export default function System() { } } + const loadMemoryProvider = async () => { + try { + setProviderLoading(true) + const response = await systemApi.getMemoryProvider() + if (response.data.status === 'success') { + setCurrentProvider(response.data.current_provider) + setAvailableProviders(response.data.available_providers) + setSelectedProvider(response.data.current_provider) + } + } catch (err: any) { + console.error('Failed to load memory provider:', err) + } finally { + setProviderLoading(false) + } + } + + const saveMemoryProvider = async () => { + if (selectedProvider === currentProvider) { + setProviderMessage('Provider is already set to ' + selectedProvider) + setTimeout(() => setProviderMessage(''), 3000) + return + } + + try { + setProviderLoading(true) + setProviderMessage('') + const response = await systemApi.setMemoryProvider(selectedProvider) + if (response.data.status === 'success') { + setCurrentProvider(selectedProvider) + setProviderMessage('โœ… ' + response.data.message) + } else { + setProviderMessage('โŒ Failed to update provider') + } + } catch (err: any) { + setProviderMessage('โŒ Error: ' + (err.response?.data?.error || err.message)) + } finally { + setProviderLoading(false) + } + } + const saveDiarizationSettings = async () => { try { setDiarizationLoading(true) @@ -139,6 +184,7 @@ export default function System() { useEffect(() => { loadSystemData() loadDiarizationSettings() + loadMemoryProvider() }, [isAdmin]) const getStatusIcon = (healthy: boolean) => { @@ -285,6 +331,61 @@ export default function System() {
))} + + {/* Memory Provider Selector */} +
+
+ + + Memory Provider + +
+
+ {/* Current Provider Display */} +
+ Current: + + {currentProvider || 'Loading...'} + +
+ + {/* Provider Selector */} +
+ + +
+ + {/* Status Message */} + {providerMessage && ( +
+ {providerMessage} +
+ )} +
+
)} diff --git a/backends/advanced/webui/src/pages/TimelineRouter.tsx b/backends/advanced/webui/src/pages/TimelineRouter.tsx new file mode 100644 index 00000000..fbe2f9cb --- /dev/null +++ b/backends/advanced/webui/src/pages/TimelineRouter.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import { Calendar } from 'lucide-react' +import FrappeGanttTimeline from './FrappeGanttTimeline' +import MyceliaTimeline from './MyceliaTimeline' + +type TimelineImplementation = 'frappe' | 'mycelia' + +export default function TimelineRouter() { + const [activeImplementation, setActiveImplementation] = useState('frappe') + + return ( +
+ {/* Header */} +
+
+

+ + Timeline +

+

+ Visualize your memories on an interactive timeline +

+
+
+ + {/* Tab Navigation */} +
+ +
+ + {/* Timeline Implementation */} +
+ {activeImplementation === 'frappe' && } + {activeImplementation === 'mycelia' && } +
+
+ ) +} diff --git a/backends/advanced/webui/src/services/api.ts b/backends/advanced/webui/src/services/api.ts index d40508e8..e43902ad 100644 --- a/backends/advanced/webui/src/services/api.ts +++ b/backends/advanced/webui/src/services/api.ts @@ -105,15 +105,16 @@ export const conversationsApi = { export const memoriesApi = { getAll: (userId?: string) => api.get('/api/memories', { params: userId ? { user_id: userId } : {} }), + getById: (id: string, userId?: string) => api.get(`/api/memories/${id}`, { 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 }), + 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}`), deleteAll: () => api.delete('/api/admin/memory/delete-all'), @@ -138,15 +139,19 @@ export const systemApi = { // Memory Configuration Management getMemoryConfigRaw: () => api.get('/api/admin/memory/config/raw'), - updateMemoryConfigRaw: (configYaml: string) => + updateMemoryConfigRaw: (configYaml: string) => api.post('/api/admin/memory/config/raw', configYaml, { headers: { 'Content-Type': 'text/plain' } }), - validateMemoryConfig: (configYaml: string) => + validateMemoryConfig: (configYaml: string) => api.post('/api/admin/memory/config/validate', configYaml, { headers: { 'Content-Type': 'text/plain' } }), reloadMemoryConfig: () => api.post('/api/admin/memory/config/reload'), + + // Memory Provider Management + getMemoryProvider: () => api.get('/api/admin/memory/provider'), + setMemoryProvider: (provider: string) => api.post('/api/admin/memory/provider', { provider }), } export const queueApi = { diff --git a/backends/advanced/webui/src/types/react-gantt-timeline.d.ts b/backends/advanced/webui/src/types/react-gantt-timeline.d.ts new file mode 100644 index 00000000..513337aa --- /dev/null +++ b/backends/advanced/webui/src/types/react-gantt-timeline.d.ts @@ -0,0 +1,45 @@ +declare module 'react-gantt-timeline' { + import { ComponentType } from 'react' + + export interface TimelineTask { + id: string + name: string + start: Date + end: Date + color?: string + } + + export interface TimelineConfig { + header?: { + top?: { + style?: React.CSSProperties + } + middle?: { + style?: React.CSSProperties + } + bottom?: { + style?: React.CSSProperties + } + } + taskList?: { + title?: string + label?: { + width?: string + } + columns?: Array<{ + id: number + title: string + fieldName: string + width: number + }> + } + } + + export interface TimelineProps { + data: TimelineTask[] + config?: TimelineConfig + } + + const Timeline: ComponentType + export default Timeline +} diff --git a/extras/mycelia b/extras/mycelia new file mode 160000 index 00000000..47ea1966 --- /dev/null +++ b/extras/mycelia @@ -0,0 +1 @@ +Subproject commit 47ea1966dd8a8c10662c91c7a3f907798f6a7dbc diff --git a/quickstart.md b/quickstart.md index bcadc72a..8d7897f2 100644 --- a/quickstart.md +++ b/quickstart.md @@ -107,10 +107,44 @@ The setup wizard will automatically download and configure: **Download the code:** ```bash -git clone https://github.com/AnkushMalaker/friend-lite.git +git clone --recursive https://github.com/AnkushMalaker/friend-lite.git cd friend-lite ``` +*The `--recursive` flag downloads the Mycelia submodule (see note below)* + +**If you already cloned without `--recursive`:** +```bash +git submodule update --init --recursive +``` + +
+๐Ÿ“ฆ About the Mycelia Submodule (Optional - Click to expand) + +**What is Mycelia?** +Mycelia is an optional self-hosted AI memory system that Friend-Lite can use as an alternative memory backend. It provides: +- Timeline-based memory visualization +- Advanced audio processing with speaker diarization +- MongoDB-based full-text search +- MCP (Model Context Protocol) integration + +**Do I need it?** +**Most users don't need Mycelia!** The default Friend-Lite memory system works great for most use cases. Only consider Mycelia if you: +- Want timeline-based memory visualization +- Need advanced speaker diarization features +- Want to use MongoDB for memory storage instead of Qdrant + +**Runtime Dependencies (if using Mycelia):** +- **Deno** - JavaScript/TypeScript runtime (install: `curl -fsSL https://deno.land/install.sh | sh`) +- **MongoDB** - Database for memory storage +- **Redis** - Caching layer +- **FFmpeg** - Audio processing (usually pre-installed) + +**Build/Setup:** +If you choose Mycelia as your memory provider during setup wizard, the wizard will configure the necessary services automatically. No manual build steps required! + +
+ **Run the setup wizard:** ```bash uv run --with-requirements setup-requirements.txt python wizard.py diff --git a/tests/endpoints/client_queue_tests.robot b/tests/endpoints/client_queue_tests.robot index ace3588a..dd8016da 100644 --- a/tests/endpoints/client_queue_tests.robot +++ b/tests/endpoints/client_queue_tests.robot @@ -2,9 +2,10 @@ Documentation Client and Queue Management API Tests Library RequestsLibrary Library Collections -Resource ../resources/setup_resources.robot -Resource ../resources/session_resources.robot -Resource ../resources/user_resources.robot +Resource ../setup/setup_keywords.robot +Resource ../setup/teardown_keywords.robot +Resource ../resources/user_keywords.robot +Resource ../resources/session_keywords.robot Suite Setup Suite Setup Suite Teardown Delete All Sessions @@ -86,16 +87,11 @@ Get Queue Statistics Test Should Be Equal As Integers ${response.status_code} 200 ${stats}= Set Variable ${response.json()} - Dictionary Should Contain Key ${stats} queued - Dictionary Should Contain Key ${stats} processing - Dictionary Should Contain Key ${stats} completed - Dictionary Should Contain Key ${stats} failed + Dictionary Should Contain Key ${stats} queued_jobs + Dictionary Should Contain Key ${stats} processing_jobs + Dictionary Should Contain Key ${stats} completed_jobs + Dictionary Should Contain Key ${stats} failed_jobs - # All counts should be non-negative - Should Be True ${stats}[queued] >= 0 - Should Be True ${stats}[processing] >= 0 - Should Be True ${stats}[completed] >= 0 - Should Be True ${stats}[failed] >= 0 Get Queue Health Test [Documentation] Test getting queue health status @@ -103,16 +99,16 @@ Get Queue Health Test Get Anonymous Session anon_session Create API Session admin_session - ${response}= GET On Session admin_session /api/queue/health + ${response}= GET On Session admin_session /api/queue/worker-details Should Be Equal As Integers ${response.status_code} 200 ${health}= Set Variable ${response.json()} - Dictionary Should Contain Key ${health} status - Dictionary Should Contain Key ${health} worker_running - Dictionary Should Contain Key ${health} message + Dictionary Should Contain Key ${health} redis_connection + Dictionary Should Contain Key ${health} workers + Dictionary Should Contain Key ${health} queues # Status should be one of expected values - Should Be True '${health}[status]' in ['healthy', 'stopped', 'unhealthy'] + Should Be True '${health}[redis_connection]' in ['healthy', 'stopped', 'unhealthy'] Queue Jobs User Isolation Test [Documentation] Test that regular users only see their own queue jobs @@ -120,7 +116,7 @@ Queue Jobs User Isolation Test Get Anonymous Session anon_session Create API Session admin_session - + ${RANDOM_ID}= Get Random ID # Create a test user ${test_user}= Create Test User admin_session test-user-${RANDOM_ID}@example.com test-password-123 Create API Session user_session email=test-user-${RANDOM_ID}@example.com password=test-password-123 @@ -139,8 +135,8 @@ Queue Jobs User Isolation Test END END - # Cleanup - Delete Test User ${test_user}[user_id] + # # Cleanup + # Delete Test User ${test_user}[user_id] Invalid Queue Parameters Test [Documentation] Test queue endpoints with invalid parameters @@ -167,7 +163,8 @@ Invalid Queue Parameters Test Unauthorized Client Access Test [Documentation] Test that client endpoints require authentication [Tags] client security negative - Get Anonymous Session session + ${session}= Get Anonymous Session session + # Try to access active clients without token ${response}= GET On Session ${session} /api/clients/active expected_status=401 @@ -176,8 +173,8 @@ Unauthorized Client Access Test Unauthorized Queue Access Test [Documentation] Test that queue endpoints require authentication [Tags] queue security negative - Get Anonymous Session session - + ${session}= Get Anonymous Session session + # Try to access queue jobs without token ${response}= GET On Session ${session} /api/queue/jobs expected_status=401 Should Be Equal As Integers ${response.status_code} 401 @@ -186,22 +183,10 @@ Unauthorized Queue Access Test ${response}= GET On Session ${session} /api/queue/stats expected_status=401 Should Be Equal As Integers ${response.status_code} 401 -Queue Health Public Access Test - [Documentation] Test that queue health endpoint is publicly accessible - [Tags] queue health public - Get Anonymous Session session - - # Queue health should be accessible without authentication - ${response}= GET On Session ${session} /api/queue/health - Should Be Equal As Integers ${response.status_code} 200 - - ${health}= Set Variable ${response.json()} - Dictionary Should Contain Key ${health} status Client Manager Integration Test [Documentation] Test client manager functionality [Tags] client manager integration - Get Anonymous Session anon_session Create API Session admin_session diff --git a/tests/infrastructure/infra_tests.robot b/tests/infrastructure/infra_tests.robot index d10c9476..93bf38ff 100644 --- a/tests/infrastructure/infra_tests.robot +++ b/tests/infrastructure/infra_tests.robot @@ -259,7 +259,7 @@ WebSocket Disconnect Conversation End Reason Test ${stream_id}= Open Audio Stream device_name=${device_name} # Send audio fast (no realtime pacing) to simulate disconnect before END signal - Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=100 + Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=200 # Wait for conversation job to be created and conversation_id to be populated ${conv_jobs}= Wait Until Keyword Succeeds 30s 2s diff --git a/tests/integration/websocket_streaming_tests.robot b/tests/integration/websocket_streaming_tests.robot index f2375261..01e0a533 100644 --- a/tests/integration/websocket_streaming_tests.robot +++ b/tests/integration/websocket_streaming_tests.robot @@ -211,7 +211,7 @@ Segment Timestamps Match Cropped Audio # Uses default EXPECTED_SEGMENT_TIMES from test_data.py # To use a different dataset: Verify Segments Match Expected Timestamps ${segments} ${EXPECTED_SEGMENT_TIMES_SHORT} # To use custom tolerance: Verify Segments Match Expected Timestamps ${segments} ${EXPECTED_SEGMENT_TIMES} ${tolerance}=1.0 - Verify Segments Match Expected Timestamps ${segments} + Verify Segments Match Expected Timestamps ${segments} expected_segments=${EXPECTED_SEGMENT_TIMES} Log To Console โœ“ Validated ${segment_count} segments with proper cropped timestamps matching expected data