Merge pull request #39 from langflow-ai/docling-serve-tui

tui docling serve button
This commit is contained in:
Sebastián Estévez 2025-09-16 11:43:43 -04:00 committed by GitHub
commit 8dc737c124
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2359 additions and 122 deletions

View file

@ -8,7 +8,8 @@ dependencies = [
"agentd>=0.2.2",
"aiofiles>=24.1.0",
"cryptography>=45.0.6",
"docling>=2.41.0",
"docling[vlm]>=2.41.0; sys_platform != 'darwin'",
"docling[ocrmac,vlm]>=2.41.0; sys_platform == 'darwin'",
"google-api-python-client>=2.143.0",
"google-auth-httplib2>=0.2.0",
"google-auth-oauthlib>=1.2.0",
@ -27,6 +28,7 @@ dependencies = [
"python-dotenv>=1.0.0",
"textual-fspicker>=0.6.0",
"structlog>=25.4.0",
"docling-serve>=1.4.1",
]
[project.scripts]

View file

@ -14,6 +14,7 @@ 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
@ -26,7 +27,7 @@ class OpenRAGTUI(App):
CSS = """
Screen {
background: $background;
background: #0f172a;
}
#main-container {
@ -114,7 +115,8 @@ class OpenRAGTUI(App):
}
#services-table {
height: 1fr;
height: auto;
max-height: 12;
margin-bottom: 1;
}
@ -174,6 +176,82 @@ class OpenRAGTUI(App):
height: 100%;
padding: 1;
}
/* Frontend-inspired color scheme */
Static {
color: #f1f5f9;
}
Button.success {
background: #4ade80;
color: #000;
}
Button.error {
background: #ef4444;
color: #fff;
}
Button.warning {
background: #eab308;
color: #000;
}
Button.primary {
background: #2563eb;
color: #fff;
}
Button.default {
background: #475569;
color: #f1f5f9;
border: solid #64748b;
}
DataTable {
background: #1e293b;
color: #f1f5f9;
}
DataTable > .datatable--header {
background: #334155;
color: #f1f5f9;
}
DataTable > .datatable--cursor {
background: #475569;
}
Input {
background: #334155;
color: #f1f5f9;
border: solid #64748b;
}
Label {
color: #f1f5f9;
}
Footer {
background: #334155;
color: #f1f5f9;
}
#runtime-status {
background: #1e293b;
border: solid #64748b;
color: #f1f5f9;
}
#system-info {
background: #1e293b;
border: solid #64748b;
color: #f1f5f9;
}
#services-table, #images-table {
background: #1e293b;
}
"""
def __init__(self):
@ -181,6 +259,7 @@ class OpenRAGTUI(App):
self.platform_detector = PlatformDetector()
self.container_manager = ContainerManager()
self.env_manager = EnvManager()
self.docling_manager = DoclingManager() # Initialize singleton instance
def on_mount(self) -> None:
"""Initialize the application."""
@ -201,6 +280,8 @@ class OpenRAGTUI(App):
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]:
@ -222,15 +303,19 @@ class OpenRAGTUI(App):
def run_tui():
"""Run the OpenRAG TUI application."""
app = None
try:
app = OpenRAGTUI()
app.run()
except KeyboardInterrupt:
logger.info("OpenRAG TUI interrupted by user")
sys.exit(0)
except Exception as e:
logger.error("Error running OpenRAG TUI", error=str(e))
sys.exit(1)
finally:
# Ensure cleanup happens even on exceptions
if app and hasattr(app, 'docling_manager'):
app.docling_manager.cleanup()
sys.exit(0)
if __name__ == "__main__":

View file

@ -0,0 +1,403 @@
"""Docling serve manager for local document processing service."""
import asyncio
import os
import subprocess
import sys
import threading
import time
from typing import Optional, Tuple, Dict, Any, List, AsyncIterator
from utils.logging_config import get_logger
logger = get_logger(__name__)
class DoclingManager:
"""Manages local docling serve instance as external process."""
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
# Only initialize once
if self._initialized:
return
self._process: Optional[subprocess.Popen] = None
self._port = 5001
self._host = "127.0.0.1"
self._running = False
self._external_process = False
# Log storage - simplified, no queue
self._log_buffer: List[str] = []
self._max_log_lines = 1000
self._log_lock = threading.Lock() # Thread-safe access to log buffer
self._initialized = True
def cleanup(self):
"""Cleanup resources and stop any running processes."""
if self._process and self._process.poll() is None:
self._add_log_entry("Cleaning up docling-serve process on exit")
try:
self._process.terminate()
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait()
except Exception as e:
self._add_log_entry(f"Error during cleanup: {e}")
self._running = False
self._process = None
def _add_log_entry(self, message: str) -> None:
"""Add a log entry to the buffer (thread-safe)."""
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
entry = f"[{timestamp}] {message}"
with self._log_lock:
self._log_buffer.append(entry)
# Keep buffer size limited
if len(self._log_buffer) > self._max_log_lines:
self._log_buffer = self._log_buffer[-self._max_log_lines:]
def is_running(self) -> bool:
"""Check if docling serve is running."""
# First check our internal state
internal_running = self._running and self._process is not None and self._process.poll() is None
# If we think it's not running, check if something is listening on the port
# This handles cases where docling-serve was started outside the TUI
if not internal_running:
try:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.5)
result = s.connect_ex((self._host, self._port))
s.close()
# If port is in use, something is running there
if result == 0:
# Only log this once when we first detect external process
if not self._external_process:
self._add_log_entry(f"Detected external docling-serve running on {self._host}:{self._port}")
# Set a flag to indicate this is an external process
self._external_process = True
return True
except Exception as e:
# Only log errors occasionally to avoid spam
if not hasattr(self, '_last_port_error') or self._last_port_error != str(e):
self._add_log_entry(f"Error checking port: {e}")
self._last_port_error = str(e)
else:
# If we started it, it's not external
self._external_process = False
return internal_running
def get_status(self) -> Dict[str, Any]:
"""Get current status of docling serve."""
if self.is_running():
pid = self._process.pid if self._process else None
return {
"status": "running",
"port": self._port,
"host": self._host,
"endpoint": f"http://{self._host}:{self._port}",
"docs_url": f"http://{self._host}:{self._port}/docs",
"ui_url": f"http://{self._host}:{self._port}/ui",
"pid": pid
}
else:
return {
"status": "stopped",
"port": self._port,
"host": self._host,
"endpoint": None,
"docs_url": None,
"ui_url": None,
"pid": None
}
async def start(self, port: int = 5001, host: str = "127.0.0.1", enable_ui: bool = False) -> Tuple[bool, str]:
"""Start docling serve as external process."""
if self.is_running():
return False, "Docling serve is already running"
self._port = port
self._host = host
# Clear log buffer when starting
self._log_buffer = []
self._add_log_entry("Starting docling serve as external process...")
try:
# Build command to run docling-serve
# Check if we should use uv run (look for uv in environment or check if we're in a uv project)
import shutil
if shutil.which("uv") and (os.path.exists("pyproject.toml") or os.getenv("VIRTUAL_ENV")):
cmd = [
"uv", "run", "python", "-m", "docling_serve", "run",
"--host", host,
"--port", str(port),
]
else:
cmd = [
sys.executable, "-m", "docling_serve", "run",
"--host", host,
"--port", str(port),
]
if enable_ui:
cmd.append("--enable-ui")
self._add_log_entry(f"Starting process: {' '.join(cmd)}")
# Start as subprocess
self._process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
bufsize=0 # Unbuffered for real-time output
)
self._running = True
self._add_log_entry("External process started")
# Start a thread to capture output
self._start_output_capture()
# Wait for the process to start and begin listening
self._add_log_entry("Waiting for docling-serve to start listening...")
# Wait up to 10 seconds for the service to start listening
for i in range(10):
await asyncio.sleep(1.0)
# Check if process is still alive
if self._process.poll() is not None:
break
# Check if it's listening on the port
try:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.5)
result = s.connect_ex((host, port))
s.close()
if result == 0:
self._add_log_entry(f"Docling-serve is now listening on {host}:{port}")
break
except:
pass
self._add_log_entry(f"Waiting for startup... ({i+1}/10)")
# Add a test message to verify logging is working
self._add_log_entry(f"Process PID: {self._process.pid}, Poll: {self._process.poll()}")
if self._process.poll() is not None:
# Process already exited - get return code and any output
return_code = self._process.returncode
self._add_log_entry(f"Process exited with code: {return_code}")
try:
# Try to read any remaining output
stdout_data = ""
stderr_data = ""
if self._process.stdout:
stdout_data = self._process.stdout.read()
if self._process.stderr:
stderr_data = self._process.stderr.read()
if stdout_data:
self._add_log_entry(f"Final stdout: {stdout_data[:500]}")
if stderr_data:
self._add_log_entry(f"Final stderr: {stderr_data[:500]}")
except Exception as e:
self._add_log_entry(f"Error reading final output: {e}")
self._running = False
return False, f"Docling serve process exited immediately (code: {return_code})"
return True, f"Docling serve starting on http://{host}:{port}"
except FileNotFoundError:
return False, "docling-serve not available. Please install: uv add docling-serve"
except Exception as e:
self._running = False
self._process = None
return False, f"Error starting docling serve: {str(e)}"
def _start_output_capture(self):
"""Start threads to capture subprocess stdout and stderr."""
def capture_stdout():
if not self._process or not self._process.stdout:
self._add_log_entry("No stdout pipe available")
return
self._add_log_entry("Starting stdout capture thread")
try:
while self._running and self._process and self._process.poll() is None:
line = self._process.stdout.readline()
if line:
self._add_log_entry(f"STDOUT: {line.rstrip()}")
else:
# No more output, wait a bit
time.sleep(0.1)
except Exception as e:
self._add_log_entry(f"Error capturing stdout: {e}")
finally:
self._add_log_entry("Stdout capture thread ended")
def capture_stderr():
if not self._process or not self._process.stderr:
self._add_log_entry("No stderr pipe available")
return
self._add_log_entry("Starting stderr capture thread")
try:
while self._running and self._process and self._process.poll() is None:
line = self._process.stderr.readline()
if line:
self._add_log_entry(f"STDERR: {line.rstrip()}")
else:
# No more output, wait a bit
time.sleep(0.1)
except Exception as e:
self._add_log_entry(f"Error capturing stderr: {e}")
finally:
self._add_log_entry("Stderr capture thread ended")
# Start both capture threads
stdout_thread = threading.Thread(target=capture_stdout, daemon=True)
stderr_thread = threading.Thread(target=capture_stderr, daemon=True)
stdout_thread.start()
stderr_thread.start()
self._add_log_entry("Output capture threads started")
async def stop(self) -> Tuple[bool, str]:
"""Stop docling serve."""
if not self.is_running():
return False, "Docling serve is not running"
try:
self._add_log_entry("Stopping docling-serve process")
if self._process:
# We started this process, so we can stop it directly
self._add_log_entry(f"Terminating our process (PID: {self._process.pid})")
self._process.terminate()
# Wait for it to stop
try:
self._process.wait(timeout=10)
self._add_log_entry("Process terminated gracefully")
except subprocess.TimeoutExpired:
# Force kill if it doesn't stop gracefully
self._add_log_entry("Process didn't stop gracefully, force killing")
self._process.kill()
self._process.wait()
self._add_log_entry("Process force killed")
elif self._external_process:
# This is an external process, we can't stop it directly
self._add_log_entry("Cannot stop external docling-serve process - it was started outside the TUI")
self._running = False
self._external_process = False
return False, "Cannot stop external docling-serve process. Please stop it manually."
self._running = False
self._process = None
self._external_process = False
self._add_log_entry("Docling serve stopped successfully")
return True, "Docling serve stopped successfully"
except Exception as e:
self._add_log_entry(f"Error stopping docling serve: {e}")
return False, f"Error stopping docling serve: {str(e)}"
async def restart(self, port: Optional[int] = None, host: Optional[str] = None, enable_ui: bool = False) -> Tuple[bool, str]:
"""Restart docling serve."""
# Use current settings if not specified
if port is None:
port = self._port
if host is None:
host = self._host
# Stop if running
if self.is_running():
success, msg = await self.stop()
if not success:
return False, f"Failed to stop: {msg}"
# Wait a moment for cleanup
await asyncio.sleep(1)
# Start with new settings
return await self.start(port, host, enable_ui)
def add_manual_log_entry(self, message: str) -> None:
"""Add a manual log entry - useful for debugging."""
self._add_log_entry(f"MANUAL: {message}")
def get_logs(self, lines: int = 50) -> Tuple[bool, str]:
"""Get logs from the docling-serve process."""
if self.is_running():
with self._log_lock:
# If we have no logs but the service is running, it might have been started externally
if not self._log_buffer:
return True, "No logs available yet..."
# Return the most recent logs
log_count = min(lines, len(self._log_buffer))
logs = "\n".join(self._log_buffer[-log_count:])
return True, logs
else:
return True, "Docling serve is not running."
async def follow_logs(self) -> AsyncIterator[str]:
"""Follow logs from the docling-serve process in real-time."""
# First yield status message and any existing logs
status_msg = f"Docling serve is running on http://{self._host}:{self._port}"
with self._log_lock:
if self._log_buffer:
yield "\n".join(self._log_buffer)
last_log_index = len(self._log_buffer)
else:
yield "Waiting for logs..."
last_log_index = 0
# Then start monitoring for new logs
while self.is_running():
with self._log_lock:
# Check if we have new logs
if len(self._log_buffer) > last_log_index:
# Yield only the new logs
new_logs = self._log_buffer[last_log_index:]
yield "\n".join(new_logs)
last_log_index = len(self._log_buffer)
# Wait a bit before checking again
await asyncio.sleep(0.1)
# Final check for any logs that came in during shutdown
with self._log_lock:
if len(self._log_buffer) > last_log_index:
yield "\n".join(self._log_buffer[last_log_index:])

