diff --git a/Makefile b/Makefile index fe76467a..e8b08a1b 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,14 @@ infra: @echo " OpenSearch: http://localhost:9200" @echo " Dashboards: http://localhost:5601" +infra-cpu: + @echo "🔧 Starting infrastructure services only..." + docker-compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow + @echo "✅ Infrastructure services started!" + @echo " Langflow: http://localhost:7860" + @echo " OpenSearch: http://localhost:9200" + @echo " Dashboards: http://localhost:5601" + # Container management stop: @echo "🛑 Stopping all containers..." diff --git a/frontend/components/knowledge-actions-dropdown.tsx b/frontend/components/knowledge-actions-dropdown.tsx new file mode 100644 index 00000000..ecf77e22 --- /dev/null +++ b/frontend/components/knowledge-actions-dropdown.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { EllipsisVertical } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "./ui/button"; + +export function KnowledgeActionsDropdown() { + return ( + + + + + + Delete + + + ); +} diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index 5c14e593..3dc0b5f0 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -29,7 +29,7 @@ const Input = React.forwardRef( /> =0.4.0" } }, + "node_modules/ag-charts-types": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.2.0.tgz", + "integrity": "sha512-d2qQrQirt9wP36YW5HPuOvXsiajyiFnr1CTsoCbs02bavPDz7Lk2jHp64+waM4YKgXb3GN7gafbBI9Qgk33BmQ==" + }, + "node_modules/ag-grid-community": { + "version": "34.2.0", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.2.0.tgz", + "integrity": "sha512-peS7THEMYwpIrwLQHmkRxw/TlOnddD/F5A88RqlBxf8j+WqVYRWMOOhU5TqymGcha7z2oZ8IoL9ROl3gvtdEjg==", + "dependencies": { + "ag-charts-types": "12.2.0" + } + }, + "node_modules/ag-grid-react": { + "version": "34.2.0", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.2.0.tgz", + "integrity": "sha512-dLKFw6hz75S0HLuZvtcwjm+gyiI4gXVzHEu7lWNafWAX0mb8DhogEOP5wbzAlsN6iCfi7bK/cgZImZFjenlqwg==", + "dependencies": { + "ag-grid-community": "34.2.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 185e3866..09dac477 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,8 @@ "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.86.0", + "ag-grid-community": "^34.2.0", + "ag-grid-react": "^34.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/frontend/src/app/knowledge/page.tsx b/frontend/src/app/knowledge/page.tsx index 88b93671..8b85313e 100644 --- a/frontend/src/app/knowledge/page.tsx +++ b/frontend/src/app/knowledge/page.tsx @@ -8,7 +8,14 @@ import { Loader2, Search, } from "lucide-react"; -import { type FormEvent, useCallback, useEffect, useState } from "react"; +import { AgGridReact, CustomCellRendererProps } from "ag-grid-react"; +import { + type FormEvent, + useCallback, + useEffect, + useState, + useRef, +} from "react"; import { SiGoogledrive } from "react-icons/si"; import { TbBrandOnedrive } from "react-icons/tb"; import { KnowledgeDropdown } from "@/components/knowledge-dropdown"; @@ -18,6 +25,10 @@ import { Input } from "@/components/ui/input"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useTask } from "@/contexts/task-context"; import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery"; +import { ColDef, RowClickedEvent } from "ag-grid-community"; +import "@/components/AgGrid/registerAgGridModules"; +import "@/components/AgGrid/agGridStyles.css"; +import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown"; // Function to get the appropriate icon for a connector type function getSourceIcon(connectorType?: string) { @@ -64,11 +75,94 @@ function SearchPage() { } setQuery(queryInputText); }, - [queryInputText, refetchSearch, query], + [queryInputText, refetchSearch, query] ); const fileResults = data as File[]; + const gridRef = useRef(null); + + const [columnDefs] = useState[]>([ + { + field: "filename", + headerName: "Source", + cellRenderer: ({ data, value }: CustomCellRendererProps) => { + return ( +
+ {getSourceIcon(data?.connector_type)} + + {value} + +
+ ); + }, + }, + { + field: "size", + headerName: "Size", + valueFormatter: (params) => + params.value ? `${Math.round(params.value / 1024)} KB` : "-", + }, + { + field: "mimetype", + headerName: "Type", + }, + { + field: "owner", + headerName: "Owner", + valueFormatter: (params) => + params.value || + params.data?.owner_name || + params.data?.owner_email || + "—", + }, + + { + field: "chunkCount", + headerName: "Chunks", + }, + { + field: "avgScore", + headerName: "Avg score", + cellRenderer: ({ value }: CustomCellRendererProps) => { + return ( + + {value.toFixed(2)} + + ); + }, + }, + { + cellRenderer: () => { + return ; + }, + cellStyle: { + alignItems: 'center', + display: 'flex', + justifyContent: 'center', + padding: 0, + }, + colId: 'actions', + filter: false, + maxWidth: 60, + minWidth: 60, + resizable: false, + sortable: false, + initialFlex: 0, + }, + ]); + + const defaultColDef: ColDef = { + cellStyle: () => ({ + display: "flex", + alignItems: "center", + }), + initialFlex: 1, + minWidth: 100, + resizable: false, + suppressMovable: true, + }; + return (
+
+

Project Knowledge

+ +
{/* Search Input Area */} -
+
-
- -
- - {/* Results Area */} -
-
- {fileResults.length === 0 && !isFetching ? ( -
+ {selectedFile ? ( + // Show chunks for selected file + <> +
+ + + Chunks from {selectedFile} + +
+ {fileResults + .filter((file) => file.filename === selectedFile) + .flatMap((file) => file.chunks) + .map((chunk, index) => ( +
+
+
+ + + {chunk.filename} + +
+ + {chunk.score.toFixed(2)} + +
+
+ {chunk.mimetype} • Page {chunk.page} +
+

+ {chunk.text} +

+
+ ))} + + ) : ( + ) => { + setSelectedFile(params.data?.filename ?? ""); + }} + noRowsOverlayComponent={() => ( +

No documents found @@ -128,140 +272,9 @@ function SearchPage() { Try adjusting your search terms

- ) : ( -
- {/* Results Count */} -
-
- {fileResults.length} file - {fileResults.length !== 1 ? "s" : ""} found -
-
- - {/* Results Display */} -
- {selectedFile ? ( - // Show chunks for selected file - <> -
- - - Chunks from {selectedFile} - -
- {fileResults - .filter((file) => file.filename === selectedFile) - .flatMap((file) => file.chunks) - .map((chunk, index) => ( -
-
-
- - - {chunk.filename} - -
- - {chunk.score.toFixed(2)} - -
-
- {chunk.mimetype} • Page {chunk.page} -
-

- {chunk.text} -

-
- ))} - - ) : ( - // Show files table -
- - - - - - - - - - - - - {fileResults.map((file) => ( - setSelectedFile(file.filename)} - > - - - - - - - - ))} - -
- Source - - Type - - Size - - Matching chunks - - Average score - - Owner -
-
- {getSourceIcon(file.connector_type)} - - {file.filename} - -
-
- {file.mimetype} - - {file.size - ? `${Math.round(file.size / 1024)} KB` - : "—"} - - {file.chunkCount} - - - {file.avgScore.toFixed(2)} - - - {file.owner_name || file.owner || "—"} -
-
- )} -
-
)} -
-
+ /> + )}
); diff --git a/frontend/src/components/AgGrid/agGridStyles.css b/frontend/src/components/AgGrid/agGridStyles.css new file mode 100644 index 00000000..b595e18c --- /dev/null +++ b/frontend/src/components/AgGrid/agGridStyles.css @@ -0,0 +1,21 @@ +body { + --ag-text-color: hsl(var(--muted-foreground)); + --ag-background-color: hsl(var(--background)); + --ag-header-background-color: hsl(var(--background)); + --ag-header-text-color: hsl(var(--muted-foreground)); + --ag-header-column-resize-handle-color: hsl(var(--border)); + --ag-header-row-border: hsl(var(--border)); + --ag-header-font-weight: var(--font-medium); + --ag-row-border: undefined; + --ag-row-hover-color: hsl(var(--muted)); + --ag-wrapper-border: none; + --ag-font-family: var(--font-sans); + + .ag-header { + border-bottom: 1px solid hsl(var(--border)); + margin-bottom: 0.5rem; + } + .ag-row { + cursor: pointer; + } +} diff --git a/frontend/src/components/AgGrid/registerAgGridModules.ts b/frontend/src/components/AgGrid/registerAgGridModules.ts new file mode 100644 index 00000000..da2c5280 --- /dev/null +++ b/frontend/src/components/AgGrid/registerAgGridModules.ts @@ -0,0 +1,33 @@ +import { + ModuleRegistry, + ValidationModule, + ColumnAutoSizeModule, + ColumnApiModule, + PaginationModule, + CellStyleModule, + QuickFilterModule, + ClientSideRowModelModule, + TextFilterModule, + DateFilterModule, + EventApiModule, + GridStateModule, + } from 'ag-grid-community'; + + // Importing necessary modules from ag-grid-community + // https://www.ag-grid.com/javascript-data-grid/modules/#selecting-modules + + ModuleRegistry.registerModules([ + ColumnAutoSizeModule, + ColumnApiModule, + PaginationModule, + CellStyleModule, + QuickFilterModule, + ClientSideRowModelModule, + TextFilterModule, + DateFilterModule, + EventApiModule, + GridStateModule, + // The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development. + ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), + ]); + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 86348ee5..a2a0e41f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openrag" -version = "0.1.3" +version = "0.1.8" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" @@ -37,6 +37,7 @@ openrag = "tui.main:run_tui" [tool.uv] package = true + [tool.uv.sources] torch = [ { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" }, diff --git a/src/services/task_service.py b/src/services/task_service.py index 8e69d4ae..0341aadf 100644 --- a/src/services/task_service.py +++ b/src/services/task_service.py @@ -85,6 +85,8 @@ class TaskService: async def create_custom_task(self, user_id: str, items: list, processor) -> str: """Create a new task with custom processor for any type of items""" + # Store anonymous tasks under a stable key so they can be retrieved later + store_user_id = user_id or AnonymousUser().user_id task_id = str(uuid.uuid4()) upload_task = UploadTask( task_id=task_id, @@ -95,12 +97,14 @@ class TaskService: # Attach the custom processor to the task upload_task.processor = processor - if user_id not in self.task_store: - self.task_store[user_id] = {} - self.task_store[user_id][task_id] = upload_task + if store_user_id not in self.task_store: + self.task_store[store_user_id] = {} + self.task_store[store_user_id][task_id] = upload_task # Start background processing - background_task = asyncio.create_task(self.background_custom_processor(user_id, task_id, items)) + background_task = asyncio.create_task( + self.background_custom_processor(store_user_id, task_id, items) + ) self.background_tasks.add(background_task) background_task.add_done_callback(self.background_tasks.discard) diff --git a/src/tui/_assets/docker-compose-cpu.yml b/src/tui/_assets/docker-compose-cpu.yml new file mode 100644 index 00000000..06d44643 --- /dev/null +++ b/src/tui/_assets/docker-compose-cpu.yml @@ -0,0 +1,111 @@ +services: + opensearch: + image: phact/openrag-opensearch:${OPENRAG_VERSION:-latest} + #build: + # context: . + # dockerfile: Dockerfile + container_name: os + depends_on: + - openrag-backend + environment: + - discovery.type=single-node + - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD} + # Run security setup in background after OpenSearch starts + command: > + bash -c " + # Start OpenSearch in background + /usr/share/opensearch/opensearch-docker-entrypoint.sh opensearch & + + # Wait a bit for OpenSearch to start, then apply security config + sleep 10 && /usr/share/opensearch/setup-security.sh & + + # Wait for background processes + wait + " + ports: + - "9200:9200" + - "9600:9600" + + dashboards: + image: opensearchproject/opensearch-dashboards:3.0.0 + container_name: osdash + depends_on: + - opensearch + environment: + OPENSEARCH_HOSTS: '["https://opensearch:9200"]' + OPENSEARCH_USERNAME: "admin" + OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD} + ports: + - "5601:5601" + + openrag-backend: + image: phact/openrag-backend:${OPENRAG_VERSION:-latest} + #build: + #context: . + #dockerfile: Dockerfile.backend + container_name: openrag-backend + depends_on: + - langflow + environment: + - OPENSEARCH_HOST=opensearch + - LANGFLOW_URL=http://langflow:7860 + - LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL} + - 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} + - DISABLE_INGEST_WITH_LANGFLOW=${DISABLE_INGEST_WITH_LANGFLOW:-false} + - NUDGES_FLOW_ID=${NUDGES_FLOW_ID} + - OPENSEARCH_PORT=9200 + - OPENSEARCH_USERNAME=admin + - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - NVIDIA_DRIVER_CAPABILITIES=compute,utility + - NVIDIA_VISIBLE_DEVICES=all + - 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} + volumes: + - ./documents:/app/documents:Z + - ./keys:/app/keys:Z + - ./flows:/app/flows:Z + + openrag-frontend: + image: phact/openrag-frontend:${OPENRAG_VERSION:-latest} + #build: + #context: . + #dockerfile: Dockerfile.frontend + container_name: openrag-frontend + depends_on: + - openrag-backend + environment: + - OPENRAG_BACKEND_HOST=openrag-backend + ports: + - "3000:3000" + + langflow: + volumes: + - ./flows:/app/flows:Z + image: phact/langflow:${LANGFLOW_VERSION:-responses} + container_name: langflow + ports: + - "7860:7860" + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY} + - LANGFLOW_LOAD_FLOWS_PATH=/app/flows + - LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY} + - JWT="dummy" + - OPENRAG-QUERY-FILTER="{}" + - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD} + - LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER,OPENSEARCH_PASSWORD + - LANGFLOW_LOG_LEVEL=DEBUG + - LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN} + - LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER} + - LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD} + - LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE} + - LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI} diff --git a/src/tui/_assets/docker-compose.yml b/src/tui/_assets/docker-compose.yml new file mode 100644 index 00000000..997cf463 --- /dev/null +++ b/src/tui/_assets/docker-compose.yml @@ -0,0 +1,111 @@ +services: + opensearch: + image: phact/openrag-opensearch:${OPENRAG_VERSION:-latest} + #build: + #context: . + #dockerfile: Dockerfile + container_name: os + depends_on: + - openrag-backend + environment: + - discovery.type=single-node + - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD} + # Run security setup in background after OpenSearch starts + command: > + bash -c " + # Start OpenSearch in background + /usr/share/opensearch/opensearch-docker-entrypoint.sh opensearch & + + # Wait a bit for OpenSearch to start, then apply security config + sleep 10 && /usr/share/opensearch/setup-security.sh & + + # Wait for background processes + wait + " + ports: + - "9200:9200" + - "9600:9600" + + dashboards: + image: opensearchproject/opensearch-dashboards:3.0.0 + container_name: osdash + depends_on: + - opensearch + environment: + OPENSEARCH_HOSTS: '["https://opensearch:9200"]' + OPENSEARCH_USERNAME: "admin" + OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD} + ports: + - "5601:5601" + + openrag-backend: + image: phact/openrag-backend:${OPENRAG_VERSION:-latest} + #build: + #context: . + #dockerfile: Dockerfile.backend + container_name: openrag-backend + depends_on: + - langflow + environment: + - OPENSEARCH_HOST=opensearch + - LANGFLOW_URL=http://langflow:7860 + - LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL} + - 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} + - DISABLE_INGEST_WITH_LANGFLOW=${DISABLE_INGEST_WITH_LANGFLOW:-false} + - NUDGES_FLOW_ID=${NUDGES_FLOW_ID} + - OPENSEARCH_PORT=9200 + - OPENSEARCH_USERNAME=admin + - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - NVIDIA_DRIVER_CAPABILITIES=compute,utility + - NVIDIA_VISIBLE_DEVICES=all + - 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} + volumes: + - ./documents:/app/documents:Z + - ./keys:/app/keys:Z + - ./flows:/app/flows:Z + gpus: all + + openrag-frontend: + image: phact/openrag-frontend:${OPENRAG_VERSION:-latest} + #build: + #context: . + #dockerfile: Dockerfile.frontend + container_name: openrag-frontend + depends_on: + - openrag-backend + environment: + - OPENRAG_BACKEND_HOST=openrag-backend + ports: + - "3000:3000" + + langflow: + volumes: + - ./flows:/app/flows:Z + image: phact/langflow:${LANGFLOW_VERSION:-responses} + container_name: langflow + ports: + - "7860:7860" + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY} + - LANGFLOW_LOAD_FLOWS_PATH=/app/flows + - LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY} + - JWT="dummy" + - OPENRAG-QUERY-FILTER="{}" + - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD} + - LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER,OPENSEARCH_PASSWORD + - LANGFLOW_LOG_LEVEL=DEBUG + - LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN} + - LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER} + - LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD} + - LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE} + - LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI} diff --git a/src/tui/_assets/documents/2506.08231v1.pdf b/src/tui/_assets/documents/2506.08231v1.pdf new file mode 100644 index 00000000..61e83265 Binary files /dev/null and b/src/tui/_assets/documents/2506.08231v1.pdf differ diff --git a/src/tui/_assets/documents/ai-human-resources.pdf b/src/tui/_assets/documents/ai-human-resources.pdf new file mode 100644 index 00000000..5e36eab4 Binary files /dev/null and b/src/tui/_assets/documents/ai-human-resources.pdf differ diff --git a/src/tui/_assets/documents/warmup_ocr.pdf b/src/tui/_assets/documents/warmup_ocr.pdf new file mode 100644 index 00000000..8b17f8b2 Binary files /dev/null and b/src/tui/_assets/documents/warmup_ocr.pdf differ diff --git a/src/tui/main.py b/src/tui/main.py index c2a785f3..b68293fe 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -4,6 +4,10 @@ import sys from pathlib import Path from textual.app import App, ComposeResult from utils.logging_config import get_logger +try: + from importlib.resources import files +except ImportError: + from importlib_resources import files logger = get_logger(__name__) @@ -301,10 +305,42 @@ class OpenRAGTUI(App): return True, "Runtime requirements satisfied" +def copy_sample_documents(): + """Copy sample documents from package to current directory if they don't exist.""" + documents_dir = Path("documents") + + # Check if documents directory already exists and has files + if documents_dir.exists() and any(documents_dir.glob("*.pdf")): + return # Documents already exist, don't overwrite + + try: + # Get sample documents from package assets + assets_files = files("tui._assets.documents") + + # Create documents directory if it doesn't exist + documents_dir.mkdir(exist_ok=True) + + # Copy each sample document + for resource in assets_files.iterdir(): + if resource.is_file() and resource.name.endswith('.pdf'): + dest_path = documents_dir / resource.name + if not dest_path.exists(): + content = resource.read_bytes() + dest_path.write_bytes(content) + logger.info(f"Copied sample document: {resource.name}") + + except Exception as e: + logger.debug(f"Could not copy sample documents: {e}") + # This is not a critical error - the app can work without sample documents + + def run_tui(): """Run the OpenRAG TUI application.""" app = None try: + # Copy sample documents on first run + copy_sample_documents() + app = OpenRAGTUI() app.run() except KeyboardInterrupt: diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index d1258df8..d1be1e9f 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -9,6 +9,10 @@ from enum import Enum from pathlib import Path from typing import Dict, List, Optional, AsyncIterator from utils.logging_config import get_logger +try: + from importlib.resources import files +except ImportError: + from importlib_resources import files logger = get_logger(__name__) @@ -51,8 +55,8 @@ class ContainerManager: def __init__(self, compose_file: Optional[Path] = None): self.platform_detector = PlatformDetector() self.runtime_info = self.platform_detector.detect_runtime() - self.compose_file = compose_file or Path("docker-compose.yml") - self.cpu_compose_file = Path("docker-compose-cpu.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.services_cache: Dict[str, ServiceInfo] = {} self.last_status_update = 0 # Auto-select CPU compose if no GPU available @@ -80,6 +84,42 @@ class ContainerManager: "langflow": "langflow", } + def _find_compose_file(self, filename: str) -> Path: + """Find compose file in current directory or package resources.""" + # First check current working directory + cwd_path = Path(filename) + self._compose_search_log = f"Searching for {filename}:\n" + self._compose_search_log += f" 1. Current directory: {cwd_path.absolute()}" + + if cwd_path.exists(): + self._compose_search_log += " ✓ FOUND" + return cwd_path + else: + self._compose_search_log += " ✗ NOT FOUND" + + # Then check package resources + self._compose_search_log += f"\n 2. Package resources: " + try: + pkg_files = files("tui._assets") + self._compose_search_log += f"{pkg_files}" + compose_resource = pkg_files / filename + + if compose_resource.is_file(): + self._compose_search_log += f" ✓ FOUND, copying to current directory" + # Copy to cwd for compose command to work + content = compose_resource.read_text() + cwd_path.write_text(content) + return cwd_path + else: + self._compose_search_log += f" ✗ NOT FOUND" + except Exception as e: + self._compose_search_log += f" ✗ SKIPPED ({e})" + # Don't log this as an error since it's expected when running from source + + # Fall back to original path (will fail later if not found) + self._compose_search_log += f"\n 3. Falling back to: {cwd_path.absolute()}" + return Path(filename) + def is_available(self) -> bool: """Check if container runtime is available.""" return self.runtime_info.runtime_type != RuntimeType.NONE @@ -144,14 +184,15 @@ class ContainerManager: ) # Simple approach: read line by line and yield each one - while True: - line = await process.stdout.readline() - if not line: - break + if process.stdout: + while True: + line = await process.stdout.readline() + if not line: + break - line_text = line.decode().rstrip() - if line_text: - yield line_text + line_text = line.decode(errors="ignore").rstrip() + if line_text: + yield line_text # Wait for process to complete await process.wait() @@ -159,6 +200,59 @@ class ContainerManager: except Exception as e: yield f"Command execution failed: {e}" + async def _stream_compose_command( + self, + args: List[str], + success_flag: Dict[str, bool], + cpu_mode: Optional[bool] = None, + ) -> AsyncIterator[str]: + """Run compose command with live output and record success/failure.""" + if not self.is_available(): + success_flag["value"] = False + yield "No container runtime available" + return + + if cpu_mode is None: + cpu_mode = self.use_cpu_compose + compose_file = self.cpu_compose_file if cpu_mode else self.compose_file + cmd = self.runtime_info.compose_command + ["-f", str(compose_file)] + args + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=Path.cwd(), + ) + except Exception as e: + success_flag["value"] = False + yield f"Command execution failed: {e}" + return + + success_flag["value"] = True + + if process.stdout: + while True: + line = await process.stdout.readline() + if not line: + break + + line_text = line.decode(errors="ignore") + # Compose often uses carriage returns for progress bars; normalise them + for chunk in line_text.replace("\r", "\n").split("\n"): + chunk = chunk.strip() + if not chunk: + continue + yield chunk + lowered = chunk.lower() + if "error" in lowered or "failed" in lowered: + success_flag["value"] = False + + returncode = await process.wait() + if returncode != 0: + success_flag["value"] = False + yield f"Command exited with status {returncode}" + async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]: """Run a runtime command (docker/podman) and return (success, stdout, stderr).""" if not self.is_available(): @@ -408,19 +502,56 @@ class ContainerManager: return results async def start_services( - self, cpu_mode: bool = False + self, cpu_mode: Optional[bool] = None ) -> AsyncIterator[tuple[bool, str]]: """Start all services and yield progress updates.""" + if not self.is_available(): + yield False, "No container runtime available" + return + + # Diagnostic info about compose files + compose_file = self.cpu_compose_file if (cpu_mode if cpu_mode is not None else self.use_cpu_compose) else self.compose_file + + # Show the search process for debugging + if hasattr(self, '_compose_search_log'): + for line in self._compose_search_log.split('\n'): + if line.strip(): + yield False, line + + yield False, f"Final compose file: {compose_file.absolute()}" + if not compose_file.exists(): + yield False, f"ERROR: Compose file not found at {compose_file.absolute()}" + return + yield False, "Starting OpenRAG services..." - success, stdout, stderr = await self._run_compose_command( - ["up", "-d"], cpu_mode - ) + missing_images: List[str] = [] + try: + images_info = await self.get_project_images_info() + missing_images = [image for image, digest in images_info if digest == "-"] + except Exception: + missing_images = [] - if success: + if missing_images: + images_list = ", ".join(missing_images) + yield False, f"Pulling container images ({images_list})..." + pull_success = {"value": True} + async for line in self._stream_compose_command( + ["pull"], pull_success, cpu_mode + ): + yield False, line + if not pull_success["value"]: + yield False, "Some images failed to pull; attempting to start services anyway..." + + yield False, "Creating and starting containers..." + up_success = {"value": True} + async for line in self._stream_compose_command(["up", "-d"], up_success, cpu_mode): + yield False, line + + if up_success["value"]: yield True, "Services started successfully" else: - yield False, f"Failed to start services: {stderr}" + yield False, "Failed to start services. See output above for details." async def stop_services(self) -> AsyncIterator[tuple[bool, str]]: """Stop all services and yield progress updates.""" diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index 4c4f294d..9954b463 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -79,6 +79,15 @@ class EnvManager: """Generate a secure secret key for Langflow.""" return secrets.token_urlsafe(32) + def _quote_env_value(self, value: str) -> str: + """Single quote all environment variable values for consistency.""" + if not value: + return "''" + + # Escape any existing single quotes by replacing ' with '\'' + escaped_value = value.replace("'", "'\\''") + return f"'{escaped_value}'" + def load_existing_env(self) -> bool: """Load existing .env file if it exists.""" if not self.env_file.exists(): @@ -237,36 +246,36 @@ class EnvManager: # Core settings f.write("# Core settings\n") - f.write(f"LANGFLOW_SECRET_KEY={self.config.langflow_secret_key}\n") - f.write(f"LANGFLOW_SUPERUSER={self.config.langflow_superuser}\n") + f.write(f"LANGFLOW_SECRET_KEY={self._quote_env_value(self.config.langflow_secret_key)}\n") + f.write(f"LANGFLOW_SUPERUSER={self._quote_env_value(self.config.langflow_superuser)}\n") f.write( - f"LANGFLOW_SUPERUSER_PASSWORD={self.config.langflow_superuser_password}\n" + f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n" ) - f.write(f"LANGFLOW_CHAT_FLOW_ID={self.config.langflow_chat_flow_id}\n") + f.write(f"LANGFLOW_CHAT_FLOW_ID={self._quote_env_value(self.config.langflow_chat_flow_id)}\n") f.write( - f"LANGFLOW_INGEST_FLOW_ID={self.config.langflow_ingest_flow_id}\n" + f"LANGFLOW_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_ingest_flow_id)}\n" ) - f.write(f"NUDGES_FLOW_ID={self.config.nudges_flow_id}\n") - f.write(f"OPENSEARCH_PASSWORD={self.config.opensearch_password}\n") - f.write(f"OPENAI_API_KEY={self.config.openai_api_key}\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") + f.write(f"OPENAI_API_KEY={self._quote_env_value(self.config.openai_api_key)}\n") f.write( - f"OPENRAG_DOCUMENTS_PATHS={self.config.openrag_documents_paths}\n" + f"OPENRAG_DOCUMENTS_PATHS={self._quote_env_value(self.config.openrag_documents_paths)}\n" ) f.write("\n") # Ingestion settings f.write("# Ingestion settings\n") - f.write(f"DISABLE_INGEST_WITH_LANGFLOW={self.config.disable_ingest_with_langflow}\n") + f.write(f"DISABLE_INGEST_WITH_LANGFLOW={self._quote_env_value(self.config.disable_ingest_with_langflow)}\n") f.write("\n") # Langflow auth settings f.write("# Langflow auth settings\n") - f.write(f"LANGFLOW_AUTO_LOGIN={self.config.langflow_auto_login}\n") + f.write(f"LANGFLOW_AUTO_LOGIN={self._quote_env_value(self.config.langflow_auto_login)}\n") f.write( - f"LANGFLOW_NEW_USER_IS_ACTIVE={self.config.langflow_new_user_is_active}\n" + f"LANGFLOW_NEW_USER_IS_ACTIVE={self._quote_env_value(self.config.langflow_new_user_is_active)}\n" ) f.write( - f"LANGFLOW_ENABLE_SUPERUSER_CLI={self.config.langflow_enable_superuser_cli}\n" + f"LANGFLOW_ENABLE_SUPERUSER_CLI={self._quote_env_value(self.config.langflow_enable_superuser_cli)}\n" ) f.write("\n") @@ -277,10 +286,10 @@ class EnvManager: ): f.write("# Google OAuth settings\n") f.write( - f"GOOGLE_OAUTH_CLIENT_ID={self.config.google_oauth_client_id}\n" + f"GOOGLE_OAUTH_CLIENT_ID={self._quote_env_value(self.config.google_oauth_client_id)}\n" ) f.write( - f"GOOGLE_OAUTH_CLIENT_SECRET={self.config.google_oauth_client_secret}\n" + f"GOOGLE_OAUTH_CLIENT_SECRET={self._quote_env_value(self.config.google_oauth_client_secret)}\n" ) f.write("\n") @@ -290,10 +299,10 @@ class EnvManager: ): f.write("# Microsoft Graph OAuth settings\n") f.write( - f"MICROSOFT_GRAPH_OAUTH_CLIENT_ID={self.config.microsoft_graph_oauth_client_id}\n" + f"MICROSOFT_GRAPH_OAUTH_CLIENT_ID={self._quote_env_value(self.config.microsoft_graph_oauth_client_id)}\n" ) f.write( - f"MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET={self.config.microsoft_graph_oauth_client_secret}\n" + f"MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET={self._quote_env_value(self.config.microsoft_graph_oauth_client_secret)}\n" ) f.write("\n") @@ -311,7 +320,7 @@ class EnvManager: if not optional_written: f.write("# Optional settings\n") optional_written = True - f.write(f"{var_name}={var_value}\n") + f.write(f"{var_name}={self._quote_env_value(var_value)}\n") if optional_written: f.write("\n") diff --git a/src/tui/screens/diagnostics.py b/src/tui/screens/diagnostics.py index 3be628f2..bad456e4 100644 --- a/src/tui/screens/diagnostics.py +++ b/src/tui/screens/diagnostics.py @@ -10,10 +10,11 @@ from typing import List, Optional from textual.app import ComposeResult from textual.containers import Container, Vertical, Horizontal, ScrollableContainer from textual.screen import Screen -from textual.widgets import Header, Footer, Static, Button, Label, Log +from textual.widgets import Header, Footer, Static, Button, Log from rich.text import Text from ..managers.container_manager import ContainerManager +from ..utils.clipboard import copy_text_to_clipboard class DiagnosticsScreen(Screen): @@ -117,67 +118,13 @@ class DiagnosticsScreen(Screen): content = "\n".join(str(line) for line in log.lines) status = self.query_one("#copy-status", Static) - # Try to use pyperclip if available - try: - import pyperclip - - pyperclip.copy(content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - self._hide_status_after_delay(status) - return - except ImportError: - pass - - # Fallback to platform-specific clipboard commands - import subprocess - import platform - - system = platform.system() - if system == "Darwin": # macOS - process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, text=True) - process.communicate(input=content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - elif system == "Windows": - process = subprocess.Popen(["clip"], stdin=subprocess.PIPE, text=True) - process.communicate(input=content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - elif system == "Linux": - # Try xclip first, then xsel - try: - process = subprocess.Popen( - ["xclip", "-selection", "clipboard"], - stdin=subprocess.PIPE, - text=True, - ) - process.communicate(input=content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - except FileNotFoundError: - try: - process = subprocess.Popen( - ["xsel", "--clipboard", "--input"], - stdin=subprocess.PIPE, - text=True, - ) - process.communicate(input=content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - except FileNotFoundError: - self.notify( - "Clipboard utilities not found. Install xclip or xsel.", - severity="error", - ) - status.update( - "❌ Clipboard utilities not found. Install xclip or xsel." - ) + success, message = copy_text_to_clipboard(content) + if success: + self.notify(message, severity="information") + status.update(f"✓ {message}") else: - self.notify( - "Clipboard not supported on this platform", severity="error" - ) - status.update("❌ Clipboard not supported on this platform") + self.notify(message, severity="error") + status.update(f"❌ {message}") self._hide_status_after_delay(status) except Exception as e: diff --git a/src/tui/screens/logs.py b/src/tui/screens/logs.py index 74c1adf1..c426c6b4 100644 --- a/src/tui/screens/logs.py +++ b/src/tui/screens/logs.py @@ -10,11 +10,32 @@ from rich.text import Text from ..managers.container_manager import ContainerManager from ..managers.docling_manager import DoclingManager +from ..utils.clipboard import copy_text_to_clipboard class LogsScreen(Screen): """Logs viewing and monitoring screen.""" + CSS = """ + #main-container { + height: 1fr; + } + + #logs-content { + height: 1fr; + padding: 1 1 0 1; + } + + #logs-area { + height: 1fr; + min-height: 30; + } + + #logs-button-row { + padding: 1 0 0 0; + } + """ + BINDINGS = [ ("escape", "back", "Back"), ("f", "follow", "Follow Logs"), @@ -27,6 +48,7 @@ class LogsScreen(Screen): ("k", "scroll_up", "Scroll Up"), ("ctrl+u", "scroll_page_up", "Page Up"), ("ctrl+f", "scroll_page_down", "Page Down"), + ("ctrl+c", "copy_logs", "Copy Logs"), ] def __init__(self, initial_service: str = "openrag-backend"): @@ -51,17 +73,17 @@ class LogsScreen(Screen): self.following = False self.follow_task = None self.auto_scroll = True + self._status_task = None def compose(self) -> ComposeResult: """Create the logs screen layout.""" - yield Container( - Vertical( - Static(f"Service Logs: {self.current_service}", id="logs-title"), - self._create_logs_area(), - id="logs-content", - ), - id="main-container", - ) + with Container(id="main-container"): + with Vertical(id="logs-content"): + yield Static(f"Service Logs: {self.current_service}", id="logs-title") + yield self._create_logs_area() + with Horizontal(id="logs-button-row"): + yield Button("Copy to Clipboard", variant="default", id="copy-btn") + yield Static("", id="copy-status", classes="copy-indicator") yield Footer() def _create_logs_area(self) -> TextArea: @@ -108,6 +130,9 @@ class LogsScreen(Screen): def on_unmount(self) -> None: """Clean up when unmounting.""" self._stop_following() + if self._status_task: + self._status_task.cancel() + self._status_task = None async def _load_logs(self, lines: int = 200) -> None: """Load recent logs for the current service.""" @@ -235,6 +260,10 @@ class LogsScreen(Screen): """Clear the logs area.""" self.logs_area.text = "" + def action_copy_logs(self) -> None: + """Copy log content to the clipboard.""" + self._copy_logs_to_clipboard() + def action_toggle_auto_scroll(self) -> None: """Toggle auto scroll on/off.""" self.auto_scroll = not self.auto_scroll @@ -284,3 +313,44 @@ class LogsScreen(Screen): """Go back to previous screen.""" self._stop_following() self.app.pop_screen() + + def _copy_logs_to_clipboard(self) -> None: + """Copy the current log buffer to the clipboard.""" + if not self.logs_area: + return + + content = self.logs_area.text or "" + status_widget = self.query_one("#copy-status", Static) + + if not content.strip(): + message = "No logs to copy" + self.notify(message, severity="warning") + status_widget.update(Text("⚠ No logs to copy", style="bold yellow")) + self._schedule_status_clear(status_widget) + return + + success, message = copy_text_to_clipboard(content) + self.notify(message, severity="information" if success else "error") + prefix = "✓" if success else "❌" + style = "bold green" if success else "bold red" + status_widget.update(Text(f"{prefix} {message}", style=style)) + self._schedule_status_clear(status_widget) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "copy-btn": + self._copy_logs_to_clipboard() + + def _schedule_status_clear(self, widget: Static, delay: float = 3.0) -> None: + """Clear the status message after a short delay.""" + if self._status_task: + self._status_task.cancel() + + async def _clear() -> None: + try: + await asyncio.sleep(delay) + widget.update("") + except asyncio.CancelledError: + pass + + self._status_task = asyncio.create_task(_clear()) diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index 372abc27..ba8e6659 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -145,11 +145,7 @@ class MonitorScreen(Screen): # Set up auto-refresh every 5 seconds self.refresh_timer = self.set_interval(5.0, self._auto_refresh) - # Focus the services table - try: - self.services_table.focus() - except Exception: - pass + self._focus_services_table() def on_unmount(self) -> None: """Clean up when unmounting.""" @@ -224,6 +220,9 @@ class MonitorScreen(Screen): docling_pid, "Start/Stop/Logs" ) + # Restore docling selection when it was the last active table + if self._last_selected_table == "docling": + self._focus_docling_table(focus=False, set_last=False) # Populate images table (unique images as reported by runtime) if self.images_table: for image in sorted(images): @@ -509,16 +508,52 @@ class MonitorScreen(Screen): self.run_worker(self._refresh_services()) def action_cursor_down(self) -> None: - """Move cursor down in services table.""" + """Move selection down, handling both tables.""" + active_table = self._get_active_table_name() + try: - self.services_table.action_cursor_down() + if active_table == "docling": + return # Nothing to move within docling table + + if not self.services_table: + return + + row_count = self._table_row_count(self.services_table) + current = self._get_cursor_row(self.services_table) + if current is None: + current = 0 + + if current < row_count - 1: + self.services_table.action_cursor_down() + self._last_selected_table = "services" + elif self._table_row_count(self.docling_table): + self._focus_docling_table() except Exception: pass def action_cursor_up(self) -> None: - """Move cursor up in services table.""" + """Move selection up, handling both tables.""" + active_table = self._get_active_table_name() + try: - self.services_table.action_cursor_up() + if active_table == "docling": + self._focus_services_table(row="last") + return + + if not self.services_table: + return + + current = self._get_cursor_row(self.services_table) + if current is None: + current = 0 + + if current > 0: + self.services_table.action_cursor_up() + else: + # Already at the top; nothing else to do + self._set_cursor_row(self.services_table, 0) + + self._last_selected_table = "services" except Exception: pass @@ -664,59 +699,37 @@ class MonitorScreen(Screen): self.notify(f"Error opening logs: {e}", severity="error") def _get_selected_service(self) -> str | None: - """Get the currently selected service from either table.""" + """Resolve the currently selected service based on active table.""" try: - # Check both tables regardless of last_selected_table to handle cursor navigation - services_table = self.query_one("#services-table", DataTable) - services_cursor = services_table.cursor_row + active_table = self._get_active_table_name() - docling_cursor = None - if self.docling_table: - docling_cursor = self.docling_table.cursor_row - - # If we have a last selected table preference, use it if that table has a valid selection - if self._last_selected_table == "docling" and self.docling_table: - if docling_cursor is not None and docling_cursor >= 0: - row_data = self.docling_table.get_row_at(docling_cursor) - if row_data: - return "docling-serve" - - elif self._last_selected_table == "services": - if services_cursor is not None and services_cursor >= 0: - row_data = services_table.get_row_at(services_cursor) - if row_data: - service_name = str(row_data[0]) - service_mapping = { - "openrag-backend": "openrag-backend", - "openrag-frontend": "openrag-frontend", - "opensearch": "opensearch", - "langflow": "langflow", - "dashboards": "dashboards", - } - selected_service = service_mapping.get(service_name, service_name) - return selected_service - - # Fallback: check both tables if no last_selected_table or it doesn't have a selection - if self.docling_table and docling_cursor is not None and docling_cursor >= 0: - row_data = self.docling_table.get_row_at(docling_cursor) - if row_data: + if active_table == "docling" and self.docling_table: + cursor = self._get_cursor_row(self.docling_table) + if cursor is not None and cursor >= 0: return "docling-serve" - if services_cursor is not None and services_cursor >= 0: - row_data = services_table.get_row_at(services_cursor) - if row_data: - service_name = str(row_data[0]) - service_mapping = { - "openrag-backend": "openrag-backend", - "openrag-frontend": "openrag-frontend", - "opensearch": "opensearch", - "langflow": "langflow", - "dashboards": "dashboards", - } - selected_service = service_mapping.get(service_name, service_name) - return selected_service + services_table = self.query_one("#services-table", DataTable) + row_count = self._table_row_count(services_table) + if row_count == 0: + return None - return None + cursor = self._get_cursor_row(services_table) + if cursor is None or cursor < 0 or cursor >= row_count: + cursor = 0 + + row_data = services_table.get_row_at(cursor) + if not row_data: + return None + + service_name = str(row_data[0]) + service_mapping = { + "openrag-backend": "openrag-backend", + "openrag-frontend": "openrag-frontend", + "opensearch": "opensearch", + "langflow": "langflow", + "dashboards": "dashboards", + } + return service_mapping.get(service_name, service_name) except Exception as e: self.notify(f"Error getting selected service: {e}", severity="error") return None @@ -728,15 +741,118 @@ class MonitorScreen(Screen): try: # Track which table was selected if selected_table.id == "services-table": - self._last_selected_table = "services" - # Clear docling table selection - if self.docling_table: - self.docling_table.cursor_row = -1 + self._focus_services_table(row="current") elif selected_table.id == "docling-table": - self._last_selected_table = "docling" - # Clear services table selection - services_table = self.query_one("#services-table", DataTable) - services_table.cursor_row = -1 + self._focus_docling_table() except Exception: # Ignore errors during table manipulation pass + + def _get_active_table_name(self) -> str: + """Determine which table is currently active.""" + if self.docling_table and self.docling_table.has_focus: + return "docling" + if self.services_table and self.services_table.has_focus: + return "services" + return self._last_selected_table or "services" + + def _table_row_count(self, table: DataTable | None) -> int: + """Safely compute the number of rows in a DataTable.""" + if not table: + return 0 + + count_attr = getattr(table, "row_count", None) + if callable(count_attr): + try: + return int(count_attr()) + except Exception: + pass + + if isinstance(count_attr, int): + return count_attr + + try: + rows = getattr(table, "rows", None) + if rows is not None: + return len(rows) + except Exception: + pass + + return 0 + + def _get_cursor_row(self, table: DataTable | None) -> int | None: + """Return the current cursor row for the given table.""" + if not table: + return None + + coord = getattr(table, "cursor_coordinate", None) + if coord is None: + return None + + row = getattr(coord, "row", None) + if row is not None: + return row + + if isinstance(coord, tuple) and coord: + return coord[0] + + return None + + def _set_cursor_row(self, table: DataTable | None, row: int) -> None: + """Set the cursor row for the given table, if possible.""" + if not table: + return + + try: + table.cursor_coordinate = (row, 0) + except Exception: + move_cursor = getattr(table, "move_cursor", None) + if callable(move_cursor): + try: + move_cursor(row, 0, expand=False) + except Exception: + pass + + def _focus_services_table(self, row: str | None = None, set_last: bool = True) -> None: + """Focus the services table and update selection.""" + if not self.services_table: + return + + try: + self.services_table.focus() + row_count = self._table_row_count(self.services_table) + + if row_count: + if row == "last": + self._set_cursor_row(self.services_table, row_count - 1) + elif row == "current": + # Keep existing cursor position if valid + cursor = self._get_cursor_row(self.services_table) + if cursor is None or cursor < 0 or cursor >= row_count: + self._set_cursor_row(self.services_table, 0) + else: + cursor = self._get_cursor_row(self.services_table) + if cursor is None or cursor < 0: + self._set_cursor_row(self.services_table, 0) + + if set_last: + self._last_selected_table = "services" + except Exception: + pass + + def _focus_docling_table(self, focus: bool = True, set_last: bool = True) -> None: + """Focus the docling table and select its row.""" + if not self.docling_table: + return + + try: + if focus: + self.docling_table.focus() + + if self._table_row_count(self.docling_table): + self._set_cursor_row(self.docling_table, 0) + + if set_last: + self._last_selected_table = "docling" + except Exception: + pass diff --git a/src/tui/utils/clipboard.py b/src/tui/utils/clipboard.py new file mode 100644 index 00000000..4548a76c --- /dev/null +++ b/src/tui/utils/clipboard.py @@ -0,0 +1,50 @@ +"""Clipboard helper utilities for the TUI.""" + +from __future__ import annotations + +import platform +import subprocess +from typing import Tuple + + +def copy_text_to_clipboard(text: str) -> Tuple[bool, str]: + """Copy ``text`` to the system clipboard. + + Returns a tuple of (success, message) so callers can surface feedback to users. + """ + # Try optional dependency first for cross-platform consistency + try: + import pyperclip # type: ignore + + pyperclip.copy(text) + return True, "Copied to clipboard" + except ImportError: + # Fall back to platform-specific commands + pass + except Exception as exc: # pragma: no cover - defensive catch for pyperclip edge cases + return False, f"Clipboard error: {exc}" + + system = platform.system() + + try: + if system == "Darwin": + process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, text=True) + process.communicate(input=text) + return True, "Copied to clipboard" + if system == "Windows": + process = subprocess.Popen(["clip"], stdin=subprocess.PIPE, text=True) + process.communicate(input=text) + return True, "Copied to clipboard" + if system == "Linux": + for command in (["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]): + try: + process = subprocess.Popen(command, stdin=subprocess.PIPE, text=True) + process.communicate(input=text) + return True, "Copied to clipboard" + except FileNotFoundError: + continue + return False, "Clipboard utilities not found. Install xclip or xsel." + return False, "Clipboard not supported on this platform" + except Exception as exc: # pragma: no cover - subprocess errors + return False, f"Clipboard error: {exc}" + diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 8f703648..015861f0 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -2,14 +2,15 @@ import asyncio import inspect -from typing import Callable, List, Optional, AsyncIterator, Any +from typing import Callable, Optional, AsyncIterator +from rich.text import Text from textual.app import ComposeResult -from textual.worker import Worker from textual.containers import Container, ScrollableContainer from textual.screen import ModalScreen -from textual.widgets import Button, Static, Label, RichLog -from rich.console import Console +from textual.widgets import Button, Static, Label, TextArea + +from ..utils.clipboard import copy_text_to_clipboard class CommandOutputModal(ModalScreen): @@ -46,11 +47,14 @@ class CommandOutputModal(ModalScreen): #command-output { height: 100%; border: solid $accent; - padding: 1 2; margin: 1 0; background: $surface-darken-1; } + #command-output > .text-area--content { + padding: 1 2; + } + #button-row { width: 100%; height: auto; @@ -63,6 +67,11 @@ class CommandOutputModal(ModalScreen): margin: 0 1; min-width: 16; } + + #copy-status { + text-align: center; + margin-bottom: 1; + } """ def __init__( @@ -82,44 +91,66 @@ class CommandOutputModal(ModalScreen): self.title_text = title self.command_generator = command_generator self.on_complete = on_complete + self._output_text: str = "" + self._status_task: Optional[asyncio.Task] = None def compose(self) -> ComposeResult: """Create the modal dialog layout.""" with Container(id="dialog"): yield Label(self.title_text, id="title") with ScrollableContainer(id="output-container"): - yield RichLog(id="command-output", highlight=True, markup=True) + yield TextArea( + text="", + read_only=True, + show_line_numbers=False, + id="command-output", + ) with Container(id="button-row"): - yield Button("Close", variant="primary", id="close-btn") + yield Button("Copy Output", variant="default", id="copy-btn") + yield Button( + "Close", variant="primary", id="close-btn", disabled=True + ) + yield Static("", id="copy-status") def on_mount(self) -> None: """Start the command when the modal is mounted.""" # Start the command but don't store the worker self.run_worker(self._run_command(), exclusive=False) + # Focus the output so users can select text immediately + try: + self.query_one("#command-output", TextArea).focus() + except Exception: + pass + + def on_unmount(self) -> None: + """Cancel any pending timers when modal closes.""" + if self._status_task: + self._status_task.cancel() + self._status_task = None def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "close-btn": self.dismiss() + elif event.button.id == "copy-btn": + self.copy_to_clipboard() async def _run_command(self) -> None: """Run the command and update the output in real-time.""" - output = self.query_one("#command-output", RichLog) + output = self.query_one("#command-output", TextArea) + container = self.query_one("#output-container", ScrollableContainer) try: async for is_complete, message in self.command_generator: - # Simple approach: just append each line as it comes - output.write(message + "\n") - - # Scroll to bottom - container = self.query_one("#output-container", ScrollableContainer) + self._append_output(message) + output.text = self._output_text container.scroll_end(animate=False) # If command is complete, update UI if is_complete: - output.write( - "[bold green]Command completed successfully[/bold green]\n" - ) + self._append_output("Command completed successfully") + output.text = self._output_text + container.scroll_end(animate=False) # Call the completion callback if provided if self.on_complete: await asyncio.sleep(0.5) # Small delay for better UX @@ -131,12 +162,57 @@ class CommandOutputModal(ModalScreen): self.call_after_refresh(_invoke_callback) except Exception as e: - output.write(f"[bold red]Error: {e}[/bold red]\n") + self._append_output(f"Error: {e}") + output.text = self._output_text + container.scroll_end(animate=False) + finally: + # Enable the close button and focus it + close_btn = self.query_one("#close-btn", Button) + close_btn.disabled = False + close_btn.focus() - # Enable the close button and focus it - close_btn = self.query_one("#close-btn", Button) - close_btn.disabled = False - close_btn.focus() + def _append_output(self, message: str) -> None: + """Append a message to the output buffer.""" + if message is None: + return + message = message.rstrip("\n") + if not message: + return + if self._output_text: + self._output_text += "\n" + message + else: + self._output_text = message + + def copy_to_clipboard(self) -> None: + """Copy the modal output to the clipboard.""" + if not self._output_text: + message = "No output to copy yet" + self.notify(message, severity="warning") + status = self.query_one("#copy-status", Static) + status.update(Text(message, style="bold yellow")) + self._schedule_status_clear(status) + return + + success, message = copy_text_to_clipboard(self._output_text) + self.notify(message, severity="information" if success else "error") + status = self.query_one("#copy-status", Static) + style = "bold green" if success else "bold red" + status.update(Text(message, style=style)) + self._schedule_status_clear(status) + + def _schedule_status_clear(self, widget: Static, delay: float = 3.0) -> None: + """Clear the status message after a delay.""" + if self._status_task: + self._status_task.cancel() + + async def _clear() -> None: + try: + await asyncio.sleep(delay) + widget.update("") + except asyncio.CancelledError: + pass + + self._status_task = asyncio.create_task(_clear()) # Made with Bob diff --git a/uv.lock b/uv.lock index 853b6b23..0a60fd52 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" resolution-markers = [ "sys_platform == 'darwin'", @@ -2282,7 +2282,7 @@ wheels = [ [[package]] name = "openrag" -version = "0.1.3" +version = "0.1.8" source = { editable = "." } dependencies = [ { name = "agentd" },