Merge branch 'main' into docs-install-options

This commit is contained in:
Mendon Kissling 2025-11-06 14:40:08 -05:00
commit e6343ed078
22 changed files with 1275 additions and 471 deletions

View file

@ -3,150 +3,153 @@
import { AlertTriangle, Copy, ExternalLink } from "lucide-react"; import { AlertTriangle, Copy, ExternalLink } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import {
Banner, Banner,
BannerAction, BannerAction,
BannerIcon, BannerIcon,
BannerTitle, BannerTitle,
} from "@/components/ui/banner"; } from "@/components/ui/banner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { HEADER_HEIGHT } from "@/lib/constants";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery"; import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
interface DoclingHealthBannerProps { interface DoclingHealthBannerProps {
className?: string; className?: string;
} }
// DoclingSetupDialog component // DoclingSetupDialog component
interface DoclingSetupDialogProps { interface DoclingSetupDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
className?: string; className?: string;
} }
function DoclingSetupDialog({ function DoclingSetupDialog({
open, open,
onOpenChange, onOpenChange,
className, className,
}: DoclingSetupDialogProps) { }: DoclingSetupDialogProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = async () => { const handleCopy = async () => {
await navigator.clipboard.writeText("uv run openrag"); await navigator.clipboard.writeText("uv run openrag");
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn("max-w-lg", className)}> <DialogContent className={cn("max-w-lg", className)}>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" /> <AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
docling-serve is stopped. Knowledge ingest is unavailable. docling-serve is stopped. Knowledge ingest is unavailable.
</DialogTitle> </DialogTitle>
<DialogDescription>Start docling-serve by running:</DialogDescription> <DialogDescription>Start docling-serve by running:</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono"> <code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
uv run openrag uv run openrag
</code> </code>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handleCopy} onClick={handleCopy}
className="shrink-0" className="shrink-0"
title={copied ? "Copied!" : "Copy to clipboard"} title={copied ? "Copied!" : "Copy to clipboard"}
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
<DialogDescription> <DialogDescription>
Then, select{" "} Then, select{" "}
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
Start All Services Start All Services
</span>{" "} </span>{" "}
in the TUI. Once docling-serve is running, refresh OpenRAG. in the TUI. Once docling-serve is running, refresh OpenRAG.
</DialogDescription> </DialogDescription>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="default" onClick={() => onOpenChange(false)}> <Button variant="default" onClick={() => onOpenChange(false)}>
Close Close
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
// Custom hook to check docling health status // Custom hook to check docling health status
export function useDoclingHealth() { export function useDoclingHealth() {
const { data: health, isLoading, isError } = useDoclingHealthQuery(); const { data: health, isLoading, isError } = useDoclingHealthQuery();
const isHealthy = health?.status === "healthy" && !isError; const isHealthy = health?.status === "healthy" && !isError;
// Only consider unhealthy if backend is up but docling is down // Only consider unhealthy if backend is up but docling is down
// Don't show banner if backend is unavailable // Don't show banner if backend is unavailable
const isUnhealthy = health?.status === "unhealthy"; const isUnhealthy = health?.status === "unhealthy";
const isBackendUnavailable = health?.status === "backend-unavailable" || isError; const isBackendUnavailable =
health?.status === "backend-unavailable" || isError;
return { return {
health, health,
isLoading, isLoading,
isError, isError,
isHealthy, isHealthy,
isUnhealthy, isUnhealthy,
isBackendUnavailable, isBackendUnavailable,
}; };
} }
export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) { export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) {
const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth(); const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth();
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
// Only show banner when service is unhealthy // Only show banner when service is unhealthy
if (isLoading || isHealthy) { if (isLoading || isHealthy) {
return null; return null;
} }
if (isUnhealthy) { if (isUnhealthy) {
return ( return (
<> <>
<Banner <Banner
className={cn( className={cn(
`bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-200 border-amber-200 dark:border-amber-800`, "bg-amber-50 dark:bg-amber-950 text-foreground border-accent-amber border-b w-full",
className, className
)} )}
> >
<BannerIcon icon={AlertTriangle} /> <BannerIcon
<BannerTitle className="font-medium"> icon={AlertTriangle}
docling-serve native service is stopped. Knowledge ingest is className="text-accent-amber-foreground"
unavailable. />
</BannerTitle> <BannerTitle className="font-medium">
<BannerAction docling-serve native service is stopped. Knowledge ingest is
onClick={() => setShowDialog(true)} unavailable.
className="bg-foreground text-background hover:bg-primary/90" </BannerTitle>
> <BannerAction
Setup Docling Serve onClick={() => setShowDialog(true)}
<ExternalLink className="h-3 w-3 ml-1" /> className="bg-foreground text-background hover:bg-primary/90"
</BannerAction> >
</Banner> Setup Docling Serve
<ExternalLink className="h-3 w-3 ml-1" />
</BannerAction>
</Banner>
<DoclingSetupDialog open={showDialog} onOpenChange={setShowDialog} /> <DoclingSetupDialog open={showDialog} onOpenChange={setShowDialog} />
</> </>
); );
} }
return null; return null;
} }

View file

@ -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 (
<Banner
className={cn(
"bg-red-50 dark:bg-red-950 text-foreground border-accent-red border-b w-full",
className
)}
>
<BannerIcon
className="text-accent-red-foreground"
icon={AlertTriangle}
/>
<BannerTitle className="font-medium flex items-center gap-2">
{errorMessage}
</BannerTitle>
<Button size="sm" onClick={() => router.push(settingsUrl)}>
Fix Setup
</Button>
</Banner>
);
}
return null;
}

View file

@ -62,7 +62,6 @@ export const useUpdateSettingsMutation = (
onSuccess: (...args) => { onSuccess: (...args) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["settings"], queryKey: ["settings"],
refetchType: "all"
}); });
options?.onSuccess?.(...args); options?.onSuccess?.(...args);
}, },

View file

@ -3,6 +3,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useGetSettingsQuery } from "./useGetSettingsQuery";
export interface ModelOption { export interface ModelOption {
value: string; value: string;
@ -55,6 +56,7 @@ export const useGetOpenAIModelsQuery = (
queryFn: getOpenAIModels, queryFn: getOpenAIModels,
staleTime: 0, // Always fetch fresh data staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results gcTime: 0, // Don't cache results
retry: false,
...options, ...options,
}, },
queryClient, queryClient,
@ -89,6 +91,7 @@ export const useGetOllamaModelsQuery = (
queryFn: getOllamaModels, queryFn: getOllamaModels,
staleTime: 0, // Always fetch fresh data staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results gcTime: 0, // Don't cache results
retry: false,
...options, ...options,
}, },
queryClient, queryClient,
@ -129,6 +132,7 @@ export const useGetIBMModelsQuery = (
queryFn: getIBMModels, queryFn: getIBMModels,
staleTime: 0, // Always fetch fresh data staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results gcTime: 0, // Don't cache results
retry: false,
...options, ...options,
}, },
queryClient, queryClient,
@ -136,3 +140,65 @@ export const useGetIBMModelsQuery = (
return queryResult; return queryResult;
}; };
/**
* Hook that automatically fetches models for the current provider
* based on the settings configuration
*/
export const useGetCurrentProviderModelsQuery = (
options?: Omit<UseQueryOptions<ModelsResponse>, "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<typeof useGetOpenAIModelsQuery>;
}
};

View file