View file

@ -9,6 +9,7 @@ from textual.timer import Timer
from rich.text import Text
from ..managers.container_manager import ContainerManager
from ..managers.docling_manager import DoclingManager
class LogsScreen(Screen):
@ -31,6 +32,7 @@ class LogsScreen(Screen):
def __init__(self, initial_service: str = "openrag-backend"):
super().__init__()
self.container_manager = ContainerManager()
self.docling_manager = DoclingManager()
# Validate the initial service against available options
valid_services = [
@ -39,6 +41,7 @@ class LogsScreen(Screen):
"opensearch",
"langflow",
"dashboards",
"docling-serve", # Add docling-serve as a valid service
]
if initial_service not in valid_services:
initial_service = "openrag-backend" # fallback
@ -92,6 +95,10 @@ class LogsScreen(Screen):
await self._load_logs()
# Start following logs by default
if not self.following:
self.action_follow()
# Focus the logs area since there are no buttons
try:
self.logs_area.focus()
@ -104,6 +111,19 @@ class LogsScreen(Screen):
async def _load_logs(self, lines: int = 200) -> None:
"""Load recent logs for the current service."""
# Special handling for docling-serve
if self.current_service == "docling-serve":
success, logs = self.docling_manager.get_logs(lines)
if success:
self.logs_area.text = logs
# Scroll to bottom if auto scroll is enabled
if self.auto_scroll:
self.logs_area.scroll_end()
else:
self.logs_area.text = f"Failed to load logs: {logs}"
return
# Regular container services
if not self.container_manager.is_available():
self.logs_area.text = "No container runtime available"
return
@ -130,6 +150,37 @@ class LogsScreen(Screen):
async def _follow_logs(self) -> None:
"""Follow logs in real-time."""
# Special handling for docling-serve
if self.current_service == "docling-serve":
try:
async for log_lines in self.docling_manager.follow_logs():
if not self.following:
break
# Update logs area with new content
current_text = self.logs_area.text
new_text = current_text + "\n" + log_lines if current_text else log_lines
# Keep only last 1000 lines to prevent memory issues
lines = new_text.split("\n")
if len(lines) > 1000:
lines = lines[-1000:]
new_text = "\n".join(lines)
self.logs_area.text = new_text
# Scroll to bottom if auto scroll is enabled
if self.auto_scroll:
self.logs_area.scroll_end()
except asyncio.CancelledError:
pass
except Exception as e:
if self.following: # Only show error if we're still supposed to be following
self.notify(f"Error following docling logs: {e}", severity="error")
finally:
self.following = False
return
# Regular container services
if not self.container_manager.is_available():
return

View file

@ -16,6 +16,7 @@ from rich.text import Text
from rich.table import Table
from ..managers.container_manager import ContainerManager, ServiceStatus, ServiceInfo
from ..managers.docling_manager import DoclingManager
from ..utils.platform import RuntimeType
from ..widgets.command_modal import CommandOutputModal
from ..widgets.diagnostics_notification import notify_with_diagnostics
@ -39,12 +40,23 @@ class MonitorScreen(Screen):
def __init__(self):
super().__init__()
self.container_manager = ContainerManager()
self.docling_manager = DoclingManager()
self.services_table = None
self.docling_table = None
self.images_table = None
self.status_text = None
self.refresh_timer = None
self.operation_in_progress = False
self._follow_task = None
# Track which table was last selected for mutual exclusion
self._last_selected_table = None
def on_unmount(self) -> None:
"""Clean up when the screen is unmounted."""
if hasattr(self, 'docling_manager'):
self.docling_manager.cleanup()
super().on_unmount()
self._follow_service = None
self._logs_buffer = []
@ -64,13 +76,17 @@ class MonitorScreen(Screen):
classes="button-row",
id="mode-row",
)
# Images summary table (above services)
# Container Images table
yield Static("Container Images", classes="tab-header")
self.images_table = DataTable(id="images-table", show_cursor=False)
self.images_table.can_focus = False
self.images_table.add_columns("Image", "Digest")
yield self.images_table
yield Static(" ")
# Container Services table
yield Static("Container Services", classes="tab-header")
# Dynamic controls container; populated based on running state
yield Horizontal(id="services-controls", classes="button-row")
# Create services table with image + digest info
@ -79,6 +95,16 @@ class MonitorScreen(Screen):
"Service", "Status", "Health", "Ports", "Image", "Digest"
)
yield self.services_table
yield Static(" ")
# Docling Services table
yield Static("Native Services", classes="tab-header")
# Dynamic controls for docling service
yield Horizontal(id="docling-controls", classes="button-row")
# Create docling table with relevant columns only
self.docling_table = DataTable(id="docling-table")
self.docling_table.add_columns("Service", "Status", "Port", "PID", "Actions")
yield self.docling_table
def _get_runtime_status(self) -> Text:
"""Get container runtime status text."""
@ -164,10 +190,12 @@ class MonitorScreen(Screen):
# Clear existing rows
self.services_table.clear()
if self.docling_table:
self.docling_table.clear()
if self.images_table:
self.images_table.clear()
# Add service rows
# Add container service rows
for service_name, service_info in services.items():
status_style = self._get_status_style(service_info.status)
@ -179,6 +207,23 @@ class MonitorScreen(Screen):
service_info.image or "N/A",
digest_map.get(service_info.image or "", "-"),
)
# Add docling serve to its own table
docling_status = self.docling_manager.get_status()
docling_running = docling_status["status"] == "running"
docling_status_text = "running" if docling_running else "stopped"
docling_style = "bold green" if docling_running else "bold red"
docling_port = f"{docling_status['host']}:{docling_status['port']}" if docling_running else "N/A"
docling_pid = str(docling_status.get("pid")) if docling_status.get("pid") else "N/A"
if self.docling_table:
self.docling_table.add_row(
"docling-serve",
Text(docling_status_text, style=docling_style),
docling_port,
docling_pid,
"Start/Stop/Logs"
)
# Populate images table (unique images as reported by runtime)
if self.images_table:
for image in sorted(images):
@ -222,6 +267,12 @@ class MonitorScreen(Screen):
self.run_worker(self._upgrade_services())
elif button_id.startswith("reset-btn"):
self.run_worker(self._reset_services())
elif button_id.startswith("docling-start-btn"):
self.run_worker(self._start_docling_serve())
elif button_id.startswith("docling-stop-btn"):
self.run_worker(self._stop_docling_serve())
elif button_id.startswith("docling-restart-btn"):
self.run_worker(self._restart_docling_serve())
elif button_id == "toggle-mode-btn":
self.action_toggle_mode()
elif button_id.startswith("refresh-btn"):
@ -321,6 +372,59 @@ class MonitorScreen(Screen):
finally:
self.operation_in_progress = False
async def _start_docling_serve(self) -> None:
"""Start docling serve."""
self.operation_in_progress = True
try:
success, message = await self.docling_manager.start()
if success:
self.notify(message, severity="information")
else:
self.notify(f"Failed to start docling serve: {message}", severity="error")
# Refresh the services table to show updated status
await self._refresh_services()
except Exception as e:
self.notify(f"Error starting docling serve: {str(e)}", severity="error")
finally:
self.operation_in_progress = False
async def _stop_docling_serve(self) -> None:
"""Stop docling serve."""
self.operation_in_progress = True
try:
success, message = await self.docling_manager.stop()
if success:
self.notify(message, severity="information")
else:
self.notify(f"Failed to stop docling serve: {message}", severity="error")
# Refresh the services table to show updated status
await self._refresh_services()
except Exception as e:
self.notify(f"Error stopping docling serve: {str(e)}", severity="error")
finally:
self.operation_in_progress = False
async def _restart_docling_serve(self) -> None:
"""Restart docling serve."""
self.operation_in_progress = True
try:
success, message = await self.docling_manager.restart()
if success:
self.notify(message, severity="information")
else:
self.notify(f"Failed to restart docling serve: {message}", severity="error")
# Refresh the services table to show updated status
await self._refresh_services()
except Exception as e:
self.notify(f"Error restarting docling serve: {str(e)}", severity="error")
finally:
self.operation_in_progress = False
def _view_docling_logs(self) -> None:
"""View docling serve logs."""
from .logs import LogsScreen
self.app.push_screen(LogsScreen(initial_service="docling-serve"))
def _strip_ansi_codes(self, text: str) -> str:
"""Strip ANSI escape sequences from text."""
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
@ -484,12 +588,47 @@ class MonitorScreen(Screen):
Button("Upgrade", variant="warning", id=f"upgrade-btn{suffix}")
)
controls.mount(Button("Reset", variant="error", id=f"reset-btn{suffix}"))
except Exception as e:
notify_with_diagnostics(
self.app, f"Error updating controls: {e}", severity="error"
)
# Update docling controls separately
self._update_docling_controls()
def _update_docling_controls(self) -> None:
"""Update docling control buttons."""
try:
# Get the docling controls container
docling_controls = self.query_one("#docling-controls", Horizontal)
# Clear existing buttons
docling_controls.remove_children()
# Use a random suffix for unique IDs
import random
suffix = f"-{random.randint(10000, 99999)}"
# Add docling serve controls
docling_running = self.docling_manager.is_running()
if docling_running:
docling_controls.mount(
Button("Stop", variant="error", id=f"docling-stop-btn{suffix}")
)
docling_controls.mount(
Button("Restart", variant="primary", id=f"docling-restart-btn{suffix}")
)
else:
docling_controls.mount(
Button("Start", variant="success", id=f"docling-start-btn{suffix}")
)
except Exception as e:
notify_with_diagnostics(
self.app, f"Error updating docling controls: {e}", severity="error"
)
def action_back(self) -> None:
"""Go back to previous screen."""
self.app.pop_screen()
@ -513,16 +652,60 @@ class MonitorScreen(Screen):
def action_logs(self) -> None:
"""View logs for the selected service."""
try:
# Get the currently focused row in the services table
table = self.query_one("#services-table", DataTable)
selected_service = self._get_selected_service()
if selected_service:
# Push the logs screen with the selected service
from .logs import LogsScreen
logs_screen = LogsScreen(initial_service=selected_service)
self.app.push_screen(logs_screen)
else:
self.notify("No service selected", severity="warning")
except Exception as e:
self.notify(f"Error opening logs: {e}", severity="error")
if table.cursor_row is not None and table.cursor_row >= 0:
# Get the service name from the first column of the selected row
row_data = table.get_row_at(table.cursor_row)
def _get_selected_service(self) -> str | None:
"""Get the currently selected service from either table."""
try:
# Check both tables regardless of last_selected_table to handle cursor navigation
services_table = self.query_one("#services-table", DataTable)
services_cursor = services_table.cursor_row
docling_cursor = None
if self.docling_table:
docling_cursor = self.docling_table.cursor_row
# If we have a last selected table preference, use it if that table has a valid selection
if self._last_selected_table == "docling" and self.docling_table:
if docling_cursor is not None and docling_cursor >= 0:
row_data = self.docling_table.get_row_at(docling_cursor)
if row_data:
return "docling-serve"
elif self._last_selected_table == "services":
if services_cursor is not None and services_cursor >= 0:
row_data = services_table.get_row_at(services_cursor)
if row_data:
service_name = str(row_data[0])
service_mapping = {
"openrag-backend": "openrag-backend",
"openrag-frontend": "openrag-frontend",
"opensearch": "opensearch",
"langflow": "langflow",
"dashboards": "dashboards",
}
selected_service = service_mapping.get(service_name, service_name)
return selected_service
# Fallback: check both tables if no last_selected_table or it doesn't have a selection
if self.docling_table and docling_cursor is not None and docling_cursor >= 0:
row_data = self.docling_table.get_row_at(docling_cursor)
if row_data:
service_name = str(row_data[0]) # First column is service name
return "docling-serve"
# Map display names to actual service names
if services_cursor is not None and services_cursor >= 0:
row_data = services_table.get_row_at(services_cursor)
if row_data:
service_name = str(row_data[0])
service_mapping = {
"openrag-backend": "openrag-backend",
"openrag-frontend": "openrag-frontend",
@ -530,19 +713,30 @@ class MonitorScreen(Screen):
"langflow": "langflow",
"dashboards": "dashboards",
}
selected_service = service_mapping.get(service_name, service_name)
return selected_service
actual_service_name = service_mapping.get(
service_name, service_name
)
# Push the logs screen with the selected service
from .logs import LogsScreen
logs_screen = LogsScreen(initial_service=actual_service_name)
self.app.push_screen(logs_screen)
else:
self.notify("No service selected", severity="warning")
else:
self.notify("No service selected", severity="warning")
return None
except Exception as e:
self.notify(f"Error opening logs: {e}", severity="error")
self.notify(f"Error getting selected service: {e}", severity="error")
return None
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection events to ensure mutual exclusivity."""
selected_table = event.data_table
try:
# Track which table was selected
if selected_table.id == "services-table":
self._last_selected_table = "services"
# Clear docling table selection
if self.docling_table:
self.docling_table.cursor_row = -1
elif selected_table.id == "docling-table":
self._last_selected_table = "docling"
# Clear services table selection
services_table = self.query_one("#services-table", DataTable)
services_table.cursor_row = -1
except Exception:
# Ignore errors during table manipulation
pass

View file

@ -12,6 +12,8 @@ from dotenv import load_dotenv
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
class WelcomeScreen(Screen):
@ -22,15 +24,19 @@ class WelcomeScreen(Screen):
("enter", "default_action", "Continue"),
("1", "no_auth_setup", "Basic Setup"),
("2", "full_setup", "Advanced Setup"),
("3", "monitor", "Monitor Services"),
("3", "monitor", "Status"),
("4", "diagnostics", "Diagnostics"),
("5", "start_stop_services", "Start/Stop Services"),
("6", "open_app", "Open App"),
]
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
@ -38,8 +44,16 @@ class WelcomeScreen(Screen):
# Load .env file if it exists
load_dotenv()
# Check OAuth config immediately
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
)
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"),
@ -50,6 +64,46 @@ class WelcomeScreen(Screen):
)
yield Footer()
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 synchronous docker command to check services
import subprocess
result = subprocess.run(
["docker", "compose", "ps", "--format", "json"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
import json
services = []
for line in result.stdout.strip().split('\n'):
if line.strip():
try:
service = json.loads(line)
services.append(service)
except json.JSONDecodeError:
continue
# Check if any services are running
running_services = [s for s in services if s.get('State') == 'running']
self.services_running = len(running_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()
@ -61,7 +115,7 @@ class WelcomeScreen(Screen):
"""
welcome_text.append(ascii_art, style="bold blue")
welcome_text.append(ascii_art, style="bold white")
welcome_text.append("Terminal User Interface for OpenRAG\n\n", style="dim")
if self.services_running:
@ -87,28 +141,56 @@ class WelcomeScreen(Screen):
buttons = []
if self.services_running:
# Services running - only show monitor
# Services running - show app link first, then stop services
buttons.append(
Button("Monitor Services", variant="success", id="monitor-btn")
Button("Launch OpenRAG", variant="success", id="open-app-btn")
)
buttons.append(
Button("Stop Container Services", variant="error", id="stop-services-btn")
)
else:
# Services not running - show setup options
# Services not running - show setup options and start services
if has_oauth:
# Only show advanced setup if OAuth is configured
# If OAuth is configured, only show advanced setup
buttons.append(
Button("Advanced Setup", variant="success", id="advanced-setup-btn")
)
else:
# Only show basic setup if no OAuth
# 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")
)
# Always show monitor option
buttons.append(
Button("Monitor Services", variant="default", id="monitor-btn")
Button("Start Container Services", variant="primary", id="start-services-btn")
)
# Native services controls
if self.docling_running:
buttons.append(
Button(
"Stop Native Services",
variant="warning",
id="stop-native-services-btn",
)
)
else:
buttons.append(
Button(
"Start Native Services",
variant="primary",
id="start-native-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:
@ -121,6 +203,10 @@ class WelcomeScreen(Screen):
]
self.services_running = len(running_services) > 0
# 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")
@ -128,38 +214,34 @@ class WelcomeScreen(Screen):
# Set default button focus
if self.services_running:
self.default_button_id = "monitor-btn"
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 and recompose with new state
# Update the welcome text
try:
welcome_widget = self.query_one("#welcome-text")
welcome_widget.update(
self._create_welcome_text()
) # This is fine for Static widgets
# Focus the appropriate button
if self.services_running:
try:
self.query_one("#monitor-btn").focus()
except:
pass
elif self.has_oauth_config:
try:
self.query_one("#advanced-setup-btn").focus()
except:
pass
else:
try:
self.query_one("#basic-setup-btn").focus()
except:
pass
welcome_widget.update(self._create_welcome_text())
except:
pass # Widgets might not be mounted yet
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:
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
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
@ -167,15 +249,25 @@ class WelcomeScreen(Screen):
self.action_no_auth_setup()
elif event.button.id == "advanced-setup-btn":
self.action_full_setup()
elif event.button.id == "monitor-btn":
elif event.button.id == "status-btn":
self.action_monitor()
elif event.button.id == "diagnostics-btn":
self.action_diagnostics()
elif event.button.id == "start-services-btn":
self.action_start_stop_services()
elif event.button.id == "stop-services-btn":
self.action_start_stop_services()
elif event.button.id == "start-native-services-btn":
self.action_start_native_services()
elif event.button.id == "stop-native-services-btn":
self.action_stop_native_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:
self.action_monitor()
self.action_open_app()
elif self.has_oauth_config:
self.action_full_setup()
else:
@ -205,6 +297,126 @@ class WelcomeScreen(Screen):
self.app.push_screen(DiagnosticsScreen())
def action_start_stop_services(self) -> None:
"""Start or stop all services (containers + docling)."""
if self.services_running:
# Stop services - show modal with progress
if self.container_manager.is_available():
command_generator = self.container_manager.stop_services()
modal = CommandOutputModal(
"Stopping Services",
command_generator,
on_complete=self._on_services_operation_complete,
)
self.app.push_screen(modal)
else:
# Start services - show modal with progress
if self.container_manager.is_available():
command_generator = self.container_manager.start_services()
modal = CommandOutputModal(
"Starting Services",
command_generator,
on_complete=self._on_services_operation_complete,
)
self.app.push_screen(modal)
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:
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)
def action_start_native_services(self) -> None:
"""Start native services (docling)."""
if self.docling_running:
self.notify("Native services are already running.", severity="warning")
return
self.run_worker(self._start_native_services())
async def _start_native_services(self) -> None:
"""Worker task to start native services."""
try:
success, message = await self.docling_manager.start()
if success:
self.docling_running = True
self.notify(message, severity="information")
else:
self.notify(f"Failed to start native services: {message}", severity="error")
except Exception as exc:
self.notify(f"Error starting native services: {exc}", severity="error")
finally:
self.docling_running = self.docling_manager.is_running()
await self._refresh_welcome_content()
def action_stop_native_services(self) -> None:
"""Stop native services (docling)."""
if not self.docling_running and not self.docling_manager.is_running():
self.notify("Native services are not running.", severity="warning")
return
self.run_worker(self._stop_native_services())
async def _stop_native_services(self) -> None:
"""Worker task to stop native services."""
try:
success, message = await self.docling_manager.stop()
if success:
self.docling_running = False
self.notify(message, severity="information")
else:
self.notify(f"Failed to stop native services: {message}", severity="error")
except Exception as exc:
self.notify(f"Error stopping native services: {exc}", severity="error")
finally:
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()

View file

@ -1,6 +1,7 @@
"""Command output modal dialog for OpenRAG TUI."""
import asyncio
import inspect
from typing import Callable, List, Optional, AsyncIterator, Any
from textual.app import ComposeResult
@ -122,7 +123,13 @@ class CommandOutputModal(ModalScreen):
# Call the completion callback if provided
if self.on_complete:
await asyncio.sleep(0.5) # Small delay for better UX
self.on_complete()
def _invoke_callback() -> None:
callback_result = self.on_complete()
if inspect.isawaitable(callback_result):
asyncio.create_task(callback_result)
self.call_after_refresh(_invoke_callback)
except Exception as e:
output.write(f"[bold red]Error: {e}[/bold red]\n")

1397
uv.lock generated

File diff suppressed because it is too large Load diff