"""Welcome screen for OpenRAG TUI.""" import os from pathlib import Path from textual.app import ComposeResult from textual.containers import Container, Vertical, Horizontal from textual.screen import Screen from textual.widgets import Header, Footer, Static, Button from rich.text import Text from rich.align import Align from dotenv import load_dotenv from .. import __version__ from ..managers.container_manager import ContainerManager, ServiceStatus from ..managers.env_manager import EnvManager from ..managers.docling_manager import DoclingManager from ..widgets.command_modal import CommandOutputModal from ..widgets.version_mismatch_warning_modal import VersionMismatchWarningModal class WelcomeScreen(Screen): """Initial welcome screen with setup options.""" BINDINGS = [ ("q", "quit", "Quit"), ] def __init__(self): super().__init__() self.container_manager = ContainerManager() self.env_manager = EnvManager() self.docling_manager = DoclingManager() self.services_running = False self.docling_running = False self.has_oauth_config = False self.default_button_id = "basic-setup-btn" self._state_checked = False self.has_flow_backups = False # Check if .env file exists self.has_env_file = self.env_manager.env_file.exists() # Load .env file if it exists # override=True ensures .env file values take precedence over existing environment variables load_dotenv(override=True) # Check OAuth config immediately self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") ) # Check for flow backups self.has_flow_backups = self._check_flow_backups() def compose(self) -> ComposeResult: """Create the welcome screen layout.""" # Try to detect services synchronously before creating buttons self._detect_services_sync() yield Container( Vertical( Static(self._create_welcome_text(), id="welcome-text"), self._create_dynamic_buttons(), id="welcome-container", ), id="main-container", ) yield Footer() def _check_flow_backups(self) -> bool: """Check if there are any flow backups in flows/backup directory.""" from ..managers.env_manager import EnvManager # Get flows path from env config env_manager = EnvManager() env_manager.load_existing_env() flows_path = Path(env_manager.config.openrag_flows_path.replace("$HOME", str(Path.home()))).expanduser() backup_dir = flows_path / "backup" if not backup_dir.exists(): return False try: # Check if there are any .json files in the backup directory backup_files = list(backup_dir.glob("*.json")) return len(backup_files) > 0 except Exception: return False def _detect_services_sync(self) -> None: """Synchronously detect if services are running.""" if not self.container_manager.is_available(): self.services_running = False self.docling_running = self.docling_manager.is_running() return try: # Use detected runtime command to check services import subprocess compose_cmd = self.container_manager.runtime_info.compose_command + ["ps", "--format", "json"] result = subprocess.run( compose_cmd, capture_output=True, text=True, timeout=5 ) if result.returncode == 0: import json services = [] # Try parsing as a single JSON array first (podman format) try: parsed = json.loads(result.stdout.strip()) if isinstance(parsed, list): services = parsed else: services = [parsed] if isinstance(parsed, dict) else [] except json.JSONDecodeError: # Fallback: try parsing line-by-line (docker format) for line in result.stdout.strip().split('\n'): if line.strip(): try: service = json.loads(line) if isinstance(service, dict): services.append(service) except json.JSONDecodeError: continue # Check if services are running (exclude starting/created states) # State can be lowercase or mixed case, so normalize it running_services = [] starting_services = [] for s in services: if not isinstance(s, dict): continue state = str(s.get('State', '')).lower() if state == 'running': running_services.append(s) elif 'starting' in state or 'created' in state: starting_services.append(s) # Only consider services running if we have running services AND no starting services # This prevents showing the button when containers are still coming up self.services_running = len(running_services) > 0 and len(starting_services) == 0 else: self.services_running = False except Exception: # Fallback to False if detection fails self.services_running = False # Update native service state as part of detection self.docling_running = self.docling_manager.is_running() def _create_welcome_text(self) -> Text: """Create a minimal welcome message.""" welcome_text = Text() ascii_art = """ ██████╗ ██████╗ ███████╗███╗ ██╗██████╗ █████╗ ██████╗ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔══██╗██╔══██╗██╔════╝ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║██████╔╝███████║██║ ███╗ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██╗██╔══██║██║ ██║ ╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║██║ ██║╚██████╔╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ """ welcome_text.append(ascii_art, style="bold white") welcome_text.append("Terminal User Interface for OpenRAG\n", style="dim") welcome_text.append(f"v{__version__}\n\n", style="white") # Check if all services are running all_services_running = self.services_running and self.docling_running if all_services_running: welcome_text.append( "✓ All services are running\n\n", style="bold green" ) elif self.services_running or self.docling_running: welcome_text.append( "⚠ Some services are running\n\n", style="bold yellow" ) elif self.has_oauth_config: welcome_text.append( "OAuth credentials detected — Advanced Setup recommended\n\n", style="bold green", ) else: welcome_text.append("Select a setup below to continue\n\n", style="white") return welcome_text def _create_dynamic_buttons(self) -> Horizontal: """Create buttons based on current state.""" # Check OAuth config early to determine which buttons to show has_oauth = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") ) buttons = [] # If no .env file exists, only show setup buttons if not self.has_env_file: if has_oauth: # If OAuth is configured, only show advanced setup buttons.append( Button("Advanced Setup", variant="success", id="advanced-setup-btn") ) else: # If no OAuth, show both options with basic as primary buttons.append( Button("Basic Setup", variant="success", id="basic-setup-btn") ) buttons.append( Button("Advanced Setup", variant="default", id="advanced-setup-btn") ) return Horizontal(*buttons, classes="button-row") # Check if all services (native + container) are running all_services_running = self.services_running and self.docling_running if all_services_running: # All services running - show app link first, then stop all buttons.append( Button("Launch OpenRAG", variant="success", id="open-app-btn") ) buttons.append( Button("Stop All Services", variant="error", id="stop-all-services-btn") ) else: # Some or no services running - show setup options and start all if has_oauth: # If OAuth is configured, only show advanced setup buttons.append( Button("Advanced Setup", variant="success", id="advanced-setup-btn") ) else: # If no OAuth, show both options with basic as primary buttons.append( Button("Basic Setup", variant="success", id="basic-setup-btn") ) buttons.append( Button("Advanced Setup", variant="default", id="advanced-setup-btn") ) buttons.append( Button("Start OpenRAG", variant="primary", id="start-all-services-btn") ) # Always show status option buttons.append( Button("Status", variant="default", id="status-btn") ) return Horizontal(*buttons, classes="button-row") async def on_mount(self) -> None: """Initialize screen state when mounted.""" # Check if services are running if self.container_manager.is_available(): services = await self.container_manager.get_service_status() running_services = [ s.name for s in services.values() if s.status == ServiceStatus.RUNNING ] starting_services = [ s.name for s in services.values() if s.status == ServiceStatus.STARTING ] # Only consider services running if we have running services AND no starting services # This prevents showing the button when containers are still coming up self.services_running = len(running_services) > 0 and len(starting_services) == 0 else: self.services_running = False # Check native service state self.docling_running = self.docling_manager.is_running() # Check for OAuth configuration self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") ) # Set default button focus if self.services_running and self.docling_running: self.default_button_id = "open-app-btn" elif self.has_oauth_config: self.default_button_id = "advanced-setup-btn" else: self.default_button_id = "basic-setup-btn" # Update the welcome text try: welcome_widget = self.query_one("#welcome-text") welcome_widget.update(self._create_welcome_text()) except: pass # Widget might not be mounted yet # Focus the appropriate button (the buttons are created correctly in compose, # the issue was they weren't being updated after service operations) self.call_after_refresh(self._focus_appropriate_button) def _focus_appropriate_button(self) -> None: """Focus the appropriate button based on current state.""" try: if self.services_running and self.docling_running: self.query_one("#open-app-btn").focus() elif self.has_oauth_config: self.query_one("#advanced-setup-btn").focus() else: self.query_one("#basic-setup-btn").focus() except: pass # Button might not exist async def on_screen_resume(self) -> None: """Called when returning from another screen (e.g., config screen).""" # Check if .env file exists (may have been created) self.has_env_file = self.env_manager.env_file.exists() # Reload environment variables load_dotenv(override=True) # Update OAuth config state self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") ) # Re-detect service state self._detect_services_sync() # Refresh the welcome content and buttons await self._refresh_welcome_content() def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "basic-setup-btn": self.action_no_auth_setup() elif event.button.id == "advanced-setup-btn": self.action_full_setup() elif event.button.id == "status-btn": self.action_monitor() elif event.button.id == "diagnostics-btn": self.action_diagnostics() elif event.button.id == "start-all-services-btn": self.action_start_all_services() elif event.button.id == "stop-all-services-btn": self.action_stop_all_services() elif event.button.id == "open-app-btn": self.action_open_app() def action_default_action(self) -> None: """Handle Enter key - go to default action based on state.""" if self.services_running and self.docling_running: self.action_open_app() elif self.has_oauth_config: self.action_full_setup() else: self.action_no_auth_setup() def action_no_auth_setup(self) -> None: """Switch to basic configuration screen.""" from .config import ConfigScreen self.app.push_screen(ConfigScreen(mode="no_auth")) def action_full_setup(self) -> None: """Switch to advanced configuration screen.""" from .config import ConfigScreen self.app.push_screen(ConfigScreen(mode="full")) def action_monitor(self) -> None: """Switch to monitoring screen.""" from .monitor import MonitorScreen self.app.push_screen(MonitorScreen()) def action_diagnostics(self) -> None: """Switch to diagnostics screen.""" from .diagnostics import DiagnosticsScreen self.app.push_screen(DiagnosticsScreen()) def action_start_all_services(self) -> None: """Start all services (native first, then containers).""" self.run_worker(self._start_all_services()) def action_stop_all_services(self) -> None: """Stop all services (containers first, then native).""" self.run_worker(self._stop_all_services()) async def _on_services_operation_complete(self) -> None: """Handle completion of services start/stop operation.""" # Use the same sync detection method that worked on startup self._detect_services_sync() # Update OAuth config state self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") ) await self._refresh_welcome_content() def _update_default_button(self) -> None: """Update the default button target based on state.""" if self.services_running and self.docling_running: self.default_button_id = "open-app-btn" elif self.has_oauth_config: self.default_button_id = "advanced-setup-btn" else: self.default_button_id = "basic-setup-btn" async def _refresh_welcome_content(self) -> None: """Refresh welcome text and buttons based on current state.""" self._update_default_button() try: welcome_widget = self.query_one("#welcome-text", Static) welcome_widget.update(self._create_welcome_text()) welcome_container = self.query_one("#welcome-container") # Remove existing button rows before mounting updated row for button_row in list(welcome_container.query(".button-row")): await button_row.remove() await welcome_container.mount(self._create_dynamic_buttons()) except Exception: # Fallback - just refresh the whole screen self.refresh(layout=True) self.call_after_refresh(self._focus_appropriate_button) async def _start_all_services(self) -> None: """Start all services: containers first, then native services.""" # Check for port conflicts before attempting to start anything conflicts = [] # Check container ports only if services are not already running if self.container_manager.is_available() and not self.services_running: ports_available, port_conflicts = await self.container_manager.check_ports_available() if not ports_available: for service_name, port, error_msg in port_conflicts[:3]: # Show first 3 conflicts.append(f"{service_name} (port {port})") if len(port_conflicts) > 3: conflicts.append(f"and {len(port_conflicts) - 3} more") # Check native service port only if it's not already running if not self.docling_manager.is_running(): port_available, error_msg = self.docling_manager.check_port_available() if not port_available: conflicts.append(f"docling (port {self.docling_manager._port})") # If there are any conflicts, show error and return if conflicts: conflict_str = ", ".join(conflicts) self.notify( f"Cannot start services: Port conflicts detected for {conflict_str}. " f"Please stop the conflicting services first.", severity="error", timeout=10 ) return # Step 1: Start container services first (to create the network) if self.container_manager.is_available() and not self.services_running: # Check for version mismatch before starting has_mismatch, container_version, tui_version = await self.container_manager.check_version_mismatch() if has_mismatch and container_version: # Show warning modal and wait for user decision should_continue = await self.app.push_screen_wait( VersionMismatchWarningModal(container_version, tui_version) ) if not should_continue: self.notify("Start cancelled", severity="information") return # Ensure OPENRAG_VERSION is set in .env BEFORE starting services # This ensures docker compose reads the correct version try: from ..managers.env_manager import EnvManager env_manager = EnvManager() env_manager.ensure_openrag_version() # Small delay to ensure .env file is written and flushed import asyncio await asyncio.sleep(0.5) except Exception: pass # Continue even if version setting fails command_generator = self.container_manager.start_services() modal = CommandOutputModal( "Starting Container Services", command_generator, on_complete=self._on_containers_started_start_native, ) self.app.push_screen(modal) elif self.services_running: # Containers already running, just start native services self.notify("Container services already running", severity="information") await self._start_native_services_after_containers() else: self.notify("No container runtime available", severity="warning") # Still try to start native services await self._start_native_services_after_containers() async def _on_containers_started_start_native(self) -> None: """Called after containers start successfully, now start native services.""" # Update container state self._detect_services_sync() # Now start native services (docling-serve can now detect the compose network) await self._start_native_services_after_containers() async def _start_native_services_after_containers(self) -> None: """Start native services after containers have been started.""" if not self.docling_manager.is_running(): # Check for port conflicts before attempting to start port_available, error_msg = self.docling_manager.check_port_available() if not port_available: self.notify( f"Cannot start native services: {error_msg}. " f"Please stop the conflicting service first.", severity="error", timeout=10 ) # Update state and return self.docling_running = False await self._refresh_welcome_content() return self.notify("Starting native services...", severity="information") success, message = await self.docling_manager.start() if success: self.notify(message, severity="information") else: self.notify(f"Failed to start native services: {message}", severity="error") else: self.notify("Native services already running", severity="information") # Update state self.docling_running = self.docling_manager.is_running() await self._refresh_welcome_content() async def _stop_all_services(self) -> None: """Stop all services: containers first, then native.""" # Step 1: Stop container services if self.container_manager.is_available() and self.services_running: command_generator = self.container_manager.stop_services() modal = CommandOutputModal( "Stopping Container Services", command_generator, on_complete=self._on_stop_containers_complete, ) self.app.push_screen(modal) else: # No containers to stop, go directly to stopping native services await self._stop_native_services_after_containers() async def _on_stop_containers_complete(self) -> None: """Called after containers are stopped, now stop native services.""" # Update container state self._detect_services_sync() # Now stop native services await self._stop_native_services_after_containers() async def _stop_native_services_after_containers(self) -> None: """Stop native services after containers have been stopped.""" if self.docling_manager.is_running(): self.notify("Stopping native services...", severity="information") success, message = await self.docling_manager.stop() if success: self.notify(message, severity="information") else: self.notify(f"Failed to stop native services: {message}", severity="error") else: self.notify("Native services already stopped", severity="information") # Update state self.docling_running = self.docling_manager.is_running() await self._refresh_welcome_content() def action_open_app(self) -> None: """Open the OpenRAG app in the default browser.""" import webbrowser try: webbrowser.open("http://localhost:3000") self.notify("Opening OpenRAG app in browser...", severity="information") except Exception as e: self.notify(f"Error opening app: {e}", severity="error") def action_quit(self) -> None: """Quit the application.""" self.app.exit()