diff --git a/.env.example b/.env.example index dce12c465..dafcabf0c 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,11 @@ OPENSEARCH_PASSWORD= # Default: ./opensearch-data OPENSEARCH_DATA_PATH=./opensearch-data +# Path to persist Langflow database and state (flows, credentials, settings) +# Without this volume, flow edits will be lost on container restart +# Default: ./langflow-data +LANGFLOW_DATA_PATH=./langflow-data + # OpenSearch Connection OPENSEARCH_HOST=opensearch OPENSEARCH_PORT=9200 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index f73f89bb7..6b80efbeb 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -37,10 +37,10 @@ jobs: docker builder prune -af || true docker compose -f docker-compose.yml down -v --remove-orphans || true - - name: Cleanup root-owned files (OpenSearch data, config) + - name: Cleanup root-owned files (OpenSearch data, config, Langflow data) run: | for i in 1 2 3; do - docker run --rm -v $(pwd):/work alpine sh -c "rm -rf /work/opensearch-data /work/config" && break + docker run --rm -v $(pwd):/work alpine sh -c "rm -rf /work/opensearch-data /work/config /work/langflow-data" && break echo "Attempt $i failed, retrying in 5s..." sleep 5 done || true diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 4dd3485e3..470ccbaa5 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -42,10 +42,10 @@ jobs: docker builder prune -af || true docker compose -f docker-compose.yml down -v --remove-orphans || true - - name: Cleanup root-owned files (OpenSearch data, config) + - name: Cleanup root-owned files (OpenSearch data, config, Langflow data) run: | for i in 1 2 3; do - docker run --rm -v $(pwd):/work alpine sh -c "rm -rf /work/opensearch-data /work/config" && break + docker run --rm -v $(pwd):/work alpine sh -c "rm -rf /work/opensearch-data /work/config /work/langflow-data" && break echo "Attempt $i failed, retrying in 5s..." sleep 5 done || true diff --git a/.gitignore b/.gitignore index 7da9d1140..8ee2c4059 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ wheels/ # OpenSearch data directory opensearch-data/ +# Langflow data directory +langflow-data/ node_modules diff --git a/Dockerfile.langflow b/Dockerfile.langflow index 2f8286cd0..abb651c74 100644 --- a/Dockerfile.langflow +++ b/Dockerfile.langflow @@ -1,7 +1,20 @@ +# syntax=docker/dockerfile:1.4 FROM langflowai/langflow:1.8.0 -RUN pip install uv +# Switch to root so the entrypoint can fix data directory ownership before dropping privileges. +# The base image runs as uid=1000; we restore that in the entrypoint script. +USER root + +# (+) Install uv +# (+) Pre-create the Langflow data directory. +# - For named Docker volumes, this seeds the volume with the correct path on first mount. +# - For bind mounts, the entrypoint chowns the directory at startup. +RUN pip install uv \ + && mkdir -p /app/langflow-data + +COPY --chmod=755 docker-entrypoint-langflow.sh /docker-entrypoint-langflow.sh EXPOSE 7860 -CMD ["langflow", "run", "--host", "0.0.0.0", "--port", "7860"] \ No newline at end of file +ENTRYPOINT ["/docker-entrypoint-langflow.sh"] +CMD ["langflow", "run", "--host", "0.0.0.0", "--port", "7860"] diff --git a/Makefile b/Makefile index f5d391e8f..df179205c 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,8 @@ endef test test-unit test-integration test-ci test-ci-local test-sdk test-os-jwt lint \ backend frontend docling docling-stop install-be install-fe build-be build-fe build-os build-lf logs-be logs-fe logs-lf logs-os \ shell-be shell-lf shell-os restart status health db-reset clear-os-data flow-upload setup factory-reset \ - dev-branch build-langflow-dev stop-dev clean-dev logs-dev logs-lf-dev shell-lf-dev restart-dev status-dev + dev-branch build-langflow-dev stop-dev clean-dev logs-dev logs-lf-dev shell-lf-dev restart-dev status-dev \ + ensure-langflow-data all: help @@ -319,7 +320,10 @@ help_utils: ## Show utility commands # DEVELOPMENT ENVIRONMENTS ###################### -dev: ## Start full stack with GPU support +ensure-langflow-data: ## Create the langflow-data directory if it does not exist + @mkdir -p langflow-data + +dev: ensure-langflow-data ## Start full stack with GPU support @echo "$(YELLOW)Starting OpenRAG with GPU support...$(NC)" $(COMPOSE_CMD) -f docker-compose.yml -f docker-compose.gpu.yml up -d @echo "$(PURPLE)Services started!$(NC)" @@ -329,7 +333,7 @@ dev: ## Start full stack with GPU support @echo " $(CYAN)OpenSearch:$(NC) http://localhost:9200" @echo " $(CYAN)Dashboards:$(NC) http://localhost:5601" -dev-cpu: ## Start full stack with CPU only +dev-cpu: ensure-langflow-data ## Start full stack with CPU only @echo "$(YELLOW)Starting OpenRAG with CPU only...$(NC)" $(COMPOSE_CMD) up -d @echo "$(PURPLE)Services started!$(NC)" @@ -339,7 +343,7 @@ dev-cpu: ## Start full stack with CPU only @echo " $(CYAN)OpenSearch:$(NC) http://localhost:9200" @echo " $(CYAN)Dashboards:$(NC) http://localhost:5601" -dev-local: ## Start infrastructure for local development +dev-local: ensure-langflow-data ## Start infrastructure for local development @echo "$(YELLOW)Starting infrastructure only (for local development)...$(NC)" $(COMPOSE_CMD) -f docker-compose.yml -f docker-compose.gpu.yml up -d opensearch openrag-backend dashboards langflow @echo "$(PURPLE)Infrastructure started!$(NC)" @@ -350,7 +354,7 @@ dev-local: ## Start infrastructure for local development @echo "" @echo "$(YELLOW)Now run 'make backend' and 'make frontend' in separate terminals$(NC)" -dev-local-cpu: ## Start infrastructure for local development, with CPU only +dev-local-cpu: ensure-langflow-data ## Start infrastructure for local development, with CPU only @echo "$(YELLOW)Starting infrastructure only (for local development)...$(NC)" $(COMPOSE_CMD) up -d opensearch openrag-backend dashboards langflow @echo "$(PURPLE)Infrastructure started!$(NC)" @@ -361,7 +365,7 @@ dev-local-cpu: ## Start infrastructure for local development, with CPU only @echo "" @echo "$(YELLOW)Now run 'make backend' and 'make frontend' in separate terminals$(NC)" -dev-local-build-lf: ## Start infrastructure for local development, building only Langflow image +dev-local-build-lf: ensure-langflow-data ## Start infrastructure for local development, building only Langflow image @echo "$(YELLOW)Building Langflow image...$(NC)" $(COMPOSE_CMD) -f docker-compose.yml -f docker-compose.gpu.yml build langflow @echo "$(YELLOW)Starting infrastructure only (for local development)...$(NC)" @@ -374,7 +378,7 @@ dev-local-build-lf: ## Start infrastructure for local development, building only @echo "" @echo "$(YELLOW)Now run 'make backend' and 'make frontend' in separate terminals$(NC)" -dev-local-build-lf-cpu: ## Start infrastructure for local development, building only Langflow image with CPU only +dev-local-build-lf-cpu: ensure-langflow-data ## Start infrastructure for local development, building only Langflow image with CPU only @echo "$(YELLOW)Building Langflow image (CPU)...$(NC)" $(COMPOSE_CMD) build langflow @echo "$(YELLOW)Starting infrastructure only (for local development)...$(NC)" @@ -393,7 +397,7 @@ dev-local-build-lf-cpu: ## Start infrastructure for local development, building # Usage: make dev-branch BRANCH=test-openai-responses # make dev-branch BRANCH=feature-x REPO=https://github.com/myorg/langflow.git -dev-branch: ## Build & run full stack with custom Langflow branch +dev-branch: ensure-langflow-data ## Build & run full stack with custom Langflow branch @echo "$(YELLOW)Building Langflow from branch: $(BRANCH)$(NC)" @echo " $(CYAN)Repository:$(NC) $(REPO)" @echo "" @@ -409,7 +413,7 @@ dev-branch: ## Build & run full stack with custom Langflow branch @echo " $(CYAN)OpenSearch:$(NC) http://localhost:9200" @echo " $(CYAN)Dashboards:$(NC) http://localhost:5601" -dev-branch-cpu: ## Build & run full stack with custom Langflow branch and CPU only mode +dev-branch-cpu: ensure-langflow-data ## Build & run full stack with custom Langflow branch and CPU only mode @echo "$(YELLOW)Building Langflow from branch: $(BRANCH)$(NC)" @echo " $(CYAN)Repository:$(NC) $(REPO)" @echo "" @@ -436,7 +440,7 @@ stop-dev: ## Stop dev environment containers $(COMPOSE_CMD) -f docker-compose.dev.yml down @echo "$(PURPLE)Dev environment stopped.$(NC)" -restart-dev: ## Restart dev environment +restart-dev: ensure-langflow-data ## Restart dev environment @echo "$(YELLOW)Restarting dev environment with branch: $(BRANCH)$(NC)" $(COMPOSE_CMD) -f docker-compose.dev.yml down GIT_BRANCH=$(BRANCH) GIT_REPO=$(REPO) $(COMPOSE_CMD) -f docker-compose.dev.yml up -d @@ -502,6 +506,7 @@ factory-reset: ## Complete reset (stop, remove volumes, clear data, remove image echo " - Stop all containers"; \ echo " - Remove all volumes"; \ echo " - Delete opensearch-data directory"; \ + echo " - Delete langflow-data directory"; \ echo " - Delete config directory"; \ echo " - Delete JWT keys (private_key.pem, public_key.pem)"; \ echo " - Remove OpenRAG images"; \ @@ -525,6 +530,11 @@ factory-reset: ## Complete reset (stop, remove volumes, clear data, remove image rm -rf opensearch-data/* 2>/dev/null || true; \ echo "$(PURPLE)opensearch-data removed$(NC)"; \ fi; \ + if [ -d "langflow-data" ]; then \ + echo "Removing langflow-data..."; \ + rm -rf langflow-data; \ + echo "$(PURPLE)langflow-data removed$(NC)"; \ + fi; \ if [ -d "config" ]; then \ echo "Removing config..."; \ rm -rf config; \ @@ -671,7 +681,7 @@ test-integration: ## Run integration tests (requires infrastructure) @echo "$(YELLOW)Make sure to run 'make dev-local' first!$(NC)" uv run pytest tests/integration/core/ -v -test-ci: ## Start infra, run integration + SDK tests, tear down (uses DockerHub images) +test-ci: ensure-langflow-data ## Start infra, run integration + SDK tests, tear down (uses DockerHub images) @set -e; \ echo "$(YELLOW)Installing test dependencies...$(NC)"; \ uv sync --group dev; \ @@ -800,7 +810,7 @@ test-ci: ## Start infra, run integration + SDK tests, tear down (uses DockerHub $(COMPOSE_CMD) down -v 2>/dev/null || true; \ exit $$TEST_RESULT -test-ci-local: ## Same as test-ci but builds all images locally +test-ci-local: ensure-langflow-data ## Same as test-ci but builds all images locally @set -e; \ echo "$(YELLOW)Installing test dependencies...$(NC)"; \ uv sync --group dev; \ diff --git a/docker-compose.yml b/docker-compose.yml index a4faf3692..6a2a07f16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -127,6 +127,7 @@ services: langflow: volumes: - ${OPENRAG_FLOWS_PATH:-./flows}:/app/flows:U,z + - ${LANGFLOW_DATA_PATH:-./langflow-data}:/app/langflow-data:U,z image: langflowai/openrag-langflow:${OPENRAG_VERSION:-latest} build: context: . @@ -145,7 +146,8 @@ services: - WATSONX_URL=${WATSONX_URL:-${WATSONX_ENDPOINT}} - WATSONX_PROJECT_ID=${WATSONX_PROJECT_ID} - OLLAMA_BASE_URL=${OLLAMA_ENDPOINT} - - LANGFLOW_LOAD_FLOWS_PATH=/app/flows + - LANGFLOW_CONFIG_DIR=/app/langflow-data + - LANGFLOW_DATABASE_URL=sqlite:////app/langflow-data/langflow.db - LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY} - JWT=None - OWNER=None diff --git a/docker-entrypoint-langflow.sh b/docker-entrypoint-langflow.sh new file mode 100644 index 000000000..550ddcd53 --- /dev/null +++ b/docker-entrypoint-langflow.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +# Fix ownership of the Langflow data directory so the container user (uid=1000) can write to it. +# When the directory is bind-mounted from a host with a different UID (e.g. CI runners at uid=1001), +# the container user cannot create files. Running chown here as root — before dropping privileges — +# mirrors the pattern used by official database images (OpenSearch, PostgreSQL, Redis). +chown -R 1000:1000 /app/langflow-data + +# Drop from root to uid=1000 and exec the main process. +# Python is used for privilege drop — it is guaranteed to be present in the Langflow image +# and requires no additional packages (unlike gosu or su-exec). +exec python3 -c 'import os, sys; os.setgid(1000); os.setuid(1000); os.execvp(sys.argv[1], sys.argv[1:])' "$@" diff --git a/docs/docs/reference/configuration.mdx b/docs/docs/reference/configuration.mdx index e2ba0470f..22837dfcf 100644 --- a/docs/docs/reference/configuration.mdx +++ b/docs/docs/reference/configuration.mdx @@ -94,6 +94,7 @@ For better security, it is recommended to set `LANGFLOW_SUPERUSER_PASSWORD` so t | Variable | Default | Description | |----------|---------|-------------| +| `LANGFLOW_DATA_PATH` | `./langflow-data` | The path where OpenRAG persists the Langflow database (flows, credentials, settings) across container restarts. | | `LANGFLOW_AUTO_LOGIN` | Determined by `LANGFLOW_SUPERUSER_PASSWORD` | Whether to enable [auto-login mode](https://docs.langflow.org/api-keys-and-authentication#langflow-auto-login) for the Langflow visual editor and CLI. If `LANGFLOW_SUPERUSER_PASSWORD` isn't set, then `LANGFLOW_AUTO_LOGIN` is `True` and auto-login mode is enabled. If `LANGFLOW_SUPERUSER_PASSWORD` is set, then `LANGFLOW_AUTO_LOGIN` is `False` and auto-login mode is disabled. Langflow API calls always require authentication with a Langflow API key regardless of the auto-login setting. | | `LANGFLOW_ENABLE_SUPERUSER_CLI` | Determined by `LANGFLOW_SUPERUSER_PASSWORD` | Whether to enable the [Langflow CLI `langflow superuser` command](https://docs.langflow.org/api-keys-and-authentication#langflow-enable-superuser-cli). If `LANGFLOW_SUPERUSER_PASSWORD` isn't set, then `LANGFLOW_ENABLE_SUPERUSER_CLI` is `True` and superuser accounts can be created with the Langflow CLI. If `LANGFLOW_SUPERUSER_PASSWORD` is set, then `LANGFLOW_ENABLE_SUPERUSER_CLI` is `False` and the `langflow superuser` command is disabled. | | `LANGFLOW_NEW_USER_IS_ACTIVE` | Determined by `LANGFLOW_SUPERUSER_PASSWORD` | Whether new [Langflow user accounts are active by default](https://docs.langflow.org/api-keys-and-authentication#langflow-new-user-is-active). If `LANGFLOW_SUPERUSER_PASSWORD` isn't set, then `LANGFLOW_NEW_USER_IS_ACTIVE` is `True` and new user accounts are active by default. If `LANGFLOW_SUPERUSER_PASSWORD` is set, then `LANGFLOW_NEW_USER_IS_ACTIVE` is `False` and new user accounts are inactive by default. | diff --git a/frontend/.env.test.example b/frontend/.env.test.example index f53600840..c053ca248 100644 --- a/frontend/.env.test.example +++ b/frontend/.env.test.example @@ -7,6 +7,7 @@ OPENSEARCH_PASSWORD= # Paths OPENSEARCH_DATA_PATH=./opensearch-data +LANGFLOW_DATA_PATH=./langflow-data OPENSEARCH_INDEX_NAME=documents # Model Providers diff --git a/src/main.py b/src/main.py index c168f6c6a..ac44e20b6 100644 --- a/src/main.py +++ b/src/main.py @@ -1198,6 +1198,15 @@ async def startup_tasks(services): # Update MCP servers with provider credentials (especially important for no-auth mode) await _update_mcp_servers_with_provider_credentials(services) + # Ensure all configured flows exist in Langflow (create-only, never overwrites). + # This replaces LANGFLOW_LOAD_FLOWS_PATH, which performed a blind upsert on + # every container start and discarded any user edits made in the Langflow UI. + try: + flows_service = services["flows_service"] + await flows_service.ensure_flows_exist() + except Exception as e: + logger.warning("Failed to ensure Langflow flows exist at startup", error=str(e)) + # Check if flows were reset and reapply settings if config is edited try: config = get_openrag_config() diff --git a/src/services/flows_service.py b/src/services/flows_service.py index 00b381fb4..137af1080 100644 --- a/src/services/flows_service.py +++ b/src/services/flows_service.py @@ -47,7 +47,7 @@ async def resolve_ollama_url(self, endpoint: str, force_refresh: bool = False) - resolved_url = None for cand in candidates: test_url = replace_localhost_patterns(endpoint, cand) - + logger.debug(f"Probing Ollama candidate via Langflow: {test_url}") try: response = await clients.langflow_request( @@ -61,7 +61,7 @@ async def resolve_ollama_url(self, endpoint: str, force_refresh: bool = False) - except Exception as e: logger.debug(f"Probe failed for {test_url}: {e}") continue - + if not resolved_url: # Fallback to simple transformation if probing fails resolved_url = transform_localhost_url(endpoint) @@ -95,23 +95,23 @@ def _get_backup_directory(self): def _get_latest_backup_path(self, flow_id: str, flow_type: str): """ Get the path to the latest backup file for a flow. - + Args: flow_id: The flow ID flow_type: The flow type name - + Returns: str: Path to latest backup file, or None if no backup exists """ backup_dir = self._get_backup_directory() - + if not os.path.exists(backup_dir): return None - + # Find all backup files for this flow backup_files = [] prefix = f"{flow_type}_" - + try: for filename in os.listdir(backup_dir): if filename.startswith(prefix) and filename.endswith(".json"): @@ -122,10 +122,10 @@ def _get_latest_backup_path(self, flow_id: str, flow_type: str): except Exception as e: logger.warning(f"Error reading backup directory: {str(e)}") return None - + if not backup_files: return None - + # Return the most recent backup (highest mtime) backup_files.sort(key=lambda x: x[0], reverse=True) return backup_files[0][1] @@ -134,17 +134,17 @@ def _compare_flows(self, flow1: dict, flow2: dict): """ Compare two flow structures to see if they're different. Normalizes both flows before comparison. - + Args: flow1: First flow data flow2: Second flow data - + Returns: bool: True if flows are different, False if they're the same """ normalized1 = self._normalize_flow_structure(flow1) normalized2 = self._normalize_flow_structure(flow2) - + # Compare normalized structures return normalized1 != normalized2 @@ -152,10 +152,10 @@ async def backup_all_flows(self, only_if_changed=True): """ Backup all flows from Langflow to the backup folder. Only backs up flows that have changed since the last backup. - + Args: only_if_changed: If True, only backup flows that differ from latest backup - + Returns: dict: Summary of backup operations with success/failure status """ @@ -200,7 +200,7 @@ async def backup_all_flows(self, only_if_changed=True): flow_locked = current_flow.get("locked", False) latest_backup_path = self._get_latest_backup_path(flow_id, flow_type) has_backups = latest_backup_path is not None - + # If flow is locked and no backups exist, skip backup if flow_locked and not has_backups: logger.debug( @@ -212,13 +212,13 @@ async def backup_all_flows(self, only_if_changed=True): "reason": "locked_without_backups", }) continue - + # Check if we need to backup (only if changed) if only_if_changed and has_backups: try: with open(latest_backup_path, "r") as f: latest_backup = json.load(f) - + # Compare flows if not self._compare_flows(current_flow, latest_backup): logger.debug( @@ -280,12 +280,12 @@ async def backup_all_flows(self, only_if_changed=True): async def _backup_flow(self, flow_id: str, flow_type: str, flow_data: dict = None): """ Backup a single flow to the backup folder. - + Args: flow_id: The flow ID to backup flow_type: The flow type name (nudges, retrieval, ingest, url_ingest) flow_data: The flow data to backup (if None, fetches from API) - + Returns: str: Path to the backup file, or None if backup failed """ @@ -717,7 +717,7 @@ def _normalize_flow_structure(self, flow_data): for node in nodes: node_data = node.get("data", {}) node_template = node_data.get("node", {}) - + normalized_node = { "id": node.get("id"), # Keep ID for edge matching "type": node.get("type"), @@ -775,20 +775,20 @@ async def _compare_flow_with_file(self, flow_id: str): # Compare entire normalized structures exactly # Sort nodes and edges for consistent comparison normalized_langflow["data"]["nodes"] = sorted( - normalized_langflow["data"]["nodes"], + normalized_langflow["data"]["nodes"], key=lambda x: (x.get("id", ""), x.get("type", "")) ) normalized_file["data"]["nodes"] = sorted( - normalized_file["data"]["nodes"], + normalized_file["data"]["nodes"], key=lambda x: (x.get("id", ""), x.get("type", "")) ) normalized_langflow["data"]["edges"] = sorted( - normalized_langflow["data"]["edges"], + normalized_langflow["data"]["edges"], key=lambda x: (x.get("source", ""), x.get("target", ""), x.get("sourceHandle", ""), x.get("targetHandle", "")) ) normalized_file["data"]["edges"] = sorted( - normalized_file["data"]["edges"], + normalized_file["data"]["edges"], key=lambda x: (x.get("source", ""), x.get("target", ""), x.get("sourceHandle", ""), x.get("targetHandle", "")) ) @@ -799,6 +799,67 @@ async def _compare_flow_with_file(self, flow_id: str): logger.error(f"Error comparing flow {flow_id} with file: {str(e)}") return False + async def ensure_flows_exist(self): + """ + Ensure all configured flows exist in Langflow. + + Creates flows from their JSON files if they are not already present in + the Langflow database. This is intentionally create-only: it never + patches or overwrites an existing flow, preserving any edits the user + has made in the Langflow UI. + + This replaces the LANGFLOW_LOAD_FLOWS_PATH mechanism, which performed a + blind upsert on every container start and discarded user edits. + """ + flow_configs = [ + ("nudges", NUDGES_FLOW_ID), + ("retrieval", LANGFLOW_CHAT_FLOW_ID), + ("ingest", LANGFLOW_INGEST_FLOW_ID), + ("url_ingest", LANGFLOW_URL_INGEST_FLOW_ID), + ] + + for flow_type, flow_id in flow_configs: + if not flow_id: + continue + + try: + response = await clients.langflow_request( + "GET", f"/api/v1/flows/{flow_id}" + ) + if response.status_code == 200: + logger.info( + f"Flow {flow_type} (ID: {flow_id}) already exists, skipping creation" + ) + continue + + flow_path = self._find_flow_file_by_id(flow_id) + if not flow_path: + logger.warning( + f"No flow file found for {flow_type} (ID: {flow_id}), cannot create" + ) + continue + + with open(flow_path, "r") as f: + flow_data = json.load(f) + + response = await clients.langflow_request( + "PUT", f"/api/v1/flows/{flow_id}", json=flow_data + ) + if response.status_code in (200, 201): + logger.info( + f"Created {flow_type} flow (ID: {flow_id}) from {os.path.basename(flow_path)}" + ) + else: + logger.warning( + f"Failed to create {flow_type} flow (ID: {flow_id}): " + f"HTTP {response.status_code} — {response.text}" + ) + + except Exception as e: + logger.error( + f"Error ensuring {flow_type} flow (ID: {flow_id}) exists: {e}" + ) + async def check_flows_reset(self): """ Check if any flows have been reset by comparing with JSON files. @@ -819,7 +880,7 @@ async def check_flows_reset(self): logger.info(f"Checking if {flow_type} flow (ID: {flow_id}) was reset") is_reset = await self._compare_flow_with_file(flow_id) - + if is_reset: logger.info(f"Flow {flow_type} (ID: {flow_id}) appears to have been reset") reset_flows.append(flow_type) @@ -827,7 +888,7 @@ async def check_flows_reset(self): logger.info(f"Flow {flow_type} (ID: {flow_id}) does not match reset state") return reset_flows - + async def change_langflow_model_value( self, provider: str, @@ -917,23 +978,23 @@ async def _update_provider_components( # Get all embedding nodes in the flow embedding_nodes = self._find_nodes_in_flow(flow_data, display_name=OPENAI_EMBEDDING_COMPONENT_DISPLAY_NAME) logger.info(f"Found {len(embedding_nodes)} embedding nodes in flow {flow_name} with display name '{OPENAI_EMBEDDING_COMPONENT_DISPLAY_NAME}'") - + # Count configured embedding-enabled providers config_obj = get_openrag_config() configured_providers = [] if config_obj.providers.openai.configured: configured_providers.append("openai") if config_obj.providers.watsonx.configured: configured_providers.append("watsonx") if config_obj.providers.ollama.configured: configured_providers.append("ollama") - + # Ensure current provider is in the list for counting purposes if it's being configured if provider in ["openai", "watsonx", "ollama"] and provider not in configured_providers: configured_providers.append(provider) - + all_possible = ["openai", "watsonx", "ollama"] configured_providers = [p for p in all_possible if p in configured_providers] provider_count = len(configured_providers) logger.info(f"Configured embedding providers: {configured_providers} (count: {provider_count})") - + # Determine slot mapping context if provider_count == 1: logger.info("Configuration mode: all 3 slots belong to the single active provider") @@ -948,7 +1009,7 @@ async def _update_provider_components( for node, idx in embedding_nodes: if self._get_node_provider(node) == provider_display: matched_nodes.append((node, idx)) - + if matched_nodes: logger.info(f"Found {len(matched_nodes)} nodes already configured for provider '{provider}'") for node, idx in matched_nodes: @@ -1035,7 +1096,7 @@ async def _update_component_langflow(self, template, model: str): # Only call if code field exists (custom components should have code) if "code" in template and "value" in template["code"]: code_value = template["code"]["value"] - + try: update_payload = { "code": code_value, @@ -1044,11 +1105,11 @@ async def _update_component_langflow(self, template, model: str): "field_value": model, "tool_mode": False, } - + response = await clients.langflow_request( "POST", "/api/v1/custom_component/update", json=update_payload ) - + if response.status_code == 200: response_data = response.json() # Update template with the new template from response.data @@ -1161,11 +1222,11 @@ async def _enable_model_in_langflow(self, provider_name: str, model_value: str): "model_id": model_value, "enabled": True }] - + response = await clients.langflow_request( "POST", "/api/v1/models/enabled_models", json=enable_payload ) - + if response.status_code == 200: logger.info(f"Successfully enabled model {model_value} for provider {provider_name}") else: diff --git a/src/tui/config_fields.py b/src/tui/config_fields.py index 184365a32..d7bf89a64 100644 --- a/src/tui/config_fields.py +++ b/src/tui/config_fields.py @@ -91,6 +91,12 @@ class ConfigSection: "langflow_superuser", "LANGFLOW_SUPERUSER", "Admin Username", placeholder="admin", default="admin", ), + ConfigField( + "langflow_data_path", "LANGFLOW_DATA_PATH", "Data Path", + placeholder="~/.openrag/data/langflow-data", + default="$HOME/.openrag/data/langflow-data", + helper_text="Directory to persist Langflow flows and state across restarts", + ), ConfigField( "langflow_public_url", "LANGFLOW_PUBLIC_URL", "Public URL", placeholder="http://localhost:7860", diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index 1a7780b65..5545df7e0 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -95,6 +95,7 @@ class EnvConfig: openrag_config_path: str = "$HOME/.openrag/config" openrag_data_path: str = "$HOME/.openrag/data" # Backend data (conversations, tokens, etc.) opensearch_data_path: str = "$HOME/.openrag/data/opensearch-data" + langflow_data_path: str = "$HOME/.openrag/data/langflow-data" openrag_tui_config_path_legacy: str = "$HOME/.openrag/tui/config" # Container version (linked to TUI version) @@ -212,6 +213,7 @@ def _env_attr_map(self) -> Dict[str, str]: "OPENRAG_CONFIG_PATH": "openrag_config_path", "OPENRAG_DATA_PATH": "openrag_data_path", "OPENSEARCH_DATA_PATH": "opensearch_data_path", + "LANGFLOW_DATA_PATH": "langflow_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", @@ -492,6 +494,9 @@ def save_env_file(self) -> bool: f.write( f"OPENSEARCH_DATA_PATH={self._quote_env_value(expand_path(self.config.opensearch_data_path))}\n" ) + f.write( + f"LANGFLOW_DATA_PATH={self._quote_env_value(expand_path(self.config.langflow_data_path))}\n" + ) # Set OPENRAG_VERSION to TUI version if self.config.openrag_version: f.write(f"OPENRAG_VERSION={self._quote_env_value(self.config.openrag_version)}\n") diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 14639b034..d88013010 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -204,6 +204,7 @@ def _create_header_text(self) -> Text: "opensearch_data_path", "langflow_superuser_password", "langflow_superuser", + "langflow_data_path", "google_oauth_client_id", "microsoft_graph_oauth_client_id", "openrag_documents_paths", @@ -307,6 +308,25 @@ def _render_opensearch_data_path(self, field: ConfigField) -> ComposeResult: self.inputs[field.name] = input_widget yield Static(" ") + def _render_langflow_data_path(self, field: ConfigField) -> ComposeResult: + """Langflow data path with file picker.""" + yield Label(field.label) + yield Static(field.helper_text, classes="helper-text") + current_value = getattr(self.env_manager.config, field.name, field.default) + input_widget = Input( + placeholder=field.placeholder, + value=current_value, + id=f"input-{field.name}", + ) + yield input_widget + yield Horizontal( + Button("Pick…", id="pick-langflow-data-btn"), + id="langflow-data-path-actions", + classes="controls-row", + ) + self.inputs[field.name] = input_widget + yield Static(" ") + def _render_langflow_superuser_password(self, field: ConfigField) -> ComposeResult: """Langflow password with generate checkbox and eye toggle.""" with Horizontal(): @@ -457,6 +477,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.action_pick_documents_path() elif event.button.id == "pick-opensearch-data-btn": self.action_pick_opensearch_data_path() + elif event.button.id == "pick-langflow-data-btn": + self.action_pick_langflow_data_path() elif event.button.id and event.button.id.startswith("toggle-"): # Generic toggle for password/secret field visibility field_name = event.button.id.removeprefix("toggle-") @@ -656,6 +678,58 @@ def _set_path(result) -> None: self._opensearch_data_pick_callback = _set_path # type: ignore[attr-defined] self.app.push_screen(picker) + def action_pick_langflow_data_path(self) -> None: + """Open textual-fspicker to select Langflow data directory.""" + try: + import importlib + + fsp = importlib.import_module("textual_fspicker") + except Exception: + self.notify("textual-fspicker not available", severity="warning") + return + + input_widget = self.inputs.get("langflow_data_path") + start = Path.home() + if input_widget and input_widget.value: + path_str = input_widget.value.strip() + if path_str: + candidate = Path(path_str).expanduser() + if candidate.exists(): + start = candidate + elif candidate.parent.exists(): + start = candidate.parent + + 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 _set_path(result) -> None: + if not result: + return + path_str = str(result) + if input_widget is None: + return + input_widget.value = path_str + + try: + self.app.push_screen(picker, _set_path) # type: ignore[arg-type] + except TypeError: + self._langflow_data_pick_callback = _set_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 @@ -675,6 +749,15 @@ def on_screen_dismissed(self, event) -> None: # type: ignore[override] delattr(self, "_opensearch_data_pick_callback") except Exception: pass + + # Handle Langflow data path picker callback + cb = getattr(self, "_langflow_data_pick_callback", None) + if cb is not None: + cb(getattr(event, "result", None)) + try: + delattr(self, "_langflow_data_pick_callback") + except Exception: + pass except Exception: pass