diff --git a/frontend/app/knowledge/page.tsx b/frontend/app/knowledge/page.tsx index 91ea9f8a..d9ebf1d6 100644 --- a/frontend/app/knowledge/page.tsx +++ b/frontend/app/knowledge/page.tsx @@ -391,7 +391,7 @@ function SearchPage() { 1 ? "Delete Documents" : "Delete Document"} description={`Are you sure you want to delete ${ selectedRows.length } document${ @@ -400,7 +400,7 @@ function SearchPage() { Documents to be deleted: ${selectedRows.map((row) => `• ${row.filename}`).join("\n")}`} - confirmText="Delete All" + confirmText={selectedRows.length > 1 ? "Delete All" : "Delete"} onConfirm={handleBulkDelete} isLoading={deleteDocumentMutation.isPending} /> diff --git a/frontend/app/onboarding/_components/onboarding-content.tsx b/frontend/app/onboarding/_components/onboarding-content.tsx index 6c6cffac..ee47f347 100644 --- a/frontend/app/onboarding/_components/onboarding-content.tsx +++ b/frontend/app/onboarding/_components/onboarding-content.tsx @@ -171,7 +171,7 @@ export function OnboardingContent({ /> - {/* Step 2 */} + {/* Step 3 */} = 2} isCompleted={currentStep > 2 || !!selectedNudge} @@ -209,7 +209,7 @@ export function OnboardingContent({ /> )} - {/* Step 3 */} + {/* Step 4 */} = 3 && !isLoading && !!displayMessage} isCompleted={currentStep > 3} diff --git a/frontend/components/knowledge-dropdown.tsx b/frontend/components/knowledge-dropdown.tsx index 643ee0e5..d0100790 100644 --- a/frontend/components/knowledge-dropdown.tsx +++ b/frontend/components/knowledge-dropdown.tsx @@ -43,6 +43,33 @@ import { } from "@/lib/upload-utils"; import { cn } from "@/lib/utils"; +// Supported file extensions - single source of truth +const SUPPORTED_EXTENSIONS = [ + ".pdf", + ".doc", + ".docx", + ".pptx", + ".ppt", + ".xlsx", + ".xls", + ".csv", + ".txt", + ".md", + ".html", + ".htm", + ".rtf", + ".odt", + ".asciidoc", + ".adoc", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".tiff", + ".webp", +]; + export function KnowledgeDropdown() { const { addTask } = useTask(); const { refetch: refetchTasks } = useGetTasksQuery(); @@ -247,27 +274,18 @@ export function KnowledgeDropdown() { try { const fileList = Array.from(files); - const supportedExtensions = [ - ".pdf", - ".doc", - ".docx", - ".txt", - ".md", - ".rtf", - ".odt", - ]; const filteredFiles = fileList.filter((file) => { const ext = file.name .substring(file.name.lastIndexOf(".")) .toLowerCase(); - return supportedExtensions.includes(ext); + return SUPPORTED_EXTENSIONS.includes(ext); }); if (filteredFiles.length === 0) { toast.error("No supported files found", { description: - "Please select a folder containing PDF, DOC, DOCX, TXT, MD, RTF, or ODT files.", + "Please select a folder containing supported document files (PDF, DOCX, PPTX, XLSX, CSV, HTML, images, etc.).", }); return; } @@ -527,7 +545,7 @@ export function KnowledgeDropdown() { type="file" onChange={handleFileChange} className="hidden" - accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt" + accept={SUPPORTED_EXTENSIONS.join(",")} /> None: + """Override notify to make notifications last 20 seconds by default.""" + # If timeout is None (default), make it 20 seconds + if timeout is None: + timeout = 20.0 + super().notify(message, title=title, severity=severity, timeout=timeout, markup=markup) def on_mount(self) -> None: """Initialize the application.""" diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index 04ba0a2a..b1fd73bb 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -35,6 +35,13 @@ class EnvConfig: langflow_ingest_flow_id: str = "5488df7c-b93f-4f87-a446-b67028bc0813" langflow_url_ingest_flow_id: str = "72c3d17c-2dac-4a73-b48a-6518473d7830" + # Provider API keys and endpoints + anthropic_api_key: str = "" + ollama_endpoint: str = "" + watsonx_api_key: str = "" + watsonx_endpoint: str = "" + watsonx_project_id: str = "" + # OAuth settings google_oauth_client_id: str = "" google_oauth_client_secret: str = "" @@ -109,57 +116,75 @@ class EnvManager: return f"'{escaped_value}'" def load_existing_env(self) -> bool: - """Load existing .env file if it exists.""" - if not self.env_file.exists(): - return False + """Load existing .env file if it exists, or fall back to environment variables.""" + import os + + # Map env vars to config attributes + attr_map = { + "OPENAI_API_KEY": "openai_api_key", + "ANTHROPIC_API_KEY": "anthropic_api_key", + "OLLAMA_ENDPOINT": "ollama_endpoint", + "WATSONX_API_KEY": "watsonx_api_key", + "WATSONX_ENDPOINT": "watsonx_endpoint", + "WATSONX_PROJECT_ID": "watsonx_project_id", + "OPENSEARCH_PASSWORD": "opensearch_password", + "LANGFLOW_SECRET_KEY": "langflow_secret_key", + "LANGFLOW_SUPERUSER": "langflow_superuser", + "LANGFLOW_SUPERUSER_PASSWORD": "langflow_superuser_password", + "LANGFLOW_CHAT_FLOW_ID": "langflow_chat_flow_id", + "LANGFLOW_INGEST_FLOW_ID": "langflow_ingest_flow_id", + "LANGFLOW_URL_INGEST_FLOW_ID": "langflow_url_ingest_flow_id", + "NUDGES_FLOW_ID": "nudges_flow_id", + "GOOGLE_OAUTH_CLIENT_ID": "google_oauth_client_id", + "GOOGLE_OAUTH_CLIENT_SECRET": "google_oauth_client_secret", + "MICROSOFT_GRAPH_OAUTH_CLIENT_ID": "microsoft_graph_oauth_client_id", + "MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET": "microsoft_graph_oauth_client_secret", + "WEBHOOK_BASE_URL": "webhook_base_url", + "AWS_ACCESS_KEY_ID": "aws_access_key_id", + "AWS_SECRET_ACCESS_KEY": "aws_secret_access_key", + "LANGFLOW_PUBLIC_URL": "langflow_public_url", + "OPENRAG_DOCUMENTS_PATHS": "openrag_documents_paths", + "OPENSEARCH_DATA_PATH": "opensearch_data_path", + "LANGFLOW_AUTO_LOGIN": "langflow_auto_login", + "LANGFLOW_NEW_USER_IS_ACTIVE": "langflow_new_user_is_active", + "LANGFLOW_ENABLE_SUPERUSER_CLI": "langflow_enable_superuser_cli", + "DISABLE_INGEST_WITH_LANGFLOW": "disable_ingest_with_langflow", + } + + loaded_from_file = False + + # Try to load from .env file first + if self.env_file.exists(): + try: + with open(self.env_file, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue - try: - with open(self.env_file, "r") as f: - for line in f: - line = line.strip() - if not line or line.startswith("#"): - continue + if "=" in line: + key, value = line.split("=", 1) + key = key.strip() + value = sanitize_env_value(value) - if "=" in line: - key, value = line.split("=", 1) - key = key.strip() - value = sanitize_env_value(value) + if key in attr_map: + setattr(self.config, attr_map[key], value) - # Map env vars to config attributes - attr_map = { - "OPENAI_API_KEY": "openai_api_key", - "OPENSEARCH_PASSWORD": "opensearch_password", - "LANGFLOW_SECRET_KEY": "langflow_secret_key", - "LANGFLOW_SUPERUSER": "langflow_superuser", - "LANGFLOW_SUPERUSER_PASSWORD": "langflow_superuser_password", - "LANGFLOW_CHAT_FLOW_ID": "langflow_chat_flow_id", - "LANGFLOW_INGEST_FLOW_ID": "langflow_ingest_flow_id", - "LANGFLOW_URL_INGEST_FLOW_ID": "langflow_url_ingest_flow_id", - "NUDGES_FLOW_ID": "nudges_flow_id", - "GOOGLE_OAUTH_CLIENT_ID": "google_oauth_client_id", - "GOOGLE_OAUTH_CLIENT_SECRET": "google_oauth_client_secret", - "MICROSOFT_GRAPH_OAUTH_CLIENT_ID": "microsoft_graph_oauth_client_id", - "MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET": "microsoft_graph_oauth_client_secret", - "WEBHOOK_BASE_URL": "webhook_base_url", - "AWS_ACCESS_KEY_ID": "aws_access_key_id", - "AWS_SECRET_ACCESS_KEY": "aws_secret_access_key", - "LANGFLOW_PUBLIC_URL": "langflow_public_url", - "OPENRAG_DOCUMENTS_PATHS": "openrag_documents_paths", - "OPENSEARCH_DATA_PATH": "opensearch_data_path", - "LANGFLOW_AUTO_LOGIN": "langflow_auto_login", - "LANGFLOW_NEW_USER_IS_ACTIVE": "langflow_new_user_is_active", - "LANGFLOW_ENABLE_SUPERUSER_CLI": "langflow_enable_superuser_cli", - "DISABLE_INGEST_WITH_LANGFLOW": "disable_ingest_with_langflow", - } - - if key in attr_map: - setattr(self.config, attr_map[key], value) + loaded_from_file = True + except Exception as e: + logger.error("Error loading .env file", error=str(e)) + + # Fall back to environment variables if .env file doesn't exist or failed to load + if not loaded_from_file: + logger.info("No .env file found, loading from environment variables") + for env_key, attr_name in attr_map.items(): + value = os.environ.get(env_key, "") + if value: + setattr(self.config, attr_name, value) return True - - except Exception as e: - logger.error("Error loading .env file", error=str(e)) - return False + + return loaded_from_file def setup_secure_defaults(self) -> None: """Set up secure default values for passwords and keys.""" @@ -197,6 +222,30 @@ class EnvManager: "Invalid OpenAI API key format (should start with sk-)" ) + # Import validation functions for new provider fields + from ..utils.validation import validate_anthropic_api_key + + # Validate Anthropic API key format if provided + if self.config.anthropic_api_key: + if not validate_anthropic_api_key(self.config.anthropic_api_key): + self.config.validation_errors["anthropic_api_key"] = ( + "Invalid Anthropic API key format (should start with sk-ant-)" + ) + + # Validate Ollama endpoint if provided + if self.config.ollama_endpoint: + if not validate_url(self.config.ollama_endpoint): + self.config.validation_errors["ollama_endpoint"] = ( + "Invalid Ollama endpoint URL format" + ) + + # Validate IBM watsonx.ai endpoint if provided + if self.config.watsonx_endpoint: + if not validate_url(self.config.watsonx_endpoint): + self.config.validation_errors["watsonx_endpoint"] = ( + "Invalid IBM watsonx.ai endpoint URL format" + ) + # Validate documents paths only if provided (optional) if self.config.openrag_documents_paths: is_valid, error_msg, _ = validate_documents_paths( @@ -289,9 +338,6 @@ class EnvManager: f.write(f"LANGFLOW_URL_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_url_ingest_flow_id)}\n") f.write(f"NUDGES_FLOW_ID={self._quote_env_value(self.config.nudges_flow_id)}\n") f.write(f"OPENSEARCH_PASSWORD={self._quote_env_value(self.config.opensearch_password)}\n") - # Only write OpenAI API key if provided (can be set during onboarding instead) - if self.config.openai_api_key: - f.write(f"OPENAI_API_KEY={self._quote_env_value(self.config.openai_api_key)}\n") f.write( f"OPENRAG_DOCUMENTS_PATHS={self._quote_env_value(self.config.openrag_documents_paths)}\n" ) @@ -300,6 +346,27 @@ class EnvManager: ) f.write("\n") + # Provider API keys and endpoints (optional - can be set during onboarding) + provider_vars = [] + if self.config.openai_api_key: + provider_vars.append(("OPENAI_API_KEY", self.config.openai_api_key)) + if self.config.anthropic_api_key: + provider_vars.append(("ANTHROPIC_API_KEY", self.config.anthropic_api_key)) + if self.config.ollama_endpoint: + provider_vars.append(("OLLAMA_ENDPOINT", self.config.ollama_endpoint)) + if self.config.watsonx_api_key: + provider_vars.append(("WATSONX_API_KEY", self.config.watsonx_api_key)) + if self.config.watsonx_endpoint: + provider_vars.append(("WATSONX_ENDPOINT", self.config.watsonx_endpoint)) + if self.config.watsonx_project_id: + provider_vars.append(("WATSONX_PROJECT_ID", self.config.watsonx_project_id)) + + if provider_vars: + f.write("# AI Provider API Keys and Endpoints\n") + for var_name, var_value in provider_vars: + f.write(f"{var_name}={self._quote_env_value(var_value)}\n") + f.write("\n") + # Ingestion settings f.write("# Ingestion settings\n") f.write(f"DISABLE_INGEST_WITH_LANGFLOW={self._quote_env_value(self.config.disable_ingest_with_langflow)}\n") diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 52d2b291..51662964 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -20,7 +20,13 @@ 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 ..utils.validation import ( + validate_openai_api_key, + validate_anthropic_api_key, + validate_ollama_endpoint, + validate_watsonx_endpoint, + validate_documents_paths, +) from pathlib import Path @@ -37,6 +43,45 @@ class OpenAIKeyValidator(Validator): return self.failure("Invalid OpenAI API key format (should start with sk-)") +class AnthropicKeyValidator(Validator): + """Validator for Anthropic API keys.""" + + def validate(self, value: str) -> ValidationResult: + if not value: + return self.success() + + if validate_anthropic_api_key(value): + return self.success() + else: + return self.failure("Invalid Anthropic API key format (should start with sk-ant-)") + + +class OllamaEndpointValidator(Validator): + """Validator for Ollama endpoint URLs.""" + + def validate(self, value: str) -> ValidationResult: + if not value: + return self.success() + + if validate_ollama_endpoint(value): + return self.success() + else: + return self.failure("Invalid Ollama endpoint URL format") + + +class WatsonxEndpointValidator(Validator): + """Validator for IBM watsonx.ai endpoint URLs.""" + + def validate(self, value: str) -> ValidationResult: + if not value: + return self.success() + + if validate_watsonx_endpoint(value): + return self.success() + else: + return self.failure("Invalid watsonx.ai endpoint URL format") + + class DocumentsPathValidator(Validator): """Validator for documents paths.""" @@ -100,6 +145,9 @@ class ConfigScreen(Screen): self.mode = mode # "no_auth" or "full" self.env_manager = EnvManager() self.inputs = {} + + # Check if .env file exists + self.has_env_file = self.env_manager.env_file.exists() # Load existing config if available self.env_manager.load_existing_env() @@ -111,12 +159,15 @@ class ConfigScreen(Screen): with ScrollableContainer(id="config-scroll"): with Vertical(id="config-form"): yield from self._create_all_fields() - yield Horizontal( + # Create button row - conditionally include Back button + buttons = [ 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", - ) + ] + # Only show Back button if .env file exists + if self.has_env_file: + buttons.append(Button("Back", variant="default", id="back-btn")) + yield Horizontal(*buttons, classes="button-row") yield Footer() def _create_header_text(self) -> Text: @@ -203,14 +254,13 @@ class ConfigScreen(Screen): yield Static(" ") # OpenAI API Key - yield Label("OpenAI API Key") - # Where to create OpenAI keys (helper above the box) + yield Label("OpenAI API Key (optional)") yield Static( Text("Get a key: https://platform.openai.com/api-keys", style="dim"), classes="helper-text", ) yield Static( - Text("Can also be provided during onboarding", style="dim italic"), + Text("Can be configured later in the UI", style="dim italic"), classes="helper-text", ) current_value = getattr(self.env_manager.config, "openai_api_key", "") @@ -227,6 +277,107 @@ class ConfigScreen(Screen): yield Button("Show", id="toggle-openai-key", variant="default") yield Static(" ") + # Anthropic API Key + yield Label("Anthropic API Key (optional)") + yield Static( + Text("Get a key: https://console.anthropic.com/settings/keys", style="dim"), + classes="helper-text", + ) + yield Static( + Text("Can be configured later in the UI", style="dim italic"), + classes="helper-text", + ) + current_value = getattr(self.env_manager.config, "anthropic_api_key", "") + with Horizontal(id="anthropic-key-row"): + input_widget = Input( + placeholder="sk-ant-...", + value=current_value, + password=True, + validators=[AnthropicKeyValidator()], + id="input-anthropic_api_key", + ) + yield input_widget + self.inputs["anthropic_api_key"] = input_widget + yield Button("Show", id="toggle-anthropic-key", variant="default") + yield Static(" ") + + # Ollama Endpoint + yield Label("Ollama Base URL (optional)") + yield Static( + Text("Endpoint of your Ollama server", style="dim"), + classes="helper-text", + ) + yield Static( + Text("Can be configured later in the UI", style="dim italic"), + classes="helper-text", + ) + current_value = getattr(self.env_manager.config, "ollama_endpoint", "") + input_widget = Input( + placeholder="http://localhost:11434", + value=current_value, + validators=[OllamaEndpointValidator()], + id="input-ollama_endpoint", + ) + yield input_widget + self.inputs["ollama_endpoint"] = input_widget + yield Static(" ") + + # IBM watsonx.ai API Key + yield Label("IBM watsonx.ai API Key (optional)") + yield Static( + Text("Get a key: https://cloud.ibm.com/iam/apikeys", style="dim"), + classes="helper-text", + ) + yield Static( + Text("Can be configured later in the UI", style="dim italic"), + classes="helper-text", + ) + current_value = getattr(self.env_manager.config, "watsonx_api_key", "") + with Horizontal(id="watsonx-key-row"): + input_widget = Input( + placeholder="", + value=current_value, + password=True, + id="input-watsonx_api_key", + ) + yield input_widget + self.inputs["watsonx_api_key"] = input_widget + yield Button("Show", id="toggle-watsonx-key", variant="default") + yield Static(" ") + + # IBM watsonx.ai Endpoint + yield Label("IBM watsonx.ai Endpoint") + yield Static( + Text("Example: https://us-south.ml.cloud.ibm.com", style="dim"), + classes="helper-text", + ) + current_value = getattr(self.env_manager.config, "watsonx_endpoint", "") + input_widget = Input( + placeholder="https://us-south.ml.cloud.ibm.com", + value=current_value, + validators=[WatsonxEndpointValidator()], + id="input-watsonx_endpoint", + ) + yield input_widget + self.inputs["watsonx_endpoint"] = input_widget + yield Static(" ") + + # IBM watsonx.ai Project ID + yield Label("IBM watsonx.ai Project ID") + yield Static( + Text("Find in your IBM Cloud project settings", style="dim"), + classes="helper-text", + ) + current_value = getattr(self.env_manager.config, "watsonx_project_id", "") + input_widget = Input( + placeholder="", + value=current_value, + id="input-watsonx_project_id", + ) + yield input_widget + self.inputs["watsonx_project_id"] = input_widget + yield Static(" ") + # Add OAuth fields only in full mode if self.mode == "full": # Google OAuth Client ID @@ -550,6 +701,18 @@ class ConfigScreen(Screen): if input_widget: input_widget.password = not input_widget.password event.button.label = "🙈" if not input_widget.password else "👁" + elif event.button.id == "toggle-anthropic-key": + # Toggle Anthropic API key visibility + input_widget = self.inputs.get("anthropic_api_key") + if input_widget: + input_widget.password = not input_widget.password + event.button.label = "Hide" if not input_widget.password else "Show" + elif event.button.id == "toggle-watsonx-key": + # Toggle watsonx API key visibility + input_widget = self.inputs.get("watsonx_api_key") + if input_widget: + input_widget.password = not input_widget.password + event.button.label = "Hide" if not input_widget.password else "Show" def action_generate(self) -> None: """Generate secure passwords for admin accounts.""" diff --git a/src/tui/screens/welcome.py b/src/tui/screens/welcome.py index 66321010..64ad888a 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -22,13 +22,6 @@ class WelcomeScreen(Screen): BINDINGS = [ ("q", "quit", "Quit"), - ("enter", "default_action", "Continue"), - ("1", "no_auth_setup", "Basic Setup"), - ("2", "full_setup", "Advanced Setup"), - ("3", "monitor", "Status"), - ("4", "diagnostics", "Diagnostics"), - ("5", "start_stop_services", "Start/Stop Services"), - ("6", "open_app", "Open App"), ] def __init__(self): @@ -41,6 +34,9 @@ class WelcomeScreen(Screen): self.has_oauth_config = False self.default_button_id = "basic-setup-btn" self._state_checked = False + + # Check if .env file exists + self.has_env_file = self.env_manager.env_file.exists() # Load .env file if it exists load_dotenv() @@ -161,6 +157,23 @@ class WelcomeScreen(Screen): buttons = [] + # If no .env file exists, only show setup buttons + if not self.has_env_file: + if has_oauth: + # If OAuth is configured, only show advanced setup + buttons.append( + Button("Advanced Setup", variant="success", id="advanced-setup-btn") + ) + else: + # If no OAuth, show both options with basic as primary + buttons.append( + Button("Basic Setup", variant="success", id="basic-setup-btn") + ) + buttons.append( + Button("Advanced Setup", variant="default", id="advanced-setup-btn") + ) + return Horizontal(*buttons, classes="button-row") + # Check if all services (native + container) are running all_services_running = self.services_running and self.docling_running @@ -189,7 +202,7 @@ class WelcomeScreen(Screen): ) buttons.append( - Button("Start All Services", variant="primary", id="start-all-services-btn") + Button("Start OpenRAG", variant="primary", id="start-all-services-btn") ) # Always show status option @@ -253,6 +266,9 @@ class WelcomeScreen(Screen): async def on_screen_resume(self) -> None: """Called when returning from another screen (e.g., config screen).""" + # Check if .env file exists (may have been created) + self.has_env_file = self.env_manager.env_file.exists() + # Reload environment variables load_dotenv(override=True) diff --git a/src/tui/utils/validation.py b/src/tui/utils/validation.py index c9764d00..c91c4f00 100644 --- a/src/tui/utils/validation.py +++ b/src/tui/utils/validation.py @@ -63,6 +63,27 @@ def validate_openai_api_key(key: str) -> bool: return key.startswith("sk-") and len(key) > 20 +def validate_anthropic_api_key(key: str) -> bool: + """Validate Anthropic API key format.""" + if not key: + return False + return key.startswith("sk-ant-") and len(key) > 20 + + +def validate_ollama_endpoint(endpoint: str) -> bool: + """Validate Ollama endpoint URL format.""" + if not endpoint: + return False + return validate_url(endpoint) + + +def validate_watsonx_endpoint(endpoint: str) -> bool: + """Validate IBM watsonx.ai endpoint URL format.""" + if not endpoint: + return False + return validate_url(endpoint) + + def validate_google_oauth_client_id(client_id: str) -> bool: """Validate Google OAuth client ID format.""" if not client_id: diff --git a/uv.lock b/uv.lock index cb8cd34e..e99e9dc8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -2352,7 +2352,7 @@ wheels = [ [[package]] name = "openrag" -version = "0.1.35" +version = "0.1.37" source = { editable = "." } dependencies = [ { name = "agentd" },