Merge pull request #234 from langflow-ai/tui-improvements

TUI improvements
This commit is contained in:
Sebastián Estévez 2025-10-08 23:27:58 -04:00 committed by GitHub
commit cb361dffa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 855 additions and 268 deletions

View file

@ -50,6 +50,7 @@ services:
- OPENSEARCH_HOST=opensearch
- LANGFLOW_URL=http://langflow:7860
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
- LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}

View file

@ -50,6 +50,7 @@ services:
- OPENSEARCH_HOST=opensearch
- LANGFLOW_URL=http://langflow:7860
- LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
- LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
- LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
- LANGFLOW_CHAT_FLOW_ID=${LANGFLOW_CHAT_FLOW_ID}

View file

@ -69,7 +69,7 @@ function DoclingSetupDialog({
</div>
<DialogDescription>
Then, select <span className="font-semibold text-foreground">Start Native Services</span> in the TUI. Once docling-serve is running, refresh OpenRAG.
Then, select <span className="font-semibold text-foreground">Start All Services</span> in the TUI. Once docling-serve is running, refresh OpenRAG.
</DialogDescription>
</div>

View file

@ -43,6 +43,7 @@ if _legacy_flow_id and not os.getenv("LANGFLOW_CHAT_FLOW_ID"):
# Langflow superuser credentials for API key generation
LANGFLOW_AUTO_LOGIN = os.getenv("LANGFLOW_AUTO_LOGIN", "False").lower() in ("true", "1", "yes")
LANGFLOW_SUPERUSER = os.getenv("LANGFLOW_SUPERUSER")
LANGFLOW_SUPERUSER_PASSWORD = os.getenv("LANGFLOW_SUPERUSER_PASSWORD")
# Allow explicit key via environment; generation will be skipped if set
@ -179,7 +180,16 @@ async def generate_langflow_api_key(modify: bool = False):
)
LANGFLOW_KEY = None # Clear invalid key
if not LANGFLOW_SUPERUSER or not LANGFLOW_SUPERUSER_PASSWORD:
# Use default langflow/langflow credentials if auto-login is enabled and credentials not set
username = LANGFLOW_SUPERUSER
password = LANGFLOW_SUPERUSER_PASSWORD
if LANGFLOW_AUTO_LOGIN and (not username or not password):
logger.info("LANGFLOW_AUTO_LOGIN is enabled, using default langflow/langflow credentials")
username = username or "langflow"
password = password or "langflow"
if not username or not password:
logger.warning(
"LANGFLOW_SUPERUSER and LANGFLOW_SUPERUSER_PASSWORD not set, skipping API key generation"
)
@ -197,8 +207,8 @@ async def generate_langflow_api_key(modify: bool = False):
f"{LANGFLOW_URL}/api/v1/login",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"username": LANGFLOW_SUPERUSER,
"password": LANGFLOW_SUPERUSER_PASSWORD,
"username": username,
"password": password,
},
timeout=10,
)

View file

@ -32,34 +32,35 @@ class OpenRAGTUI(App):
CSS = """
Screen {
background: #0f172a;
background: #27272a;
}
#main-container {
height: 100%;
padding: 1;
}
#welcome-container {
align: center middle;
width: 100%;
height: 100%;
}
#welcome-text {
text-align: center;
margin-bottom: 2;
}
.button-row {
align: center middle;
height: auto;
margin: 1 0;
}
.button-row Button {
margin: 0 1;
min-width: 20;
border: solid #3f3f46;
}
#config-header {
@ -106,6 +107,41 @@ class OpenRAGTUI(App):
margin: 0 0 1 1;
}
/* Password field label rows */
#config-form Horizontal {
height: auto;
align: left middle;
margin-bottom: 0;
}
#config-form Horizontal Label {
width: auto;
margin-right: 1;
}
/* Password input rows */
#opensearch-password-row,
#langflow-password-row {
width: 100%;
height: auto;
align: left middle;
}
#opensearch-password-row Input,
#langflow-password-row Input {
width: 1fr;
}
/* Password toggle buttons */
#toggle-opensearch-password,
#toggle-langflow-password {
min-width: 8;
width: 8;
height: 3;
padding: 0 1;
margin-left: 1;
}
/* Docs path actions row */
#services-content {
@ -182,80 +218,144 @@ class OpenRAGTUI(App):
padding: 1;
}
/* Frontend-inspired color scheme */
/* Modern dark theme with pink accents */
Static {
color: #f1f5f9;
color: #fafafa;
}
Button.success {
background: #4ade80;
color: #000;
Button,
Button.-default,
Button.-primary,
Button.-success,
Button.-warning,
Button.-error {
background: #27272a !important;
color: #fafafa !important;
border: round #52525b !important;
text-style: none !important;
tint: transparent 0% !important;
}
Button.error {
background: #ef4444;
color: #fff;
Button > *,
Button.-default > *,
Button.-primary > *,
Button.-success > *,
Button.-warning > *,
Button.-error > * {
background: transparent !important;
color: #fafafa !important;
text-style: none !important;
}
Button.warning {
background: #eab308;
color: #000;
Button:hover,
Button.-default:hover,
Button.-primary:hover,
Button.-success:hover,
Button.-warning:hover,
Button.-error:hover {
background: #27272a !important;
color: #fafafa !important;
border: round #52525b !important;
}
Button.primary {
background: #2563eb;
color: #fff;
}
Button.default {
background: #475569;
color: #f1f5f9;
border: solid #64748b;
Button:focus,
Button:focus-within,
Button.-active,
Button.-default:focus,
Button.-default:focus-within,
Button.-default.-active,
Button.-primary:focus,
Button.-primary:focus-within,
Button.-primary.-active,
Button.-success:focus,
Button.-success:focus-within,
Button.-success.-active,
Button.-warning:focus,
Button.-warning:focus-within,
Button.-warning.-active,
Button.-error:focus,
Button.-error:focus-within,
Button.-error.-active {
background: #27272a !important;
color: #fafafa !important;
border: round #ec4899 !important;
}
DataTable {
background: #1e293b;
color: #f1f5f9;
background: #27272a;
color: #fafafa;
}
DataTable > .datatable--header {
background: #334155;
color: #f1f5f9;
background: #3f3f46;
color: #fafafa;
}
DataTable > .datatable--cursor {
background: #475569;
background: #52525b;
}
Input {
background: #334155;
color: #f1f5f9;
border: solid #64748b;
background: #18181b;
color: #fafafa;
border: solid #3f3f46;
}
Input:focus {
border: solid #ec4899;
}
Label {
color: #f1f5f9;
color: #fafafa;
}
Checkbox {
background: transparent;
color: #fafafa;
border: none;
padding: 0;
margin-left: 2;
}
Checkbox > Static {
background: transparent;
color: #fafafa;
}
Header {
background: #27272a;
color: #fafafa;
}
Footer {
background: #334155;
color: #f1f5f9;
background: #27272a;
color: #a1a1aa;
}
#runtime-status {
background: #1e293b;
border: solid #64748b;
color: #f1f5f9;
background: #27272a;
border: solid #3f3f46;
color: #fafafa;
}
#system-info {
background: #1e293b;
border: solid #64748b;
color: #f1f5f9;
background: #27272a;
border: solid #3f3f46;
color: #fafafa;
}
#services-table, #images-table {
background: #1e293b;
background: #27272a;
}
* {
scrollbar-background: #27272a;
scrollbar-background-hover: #3f3f46;
scrollbar-background-active: #3f3f46;
scrollbar-color: #52525b;
scrollbar-color-hover: #71717a;
scrollbar-color-active: #71717a;
scrollbar-corner-color: #27272a;
}
"""

View file

@ -164,10 +164,15 @@ class ContainerManager:
async def _run_compose_command_streaming(
self, args: List[str], cpu_mode: Optional[bool] = None
) -> AsyncIterator[str]:
"""Run a compose command and yield output lines in real-time."""
) -> AsyncIterator[tuple[str, bool]]:
"""Run a compose command and yield output with progress bar support.
Yields:
Tuples of (message, replace_last) where replace_last indicates if the
message should replace the previous line (for progress updates)
"""
if not self.is_available():
yield "No container runtime available"
yield ("No container runtime available", False)
return
if cpu_mode is None:
@ -179,37 +184,58 @@ class ContainerManager:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, # Combine stderr with stdout for unified output
stderr=asyncio.subprocess.STDOUT,
cwd=Path.cwd(),
)
# Simple approach: read line by line and yield each one
if process.stdout:
buffer = ""
while True:
line = await process.stdout.readline()
if not line:
chunk = await process.stdout.read(1024)
if not chunk:
if buffer.strip():
yield (buffer.strip(), False)
break
line_text = line.decode(errors="ignore").rstrip()
if line_text:
yield line_text
buffer += chunk.decode(errors="ignore")
while "\n" in buffer or "\r" in buffer:
cr_pos = buffer.find("\r")
nl_pos = buffer.find("\n")
if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos):
line = buffer[:cr_pos]
buffer = buffer[cr_pos + 1:]
if line.strip():
yield (line.strip(), True)
elif nl_pos != -1:
line = buffer[:nl_pos]
buffer = buffer[nl_pos + 1:]
if line.strip():
yield (line.strip(), False)
else:
break
# Wait for process to complete
await process.wait()
except Exception as e:
yield f"Command execution failed: {e}"
yield (f"Command execution failed: {e}", False)
async def _stream_compose_command(
self,
args: List[str],
success_flag: Dict[str, bool],
cpu_mode: Optional[bool] = None,
) -> AsyncIterator[str]:
"""Run compose command with live output and record success/failure."""
) -> AsyncIterator[tuple[str, bool]]:
"""Run compose command with live output and record success/failure.
Yields:
Tuples of (message, replace_last) where replace_last indicates if the
message should replace the previous line (for progress updates)
"""
if not self.is_available():
success_flag["value"] = False
yield "No container runtime available"
yield ("No container runtime available", False)
return
if cpu_mode is None:
@ -226,32 +252,52 @@ class ContainerManager:
)
except Exception as e:
success_flag["value"] = False
yield f"Command execution failed: {e}"
yield (f"Command execution failed: {e}", False)
return
success_flag["value"] = True
if process.stdout:
# Buffer to accumulate data for progress bar handling
buffer = ""
while True:
line = await process.stdout.readline()
if not line:
chunk = await process.stdout.read(1024)
if not chunk:
# Process any remaining buffer content
if buffer.strip():
yield (buffer.strip(), False)
break
line_text = line.decode(errors="ignore")
# Compose often uses carriage returns for progress bars; normalise them
for chunk in line_text.replace("\r", "\n").split("\n"):
chunk = chunk.strip()
if not chunk:
continue
yield chunk
lowered = chunk.lower()
if "error" in lowered or "failed" in lowered:
success_flag["value"] = False
buffer += chunk.decode(errors="ignore")
# Process complete lines or carriage return updates
while "\n" in buffer or "\r" in buffer:
# Check if we have a carriage return (progress update) before newline
cr_pos = buffer.find("\r")
nl_pos = buffer.find("\n")
if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos):
# Carriage return found - extract and yield as replaceable line
line = buffer[:cr_pos]
buffer = buffer[cr_pos + 1:]
if line.strip():
yield (line.strip(), True) # replace_last=True for progress updates
elif nl_pos != -1:
# Newline found - extract and yield as new line
line = buffer[:nl_pos]
buffer = buffer[nl_pos + 1:]
if line.strip():
lowered = line.lower()
yield (line.strip(), False) # replace_last=False for new lines
if "error" in lowered or "failed" in lowered:
success_flag["value"] = False
else:
break
returncode = await process.wait()
if returncode != 0:
success_flag["value"] = False
yield f"Command exited with status {returncode}"
yield (f"Command exited with status {returncode}", False)
async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]:
"""Run a runtime command (docker/podman) and return (success, stdout, stderr)."""
@ -516,14 +562,14 @@ class ContainerManager:
if hasattr(self, '_compose_search_log'):
for line in self._compose_search_log.split('\n'):
if line.strip():
yield False, line
yield False, line, False
yield False, f"Final compose file: {compose_file.absolute()}"
yield False, f"Final compose file: {compose_file.absolute()}", False
if not compose_file.exists():
yield False, f"ERROR: Compose file not found at {compose_file.absolute()}"
yield False, f"ERROR: Compose file not found at {compose_file.absolute()}", False
return
yield False, "Starting OpenRAG services..."
yield False, "Starting OpenRAG services...", False
missing_images: List[str] = []
try:
@ -534,30 +580,30 @@ class ContainerManager:
if missing_images:
images_list = ", ".join(missing_images)
yield False, f"Pulling container images ({images_list})..."
yield False, f"Pulling container images ({images_list})...", False
pull_success = {"value": True}
async for line in self._stream_compose_command(
async for message, replace_last in self._stream_compose_command(
["pull"], pull_success, cpu_mode
):
yield False, line
yield False, message, replace_last
if not pull_success["value"]:
yield False, "Some images failed to pull; attempting to start services anyway..."
yield False, "Some images failed to pull; attempting to start services anyway...", False
yield False, "Creating and starting containers..."
yield False, "Creating and starting containers...", False
up_success = {"value": True}
async for line in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
yield False, line
async for message, replace_last in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
yield False, message, replace_last
if up_success["value"]:
yield True, "Services started successfully"
yield True, "Services started successfully", False
else:
yield False, "Failed to start services. See output above for details."
yield False, "Failed to start services. See output above for details.", False
async def stop_services(self) -> AsyncIterator[tuple[bool, str]]:
"""Stop all services and yield progress updates."""
yield False, "Stopping OpenRAG services..."
success, stdout, stderr = await self._run_compose_command(["down"])
success, stdout, stderr = await self._run_compose_command(["stop"])
if success:
yield True, "Services stopped successfully"
@ -581,35 +627,35 @@ class ContainerManager:
self, cpu_mode: bool = False
) -> AsyncIterator[tuple[bool, str]]:
"""Upgrade services (pull latest images and restart) and yield progress updates."""
yield False, "Pulling latest images..."
yield False, "Pulling latest images...", False
# Pull latest images with streaming output
pull_success = True
async for line in self._run_compose_command_streaming(["pull"], cpu_mode):
yield False, line
async for message, replace_last in self._run_compose_command_streaming(["pull"], cpu_mode):
yield False, message, replace_last
# Check for error patterns in the output
if "error" in line.lower() or "failed" in line.lower():
if "error" in message.lower() or "failed" in message.lower():
pull_success = False
if not pull_success:
yield False, "Failed to pull some images, but continuing with restart..."
yield False, "Failed to pull some images, but continuing with restart...", False
yield False, "Images updated, restarting services..."
yield False, "Images updated, restarting services...", False
# Restart with new images using streaming output
restart_success = True
async for line in self._run_compose_command_streaming(
async for message, replace_last in self._run_compose_command_streaming(
["up", "-d", "--force-recreate"], cpu_mode
):
yield False, line
yield False, message, replace_last
# Check for error patterns in the output
if "error" in line.lower() or "failed" in line.lower():
if "error" in message.lower() or "failed" in message.lower():
restart_success = False
if restart_success:
yield True, "Services upgraded and restarted successfully"
yield True, "Services upgraded and restarted successfully", False
else:
yield False, "Some errors occurred during service restart"
yield False, "Some errors occurred during service restart", False
async def reset_services(self) -> AsyncIterator[tuple[bool, str]]:
"""Reset all services (stop, remove containers/volumes, clear data) and yield progress updates."""

View file

@ -149,8 +149,17 @@ class EnvManager:
if not self.config.langflow_secret_key:
self.config.langflow_secret_key = self.generate_langflow_secret_key()
# Configure autologin based on whether password is set
if not self.config.langflow_superuser_password:
self.config.langflow_superuser_password = self.generate_secure_password()
# If no password is set, enable autologin mode
self.config.langflow_auto_login = "True"
self.config.langflow_new_user_is_active = "True"
self.config.langflow_enable_superuser_cli = "True"
else:
# If password is set, disable autologin mode
self.config.langflow_auto_login = "False"
self.config.langflow_new_user_is_active = "False"
self.config.langflow_enable_superuser_cli = "False"
def validate_config(self, mode: str = "full") -> bool:
"""
@ -183,10 +192,7 @@ class EnvManager:
# Langflow secret key is auto-generated; no user input required
if not validate_non_empty(self.config.langflow_superuser_password):
self.config.validation_errors["langflow_superuser_password"] = (
"Langflow superuser password is required"
)
# Langflow password is now optional - if not provided, autologin mode will be enabled
if mode == "full":
# Validate OAuth settings if provided
@ -249,10 +255,12 @@ class EnvManager:
# Core settings
f.write("# Core settings\n")
f.write(f"LANGFLOW_SECRET_KEY={self._quote_env_value(self.config.langflow_secret_key)}\n")
f.write(f"LANGFLOW_SUPERUSER={self._quote_env_value(self.config.langflow_superuser)}\n")
f.write(
f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n"
)
# Only write LANGFLOW_SUPERUSER and password if password is set
if self.config.langflow_superuser_password:
f.write(f"LANGFLOW_SUPERUSER={self._quote_env_value(self.config.langflow_superuser)}\n")
f.write(
f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n"
)
f.write(f"LANGFLOW_CHAT_FLOW_ID={self._quote_env_value(self.config.langflow_chat_flow_id)}\n")
f.write(
f"LANGFLOW_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_ingest_flow_id)}\n"

View file

@ -1,5 +1,6 @@
"""Configuration screen for OpenRAG TUI."""
import re
from textual.app import ComposeResult
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
from textual.screen import Screen
@ -12,6 +13,7 @@ from textual.widgets import (
Label,
TabbedContent,
TabPane,
Checkbox,
)
from textual.validation import ValidationResult, Validator
from rich.text import Text
@ -50,6 +52,40 @@ class DocumentsPathValidator(Validator):
return self.failure(error_msg)
class PasswordValidator(Validator):
"""Validator for OpenSearch admin password."""
def validate(self, value: str) -> ValidationResult:
# Allow empty value (will be auto-generated)
if not value:
return self.success()
# Minimum length: 8 characters
if len(value) < 8:
return self.failure("Password must be at least 8 characters long")
# Check for required character types
has_uppercase = bool(re.search(r"[A-Z]", value))
has_lowercase = bool(re.search(r"[a-z]", value))
has_digit = bool(re.search(r"[0-9]", value))
has_special = bool(re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>/?]", value))
missing = []
if not has_uppercase:
missing.append("uppercase letter")
if not has_lowercase:
missing.append("lowercase letter")
if not has_digit:
missing.append("digit")
if not has_special:
missing.append("special character")
if missing:
return self.failure(f"Password must contain: {', '.join(missing)}")
return self.success()
class ConfigScreen(Screen):
"""Configuration screen for environment setup."""
@ -112,41 +148,54 @@ class ConfigScreen(Screen):
# OpenSearch Admin Password
yield Label("OpenSearch Admin Password *")
current_value = getattr(self.env_manager.config, "opensearch_password", "")
input_widget = Input(
placeholder="Auto-generated secure password",
value=current_value,
password=True,
id="input-opensearch_password",
yield Static(
"Min 8 chars with uppercase, lowercase, digit, and special character",
classes="helper-text",
)
yield input_widget
self.inputs["opensearch_password"] = input_widget
current_value = getattr(self.env_manager.config, "opensearch_password", "")
with Horizontal(id="opensearch-password-row"):
input_widget = Input(
placeholder="Auto-generated secure password",
value=current_value,
password=True,
id="input-opensearch_password",
validators=[PasswordValidator()],
)
yield input_widget
self.inputs["opensearch_password"] = input_widget
yield Button("👁", id="toggle-opensearch-password", variant="default")
yield Static(" ")
# Langflow Admin Username
yield Label("Langflow Admin Username *")
# Langflow Admin Password
with Horizontal():
yield Label("Langflow Admin Password (optional)")
yield Checkbox("Generate password", id="generate-langflow-password")
current_value = getattr(
self.env_manager.config, "langflow_superuser_password", ""
)
with Horizontal(id="langflow-password-row"):
input_widget = Input(
placeholder="Langflow password",
value=current_value,
password=True,
id="input-langflow_superuser_password",
)
yield input_widget
self.inputs["langflow_superuser_password"] = input_widget
yield Button("👁", id="toggle-langflow-password", variant="default")
yield Static(" ")
# Langflow Admin Username - conditionally displayed based on password
current_password = getattr(self.env_manager.config, "langflow_superuser_password", "")
yield Label("Langflow Admin Username *", id="langflow-username-label")
current_value = getattr(self.env_manager.config, "langflow_superuser", "")
input_widget = Input(
placeholder="admin", value=current_value, id="input-langflow_superuser"
)
yield input_widget
self.inputs["langflow_superuser"] = input_widget
yield Static(" ")
yield Static(" ", id="langflow-username-spacer")
# Langflow Admin Password
yield Label("Langflow Admin Password *")
current_value = getattr(
self.env_manager.config, "langflow_superuser_password", ""
)
input_widget = Input(
placeholder="Auto-generated secure password",
value=current_value,
password=True,
id="input-langflow_superuser_password",
)
yield input_widget
self.inputs["langflow_superuser_password"] = input_widget
yield Static(" ")
yield Static(" ")
# API Keys Section
@ -161,15 +210,17 @@ class ConfigScreen(Screen):
classes="helper-text",
)
current_value = getattr(self.env_manager.config, "openai_api_key", "")
input_widget = Input(
placeholder="sk-...",
value=current_value,
password=True,
validators=[OpenAIKeyValidator()],
id="input-openai_api_key",
)
yield input_widget
self.inputs["openai_api_key"] = input_widget
with Horizontal(id="openai-key-row"):
input_widget = Input(
placeholder="sk-...",
value=current_value,
password=True,
validators=[OpenAIKeyValidator()],
id="input-openai_api_key",
)
yield input_widget
self.inputs["openai_api_key"] = input_widget
yield Button("Show", id="toggle-openai-key", variant="default")
yield Static(" ")
# Add OAuth fields only in full mode
@ -212,14 +263,16 @@ class ConfigScreen(Screen):
current_value = getattr(
self.env_manager.config, "google_oauth_client_secret", ""
)
input_widget = Input(
placeholder="",
value=current_value,
password=True,
id="input-google_oauth_client_secret",
)
yield input_widget
self.inputs["google_oauth_client_secret"] = input_widget
with Horizontal(id="google-secret-row"):
input_widget = Input(
placeholder="",
value=current_value,
password=True,
id="input-google_oauth_client_secret",
)
yield input_widget
self.inputs["google_oauth_client_secret"] = input_widget
yield Button("Show", id="toggle-google-secret", variant="default")
yield Static(" ")
# Microsoft Graph Client ID
@ -260,14 +313,16 @@ class ConfigScreen(Screen):
current_value = getattr(
self.env_manager.config, "microsoft_graph_oauth_client_secret", ""
)
input_widget = Input(
placeholder="",
value=current_value,
password=True,
id="input-microsoft_graph_oauth_client_secret",
)
yield input_widget
self.inputs["microsoft_graph_oauth_client_secret"] = input_widget
with Horizontal(id="microsoft-secret-row"):
input_widget = Input(
placeholder="",
value=current_value,
password=True,
id="input-microsoft_graph_oauth_client_secret",
)
yield input_widget
self.inputs["microsoft_graph_oauth_client_secret"] = input_widget
yield Button("Show", id="toggle-microsoft-secret", variant="default")
yield Static(" ")
# AWS Access Key ID
@ -328,50 +383,8 @@ class ConfigScreen(Screen):
self.inputs["openrag_documents_paths"] = input_widget
yield Static(" ")
# Langflow Auth Settings
yield Static("Langflow Auth Settings", classes="tab-header")
yield Static(" ")
# Langflow Auto Login
yield Label("Langflow Auto Login")
current_value = getattr(self.env_manager.config, "langflow_auto_login", "False")
input_widget = Input(
placeholder="False", value=current_value, id="input-langflow_auto_login"
)
yield input_widget
self.inputs["langflow_auto_login"] = input_widget
yield Static(" ")
# Langflow New User Is Active
yield Label("Langflow New User Is Active")
current_value = getattr(
self.env_manager.config, "langflow_new_user_is_active", "False"
)
input_widget = Input(
placeholder="False",
value=current_value,
id="input-langflow_new_user_is_active",
)
yield input_widget
self.inputs["langflow_new_user_is_active"] = input_widget
yield Static(" ")
# Langflow Enable Superuser CLI
yield Label("Langflow Enable Superuser CLI")
current_value = getattr(
self.env_manager.config, "langflow_enable_superuser_cli", "False"
)
input_widget = Input(
placeholder="False",
value=current_value,
id="input-langflow_enable_superuser_cli",
)
yield input_widget
self.inputs["langflow_enable_superuser_cli"] = input_widget
yield Static(" ")
yield Static(" ")
# Langflow Secret Key removed from UI; generated automatically on save
# Langflow Auth Settings - These are automatically configured based on password presence
# Not shown in UI; set in env_manager.setup_secure_defaults()
# Add optional fields only in full mode
if self.mode == "full":
@ -454,6 +467,10 @@ class ConfigScreen(Screen):
def on_mount(self) -> None:
"""Initialize the screen when mounted."""
# Set initial visibility of username field based on password
current_password = getattr(self.env_manager.config, "langflow_superuser_password", "")
self._update_langflow_username_visibility(current_password)
# Focus the first input field
try:
# Find the first input field and focus it
@ -463,6 +480,26 @@ class ConfigScreen(Screen):
except Exception:
pass
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
"""Handle checkbox changes."""
if event.checkbox.id == "generate-langflow-password":
langflow_password_input = self.inputs.get("langflow_superuser_password")
if event.value:
# Generate password when checked
password = self.env_manager.generate_secure_password()
if langflow_password_input:
langflow_password_input.value = password
# Show username field
self._update_langflow_username_visibility(password)
self.notify("Generated Langflow password", severity="information")
else:
# Clear password when unchecked (enable autologin)
if langflow_password_input:
langflow_password_input.value = ""
# Hide username field
self._update_langflow_username_visibility("")
self.notify("Cleared Langflow password - autologin enabled", severity="information")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "generate-btn":
@ -473,21 +510,58 @@ class ConfigScreen(Screen):
self.action_back()
elif event.button.id == "pick-docs-btn":
self.action_pick_documents_path()
elif event.button.id == "toggle-opensearch-password":
# Toggle OpenSearch password visibility
input_widget = self.inputs.get("opensearch_password")
if input_widget:
input_widget.password = not input_widget.password
event.button.label = "🙈" if not input_widget.password else "👁"
elif event.button.id == "toggle-langflow-password":
# Toggle Langflow password visibility
input_widget = self.inputs.get("langflow_superuser_password")
if input_widget:
input_widget.password = not input_widget.password
event.button.label = "🙈" if not input_widget.password else "👁"
def action_generate(self) -> None:
"""Generate secure passwords for admin accounts."""
self.env_manager.setup_secure_defaults()
# First sync input values to config to get current state
opensearch_input = self.inputs.get("opensearch_password")
if opensearch_input:
self.env_manager.config.opensearch_password = opensearch_input.value
# Only generate OpenSearch password if empty
if not self.env_manager.config.opensearch_password:
self.env_manager.config.opensearch_password = self.env_manager.generate_secure_password()
# Update secret keys
if not self.env_manager.config.langflow_secret_key:
self.env_manager.config.langflow_secret_key = self.env_manager.generate_langflow_secret_key()
# Update input fields with generated values
for field_name, input_widget in self.inputs.items():
if field_name in ["opensearch_password", "langflow_superuser_password"]:
new_value = getattr(self.env_manager.config, field_name)
input_widget.value = new_value
if opensearch_input:
opensearch_input.value = self.env_manager.config.opensearch_password
self.notify("Generated secure passwords", severity="information")
self.notify("Generated secure password for OpenSearch", severity="information")
def action_save(self) -> None:
"""Save the configuration."""
# First, check Textual input validators
validation_errors = []
for field_name, input_widget in self.inputs.items():
if hasattr(input_widget, "validate") and input_widget.value:
result = input_widget.validate(input_widget.value)
if result and not result.is_valid:
for failure in result.failures:
validation_errors.append(f"{field_name}: {failure.description}")
if validation_errors:
self.notify(
f"Validation failed:\n" + "\n".join(validation_errors[:3]),
severity="error",
)
return
# Update config from input fields
for field_name, input_widget in self.inputs.items():
setattr(self.env_manager.config, field_name, input_widget.value)
@ -507,10 +581,8 @@ class ConfigScreen(Screen):
# Save to file
if self.env_manager.save_env_file():
self.notify("Configuration saved successfully!", severity="information")
# Switch to monitor screen
from .monitor import MonitorScreen
self.app.push_screen(MonitorScreen())
# Go back to welcome screen
self.dismiss()
else:
self.notify("Failed to save configuration", severity="error")
@ -588,5 +660,31 @@ class ConfigScreen(Screen):
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle input changes for real-time validation feedback."""
# Handle Langflow password changes - show/hide username field
if event.input.id == "input-langflow_superuser_password":
self._update_langflow_username_visibility(event.value)
# This will trigger validation display in real-time
pass
def _update_langflow_username_visibility(self, password_value: str) -> None:
"""Show or hide the Langflow username field based on password presence."""
has_password = bool(password_value and password_value.strip())
# Get the widgets
try:
username_label = self.query_one("#langflow-username-label")
username_input = self.query_one("#input-langflow_superuser")
username_spacer = self.query_one("#langflow-username-spacer")
# Show or hide based on password presence
if has_password:
username_label.display = True
username_input.display = True
username_spacer.display = True
else:
username_label.display = False
username_input.display = False
username_spacer.display = False
except Exception:
# Widgets don't exist yet, ignore
pass

View file

@ -118,7 +118,7 @@ class WelcomeScreen(Screen):
"""
welcome_text.append(ascii_art, style="bold white")
welcome_text.append("Terminal User Interface for OpenRAG\n", style="dim")
welcome_text.append(f"v{__version__}\n\n", style="dim cyan")
welcome_text.append(f"v{__version__}\n\n", style="white")
# Check if all services are running
all_services_running = self.services_running and self.docling_running
@ -237,6 +237,22 @@ class WelcomeScreen(Screen):
except:
pass # Button might not exist
async def on_screen_resume(self) -> None:
"""Called when returning from another screen (e.g., config screen)."""
# Reload environment variables
load_dotenv(override=True)
# Update OAuth config state
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
)
# Re-detect service state
self._detect_services_sync()
# Refresh the welcome content and buttons
await self._refresh_welcome_content()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "basic-setup-btn":

View file

@ -6,53 +6,62 @@ from typing import Callable, Optional, AsyncIterator
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import Container, ScrollableContainer
from textual.containers import Container, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Button, Static, Label, TextArea
from textual.widgets import Button, Static, Label, TextArea, Footer
from ..utils.clipboard import copy_text_to_clipboard
from .waves import Waves
class CommandOutputModal(ModalScreen):
"""Modal dialog for displaying command output in real-time."""
BINDINGS = [
("w,+", "add_wave", "Add"),
("r,-", "remove_wave", "Remove"),
("p", "pause_waves", "Pause"),
("f", "speed_up", "Faster"),
("s", "speed_down", "Slower"),
]
DEFAULT_CSS = """
CommandOutputModal {
align: center middle;
overflow: hidden;
}
#waves-background {
width: 100%;
height: 100%;
layer: background;
overflow: hidden;
}
#dialog {
width: 90%;
height: 90%;
border: thick $primary;
background: $surface;
border: solid #3f3f46;
background: #27272a;
padding: 0;
overflow: hidden;
}
#title {
background: $primary;
color: $text;
background: #3f3f46;
color: #fafafa;
padding: 1 2;
text-align: center;
width: 100%;
text-style: bold;
}
#output-container {
height: 1fr;
padding: 0;
margin: 0 1;
}
#command-output {
height: 100%;
border: solid $accent;
margin: 1 0;
background: $surface-darken-1;
}
#command-output > .text-area--content {
padding: 1 2;
height: 1fr;
border: solid #3f3f46;
margin: 1;
background: #18181b;
color: #fafafa;
}
#button-row {
@ -66,11 +75,96 @@ class CommandOutputModal(ModalScreen):
#button-row Button {
margin: 0 1;
min-width: 16;
background: #27272a;
color: #fafafa;
border: round #52525b;
text-style: none;
tint: transparent 0%;
}
#button-row Button > Static {
background: transparent !important;
color: #fafafa !important;
text-style: none;
}
#button-row Button > * {
background: transparent !important;
color: #fafafa !important;
}
#button-row Button:hover {
background: #27272a !important;
color: #fafafa !important;
border: round #52525b;
tint: transparent 0%;
text-style: none;
}
#button-row Button:hover > Static {
background: transparent !important;
color: #fafafa !important;
text-style: none;
}
#button-row Button:hover > * {
background: transparent !important;
color: #fafafa !important;
}
#button-row Button:focus {
background: #27272a !important;
color: #fafafa !important;
border: round #ec4899;
tint: transparent 0%;
text-style: none;
}
#button-row Button:focus > Static {
background: transparent !important;
color: #fafafa !important;
text-style: none;
}
#button-row Button:focus > * {
background: transparent !important;
color: #fafafa !important;
}
#button-row Button.-active {
background: #27272a !important;
color: #fafafa !important;
border: round #ec4899;
tint: transparent 0%;
text-style: none;
}
#button-row Button.-active > Static {
background: transparent !important;
color: #fafafa !important;
text-style: none;
}
#button-row Button.-active > * {
background: transparent !important;
color: #fafafa !important;
}
#button-row Button:disabled {
background: #27272a;
color: #52525b;
border: round #3f3f46;
}
#button-row Button:disabled > Static {
background: transparent;
color: #52525b;
}
#copy-status {
text-align: center;
margin-bottom: 1;
color: #a1a1aa;
}
"""
@ -84,43 +178,40 @@ class CommandOutputModal(ModalScreen):
Args:
title: Title of the modal dialog
command_generator: Async generator that yields (is_complete, message) tuples
command_generator: Async generator that yields (is_complete, message) or (is_complete, message, replace_last) tuples
on_complete: Optional callback to run when command completes
"""
super().__init__()
self.title_text = title
self.command_generator = command_generator
self.on_complete = on_complete
self._output_text: str = ""
self._output_lines: list[str] = []
self._layer_line_map: dict[str, int] = {} # Maps layer ID to line index
self._status_task: Optional[asyncio.Task] = None
def compose(self) -> ComposeResult:
"""Create the modal dialog layout."""
yield Waves(id="waves-background")
with Container(id="dialog"):
yield Label(self.title_text, id="title")
with ScrollableContainer(id="output-container"):
yield TextArea(
text="",
read_only=True,
show_line_numbers=False,
id="command-output",
)
yield TextArea(
text="",
read_only=True,
show_line_numbers=False,
id="command-output",
)
with Container(id="button-row"):
yield Button("Copy Output", variant="default", id="copy-btn")
yield Button(
"Close", variant="primary", id="close-btn", disabled=True
)
yield Static("", id="copy-status")
yield Footer()
def on_mount(self) -> None:
"""Start the command when the modal is mounted."""
# Start the command but don't store the worker
self.run_worker(self._run_command(), exclusive=False)
# Focus the output so users can select text immediately
try:
self.query_one("#command-output", TextArea).focus()
except Exception:
pass
def on_unmount(self) -> None:
"""Cancel any pending timers when modal closes."""
@ -135,22 +226,59 @@ class CommandOutputModal(ModalScreen):
elif event.button.id == "copy-btn":
self.copy_to_clipboard()
def action_add_wave(self) -> None:
"""Add a wave to the animation."""
waves = self.query_one("#waves-background", Waves)
waves._add_wavelet()
def action_remove_wave(self) -> None:
"""Remove a wave from the animation."""
waves = self.query_one("#waves-background", Waves)
if waves.wavelets:
waves.wavelets.pop()
def action_pause_waves(self) -> None:
"""Pause/unpause the wave animation."""
waves = self.query_one("#waves-background", Waves)
waves.paused = not waves.paused
def action_speed_up(self) -> None:
"""Increase wave speed."""
waves = self.query_one("#waves-background", Waves)
for w in waves.wavelets:
w.speed = min(2.0, w.speed * 1.2)
def action_speed_down(self) -> None:
"""Decrease wave speed."""
waves = self.query_one("#waves-background", Waves)
for w in waves.wavelets:
w.speed = max(0.1, w.speed * 0.8)
async def _run_command(self) -> None:
"""Run the command and update the output in real-time."""
output = self.query_one("#command-output", TextArea)
container = self.query_one("#output-container", ScrollableContainer)
try:
async for is_complete, message in self.command_generator:
self._append_output(message)
output.text = self._output_text
container.scroll_end(animate=False)
async for result in self.command_generator:
# Handle both (is_complete, message) and (is_complete, message, replace_last) tuples
if len(result) == 2:
is_complete, message = result
replace_last = False
else:
is_complete, message, replace_last = result
self._update_output(message, replace_last)
output.text = "\n".join(self._output_lines)
# Move cursor to end to trigger scroll
output.move_cursor((len(self._output_lines), 0))
# If command is complete, update UI
if is_complete:
self._append_output("Command completed successfully")
output.text = self._output_text
container.scroll_end(animate=False)
self._update_output("Command completed successfully", False)
output.text = "\n".join(self._output_lines)
output.move_cursor((len(self._output_lines), 0))
# Call the completion callback if provided
if self.on_complete:
await asyncio.sleep(0.5) # Small delay for better UX
@ -162,30 +290,62 @@ class CommandOutputModal(ModalScreen):
self.call_after_refresh(_invoke_callback)
except Exception as e:
self._append_output(f"Error: {e}")
output.text = self._output_text
container.scroll_end(animate=False)
self._update_output(f"Error: {e}", False)
output.text = "\n".join(self._output_lines)
output.move_cursor((len(self._output_lines), 0))
finally:
# Enable the close button and focus it
close_btn = self.query_one("#close-btn", Button)
close_btn.disabled = False
close_btn.focus()
def _append_output(self, message: str) -> None:
"""Append a message to the output buffer."""
def _update_output(self, message: str, replace_last: bool = False) -> None:
"""Update the output buffer by appending or replacing the last line.
Args:
message: The message to add or use as replacement
replace_last: If True, replace the last line (or layer-specific line); if False, append new line
"""
if message is None:
return
message = message.rstrip("\n")
if not message:
return
if self._output_text:
self._output_text += "\n" + message
# Always check if this is a layer update (regardless of replace_last flag)
parts = message.split(None, 1)
if parts:
potential_layer_id = parts[0]
# Check if this looks like a layer ID (hex string, 12 chars for Docker layers)
if len(potential_layer_id) == 12 and all(c in '0123456789abcdefABCDEF' for c in potential_layer_id):
# This is a layer message
if potential_layer_id in self._layer_line_map:
# Update the existing line for this layer
line_idx = self._layer_line_map[potential_layer_id]
if 0 <= line_idx < len(self._output_lines):
self._output_lines[line_idx] = message
return
else:
# New layer, add it and track the line index
self._layer_line_map[potential_layer_id] = len(self._output_lines)
self._output_lines.append(message)
return
# Not a layer message, handle normally
if replace_last:
# Fallback: just replace the last line
if self._output_lines:
self._output_lines[-1] = message
else:
self._output_lines.append(message)
else:
self._output_text = message
# Append as a new line
self._output_lines.append(message)
def copy_to_clipboard(self) -> None:
"""Copy the modal output to the clipboard."""
if not self._output_text:
if not self._output_lines:
message = "No output to copy yet"
self.notify(message, severity="warning")
status = self.query_one("#copy-status", Static)
@ -193,7 +353,8 @@ class CommandOutputModal(ModalScreen):
self._schedule_status_clear(status)
return
success, message = copy_text_to_clipboard(self._output_text)
output_text = "\n".join(self._output_lines)
success, message = copy_text_to_clipboard(output_text)
self.notify(message, severity="information" if success else "error")
status = self.query_one("#copy-status", Static)
style = "bold green" if success else "bold red"

146
src/tui/widgets/waves.py Normal file
View file

@ -0,0 +1,146 @@
"""Waves animation widget for command modals."""
import math
import random
from dataclasses import dataclass
from typing import List
from textual import events
from textual.reactive import reactive
from textual.widgets import Static
@dataclass
class Wavelet:
x: float
lane: int # 0 or 1
speed: float # chars/frame
phase: float # for subtle vertical bob / color cycle
hue: int # index in palette
class Waves(Static):
"""Animated waves widget that displays moving wavelets around the border."""
can_focus = False
fps = 24
paused = reactive(False)
show_help = reactive(False)
def on_mount(self):
self.palette = ["#93c5fd", "#60a5fa", "#38bdf8", "#a78bfa", "#f472b6"]
self.wavelets: List[Wavelet] = []
# Start with a few wavelets
for _ in range(3):
self._add_wavelet()
self.set_interval(1 / self.fps, self._tick)
def _offset_for_lane(self, lane: int, width: int, height: int) -> int:
max_offset = max(1, min(width, height) // 2 - 1)
return min(1 + lane, max_offset)
def _build_path(
self, width: int, height: int, offset: int
) -> List[tuple[int, int]]:
left = offset
right = max(offset, width - offset - 1)
top = offset
bottom = max(offset, height - offset - 1)
if right < left or bottom < top:
return [(max(0, left), max(0, top))]
path: List[tuple[int, int]] = []
# Top edge
for x in range(left, right + 1):
path.append((x, top))
# Right edge (excluding corners already added)
for y in range(top + 1, bottom):
path.append((right, y))
# Bottom edge (if distinct from top)
if bottom != top:
for x in range(right, left - 1, -1):
path.append((x, bottom))
# Left edge (excluding corners)
if right != left:
for y in range(bottom - 1, top, -1):
path.append((left, y))
return path or [(left, top)]
def _path_for_lane(
self, width: int, height: int, lane: int
) -> List[tuple[int, int]]:
offset = self._offset_for_lane(lane, width, height)
path = self._build_path(width, height, offset)
if not path and offset > 1:
offset = 1
path = self._build_path(width, height, offset)
return path
def _add_wavelet(self):
w = max(10, self.size.width)
self.wavelets.append(
Wavelet(
x=0,
lane=random.choice([0, 1]),
speed=0.25 + random.random() * 0.35, # slow & smooth
phase=random.random() * math.tau,
hue=random.randrange(len(self.palette)),
)
)
# Initialize position once we know the current perimeter
h = max(6, self.size.height)
path = self._path_for_lane(w, h, self.wavelets[-1].lane)
if path:
self.wavelets[-1].x = random.uniform(0, len(path))
def set_throughput(self, bytes_per_sec: float):
"""Modulate wavelet speed based on download throughput."""
boost = min(1.8, 1.0 + math.log10(bytes_per_sec + 1) / 6.0)
for w in self.wavelets:
w.speed = min(1.2, w.speed * 0.7 + (0.25 * boost))
def _tick(self):
if self.paused:
self.refresh()
return
width = max(10, self.size.width)
height = max(6, self.size.height)
for w in self.wavelets:
path = self._path_for_lane(width, height, w.lane)
if not path:
continue
perimeter = len(path)
if perimeter <= 0:
continue
w.x %= perimeter
new_pos = w.x + w.speed
wrapped = new_pos >= perimeter
w.x = new_pos % perimeter
if wrapped:
# Tiny color/phase change on wrap
w.hue = (w.hue + 1) % len(self.palette)
w.phase = random.random() * math.tau
self.refresh()
def render(self) -> str:
W = max(10, self.size.width)
H = max(6, self.size.height)
buf = [[" "] * W for _ in range(H)]
# Draw wavelets moving around the border
for wv in self.wavelets:
path = self._path_for_lane(W, H, wv.lane)
if not path:
continue
perimeter = len(path)
if perimeter <= 0:
continue
idx = int(wv.x) % perimeter
x, y = path[idx]
if 0 <= x < W and 0 <= y < H:
col = self.palette[wv.hue]
buf[y][x] = f"[{col}]≈[/]"
# No border - just wavelets on empty background
return "\n".join("".join(r) for r in buf)