diff --git a/frontend/app/api/queries/useDoclingHealthQuery.ts b/frontend/app/api/queries/useDoclingHealthQuery.ts index 88c0a39b..01441f4b 100644 --- a/frontend/app/api/queries/useDoclingHealthQuery.ts +++ b/frontend/app/api/queries/useDoclingHealthQuery.ts @@ -56,8 +56,13 @@ export const useDoclingHealthQuery = ( queryKey: ["docling-health"], queryFn: checkDoclingHealth, retry: 1, - refetchInterval: 30000, // Check every 30 seconds - staleTime: 25000, // Consider data stale after 25 seconds + refetchInterval: (query) => { + // If healthy, check every 30 seconds; otherwise check every 3 seconds + return query.state.data?.status === "healthy" ? 30000 : 3000; + }, + refetchOnWindowFocus: true, + refetchOnMount: true, + staleTime: 30000, // Consider data stale after 25 seconds ...options, }, queryClient, diff --git a/frontend/app/api/queries/useProviderHealthQuery.ts b/frontend/app/api/queries/useProviderHealthQuery.ts index d4038cfc..82ca2db2 100644 --- a/frontend/app/api/queries/useProviderHealthQuery.ts +++ b/frontend/app/api/queries/useProviderHealthQuery.ts @@ -92,6 +92,13 @@ export const useProviderHealthQuery = ( queryKey: ["provider", "health"], queryFn: checkProviderHealth, retry: false, // Don't retry health checks automatically + refetchInterval: (query) => { + // If healthy, check every 30 seconds; otherwise check every 3 seconds + return query.state.data?.status === "healthy" ? 30000 : 3000; + }, + refetchOnWindowFocus: true, + refetchOnMount: true, + staleTime: 30000, // Consider data stale after 25 seconds enabled: !!settings?.edited && options?.enabled !== false, // Only run after onboarding is complete ...options, }, diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7ffab80e..4765ef8c 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -365,4 +365,22 @@ width: 100%; height: 30px; } + + .thinking-dots::after { + content: "."; + animation: thinking-dots 1.4s steps(3, end) infinite; + } + + @keyframes thinking-dots { + 0% { + content: "."; + } + 33.33% { + content: ".."; + } + 66.66%, + 100% { + content: "..."; + } + } } diff --git a/frontend/src/app/chat/components/assistant-message.tsx b/frontend/src/app/chat/components/assistant-message.tsx new file mode 100644 index 00000000..0f24dd8c --- /dev/null +++ b/frontend/src/app/chat/components/assistant-message.tsx @@ -0,0 +1,97 @@ +import { GitBranch } from "lucide-react"; +import { motion } from "motion/react"; +import DogIcon from "@/components/logo/dog-icon"; +import { MarkdownRenderer } from "@/components/markdown-renderer"; +import { cn } from "@/lib/utils"; +import type { FunctionCall } from "../types"; +import { FunctionCalls } from "./function-calls"; +import { Message } from "./message"; + +interface AssistantMessageProps { + content: string; + functionCalls?: FunctionCall[]; + messageIndex?: number; + expandedFunctionCalls: Set; + onToggle: (functionCallId: string) => void; + isStreaming?: boolean; + showForkButton?: boolean; + onFork?: (e: React.MouseEvent) => void; + isCompleted?: boolean; + isInactive?: boolean; + animate?: boolean; + delay?: number; +} + +export function AssistantMessage({ + content, + functionCalls = [], + messageIndex, + expandedFunctionCalls, + onToggle, + isStreaming = false, + showForkButton = false, + onFork, + isCompleted = false, + isInactive = false, + animate = true, + delay = 0.2, +}: AssistantMessageProps) { + return ( + + + + + } + actions={ + showForkButton && onFork ? ( + + ) : undefined + } + > + +
+ ' + : 'Thinking') + : content + } + /> +
+
+
+ ); +} diff --git a/frontend/src/app/onboarding/components/ibm-onboarding.tsx b/frontend/src/app/onboarding/components/ibm-onboarding.tsx new file mode 100644 index 00000000..3bb830b6 --- /dev/null +++ b/frontend/src/app/onboarding/components/ibm-onboarding.tsx @@ -0,0 +1,210 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; +import IBMLogo from "@/components/logo/ibm-logo"; +import { useDebouncedValue } from "@/lib/debounce"; +import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; +import { useGetIBMModelsQuery } from "../../api/queries/useGetModelsQuery"; +import { useModelSelection } from "../hooks/useModelSelection"; +import { useUpdateSettings } from "../hooks/useUpdateSettings"; +import { AdvancedOnboarding } from "./advanced"; +import { ModelSelector } from "./model-selector"; + +export function IBMOnboarding({ + isEmbedding = false, + setSettings, + sampleDataset, + setSampleDataset, + setIsLoadingModels, + alreadyConfigured = false, +}: { + isEmbedding?: boolean; + setSettings: Dispatch>; + sampleDataset: boolean; + setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + alreadyConfigured?: boolean; +}) { + const [endpoint, setEndpoint] = useState(alreadyConfigured ? "" : "https://us-south.ml.cloud.ibm.com"); + const [apiKey, setApiKey] = useState(""); + const [projectId, setProjectId] = useState(""); + + const options = [ + { + value: "https://us-south.ml.cloud.ibm.com", + label: "https://us-south.ml.cloud.ibm.com", + default: true, + }, + { + value: "https://eu-de.ml.cloud.ibm.com", + label: "https://eu-de.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://eu-gb.ml.cloud.ibm.com", + label: "https://eu-gb.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://au-syd.ml.cloud.ibm.com", + label: "https://au-syd.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://jp-tok.ml.cloud.ibm.com", + label: "https://jp-tok.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://ca-tor.ml.cloud.ibm.com", + label: "https://ca-tor.ml.cloud.ibm.com", + default: false, + }, + ]; + const debouncedEndpoint = useDebouncedValue(endpoint, 500); + const debouncedApiKey = useDebouncedValue(apiKey, 500); + const debouncedProjectId = useDebouncedValue(projectId, 500); + + // Fetch models from API when all credentials are provided + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useGetIBMModelsQuery( + { + endpoint: debouncedEndpoint ? debouncedEndpoint : undefined, + apiKey: debouncedApiKey ? debouncedApiKey : undefined, + projectId: debouncedProjectId ? debouncedProjectId : undefined, + }, + { enabled: !!debouncedEndpoint || !!debouncedApiKey || !!debouncedProjectId || alreadyConfigured }, + ); + + // Use custom hook for model selection logic + const { + languageModel, + embeddingModel, + setLanguageModel, + setEmbeddingModel, + languageModels, + embeddingModels, + } = useModelSelection(modelsData, isEmbedding); + const handleSampleDatasetChange = (dataset: boolean) => { + setSampleDataset(dataset); + }; + + useEffect(() => { + setIsLoadingModels?.(isLoadingModels); + }, [isLoadingModels, setIsLoadingModels]); + + // Update settings when values change + useUpdateSettings( + "watsonx", + { + endpoint, + apiKey, + projectId, + languageModel, + embeddingModel, + }, + setSettings, + isEmbedding, + ); + + return ( + <> +
+ +
+ {} : setEndpoint} + searchPlaceholder="Search endpoint..." + noOptionsPlaceholder={ + alreadyConfigured + ? "https://•••••••••••••••••••••••••••••••••••••••••" + : "No endpoints available" + } + placeholder="Select endpoint..." + /> + {alreadyConfigured && ( +

+ Reusing endpoint from model provider selection. +

+ )} +
+
+ +
+ setProjectId(e.target.value)} + disabled={alreadyConfigured} + /> + {alreadyConfigured && ( +

+ Reusing project ID from model provider selection. +

+ )} +
+
+ setApiKey(e.target.value)} + disabled={alreadyConfigured} + /> + {alreadyConfigured && ( +

+ Reusing API key from model provider selection. +

+ )} +
+ {isLoadingModels && ( +

+ Validating configuration... +

+ )} + {modelsError && ( +

+ Connection failed. Check your configuration. +

+ )} +
+ } + languageModels={languageModels} + embeddingModels={embeddingModels} + languageModel={languageModel} + embeddingModel={embeddingModel} + sampleDataset={sampleDataset} + setLanguageModel={setLanguageModel} + setEmbeddingModel={setEmbeddingModel} + setSampleDataset={handleSampleDatasetChange} + /> + + ); +} diff --git a/frontend/src/app/onboarding/components/ollama-onboarding.tsx b/frontend/src/app/onboarding/components/ollama-onboarding.tsx new file mode 100644 index 00000000..e85366ba --- /dev/null +++ b/frontend/src/app/onboarding/components/ollama-onboarding.tsx @@ -0,0 +1,174 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; +import OllamaLogo from "@/components/logo/ollama-logo"; +import { useDebouncedValue } from "@/lib/debounce"; +import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; +import { useGetOllamaModelsQuery } from "../../api/queries/useGetModelsQuery"; +import { useModelSelection } from "../hooks/useModelSelection"; +import { useUpdateSettings } from "../hooks/useUpdateSettings"; +import { ModelSelector } from "./model-selector"; + +export function OllamaOnboarding({ + setSettings, + sampleDataset, + setSampleDataset, + setIsLoadingModels, + isEmbedding = false, + alreadyConfigured = false, +}: { + setSettings: Dispatch>; + sampleDataset: boolean; + setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + isEmbedding?: boolean; + alreadyConfigured?: boolean; +}) { + const [endpoint, setEndpoint] = useState(alreadyConfigured ? undefined : `http://localhost:11434`); + const [showConnecting, setShowConnecting] = useState(false); + const debouncedEndpoint = useDebouncedValue(endpoint, 500); + + // Fetch models from API when endpoint is provided (debounced) + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useGetOllamaModelsQuery( + debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined, + { enabled: !!debouncedEndpoint || alreadyConfigured }, + ); + + // Use custom hook for model selection logic + const { + languageModel, + embeddingModel, + setLanguageModel, + setEmbeddingModel, + languageModels, + embeddingModels, + } = useModelSelection(modelsData, isEmbedding); + + // Handle delayed display of connecting state + useEffect(() => { + let timeoutId: NodeJS.Timeout; + + if (debouncedEndpoint && isLoadingModels) { + timeoutId = setTimeout(() => { + setIsLoadingModels?.(true); + setShowConnecting(true); + }, 500); + } else { + setShowConnecting(false); + setIsLoadingModels?.(false); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [debouncedEndpoint, isLoadingModels, setIsLoadingModels]); + + // Update settings when values change + useUpdateSettings( + "ollama", + { + endpoint, + languageModel, + embeddingModel, + }, + setSettings, + isEmbedding, + ); + + // Check validation state based on models query + const hasConnectionError = debouncedEndpoint && modelsError; + const hasNoModels = + modelsData && + !modelsData.language_models?.length && + !modelsData.embedding_models?.length; + + return ( +
+
+ setEndpoint(e.target.value)} + disabled={alreadyConfigured} + /> + {alreadyConfigured && ( +

+ Reusing endpoint from model provider selection. +

+ )} + {showConnecting && ( +

+ Connecting to Ollama server... +

+ )} + {hasConnectionError && ( +

+ Can't reach Ollama at {debouncedEndpoint}. Update the base URL or + start the server. +

+ )} + {hasNoModels && ( +

+ No models found. Install embedding and agent models on your Ollama + server. +

+ )} +
+ {isEmbedding && setEmbeddingModel && ( + + } + noOptionsPlaceholder={ + isLoadingModels + ? "Loading models..." + : "No embedding models detected. Install an embedding model to continue." + } + value={embeddingModel} + onValueChange={setEmbeddingModel} + /> + + )} + {!isEmbedding && setLanguageModel && ( + + } + noOptionsPlaceholder={ + isLoadingModels + ? "Loading models..." + : "No language models detected. Install a language model to continue." + } + value={languageModel} + onValueChange={setLanguageModel} + /> + + )} +
+ ); +} diff --git a/frontend/src/app/onboarding/components/openai-onboarding.tsx b/frontend/src/app/onboarding/components/openai-onboarding.tsx new file mode 100644 index 00000000..47c427a9 --- /dev/null +++ b/frontend/src/app/onboarding/components/openai-onboarding.tsx @@ -0,0 +1,168 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; +import OpenAILogo from "@/components/logo/openai-logo"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/lib/debounce"; +import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; +import { useGetOpenAIModelsQuery } from "../../api/queries/useGetModelsQuery"; +import { useModelSelection } from "../hooks/useModelSelection"; +import { useUpdateSettings } from "../hooks/useUpdateSettings"; +import { AdvancedOnboarding } from "./advanced"; + +export function OpenAIOnboarding({ + setSettings, + sampleDataset, + setSampleDataset, + setIsLoadingModels, + isEmbedding = false, + hasEnvApiKey = false, + alreadyConfigured = false, +}: { + setSettings: Dispatch>; + sampleDataset: boolean; + setSampleDataset: (dataset: boolean) => void; + setIsLoadingModels?: (isLoading: boolean) => void; + isEmbedding?: boolean; + hasEnvApiKey?: boolean; + alreadyConfigured?: boolean; +}) { + const [apiKey, setApiKey] = useState(""); + const [getFromEnv, setGetFromEnv] = useState(hasEnvApiKey && !alreadyConfigured); + const debouncedApiKey = useDebouncedValue(apiKey, 500); + + // Fetch models from API when API key is provided + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useGetOpenAIModelsQuery( + getFromEnv + ? { apiKey: "" } + : debouncedApiKey + ? { apiKey: debouncedApiKey } + : undefined, + { enabled: debouncedApiKey !== "" || getFromEnv || alreadyConfigured }, + ); + // Use custom hook for model selection logic + const { + languageModel, + embeddingModel, + setLanguageModel, + setEmbeddingModel, + languageModels, + embeddingModels, + } = useModelSelection(modelsData, isEmbedding); + const handleSampleDatasetChange = (dataset: boolean) => { + setSampleDataset(dataset); + }; + + const handleGetFromEnvChange = (fromEnv: boolean) => { + setGetFromEnv(fromEnv); + if (fromEnv) { + setApiKey(""); + } + setEmbeddingModel?.(""); + setLanguageModel?.(""); + }; + + useEffect(() => { + setIsLoadingModels?.(isLoadingModels); + }, [isLoadingModels, setIsLoadingModels]); + + // Update settings when values change + useUpdateSettings( + "openai", + { + apiKey, + languageModel, + embeddingModel, + }, + setSettings, + isEmbedding, + ); + + return ( + <> +
+ {!alreadyConfigured && ( + + + +
+ +
+
+ {!hasEnvApiKey && ( + + OpenAI API key not detected in the environment. + + )} +
+
+ )} + {(!getFromEnv || alreadyConfigured) && ( +
+ setApiKey(e.target.value)} + disabled={alreadyConfigured} + /> + {alreadyConfigured && ( +

+ Reusing key from model provider selection. +

+ )} + {isLoadingModels && ( +

+ Validating API key... +

+ )} + {modelsError && ( +

+ Invalid OpenAI API key. Verify or replace the key. +

+ )} +
+ )} +
+ } + languageModels={languageModels} + embeddingModels={embeddingModels} + languageModel={languageModel} + embeddingModel={embeddingModel} + sampleDataset={sampleDataset} + setLanguageModel={setLanguageModel} + setSampleDataset={handleSampleDatasetChange} + setEmbeddingModel={setEmbeddingModel} + /> + + ); +} diff --git a/src/api/provider_validation.py b/src/api/provider_validation.py index e51cc3bc..2fcc1e65 100644 --- a/src/api/provider_validation.py +++ b/src/api/provider_validation.py @@ -112,7 +112,7 @@ async def _test_openai_completion_with_tools(api_key: str, llm_model: str) -> No } # Simple tool calling test - payload = { + base_payload = { "model": llm_model, "messages": [ {"role": "user", "content": "What tools do you have available?"} @@ -136,10 +136,11 @@ async def _test_openai_completion_with_tools(api_key: str, llm_model: str) -> No } } ], - "max_tokens": 50, } async with httpx.AsyncClient() as client: + # Try with max_tokens first + payload = {**base_payload, "max_tokens": 50} response = await client.post( "https://api.openai.com/v1/chat/completions", headers=headers, @@ -147,6 +148,17 @@ async def _test_openai_completion_with_tools(api_key: str, llm_model: str) -> No timeout=30.0, ) + # If max_tokens doesn't work, try with max_completion_tokens + if response.status_code != 200: + logger.info("max_tokens parameter failed, trying max_completion_tokens instead") + payload = {**base_payload, "max_completion_tokens": 50} + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers=headers, + json=payload, + timeout=30.0, + ) + if response.status_code != 200: logger.error(f"OpenAI completion test failed: {response.status_code} - {response.text}") raise Exception(f"OpenAI API error: {response.status_code}") diff --git a/src/services/models_service.py b/src/services/models_service.py index 28dee73a..f26d0594 100644 --- a/src/services/models_service.py +++ b/src/services/models_service.py @@ -1,6 +1,5 @@ import httpx from typing import Dict, List -from api.provider_validation import test_embedding from utils.container_utils import transform_localhost_url from utils.logging_config import get_logger @@ -229,20 +228,14 @@ class ModelsService: f"Model: {model_name}, Capabilities: {capabilities}" ) - # Check if model has required capabilities + # Check if model has embedding capability + has_embedding = "embedding" in capabilities + # Check if model has required capabilities for language models has_completion = DESIRED_CAPABILITY in capabilities has_tools = TOOL_CALLING_CAPABILITY in capabilities - # Check if it's an embedding model - try: - await test_embedding("ollama", endpoint=endpoint, embedding_model=model_name) - is_embedding = True - except Exception as e: - logger.warning(f"Failed to test embedding for model {model_name}: {str(e)}") - is_embedding = False - - if is_embedding: - # Embedding models only need completion capability + if has_embedding: + # Embedding models have embedding capability embedding_models.append( { "value": model_name, @@ -250,7 +243,7 @@ class ModelsService: "default": "nomic-embed-text" in model_name.lower(), } ) - elif not is_embedding and has_completion and has_tools: + if has_completion and has_tools: # Language models need both completion and tool calling language_models.append( { @@ -333,34 +326,6 @@ class ModelsService: if project_id: headers["Project-ID"] = project_id - # Validate credentials with a minimal completion request - async with httpx.AsyncClient() as client: - validation_url = f"{watson_endpoint}/ml/v1/text/generation" - validation_params = {"version": "2024-09-16"} - validation_payload = { - "input": "test", - "model_id": "ibm/granite-3-2b-instruct", - "project_id": project_id, - "parameters": { - "max_new_tokens": 1, - }, - } - - validation_response = await client.post( - validation_url, - headers=headers, - params=validation_params, - json=validation_payload, - timeout=10.0, - ) - - if validation_response.status_code != 200: - raise Exception( - f"Invalid credentials or endpoint: {validation_response.status_code} - {validation_response.text}" - ) - - logger.info("IBM Watson credentials validated successfully") - # Fetch foundation models using the correct endpoint models_url = f"{watson_endpoint}/ml/v1/foundation_model_specs" @@ -424,6 +389,39 @@ class ModelsService: } ) + # Validate credentials with the first available LLM model + if language_models: + first_llm_model = language_models[0]["value"] + + async with httpx.AsyncClient() as client: + validation_url = f"{watson_endpoint}/ml/v1/text/generation" + validation_params = {"version": "2024-09-16"} + validation_payload = { + "input": "test", + "model_id": first_llm_model, + "project_id": project_id, + "parameters": { + "max_new_tokens": 1, + }, + } + + validation_response = await client.post( + validation_url, + headers=headers, + params=validation_params, + json=validation_payload, + timeout=10.0, + ) + + if validation_response.status_code != 200: + raise Exception( + f"Invalid credentials or endpoint: {validation_response.status_code} - {validation_response.text}" + ) + + logger.info(f"IBM Watson credentials validated successfully using model: {first_llm_model}") + else: + logger.warning("No language models available to validate credentials") + if not language_models and not embedding_models: raise Exception("No IBM models retrieved from API") diff --git a/src/tui/managers/docling_manager.py b/src/tui/managers/docling_manager.py index e58a5b1e..109cb7c1 100644 --- a/src/tui/managers/docling_manager.py +++ b/src/tui/managers/docling_manager.py @@ -34,6 +34,7 @@ class DoclingManager: # Bind to all interfaces by default (can be overridden with DOCLING_BIND_HOST env var) self._host = os.getenv('DOCLING_BIND_HOST', '0.0.0.0') self._running = False + self._starting = False self._external_process = False # PID file to track docling-serve across sessions (in current working directory) @@ -126,6 +127,7 @@ class DoclingManager: if self._process is not None and self._process.poll() is None: self._running = True self._external_process = False + self._starting = False # Clear starting flag if service is running return True # Check if we have a PID from file @@ -133,6 +135,7 @@ class DoclingManager: if pid is not None and self._is_process_running(pid): self._running = True self._external_process = True + self._starting = False # Clear starting flag if service is running return True # No running process found @@ -142,6 +145,19 @@ class DoclingManager: def get_status(self) -> Dict[str, Any]: """Get current status of docling serve.""" + # Check for starting state first + if self._starting: + display_host = "localhost" if self._host == "0.0.0.0" else self._host + return { + "status": "starting", + "port": self._port, + "host": self._host, + "endpoint": None, + "docs_url": None, + "ui_url": None, + "pid": None + } + if self.is_running(): # Try to get PID from process handle first, then from PID file pid = None @@ -196,6 +212,9 @@ class DoclingManager: except Exception as e: self._add_log_entry(f"Error checking port availability: {e}") + # Set starting flag to show "Starting" status in UI + self._starting = True + # Clear log buffer when starting self._log_buffer = [] self._add_log_entry("Starting docling serve as external process...") @@ -261,6 +280,8 @@ class DoclingManager: if result == 0: self._add_log_entry(f"Docling-serve is now listening on {self._host}:{self._port}") + # Service is now running, clear starting flag + self._starting = False break except: pass @@ -294,16 +315,24 @@ class DoclingManager: self._add_log_entry(f"Error reading final output: {e}") self._running = False + self._starting = False return False, f"Docling serve process exited immediately (code: {return_code})" + # If we get here and the process is still running but not listening yet, + # clear the starting flag anyway (it's running, just not ready) + if self._process.poll() is None: + self._starting = False + display_host = "localhost" if self._host == "0.0.0.0" else self._host return True, f"Docling serve starting on http://{display_host}:{port}" except FileNotFoundError: + self._starting = False return False, "docling-serve not available. Please install: uv add docling-serve" except Exception as e: self._running = False self._process = None + self._starting = False return False, f"Error starting docling serve: {str(e)}" def _start_output_capture(self): diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index 91df51f6..01c243c6 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -206,10 +206,21 @@ class MonitorScreen(Screen): # Add docling serve to its own table docling_status = self.docling_manager.get_status() - docling_running = docling_status["status"] == "running" - docling_status_text = "running" if docling_running else "stopped" - docling_style = "bold green" if docling_running else "bold red" - docling_port = f"{docling_status['host']}:{docling_status['port']}" if docling_running else "N/A" + docling_status_value = docling_status["status"] + docling_running = docling_status_value == "running" + docling_starting = docling_status_value == "starting" + + if docling_running: + docling_status_text = "running" + docling_style = "bold green" + elif docling_starting: + docling_status_text = "starting" + docling_style = "bold yellow" + else: + docling_status_text = "stopped" + docling_style = "bold red" + + docling_port = f"{docling_status['host']}:{docling_status['port']}" if (docling_running or docling_starting) else "N/A" docling_pid = str(docling_status.get("pid")) if docling_status.get("pid") else "N/A" if self.docling_table: @@ -375,15 +386,25 @@ class MonitorScreen(Screen): """Start docling serve.""" self.operation_in_progress = True try: - success, message = await self.docling_manager.start() + # Start the service (this sets _starting = True internally at the start) + # Create task and let it begin executing (which sets the flag) + start_task = asyncio.create_task(self.docling_manager.start()) + # Give it a tiny moment to set the _starting flag + await asyncio.sleep(0.1) + # Refresh immediately to show "Starting" status + await self._refresh_services() + # Now wait for start to complete + success, message = await start_task if success: self.notify(message, severity="information") else: self.notify(f"Failed to start docling serve: {message}", severity="error") - # Refresh the services table to show updated status + # Refresh again to show final status (running or stopped) await self._refresh_services() except Exception as e: self.notify(f"Error starting docling serve: {str(e)}", severity="error") + # Refresh on error to clear starting status + await self._refresh_services() finally: self.operation_in_progress = False @@ -646,7 +667,11 @@ class MonitorScreen(Screen): suffix = f"-{random.randint(10000, 99999)}" # Add docling serve controls - docling_running = self.docling_manager.is_running() + docling_status = self.docling_manager.get_status() + docling_status_value = docling_status["status"] + docling_running = docling_status_value == "running" + docling_starting = docling_status_value == "starting" + if docling_running: docling_controls.mount( Button("Stop", variant="error", id=f"docling-stop-btn{suffix}") @@ -654,6 +679,11 @@ class MonitorScreen(Screen): docling_controls.mount( Button("Restart", variant="primary", id=f"docling-restart-btn{suffix}") ) + elif docling_starting: + # Show disabled button or no button when starting + start_btn = Button("Starting...", variant="warning", id=f"docling-start-btn{suffix}") + start_btn.disabled = True + docling_controls.mount(start_btn) else: docling_controls.mount( Button("Start", variant="success", id=f"docling-start-btn{suffix}")