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 { 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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) => {
|
onSuccess: (...args) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["settings"],
|
queryKey: ["settings"],
|
||||||
refetchType: "all"
|
|
||||||
});
|
});
|
||||||
options?.onSuccess?.(...args);
|
options?.onSuccess?.(...args);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
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 {
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,15 @@ export function ModelSelectors({
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [defaultLlmModel, defaultEmbeddingModel, setValue]);
|
}, [
|
||||||
|
defaultLlmModel,
|
||||||
|
defaultEmbeddingModel,
|
||||||
|
llmModel,
|
||||||
|
embeddingModel,
|
||||||
|
setValue,
|
||||||
|
languageModelName,
|
||||||
|
embeddingModelName,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
@ -102,6 +108,20 @@ const WatsonxSettingsDialog = ({
|
||||||
|
|
||||||
<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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue