openrag/src/tui/main.py

803 lines
24 KiB
Python

"""Main TUI application for OpenRAG."""
import os
import subprocess
import sys
from pathlib import Path
from typing import Iterable, Optional
from textual.app import App, ComposeResult
from utils.logging_config import get_logger
try:
from importlib.resources import files
except ImportError:
from importlib_resources import files
logger = get_logger(__name__)
from .screens.welcome import WelcomeScreen
from .screens.config import ConfigScreen
from .screens.monitor import MonitorScreen
from .screens.logs import LogsScreen
from .screens.diagnostics import DiagnosticsScreen
from .managers.env_manager import EnvManager
from .managers.container_manager import ContainerManager
from .managers.docling_manager import DoclingManager
from .utils.platform import PlatformDetector
from .widgets.diagnostics_notification import notify_with_diagnostics
class OpenRAGTUI(App):
"""OpenRAG Terminal User Interface application."""
TITLE = "OpenRAG TUI"
SUB_TITLE = "Container Management & Configuration"
CSS = """
Screen {
background: #27272a;
}
#main-container {
height: 100%;
padding: 1;
}
#welcome-container {
align: center middle;
width: 100%;
height: 100%;
}
#welcome-text {
text-align: center;
margin-bottom: 2;
}
.button-row {
align: center middle;
height: auto;
margin: 1 0;
}
.button-row Button {
margin: 0 1;
min-width: 20;
border: solid #3f3f46;
}
#config-header {
text-align: center;
margin-bottom: 2;
}
#config-scroll {
height: 1fr;
overflow-y: auto;
}
#config-form {
width: 80%;
max-width: 100;
margin: 0;
padding: 1;
height: auto;
}
#config-form Input {
margin-bottom: 1;
width: 100%;
}
/* Actions under Documents Paths input */
#docs-path-actions {
width: 100%;
padding-left: 0;
margin-top: -1;
height: auto;
}
#docs-path-actions Button {
width: auto;
min-width: 12;
}
#config-form Label {
margin-bottom: 0;
padding-left: 1;
}
.helper-text {
margin: 0 0 1 1;
}
/* Password field label rows */
#config-form Horizontal {
height: auto;
align: left middle;
margin-bottom: 0;
}
#config-form Horizontal Label {
width: auto;
margin-right: 1;
}
/* Password input rows */
#opensearch-password-row,
#langflow-password-row {
width: 100%;
height: auto;
align: left middle;
}
#opensearch-password-row Input,
#langflow-password-row Input {
width: 1fr;
}
/* Password toggle buttons */
#toggle-opensearch-password,
#toggle-langflow-password {
min-width: 8;
width: 8;
height: 3;
padding: 0 1;
margin-left: 1;
}
/* Docs path actions row */
#services-content {
height: 100%;
}
#runtime-status {
background: $panel;
border: solid $primary;
padding: 1;
margin-bottom: 1;
}
#services-table {
height: auto;
max-height: 12;
margin-bottom: 1;
}
#images-table {
height: auto;
max-height: 8;
margin-bottom: 1;
}
#logs-scroll {
height: 1fr;
border: solid $primary;
background: $surface;
}
.controls-row {
align: left middle;
height: auto;
margin: 1 0;
}
.controls-row > * {
margin-right: 1;
}
.label {
width: auto;
margin-right: 1;
text-style: bold;
}
#system-info {
background: $panel;
border: solid $primary;
padding: 2;
height: 1fr;
}
TabbedContent {
height: 1fr;
}
TabPane {
padding: 1;
height: 1fr;
}
.tab-header {
text-style: bold;
color: $accent;
margin-bottom: 1;
}
TabPane ScrollableContainer {
height: 100%;
padding: 1;
}
/* Modern dark theme with pink accents */
Static {
color: #fafafa;
}
Button,
Button.-default,
Button.-primary,
Button.-success,
Button.-warning,
Button.-error {
background: #27272a !important;
color: #fafafa !important;
border: round #52525b !important;
text-style: none !important;
tint: transparent 0% !important;
}
Button > *,
Button.-default > *,
Button.-primary > *,
Button.-success > *,
Button.-warning > *,
Button.-error > * {
background: transparent !important;
color: #fafafa !important;
text-style: none !important;
}
Button:hover,
Button.-default:hover,
Button.-primary:hover,
Button.-success:hover,
Button.-warning:hover,
Button.-error:hover {
background: #27272a !important;
color: #fafafa !important;
border: round #52525b !important;
}
Button:focus,
Button:focus-within,
Button.-active,
Button.-default:focus,
Button.-default:focus-within,
Button.-default.-active,
Button.-primary:focus,
Button.-primary:focus-within,
Button.-primary.-active,
Button.-success:focus,
Button.-success:focus-within,
Button.-success.-active,
Button.-warning:focus,
Button.-warning:focus-within,
Button.-warning.-active,
Button.-error:focus,
Button.-error:focus-within,
Button.-error.-active {
background: #27272a !important;
color: #fafafa !important;
border: round #ec4899 !important;
}
DataTable {
background: #27272a;
color: #fafafa;
}
DataTable > .datatable--header {
background: #3f3f46;
color: #fafafa;
}
DataTable > .datatable--cursor {
background: #52525b;
}
Input {
background: #18181b;
color: #fafafa;
border: solid #3f3f46;
}
Input:focus {
border: solid #ec4899;
}
Label {
color: #fafafa;
}
Checkbox {
background: transparent;
color: #fafafa;
border: none;
padding: 0;
margin-left: 2;
}
Checkbox > Static {
background: transparent;
color: #fafafa;
}
Header {
background: #27272a;
color: #fafafa;
}
Footer {
background: #27272a;
color: #a1a1aa;
}
#runtime-status {
background: #27272a;
border: solid #3f3f46;
color: #fafafa;
}
#system-info {
background: #27272a;
border: solid #3f3f46;
color: #fafafa;
}
#services-table, #images-table {
background: #27272a;
}
* {
scrollbar-background: #27272a;
scrollbar-background-hover: #3f3f46;
scrollbar-background-active: #3f3f46;
scrollbar-color: #52525b;
scrollbar-color-hover: #71717a;
scrollbar-color-active: #71717a;
scrollbar-corner-color: #27272a;
}
"""
def __init__(self):
super().__init__()
self.platform_detector = PlatformDetector()
self.container_manager = ContainerManager()
self.env_manager = EnvManager()
self.docling_manager = DoclingManager() # Initialize singleton instance
def notify(
self,
message: str,
*,
title: str = "",
severity: str = "information",
timeout: float | None = None,
markup: bool = True,
) -> None:
"""Override notify to make notifications last 20 seconds by default."""
# If timeout is None (default), make it 20 seconds
if timeout is None:
timeout = 20.0
super().notify(message, title=title, severity=severity, timeout=timeout, markup=markup)
def on_mount(self) -> None:
"""Initialize the application."""
# Check for runtime availability and show appropriate screen
if not self.container_manager.is_available():
notify_with_diagnostics(
self,
"No container runtime found. Please install Docker or Podman.",
severity="warning",
timeout=10,
)
# Load existing config if available
config_exists = self.env_manager.load_existing_env()
# Start with welcome screen
self.push_screen(WelcomeScreen())
async def action_quit(self) -> None:
"""Quit the application."""
# Cleanup docling manager before exiting
self.docling_manager.cleanup()
self.exit()
def check_runtime_requirements(self) -> tuple[bool, str]:
"""Check if runtime requirements are met."""
if not self.container_manager.is_available():
return False, self.platform_detector.get_installation_instructions()
# Check Podman macOS memory if applicable
runtime_info = self.container_manager.get_runtime_info()
if runtime_info.runtime_type.value == "podman":
is_sufficient, _, message = (
self.platform_detector.check_podman_macos_memory()
)
if not is_sufficient:
return False, f"Podman VM memory insufficient:\n{message}"
return True, "Runtime requirements satisfied"
def _copy_assets(resource_tree, destination: Path, allowed_suffixes: Optional[Iterable[str]] = None, *, force: bool = False) -> None:
"""Copy packaged assets into destination and optionally overwrite existing files.
When ``force`` is True, files are refreshed if the packaged bytes differ.
"""
destination.mkdir(parents=True, exist_ok=True)
for resource in resource_tree.iterdir():
target_path = destination / resource.name
if resource.is_dir():
_copy_assets(resource, target_path, allowed_suffixes, force=force)
continue
if allowed_suffixes and not any(resource.name.endswith(suffix) for suffix in allowed_suffixes):
continue
resource_bytes = resource.read_bytes()
if target_path.exists():
if not force:
continue
try:
if target_path.read_bytes() == resource_bytes:
continue
except Exception as read_error:
logger.debug(f"Failed to read existing asset {target_path}: {read_error}")
target_path.write_bytes(resource_bytes)
logger.info(f"Copied bundled asset: {target_path}")
def copy_sample_documents(*, force: bool = False) -> None:
"""Copy sample documents from package to host directory.
Uses the first path from OPENRAG_DOCUMENTS_PATHS env var.
Defaults to ~/.openrag/documents if not configured.
"""
from .managers.env_manager import EnvManager
from pathlib import Path
# Get the configured documents path from env
env_manager = EnvManager()
env_manager.load_existing_env()
# Parse the first path from the documents paths config
documents_path_str = env_manager.config.openrag_documents_paths
if documents_path_str:
first_path = documents_path_str.split(',')[0].strip()
# Expand $HOME and ~
first_path = first_path.replace("$HOME", str(Path.home()))
documents_dir = Path(first_path).expanduser()
else:
# Default fallback
documents_dir = Path.home() / ".openrag" / "documents"
documents_dir.mkdir(parents=True, exist_ok=True)
try:
assets_files = files("tui._assets.openrag-documents")
_copy_assets(assets_files, documents_dir, allowed_suffixes=(".pdf",), force=force)
except Exception as e:
logger.debug(f"Could not copy sample documents: {e}")
# This is not a critical error - the app can work without sample documents
def copy_sample_flows(*, force: bool = False) -> None:
"""Copy sample flows from package to host directory.
Flows are placed in ~/.openrag/flows/ which will be volume-mounted to containers.
"""
from pathlib import Path
# Flows always go to ~/.openrag/flows/ - this will be volume-mounted
flows_dir = Path.home() / ".openrag" / "flows"
flows_dir.mkdir(parents=True, exist_ok=True)
try:
assets_files = files("tui._assets.flows")
_copy_assets(assets_files, flows_dir, allowed_suffixes=(".json",), force=force)
except Exception as e:
logger.debug(f"Could not copy sample flows: {e}")
# The app can proceed without bundled flows
def copy_compose_files(*, force: bool = False) -> None:
"""Copy docker-compose templates into the TUI workspace if they are missing."""
from utils.paths import get_tui_compose_file
try:
assets_root = files("tui._assets")
except Exception as e:
logger.debug(f"Could not access compose assets: {e}")
return
for filename in ("docker-compose.yml", "docker-compose.gpu.yml"):
is_gpu = "gpu" in filename
destination = get_tui_compose_file(gpu=is_gpu)
if destination.exists() and not force:
continue
try:
resource = assets_root.joinpath(filename)
if not resource.is_file():
logger.debug(f"Compose template not found in assets: {filename}")
continue
resource_bytes = resource.read_bytes()
if destination.exists():
try:
if destination.read_bytes() == resource_bytes:
continue
except Exception as read_error:
logger.debug(f"Failed to read existing compose file {destination}: {read_error}")
destination.write_bytes(resource_bytes)
logger.info(f"Copied docker-compose template to {destination}")
except Exception as error:
logger.debug(f"Could not copy compose file {filename}: {error}")
def migrate_legacy_data_directories():
"""Migrate data from CWD-based directories to ~/.openrag/.
This is a one-time migration for users upgrading from the old layout.
Migrates: documents, flows, keys, config, opensearch-data
Prompts user for confirmation before migrating. If user declines,
exits with a message to downgrade to v1.52 or earlier.
"""
import shutil
import sys
cwd = Path.cwd()
target_base = Path.home() / ".openrag"
marker = target_base / ".migrated"
# Check if migration already completed
if marker.exists():
return
# Define migration mappings: (source_path, target_path, description)
migrations = [
(cwd / "openrag-documents", target_base / "documents", "documents"),
(cwd / "flows", target_base / "flows", "flows"),
(cwd / "keys", target_base / "keys", "keys"),
(cwd / "config", target_base / "config", "config"),
(cwd / "opensearch-data", target_base / "data" / "opensearch-data", "OpenSearch data"),
]
# Check which sources exist and need migration
sources_to_migrate = [(s, t, d) for s, t, d in migrations if s.exists()]
if not sources_to_migrate:
# No legacy data to migrate, just mark as done and update .env paths
marker.parent.mkdir(parents=True, exist_ok=True)
marker.touch()
# Still need to update .env with centralized paths
try:
from managers.env_manager import EnvManager
env_manager = EnvManager()
env_manager.load_existing_env()
# Explicitly set centralized paths (overrides any old CWD-relative paths)
home = str(Path.home())
env_manager.config.openrag_documents_paths = f"{home}/.openrag/documents"
env_manager.config.openrag_documents_path = f"{home}/.openrag/documents"
env_manager.config.openrag_keys_path = f"{home}/.openrag/keys"
env_manager.config.openrag_flows_path = f"{home}/.openrag/flows"
env_manager.config.openrag_config_path = f"{home}/.openrag/config"
env_manager.config.openrag_data_path = f"{home}/.openrag/data"
env_manager.config.opensearch_data_path = f"{home}/.openrag/data/opensearch-data"
env_manager.save_env()
logger.info("Updated .env file with centralized paths")
except Exception as e:
logger.warning(f"Failed to update .env paths: {e}")
return
# Prompt user for confirmation
print("\n" + "=" * 60)
print(" OpenRAG Data Migration Required")
print("=" * 60)
print(f"\nStarting with this version, OpenRAG stores data in:")
print(f" {target_base}")
print("\nThe following will be copied from your current directory:")
for source, target, desc in sources_to_migrate:
print(f" - {desc}: {source.name}/ -> {target}")
print("\nThis is a one-time migration.")
print("\nIf you don't want to migrate, exit and downgrade to v1.52 or earlier.")
try:
response = input("\nProceed with migration? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
response = ""
if response != "y":
print("\nMigration cancelled. Exiting.")
sys.exit(0)
print("\nMigrating...")
# Perform migration (always copy, never delete originals)
for source, target, description in sources_to_migrate:
try:
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
# Target exists - merge contents (copy only new items)
logger.info(f"Merging {description} from {source} to {target}")
if source.is_dir():
for item in source.iterdir():
src_item = source / item.name
dst_item = target / item.name
if not dst_item.exists():
if src_item.is_dir():
shutil.copytree(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
logger.debug(f"Copied {src_item} to {dst_item}")
else:
# Target doesn't exist - copy entire directory
logger.info(f"Copying {description} from {source} to {target}")
if source.is_dir():
shutil.copytree(source, target)
else:
shutil.copy2(source, target)
print(f" Migrated {description}")
except Exception as e:
logger.warning(f"Failed to migrate {description}: {e}")
print(f" Warning: Failed to migrate {description}: {e}")
# Create marker to prevent future migration prompts
marker.parent.mkdir(parents=True, exist_ok=True)
marker.touch()
# Update .env file with centralized paths
try:
from managers.env_manager import EnvManager
env_manager = EnvManager()
env_manager.load_existing_env()
# Explicitly set centralized paths (overrides any old CWD-relative paths)
home = str(Path.home())
env_manager.config.openrag_documents_paths = f"{home}/.openrag/documents"
env_manager.config.openrag_documents_path = f"{home}/.openrag/documents"
env_manager.config.openrag_keys_path = f"{home}/.openrag/keys"
env_manager.config.openrag_flows_path = f"{home}/.openrag/flows"
env_manager.config.openrag_config_path = f"{home}/.openrag/config"
env_manager.config.openrag_data_path = f"{home}/.openrag/data"
env_manager.config.opensearch_data_path = f"{home}/.openrag/data/opensearch-data"
env_manager.save_env()
print(" Updated .env with centralized paths")
logger.info("Updated .env file with centralized paths")
except Exception as e:
logger.warning(f"Failed to update .env paths: {e}")
print(f" Warning: Failed to update .env paths: {e}")
print("\nMigration complete!\n")
logger.info("Data migration completed successfully")
def generate_jwt_keys(keys_dir: Path):
"""Generate RSA keys for JWT signing if they don't exist.
This pre-generates keys on the host so containers can read them,
avoiding permission issues with Podman rootless mode.
"""
private_key_path = keys_dir / "private_key.pem"
public_key_path = keys_dir / "public_key.pem"
if private_key_path.exists() and public_key_path.exists():
logger.debug("JWT keys already exist")
return
try:
# Generate private key
subprocess.run(
["openssl", "genrsa", "-out", str(private_key_path), "2048"],
check=True,
capture_output=True,
)
# Set restrictive permissions on private key (readable by owner only)
os.chmod(private_key_path, 0o600)
# Generate public key from private key
subprocess.run(
[
"openssl",
"rsa",
"-in", str(private_key_path),
"-pubout",
"-out", str(public_key_path),
],
check=True,
capture_output=True,
)
# Set permissions on public key (readable by all)
os.chmod(public_key_path, 0o644)
logger.info("Generated RSA keys for JWT signing")
except FileNotFoundError:
logger.warning("openssl not found, skipping JWT key generation (will be generated in container)")
except subprocess.CalledProcessError as e:
logger.error(f"Failed to generate RSA keys: {e}")
def setup_host_directories():
"""Initialize OpenRAG directory structure on the host.
Creates directories that will be volume-mounted into containers:
- ~/.openrag/documents/ (for document ingestion)
- ~/.openrag/flows/ (for Langflow flows)
- ~/.openrag/keys/ (for JWT keys)
- ~/.openrag/config/ (for configuration)
- ~/.openrag/data/ (for backend data: conversations, OAuth tokens, etc.)
- ~/.openrag/data/opensearch-data/ (for OpenSearch index)
"""
base_dir = Path.home() / ".openrag"
directories = [
base_dir / "documents",
base_dir / "flows",
base_dir / "keys",
base_dir / "config",
base_dir / "data",
base_dir / "data" / "opensearch-data",
]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)
logger.debug(f"Ensured directory exists: {directory}")
# Generate JWT keys on host to avoid container permission issues
generate_jwt_keys(base_dir / "keys")
def run_tui():
"""Run the OpenRAG TUI application."""
# Check for native Windows before launching TUI
from .utils.platform import PlatformDetector
platform_detector = PlatformDetector()
if platform_detector.is_native_windows():
print("\n" + "=" * 60)
print("⚠️ Native Windows Not Supported")
print("=" * 60)
print(platform_detector.get_wsl_recommendation())
print("=" * 60 + "\n")
sys.exit(1)
app = None
try:
# Migrate legacy data directories from CWD to ~/.openrag/
migrate_legacy_data_directories()
# Initialize host directory structure
setup_host_directories()
# Keep bundled assets aligned with the packaged versions
copy_sample_documents(force=True)
copy_sample_flows(force=True)
copy_compose_files(force=True)
app = OpenRAGTUI()
app.run()
except KeyboardInterrupt:
logger.info("OpenRAG TUI interrupted by user")
except Exception as e:
logger.error("Error running OpenRAG TUI", error=str(e))
finally:
# Ensure cleanup happens even on exceptions
if app and hasattr(app, 'docling_manager'):
app.docling_manager.cleanup()
sys.exit(0)
if __name__ == "__main__":
run_tui()