diff --git a/docker-compose-cpu.yml b/docker-compose-cpu.yml index c0af8a01..6bda8fe1 100644 --- a/docker-compose-cpu.yml +++ b/docker-compose-cpu.yml @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index df8a3228..50ee0f41 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/frontend/components/docling-health-banner.tsx b/frontend/components/docling-health-banner.tsx index c65a93cc..afa79deb 100644 --- a/frontend/components/docling-health-banner.tsx +++ b/frontend/components/docling-health-banner.tsx @@ -69,7 +69,7 @@ function DoclingSetupDialog({ - Then, select Start Native Services in the TUI. Once docling-serve is running, refresh OpenRAG. + Then, select Start All Services in the TUI. Once docling-serve is running, refresh OpenRAG. diff --git a/src/config/settings.py b/src/config/settings.py index 598ccfb2..0672ad68 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -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, ) diff --git a/src/tui/main.py b/src/tui/main.py index beee4497..51418786 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -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; } """ diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index d1be1e9f..41dd7178 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -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.""" diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index 9510fb70..8ee86627 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -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" diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 7d4a8924..a9ef334d 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -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 diff --git a/src/tui/screens/welcome.py b/src/tui/screens/welcome.py index ea85de9e..b0cc62c9 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -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": diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 015861f0..a5013031 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -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" diff --git a/src/tui/widgets/waves.py b/src/tui/widgets/waves.py new file mode 100644 index 00000000..5e3d7d8f --- /dev/null +++ b/src/tui/widgets/waves.py @@ -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)