Merge remote-tracking branch 'origin/main' into fix/upgrade_path

This commit is contained in:
Lucas Oliveira 2025-11-26 09:40:02 -03:00
commit 5d1c615925
21 changed files with 824 additions and 195 deletions

View file

@ -53,58 +53,63 @@ jobs:
# backend # backend
- image: backend - image: backend
file: ./Dockerfile.backend file: ./Dockerfile.backend
tag: phact/openrag-backend tag: langflowai/openrag-backend
platform: linux/amd64 platform: linux/amd64
arch: amd64 arch: amd64
runs-on: ubuntu-latest-16-cores runs-on: ubuntu-latest-16-cores
- image: backend - image: backend
file: ./Dockerfile.backend file: ./Dockerfile.backend
tag: phact/openrag-backend tag: langflowai/openrag-backend
platform: linux/arm64 platform: linux/arm64
arch: arm64 arch: arm64
runs-on: [self-hosted, linux, ARM64, langflow-ai-arm64-2] #runs-on: [self-hosted, linux, ARM64, langflow-ai-arm64-2]
runs-on: RagRunner
# frontend # frontend
- image: frontend - image: frontend
file: ./Dockerfile.frontend file: ./Dockerfile.frontend
tag: phact/openrag-frontend tag: langflowai/openrag-frontend
platform: linux/amd64 platform: linux/amd64
arch: amd64 arch: amd64
runs-on: ubuntu-latest-16-cores runs-on: ubuntu-latest-16-cores
- image: frontend - image: frontend
file: ./Dockerfile.frontend file: ./Dockerfile.frontend
tag: phact/openrag-frontend tag: langflowai/openrag-frontend
platform: linux/arm64 platform: linux/arm64
arch: arm64 arch: arm64
runs-on: [self-hosted, linux, ARM64, langflow-ai-arm64-2] #runs-on: [self-hosted, linux, ARM64, langflow-ai-arm64-2]
runs-on: RagRunner
# langflow # langflow
- image: langflow - image: langflow
file: ./Dockerfile.langflow file: ./Dockerfile.langflow
tag: phact/openrag-langflow tag: langflowai/openrag-langflow
platform: linux/amd64 platform: linux/amd64
arch: amd64 arch: amd64
runs-on: ubuntu-latest-16-cores runs-on: ubuntu-latest-16-cores
- image: langflow - image: langflow
file: ./Dockerfile.langflow file: ./Dockerfile.langflow
tag: phact/openrag-langflow tag: langflowai/openrag-langflow
platform: linux/arm64 platform: linux/arm64
arch: arm64 arch: arm64
runs-on: self-hosted #runs-on: self-hosted
runs-on: RagRunner
# opensearch # opensearch
- image: opensearch - image: opensearch
file: ./Dockerfile file: ./Dockerfile
tag: phact/openrag-opensearch tag: langflowai/openrag-opensearch
platform: linux/amd64 platform: linux/amd64
arch: amd64 arch: amd64
runs-on: ubuntu-latest-16-cores runs-on: ubuntu-latest-16-cores
- image: opensearch - image: opensearch
file: ./Dockerfile file: ./Dockerfile
tag: phact/openrag-opensearch tag: langflowai/openrag-opensearch
platform: linux/arm64 platform: linux/arm64
arch: arm64 arch: arm64
runs-on: [self-hosted, linux, ARM64, langflow-ai-arm64-2] #runs-on: [self-hosted, linux, ARM64, langflow-ai-arm64-2]
#runs-on: self-hosted
runs-on: RagRunner
runs-on: ${{ matrix.runs-on }} runs-on: ${{ matrix.runs-on }}
@ -165,40 +170,40 @@ jobs:
VERSION=${{ steps.version.outputs.version }} VERSION=${{ steps.version.outputs.version }}
# Create versioned tags # Create versioned tags
docker buildx imagetools create -t phact/openrag-backend:$VERSION \ docker buildx imagetools create -t langflowai/openrag-backend:$VERSION \
phact/openrag-backend:$VERSION-amd64 \ langflowai/openrag-backend:$VERSION-amd64 \
phact/openrag-backend:$VERSION-arm64 langflowai/openrag-backend:$VERSION-arm64
docker buildx imagetools create -t phact/openrag-frontend:$VERSION \ docker buildx imagetools create -t langflowai/openrag-frontend:$VERSION \
phact/openrag-frontend:$VERSION-amd64 \ langflowai/openrag-frontend:$VERSION-amd64 \
phact/openrag-frontend:$VERSION-arm64 langflowai/openrag-frontend:$VERSION-arm64
docker buildx imagetools create -t phact/openrag-langflow:$VERSION \ docker buildx imagetools create -t langflowai/openrag-langflow:$VERSION \
phact/openrag-langflow:$VERSION-amd64 \ langflowai/openrag-langflow:$VERSION-amd64 \
phact/openrag-langflow:$VERSION-arm64 langflowai/openrag-langflow:$VERSION-arm64
docker buildx imagetools create -t phact/openrag-opensearch:$VERSION \ docker buildx imagetools create -t langflowai/openrag-opensearch:$VERSION \
phact/openrag-opensearch:$VERSION-amd64 \ langflowai/openrag-opensearch:$VERSION-amd64 \
phact/openrag-opensearch:$VERSION-arm64 langflowai/openrag-opensearch:$VERSION-arm64
# Only update latest tags if version is numeric # Only update latest tags if version is numeric
if [[ "$VERSION" =~ ^[0-9.-]+$ ]]; then if [[ "$VERSION" =~ ^[0-9.-]+$ ]]; then
echo "Updating latest tags for production release: $VERSION" echo "Updating latest tags for production release: $VERSION"
docker buildx imagetools create -t phact/openrag-backend:latest \ docker buildx imagetools create -t langflowai/openrag-backend:latest \
phact/openrag-backend:$VERSION-amd64 \ langflowai/openrag-backend:$VERSION-amd64 \
phact/openrag-backend:$VERSION-arm64 langflowai/openrag-backend:$VERSION-arm64
docker buildx imagetools create -t phact/openrag-frontend:latest \ docker buildx imagetools create -t langflowai/openrag-frontend:latest \
phact/openrag-frontend:$VERSION-amd64 \ langflowai/openrag-frontend:$VERSION-amd64 \
phact/openrag-frontend:$VERSION-arm64 langflowai/openrag-frontend:$VERSION-arm64
docker buildx imagetools create -t phact/openrag-langflow:latest \ docker buildx imagetools create -t langflowai/openrag-langflow:latest \
phact/openrag-langflow:$VERSION-amd64 \ langflowai/openrag-langflow:$VERSION-amd64 \
phact/openrag-langflow:$VERSION-arm64 langflowai/openrag-langflow:$VERSION-arm64
docker buildx imagetools create -t phact/openrag-opensearch:latest \ docker buildx imagetools create -t langflowai/openrag-opensearch:latest \
phact/openrag-opensearch:$VERSION-amd64 \ langflowai/openrag-opensearch:$VERSION-amd64 \
phact/openrag-opensearch:$VERSION-arm64 langflowai/openrag-opensearch:$VERSION-arm64
else else
echo "Skipping latest tags - version: $VERSION (not numeric)" echo "Skipping latest tags - version: $VERSION (not numeric)"
fi fi

View file

@ -31,14 +31,23 @@ jobs:
steps: steps:
- run: df -h - run: df -h
#- name: "node-cleanup"
#run: | - name: Cleanup Docker cache
# sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL run: |
# sudo docker image prune --all --force docker system prune -af || true
# sudo docker builder prune -a docker builder prune -af || true
docker-compose -f docker-compose.yml down -v --remove-orphans || true
- run: df -h - run: df -h
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Verify workspace
run: |
echo "Current directory: $(pwd)"
echo "Workspace: ${GITHUB_WORKSPACE}"
ls -la
- name: Set up UV - name: Set up UV
uses: astral-sh/setup-uv@v3 uses: astral-sh/setup-uv@v3

View file

@ -1,4 +1,4 @@
FROM langflowai/langflow-nightly:1.7.0.dev5 FROM langflowai/langflow-nightly:1.7.0.dev19
EXPOSE 7860 EXPOSE 7860

View file

@ -210,7 +210,7 @@ test-ci:
echo "Pulling latest images..."; \ echo "Pulling latest images..."; \
docker compose -f docker-compose-cpu.yml pull; \ docker compose -f docker-compose-cpu.yml pull; \
echo "Building OpenSearch image override..."; \ echo "Building OpenSearch image override..."; \
docker build --no-cache -t phact/openrag-opensearch:latest -f Dockerfile .; \ docker build --no-cache -t langflowai/openrag-opensearch:latest -f Dockerfile .; \
echo "Starting infra (OpenSearch + Dashboards + Langflow) with CPU containers"; \ echo "Starting infra (OpenSearch + Dashboards + Langflow) with CPU containers"; \
docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow; \ docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow; \
echo "Starting docling-serve..."; \ echo "Starting docling-serve..."; \
@ -288,10 +288,10 @@ test-ci-local:
echo "Cleaning up old containers and volumes..."; \ echo "Cleaning up old containers and volumes..."; \
docker compose -f docker-compose-cpu.yml down -v 2>/dev/null || true; \ docker compose -f docker-compose-cpu.yml down -v 2>/dev/null || true; \
echo "Building all images locally..."; \ echo "Building all images locally..."; \
docker build -t phact/openrag-opensearch:latest -f Dockerfile .; \ docker build -t langflowai/openrag-opensearch:latest -f Dockerfile .; \
docker build -t phact/openrag-backend:latest -f Dockerfile.backend .; \ docker build -t langflowai/openrag-backend:latest -f Dockerfile.backend .; \
docker build -t phact/openrag-frontend:latest -f Dockerfile.frontend .; \ docker build -t langflowai/openrag-frontend:latest -f Dockerfile.frontend .; \
docker build -t phact/openrag-langflow:latest -f Dockerfile.langflow .; \ docker build -t langflowai/openrag-langflow:latest -f Dockerfile.langflow .; \
echo "Starting infra (OpenSearch + Dashboards + Langflow) with CPU containers"; \ echo "Starting infra (OpenSearch + Dashboards + Langflow) with CPU containers"; \
docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow; \ docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow; \
echo "Starting docling-serve..."; \ echo "Starting docling-serve..."; \

View file

@ -1,6 +1,6 @@
services: services:
opensearch: opensearch:
image: phact/openrag-opensearch:${OPENRAG_VERSION:-latest} image: langflowai/openrag-opensearch:${OPENRAG_VERSION:-latest}
#build: #build:
# context: . # context: .
# dockerfile: Dockerfile # dockerfile: Dockerfile
@ -44,7 +44,7 @@ services:
- "5601:5601" - "5601:5601"
openrag-backend: openrag-backend:
image: phact/openrag-backend:${OPENRAG_VERSION:-latest} image: langflowai/openrag-backend:${OPENRAG_VERSION:-latest}
# build: # build:
# context: . # context: .
# dockerfile: Dockerfile.backend # dockerfile: Dockerfile.backend
@ -87,7 +87,7 @@ services:
- ./config:/app/config:Z - ./config:/app/config:Z
openrag-frontend: openrag-frontend:
image: phact/openrag-frontend:${OPENRAG_VERSION:-latest} image: langflowai/openrag-frontend:${OPENRAG_VERSION:-latest}
# build: # build:
# context: . # context: .
# dockerfile: Dockerfile.frontend # dockerfile: Dockerfile.frontend
@ -102,7 +102,7 @@ services:
langflow: langflow:
volumes: volumes:
- ./flows:/app/flows:U,z - ./flows:/app/flows:U,z
image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest} image: langflowai/openrag-langflow:${LANGFLOW_VERSION:-latest}
# build: # build:
# context: . # context: .
# dockerfile: Dockerfile.langflow # dockerfile: Dockerfile.langflow

7
docker-compose.gpu.yml Normal file
View file

@ -0,0 +1,7 @@
services:
openrag-backend:
environment:
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NVIDIA_VISIBLE_DEVICES=all
gpus: all

View file

@ -1,6 +1,6 @@
services: services:
opensearch: opensearch:
image: phact/openrag-opensearch:${OPENRAG_VERSION:-latest} image: langflowai/openrag-opensearch:${OPENRAG_VERSION:-latest}
#build: #build:
#context: . #context: .
#dockerfile: Dockerfile #dockerfile: Dockerfile
@ -44,7 +44,7 @@ services:
- "5601:5601" - "5601:5601"
openrag-backend: openrag-backend:
image: phact/openrag-backend:${OPENRAG_VERSION:-latest} image: langflowai/openrag-backend:${OPENRAG_VERSION:-latest}
# build: # build:
# context: . # context: .
# dockerfile: Dockerfile.backend # dockerfile: Dockerfile.backend
@ -72,8 +72,6 @@ services:
- WATSONX_ENDPOINT=${WATSONX_ENDPOINT} - WATSONX_ENDPOINT=${WATSONX_ENDPOINT}
- WATSONX_PROJECT_ID=${WATSONX_PROJECT_ID} - WATSONX_PROJECT_ID=${WATSONX_PROJECT_ID}
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT} - OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NVIDIA_VISIBLE_DEVICES=all
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID} - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET} - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET}
- MICROSOFT_GRAPH_OAUTH_CLIENT_ID=${MICROSOFT_GRAPH_OAUTH_CLIENT_ID} - MICROSOFT_GRAPH_OAUTH_CLIENT_ID=${MICROSOFT_GRAPH_OAUTH_CLIENT_ID}
@ -86,10 +84,9 @@ services:
- ./keys:/app/keys:Z - ./keys:/app/keys:Z
- ./flows:/app/flows:U,z - ./flows:/app/flows:U,z
- ./config:/app/config:Z - ./config:/app/config:Z
gpus: all
openrag-frontend: openrag-frontend:
image: phact/openrag-frontend:${OPENRAG_VERSION:-latest} image: langflowai/openrag-frontend:${OPENRAG_VERSION:-latest}
# build: # build:
# context: . # context: .
# dockerfile: Dockerfile.frontend # dockerfile: Dockerfile.frontend
@ -104,7 +101,7 @@ services:
langflow: langflow:
volumes: volumes:
- ./flows:/app/flows:U,z - ./flows:/app/flows:U,z
image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest} image: langflowai/openrag-langflow:${LANGFLOW_VERSION:-latest}
# build: # build:
# context: . # context: .
# dockerfile: Dockerfile.langflow # dockerfile: Dockerfile.langflow
@ -128,10 +125,10 @@ services:
- CONNECTOR_TYPE=system - CONNECTOR_TYPE=system
- CONNECTOR_TYPE_URL=url - CONNECTOR_TYPE_URL=url
- OPENRAG-QUERY-FILTER="{}" - OPENRAG-QUERY-FILTER="{}"
- OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
- FILENAME=None - FILENAME=None
- MIMETYPE=None - MIMETYPE=None
- FILESIZE=0 - FILESIZE=0
- OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
- LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER,OPENSEARCH_PASSWORD,OWNER,OWNER_NAME,OWNER_EMAIL,CONNECTOR_TYPE,FILENAME,MIMETYPE,FILESIZE - LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER,OPENSEARCH_PASSWORD,OWNER,OWNER_NAME,OWNER_EMAIL,CONNECTOR_TYPE,FILENAME,MIMETYPE,FILESIZE
- LANGFLOW_LOG_LEVEL=DEBUG - LANGFLOW_LOG_LEVEL=DEBUG
- LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN} - LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "openrag" name = "openrag"
version = "0.1.38" version = "0.1.40"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"

View file

@ -1 +0,0 @@
../../../docker-compose-cpu.yml

View file

@ -0,0 +1 @@
../../../docker-compose.gpu.yml

View file

@ -485,7 +485,7 @@ def copy_compose_files(*, force: bool = False) -> None:
logger.debug(f"Could not access compose assets: {e}") logger.debug(f"Could not access compose assets: {e}")
return return
for filename in ("docker-compose.yml", "docker-compose-cpu.yml"): for filename in ("docker-compose.yml", "docker-compose.gpu.yml"):
destination = Path(filename) destination = Path(filename)
if destination.exists() and not force: if destination.exists() and not force:
continue continue

View file

@ -43,6 +43,7 @@ class ServiceInfo:
image: Optional[str] = None image: Optional[str] = None
image_digest: Optional[str] = None image_digest: Optional[str] = None
created: Optional[str] = None created: Optional[str] = None
error_message: Optional[str] = None
def __post_init__(self): def __post_init__(self):
if self.ports is None: if self.ports is None:
@ -56,15 +57,15 @@ class ContainerManager:
self.platform_detector = PlatformDetector() self.platform_detector = PlatformDetector()
self.runtime_info = self.platform_detector.detect_runtime() self.runtime_info = self.platform_detector.detect_runtime()
self.compose_file = compose_file or self._find_compose_file("docker-compose.yml") self.compose_file = compose_file or self._find_compose_file("docker-compose.yml")
self.cpu_compose_file = self._find_compose_file("docker-compose-cpu.yml") self.gpu_compose_file = self._find_compose_file("docker-compose.gpu.yml")
self.services_cache: Dict[str, ServiceInfo] = {} self.services_cache: Dict[str, ServiceInfo] = {}
self.last_status_update = 0 self.last_status_update = 0
# Auto-select CPU compose if no GPU available # Auto-select GPU override if GPU is available
try: try:
has_gpu, _ = detect_gpu_devices() has_gpu, _ = detect_gpu_devices()
self.use_cpu_compose = not has_gpu self.use_gpu_compose = has_gpu
except Exception: except Exception:
self.use_cpu_compose = True self.use_gpu_compose = False
# Expected services based on compose files # Expected services based on compose files
self.expected_services = [ self.expected_services = [
@ -135,6 +136,96 @@ class ContainerManager:
return self.platform_detector.get_compose_installation_instructions() return self.platform_detector.get_compose_installation_instructions()
return self.platform_detector.get_installation_instructions() return self.platform_detector.get_installation_instructions()
def _extract_ports_from_compose(self) -> Dict[str, List[int]]:
"""Extract port mappings from compose files.
Returns:
Dict mapping service name to list of host ports
"""
service_ports: Dict[str, List[int]] = {}
compose_files = [self.compose_file]
if hasattr(self, 'cpu_compose_file') and self.cpu_compose_file and self.cpu_compose_file.exists():
compose_files.append(self.cpu_compose_file)
for compose_file in compose_files:
if not compose_file.exists():
continue
try:
import re
content = compose_file.read_text()
current_service = None
in_ports_section = False
for line in content.splitlines():
# Detect service names
service_match = re.match(r'^ (\w[\w-]*):$', line)
if service_match:
current_service = service_match.group(1)
in_ports_section = False
if current_service not in service_ports:
service_ports[current_service] = []
continue
# Detect ports section
if current_service and re.match(r'^ ports:$', line):
in_ports_section = True
continue
# Exit ports section on new top-level key
if in_ports_section and re.match(r'^ \w+:', line):
in_ports_section = False
# Extract port mappings
if in_ports_section and current_service:
# Match patterns like: - "3000:3000", - "9200:9200", - 7860:7860
port_match = re.search(r'["\']?(\d+):\d+["\']?', line)
if port_match:
host_port = int(port_match.group(1))
if host_port not in service_ports[current_service]:
service_ports[current_service].append(host_port)
except Exception as e:
logger.debug(f"Error parsing {compose_file} for ports: {e}")
continue
return service_ports
async def check_ports_available(self) -> tuple[bool, List[tuple[str, int, str]]]:
"""Check if required ports are available.
Returns:
Tuple of (all_available, conflicts) where conflicts is a list of
(service_name, port, error_message) tuples
"""
import socket
service_ports = self._extract_ports_from_compose()
conflicts: List[tuple[str, int, str]] = []
for service_name, ports in service_ports.items():
for port in ports:
try:
# Try to bind to the port to check if it's available
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.5)
result = sock.connect_ex(('127.0.0.1', port))
sock.close()
if result == 0:
# Port is in use
conflicts.append((
service_name,
port,
f"Port {port} is already in use"
))
except Exception as e:
logger.debug(f"Error checking port {port}: {e}")
continue
return (len(conflicts) == 0, conflicts)
async def _run_compose_command( async def _run_compose_command(
self, args: List[str], cpu_mode: Optional[bool] = None self, args: List[str], cpu_mode: Optional[bool] = None
) -> tuple[bool, str, str]: ) -> tuple[bool, str, str]:
@ -143,9 +234,15 @@ class ContainerManager:
return False, "", "No container runtime available" return False, "", "No container runtime available"
if cpu_mode is None: if cpu_mode is None:
cpu_mode = self.use_cpu_compose use_gpu = self.use_gpu_compose
compose_file = self.cpu_compose_file if cpu_mode else self.compose_file else:
cmd = self.runtime_info.compose_command + ["-f", str(compose_file)] + args use_gpu = not cpu_mode
# Build compose command with override pattern
cmd = self.runtime_info.compose_command + ["-f", str(self.compose_file)]
if use_gpu and self.gpu_compose_file.exists():
cmd.extend(["-f", str(self.gpu_compose_file)])
cmd.extend(args)
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
@ -179,9 +276,15 @@ class ContainerManager:
return return
if cpu_mode is None: if cpu_mode is None:
cpu_mode = self.use_cpu_compose use_gpu = self.use_gpu_compose
compose_file = self.cpu_compose_file if cpu_mode else self.compose_file else:
cmd = self.runtime_info.compose_command + ["-f", str(compose_file)] + args use_gpu = not cpu_mode
# Build compose command with override pattern
cmd = self.runtime_info.compose_command + ["-f", str(self.compose_file)]
if use_gpu and self.gpu_compose_file.exists():
cmd.extend(["-f", str(self.gpu_compose_file)])
cmd.extend(args)
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
@ -242,9 +345,15 @@ class ContainerManager:
return return
if cpu_mode is None: if cpu_mode is None:
cpu_mode = self.use_cpu_compose use_gpu = self.use_gpu_compose
compose_file = self.cpu_compose_file if cpu_mode else self.compose_file else:
cmd = self.runtime_info.compose_command + ["-f", str(compose_file)] + args use_gpu = not cpu_mode
# Build compose command with override pattern
cmd = self.runtime_info.compose_command + ["-f", str(self.compose_file)]
if use_gpu and self.gpu_compose_file.exists():
cmd.extend(["-f", str(self.gpu_compose_file)])
cmd.extend(args)
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
@ -551,44 +660,61 @@ class ContainerManager:
"""Get resolved image names from compose files using docker/podman compose, with robust fallbacks.""" """Get resolved image names from compose files using docker/podman compose, with robust fallbacks."""
images: set[str] = set() images: set[str] = set()
compose_files = [self.compose_file, self.cpu_compose_file] # Try both GPU and CPU modes to get all images
for compose_file in compose_files: for use_gpu in [True, False]:
try: try:
if not compose_file or not compose_file.exists(): # Build compose command with override pattern
continue cmd = self.runtime_info.compose_command + ["-f", str(self.compose_file)]
if use_gpu and self.gpu_compose_file.exists():
cmd.extend(["-f", str(self.gpu_compose_file)])
cmd.extend(["config", "--format", "json"])
cpu_mode = (compose_file == self.cpu_compose_file) process = await asyncio.create_subprocess_exec(
*cmd,
# Try JSON format first stdout=asyncio.subprocess.PIPE,
success, stdout, _ = await self._run_compose_command( stderr=asyncio.subprocess.PIPE,
["config", "--format", "json"], cwd=Path.cwd(),
cpu_mode=cpu_mode
) )
stdout, stderr = await process.communicate()
stdout_text = stdout.decode() if stdout else ""
if success and stdout.strip(): if process.returncode == 0 and stdout_text.strip():
from_cfg = self._extract_images_from_compose_config(stdout, tried_json=True) from_cfg = self._extract_images_from_compose_config(stdout_text, tried_json=True)
if from_cfg: if from_cfg:
images.update(from_cfg) images.update(from_cfg)
continue # this compose file succeeded; move to next file continue
# Fallback to YAML output (for older compose versions) # Fallback to YAML output (for older compose versions)
success, stdout, _ = await self._run_compose_command( cmd = self.runtime_info.compose_command + ["-f", str(self.compose_file)]
["config"], if use_gpu and self.gpu_compose_file.exists():
cpu_mode=cpu_mode cmd.extend(["-f", str(self.gpu_compose_file)])
) cmd.append("config")
if success and stdout.strip(): process = await asyncio.create_subprocess_exec(
from_cfg = self._extract_images_from_compose_config(stdout, tried_json=False) *cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=Path.cwd(),
)
stdout, stderr = await process.communicate()
stdout_text = stdout.decode() if stdout else ""
if process.returncode == 0 and stdout_text.strip():
from_cfg = self._extract_images_from_compose_config(stdout_text, tried_json=False)
if from_cfg: if from_cfg:
images.update(from_cfg) images.update(from_cfg)
continue continue
except Exception: except Exception:
# Keep behavior resilient—just continue to next file # Keep behavior resilient—just continue to next mode
continue continue
# Fallback: manual parsing if compose config didn't work # Fallback: manual parsing if compose config didn't work
if not images: if not images:
compose_files = [self.compose_file]
if self.gpu_compose_file.exists():
compose_files.append(self.gpu_compose_file)
for compose in compose_files: for compose in compose_files:
try: try:
if not compose.exists(): if not compose.exists():
@ -638,8 +764,11 @@ class ContainerManager:
yield False, "No container runtime available" yield False, "No container runtime available"
return return
# Diagnostic info about compose files # Determine GPU mode
compose_file = self.cpu_compose_file if (cpu_mode if cpu_mode is not None else self.use_cpu_compose) else self.compose_file if cpu_mode is None:
use_gpu = self.use_gpu_compose
else:
use_gpu = not cpu_mode
# Show the search process for debugging # Show the search process for debugging
if hasattr(self, '_compose_search_log'): if hasattr(self, '_compose_search_log'):
@ -650,9 +779,23 @@ class ContainerManager:
# Show runtime detection info # Show runtime detection info
runtime_cmd_str = " ".join(self.runtime_info.compose_command) runtime_cmd_str = " ".join(self.runtime_info.compose_command)
yield False, f"Using compose command: {runtime_cmd_str}", False yield False, f"Using compose command: {runtime_cmd_str}", False
yield False, f"Final compose file: {compose_file.absolute()}", False compose_files_str = str(self.compose_file.absolute())
if not compose_file.exists(): if use_gpu and self.gpu_compose_file.exists():
yield False, f"ERROR: Compose file not found at {compose_file.absolute()}", False compose_files_str += f" + {self.gpu_compose_file.absolute()}"
yield False, f"Compose files: {compose_files_str}", False
if not self.compose_file.exists():
yield False, f"ERROR: Base compose file not found at {self.compose_file.absolute()}", False
return
# Check for port conflicts before starting
yield False, "Checking port availability...", False
ports_available, conflicts = await self.check_ports_available()
if not ports_available:
yield False, "ERROR: Port conflicts detected:", False
for service_name, port, error_msg in conflicts:
yield False, f" - {service_name}: {error_msg}", False
yield False, "Please stop the conflicting services and try again.", False
yield False, "Services not started due to port conflicts.", False
return return
yield False, "Starting OpenRAG services...", False yield False, "Starting OpenRAG services...", False
@ -677,13 +820,37 @@ class ContainerManager:
yield False, "Creating and starting containers...", False yield False, "Creating and starting containers...", False
up_success = {"value": True} up_success = {"value": True}
error_messages = []
async for message, replace_last in self._stream_compose_command(["up", "-d"], up_success, cpu_mode): async for message, replace_last in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
# Detect error patterns in the output
import re
lower_msg = message.lower()
# Check for common error patterns
if any(pattern in lower_msg for pattern in [
"port.*already.*allocated",
"address already in use",
"bind.*address already in use",
"port is already allocated"
]):
error_messages.append("Port conflict detected")
up_success["value"] = False
elif "error" in lower_msg or "failed" in lower_msg:
# Generic error detection
if message not in error_messages:
error_messages.append(message)
yield False, message, replace_last yield False, message, replace_last
if up_success["value"]: if up_success["value"]:
yield True, "Services started successfully", False yield True, "Services started successfully", False
else: else:
yield False, "Failed to start services. See output above for details.", False yield False, "Failed to start services. See output above for details.", False
if error_messages:
yield False, "\nDetected errors:", False
for err in error_messages[:5]: # Limit to first 5 errors
yield False, f" - {err}", False
async def stop_services(self) -> AsyncIterator[tuple[bool, str]]: async def stop_services(self) -> AsyncIterator[tuple[bool, str]]:
"""Stop all services and yield progress updates.""" """Stop all services and yield progress updates."""
@ -786,16 +953,11 @@ class ContainerManager:
yield "No container runtime available" yield "No container runtime available"
return return
compose_file = ( # Build compose command with override pattern
self.cpu_compose_file if self.use_cpu_compose else self.compose_file cmd = self.runtime_info.compose_command + ["-f", str(self.compose_file)]
) if self.use_gpu_compose and self.gpu_compose_file.exists():
cmd = self.runtime_info.compose_command + [ cmd.extend(["-f", str(self.gpu_compose_file)])
"-f", cmd.extend(["logs", "-f", service_name])
str(compose_file),
"logs",
"-f",
service_name,
]
try: try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(

View file

@ -143,6 +143,29 @@ class DoclingManager:
self._external_process = False self._external_process = False
return False return False
def check_port_available(self) -> tuple[bool, Optional[str]]:
"""Check if the native service port is available.
Returns:
Tuple of (available, error_message)
"""
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.5)
result = sock.connect_ex(('127.0.0.1', self._port))
sock.close()
if result == 0:
# Port is in use
return False, f"Port {self._port} is already in use"
return True, None
except Exception as e:
logger.debug(f"Error checking port {self._port}: {e}")
# If we can't check, assume it's available
return True, None
def get_status(self) -> Dict[str, Any]: def get_status(self) -> Dict[str, Any]:
"""Get current status of docling serve.""" """Get current status of docling serve."""
# Check for starting state first # Check for starting state first

View file

@ -34,13 +34,14 @@ class MonitorScreen(Screen):
("u", "upgrade", "Upgrade"), ("u", "upgrade", "Upgrade"),
("x", "reset", "Reset"), ("x", "reset", "Reset"),
("l", "logs", "View Logs"), ("l", "logs", "View Logs"),
("g", "toggle_mode", "Toggle GPU/CPU"),
("j", "cursor_down", "Move Down"), ("j", "cursor_down", "Move Down"),
("k", "cursor_up", "Move Up"), ("k", "cursor_up", "Move Up"),
] ]
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.container_manager = ContainerManager() self._container_manager = None # Use app's shared instance
self.docling_manager = DoclingManager() self.docling_manager = DoclingManager()
self.services_table = None self.services_table = None
self.docling_table = None self.docling_table = None
@ -53,6 +54,13 @@ class MonitorScreen(Screen):
# Track which table was last selected for mutual exclusion # Track which table was last selected for mutual exclusion
self._last_selected_table = None self._last_selected_table = None
@property
def container_manager(self) -> ContainerManager:
"""Get the shared container manager from the app."""
if self._container_manager is None:
self._container_manager = self.app.container_manager
return self._container_manager
def on_unmount(self) -> None: def on_unmount(self) -> None:
"""Clean up when the screen is unmounted.""" """Clean up when the screen is unmounted."""
if hasattr(self, 'docling_manager'): if hasattr(self, 'docling_manager'):
@ -70,10 +78,10 @@ class MonitorScreen(Screen):
def _create_services_tab(self) -> ComposeResult: def _create_services_tab(self) -> ComposeResult:
"""Create the services monitoring tab.""" """Create the services monitoring tab."""
# Current mode indicator + toggle # GPU/CPU mode section
yield Static("GPU Mode", id="mode-indicator", classes="tab-header")
yield Horizontal( yield Horizontal(
Static("", id="mode-indicator"), Button("Switch to CPU Mode", id="toggle-mode-btn"),
Button("Toggle Mode", id="toggle-mode-btn"),
classes="button-row", classes="button-row",
id="mode-row", id="mode-row",
) )
@ -312,17 +320,46 @@ class MonitorScreen(Screen):
"""Start services with progress updates.""" """Start services with progress updates."""
self.operation_in_progress = True self.operation_in_progress = True
try: try:
# Check for port conflicts before attempting to start
ports_available, conflicts = await self.container_manager.check_ports_available()
if not ports_available:
# Show error notification instead of modal
conflict_msgs = []
for service_name, port, error_msg in conflicts[:3]: # Show first 3
conflict_msgs.append(f"{service_name} (port {port})")
conflict_str = ", ".join(conflict_msgs)
if len(conflicts) > 3:
conflict_str += f" and {len(conflicts) - 3} more"
self.notify(
f"Cannot start services: Port conflicts detected for {conflict_str}. "
f"Please stop the conflicting services first.",
severity="error",
timeout=10
)
# Refresh to show current state
await self._refresh_services()
return
# Show command output in modal dialog # Show command output in modal dialog
command_generator = self.container_manager.start_services(cpu_mode) command_generator = self.container_manager.start_services(cpu_mode)
modal = CommandOutputModal( modal = CommandOutputModal(
"Starting Services", "Starting Services",
command_generator, command_generator,
on_complete=None, # We'll refresh in on_screen_resume instead on_complete=self._on_start_complete, # Refresh after completion
) )
self.app.push_screen(modal) self.app.push_screen(modal)
except Exception as e:
self.notify(f"Error starting services: {str(e)}", severity="error")
await self._refresh_services()
finally: finally:
self.operation_in_progress = False self.operation_in_progress = False
async def _on_start_complete(self) -> None:
"""Callback after service start completes."""
await self._refresh_services()
async def _stop_services(self) -> None: async def _stop_services(self) -> None:
"""Stop services with progress updates.""" """Stop services with progress updates."""
self.operation_in_progress = True self.operation_in_progress = True
@ -421,6 +458,19 @@ class MonitorScreen(Screen):
"""Start docling serve.""" """Start docling serve."""
self.operation_in_progress = True self.operation_in_progress = True
try: try:
# Check for port conflicts before attempting to start
port_available, error_msg = self.docling_manager.check_port_available()
if not port_available:
self.notify(
f"Cannot start docling serve: {error_msg}. "
f"Please stop the conflicting service first.",
severity="error",
timeout=10
)
# Refresh to show current state
await self._refresh_services()
return
# Start the service (this sets _starting = True internally at the start) # Start the service (this sets _starting = True internally at the start)
# Create task and let it begin executing (which sets the flag) # Create task and let it begin executing (which sets the flag)
start_task = asyncio.create_task(self.docling_manager.start()) start_task = asyncio.create_task(self.docling_manager.start())
@ -616,22 +666,21 @@ class MonitorScreen(Screen):
def _update_mode_row(self) -> None: def _update_mode_row(self) -> None:
"""Update the mode indicator and toggle button label.""" """Update the mode indicator and toggle button label."""
try: try:
use_cpu = getattr(self.container_manager, "use_cpu_compose", True) use_gpu = getattr(self.container_manager, "use_gpu_compose", False)
indicator = self.query_one("#mode-indicator", Static) indicator = self.query_one("#mode-indicator", Static)
mode_text = "Mode: CPU (no GPU detected)" if use_cpu else "Mode: GPU" indicator.update("GPU Mode" if use_gpu else "CPU Mode")
indicator.update(mode_text)
toggle_btn = self.query_one("#toggle-mode-btn", Button) toggle_btn = self.query_one("#toggle-mode-btn", Button)
toggle_btn.label = "Switch to GPU Mode" if use_cpu else "Switch to CPU Mode" toggle_btn.label = "Switch to CPU Mode" if use_gpu else "Switch to GPU Mode"
except Exception: except Exception:
pass pass
def action_toggle_mode(self) -> None: def action_toggle_mode(self) -> None:
"""Toggle between CPU/GPU compose files and refresh view.""" """Toggle between CPU/GPU compose files and refresh view."""
try: try:
current = getattr(self.container_manager, "use_cpu_compose", True) current = getattr(self.container_manager, "use_gpu_compose", False)
self.container_manager.use_cpu_compose = not current self.container_manager.use_gpu_compose = not current
self.notify( self.notify(
"Switched to GPU compose" if not current else "Switched to CPU compose", "Switched to GPU mode" if not current else "Switched to CPU mode",
severity="information", severity="information",
) )
self._update_mode_row() self._update_mode_row()

View file

@ -402,6 +402,34 @@ class WelcomeScreen(Screen):
async def _start_all_services(self) -> None: async def _start_all_services(self) -> None:
"""Start all services: containers first, then native services.""" """Start all services: containers first, then native services."""
# Check for port conflicts before attempting to start anything
conflicts = []
# Check container ports
if self.container_manager.is_available():
ports_available, port_conflicts = await self.container_manager.check_ports_available()
if not ports_available:
for service_name, port, error_msg in port_conflicts[:3]: # Show first 3
conflicts.append(f"{service_name} (port {port})")
if len(port_conflicts) > 3:
conflicts.append(f"and {len(port_conflicts) - 3} more")
# Check native service port
port_available, error_msg = self.docling_manager.check_port_available()
if not port_available:
conflicts.append(f"docling (port {self.docling_manager._port})")
# If there are any conflicts, show error and return
if conflicts:
conflict_str = ", ".join(conflicts)
self.notify(
f"Cannot start services: Port conflicts detected for {conflict_str}. "
f"Please stop the conflicting services first.",
severity="error",
timeout=10
)
return
# Step 1: Start container services first (to create the network) # Step 1: Start container services first (to create the network)
if self.container_manager.is_available(): if self.container_manager.is_available():
command_generator = self.container_manager.start_services() command_generator = self.container_manager.start_services()
@ -427,6 +455,20 @@ class WelcomeScreen(Screen):
async def _start_native_services_after_containers(self) -> None: async def _start_native_services_after_containers(self) -> None:
"""Start native services after containers have been started.""" """Start native services after containers have been started."""
if not self.docling_manager.is_running(): if not self.docling_manager.is_running():
# Check for port conflicts before attempting to start
port_available, error_msg = self.docling_manager.check_port_available()
if not port_available:
self.notify(
f"Cannot start native services: {error_msg}. "
f"Please stop the conflicting service first.",
severity="error",
timeout=10
)
# Update state and return
self.docling_running = False
await self._refresh_welcome_content()
return
self.notify("Starting native services...", severity="information") self.notify("Starting native services...", severity="information")
success, message = await self.docling_manager.start() success, message = await self.docling_manager.start()
if success: if success:

View file

@ -23,6 +23,7 @@ class CommandOutputModal(ModalScreen):
("p", "pause_waves", "Pause"), ("p", "pause_waves", "Pause"),
("f", "speed_up", "Faster"), ("f", "speed_up", "Faster"),
("s", "speed_down", "Slower"), ("s", "speed_down", "Slower"),
("escape", "close_modal", "Close"),
] ]
DEFAULT_CSS = """ DEFAULT_CSS = """
@ -188,6 +189,8 @@ class CommandOutputModal(ModalScreen):
self._output_lines: list[str] = [] self._output_lines: list[str] = []
self._layer_line_map: dict[str, int] = {} # Maps layer ID to line index self._layer_line_map: dict[str, int] = {} # Maps layer ID to line index
self._status_task: Optional[asyncio.Task] = None self._status_task: Optional[asyncio.Task] = None
self._error_detected = False
self._command_complete = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the modal dialog layout.""" """Create the modal dialog layout."""
@ -254,6 +257,12 @@ class CommandOutputModal(ModalScreen):
for w in waves.wavelets: for w in waves.wavelets:
w.speed = max(0.1, w.speed * 0.8) w.speed = max(0.1, w.speed * 0.8)
def action_close_modal(self) -> None:
"""Close the modal (only if error detected or command complete)."""
close_btn = self.query_one("#close-btn", Button)
if not close_btn.disabled:
self.dismiss()
async def _run_command(self) -> None: async def _run_command(self) -> None:
"""Run the command and update the output in real-time.""" """Run the command and update the output in real-time."""
output = self.query_one("#command-output", TextArea) output = self.query_one("#command-output", TextArea)
@ -273,8 +282,25 @@ class CommandOutputModal(ModalScreen):
# Move cursor to end to trigger scroll # Move cursor to end to trigger scroll
output.move_cursor((len(self._output_lines), 0)) output.move_cursor((len(self._output_lines), 0))
# Detect error patterns in messages
import re
lower_msg = message.lower() if message else ""
if not self._error_detected and any(pattern in lower_msg for pattern in [
"error:",
"failed",
"port.*already.*allocated",
"address already in use",
"not found",
"permission denied"
]):
self._error_detected = True
# Enable close button when error detected
close_btn = self.query_one("#close-btn", Button)
close_btn.disabled = False
# If command is complete, update UI # If command is complete, update UI
if is_complete: if is_complete:
self._command_complete = True
self._update_output("Command completed successfully", False) self._update_output("Command completed successfully", False)
output.text = "\n".join(self._output_lines) output.text = "\n".join(self._output_lines)
output.move_cursor((len(self._output_lines), 0)) output.move_cursor((len(self._output_lines), 0))

2
uv.lock generated
View file

@ -2352,7 +2352,7 @@ wheels = [
[[package]] [[package]]
name = "openrag" name = "openrag"
version = "0.1.37" version = "0.1.40"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agentd" }, { name = "agentd" },