Merge branch 'main' into docs-install-options
This commit is contained in:
commit
e6343ed078
22 changed files with 1275 additions and 471 deletions
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={cn("max-w-lg", className)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
docling-serve is stopped. Knowledge ingest is unavailable.
|
||||
</DialogTitle>
|
||||
<DialogDescription>Start docling-serve by running:</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={cn("max-w-lg", className)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
docling-serve is stopped. Knowledge ingest is unavailable.
|
||||
</DialogTitle>
|
||||
<DialogDescription>Start docling-serve by running:</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
|
||||
uv run openrag
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0"
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
|
||||
uv run openrag
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0"
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogDescription>
|
||||
Then, select{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Start All Services
|
||||
</span>{" "}
|
||||
in the TUI. Once docling-serve is running, refresh OpenRAG.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Then, select{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
Start All Services
|
||||
</span>{" "}
|
||||
in the TUI. Once docling-serve is running, refresh OpenRAG.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<Banner
|
||||
className={cn(
|
||||
`bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-200 border-amber-200 dark:border-amber-800`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<BannerIcon icon={AlertTriangle} />
|
||||
<BannerTitle className="font-medium">
|
||||
docling-serve native service is stopped. Knowledge ingest is
|
||||
unavailable.
|
||||
</BannerTitle>
|
||||
<BannerAction
|
||||
onClick={() => setShowDialog(true)}
|
||||
className="bg-foreground text-background hover:bg-primary/90"
|
||||
>
|
||||
Setup Docling Serve
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</BannerAction>
|
||||
</Banner>
|
||||
if (isUnhealthy) {
|
||||
return (
|
||||
<>
|
||||
<Banner
|
||||
className={cn(
|
||||
"bg-amber-50 dark:bg-amber-950 text-foreground border-accent-amber border-b w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<BannerIcon
|
||||
icon={AlertTriangle}
|
||||
className="text-accent-amber-foreground"
|
||||
/>
|
||||
<BannerTitle className="font-medium">
|
||||
docling-serve native service is stopped. Knowledge ingest is
|
||||
unavailable.
|
||||
</BannerTitle>
|
||||
<BannerAction
|
||||
onClick={() => setShowDialog(true)}
|
||||
className="bg-foreground text-background hover:bg-primary/90"
|
||||
>
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
74
frontend/components/provider-health-banner.tsx
Normal file
74
frontend/components/provider-health-banner.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -62,7 +62,6 @@ export const useUpdateSettingsMutation = (
|
|||
onSuccess: (...args) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["settings"],
|
||||
refetchType: "all"
|
||||
});
|
||||
options?.onSuccess?.(...args);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<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>;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
71
frontend/src/app/api/queries/useProviderHealthQuery.ts
Normal file
71
frontend/src/app/api/queries/useProviderHealthQuery.ts
Normal 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;
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{/** biome-ignore lint/a11y/useSemanticElements: has to be a Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={options.length === 0}
|
||||
aria-expanded={open}
|
||||
className={cn("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)
|
||||
useEffect(() => {
|
||||
if (
|
||||
value &&
|
||||
value !== "" &&
|
||||
!options.find((option) => option.value === value) &&
|
||||
!custom
|
||||
) {
|
||||
onValueChange("");
|
||||
}
|
||||
}, [options, value, custom, onValueChange]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{/** biome-ignore lint/a11y/useSemanticElements: has to be a Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={options.length === 0}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"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 && (
|
||||
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
|
||||
Default
|
||||
</span>
|
||||
)} */}
|
||||
{custom &&
|
||||
value &&
|
||||
!options.find((framework) => framework.value === value) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
CUSTOM
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : options.length === 0 ? (
|
||||
noOptionsPlaceholder
|
||||
) : (
|
||||
placeholder
|
||||
)}
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className=" p-0 w-[var(--radix-popover-trigger-width)]">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{noOptionsPlaceholder}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(currentValue) => {
|
||||
if (currentValue !== value) {
|
||||
onValueChange(currentValue);
|
||||
}
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.label}
|
||||
{/* {option.default && (
|
||||
{custom &&
|
||||
value &&
|
||||
!options.find((framework) => framework.value === value) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
CUSTOM
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : options.length === 0 ? (
|
||||
noOptionsPlaceholder
|
||||
) : (
|
||||
placeholder
|
||||
)}
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className=" p-0 w-[var(--radix-popover-trigger-width)]"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{noOptionsPlaceholder}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(currentValue) => {
|
||||
if (currentValue !== value) {
|
||||
onValueChange(currentValue);
|
||||
}
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<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
|
||||
Default
|
||||
</span>
|
||||
)} */}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{custom &&
|
||||
searchValue &&
|
||||
!options.find((option) => option.value === searchValue) && (
|
||||
<CommandItem
|
||||
value={searchValue}
|
||||
onSelect={(currentValue) => {
|
||||
if (currentValue !== value) {
|
||||
onValueChange(currentValue);
|
||||
}
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === searchValue ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{searchValue}
|
||||
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
|
||||
Custom
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{custom &&
|
||||
searchValue &&
|
||||
!options.find((option) => option.value === searchValue) && (
|
||||
<CommandItem
|
||||
value={searchValue}
|
||||
onSelect={(currentValue) => {
|
||||
if (currentValue !== value) {
|
||||
onValueChange(currentValue);
|
||||
}
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === searchValue ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{searchValue}
|
||||
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
|
||||
Custom
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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<
|
||||
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 (
|
||||
<Card
|
||||
key={providerKey}
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
!isActive && "text-muted-foreground"
|
||||
!isCurrentProvider && "text-muted-foreground",
|
||||
isCurrentProvider && isUnhealthy && "border-destructive"
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
|
|
@ -89,13 +117,15 @@ export const ModelProviders = () => {
|
|||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded flex items-center justify-center border",
|
||||
isActive ? logoBgColor : "bg-muted"
|
||||
isCurrentProvider ? logoBgColor : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{
|
||||
<Logo
|
||||
className={
|
||||
isActive ? logoColor : "text-muted-foreground"
|
||||
isCurrentProvider
|
||||
? logoColor
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
|
@ -103,20 +133,27 @@ export const ModelProviders = () => {
|
|||
</div>
|
||||
<CardTitle className="flex flex-row items-center gap-2">
|
||||
{name}
|
||||
{isActive && (
|
||||
<div className="h-2 w-2 bg-accent-emerald-foreground rounded-full" />
|
||||
{isCurrentProvider && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
isUnhealthy
|
||||
? "bg-destructive"
|
||||
: "bg-accent-emerald-foreground"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-end space-y-4">
|
||||
{isActive ? (
|
||||
{isCurrentProvider ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isUnhealthy ? "default" : "outline"}
|
||||
onClick={() => setDialogOpen(providerKey)}
|
||||
>
|
||||
Edit Setup
|
||||
{isUnhealthy ? "Fix Setup" : "Edit Setup"}
|
||||
</Button>
|
||||
) : (
|
||||
<p>
|
||||
|
|
@ -139,15 +176,15 @@ export const ModelProviders = () => {
|
|||
</div>
|
||||
<OpenAISettingsDialog
|
||||
open={dialogOpen === "openai"}
|
||||
setOpen={() => setDialogOpen(undefined)}
|
||||
setOpen={handleCloseDialog}
|
||||
/>
|
||||
<OllamaSettingsDialog
|
||||
open={dialogOpen === "ollama"}
|
||||
setOpen={() => setDialogOpen(undefined)}
|
||||
setOpen={handleCloseDialog}
|
||||
/>
|
||||
<WatsonxSettingsDialog
|
||||
open={dialogOpen === "watsonx"}
|
||||
setOpen={() => setDialogOpen(undefined)}
|
||||
setOpen={handleCloseDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -47,7 +47,15 @@ export function ModelSelectors({
|
|||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}, [defaultLlmModel, defaultEmbeddingModel, setValue]);
|
||||
}, [
|
||||
defaultLlmModel,
|
||||
defaultEmbeddingModel,
|
||||
llmModel,
|
||||
embeddingModel,
|
||||
setValue,
|
||||
languageModelName,
|
||||
embeddingModelName,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
</DialogHeader>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -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 { AnimatePresence, motion } from "motion/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
|
||||
|
||||
const OpenAISettingsDialog = ({
|
||||
open,
|
||||
|
|
@ -25,6 +28,7 @@ const OpenAISettingsDialog = ({
|
|||
setOpen: (open: boolean) => void;
|
||||
}) => {
|
||||
const { isAuthenticated, isNoAuthMode } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings = {} } = useGetSettingsQuery({
|
||||
enabled: isAuthenticated || isNoAuthMode,
|
||||
|
|
@ -47,14 +51,17 @@ const OpenAISettingsDialog = ({
|
|||
|
||||
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: "openai",
|
||||
};
|
||||
queryClient.setQueryData(["provider", "health"], healthData);
|
||||
|
||||
toast.success("OpenAI settings updated successfully");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update OpenAI settings", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: OpenAISettingsFormData) => {
|
||||
|
|
@ -94,7 +101,21 @@ const OpenAISettingsDialog = ({
|
|||
|
||||
<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
|
||||
variant="outline"
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -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 WatsonxSettingsDialog = ({
|
||||
open,
|
||||
|
|
@ -25,6 +28,7 @@ const WatsonxSettingsDialog = ({
|
|||
setOpen: (open: boolean) => void;
|
||||
}) => {
|
||||
const { isAuthenticated, isNoAuthMode } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings = {} } = useGetSettingsQuery({
|
||||
enabled: isAuthenticated || isNoAuthMode,
|
||||
|
|
@ -51,14 +55,16 @@ const WatsonxSettingsDialog = ({
|
|||
|
||||
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: "watsonx",
|
||||
};
|
||||
queryClient.setQueryData(["provider", "health"], healthData);
|
||||
toast.success("watsonx settings updated successfully");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update watsonx settings", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: WatsonxSettingsFormData) => {
|
||||
|
|
@ -98,10 +104,24 @@ const WatsonxSettingsDialog = ({
|
|||
</div>
|
||||
IBM watsonx.ai Setup
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -4,15 +4,10 @@ import { ArrowUpRight, Loader2, Minus, PlugZap, Plus } from "lucide-react";
|
|||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
useGetIBMModelsQuery,
|
||||
useGetOllamaModelsQuery,
|
||||
useGetOpenAIModelsQuery,
|
||||
} from "@/app/api/queries/useGetModelsQuery";
|
||||
import { useGetCurrentProviderModelsQuery } from "@/app/api/queries/useGetModelsQuery";
|
||||
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
||||
import { ConfirmationDialog } from "@/components/confirmation-dialog";
|
||||
import { LabelWrapper } from "@/components/label-wrapper";
|
||||
import OpenAILogo from "@/components/logo/openai-logo";
|
||||
import { ProtectedRoute } from "@/components/protected-route";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -24,17 +19,6 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
|
@ -46,14 +30,13 @@ import {
|
|||
} from "@/lib/constants";
|
||||
import { useDebounce } from "@/lib/debounce";
|
||||
import { ModelSelector } from "../onboarding/components/model-selector";
|
||||
import { getFallbackModels, type ModelProvider } from "./helpers/model-helpers";
|
||||
import { ModelSelectItems } from "./helpers/model-select-item";
|
||||
|
||||
import { getModelLogo, type ModelProvider } from "./helpers/model-helpers";
|
||||
import GoogleDriveIcon from "./icons/google-drive-icon";
|
||||
import OneDriveIcon from "./icons/one-drive-icon";
|
||||
import SharePointIcon from "./icons/share-point-icon";
|
||||
import ModelProviders from "./components/model-providers";
|
||||
import { useUpdateSettingsMutation } from "../api/mutations/useUpdateSettingsMutation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const { MAX_SYSTEM_PROMPT_CHARS } = UI_CONSTANTS;
|
||||
|
||||
|
|
@ -137,56 +120,18 @@ function KnowledgeSourcesPage() {
|
|||
const currentProvider = (settings.provider?.model_provider ||
|
||||
"openai") as ModelProvider;
|
||||
|
||||
// Fetch available models based on provider
|
||||
const { data: openaiModelsData } = useGetOpenAIModelsQuery(
|
||||
{
|
||||
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
|
||||
const { data: modelsData, isLoading: modelsLoading } =
|
||||
useGetCurrentProviderModelsQuery();
|
||||
|
||||
// Mutations
|
||||
const updateSettingsMutation = useUpdateSettingsMutation({
|
||||
onSuccess: () => {
|
||||
console.log("Setting updated successfully");
|
||||
toast.success("Settings updated successfully");
|
||||
},
|
||||
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
|
||||
const handleModelChange = (newModel: string) => {
|
||||
updateSettingsMutation.mutate({ llm_model: newModel });
|
||||
if (newModel) updateSettingsMutation.mutate({ llm_model: newModel });
|
||||
};
|
||||
|
||||
// Update system prompt with save button
|
||||
|
|
@ -249,7 +194,7 @@ function KnowledgeSourcesPage() {
|
|||
|
||||
// Update embedding model selection immediately
|
||||
const handleEmbeddingModelChange = (newModel: string) => {
|
||||
updateSettingsMutation.mutate({ embedding_model: newModel });
|
||||
if (newModel) updateSettingsMutation.mutate({ embedding_model: newModel });
|
||||
};
|
||||
|
||||
const isEmbeddingModelSelectDisabled = updateSettingsMutation.isPending;
|
||||
|
|
@ -465,11 +410,17 @@ function KnowledgeSourcesPage() {
|
|||
const getStatusBadge = (status: Connector["status"]) => {
|
||||
switch (status) {
|
||||
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":
|
||||
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":
|
||||
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:
|
||||
return <div className="h-2 w-2 bg-muted rounded-full" />;
|
||||
}
|
||||
|
|
@ -909,12 +860,12 @@ function KnowledgeSourcesPage() {
|
|||
<ModelSelector
|
||||
options={modelsData?.language_models || []}
|
||||
noOptionsPlaceholder={
|
||||
modelsData
|
||||
? "No language models detected."
|
||||
: "Loading models..."
|
||||
modelsLoading
|
||||
? "Loading models..."
|
||||
: "No language models detected."
|
||||
}
|
||||
icon={<OpenAILogo className="w-4 h-4" />}
|
||||
value={modelsData ? settings.agent?.llm_model || "" : ""}
|
||||
icon={getModelLogo("", currentProvider)}
|
||||
value={settings.agent?.llm_model || ""}
|
||||
onValueChange={handleModelChange}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
|
|
@ -1051,41 +1002,17 @@ function KnowledgeSourcesPage() {
|
|||
id="embedding-model-select"
|
||||
label="Embedding model"
|
||||
>
|
||||
<Select
|
||||
disabled={isEmbeddingModelSelectDisabled}
|
||||
value={
|
||||
settings.knowledge?.embedding_model ||
|
||||
modelsData?.embedding_models?.find((m) => m.default)
|
||||
?.value ||
|
||||
"text-embedding-ada-002"
|
||||
<ModelSelector
|
||||
options={modelsData?.embedding_models || []}
|
||||
noOptionsPlaceholder={
|
||||
modelsLoading
|
||||
? "Loading models..."
|
||||
: "No embedding models detected."
|
||||
}
|
||||
icon={getModelLogo("", currentProvider)}
|
||||
value={settings.knowledge?.embedding_model || ""}
|
||||
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>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export function ChatRenderer({
|
|||
ease: "easeOut",
|
||||
}}
|
||||
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 && !isOnChatPage && "bg-background",
|
||||
)}
|
||||
|
|
@ -163,10 +163,10 @@ export function ChatRenderer({
|
|||
<div
|
||||
className={cn(
|
||||
"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 &&
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
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 { TaskNotificationMenu } from "@/components/task-notification-menu";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||
import { useTask } from "@/contexts/task-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
|
||||
import { ChatRenderer } from "./chat-renderer";
|
||||
import AnimatedProcessingIcon from "./ui/animated-processing-icon";
|
||||
import { AnimatedConditional } from "./animated-conditional";
|
||||
|
||||
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
|
@ -29,13 +35,9 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|||
const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({
|
||||
enabled: !isAuthPage && (isAuthenticated || isNoAuthMode),
|
||||
});
|
||||
const {
|
||||
data: health,
|
||||
isLoading: isHealthLoading,
|
||||
isError,
|
||||
} = useDoclingHealthQuery({
|
||||
enabled: !isAuthPage,
|
||||
});
|
||||
|
||||
const { isUnhealthy: isDoclingUnhealthy } = useDoclingHealth();
|
||||
const { isUnhealthy: isProviderUnhealthy } = useProviderHealth();
|
||||
|
||||
// For auth pages, render immediately without navigation
|
||||
// This prevents the main layout from flashing
|
||||
|
|
@ -45,12 +47,13 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|||
|
||||
const isOnKnowledgePage = pathname.startsWith("/knowledge");
|
||||
|
||||
const isUnhealthy = health?.status === "unhealthy" || isError;
|
||||
const isBannerVisible = !isHealthLoading && isUnhealthy;
|
||||
const isSettingsLoadingOrError = isSettingsLoading || !settings;
|
||||
|
||||
// Show loading state when backend isn't ready
|
||||
if (isLoading || (isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated))) {
|
||||
if (
|
||||
isLoading ||
|
||||
(isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated))
|
||||
) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<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
|
||||
return (
|
||||
<div className=" h-screen w-screen flex items-center justify-center">
|
||||
<div className="h-screen w-screen flex items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"app-grid-arrangement bg-black relative",
|
||||
isBannerVisible && "banner-visible",
|
||||
isPanelOpen && isOnKnowledgePage && !isMenuOpen && "filters-open",
|
||||
isMenuOpen && "notifications-open",
|
||||
isMenuOpen && "notifications-open"
|
||||
)}
|
||||
>
|
||||
<div className={`w-full z-10 bg-background [grid-area:banner]`}>
|
||||
<DoclingHealthBanner className="w-full" />
|
||||
<div className="w-full z-10 bg-background [grid-area:banner]">
|
||||
<AnimatedConditional
|
||||
vertical
|
||||
isOpen={isDoclingUnhealthy}
|
||||
className="w-full"
|
||||
>
|
||||
<DoclingHealthBanner />
|
||||
</AnimatedConditional>
|
||||
{settings?.edited && (
|
||||
<AnimatedConditional
|
||||
vertical
|
||||
isOpen={isProviderUnhealthy}
|
||||
className="w-full"
|
||||
>
|
||||
<ProviderHealthBanner />
|
||||
</AnimatedConditional>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatRenderer settings={settings}>{children}</ChatRenderer>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ dependencies = [
|
|||
"textual-fspicker>=0.6.0",
|
||||
"structlog>=25.4.0",
|
||||
"docling-serve==1.5.0",
|
||||
"docling-core==2.48.1",
|
||||
"easyocr>=1.7.1"
|
||||
]
|
||||
|
||||
|
|
|
|||
345
scripts/run_openrag_with_prereqs.sh
Executable file
345
scripts/run_openrag_with_prereqs.sh
Executable 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 installer’s bin dir to PATH, then run: uvx openrag"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec uvx openrag
|
||||
|
||||
117
src/api/provider_health.py
Normal file
117
src/api/provider_health.py
Normal 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,
|
||||
)
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ from config.settings import (
|
|||
get_openrag_config,
|
||||
config_manager,
|
||||
)
|
||||
from api.provider_validation import validate_provider_setup
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -201,7 +202,115 @@ async def update_settings(request, session_manager):
|
|||
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
|
||||
# Only reached if validation passed or wasn't needed
|
||||
config_updated = False
|
||||
|
||||
# Update agent settings
|
||||
|
|
@ -240,14 +349,6 @@ async def update_settings(request, session_manager):
|
|||
|
||||
# Update knowledge settings
|
||||
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()
|
||||
current_config.knowledge.embedding_model = new_embedding_model
|
||||
config_updated = True
|
||||
|
|
@ -297,10 +398,6 @@ async def update_settings(request, session_manager):
|
|||
# The config will still be saved
|
||||
|
||||
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"]
|
||||
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)}")
|
||||
|
||||
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"]
|
||||
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)}")
|
||||
|
||||
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"]
|
||||
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)}")
|
||||
|
||||
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"]
|
||||
config_updated = True
|
||||
|
||||
|
|
@ -380,11 +465,6 @@ async def update_settings(request, session_manager):
|
|||
# The config will still be saved
|
||||
|
||||
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"]
|
||||
config_updated = True
|
||||
|
||||
|
|
@ -404,43 +484,20 @@ async def update_settings(request, session_manager):
|
|||
|
||||
# Update provider settings
|
||||
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()
|
||||
config_updated = True
|
||||
|
||||
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)
|
||||
if body["api_key"].strip():
|
||||
current_config.provider.api_key = body["api_key"]
|
||||
config_updated = True
|
||||
|
||||
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()
|
||||
config_updated = True
|
||||
|
||||
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()
|
||||
config_updated = True
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from api import (
|
|||
models,
|
||||
nudges,
|
||||
oidc,
|
||||
provider_health,
|
||||
router,
|
||||
search,
|
||||
settings,
|
||||
|
|
@ -986,6 +987,14 @@ async def create_app():
|
|||
),
|
||||
methods=["POST"],
|
||||
),
|
||||
# Provider health check endpoint
|
||||
Route(
|
||||
"/provider/health",
|
||||
require_auth(services["session_manager"])(
|
||||
provider_health.check_provider_health
|
||||
),
|
||||
methods=["GET"],
|
||||
),
|
||||
# Models endpoints
|
||||
Route(
|
||||
"/models/openai",
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -2361,6 +2361,7 @@ dependencies = [
|
|||
{ name = "cryptography" },
|
||||
{ name = "docling", extra = ["ocrmac"], marker = "sys_platform == 'darwin'" },
|
||||
{ name = "docling", extra = ["vlm"] },
|
||||
{ name = "docling-core" },
|
||||
{ name = "docling-serve" },
|
||||
{ name = "easyocr" },
|
||||
{ name = "google-api-python-client" },
|
||||
|
|
@ -2399,6 +2400,7 @@ requires-dist = [
|
|||
{ name = "cryptography", specifier = ">=45.0.6" },
|
||||
{ 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-core", specifier = "==2.48.1" },
|
||||
{ name = "docling-serve", specifier = "==1.5.0" },
|
||||
{ name = "easyocr", specifier = ">=1.7.1" },
|
||||
{ name = "google-api-python-client", specifier = ">=2.143.0" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue