diff --git a/cognee/__init__.py b/cognee/__init__.py index be5a16b3b..27dea1ad5 100644 --- a/cognee/__init__.py +++ b/cognee/__init__.py @@ -27,6 +27,7 @@ from .api.v1.visualize import visualize_graph, start_visualization_server from cognee.modules.visualization.cognee_network_visualization import ( cognee_network_visualization, ) +from .api.v1.ui import start_ui # Pipelines from .modules import pipelines diff --git a/cognee/api/v1/ui/__init__.py b/cognee/api/v1/ui/__init__.py new file mode 100644 index 000000000..f268a2e54 --- /dev/null +++ b/cognee/api/v1/ui/__init__.py @@ -0,0 +1 @@ +from .ui import start_ui, stop_ui, ui diff --git a/cognee/api/v1/ui/ui.py b/cognee/api/v1/ui/ui.py new file mode 100644 index 000000000..aae0ad5a9 --- /dev/null +++ b/cognee/api/v1/ui/ui.py @@ -0,0 +1,529 @@ +import os +import signal +import subprocess +import threading +import time +import webbrowser +import zipfile +import requests +from pathlib import Path +from typing import Optional, Tuple +import tempfile +import shutil + +from cognee.shared.logging_utils import get_logger +from cognee.version import get_cognee_version + +logger = get_logger() + + +def normalize_version_for_comparison(version: str) -> str: + """ + Normalize version string for comparison. + Handles development versions and edge cases. + """ + # Remove common development suffixes for comparison + normalized = ( + version.replace("-local", "").replace("-dev", "").replace("-alpha", "").replace("-beta", "") + ) + return normalized.strip() + + +def get_frontend_cache_dir() -> Path: + """ + Get the directory where downloaded frontend assets are cached. + Uses user's home directory to persist across package updates. + Each cached frontend is version-specific and will be re-downloaded + when the cognee package version changes. + """ + cache_dir = Path.home() / ".cognee" / "ui-cache" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +def get_frontend_download_info() -> Tuple[str, str]: + """ + Get the download URL and version for the actual cognee-frontend source. + Downloads the real frontend from GitHub releases, matching the installed version. + """ + version = get_cognee_version() + + # Clean up version string (remove -local suffix for development) + clean_version = version.replace("-local", "") + + # Download from specific release tag to ensure version compatibility + download_url = f"https://github.com/topoteretes/cognee/archive/refs/tags/v{clean_version}.zip" + + return download_url, version + + +def download_frontend_assets(force: bool = False) -> bool: + """ + Download and cache frontend assets. + + Args: + force: If True, re-download even if already cached + + Returns: + bool: True if successful, False otherwise + """ + cache_dir = get_frontend_cache_dir() + frontend_dir = cache_dir / "frontend" + version_file = cache_dir / "version.txt" + + # Check if already downloaded and up to date + if not force and frontend_dir.exists() and version_file.exists(): + try: + cached_version = version_file.read_text().strip() + current_version = get_cognee_version() + + # Compare normalized versions to handle development versions + cached_normalized = normalize_version_for_comparison(cached_version) + current_normalized = normalize_version_for_comparison(current_version) + + if cached_normalized == current_normalized: + logger.debug(f"Frontend assets already cached for version {current_version}") + return True + else: + logger.info( + f"Version mismatch detected: cached={cached_version}, current={current_version}" + ) + logger.info("Updating frontend cache to match current cognee version...") + # Clear the old cached version + if frontend_dir.exists(): + shutil.rmtree(frontend_dir) + if version_file.exists(): + version_file.unlink() + except Exception as e: + logger.debug(f"Error checking cached version: {e}") + # Clear potentially corrupted cache + if frontend_dir.exists(): + shutil.rmtree(frontend_dir) + if version_file.exists(): + version_file.unlink() + + download_url, version = get_frontend_download_info() + + logger.info(f"Downloading cognee frontend assets for version {version}...") + logger.info("This will be cached and reused until the cognee version changes.") + + try: + # Create a temporary directory for download + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + archive_path = temp_path / "cognee-main.zip" + + # Download the actual cognee repository from releases + logger.info( + f"Downloading cognee v{version.replace('-local', '')} from GitHub releases..." + ) + logger.info(f"URL: {download_url}") + response = requests.get(download_url, stream=True, timeout=60) + response.raise_for_status() + + with open(archive_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # Extract the archive and find the cognee-frontend directory + if frontend_dir.exists(): + shutil.rmtree(frontend_dir) + + with zipfile.ZipFile(archive_path, "r") as zip_file: + # Extract to temp directory first + extract_dir = temp_path / "extracted" + zip_file.extractall(extract_dir) + + # Find the cognee-frontend directory in the extracted content + # The archive structure will be: cognee-{version}/cognee-frontend/ + cognee_frontend_source = None + for root, dirs, files in os.walk(extract_dir): + if "cognee-frontend" in dirs: + cognee_frontend_source = Path(root) / "cognee-frontend" + break + + if not cognee_frontend_source or not cognee_frontend_source.exists(): + logger.error( + "Could not find cognee-frontend directory in downloaded release archive" + ) + logger.error("This might indicate a version mismatch or missing release.") + return False + + # Copy the cognee-frontend to our cache + shutil.copytree(cognee_frontend_source, frontend_dir) + logger.debug(f"Frontend extracted to: {frontend_dir}") + + # Write version info for future cache validation + version_file.write_text(version) + logger.debug(f"Cached frontend for cognee version: {version}") + + logger.info( + f"✓ Cognee frontend v{version.replace('-local', '')} downloaded and cached successfully!" + ) + return True + + except requests.exceptions.RequestException as e: + if "404" in str(e): + logger.error(f"Release v{version.replace('-local', '')} not found on GitHub.") + logger.error( + "This version might not have been released yet, or you're using a development version." + ) + logger.error("Try using a stable release version of cognee.") + else: + logger.error(f"Failed to download from GitHub: {str(e)}") + logger.error("You can still use cognee without the UI functionality.") + return False + except Exception as e: + logger.error(f"Failed to download frontend assets: {str(e)}") + logger.error("You can still use cognee without the UI functionality.") + return False + + +def find_frontend_path() -> Optional[Path]: + """ + Find the cognee-frontend directory. + Checks both development location and cached download location. + """ + current_file = Path(__file__) + + # First, try development paths (for contributors/developers) + dev_search_paths = [ + current_file.parents[4] / "cognee-frontend", # from cognee/api/v1/ui/ui.py to project root + current_file.parents[3] / "cognee-frontend", # fallback path + current_file.parents[2] / "cognee-frontend", # another fallback + ] + + for path in dev_search_paths: + if path.exists() and (path / "package.json").exists(): + logger.debug(f"Found development frontend at: {path}") + return path + + # Then try cached download location (for pip-installed users) + cache_dir = get_frontend_cache_dir() + cached_frontend = cache_dir / "frontend" + + if cached_frontend.exists() and (cached_frontend / "package.json").exists(): + logger.debug(f"Found cached frontend at: {cached_frontend}") + return cached_frontend + + return None + + +def check_node_npm() -> tuple[bool, str]: + """ + Check if Node.js and npm are available. + Returns (is_available, error_message) + """ + try: + # Check Node.js + result = subprocess.run(["node", "--version"], capture_output=True, text=True, timeout=10) + if result.returncode != 0: + return False, "Node.js is not installed or not in PATH" + + node_version = result.stdout.strip() + logger.debug(f"Found Node.js version: {node_version}") + + # Check npm + result = subprocess.run(["npm", "--version"], capture_output=True, text=True, timeout=10) + if result.returncode != 0: + return False, "npm is not installed or not in PATH" + + npm_version = result.stdout.strip() + logger.debug(f"Found npm version: {npm_version}") + + return True, f"Node.js {node_version}, npm {npm_version}" + + except subprocess.TimeoutExpired: + return False, "Timeout checking Node.js/npm installation" + except FileNotFoundError: + return False, "Node.js/npm not found. Please install Node.js from https://nodejs.org/" + except Exception as e: + return False, f"Error checking Node.js/npm: {str(e)}" + + +def install_frontend_dependencies(frontend_path: Path) -> bool: + """ + Install frontend dependencies if node_modules doesn't exist. + This is needed for both development and downloaded frontends since both use npm run dev. + """ + node_modules = frontend_path / "node_modules" + if node_modules.exists(): + logger.debug("Frontend dependencies already installed") + return True + + logger.info("Installing frontend dependencies (this may take a few minutes)...") + + try: + result = subprocess.run( + ["npm", "install"], + cwd=frontend_path, + capture_output=True, + text=True, + timeout=300, # 5 minutes timeout + ) + + if result.returncode == 0: + logger.info("Frontend dependencies installed successfully") + return True + else: + logger.error(f"Failed to install dependencies: {result.stderr}") + return False + + except subprocess.TimeoutExpired: + logger.error("Timeout installing frontend dependencies") + return False + except Exception as e: + logger.error(f"Error installing frontend dependencies: {str(e)}") + return False + + +def is_development_frontend(frontend_path: Path) -> bool: + """ + Check if this is a development frontend (has Next.js) vs downloaded assets. + """ + package_json_path = frontend_path / "package.json" + if not package_json_path.exists(): + return False + + try: + import json + + with open(package_json_path) as f: + package_data = json.load(f) + + # Development frontend has Next.js as dependency + dependencies = package_data.get("dependencies", {}) + dev_dependencies = package_data.get("devDependencies", {}) + + return "next" in dependencies or "next" in dev_dependencies + except Exception: + return False + + +def prompt_user_for_download() -> bool: + """ + Ask user if they want to download the frontend assets. + Returns True if user consents, False otherwise. + """ + try: + print("\n" + "=" * 60) + print("🎨 Cognee UI Setup Required") + print("=" * 60) + print("The cognee frontend is not available on your system.") + print("This is required to use the web interface.") + print("\nWhat will happen:") + print("• Download the actual cognee-frontend from GitHub") + print("• Cache it in your home directory (~/.cognee/ui-cache/)") + print("• Install dependencies with npm (requires Node.js)") + print("• This is a one-time setup per cognee version") + print("\nThe frontend will then be available offline for future use.") + + response = input("\nWould you like to download the frontend now? (y/N): ").strip().lower() + return response in ["y", "yes"] + except (KeyboardInterrupt, EOFError): + print("\nOperation cancelled by user.") + return False + + +def start_ui( + host: str = "localhost", + port: int = 3001, + open_browser: bool = True, + auto_download: bool = False, +) -> Optional[subprocess.Popen]: + """ + Start the cognee frontend UI server. + + This function will: + 1. Find the cognee-frontend directory (development) or download it (pip install) + 2. Check if Node.js and npm are available (for development mode) + 3. Install dependencies if needed (development mode) + 4. Start the appropriate server + 5. Optionally open the browser + + Args: + host: Host to bind the server to (default: localhost) + port: Port to run the server on (default: 3001) + open_browser: Whether to open the browser automatically (default: True) + auto_download: If True, download frontend without prompting (default: False) + + Returns: + subprocess.Popen object representing the running server, or None if failed + + Example: + >>> import cognee + >>> server = cognee.start_ui() + >>> # UI will be available at http://localhost:3001 + >>> # To stop the server later: + >>> server.terminate() + """ + logger.info("Starting cognee UI...") + + # Find frontend directory + frontend_path = find_frontend_path() + + if not frontend_path: + logger.info("Frontend not found locally. This is normal for pip-installed cognee.") + + # Offer to download the frontend + if auto_download or prompt_user_for_download(): + if download_frontend_assets(): + frontend_path = find_frontend_path() + if not frontend_path: + logger.error( + "Download succeeded but frontend still not found. This is unexpected." + ) + return None + else: + logger.error("Failed to download frontend assets.") + return None + else: + logger.info("Frontend download declined. UI functionality not available.") + logger.info("You can still use all other cognee features without the web interface.") + return None + + # Check Node.js and npm + node_available, node_message = check_node_npm() + if not node_available: + logger.error(f"Cannot start UI: {node_message}") + logger.error("Please install Node.js from https://nodejs.org/ to use the UI functionality") + return None + + logger.debug(f"Environment check passed: {node_message}") + + # Install dependencies if needed + if not install_frontend_dependencies(frontend_path): + logger.error("Failed to install frontend dependencies") + return None + + # Prepare environment variables + env = os.environ.copy() + env["HOST"] = host + env["PORT"] = str(port) + + # Start the development server + logger.info(f"Starting frontend server at http://{host}:{port}") + logger.info("This may take a moment to compile and start...") + + try: + # Use process group to ensure all child processes get terminated together + process = subprocess.Popen( + ["npm", "run", "dev"], + cwd=frontend_path, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + preexec_fn=os.setsid + if hasattr(os, "setsid") + else None, # Create new process group on Unix + ) + + # Give it a moment to start up + time.sleep(3) + + # Check if process is still running + if process.poll() is not None: + stdout, stderr = process.communicate() + logger.error("Frontend server failed to start:") + logger.error(f"stdout: {stdout}") + logger.error(f"stderr: {stderr}") + return None + + # Open browser if requested + if open_browser: + + def open_browser_delayed(): + time.sleep(5) # Give Next.js time to fully start + try: + webbrowser.open(f"http://{host}:{port}") # TODO: use dashboard url? + except Exception as e: + logger.warning(f"Could not open browser automatically: {e}") + + browser_thread = threading.Thread(target=open_browser_delayed, daemon=True) + browser_thread.start() + + logger.info("✓ Cognee UI is starting up...") + logger.info(f"✓ Open your browser to: http://{host}:{port}") + logger.info("✓ The UI will be available once Next.js finishes compiling") + + return process + + except Exception as e: + logger.error(f"Failed to start frontend server: {str(e)}") + return None + + +def stop_ui(process: subprocess.Popen) -> bool: + """ + Stop a running UI server process and all its children. + + Args: + process: The subprocess.Popen object returned by start_ui() + + Returns: + bool: True if stopped successfully, False otherwise + """ + if not process: + return False + + try: + # Try to terminate the process group (includes child processes like Next.js) + if hasattr(os, "killpg"): + try: + # Kill the entire process group + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + logger.debug("Sent SIGTERM to process group") + except (OSError, ProcessLookupError): + # Fall back to terminating just the main process + process.terminate() + logger.debug("Terminated main process only") + else: + process.terminate() + logger.debug("Terminated main process (Windows)") + + try: + process.wait(timeout=10) + logger.info("UI server stopped gracefully") + except subprocess.TimeoutExpired: + logger.warning("Process didn't terminate gracefully, forcing kill") + + # Force kill the process group + if hasattr(os, "killpg"): + try: + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + logger.debug("Sent SIGKILL to process group") + except (OSError, ProcessLookupError): + process.kill() + logger.debug("Force killed main process only") + else: + process.kill() + logger.debug("Force killed main process (Windows)") + + process.wait() + + logger.info("UI server stopped") + return True + + except Exception as e: + logger.error(f"Error stopping UI server: {str(e)}") + return False + + +# Convenience function similar to DuckDB's approach +def ui() -> Optional[subprocess.Popen]: + """ + Convenient alias for start_ui() with default parameters. + Similar to how DuckDB provides simple ui() function. + """ + return start_ui() + + +if __name__ == "__main__": + # Test the UI startup + server = start_ui() + if server: + try: + input("Press Enter to stop the server...") + finally: + stop_ui(server) diff --git a/cognee/cli/_cognee.py b/cognee/cli/_cognee.py index f8ade67dd..1c9406143 100644 --- a/cognee/cli/_cognee.py +++ b/cognee/cli/_cognee.py @@ -1,6 +1,8 @@ import sys import os import argparse +import signal +import subprocess from typing import Any, Sequence, Dict, Type, cast, List import click @@ -51,6 +53,31 @@ class DebugAction(argparse.Action): fmt.note("Debug mode enabled. Full stack traces will be shown.") +class UiAction(argparse.Action): + def __init__( + self, + option_strings: Sequence[str], + dest: Any = argparse.SUPPRESS, + default: Any = argparse.SUPPRESS, + help: str = None, + ) -> None: + super(UiAction, self).__init__( + option_strings=option_strings, dest=dest, default=default, nargs=0, help=help + ) + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Any, + option_string: str = None, + ) -> None: + # Set a flag to indicate UI should be started + global ACTION_EXECUTED + ACTION_EXECUTED = True + namespace.start_ui = True + + # Debug functionality is now in cognee.cli.debug module @@ -97,6 +124,11 @@ def _create_parser() -> tuple[argparse.ArgumentParser, Dict[str, SupportsCliComm action=DebugAction, help="Enable debug mode to show full stack traces on exceptions", ) + parser.add_argument( + "-ui", + action=UiAction, + help="Start the cognee web UI interface", + ) subparsers = parser.add_subparsers(title="Available commands", dest="command") @@ -140,6 +172,67 @@ def main() -> int: parser, installed_commands = _create_parser() args = parser.parse_args() + # Handle UI flag + if hasattr(args, "start_ui") and args.start_ui: + server_process = None + + def signal_handler(signum, frame): + """Handle Ctrl+C and other termination signals""" + nonlocal server_process + fmt.echo("\nShutting down UI server...") + if server_process: + try: + # Try graceful termination first + server_process.terminate() + try: + server_process.wait(timeout=5) + fmt.success("UI server stopped gracefully.") + except subprocess.TimeoutExpired: + # If graceful termination fails, force kill + fmt.echo("Force stopping UI server...") + server_process.kill() + server_process.wait() + fmt.success("UI server stopped.") + except Exception as e: + fmt.warning(f"Error stopping server: {e}") + sys.exit(0) + + # Set up signal handlers + signal.signal(signal.SIGINT, signal_handler) # Ctrl+C + signal.signal(signal.SIGTERM, signal_handler) # Termination request + + try: + from cognee import start_ui + + fmt.echo("Starting cognee UI...") + server_process = start_ui(host="localhost", port=3001, open_browser=True) + + if server_process: + fmt.success("UI server started successfully!") + fmt.echo("The interface is available at: http://localhost:3001") + fmt.note("Press Ctrl+C to stop the server...") + + try: + # Keep the server running + import time + + while server_process.poll() is None: # While process is still running + time.sleep(1) + except KeyboardInterrupt: + # This shouldn't happen now due to signal handler, but kept for safety + signal_handler(signal.SIGINT, None) + + return 0 + else: + fmt.error("Failed to start UI server. Check the logs above for details.") + return 1 + + except Exception as ex: + fmt.error(f"Error starting UI: {str(ex)}") + if debug.is_debug_enabled(): + raise ex + return 1 + if cmd := installed_commands.get(args.command): try: cmd.execute(args) diff --git a/examples/start_ui_example.py b/examples/start_ui_example.py new file mode 100644 index 000000000..55796727b --- /dev/null +++ b/examples/start_ui_example.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Example showing how to use cognee.start_ui() to launch the frontend. + +This demonstrates the new UI functionality that works similar to DuckDB's start_ui(). +""" + +import asyncio +import cognee +import time + + +async def main(): + # First, let's add some data to cognee for the UI to display + print("Adding sample data to cognee...") + await cognee.add( + "Natural language processing (NLP) is an interdisciplinary subfield of computer science and information retrieval." + ) + await cognee.add( + "Machine learning (ML) is a subset of artificial intelligence that focuses on algorithms and statistical models." + ) + + # Generate the knowledge graph + print("Generating knowledge graph...") + await cognee.cognify() + + print("\n" + "=" * 60) + print("Starting cognee UI...") + print("=" * 60) + + # Start the UI server + server = cognee.start_ui( + host="localhost", + port=3000, + open_browser=True, # This will automatically open your browser + ) + + if server: + print("UI server started successfully!") + print("The interface will be available at: http://localhost:3000") + print("\nPress Ctrl+C to stop the server when you're done...") + + try: + # Keep the server running + while server.poll() is None: # While process is still running + time.sleep(1) + except KeyboardInterrupt: + print("\nStopping UI server...") + server.terminate() + server.wait() # Wait for process to finish + print("UI server stopped.") + else: + print("Failed to start UI server. Check the logs above for details.") + + +if __name__ == "__main__": + asyncio.run(main())