openrag/src/tui/screens/config.py
2025-09-03 23:03:05 -04:00

519 lines
20 KiB
Python

"""Configuration screen for OpenRAG TUI."""
from textual.app import ComposeResult
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Input, Label, TabbedContent, TabPane
from textual.validation import ValidationResult, Validator
from rich.text import Text
from pathlib import Path
from ..managers.env_manager import EnvManager
from ..utils.validation import validate_openai_api_key, validate_documents_paths
from pathlib import Path
class OpenAIKeyValidator(Validator):
"""Validator for OpenAI API keys."""
def validate(self, value: str) -> ValidationResult:
if not value:
return self.success()
if validate_openai_api_key(value):
return self.success()
else:
return self.failure("Invalid OpenAI API key format (should start with sk-)")
class DocumentsPathValidator(Validator):
"""Validator for documents paths."""
def validate(self, value: str) -> ValidationResult:
# Optional: allow empty value
if not value:
return self.success()
is_valid, error_msg, _ = validate_documents_paths(value)
if is_valid:
return self.success()
else:
return self.failure(error_msg)
class ConfigScreen(Screen):
"""Configuration screen for environment setup."""
BINDINGS = [
("escape", "back", "Back"),
("ctrl+s", "save", "Save"),
("ctrl+g", "generate", "Generate Passwords"),
]
def __init__(self, mode: str = "full"):
super().__init__()
self.mode = mode # "no_auth" or "full"
self.env_manager = EnvManager()
self.inputs = {}
# Load existing config if available
self.env_manager.load_existing_env()
def compose(self) -> ComposeResult:
"""Create the configuration screen layout."""
# Removed top header bar and header text
with Container(id="main-container"):
with ScrollableContainer(id="config-scroll"):
with Vertical(id="config-form"):
yield from self._create_all_fields()
yield Horizontal(
Button("Generate Passwords", variant="default", id="generate-btn"),
Button("Save Configuration", variant="success", id="save-btn"),
Button("Back", variant="default", id="back-btn"),
classes="button-row"
)
yield Footer()
def _create_header_text(self) -> Text:
"""Create the configuration header text."""
header_text = Text()
if self.mode == "no_auth":
header_text.append("Quick Setup - No Authentication\n", style="bold green")
header_text.append("Configure OpenRAG for local document processing only.\n\n", style="dim")
else:
header_text.append("Full Setup - OAuth Integration\n", style="bold cyan")
header_text.append("Configure OpenRAG with cloud service integrations.\n\n", style="dim")
header_text.append("Required fields are marked with *\n", style="yellow")
header_text.append("Use Ctrl+G to generate admin passwords\n", style="dim")
return header_text
def _create_all_fields(self) -> ComposeResult:
"""Create all configuration fields in a single scrollable layout."""
# Admin Credentials Section
yield Static("Admin Credentials", classes="tab-header")
yield Static(" ")
# 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 input_widget
self.inputs["opensearch_password"] = input_widget
yield Static(" ")
# Langflow Admin Username
yield Label("Langflow Admin Username *")
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(" ")
# 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
yield Static("API Keys", classes="tab-header")
yield Static(" ")
# OpenAI API Key
yield Label("OpenAI API Key *")
# Where to create OpenAI keys (helper above the box)
yield Static(Text("Get a key: https://platform.openai.com/api-keys", style="dim"), 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
yield Static(" ")
# Add OAuth fields only in full mode
if self.mode == "full":
# Google OAuth Client ID
yield Label("Google OAuth Client ID")
# Where to create Google OAuth credentials (helper above the box)
yield Static(Text("Create credentials: https://console.cloud.google.com/apis/credentials", style="dim"), classes="helper-text")
# Callback URL guidance for Google OAuth
yield Static(
Text(
"Important: add an Authorized redirect URI to your Google OAuth app(s):\n"
" - Local: http://localhost:3000/auth/callback\n"
" - Prod: https://your-domain.com/auth/callback\n"
"If you use separate apps for login and connectors, add this URL to BOTH.",
style="dim"
),
classes="helper-text"
)
current_value = getattr(self.env_manager.config, "google_oauth_client_id", "")
input_widget = Input(
placeholder="xxx.apps.googleusercontent.com",
value=current_value,
id="input-google_oauth_client_id"
)
yield input_widget
self.inputs["google_oauth_client_id"] = input_widget
yield Static(" ")
# Google OAuth Client Secret
yield Label("Google OAuth Client Secret")
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
yield Static(" ")
# Microsoft Graph Client ID
yield Label("Microsoft Graph Client ID")
# Where to create Microsoft app registrations (helper above the box)
yield Static(Text("Create app: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", style="dim"), classes="helper-text")
# Callback URL guidance for Microsoft OAuth
yield Static(
Text(
"Important: configure a Web redirect URI for your Microsoft app(s):\n"
" - Local: http://localhost:3000/auth/callback\n"
" - Prod: https://your-domain.com/auth/callback\n"
"If you use separate apps for login and connectors, add this URI to BOTH.",
style="dim"
),
classes="helper-text"
)
current_value = getattr(self.env_manager.config, "microsoft_graph_oauth_client_id", "")
input_widget = Input(
placeholder="",
value=current_value,
id="input-microsoft_graph_oauth_client_id"
)
yield input_widget
self.inputs["microsoft_graph_oauth_client_id"] = input_widget
yield Static(" ")
# Microsoft Graph Client Secret
yield Label("Microsoft Graph Client Secret")
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
yield Static(" ")
# AWS Access Key ID
yield Label("AWS Access Key ID")
# Where to create AWS keys (helper above the box)
yield Static(Text("Create keys: https://console.aws.amazon.com/iam/home#/security_credentials", style="dim"), classes="helper-text")
current_value = getattr(self.env_manager.config, "aws_access_key_id", "")
input_widget = Input(
placeholder="",
value=current_value,
id="input-aws_access_key_id"
)
yield input_widget
self.inputs["aws_access_key_id"] = input_widget
yield Static(" ")
# AWS Secret Access Key
yield Label("AWS Secret Access Key")
current_value = getattr(self.env_manager.config, "aws_secret_access_key", "")
input_widget = Input(
placeholder="",
value=current_value,
password=True,
id="input-aws_secret_access_key"
)
yield input_widget
self.inputs["aws_secret_access_key"] = input_widget
yield Static(" ")
yield Static(" ")
# Other Settings Section
yield Static("Others", classes="tab-header")
yield Static(" ")
# Documents Paths (optional) + picker action button on next line
yield Label("Documents Paths")
current_value = getattr(self.env_manager.config, "openrag_documents_paths", "")
input_widget = Input(
placeholder="./documents,/path/to/more/docs",
value=current_value,
validators=[DocumentsPathValidator()],
id="input-openrag_documents_paths"
)
yield input_widget
# Actions row with pick button
yield Horizontal(Button("Pick…", id="pick-docs-btn"), id="docs-path-actions", classes="controls-row")
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
# Add optional fields only in full mode
if self.mode == "full":
# Webhook Base URL
yield Label("Webhook Base URL")
current_value = getattr(self.env_manager.config, "webhook_base_url", "")
input_widget = Input(
placeholder="https://your-domain.com",
value=current_value,
id="input-webhook_base_url"
)
yield input_widget
self.inputs["webhook_base_url"] = input_widget
yield Static(" ")
# Langflow Public URL
yield Label("Langflow Public URL")
current_value = getattr(self.env_manager.config, "langflow_public_url", "")
input_widget = Input(
placeholder="http://localhost:7860",
value=current_value,
id="input-langflow_public_url"
)
yield input_widget
self.inputs["langflow_public_url"] = input_widget
yield Static(" ")
def _create_field(self, field_name: str, display_name: str, placeholder: str, can_generate: bool, required: bool = False) -> ComposeResult:
"""Create a single form field."""
# Create label
label_text = f"{display_name}"
if required:
label_text += " *"
yield Label(label_text)
# Get current value
current_value = getattr(self.env_manager.config, field_name, "")
# Create input with appropriate validator
if field_name == "openai_api_key":
input_widget = Input(
placeholder=placeholder,
value=current_value,
password=True,
validators=[OpenAIKeyValidator()],
id=f"input-{field_name}"
)
elif field_name == "openrag_documents_paths":
input_widget = Input(
placeholder=placeholder,
value=current_value,
validators=[DocumentsPathValidator()],
id=f"input-{field_name}"
)
elif "password" in field_name or "secret" in field_name:
input_widget = Input(
placeholder=placeholder,
value=current_value,
password=True,
id=f"input-{field_name}"
)
else:
input_widget = Input(
placeholder=placeholder,
value=current_value,
id=f"input-{field_name}"
)
yield input_widget
self.inputs[field_name] = input_widget
# Add spacing
yield Static(" ")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "generate-btn":
self.action_generate()
elif event.button.id == "save-btn":
self.action_save()
elif event.button.id == "back-btn":
self.action_back()
elif event.button.id == "pick-docs-btn":
self.action_pick_documents_path()
def action_generate(self) -> None:
"""Generate secure passwords for admin accounts."""
self.env_manager.setup_secure_defaults()
# 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
self.notify("Generated secure passwords", severity="information")
def action_save(self) -> None:
"""Save the configuration."""
# Update config from input fields
for field_name, input_widget in self.inputs.items():
setattr(self.env_manager.config, field_name, input_widget.value)
# Validate the configuration
if not self.env_manager.validate_config(self.mode):
error_messages = []
for field, error in self.env_manager.config.validation_errors.items():
error_messages.append(f"{field}: {error}")
self.notify(f"Validation failed:\n" + "\n".join(error_messages[:3]), severity="error")
return
# 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())
else:
self.notify("Failed to save configuration", severity="error")
def action_back(self) -> None:
"""Go back to welcome screen."""
self.app.pop_screen()
def action_pick_documents_path(self) -> None:
"""Open textual-fspicker to select a path and append it to the input."""
try:
import importlib
fsp = importlib.import_module("textual_fspicker")
except Exception:
self.notify("textual-fspicker not available", severity="warning")
return
# Determine starting path from current input if possible
input_widget = self.inputs.get("openrag_documents_paths")
start = Path.home()
if input_widget and input_widget.value:
first = input_widget.value.split(",")[0].strip()
if first:
start = Path(first).expanduser()
# Prefer SelectDirectory for directories; fallback to FileOpen
PickerClass = getattr(fsp, "SelectDirectory", None) or getattr(fsp, "FileOpen", None)
if PickerClass is None:
self.notify("No compatible picker found in textual-fspicker", severity="warning")
return
try:
picker = PickerClass(location=start)
except Exception:
try:
picker = PickerClass(start)
except Exception:
self.notify("Could not initialize textual-fspicker", severity="warning")
return
def _append_path(result) -> None:
if not result:
return
path_str = str(result)
if input_widget is None:
return
current = input_widget.value or ""
paths = [p.strip() for p in current.split(",") if p.strip()]
if path_str not in paths:
paths.append(path_str)
input_widget.value = ",".join(paths)
# Push with callback when supported; otherwise, use on_screen_dismissed fallback
try:
self.app.push_screen(picker, _append_path) # type: ignore[arg-type]
except TypeError:
self._docs_pick_callback = _append_path # type: ignore[attr-defined]
self.app.push_screen(picker)
def on_screen_dismissed(self, event) -> None: # type: ignore[override]
try:
# textual-fspicker screens should dismiss with a result; hand to callback if present
cb = getattr(self, "_docs_pick_callback", None)
if cb is not None:
cb(getattr(event, "result", None))
try:
delattr(self, "_docs_pick_callback")
except Exception:
pass
except Exception:
pass
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle input changes for real-time validation feedback."""
# This will trigger validation display in real-time
pass