Merge branch 'main' into feat/refactor-folder-structure

This commit is contained in:
Cole Goldsmith 2025-11-14 15:33:50 -06:00
commit 16d49c5f2d
11 changed files with 800 additions and 52 deletions

View file

@ -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,

View file

@ -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,
},

View file

@ -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: "...";
}
}
}

View file

@ -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<string>;
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 (
<motion.div
initial={animate ? { opacity: 0, y: -20 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={
animate
? { duration: 0.4, delay: delay, ease: "easeOut" }
: { duration: 0 }
}
className={isCompleted ? "opacity-50" : ""}
>
<Message
icon={
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon
className="h-6 w-6 transition-colors duration-300"
disabled={isCompleted || isInactive}
/>
</div>
}
actions={
showForkButton && onFork ? (
<button
type="button"
onClick={onFork}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
title="Fork conversation from here"
>
<GitBranch className="h-3 w-3" />
</button>
) : undefined
}
>
<FunctionCalls
functionCalls={functionCalls}
messageIndex={messageIndex}
expandedFunctionCalls={expandedFunctionCalls}
onToggle={onToggle}
/>
<div className="relative">
<MarkdownRenderer
className={cn(
"text-sm py-1.5 transition-colors duration-300",
isCompleted ? "text-placeholder-foreground" : "text-foreground",
)}
chatMessage={
isStreaming
? (content.trim()
? content +
' <span class="inline-block w-1 h-4 bg-primary ml-1 animate-pulse"></span>'
: '<span class="text-muted-foreground italic">Thinking<span class="thinking-dots"></span></span>')
: content
}
/>
</div>
</Message>
</motion.div>
);
}

View file

@ -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<SetStateAction<OnboardingVariables>>;
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 (
<>
<div className="space-y-4">
<LabelWrapper
label="watsonx.ai API Endpoint"
helperText="Base URL of the API"
id="api-endpoint"
required
>
<div className="space-y-1">
<ModelSelector
options={alreadyConfigured ? [] : options}
value={endpoint}
custom
onValueChange={alreadyConfigured ? () => {} : setEndpoint}
searchPlaceholder="Search endpoint..."
noOptionsPlaceholder={
alreadyConfigured
? "https://•••••••••••••••••••••••••••••••••••••••••"
: "No endpoints available"
}
placeholder="Select endpoint..."
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing endpoint from model provider selection.
</p>
)}
</div>
</LabelWrapper>
<div className="space-y-1">
<LabelInput
label="watsonx Project ID"
helperText="Project ID for the model"
id="project-id"
required
placeholder={
alreadyConfigured ? "••••••••••••••••••••••••" : "your-project-id"
}
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
disabled={alreadyConfigured}
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing project ID from model provider selection.
</p>
)}
</div>
<div className="space-y-1">
<LabelInput
label="watsonx API key"
helperText="API key to access watsonx.ai"
id="api-key"
type="password"
required
placeholder={
alreadyConfigured
? "•••••••••••••••••••••••••••••••••••••••••"
: "your-api-key"
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={alreadyConfigured}
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing API key from model provider selection.
</p>
)}
</div>
{isLoadingModels && (
<p className="text-mmd text-muted-foreground">
Validating configuration...
</p>
)}
{modelsError && (
<p className="text-mmd text-accent-amber-foreground">
Connection failed. Check your configuration.
</p>
)}
</div>
<AdvancedOnboarding
icon={<IBMLogo className="w-4 h-4" />}
languageModels={languageModels}
embeddingModels={embeddingModels}
languageModel={languageModel}
embeddingModel={embeddingModel}
sampleDataset={sampleDataset}
setLanguageModel={setLanguageModel}
setEmbeddingModel={setEmbeddingModel}
setSampleDataset={handleSampleDatasetChange}
/>
</>
);
}

View file

@ -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<SetStateAction<OnboardingVariables>>;
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 (
<div className="space-y-4">
<div className="space-y-1">
<LabelInput
label="Ollama Base URL"
helperText="Base URL of your Ollama server"
id="api-endpoint"
required
placeholder={
alreadyConfigured
? "http://••••••••••••••••••••"
: "http://localhost:11434"
}
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
disabled={alreadyConfigured}
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing endpoint from model provider selection.
</p>
)}
{showConnecting && (
<p className="text-mmd text-muted-foreground">
Connecting to Ollama server...
</p>
)}
{hasConnectionError && (
<p className="text-mmd text-accent-amber-foreground">
Can't reach Ollama at {debouncedEndpoint}. Update the base URL or
start the server.
</p>
)}
{hasNoModels && (
<p className="text-mmd text-accent-amber-foreground">
No models found. Install embedding and agent models on your Ollama
server.
</p>
)}
</div>
{isEmbedding && setEmbeddingModel && (
<LabelWrapper
label="Embedding model"
helperText="Model used for knowledge ingest and retrieval"
id="embedding-model"
required={true}
>
<ModelSelector
options={embeddingModels}
icon={<OllamaLogo className="w-4 h-4" />}
noOptionsPlaceholder={
isLoadingModels
? "Loading models..."
: "No embedding models detected. Install an embedding model to continue."
}
value={embeddingModel}
onValueChange={setEmbeddingModel}
/>
</LabelWrapper>
)}
{!isEmbedding && setLanguageModel && (
<LabelWrapper
label="Language model"
helperText="Model used for chat"
id="embedding-model"
required={true}
>
<ModelSelector
options={languageModels}
icon={<OllamaLogo className="w-4 h-4" />}
noOptionsPlaceholder={
isLoadingModels
? "Loading models..."
: "No language models detected. Install a language model to continue."
}
value={languageModel}
onValueChange={setLanguageModel}
/>
</LabelWrapper>
)}
</div>
);
}

View file

@ -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<SetStateAction<OnboardingVariables>>;
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 (
<>
<div className="space-y-5">
{!alreadyConfigured && (
<LabelWrapper
label="Use environment OpenAI API key"
id="get-api-key"
description="Reuse the key from your environment config. Turn off to enter a different key."
flex
>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={getFromEnv}
onCheckedChange={handleGetFromEnvChange}
disabled={!hasEnvApiKey}
/>
</div>
</TooltipTrigger>
{!hasEnvApiKey && (
<TooltipContent>
OpenAI API key not detected in the environment.
</TooltipContent>
)}
</Tooltip>
</LabelWrapper>
)}
{(!getFromEnv || alreadyConfigured) && (
<div className="space-y-1">
<LabelInput
label="OpenAI API key"
helperText="The API key for your OpenAI account."
className={modelsError ? "!border-destructive" : ""}
id="api-key"
type="password"
required
placeholder={
alreadyConfigured
? "sk-•••••••••••••••••••••••••••••••••••••••••"
: "sk-..."
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={alreadyConfigured}
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing key from model provider selection.
</p>
)}
{isLoadingModels && (
<p className="text-mmd text-muted-foreground">
Validating API key...
</p>
)}
{modelsError && (
<p className="text-mmd text-destructive">
Invalid OpenAI API key. Verify or replace the key.
</p>
)}
</div>
)}
</div>
<AdvancedOnboarding
icon={<OpenAILogo className="w-4 h-4" />}
languageModels={languageModels}
embeddingModels={embeddingModels}
languageModel={languageModel}
embeddingModel={embeddingModel}
sampleDataset={sampleDataset}
setLanguageModel={setLanguageModel}
setSampleDataset={handleSampleDatasetChange}
setEmbeddingModel={setEmbeddingModel}
/>
</>
);
}

View file

@ -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}")

View file

@ -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")

View file

@ -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):

View file

@ -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}")