diff --git a/frontend/components/docling-health-banner.tsx b/frontend/components/docling-health-banner.tsx index 940e24cb..86a798b1 100644 --- a/frontend/components/docling-health-banner.tsx +++ b/frontend/components/docling-health-banner.tsx @@ -3,150 +3,153 @@ import { AlertTriangle, Copy, ExternalLink } from "lucide-react"; import { useState } from "react"; import { - Banner, - BannerAction, - BannerIcon, - BannerTitle, + Banner, + BannerAction, + BannerIcon, + BannerTitle, } from "@/components/ui/banner"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; -import { HEADER_HEIGHT } from "@/lib/constants"; import { cn } from "@/lib/utils"; import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery"; interface DoclingHealthBannerProps { - className?: string; + className?: string; } // DoclingSetupDialog component interface DoclingSetupDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - className?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + className?: string; } function DoclingSetupDialog({ - open, - onOpenChange, - className, + open, + onOpenChange, + className, }: DoclingSetupDialogProps) { - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState(false); - const handleCopy = async () => { - await navigator.clipboard.writeText("uv run openrag"); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; + const handleCopy = async () => { + await navigator.clipboard.writeText("uv run openrag"); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; - return ( - - - - - - docling-serve is stopped. Knowledge ingest is unavailable. - - Start docling-serve by running: - + return ( + + + + + + docling-serve is stopped. Knowledge ingest is unavailable. + + Start docling-serve by running: + -
-
- - uv run openrag - - -
+
+
+ + uv run openrag + + +
- - Then, select{" "} - - Start All Services - {" "} - in the TUI. Once docling-serve is running, refresh OpenRAG. - -
+ + Then, select{" "} + + Start All Services + {" "} + in the TUI. Once docling-serve is running, refresh OpenRAG. + +
- - - -
-
- ); + + + +
+
+ ); } // Custom hook to check docling health status export function useDoclingHealth() { - const { data: health, isLoading, isError } = useDoclingHealthQuery(); + const { data: health, isLoading, isError } = useDoclingHealthQuery(); - const isHealthy = health?.status === "healthy" && !isError; - // Only consider unhealthy if backend is up but docling is down - // Don't show banner if backend is unavailable - const isUnhealthy = health?.status === "unhealthy"; - const isBackendUnavailable = health?.status === "backend-unavailable" || isError; + const isHealthy = health?.status === "healthy" && !isError; + // Only consider unhealthy if backend is up but docling is down + // Don't show banner if backend is unavailable + const isUnhealthy = health?.status === "unhealthy"; + const isBackendUnavailable = + health?.status === "backend-unavailable" || isError; - return { - health, - isLoading, - isError, - isHealthy, - isUnhealthy, - isBackendUnavailable, - }; + return { + health, + isLoading, + isError, + isHealthy, + isUnhealthy, + isBackendUnavailable, + }; } export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) { - const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth(); - const [showDialog, setShowDialog] = useState(false); + const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth(); + const [showDialog, setShowDialog] = useState(false); - // Only show banner when service is unhealthy - if (isLoading || isHealthy) { - return null; - } + // Only show banner when service is unhealthy + if (isLoading || isHealthy) { + return null; + } - if (isUnhealthy) { - return ( - <> - - - - docling-serve native service is stopped. Knowledge ingest is - unavailable. - - setShowDialog(true)} - className="bg-foreground text-background hover:bg-primary/90" - > - Setup Docling Serve - - - + if (isUnhealthy) { + return ( + <> + + + + docling-serve native service is stopped. Knowledge ingest is + unavailable. + + setShowDialog(true)} + className="bg-foreground text-background hover:bg-primary/90" + > + Setup Docling Serve + + + - - - ); - } + + + ); + } - return null; + return null; } diff --git a/frontend/components/provider-health-banner.tsx b/frontend/components/provider-health-banner.tsx new file mode 100644 index 00000000..d2755051 --- /dev/null +++ b/frontend/components/provider-health-banner.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { AlertTriangle } from "lucide-react"; +import { Banner, BannerIcon, BannerTitle } from "@/components/ui/banner"; +import { cn } from "@/lib/utils"; +import { useProviderHealthQuery } from "@/src/app/api/queries/useProviderHealthQuery"; +import { Button } from "./ui/button"; +import { useRouter } from "next/navigation"; +import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; + +interface ProviderHealthBannerProps { + className?: string; +} + +// Custom hook to check provider health status +export function useProviderHealth() { + const { + data: health, + isLoading, + isFetching, + error, + isError, + } = useProviderHealthQuery(); + + const isHealthy = health?.status === "healthy" && !isError; + const isUnhealthy = + health?.status === "unhealthy" || health?.status === "error" || isError; + + return { + health, + isLoading, + isFetching, + error, + isError, + isHealthy, + isUnhealthy, + }; +} + +export function ProviderHealthBanner({ className }: ProviderHealthBannerProps) { + const { isLoading, isHealthy, error } = useProviderHealth(); + const router = useRouter(); + + const { data: settings = {} } = useGetSettingsQuery(); + + if (!isHealthy && !isLoading) { + const errorMessage = error?.message || "Provider validation failed"; + const settingsUrl = settings.provider?.model_provider + ? `/settings?setup=${settings.provider?.model_provider}` + : "/settings"; + + return ( + + + + {errorMessage} + + + + ); + } + + return null; +} diff --git a/frontend/src/app/api/mutations/useUpdateSettingsMutation.ts b/frontend/src/app/api/mutations/useUpdateSettingsMutation.ts index 6f75f4cb..5626822a 100644 --- a/frontend/src/app/api/mutations/useUpdateSettingsMutation.ts +++ b/frontend/src/app/api/mutations/useUpdateSettingsMutation.ts @@ -62,7 +62,6 @@ export const useUpdateSettingsMutation = ( onSuccess: (...args) => { queryClient.invalidateQueries({ queryKey: ["settings"], - refetchType: "all" }); options?.onSuccess?.(...args); }, diff --git a/frontend/src/app/api/queries/useGetModelsQuery.ts b/frontend/src/app/api/queries/useGetModelsQuery.ts index bd175c86..7fefa29d 100644 --- a/frontend/src/app/api/queries/useGetModelsQuery.ts +++ b/frontend/src/app/api/queries/useGetModelsQuery.ts @@ -3,6 +3,7 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; +import { useGetSettingsQuery } from "./useGetSettingsQuery"; export interface ModelOption { value: string; @@ -55,6 +56,7 @@ export const useGetOpenAIModelsQuery = ( queryFn: getOpenAIModels, staleTime: 0, // Always fetch fresh data gcTime: 0, // Don't cache results + retry: false, ...options, }, queryClient, @@ -89,6 +91,7 @@ export const useGetOllamaModelsQuery = ( queryFn: getOllamaModels, staleTime: 0, // Always fetch fresh data gcTime: 0, // Don't cache results + retry: false, ...options, }, queryClient, @@ -129,6 +132,7 @@ export const useGetIBMModelsQuery = ( queryFn: getIBMModels, staleTime: 0, // Always fetch fresh data gcTime: 0, // Don't cache results + retry: false, ...options, }, queryClient, @@ -136,3 +140,65 @@ export const useGetIBMModelsQuery = ( return queryResult; }; + +/** + * Hook that automatically fetches models for the current provider + * based on the settings configuration + */ +export const useGetCurrentProviderModelsQuery = ( + options?: Omit, "queryKey" | "queryFn">, +) => { + const { data: settings } = useGetSettingsQuery(); + const currentProvider = settings?.provider?.model_provider; + + // Determine which hook to use based on current provider + const openaiModels = useGetOpenAIModelsQuery( + { apiKey: "" }, + { + enabled: currentProvider === "openai" && options?.enabled !== false, + ...options, + } + ); + + const ollamaModels = useGetOllamaModelsQuery( + { endpoint: settings?.provider?.endpoint }, + { + enabled: currentProvider === "ollama" && !!settings?.provider?.endpoint && options?.enabled !== false, + ...options, + } + ); + + const ibmModels = useGetIBMModelsQuery( + { + endpoint: settings?.provider?.endpoint, + apiKey: "", + projectId: settings?.provider?.project_id, + }, + { + enabled: + currentProvider === "watsonx" && + !!settings?.provider?.endpoint && + !!settings?.provider?.project_id && + options?.enabled !== false, + ...options, + } + ); + + // Return the appropriate query result based on current provider + switch (currentProvider) { + case "openai": + return openaiModels; + case "ollama": + return ollamaModels; + case "watsonx": + return ibmModels; + default: + // Return a default/disabled query if no provider is set + return { + data: undefined, + isLoading: false, + error: null, + refetch: async () => ({ data: undefined }), + } as ReturnType; + } +}; diff --git a/frontend/src/app/api/queries/useProviderHealthQuery.ts b/frontend/src/app/api/queries/useProviderHealthQuery.ts new file mode 100644 index 00000000..980ec5f9 --- /dev/null +++ b/frontend/src/app/api/queries/useProviderHealthQuery.ts @@ -0,0 +1,71 @@ +import { ModelProvider } from "@/app/settings/helpers/model-helpers"; +import { + type UseQueryOptions, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +export interface ProviderHealthDetails { + llm_model: string; + embedding_model: string; + endpoint?: string | null; +} + +export interface ProviderHealthResponse { + status: "healthy" | "unhealthy" | "error"; + message: string; + provider: string; + details?: ProviderHealthDetails; +} + +export interface ProviderHealthParams { + provider?: "openai" | "ollama" | "watsonx"; +} + +const providerTitleMap: Record = { + openai: "OpenAI", + ollama: "Ollama", + watsonx: "IBM watsonx.ai", +}; + +export const useProviderHealthQuery = ( + params?: ProviderHealthParams, + options?: Omit< + UseQueryOptions, + "queryKey" | "queryFn" + > +) => { + const queryClient = useQueryClient(); + + async function checkProviderHealth(): Promise { + const url = new URL("/api/provider/health", window.location.origin); + + // Add provider query param if specified + if (params?.provider) { + url.searchParams.set("provider", params.provider); + } + + const response = await fetch(url.toString()); + + if (response.ok) { + return await response.json(); + } else { + // For 400 and 503 errors, still parse JSON for error details + const errorData = await response.json().catch(() => ({})); + throw new Error(`${providerTitleMap[errorData.provider as ModelProvider] || "Provider"} error: ${errorData.message || "Failed to check provider health"}`); + } + } + + const queryResult = useQuery( + { + queryKey: ["provider", "health"], + queryFn: checkProviderHealth, + retry: false, // Don't retry health checks automatically + ...options, + }, + queryClient + ); + + return queryResult; +}; + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7f07f074..326224ab 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -111,7 +111,6 @@ .app-grid-arrangement { --notifications-width: 0px; --filters-width: 0px; - --top-banner-height: 0px; --header-height: 54px; --sidebar-width: 280px; @@ -121,14 +120,11 @@ &.filters-open { --filters-width: 320px; } - &.banner-visible { - --top-banner-height: 52px; - } display: grid; height: 100%; width: 100%; grid-template-rows: - var(--top-banner-height) + auto var(--header-height) 1fr; grid-template-columns: @@ -345,12 +341,12 @@ @apply text-xs opacity-70; } - .prose :where(strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) { + .prose + :where(strong):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @apply text-current; } - .prose :where(a):not(:where([class~="not-prose"],[class~="not-prose"] *)) - { + .prose :where(a):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @apply text-current; } diff --git a/frontend/src/app/onboarding/components/model-selector.tsx b/frontend/src/app/onboarding/components/model-selector.tsx index e6f1c072..72b3d8c1 100644 --- a/frontend/src/app/onboarding/components/model-selector.tsx +++ b/frontend/src/app/onboarding/components/model-selector.tsx @@ -3,158 +3,170 @@ import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, } from "@/components/ui/command"; import { - Popover, - PopoverContent, - PopoverTrigger, + Popover, + PopoverContent, + PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; export function ModelSelector({ - options, - value, - onValueChange, - icon, - placeholder = "Select model...", - searchPlaceholder = "Search model...", - noOptionsPlaceholder = "No models available", - custom = false, - hasError = false, + options, + value = "", + onValueChange, + icon, + placeholder = "Select model...", + searchPlaceholder = "Search model...", + noOptionsPlaceholder = "No models available", + custom = false, + hasError = false, }: { - options: { - value: string; - label: string; - default?: boolean; - }[]; - value: string; - icon?: React.ReactNode; - placeholder?: string; - searchPlaceholder?: string; - noOptionsPlaceholder?: string; - custom?: boolean; - onValueChange: (value: string) => void; - hasError?: boolean; + options: { + value: string; + label: string; + default?: boolean; + }[]; + value: string; + icon?: React.ReactNode; + placeholder?: string; + searchPlaceholder?: string; + noOptionsPlaceholder?: string; + custom?: boolean; + onValueChange: (value: string) => void; + hasError?: boolean; }) { - const [open, setOpen] = useState(false); - const [searchValue, setSearchValue] = useState(""); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); - useEffect(() => { - if (value && value !== "" && (!options.find((option) => option.value === value) && !custom)) { - onValueChange(""); - } - }, [options, value, custom, onValueChange]); - return ( - - - {/** biome-ignore lint/a11y/useSemanticElements: has to be a Button */} - - - - - - - {noOptionsPlaceholder} - - {options.map((option) => ( - { - if (currentValue !== value) { - onValueChange(currentValue); - } - setOpen(false); - }} - > - -
- {option.label} - {/* {option.default && ( + {custom && + value && + !options.find((framework) => framework.value === value) && ( + + CUSTOM + + )} +
+ ) : options.length === 0 ? ( + noOptionsPlaceholder + ) : ( + placeholder + )} + + + + + + + + {noOptionsPlaceholder} + + {options.map((option) => ( + { + if (currentValue !== value) { + onValueChange(currentValue); + } + setOpen(false); + }} + > + +
+ {option.label} + {/* {option.default && ( // DISABLING DEFAULT TAG FOR NOW Default )} */} -
-
- ))} - {custom && - searchValue && - !options.find((option) => option.value === searchValue) && ( - { - if (currentValue !== value) { - onValueChange(currentValue); - } - setOpen(false); - }} - > - -
- {searchValue} - - Custom - -
-
- )} -
-
-
-
-
- ); + + + ))} + {custom && + searchValue && + !options.find((option) => option.value === searchValue) && ( + { + if (currentValue !== value) { + onValueChange(currentValue); + } + setOpen(false); + }} + > + +
+ {searchValue} + + Custom + +
+
+ )} + + + + + + ); } diff --git a/frontend/src/app/onboarding/components/ollama-onboarding.tsx b/frontend/src/app/onboarding/components/ollama-onboarding.tsx index b085fa95..9ac368c6 100644 --- a/frontend/src/app/onboarding/components/ollama-onboarding.tsx +++ b/frontend/src/app/onboarding/components/ollama-onboarding.tsx @@ -30,7 +30,7 @@ export function OllamaOnboarding({ isLoading: isLoadingModels, error: modelsError, } = useGetOllamaModelsQuery( - debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined, + debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined ); // Use custom hook for model selection logic diff --git a/frontend/src/app/settings/components/model-providers.tsx b/frontend/src/app/settings/components/model-providers.tsx index c6231d8d..fe0153fb 100644 --- a/frontend/src/app/settings/components/model-providers.tsx +++ b/frontend/src/app/settings/components/model-providers.tsx @@ -6,23 +6,51 @@ import OpenAILogo from "@/components/logo/openai-logo"; import IBMLogo from "@/components/logo/ibm-logo"; import OllamaLogo from "@/components/logo/ollama-logo"; import { useAuth } from "@/contexts/auth-context"; -import { ReactNode, useState } from "react"; +import { ReactNode, useState, useEffect } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; import OpenAISettingsDialog from "./openai-settings-dialog"; import OllamaSettingsDialog from "./ollama-settings-dialog"; import WatsonxSettingsDialog from "./watsonx-settings-dialog"; import { cn } from "@/lib/utils"; import Link from "next/link"; +import { useProviderHealth } from "@/components/provider-health-banner"; export const ModelProviders = () => { const { isAuthenticated, isNoAuthMode } = useAuth(); + const searchParams = useSearchParams(); + const router = useRouter(); const { data: settings = {} } = useGetSettingsQuery({ enabled: isAuthenticated || isNoAuthMode, }); + const { isUnhealthy } = useProviderHealth(); + const [dialogOpen, setDialogOpen] = useState(); + const allProviderKeys: ModelProvider[] = ["openai", "ollama", "watsonx"]; + + // Handle URL search param to open dialogs + useEffect(() => { + const searchParam = searchParams.get("setup"); + if (searchParam && allProviderKeys.includes(searchParam as ModelProvider)) { + setDialogOpen(searchParam as ModelProvider); + } + }, [searchParams]); + + // Function to close dialog and remove search param + const handleCloseDialog = () => { + setDialogOpen(undefined); + // Remove search param from URL + const params = new URLSearchParams(searchParams.toString()); + params.delete("setup"); + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + router.replace(newUrl); + }; + const modelProvidersMap: Record< ModelProvider, { @@ -56,7 +84,6 @@ export const ModelProviders = () => { (settings.provider?.model_provider as ModelProvider) || "openai"; // Get all provider keys with active provider first - const allProviderKeys: ModelProvider[] = ["openai", "ollama", "watsonx"]; const sortedProviderKeys = [ currentProviderKey, ...allProviderKeys.filter((key) => key !== currentProviderKey), @@ -72,14 +99,15 @@ export const ModelProviders = () => { logoColor, logoBgColor, } = modelProvidersMap[providerKey]; - const isActive = providerKey === currentProviderKey; + const isCurrentProvider = providerKey === currentProviderKey; return ( @@ -89,13 +117,15 @@ export const ModelProviders = () => {
{ } @@ -103,20 +133,27 @@ export const ModelProviders = () => {
{name} - {isActive && ( -
+ {isCurrentProvider && ( +
)}
- {isActive ? ( + {isCurrentProvider ? ( ) : (

@@ -139,15 +176,15 @@ export const ModelProviders = () => { setDialogOpen(undefined)} + setOpen={handleCloseDialog} /> setDialogOpen(undefined)} + setOpen={handleCloseDialog} /> setDialogOpen(undefined)} + setOpen={handleCloseDialog} /> ); diff --git a/frontend/src/app/settings/components/model-selectors.tsx b/frontend/src/app/settings/components/model-selectors.tsx index 5c3304a9..d6d421d2 100644 --- a/frontend/src/app/settings/components/model-selectors.tsx +++ b/frontend/src/app/settings/components/model-selectors.tsx @@ -47,7 +47,15 @@ export function ModelSelectors({ shouldValidate: true, }); } - }, [defaultLlmModel, defaultEmbeddingModel, setValue]); + }, [ + defaultLlmModel, + defaultEmbeddingModel, + llmModel, + embeddingModel, + setValue, + languageModelName, + embeddingModelName, + ]); return ( <> diff --git a/frontend/src/app/settings/components/ollama-settings-dialog.tsx b/frontend/src/app/settings/components/ollama-settings-dialog.tsx index daf77f34..48762ff3 100644 --- a/frontend/src/app/settings/components/ollama-settings-dialog.tsx +++ b/frontend/src/app/settings/components/ollama-settings-dialog.tsx @@ -16,6 +16,9 @@ import { import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useAuth } from "@/contexts/auth-context"; import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation"; +import { useQueryClient } from "@tanstack/react-query"; +import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery"; +import { AnimatePresence, motion } from "motion/react"; const OllamaSettingsDialog = ({ open, @@ -25,6 +28,7 @@ const OllamaSettingsDialog = ({ setOpen: (open: boolean) => void; }) => { const { isAuthenticated, isNoAuthMode } = useAuth(); + const queryClient = useQueryClient(); const { data: settings = {} } = useGetSettingsQuery({ enabled: isAuthenticated || isNoAuthMode, @@ -49,14 +53,17 @@ const OllamaSettingsDialog = ({ const settingsMutation = useUpdateSettingsMutation({ onSuccess: () => { + // Update provider health cache to healthy since backend validated the setup + const healthData: ProviderHealthResponse = { + status: "healthy", + message: "Provider is configured and working correctly", + provider: "ollama", + }; + queryClient.setQueryData(["provider", "health"], healthData); + toast.success("Ollama settings updated successfully"); setOpen(false); }, - onError: (error) => { - toast.error("Failed to update Ollama settings", { - description: error.message, - }); - }, }); const onSubmit = (data: OllamaSettingsFormData) => { @@ -83,6 +90,21 @@ const OllamaSettingsDialog = ({ + + + {settingsMutation.isError && ( + +

+ {settingsMutation.error?.message} +

+ + )} +