"""Main TUI application for OpenRAG.""" 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/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() documents_dir = Path(first_path).expanduser() else: # Default fallback documents_dir = Path.home() / ".openrag" / "documents" / "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 setup_host_directories(): """Initialize OpenRAG directory structure on the host. Creates directories that will be volume-mounted into containers: - ~/.openrag/documents/openrag-documents/ (for document ingestion) - ~/.openrag/flows/ (for Langflow flows) - ~/.openrag/keys/ (for JWT keys) - ~/.openrag/config/ (for configuration) - ~/.openrag/data/opensearch-data/ (for OpenSearch data) """ from pathlib import Path base_dir = Path.home() / ".openrag" directories = [ base_dir / "documents" / "openrag-documents", base_dir / "flows", base_dir / "keys", base_dir / "config", base_dir / "data" / "opensearch-data", ] for directory in directories: directory.mkdir(parents=True, exist_ok=True) logger.debug(f"Ensured directory exists: {directory}") 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: # 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()