feat: add support to start UI programmatically (#1365)

<!-- .github/pull_request_template.md -->

## Description
<!-- Provide a clear description of the changes in this PR -->
With upcoming UI features, we’re adding support to launch the UI
programmatically directly from the `cognee` package.

## What's New
1. Ability to start the UI programmaticall, works both in development
(within the repo) and from the installed package.
2. Example demonstrating how to use this feature.

## DCO Affirmation
I affirm that all code in every commit of this pull request conforms to
the terms of the Topoteretes Developer Certificate of Origin.
This commit is contained in:
Vasilije 2025-09-11 07:27:05 -07:00 committed by GitHub
commit b309537a7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 681 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1 @@
from .ui import start_ui, stop_ui, ui

529
cognee/api/v1/ui/ui.py Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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())