openrag/src/tui/screens/welcome.py
2025-12-12 15:13:11 -05:00

588 lines
25 KiB
Python

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