@ -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<ModelProvider, string> = {
openai: "OpenAI",
ollama: "Ollama",
watsonx: "IBM watsonx.ai",
};
export const useProviderHealthQuery = (
params?: ProviderHealthParams,
options?: Omit<
UseQueryOptions<ProviderHealthResponse, Error>,
"queryKey" | "queryFn"
>
) => {
const queryClient = useQueryClient();
async function checkProviderHealth(): Promise<ProviderHealthResponse> {
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;
};

View file

@ -111,7 +111,6 @@
.app-grid-arrangement { .app-grid-arrangement {
--notifications-width: 0px; --notifications-width: 0px;
--filters-width: 0px; --filters-width: 0px;
--top-banner-height: 0px;
--header-height: 54px; --header-height: 54px;
--sidebar-width: 280px; --sidebar-width: 280px;
@ -121,14 +120,11 @@
&.filters-open { &.filters-open {
--filters-width: 320px; --filters-width: 320px;
} }
&.banner-visible {
--top-banner-height: 52px;
}
display: grid; display: grid;
height: 100%; height: 100%;
width: 100%; width: 100%;
grid-template-rows: grid-template-rows:
var(--top-banner-height) auto
var(--header-height) var(--header-height)
1fr; 1fr;
grid-template-columns: grid-template-columns:
@ -345,12 +341,12 @@
@apply text-xs opacity-70; @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; @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; @apply text-current;
} }

View file

@ -3,158 +3,170 @@ import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function ModelSelector({ export function ModelSelector({
options, options,
value, value = "",
onValueChange, onValueChange,
icon, icon,
placeholder = "Select model...", placeholder = "Select model...",
searchPlaceholder = "Search model...", searchPlaceholder = "Search model...",
noOptionsPlaceholder = "No models available", noOptionsPlaceholder = "No models available",
custom = false, custom = false,
hasError = false, hasError = false,
}: { }: {
options: { options: {
value: string; value: string;
label: string; label: string;
default?: boolean; default?: boolean;
}[]; }[];
value: string; value: string;
icon?: React.ReactNode; icon?: React.ReactNode;
placeholder?: string; placeholder?: string;
searchPlaceholder?: string; searchPlaceholder?: string;
noOptionsPlaceholder?: string; noOptionsPlaceholder?: string;
custom?: boolean; custom?: boolean;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
hasError?: boolean; hasError?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
useEffect(() => { useEffect(() => {
if (value && value !== "" && (!options.find((option) => option.value === value) && !custom)) { if (
onValueChange(""); value &&
} value !== "" &&
}, [options, value, custom, onValueChange]); !options.find((option) => option.value === value) &&
return ( !custom
<Popover open={open} onOpenChange={setOpen}> ) {
<PopoverTrigger asChild> onValueChange("");
{/** biome-ignore lint/a11y/useSemanticElements: has to be a Button */} }
<Button }, [options, value, custom, onValueChange]);
variant="outline"
role="combobox" return (
disabled={options.length === 0} <Popover open={open} onOpenChange={setOpen}>
aria-expanded={open} <PopoverTrigger asChild>
className={cn("w-full gap-2 justify-between font-normal text-sm", hasError && "!border-destructive")} {/** biome-ignore lint/a11y/useSemanticElements: has to be a Button */}
> <Button
{value ? ( variant="outline"
<div className="flex items-center gap-2"> role="combobox"
{icon && <div className="w-4 h-4">{icon}</div>} disabled={options.length === 0}
{options.find((framework) => framework.value === value)?.label || aria-expanded={open}
value} className={cn(
{/* {options.find((framework) => framework.value === value) "w-full gap-2 justify-between font-normal text-sm",
hasError && "!border-destructive"
)}
>
{value ? (
<div className="flex items-center gap-2">
{icon && <div className="w-4 h-4">{icon}</div>}
{options.find((framework) => framework.value === value)?.label ||
value}
{/* {options.find((framework) => framework.value === value)
?.default && ( ?.default && (
<span className="text-xs text-foreground p-1 rounded-md bg-muted"> <span className="text-xs text-foreground p-1 rounded-md bg-muted">
Default Default
</span> </span>
)} */} )} */}
{custom && {custom &&
value && value &&
!options.find((framework) => framework.value === value) && ( !options.find((framework) => framework.value === value) && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
CUSTOM CUSTOM
</Badge> </Badge>
)} )}
</div> </div>
) : options.length === 0 ? ( ) : options.length === 0 ? (
noOptionsPlaceholder noOptionsPlaceholder
) : ( ) : (
placeholder placeholder
)} )}
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className=" p-0 w-[var(--radix-popover-trigger-width)]"> <PopoverContent
<Command> align="start"
<CommandInput className=" p-0 w-[var(--radix-popover-trigger-width)]"
placeholder={searchPlaceholder} >
value={searchValue} <Command>
onValueChange={setSearchValue} <CommandInput
/> placeholder={searchPlaceholder}
<CommandList> value={searchValue}
<CommandEmpty>{noOptionsPlaceholder}</CommandEmpty> onValueChange={setSearchValue}
<CommandGroup> />
{options.map((option) => ( <CommandList>
<CommandItem <CommandEmpty>{noOptionsPlaceholder}</CommandEmpty>
key={option.value} <CommandGroup>
value={option.value} {options.map((option) => (
onSelect={(currentValue) => { <CommandItem
if (currentValue !== value) { key={option.value}
onValueChange(currentValue); value={option.value}
} onSelect={(currentValue) => {
setOpen(false); if (currentValue !== value) {
}} onValueChange(currentValue);
> }
<CheckIcon setOpen(false);
className={cn( }}
"mr-2 h-4 w-4", >
value === option.value ? "opacity-100" : "opacity-0", <CheckIcon
)} className={cn(
/> "mr-2 h-4 w-4",
<div className="flex items-center gap-2"> value === option.value ? "opacity-100" : "opacity-0"
{option.label} )}
{/* {option.default && ( />
<div className="flex items-center gap-2">
{option.label}
{/* {option.default && (
<span className="text-xs text-foreground p-1 rounded-md bg-muted"> // DISABLING DEFAULT TAG FOR NOW <span className="text-xs text-foreground p-1 rounded-md bg-muted"> // DISABLING DEFAULT TAG FOR NOW
Default Default
</span> </span>
)} */} )} */}
</div> </div>
</CommandItem> </CommandItem>
))} ))}
{custom && {custom &&
searchValue && searchValue &&
!options.find((option) => option.value === searchValue) && ( !options.find((option) => option.value === searchValue) && (
<CommandItem <CommandItem
value={searchValue} value={searchValue}
onSelect={(currentValue) => { onSelect={(currentValue) => {
if (currentValue !== value) { if (currentValue !== value) {
onValueChange(currentValue); onValueChange(currentValue);
} }
setOpen(false); setOpen(false);
}} }}
> >
<CheckIcon <CheckIcon
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
value === searchValue ? "opacity-100" : "opacity-0", value === searchValue ? "opacity-100" : "opacity-0"
)} )}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{searchValue} {searchValue}
<span className="text-xs text-foreground p-1 rounded-md bg-muted"> <span className="text-xs text-foreground p-1 rounded-md bg-muted">
Custom Custom
</span> </span>
</div> </div>
</CommandItem> </CommandItem>
)} )}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
} }

View file

@ -30,7 +30,7 @@ export function OllamaOnboarding({
isLoading: isLoadingModels, isLoading: isLoadingModels,
error: modelsError, error: modelsError,
} = useGetOllamaModelsQuery( } = useGetOllamaModelsQuery(
debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined, debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined
); );
// Use custom hook for model selection logic // Use custom hook for model selection logic

View file

@ -6,23 +6,51 @@ import OpenAILogo from "@/components/logo/openai-logo";
import IBMLogo from "@/components/logo/ibm-logo"; import IBMLogo from "@/components/logo/ibm-logo";
import OllamaLogo from "@/components/logo/ollama-logo"; import OllamaLogo from "@/components/logo/ollama-logo";
import { useAuth } from "@/contexts/auth-context"; 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 OpenAISettingsDialog from "./openai-settings-dialog";
import OllamaSettingsDialog from "./ollama-settings-dialog"; import OllamaSettingsDialog from "./ollama-settings-dialog";
import WatsonxSettingsDialog from "./watsonx-settings-dialog"; import WatsonxSettingsDialog from "./watsonx-settings-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
import { useProviderHealth } from "@/components/provider-health-banner";
export const ModelProviders = () => { export const ModelProviders = () => {
const { isAuthenticated, isNoAuthMode } = useAuth(); const { isAuthenticated, isNoAuthMode } = useAuth();
const searchParams = useSearchParams();
const router = useRouter();
const { data: settings = {} } = useGetSettingsQuery({ const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode, enabled: isAuthenticated || isNoAuthMode,
}); });
const { isUnhealthy } = useProviderHealth();
const [dialogOpen, setDialogOpen] = useState<ModelProvider | undefined>(); const [dialogOpen, setDialogOpen] = useState<ModelProvider | undefined>();
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< const modelProvidersMap: Record<
ModelProvider, ModelProvider,
{ {
@ -56,7 +84,6 @@ export const ModelProviders = () => {
(settings.provider?.model_provider as ModelProvider) || "openai"; (settings.provider?.model_provider as ModelProvider) || "openai";
// Get all provider keys with active provider first // Get all provider keys with active provider first
const allProviderKeys: ModelProvider[] = ["openai", "ollama", "watsonx"];
const sortedProviderKeys = [ const sortedProviderKeys = [
currentProviderKey, currentProviderKey,
...allProviderKeys.filter((key) => key !== currentProviderKey), ...allProviderKeys.filter((key) => key !== currentProviderKey),
@ -72,14 +99,15 @@ export const ModelProviders = () => {
logoColor, logoColor,
logoBgColor, logoBgColor,
} = modelProvidersMap[providerKey]; } = modelProvidersMap[providerKey];
const isActive = providerKey === currentProviderKey; const isCurrentProvider = providerKey === currentProviderKey;
return ( return (
<Card <Card
key={providerKey} key={providerKey}
className={cn( className={cn(
"relative flex flex-col", "relative flex flex-col",
!isActive && "text-muted-foreground" !isCurrentProvider && "text-muted-foreground",
isCurrentProvider && isUnhealthy && "border-destructive"
)} )}
> >
<CardHeader> <CardHeader>
@ -89,13 +117,15 @@ export const ModelProviders = () => {
<div <div
className={cn( className={cn(
"w-8 h-8 rounded flex items-center justify-center border", "w-8 h-8 rounded flex items-center justify-center border",
isActive ? logoBgColor : "bg-muted" isCurrentProvider ? logoBgColor : "bg-muted"
)} )}
> >
{ {
<Logo <Logo
className={ className={
isActive ? logoColor : "text-muted-foreground" isCurrentProvider
? logoColor
: "text-muted-foreground"
} }
/> />
} }
@ -103,20 +133,27 @@ export const ModelProviders = () => {
</div> </div>
<CardTitle className="flex flex-row items-center gap-2"> <CardTitle className="flex flex-row items-center gap-2">
{name} {name}
{isActive && ( {isCurrentProvider && (
<div className="h-2 w-2 bg-accent-emerald-foreground rounded-full" /> <div
className={cn(
"h-2 w-2 rounded-full",
isUnhealthy
? "bg-destructive"
: "bg-accent-emerald-foreground"
)}
/>
)} )}
</CardTitle> </CardTitle>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col justify-end space-y-4"> <CardContent className="flex-1 flex flex-col justify-end space-y-4">
{isActive ? ( {isCurrentProvider ? (
<Button <Button
variant="outline" variant={isUnhealthy ? "default" : "outline"}
onClick={() => setDialogOpen(providerKey)} onClick={() => setDialogOpen(providerKey)}
> >
Edit Setup {isUnhealthy ? "Fix Setup" : "Edit Setup"}
</Button> </Button>
) : ( ) : (
<p> <p>
@ -139,15 +176,15 @@ export const ModelProviders = () => {
</div> </div>
<OpenAISettingsDialog <OpenAISettingsDialog
open={dialogOpen === "openai"} open={dialogOpen === "openai"}
setOpen={() => setDialogOpen(undefined)} setOpen={handleCloseDialog}
/> />
<OllamaSettingsDialog <OllamaSettingsDialog
open={dialogOpen === "ollama"} open={dialogOpen === "ollama"}
setOpen={() => setDialogOpen(undefined)} setOpen={handleCloseDialog}
/> />
<WatsonxSettingsDialog <WatsonxSettingsDialog
open={dialogOpen === "watsonx"} open={dialogOpen === "watsonx"}
setOpen={() => setDialogOpen(undefined)} setOpen={handleCloseDialog}
/> />
</> </>
); );

View file

@ -47,7 +47,15 @@ export function ModelSelectors({
shouldValidate: true, shouldValidate: true,
}); });
} }
}, [defaultLlmModel, defaultEmbeddingModel, setValue]); }, [
defaultLlmModel,
defaultEmbeddingModel,
llmModel,
embeddingModel,
setValue,
languageModelName,
embeddingModelName,
]);
return ( return (
<> <>

View file

@ -16,6 +16,9 @@ import {
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation"; 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 = ({ const OllamaSettingsDialog = ({
open, open,
@ -25,6 +28,7 @@ const OllamaSettingsDialog = ({
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
}) => { }) => {
const { isAuthenticated, isNoAuthMode } = useAuth(); const { isAuthenticated, isNoAuthMode } = useAuth();
const queryClient = useQueryClient();
const { data: settings = {} } = useGetSettingsQuery({ const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode, enabled: isAuthenticated || isNoAuthMode,
@ -49,14 +53,17 @@ const OllamaSettingsDialog = ({
const settingsMutation = useUpdateSettingsMutation({ const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => { 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"); toast.success("Ollama settings updated successfully");
setOpen(false); setOpen(false);
}, },
onError: (error) => {
toast.error("Failed to update Ollama settings", {
description: error.message,
});
},
}); });
const onSubmit = (data: OllamaSettingsFormData) => { const onSubmit = (data: OllamaSettingsFormData) => {
@ -83,6 +90,21 @@ const OllamaSettingsDialog = ({
</DialogHeader> </DialogHeader>
<OllamaSettingsForm /> <OllamaSettingsForm />
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4"> <DialogFooter className="mt-4">
<Button <Button
variant="outline" variant="outline"

View file

@ -16,6 +16,9 @@ import {
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation"; import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
import { AnimatePresence, motion } from "motion/react";
import { useQueryClient } from "@tanstack/react-query";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
const OpenAISettingsDialog = ({ const OpenAISettingsDialog = ({
open, open,
@ -25,6 +28,7 @@ const OpenAISettingsDialog = ({
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
}) => { }) => {
const { isAuthenticated, isNoAuthMode } = useAuth(); const { isAuthenticated, isNoAuthMode } = useAuth();
const queryClient = useQueryClient();
const { data: settings = {} } = useGetSettingsQuery({ const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode, enabled: isAuthenticated || isNoAuthMode,
@ -47,14 +51,17 @@ const OpenAISettingsDialog = ({
const settingsMutation = useUpdateSettingsMutation({ const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => { 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: "openai",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success("OpenAI settings updated successfully"); toast.success("OpenAI settings updated successfully");
setOpen(false); setOpen(false);
}, },
onError: (error) => {
toast.error("Failed to update OpenAI settings", {
description: error.message,
});
},
}); });
const onSubmit = (data: OpenAISettingsFormData) => { const onSubmit = (data: OpenAISettingsFormData) => {
@ -94,7 +101,21 @@ const OpenAISettingsDialog = ({
<OpenAISettingsForm isCurrentProvider={isOpenAIConfigured} /> <OpenAISettingsForm isCurrentProvider={isOpenAIConfigured} />
<DialogFooter> <AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button <Button
variant="outline" variant="outline"
type="button" type="button"

View file

@ -16,6 +16,9 @@ import {
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation"; 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 WatsonxSettingsDialog = ({ const WatsonxSettingsDialog = ({
open, open,
@ -25,6 +28,7 @@ const WatsonxSettingsDialog = ({
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
}) => { }) => {
const { isAuthenticated, isNoAuthMode } = useAuth(); const { isAuthenticated, isNoAuthMode } = useAuth();
const queryClient = useQueryClient();
const { data: settings = {} } = useGetSettingsQuery({ const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode, enabled: isAuthenticated || isNoAuthMode,
@ -51,14 +55,16 @@ const WatsonxSettingsDialog = ({
const settingsMutation = useUpdateSettingsMutation({ const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => { 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: "watsonx",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success("watsonx settings updated successfully"); toast.success("watsonx settings updated successfully");
setOpen(false); setOpen(false);
}, },
onError: (error) => {
toast.error("Failed to update watsonx settings", {
description: error.message,
});
},
}); });
const onSubmit = (data: WatsonxSettingsFormData) => { const onSubmit = (data: WatsonxSettingsFormData) => {
@ -98,10 +104,24 @@ const WatsonxSettingsDialog = ({
</div> </div>
IBM watsonx.ai Setup IBM watsonx.ai Setup
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<WatsonxSettingsForm isCurrentProvider={isWatsonxConfigured} /> <WatsonxSettingsForm isCurrentProvider={isWatsonxConfigured} />
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"

View file

@ -4,15 +4,10 @@ import { ArrowUpRight, Loader2, Minus, PlugZap, Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useState } from "react"; import { Suspense, useCallback, useEffect, useState } from "react";
import { import { useGetCurrentProviderModelsQuery } from "@/app/api/queries/useGetModelsQuery";
useGetIBMModelsQuery,
useGetOllamaModelsQuery,
useGetOpenAIModelsQuery,
} from "@/app/api/queries/useGetModelsQuery";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { ConfirmationDialog } from "@/components/confirmation-dialog"; import { ConfirmationDialog } from "@/components/confirmation-dialog";
import { LabelWrapper } from "@/components/label-wrapper"; import { LabelWrapper } from "@/components/label-wrapper";
import OpenAILogo from "@/components/logo/openai-logo";
import { ProtectedRoute } from "@/components/protected-route"; import { ProtectedRoute } from "@/components/protected-route";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -24,17 +19,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
@ -46,14 +30,13 @@ import {
} from "@/lib/constants"; } from "@/lib/constants";
import { useDebounce } from "@/lib/debounce"; import { useDebounce } from "@/lib/debounce";
import { ModelSelector } from "../onboarding/components/model-selector"; import { ModelSelector } from "../onboarding/components/model-selector";
import { getFallbackModels, type ModelProvider } from "./helpers/model-helpers"; import { getModelLogo, type ModelProvider } from "./helpers/model-helpers";
import { ModelSelectItems } from "./helpers/model-select-item";
import GoogleDriveIcon from "./icons/google-drive-icon"; import GoogleDriveIcon from "./icons/google-drive-icon";
import OneDriveIcon from "./icons/one-drive-icon"; import OneDriveIcon from "./icons/one-drive-icon";
import SharePointIcon from "./icons/share-point-icon"; import SharePointIcon from "./icons/share-point-icon";
import ModelProviders from "./components/model-providers"; import ModelProviders from "./components/model-providers";
import { useUpdateSettingsMutation } from "../api/mutations/useUpdateSettingsMutation"; import { useUpdateSettingsMutation } from "../api/mutations/useUpdateSettingsMutation";
import { toast } from "sonner";
const { MAX_SYSTEM_PROMPT_CHARS } = UI_CONSTANTS; const { MAX_SYSTEM_PROMPT_CHARS } = UI_CONSTANTS;
@ -137,56 +120,18 @@ function KnowledgeSourcesPage() {
const currentProvider = (settings.provider?.model_provider || const currentProvider = (settings.provider?.model_provider ||
"openai") as ModelProvider; "openai") as ModelProvider;
// Fetch available models based on provider const { data: modelsData, isLoading: modelsLoading } =
const { data: openaiModelsData } = useGetOpenAIModelsQuery( useGetCurrentProviderModelsQuery();
{
apiKey: ""
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "openai",
}
);
const { data: ollamaModelsData } = useGetOllamaModelsQuery(
{
endpoint: settings.provider?.endpoint,
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "ollama",
}
);
const { data: ibmModelsData } = useGetIBMModelsQuery(
{
endpoint: settings.provider?.endpoint,
apiKey: "",
projectId: settings.provider?.project_id,
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "watsonx",
}
);
// Select the appropriate models data based on provider
const modelsData =
currentProvider === "openai"
? openaiModelsData
: currentProvider === "ollama"
? ollamaModelsData
: currentProvider === "watsonx"
? ibmModelsData
: openaiModelsData; // fallback to openai
// Mutations // Mutations
const updateSettingsMutation = useUpdateSettingsMutation({ const updateSettingsMutation = useUpdateSettingsMutation({
onSuccess: () => { onSuccess: () => {
console.log("Setting updated successfully"); toast.success("Settings updated successfully");
}, },
onError: (error) => { onError: (error) => {
console.error("Failed to update setting:", error.message); toast.error("Failed to update settings", {
description: error.message,
});
}, },
}); });
@ -239,7 +184,7 @@ function KnowledgeSourcesPage() {
// Update model selection immediately // Update model selection immediately
const handleModelChange = (newModel: string) => { const handleModelChange = (newModel: string) => {
updateSettingsMutation.mutate({ llm_model: newModel }); if (newModel) updateSettingsMutation.mutate({ llm_model: newModel });
}; };
// Update system prompt with save button // Update system prompt with save button
@ -249,7 +194,7 @@ function KnowledgeSourcesPage() {
// Update embedding model selection immediately // Update embedding model selection immediately
const handleEmbeddingModelChange = (newModel: string) => { const handleEmbeddingModelChange = (newModel: string) => {
updateSettingsMutation.mutate({ embedding_model: newModel }); if (newModel) updateSettingsMutation.mutate({ embedding_model: newModel });
}; };
const isEmbeddingModelSelectDisabled = updateSettingsMutation.isPending; const isEmbeddingModelSelectDisabled = updateSettingsMutation.isPending;
@ -465,11 +410,17 @@ function KnowledgeSourcesPage() {
const getStatusBadge = (status: Connector["status"]) => { const getStatusBadge = (status: Connector["status"]) => {
switch (status) { switch (status) {
case "connected": case "connected":
return <div className="h-2 w-2 bg-green-500 rounded-full" />; return (
<div className="h-2 w-2 bg-accent-emerald-foreground rounded-full" />
);
case "connecting": case "connecting":
return <div className="h-2 w-2 bg-yellow-500 rounded-full" />; return (
<div className="h-2 w-2 bg-accent-amber-foreground rounded-full" />
);
case "error": case "error":
return <div className="h-2 w-2 bg-red-500 rounded-full" />; return (
<div className="h-2 w-2 bg-accent-red-foreground rounded-full" />
);
default: default:
return <div className="h-2 w-2 bg-muted rounded-full" />; return <div className="h-2 w-2 bg-muted rounded-full" />;
} }
@ -909,12 +860,12 @@ function KnowledgeSourcesPage() {
<ModelSelector <ModelSelector
options={modelsData?.language_models || []} options={modelsData?.language_models || []}
noOptionsPlaceholder={ noOptionsPlaceholder={
modelsData modelsLoading
? "No language models detected." ? "Loading models..."
: "Loading models..." : "No language models detected."
} }
icon={<OpenAILogo className="w-4 h-4" />} icon={getModelLogo("", currentProvider)}
value={modelsData ? settings.agent?.llm_model || "" : ""} value={settings.agent?.llm_model || ""}
onValueChange={handleModelChange} onValueChange={handleModelChange}
/> />
</LabelWrapper> </LabelWrapper>
@ -1051,41 +1002,17 @@ function KnowledgeSourcesPage() {
id="embedding-model-select" id="embedding-model-select"
label="Embedding model" label="Embedding model"
> >
<Select <ModelSelector
disabled={isEmbeddingModelSelectDisabled} options={modelsData?.embedding_models || []}
value={ noOptionsPlaceholder={
settings.knowledge?.embedding_model || modelsLoading
modelsData?.embedding_models?.find((m) => m.default) ? "Loading models..."
?.value || : "No embedding models detected."
"text-embedding-ada-002"
} }
icon={getModelLogo("", currentProvider)}
value={settings.knowledge?.embedding_model || ""}
onValueChange={handleEmbeddingModelChange} onValueChange={handleEmbeddingModelChange}
> />
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
disabled={isEmbeddingModelSelectDisabled}
id="embedding-model-select"
>
<SelectValue placeholder="Select an embedding model" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
{isEmbeddingModelSelectDisabled
? "Please wait while we update your settings"
: "Choose the embedding model used for new ingests"}
</TooltipContent>
</Tooltip>
<SelectContent>
<ModelSelectItems
models={modelsData?.embedding_models}
fallbackModels={
getFallbackModels(currentProvider).embedding
}
provider={currentProvider}
/>
</SelectContent>
</Select>
</LabelWrapper> </LabelWrapper>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">

View file

@ -155,7 +155,7 @@ export function ChatRenderer({
ease: "easeOut", ease: "easeOut",
}} }}
className={cn( className={cn(
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden", "flex h-full w-full max-w-full max-h-full items-center justify-center overflow-y-auto",
!showLayout && "absolute max-h-[calc(100vh-190px)]", !showLayout && "absolute max-h-[calc(100vh-190px)]",
showLayout && !isOnChatPage && "bg-background", showLayout && !isOnChatPage && "bg-background",
)} )}
@ -163,10 +163,10 @@ export function ChatRenderer({
<div <div
className={cn( className={cn(
"h-full bg-background w-full", "h-full bg-background w-full",
showLayout && !isOnChatPage && "p-6 container overflow-y-auto", showLayout && !isOnChatPage && "p-6 container",
showLayout && isSmallWidthPath && "max-w-[850px] ml-0", showLayout && isSmallWidthPath && "max-w-[850px] ml-0",
!showLayout && !showLayout &&
"w-full bg-card rounded-lg shadow-2xl p-0 py-2 overflow-y-auto", "w-full bg-card rounded-lg shadow-2xl p-0 py-2",
)} )}
> >
<motion.div <motion.div

View file

@ -1,18 +1,24 @@
"use client"; "use client";
import { Loader2 } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { DoclingHealthBanner } from "@/components/docling-health-banner"; import {
DoclingHealthBanner,
useDoclingHealth,
} from "@/components/docling-health-banner";
import {
ProviderHealthBanner,
useProviderHealth,
} from "@/components/provider-health-banner";
import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel"; import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel";
import { TaskNotificationMenu } from "@/components/task-notification-menu"; import { TaskNotificationMenu } from "@/components/task-notification-menu";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { useTask } from "@/contexts/task-context"; import { useTask } from "@/contexts/task-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
import { ChatRenderer } from "./chat-renderer"; import { ChatRenderer } from "./chat-renderer";
import AnimatedProcessingIcon from "./ui/animated-processing-icon"; import AnimatedProcessingIcon from "./ui/animated-processing-icon";
import { AnimatedConditional } from "./animated-conditional";
export function LayoutWrapper({ children }: { children: React.ReactNode }) { export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
@ -29,13 +35,9 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({ const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({
enabled: !isAuthPage && (isAuthenticated || isNoAuthMode), enabled: !isAuthPage && (isAuthenticated || isNoAuthMode),
}); });
const {
data: health, const { isUnhealthy: isDoclingUnhealthy } = useDoclingHealth();
isLoading: isHealthLoading, const { isUnhealthy: isProviderUnhealthy } = useProviderHealth();
isError,
} = useDoclingHealthQuery({
enabled: !isAuthPage,
});
// For auth pages, render immediately without navigation // For auth pages, render immediately without navigation
// This prevents the main layout from flashing // This prevents the main layout from flashing
@ -45,12 +47,13 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const isOnKnowledgePage = pathname.startsWith("/knowledge"); const isOnKnowledgePage = pathname.startsWith("/knowledge");
const isUnhealthy = health?.status === "unhealthy" || isError;
const isBannerVisible = !isHealthLoading && isUnhealthy;
const isSettingsLoadingOrError = isSettingsLoading || !settings; const isSettingsLoadingOrError = isSettingsLoading || !settings;
// Show loading state when backend isn't ready // Show loading state when backend isn't ready
if (isLoading || (isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated))) { if (
isLoading ||
(isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated))
) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
@ -63,17 +66,31 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
// For all other pages, render with Langflow-styled navigation and task menu // For all other pages, render with Langflow-styled navigation and task menu
return ( return (
<div className=" h-screen w-screen flex items-center justify-center"> <div className="h-screen w-screen flex items-center justify-center">
<div <div
className={cn( className={cn(
"app-grid-arrangement bg-black relative", "app-grid-arrangement bg-black relative",
isBannerVisible && "banner-visible",
isPanelOpen && isOnKnowledgePage && !isMenuOpen && "filters-open", isPanelOpen && isOnKnowledgePage && !isMenuOpen && "filters-open",
isMenuOpen && "notifications-open", isMenuOpen && "notifications-open"
)} )}
> >
<div className={`w-full z-10 bg-background [grid-area:banner]`}> <div className="w-full z-10 bg-background [grid-area:banner]">
<DoclingHealthBanner className="w-full" /> <AnimatedConditional
vertical
isOpen={isDoclingUnhealthy}
className="w-full"
>
<DoclingHealthBanner />
</AnimatedConditional>
{settings?.edited && (
<AnimatedConditional
vertical
isOpen={isProviderUnhealthy}
className="w-full"
>
<ProviderHealthBanner />
</AnimatedConditional>
)}
</div> </div>
<ChatRenderer settings={settings}>{children}</ChatRenderer> <ChatRenderer settings={settings}>{children}</ChatRenderer>

View file

@ -33,6 +33,7 @@ dependencies = [
"textual-fspicker>=0.6.0", "textual-fspicker>=0.6.0",
"structlog>=25.4.0", "structlog>=25.4.0",
"docling-serve==1.5.0", "docling-serve==1.5.0",
"docling-core==2.48.1",
"easyocr>=1.7.1" "easyocr>=1.7.1"
] ]

View file

@ -0,0 +1,345 @@
#!/usr/bin/env bash
set -euo pipefail
say() { printf "%s\n" "$*" >&2; }
hr() { say "----------------------------------------"; }
ask_yes_no() {
local prompt="${1:-Continue?} [Y/n] "
read -r -p "$prompt" ans || true
case "${ans:-Y}" in [Yy]|[Yy][Ee][Ss]|"") return 0 ;; *) return 1 ;; esac
}
# --- Platform detection ------------------------------------------------------
uname_s="$(uname -s 2>/dev/null || echo unknown)"
is_wsl=false
if [ -f /proc/version ]; then grep -qiE 'microsoft|wsl' /proc/version && is_wsl=true || true; fi
case "$uname_s" in
Darwin) PLATFORM="macOS" ;;
Linux) PLATFORM="$($is_wsl && echo WSL || echo Linux)" ;;
CYGWIN*|MINGW*|MSYS*) PLATFORM="Windows" ;;
*) PLATFORM="Unknown" ;;
esac
if [ "$PLATFORM" = "Windows" ]; then
say ">>> Native Windows shell detected. Please run this inside WSL (Ubuntu, etc.)."
exit 1
fi
# --- Minimal sudo (used only when necessary) --------------------------------
SUDO="sudo"; $SUDO -n true >/dev/null 2>&1 || SUDO="sudo" # may prompt later only if needed
# --- PATH probe for common bins (no sudo) -----------------------------------
ensure_path_has_common_bins() {
local add=()
[ -d /opt/homebrew/bin ] && add+=("/opt/homebrew/bin")
[ -d /usr/local/bin ] && add+=("/usr/local/bin")
[ -d "/Applications/Docker.app/Contents/Resources/bin" ] && add+=("/Applications/Docker.app/Contents/Resources/bin")
[ -d "$HOME/.docker/cli-plugins" ] && add+=("$HOME/.docker/cli-plugins")
for p in "${add[@]}"; do case ":$PATH:" in *":$p:"*) ;; *) PATH="$p:$PATH" ;; esac; done
export PATH
}
ensure_path_has_common_bins
# --- Helpers ----------------------------------------------------------------
has_cmd() { command -v "$1" >/dev/null 2>&1; }
docker_cli_path() { command -v docker 2>/dev/null || true; }
podman_cli_path() { command -v podman 2>/dev/null || true; }
docker_daemon_ready() { docker info >/dev/null 2>&1; } # no sudo; fails if socket perms/daemon issue
compose_v2_ready() { docker compose version >/dev/null 2>&1; }
compose_v1_ready() { command -v docker-compose >/dev/null 2>&1; }
podman_ready() { podman info >/dev/null 2>&1; } # macOS may need podman machine
docker_is_podman() {
# True if `docker` is Podman (podman-docker shim or alias)
if ! has_cmd docker; then return 1; fi
# 1) Text outputs
local out=""
out+="$(docker --version 2>&1 || true)\n"
out+="$(docker -v 2>&1 || true)\n"
out+="$(docker help 2>&1 | head -n 2 || true)\n"
if printf "%b" "$out" | grep -qiE '\bpodman\b'; then
return 0
fi
# 2) Symlink target / alternatives
local p t
p="$(command -v docker)"
if has_cmd readlink; then
t="$(readlink -f "$p" 2>/dev/null || readlink "$p" 2>/dev/null || echo "$p")"
printf "%s" "$t" | grep -qi 'podman' && return 0
fi
if [ -L /etc/alternatives/docker ]; then
t="$(readlink -f /etc/alternatives/docker 2>/dev/null || true)"
printf "%s" "$t" | grep -qi 'podman' && return 0
fi
# 3) Fallback: package id (rpm/dpkg), best effort (ignore errors)
if has_cmd rpm; then
rpm -qf "$p" 2>/dev/null | grep -qi 'podman' && return 0
fi
if has_cmd dpkg-query; then
dpkg-query -S "$p" 2>/dev/null | grep -qi 'podman' && return 0
fi
return 1
}
# --- uv install (optional) --------------------------------------------------
install_uv() {
if has_cmd uv; then
say ">>> uv present: $(uv --version 2>/dev/null || echo ok)"
return
fi
if ! ask_yes_no "uv not found. Install uv now?"; then return; fi
if ! has_cmd curl; then say ">>> curl is required to install uv. Please install curl and re-run."; exit 1; fi
curl -LsSf https://astral.sh/uv/install.sh | sh
}
# --- Docker: install if missing (never reinstall) ---------------------------
install_docker_if_missing() {
if has_cmd docker; then
say ">>> Docker CLI detected at: $(docker_cli_path)"
say ">>> Version: $(docker --version 2>/dev/null || echo 'unknown')"
return
fi
say ">>> Docker CLI not found."
if ! ask_yes_no "Install Docker now?"; then return; fi
case "$PLATFORM" in
macOS)
if has_cmd brew; then
brew install --cask docker
say ">>> Starting Docker Desktop..."
open -gj -a Docker || true
else
say ">>> Homebrew not found. Install from https://brew.sh then: brew install --cask docker"
exit 1
fi
;;
Linux|WSL)
if ! has_cmd curl; then say ">>> Need curl to install Docker. Install curl and re-run."; exit 1; fi
curl -fsSL https://get.docker.com | $SUDO sh
# Do NOT assume docker group exists everywhere; creation is distro-dependent
if getent group docker >/dev/null 2>&1; then
$SUDO usermod -aG docker "$USER" || true
fi
;;
*)
say ">>> Unsupported platform for automated Docker install."
;;
esac
}
# --- Docker daemon start/wait (sudo only if starting service) ---------------
start_docker_daemon_if_needed() {
if docker_daemon_ready; then
say ">>> Docker daemon is ready."
return 0
fi
say ">>> Docker CLI found but daemon not reachable."
case "$PLATFORM" in
macOS)
say ">>> Attempting to start Docker Desktop..."
open -gj -a Docker || true
;;
Linux|WSL)
say ">>> Attempting to start docker service (may prompt for sudo)..."
$SUDO systemctl start docker >/dev/null 2>&1 || $SUDO service docker start >/dev/null 2>&1 || true
;;
esac
for i in {1..60}; do
docker_daemon_ready && { say ">>> Docker daemon is ready."; return 0; }
sleep 2
done
say ">>> Still not reachable. If Linux: check 'systemctl status docker' and group membership."
say ">>> If macOS: open Docker.app and wait for 'Docker Desktop is running'."
return 1
}
# --- Docker group activation (safe: only if group exists) -------------------
activate_docker_group_now() {
[ "$PLATFORM" = "Linux" ] || [ "$PLATFORM" = "WSL" ] || return 0
has_cmd docker || return 0
# only act if the docker group actually exists
if ! getent group docker >/dev/null 2>&1; then
return 0
fi
# If user already in group, nothing to do
if id -nG "$USER" 2>/dev/null | grep -qw docker; then return 0; fi
# Re-enter with sg if available
if has_cmd sg; then
if [ -z "${REENTERED_WITH_DOCKER_GROUP:-}" ]; then
say ">>> Re-entering shell with 'docker' group active for this run..."
export REENTERED_WITH_DOCKER_GROUP=1
exec sg docker -c "REENTERED_WITH_DOCKER_GROUP=1 bash \"$0\""
fi
else
say ">>> You were likely added to 'docker' group. Open a new shell or run: newgrp docker"
fi
}
# --- Compose detection/offer (no reinstall) ---------------------------------
check_or_offer_compose() {
if compose_v2_ready; then
say ">>> Docker Compose v2 available (docker compose)."
return 0
fi
if compose_v1_ready; then
say ">>> docker-compose (v1) available: $(docker-compose --version 2>/dev/null || echo ok)"
return 0
fi
say ">>> Docker Compose not found."
if ! ask_yes_no "Install Docker Compose plugin (v2)?"; then
say ">>> Skipping Compose install."
return 1
fi
case "$PLATFORM" in
macOS)
say ">>> On macOS, Docker Desktop bundles Compose v2. Starting Desktop…"
open -gj -a Docker || true
;;
Linux|WSL)
if has_cmd apt-get; then $SUDO apt-get update -y && $SUDO apt-get install -y docker-compose-plugin || true
elif has_cmd dnf; then $SUDO dnf install -y docker-compose-plugin || true
elif has_cmd yum; then $SUDO yum install -y docker-compose-plugin || true
elif has_cmd zypper; then $SUDO zypper install -y docker-compose docker-compose-plugin || true
elif has_cmd pacman; then $SUDO pacman -Sy --noconfirm docker-compose || true
else
say ">>> Please install Compose via your distro's instructions."
fi
;;
esac
if compose_v2_ready || compose_v1_ready; then
say ">>> Compose is now available."
else
say ">>> Could not verify Compose installation automatically."
fi
}
# --- Podman: install if missing (never reinstall) ---------------------------
install_podman_if_missing() {
if has_cmd podman; then
say ">>> Podman CLI detected at: $(podman_cli_path)"
say ">>> Version: $(podman --version 2>/dev/null || echo 'unknown')"
return
fi
say ">>> Podman CLI not found."
if ! ask_yes_no "Install Podman now?"; then return; fi
case "$PLATFORM" in
macOS)
if has_cmd brew; then
brew install podman
else
say ">>> Install Homebrew first (https://brew.sh) then: brew install podman"
exit 1
fi
;;
Linux|WSL)
if has_cmd apt-get; then $SUDO apt-get update -y && $SUDO apt-get install -y podman
elif has_cmd dnf; then $SUDO dnf install -y podman
elif has_cmd yum; then $SUDO yum install -y podman
elif has_cmd zypper; then $SUDO zypper install -y podman
elif has_cmd pacman; then $SUDO pacman -Sy --noconfirm podman
else
say ">>> Please install 'podman' via your distro."
fi
;;
esac
}
ensure_podman_ready() {
if [ "$PLATFORM" = "macOS" ]; then
if ! podman machine list 2>/dev/null | grep -q running; then
say ">>> Starting Podman machine (macOS)…"
podman machine start || true
for i in {1..30}; do podman_ready && break || sleep 2; done
fi
fi
if podman_ready; then
say ">>> Podman is ready."
return 0
else
say ">>> Podman CLI present but not ready (try 'podman machine start' on macOS)."
return 1
fi
}
# --- Runtime auto-detect (prefer no prompt) ---------------------------------
hr
say "Platform: $PLATFORM"
hr
# uv (optional)
if has_cmd uv; then say ">>> uv present: $(uv --version 2>/dev/null || echo ok)"; else install_uv; fi
RUNTIME=""
if docker_is_podman; then
say ">>> Detected podman-docker shim: using Podman runtime."
RUNTIME="Podman"
elif has_cmd docker; then
say ">>> Docker CLI detected."
RUNTIME="Docker"
elif has_cmd podman; then
say ">>> Podman CLI detected."
RUNTIME="Podman"
fi
if [ -z "$RUNTIME" ]; then
say "Choose container runtime:"
PS3="Select [1-2]: "
select rt in "Docker" "Podman"; do
case "$REPLY" in 1|2) RUNTIME="$rt"; break ;; *) say "Invalid choice";; esac
done
fi
say "Selected runtime: $RUNTIME"
hr
# --- Execute runtime path ----------------------------------------------------
if [ "$RUNTIME" = "Docker" ]; then
install_docker_if_missing # no reinstall if present
activate_docker_group_now # safe: only if group exists and user not in it
start_docker_daemon_if_needed # sudo only to start service on Linux/WSL
check_or_offer_compose # offer to install Compose only if missing
else
install_podman_if_missing # no reinstall if present
ensure_podman_ready
# Optional: podman-compose for compose-like UX
if ! command -v podman-compose >/dev/null 2>&1; then
if ask_yes_no "Install podman-compose (optional)?"; then
if has_cmd brew; then brew install podman-compose
elif has_cmd apt-get; then $SUDO apt-get update -y && $SUDO apt-get install -y podman-compose || pip3 install --user podman-compose || true
elif has_cmd dnf; then $SUDO dnf install -y podman-compose || true
elif has_cmd yum; then $SUDO yum install -y podman-compose || true
elif has_cmd zypper; then $SUDO zypper install -y podman-compose || true
elif has_cmd pacman; then $SUDO pacman -Sy --noconfirm podman-compose || true
else say ">>> Please install podman-compose via your distro."; fi
fi
fi
fi
hr
say "Environment ready — launching: uvx openrag"
hr
if ! has_cmd uv; then
say ">>> 'uv' not on PATH. Add the installers bin dir to PATH, then run: uvx openrag"
exit 1
fi
exec uvx openrag

117
src/api/provider_health.py Normal file
View file

@ -0,0 +1,117 @@
"""Provider health check endpoint."""
from starlette.responses import JSONResponse
from utils.logging_config import get_logger
from config.settings import get_openrag_config
from api.provider_validation import validate_provider_setup
logger = get_logger(__name__)
async def check_provider_health(request):
"""
Check if the configured provider is healthy and properly validated.
Query parameters:
provider (optional): Provider to check ('openai', 'ollama', 'watsonx').
If not provided, checks the currently configured provider.
Returns:
200: Provider is healthy and validated
400: Invalid provider specified
503: Provider validation failed
"""
try:
# Get optional provider from query params
query_params = dict(request.query_params)
check_provider = query_params.get("provider")
# Get current config
current_config = get_openrag_config()
# Determine which provider to check
if check_provider:
provider = check_provider.lower()
else:
provider = current_config.provider.model_provider
# Validate provider name
valid_providers = ["openai", "ollama", "watsonx"]
if provider not in valid_providers:
return JSONResponse(
{
"status": "error",
"message": f"Invalid provider: {provider}. Must be one of: {', '.join(valid_providers)}",
"provider": provider,
},
status_code=400,
)
# Get provider configuration
if check_provider:
# If checking a specific provider, we may not have all config
# So we'll try to use what's available or fail gracefully
if provider == current_config.provider.model_provider:
# Use current config if checking current provider
api_key = current_config.provider.api_key
endpoint = current_config.provider.endpoint
project_id = current_config.provider.project_id
llm_model = current_config.agent.llm_model
embedding_model = current_config.knowledge.embedding_model
else:
# For other providers, we can't validate without config
return JSONResponse(
{
"status": "error",
"message": f"Cannot validate {provider} - not currently configured. Please configure it first.",
"provider": provider,
},
status_code=400,
)
else:
# Check current provider
api_key = current_config.provider.api_key
endpoint = current_config.provider.endpoint
project_id = current_config.provider.project_id
llm_model = current_config.agent.llm_model
embedding_model = current_config.knowledge.embedding_model
logger.info(f"Checking health for provider: {provider}")
# Validate provider setup
await validate_provider_setup(
provider=provider,
api_key=api_key,
embedding_model=embedding_model,
llm_model=llm_model,
endpoint=endpoint,
project_id=project_id,
)
return JSONResponse(
{
"status": "healthy",
"message": "Properly configured and validated",
"provider": provider,
"details": {
"llm_model": llm_model,
"embedding_model": embedding_model,
"endpoint": endpoint if provider in ["ollama", "watsonx"] else None,
},
},
status_code=200,
)
except Exception as e:
error_message = str(e)
logger.error(f"Provider health check failed for {provider}: {error_message}")
return JSONResponse(
{
"status": "unhealthy",
"message": error_message,
"provider": provider,
},
status_code=503,
)

View file

@ -15,6 +15,7 @@ from config.settings import (
get_openrag_config, get_openrag_config,
config_manager, config_manager,
) )
from api.provider_validation import validate_provider_setup
logger = get_logger(__name__) logger = get_logger(__name__)
@ -201,7 +202,115 @@ async def update_settings(request, session_manager):
status_code=400, status_code=400,
) )
# Validate types early before modifying config
if "embedding_model" in body:
if (
not isinstance(body["embedding_model"], str)
or not body["embedding_model"].strip()
):
return JSONResponse(
{"error": "embedding_model must be a non-empty string"},
status_code=400,
)
if "table_structure" in body:
if not isinstance(body["table_structure"], bool):
return JSONResponse(
{"error": "table_structure must be a boolean"}, status_code=400
)
if "ocr" in body:
if not isinstance(body["ocr"], bool):
return JSONResponse(
{"error": "ocr must be a boolean"}, status_code=400
)
if "picture_descriptions" in body:
if not isinstance(body["picture_descriptions"], bool):
return JSONResponse(
{"error": "picture_descriptions must be a boolean"}, status_code=400
)
if "chunk_size" in body:
if not isinstance(body["chunk_size"], int) or body["chunk_size"] <= 0:
return JSONResponse(
{"error": "chunk_size must be a positive integer"}, status_code=400
)
if "chunk_overlap" in body:
if not isinstance(body["chunk_overlap"], int) or body["chunk_overlap"] < 0:
return JSONResponse(
{"error": "chunk_overlap must be a non-negative integer"},
status_code=400,
)
if "model_provider" in body:
if (
not isinstance(body["model_provider"], str)
or not body["model_provider"].strip()
):
return JSONResponse(
{"error": "model_provider must be a non-empty string"},
status_code=400,
)
if "api_key" in body:
if not isinstance(body["api_key"], str):
return JSONResponse(
{"error": "api_key must be a string"}, status_code=400
)
if "endpoint" in body:
if not isinstance(body["endpoint"], str) or not body["endpoint"].strip():
return JSONResponse(
{"error": "endpoint must be a non-empty string"}, status_code=400
)
if "project_id" in body:
if (
not isinstance(body["project_id"], str)
or not body["project_id"].strip()
):
return JSONResponse(
{"error": "project_id must be a non-empty string"}, status_code=400
)
# Validate provider setup if provider-related fields are being updated
# Do this BEFORE modifying any config
provider_fields = ["model_provider", "api_key", "endpoint", "project_id", "llm_model", "embedding_model"]
should_validate = any(field in body for field in provider_fields)
if should_validate:
try:
logger.info("Running provider validation before modifying config")
provider = body.get("model_provider", current_config.provider.model_provider)
api_key = body.get("api_key") if "api_key" in body and body["api_key"].strip() else current_config.provider.api_key
endpoint = body.get("endpoint", current_config.provider.endpoint)
project_id = body.get("project_id", current_config.provider.project_id)
llm_model = body.get("llm_model", current_config.agent.llm_model)
embedding_model = body.get("embedding_model", current_config.knowledge.embedding_model)
await validate_provider_setup(
provider=provider,
api_key=api_key,
embedding_model=embedding_model,
llm_model=llm_model,
endpoint=endpoint,
project_id=project_id,
)
logger.info(f"Provider validation successful for {provider}")
except Exception as e:
logger.error(f"Provider validation failed: {str(e)}")
return JSONResponse(
{"error": f"{str(e)}"},
status_code=400
)
# Update configuration # Update configuration
# Only reached if validation passed or wasn't needed
config_updated = False config_updated = False
# Update agent settings # Update agent settings
@ -240,14 +349,6 @@ async def update_settings(request, session_manager):
# Update knowledge settings # Update knowledge settings
if "embedding_model" in body: if "embedding_model" in body:
if (
not isinstance(body["embedding_model"], str)
or not body["embedding_model"].strip()
):
return JSONResponse(
{"error": "embedding_model must be a non-empty string"},
status_code=400,
)
new_embedding_model = body["embedding_model"].strip() new_embedding_model = body["embedding_model"].strip()
current_config.knowledge.embedding_model = new_embedding_model current_config.knowledge.embedding_model = new_embedding_model
config_updated = True config_updated = True
@ -297,10 +398,6 @@ async def update_settings(request, session_manager):
# The config will still be saved # The config will still be saved
if "table_structure" in body: if "table_structure" in body:
if not isinstance(body["table_structure"], bool):
return JSONResponse(
{"error": "table_structure must be a boolean"}, status_code=400
)
current_config.knowledge.table_structure = body["table_structure"] current_config.knowledge.table_structure = body["table_structure"]
config_updated = True config_updated = True
@ -318,10 +415,6 @@ async def update_settings(request, session_manager):
logger.error(f"Failed to update docling settings in flow: {str(e)}") logger.error(f"Failed to update docling settings in flow: {str(e)}")
if "ocr" in body: if "ocr" in body:
if not isinstance(body["ocr"], bool):
return JSONResponse(
{"error": "ocr must be a boolean"}, status_code=400
)
current_config.knowledge.ocr = body["ocr"] current_config.knowledge.ocr = body["ocr"]
config_updated = True config_updated = True
@ -339,10 +432,6 @@ async def update_settings(request, session_manager):
logger.error(f"Failed to update docling settings in flow: {str(e)}") logger.error(f"Failed to update docling settings in flow: {str(e)}")
if "picture_descriptions" in body: if "picture_descriptions" in body:
if not isinstance(body["picture_descriptions"], bool):
return JSONResponse(
{"error": "picture_descriptions must be a boolean"}, status_code=400
)
current_config.knowledge.picture_descriptions = body["picture_descriptions"] current_config.knowledge.picture_descriptions = body["picture_descriptions"]
config_updated = True config_updated = True
@ -360,10 +449,6 @@ async def update_settings(request, session_manager):
logger.error(f"Failed to update docling settings in flow: {str(e)}") logger.error(f"Failed to update docling settings in flow: {str(e)}")
if "chunk_size" in body: if "chunk_size" in body:
if not isinstance(body["chunk_size"], int) or body["chunk_size"] <= 0:
return JSONResponse(
{"error": "chunk_size must be a positive integer"}, status_code=400
)
current_config.knowledge.chunk_size = body["chunk_size"] current_config.knowledge.chunk_size = body["chunk_size"]
config_updated = True config_updated = True
@ -380,11 +465,6 @@ async def update_settings(request, session_manager):
# The config will still be saved # The config will still be saved
if "chunk_overlap" in body: if "chunk_overlap" in body:
if not isinstance(body["chunk_overlap"], int) or body["chunk_overlap"] < 0:
return JSONResponse(
{"error": "chunk_overlap must be a non-negative integer"},
status_code=400,
)
current_config.knowledge.chunk_overlap = body["chunk_overlap"] current_config.knowledge.chunk_overlap = body["chunk_overlap"]
config_updated = True config_updated = True
@ -404,43 +484,20 @@ async def update_settings(request, session_manager):
# Update provider settings # Update provider settings
if "model_provider" in body: if "model_provider" in body:
if (
not isinstance(body["model_provider"], str)
or not body["model_provider"].strip()
):
return JSONResponse(
{"error": "model_provider must be a non-empty string"},
status_code=400,
)
current_config.provider.model_provider = body["model_provider"].strip() current_config.provider.model_provider = body["model_provider"].strip()
config_updated = True config_updated = True
if "api_key" in body: if "api_key" in body:
if not isinstance(body["api_key"], str):
return JSONResponse(
{"error": "api_key must be a string"}, status_code=400
)
# Only update if non-empty string (empty string means keep current value) # Only update if non-empty string (empty string means keep current value)
if body["api_key"].strip(): if body["api_key"].strip():
current_config.provider.api_key = body["api_key"] current_config.provider.api_key = body["api_key"]
config_updated = True config_updated = True
if "endpoint" in body: if "endpoint" in body:
if not isinstance(body["endpoint"], str) or not body["endpoint"].strip():
return JSONResponse(
{"error": "endpoint must be a non-empty string"}, status_code=400
)
current_config.provider.endpoint = body["endpoint"].strip() current_config.provider.endpoint = body["endpoint"].strip()
config_updated = True config_updated = True
if "project_id" in body: if "project_id" in body:
if (
not isinstance(body["project_id"], str)
or not body["project_id"].strip()
):
return JSONResponse(
{"error": "project_id must be a non-empty string"}, status_code=400
)
current_config.provider.project_id = body["project_id"].strip() current_config.provider.project_id = body["project_id"].strip()
config_updated = True config_updated = True

View file

@ -39,6 +39,7 @@ from api import (
models, models,
nudges, nudges,
oidc, oidc,
provider_health,
router, router,
search, search,
settings, settings,
@ -986,6 +987,14 @@ async def create_app():
), ),
methods=["POST"], methods=["POST"],
), ),
# Provider health check endpoint
Route(
"/provider/health",
require_auth(services["session_manager"])(
provider_health.check_provider_health
),
methods=["GET"],
),
# Models endpoints # Models endpoints
Route( Route(
"/models/openai", "/models/openai",

2
uv.lock generated
View file

@ -2361,6 +2361,7 @@ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "docling", extra = ["ocrmac"], marker = "sys_platform == 'darwin'" }, { name = "docling", extra = ["ocrmac"], marker = "sys_platform == 'darwin'" },
{ name = "docling", extra = ["vlm"] }, { name = "docling", extra = ["vlm"] },
{ name = "docling-core" },
{ name = "docling-serve" }, { name = "docling-serve" },
{ name = "easyocr" }, { name = "easyocr" },
{ name = "google-api-python-client" }, { name = "google-api-python-client" },
@ -2399,6 +2400,7 @@ requires-dist = [
{ name = "cryptography", specifier = ">=45.0.6" }, { name = "cryptography", specifier = ">=45.0.6" },
{ name = "docling", extras = ["ocrmac", "vlm"], marker = "sys_platform == 'darwin'", specifier = "==2.41.0" }, { name = "docling", extras = ["ocrmac", "vlm"], marker = "sys_platform == 'darwin'", specifier = "==2.41.0" },
{ name = "docling", extras = ["vlm"], marker = "sys_platform != 'darwin'", specifier = "==2.41.0" }, { name = "docling", extras = ["vlm"], marker = "sys_platform != 'darwin'", specifier = "==2.41.0" },
{ name = "docling-core", specifier = "==2.48.1" },
{ name = "docling-serve", specifier = "==1.5.0" }, { name = "docling-serve", specifier = "==1.5.0" },
{ name = "easyocr", specifier = ">=1.7.1" }, { name = "easyocr", specifier = ">=1.7.1" },
{ name = "google-api-python-client", specifier = ">=2.143.0" }, { name = "google-api-python-client", specifier = ">=2.143.0" },