Merge pull request #39 from langflow-ai/docling-serve-tui
tui docling serve button
This commit is contained in:
commit
8dc737c124
8 changed files with 2359 additions and 122 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
403
src/tui/managers/docling_manager.py
Normal file
403
src/tui/managers/docling_manager.py
Normal 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:])
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue