better settings form validation, grouped model selection (#383)

* better form validation, grouped model selection

* bump version

* fix fe build issue

* fix test

* change linting error

* Fixed integration tests

* fixed tests

* sample commit

---------

Co-authored-by: Lucas Oliveira <lucas.edu.oli@hotmail.com>
This commit is contained in:
Cole Goldsmith 2025-11-11 19:39:59 -06:00 committed by GitHub
parent a553fb7d9b
commit 1385fd5d5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1906 additions and 1798 deletions

View file

@ -17,8 +17,22 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export type ModelOption = {
value: string;
label: string;
default?: boolean;
provider?: string;
};
export type GroupedModelOption = {
group: string;
options: ModelOption[];
icon?: React.ReactNode;
};
export function ModelSelector({
options,
groupedOptions,
value = "",
onValueChange,
icon,
@ -28,33 +42,40 @@ export function ModelSelector({
custom = false,
hasError = false,
}: {
options: {
value: string;
label: string;
default?: boolean;
}[];
options?: ModelOption[];
groupedOptions?: GroupedModelOption[];
value: string;
icon?: React.ReactNode;
placeholder?: string;
searchPlaceholder?: string;
noOptionsPlaceholder?: string;
custom?: boolean;
onValueChange: (value: string) => void;
onValueChange: (value: string, provider?: string) => void;
hasError?: boolean;
}) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
// Flatten grouped options or use regular options
const allOptions =
groupedOptions?.flatMap((group) => group.options) || options || [];
// Find the group icon for the selected value
const selectedOptionGroup = groupedOptions?.find((group) =>
group.options.some((opt) => opt.value === value)
);
const selectedIcon = selectedOptionGroup?.icon || icon;
useEffect(() => {
if (
value &&
value !== "" &&
!options.find((option) => option.value === value) &&
!allOptions.find((option) => option.value === value) &&
!custom
) {
onValueChange("");
}
}, [options, value, custom, onValueChange]);
}, [allOptions, value, custom, onValueChange]);
return (
<Popover open={open} onOpenChange={setOpen} modal={false}>
@ -63,7 +84,7 @@ export function ModelSelector({
<Button
variant="outline"
role="combobox"
disabled={options.length === 0}
disabled={allOptions.length === 0}
aria-expanded={open}
className={cn(
"w-full gap-2 justify-between font-normal text-sm",
@ -72,24 +93,18 @@ export function ModelSelector({
>
{value ? (
<div className="flex items-center gap-2">
{icon && <div className="w-4 h-4">{icon}</div>}
{options.find((framework) => framework.value === value)?.label ||
value}
{/* {options.find((framework) => framework.value === value)
?.default && (
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
Default
</span>
)} */}
{selectedIcon && <div className="w-4 h-4">{selectedIcon}</div>}
{allOptions.find((framework) => framework.value === value)
?.label || value}
{custom &&
value &&
!options.find((framework) => framework.value === value) && (
!allOptions.find((framework) => framework.value === value) && (
<Badge variant="outline" className="text-xs">
CUSTOM
</Badge>
)}
</div>
) : options.length === 0 ? (
) : allOptions.length === 0 ? (
noOptionsPlaceholder
) : (
placeholder
@ -113,42 +128,52 @@ export function ModelSelector({
onWheel={(e) => e.stopPropagation()}
>
<CommandEmpty>{noOptionsPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
if (currentValue !== value) {
onValueChange(currentValue);
}
setOpen(false);
}}
{groupedOptions ? (
groupedOptions.map((group) => (
<CommandGroup
key={group.group}
heading={
<div className="flex items-center gap-2">
{group.icon && (
<div className="w-4 h-4">{group.icon}</div>
)}
<span>{group.group}</span>
</div>
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
{option.label}
{/* {option.default && (
<span className="text-xs text-foreground p-1 rounded-md bg-muted"> // DISABLING DEFAULT TAG FOR NOW
Default
</span>
)} */}
</div>
</CommandItem>
))}
{custom &&
searchValue &&
!options.find((option) => option.value === searchValue) && (
{group.options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
if (currentValue !== value) {
onValueChange(currentValue, option.provider);
}
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
{option.label}
</div>
</CommandItem>
))}
</CommandGroup>
))
) : (
<CommandGroup>
{allOptions.map((option) => (
<CommandItem
value={searchValue}
key={option.value}
value={option.value}
onSelect={(currentValue) => {
if (currentValue !== value) {
onValueChange(currentValue);
onValueChange(currentValue, option.provider);
}
setOpen(false);
}}
@ -156,18 +181,44 @@ export function ModelSelector({
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === searchValue ? "opacity-100" : "opacity-0"
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
{searchValue}
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
Custom
</span>
{option.label}
</div>
</CommandItem>
)}
</CommandGroup>
))}
{custom &&
searchValue &&
!allOptions.find(
(option) => option.value === searchValue
) && (
<CommandItem
value={searchValue}
onSelect={(currentValue) => {
if (currentValue !== value) {
onValueChange(currentValue);
}
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
value === searchValue ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
{searchValue}
<span className="text-xs text-foreground p-1 rounded-md bg-muted">
Custom
</span>
</div>
</CommandItem>
)}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>

View file

@ -15,7 +15,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
const [isUploading, setIsUploading] = useState(false);
const [currentStep, setCurrentStep] = useState<number | null>(null);
const STEP_LIST = ["Uploading your document", "Generating embeddings", "Ingesting document", "Processing your document"];
const STEP_LIST = ["Uploading your document", "Processing your document"];
// Query tasks to track completion
const { data: tasks } = useGetTasksQuery({

View file

@ -1,5 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
@ -8,134 +9,150 @@ import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealth
import AnthropicLogo from "@/components/logo/anthropic-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useDebouncedValue } from "@/lib/debounce";
import {
AnthropicSettingsForm,
type AnthropicSettingsFormData,
AnthropicSettingsForm,
type AnthropicSettingsFormData,
} from "./anthropic-settings-form";
const AnthropicSettingsDialog = ({
open,
setOpen,
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<Error | null>(null);
const methods = useForm<AnthropicSettingsFormData>({
mode: "onSubmit",
defaultValues: {
apiKey: "",
},
});
const methods = useForm<AnthropicSettingsFormData>({
mode: "onSubmit",
defaultValues: {
apiKey: "",
},
});
const { handleSubmit, watch, formState } = methods;
const apiKey = watch("apiKey");
const debouncedApiKey = useDebouncedValue(apiKey, 500);
const { handleSubmit, watch } = methods;
const apiKey = watch("apiKey");
const {
isLoading: isLoadingModels,
error: modelsError,
} = useGetAnthropicModelsQuery(
{
apiKey: debouncedApiKey,
},
{
enabled: !!debouncedApiKey && open,
}
);
const { refetch: validateCredentials } = useGetAnthropicModelsQuery(
{
apiKey: apiKey,
},
{
enabled: false,
}
);
const hasValidationError = !!modelsError || !!formState.errors.apiKey;
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "anthropic",
};
queryClient.setQueryData(["provider", "health"], healthData);
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "anthropic",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success(
"Anthropic credentials saved. Configure models in the Settings page."
);
setOpen(false);
},
});
toast.success("Anthropic credentials saved. Configure models in the Settings page.");
setOpen(false);
},
});
const onSubmit = async (data: AnthropicSettingsFormData) => {
// Clear any previous validation errors
setValidationError(null);
const onSubmit = (data: AnthropicSettingsFormData) => {
const payload: {
anthropic_api_key?: string;
} = {};
// Only validate if a new API key was entered
if (data.apiKey) {
setIsValidating(true);
const result = await validateCredentials();
setIsValidating(false);
// Only include api_key if a value was entered
if (data.apiKey) {
payload.anthropic_api_key = data.apiKey;
}
if (result.isError) {
setValidationError(result.error);
return;
}
}
// Submit the update
settingsMutation.mutate(payload);
};
const payload: {
anthropic_api_key?: string;
} = {};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<AnthropicLogo className="text-black" />
</div>
Anthropic Setup
</DialogTitle>
</DialogHeader>
// Only include api_key if a value was entered
if (data.apiKey) {
payload.anthropic_api_key = data.apiKey;
}
<AnthropicSettingsForm
modelsError={modelsError}
isLoadingModels={isLoadingModels}
/>
// Submit the update
settingsMutation.mutate(payload);
};
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || hasValidationError || isLoadingModels}
>
{settingsMutation.isPending ? "Saving..." : isLoadingModels ? "Validating..." : "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<AnthropicLogo className="text-black" />
</div>
Anthropic Setup
</DialogTitle>
</DialogHeader>
<AnthropicSettingsForm
modelsError={validationError}
isLoadingModels={isValidating}
/>
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || isValidating}
>
{settingsMutation.isPending
? "Saving..."
: isValidating
? "Validating..."
: "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default AnthropicSettingsDialog;

View file

@ -7,6 +7,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
@ -20,7 +21,6 @@ import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettings
import { useQueryClient } from "@tanstack/react-query";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import { AnimatePresence, motion } from "motion/react";
import { useDebouncedValue } from "@/lib/debounce";
const OllamaSettingsDialog = ({
open,
@ -31,6 +31,8 @@ const OllamaSettingsDialog = ({
}) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const queryClient = useQueryClient();
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<Error | null>(null);
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
@ -47,24 +49,18 @@ const OllamaSettingsDialog = ({
},
});
const { handleSubmit, watch, formState } = methods;
const { handleSubmit, watch } = methods;
const endpoint = watch("endpoint");
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
const {
isLoading: isLoadingModels,
error: modelsError,
} = useGetOllamaModelsQuery(
const { refetch: validateCredentials } = useGetOllamaModelsQuery(
{
endpoint: debouncedEndpoint,
endpoint: endpoint,
},
{
enabled: formState.isDirty && !!debouncedEndpoint && open,
enabled: false,
}
);
const hasValidationError = !!modelsError || !!formState.errors.endpoint;
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
@ -75,12 +71,27 @@ const OllamaSettingsDialog = ({
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success("Ollama endpoint saved. Configure models in the Settings page.");
toast.success(
"Ollama endpoint saved. Configure models in the Settings page."
);
setOpen(false);
},
});
const onSubmit = (data: OllamaSettingsFormData) => {
const onSubmit = async (data: OllamaSettingsFormData) => {
// Clear any previous validation errors
setValidationError(null);
// Validate endpoint by fetching models
setIsValidating(true);
const result = await validateCredentials();
setIsValidating(false);
if (result.isError) {
setValidationError(result.error);
return;
}
settingsMutation.mutate({
ollama_endpoint: data.endpoint,
});
@ -101,8 +112,8 @@ const OllamaSettingsDialog = ({
</DialogHeader>
<OllamaSettingsForm
modelsError={modelsError}
isLoadingModels={isLoadingModels}
modelsError={validationError}
isLoadingModels={isValidating}
/>
<AnimatePresence mode="wait">
@ -129,9 +140,13 @@ const OllamaSettingsDialog = ({
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || hasValidationError || isLoadingModels}
disabled={settingsMutation.isPending || isValidating}
>
{settingsMutation.isPending ? "Saving..." : isLoadingModels ? "Validating..." : "Save"}
{settingsMutation.isPending
? "Saving..."
: isValidating
? "Validating..."
: "Save"}
</Button>
</DialogFooter>
</form>

View file

@ -1,5 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
@ -8,134 +9,150 @@ import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealth
import OpenAILogo from "@/components/logo/openai-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useDebouncedValue } from "@/lib/debounce";
import {
OpenAISettingsForm,
type OpenAISettingsFormData,
OpenAISettingsForm,
type OpenAISettingsFormData,
} from "./openai-settings-form";
const OpenAISettingsDialog = ({
open,
setOpen,
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<Error | null>(null);
const methods = useForm<OpenAISettingsFormData>({
mode: "onSubmit",
defaultValues: {
apiKey: "",
},
});
const methods = useForm<OpenAISettingsFormData>({
mode: "onSubmit",
defaultValues: {
apiKey: "",
},
});
const { handleSubmit, watch, formState } = methods;
const apiKey = watch("apiKey");
const debouncedApiKey = useDebouncedValue(apiKey, 500);
const { handleSubmit, watch } = methods;
const apiKey = watch("apiKey");
const {
isLoading: isLoadingModels,
error: modelsError,
} = useGetOpenAIModelsQuery(
{
apiKey: debouncedApiKey,
},
{
enabled: !!debouncedApiKey && open,
}
);
const { refetch: validateCredentials } = useGetOpenAIModelsQuery(
{
apiKey: apiKey,
},
{
enabled: false,
}
);
const hasValidationError = !!modelsError || !!formState.errors.apiKey;
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "openai",
};
queryClient.setQueryData(["provider", "health"], healthData);
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "openai",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success(
"OpenAI credentials saved. Configure models in the Settings page."
);
setOpen(false);
},
});
toast.success("OpenAI credentials saved. Configure models in the Settings page.");
setOpen(false);
},
});
const onSubmit = async (data: OpenAISettingsFormData) => {
// Clear any previous validation errors
setValidationError(null);
const onSubmit = (data: OpenAISettingsFormData) => {
const payload: {
openai_api_key?: string;
} = {};
// Only validate if a new API key was entered
if (data.apiKey) {
setIsValidating(true);
const result = await validateCredentials();
setIsValidating(false);
// Only include api_key if a value was entered
if (data.apiKey) {
payload.openai_api_key = data.apiKey;
}
if (result.isError) {
setValidationError(result.error);
return;
}
}
// Submit the update
settingsMutation.mutate(payload);
};
const payload: {
openai_api_key?: string;
} = {};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<OpenAILogo className="text-black" />
</div>
OpenAI Setup
</DialogTitle>
</DialogHeader>
// Only include api_key if a value was entered
if (data.apiKey) {
payload.openai_api_key = data.apiKey;
}
<OpenAISettingsForm
modelsError={modelsError}
isLoadingModels={isLoadingModels}
/>
// Submit the update
settingsMutation.mutate(payload);
};
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || hasValidationError || isLoadingModels}
>
{settingsMutation.isPending ? "Saving..." : isLoadingModels ? "Validating..." : "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<OpenAILogo className="text-black" />
</div>
OpenAI Setup
</DialogTitle>
</DialogHeader>
<OpenAISettingsForm
modelsError={validationError}
isLoadingModels={isValidating}
/>
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || isValidating}
>
{settingsMutation.isPending
? "Saving..."
: isValidating
? "Validating..."
: "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default OpenAISettingsDialog;

View file

@ -7,6 +7,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
@ -18,7 +19,6 @@ import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettings
import { useQueryClient } from "@tanstack/react-query";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import { AnimatePresence, motion } from "motion/react";
import { useDebouncedValue } from "@/lib/debounce";
const WatsonxSettingsDialog = ({
open,
@ -28,6 +28,8 @@ const WatsonxSettingsDialog = ({
setOpen: (open: boolean) => void;
}) => {
const queryClient = useQueryClient();
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<Error | null>(null);
const methods = useForm<WatsonxSettingsFormData>({
mode: "onSubmit",
@ -38,31 +40,22 @@ const WatsonxSettingsDialog = ({
},
});
const { handleSubmit, watch, formState } = methods;
const { handleSubmit, watch } = methods;
const endpoint = watch("endpoint");
const apiKey = watch("apiKey");
const projectId = watch("projectId");
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
const debouncedApiKey = useDebouncedValue(apiKey, 500);
const debouncedProjectId = useDebouncedValue(projectId, 500);
const {
isLoading: isLoadingModels,
error: modelsError,
} = useGetIBMModelsQuery(
const { refetch: validateCredentials } = useGetIBMModelsQuery(
{
endpoint: debouncedEndpoint,
apiKey: debouncedApiKey,
projectId: debouncedProjectId,
endpoint: endpoint,
apiKey: apiKey,
projectId: projectId,
},
{
enabled: !!debouncedEndpoint && !!debouncedApiKey && !!debouncedProjectId && open,
enabled: false,
}
);
const hasValidationError = !!modelsError || !!formState.errors.endpoint || !!formState.errors.apiKey || !!formState.errors.projectId;
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
@ -72,12 +65,27 @@ const WatsonxSettingsDialog = ({
provider: "watsonx",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success("watsonx credentials saved. Configure models in the Settings page.");
toast.success(
"watsonx credentials saved. Configure models in the Settings page."
);
setOpen(false);
},
});
const onSubmit = (data: WatsonxSettingsFormData) => {
const onSubmit = async (data: WatsonxSettingsFormData) => {
// Clear any previous validation errors
setValidationError(null);
// Validate credentials by fetching models
setIsValidating(true);
const result = await validateCredentials();
setIsValidating(false);
if (result.isError) {
setValidationError(result.error);
return;
}
const payload: {
watsonx_endpoint: string;
watsonx_api_key?: string;
@ -111,10 +119,10 @@ const WatsonxSettingsDialog = ({
</DialogHeader>
<WatsonxSettingsForm
modelsError={modelsError}
isLoadingModels={isLoadingModels}
modelsError={validationError}
isLoadingModels={isValidating}
/>
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
@ -139,9 +147,13 @@ const WatsonxSettingsDialog = ({
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || hasValidationError || isLoadingModels}
disabled={settingsMutation.isPending || isValidating}
>
{settingsMutation.isPending ? "Saving..." : isLoadingModels ? "Validating..." : "Save"}
{settingsMutation.isPending
? "Saving..."
: isValidating
? "Validating..."
: "Save"}
</Button>
</DialogFooter>
</form>

File diff suppressed because it is too large Load diff

View file

@ -1,189 +1,204 @@
"use client";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronRight } from "lucide-react";
import { IngestSettings as IngestSettingsType } from "./types";
import { useEffect } from "react";
import {
useGetIBMModelsQuery,
useGetOllamaModelsQuery,
useGetOpenAIModelsQuery,
} from "@/app/api/queries/useGetModelsQuery";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import type { ModelOption } from "@/app/onboarding/components/model-selector";
import {
getFallbackModels,
type ModelProvider,
} from "@/app/settings/helpers/model-helpers";
import { ModelSelectItems } from "@/app/settings/helpers/model-select-item";
import { LabelWrapper } from "@/components/label-wrapper";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ModelSelectItems } from "@/app/settings/helpers/model-select-item";
import { getFallbackModels, type ModelProvider } from "@/app/settings/helpers/model-helpers";
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { NumberInput } from "@/components/ui/inputs/number-input";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import {
useGetOpenAIModelsQuery,
useGetOllamaModelsQuery,
useGetIBMModelsQuery,
} from "@/app/api/queries/useGetModelsQuery";
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/contexts/auth-context";
import { useEffect } from "react";
import type { IngestSettings as IngestSettingsType } from "./types";
interface IngestSettingsProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
settings?: IngestSettingsType;
onSettingsChange?: (settings: IngestSettingsType) => void;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
settings?: IngestSettingsType;
onSettingsChange?: (settings: IngestSettingsType) => void;
}
export const IngestSettings = ({
isOpen,
onOpenChange,
settings,
onSettingsChange,
isOpen,
onOpenChange,
settings,
onSettingsChange,
}: IngestSettingsProps) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const { isAuthenticated, isNoAuthMode } = useAuth();
// Fetch settings from API to get current embedding model
const { data: apiSettings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
// Fetch settings from API to get current embedding model
const { data: apiSettings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
// Get the current provider from API settings
const currentProvider = (apiSettings.providers?.model_provider ||
"openai") as ModelProvider;
// Get the current provider from API settings
const currentProvider = (apiSettings.knowledge?.embedding_provider ||
"openai") as ModelProvider;
// Fetch available models based on provider
const { data: openaiModelsData } = useGetOpenAIModelsQuery(undefined, {
enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "openai",
});
// Fetch available models based on provider
const { data: openaiModelsData } = useGetOpenAIModelsQuery(undefined, {
enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "openai",
});
const { data: ollamaModelsData } = useGetOllamaModelsQuery(undefined, {
enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "ollama",
});
const { data: ollamaModelsData } = useGetOllamaModelsQuery(undefined, {
enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "ollama",
});
const { data: ibmModelsData } = useGetIBMModelsQuery(undefined, {
enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "watsonx",
});
const { data: ibmModelsData } = useGetIBMModelsQuery(undefined, {
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;
// Select the appropriate models data based on provider
const modelsData =
currentProvider === "openai"
? openaiModelsData
: currentProvider === "ollama"
? ollamaModelsData
: currentProvider === "watsonx"
? ibmModelsData
: openaiModelsData;
// Get embedding model from API settings
const apiEmbeddingModel =
apiSettings.knowledge?.embedding_model ||
modelsData?.embedding_models?.find((m) => m.default)?.value ||
"text-embedding-3-small";
// Get embedding model from API settings
const apiEmbeddingModel =
apiSettings.knowledge?.embedding_model ||
modelsData?.embedding_models?.find((m) => m.default)?.value ||
"text-embedding-3-small";
// Default settings - use API embedding model
const defaultSettings: IngestSettingsType = {
chunkSize: 1000,
chunkOverlap: 200,
ocr: false,
pictureDescriptions: false,
embeddingModel: apiEmbeddingModel,
};
// Default settings - use API embedding model
const defaultSettings: IngestSettingsType = {
chunkSize: 1000,
chunkOverlap: 200,
ocr: false,
pictureDescriptions: false,
embeddingModel: apiEmbeddingModel,
};
// Use provided settings or defaults
const currentSettings = settings || defaultSettings;
// Use provided settings or defaults
const currentSettings = settings || defaultSettings;
// Update settings when API embedding model changes
useEffect(() => {
if (apiEmbeddingModel && (!settings || settings.embeddingModel !== apiEmbeddingModel)) {
onSettingsChange?.({ ...currentSettings, embeddingModel: apiEmbeddingModel });
}
}, [apiEmbeddingModel]);
// Update settings when API embedding model changes
useEffect(() => {
if (
apiEmbeddingModel &&
(!settings || settings.embeddingModel !== apiEmbeddingModel)
) {
onSettingsChange?.({
...currentSettings,
embeddingModel: apiEmbeddingModel,
});
}
}, [apiEmbeddingModel, settings, onSettingsChange, currentSettings]);
const handleSettingsChange = (newSettings: Partial<IngestSettingsType>) => {
const updatedSettings = { ...currentSettings, ...newSettings };
onSettingsChange?.(updatedSettings);
};
const handleSettingsChange = (newSettings: Partial<IngestSettingsType>) => {
const updatedSettings = { ...currentSettings, ...newSettings };
onSettingsChange?.(updatedSettings);
};
return (
<Collapsible
open={isOpen}
onOpenChange={onOpenChange}
className="border rounded-xl p-4 border-border"
>
<CollapsibleTrigger className="flex items-center gap-2 justify-between w-full -m-4 p-4 rounded-md transition-colors">
<div className="flex items-center gap-2">
<ChevronRight
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isOpen ? "rotate-90" : ""
}`}
/>
<span className="text-sm font-medium">Ingest settings</span>
</div>
</CollapsibleTrigger>
return (
<Collapsible
open={isOpen}
onOpenChange={onOpenChange}
className="border rounded-xl p-4 border-border"
>
<CollapsibleTrigger className="flex items-center gap-2 justify-between w-full -m-4 p-4 rounded-md transition-colors">
<div className="flex items-center gap-2">
<ChevronRight
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isOpen ? "rotate-90" : ""
}`}
/>
<span className="text-sm font-medium">Ingest settings</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-up-2 data-[state=open]:slide-down-2">
<div className="mt-6">
{/* Embedding model selection */}
<LabelWrapper
helperText="Model used for knowledge ingest and retrieval"
id="embedding-model-select"
label="Embedding model"
>
<Select
disabled={false}
value={currentSettings.embeddingModel}
onValueChange={(value) => handleSettingsChange({ embeddingModel: value })}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger id="embedding-model-select">
<SelectValue placeholder="Select an embedding model" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
Choose the embedding model for this upload
</TooltipContent>
</Tooltip>
<SelectContent>
<ModelSelectItems
models={modelsData?.embedding_models}
fallbackModels={getFallbackModels(currentProvider).embedding}
provider={currentProvider}
/>
</SelectContent>
</Select>
</LabelWrapper>
</div>
<div className="mt-6">
<div className="flex items-center gap-4 w-full mb-6">
<div className="w-full">
<NumberInput
id="chunk-size"
label="Chunk size"
value={currentSettings.chunkSize}
onChange={(value) => handleSettingsChange({ chunkSize: value })}
unit="characters"
/>
</div>
<div className="w-full">
<NumberInput
id="chunk-overlap"
label="Chunk overlap"
value={currentSettings.chunkOverlap}
onChange={(value) =>
handleSettingsChange({ chunkOverlap: value })
}
unit="characters"
/>
</div>
</div>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-up-2 data-[state=open]:slide-down-2">
<div className="mt-6">
{/* Embedding model selection */}
<LabelWrapper
helperText="Model used for knowledge ingest and retrieval"
id="embedding-model-select"
label="Embedding model"
>
<Select
disabled={false}
value={currentSettings.embeddingModel}
onValueChange={(value) =>
handleSettingsChange({ embeddingModel: value })
}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger id="embedding-model-select">
<SelectValue placeholder="Select an embedding model" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
Choose the embedding model for this upload
</TooltipContent>
</Tooltip>
<SelectContent>
<ModelSelectItems
models={modelsData?.embedding_models}
fallbackModels={
getFallbackModels(currentProvider)
.embedding as ModelOption[]
}
provider={currentProvider}
/>
</SelectContent>
</Select>
</LabelWrapper>
</div>
<div className="mt-6">
<div className="flex items-center gap-4 w-full mb-6">
<div className="w-full">
<NumberInput
id="chunk-size"
label="Chunk size"
value={currentSettings.chunkSize}
onChange={(value) => handleSettingsChange({ chunkSize: value })}
unit="characters"
/>
</div>
<div className="w-full">
<NumberInput
id="chunk-overlap"
label="Chunk overlap"
value={currentSettings.chunkOverlap}
onChange={(value) =>
handleSettingsChange({ chunkOverlap: value })
}
unit="characters"
/>
</div>
</div>
{/* <div className="flex gap-2 items-center justify-between">
{/* <div className="flex gap-2 items-center justify-between">
<div>
<div className="text-sm font-semibold pb-2">Table Structure</div>
<div className="text-sm text-muted-foreground">
@ -199,39 +214,39 @@ export const IngestSettings = ({
/>
</div> */}
<div className="flex items-center justify-between border-b pb-3 mb-3">
<div>
<div className="text-sm font-semibold pb-2">OCR</div>
<div className="text-sm text-muted-foreground">
Extracts text from images/PDFs. Ingest is slower when enabled.
</div>
</div>
<Switch
checked={currentSettings.ocr}
onCheckedChange={(checked) =>
handleSettingsChange({ ocr: checked })
}
/>
</div>
<div className="flex items-center justify-between border-b pb-3 mb-3">
<div>
<div className="text-sm font-semibold pb-2">OCR</div>
<div className="text-sm text-muted-foreground">
Extracts text from images/PDFs. Ingest is slower when enabled.
</div>
</div>
<Switch
checked={currentSettings.ocr}
onCheckedChange={(checked) =>
handleSettingsChange({ ocr: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm pb-2 font-semibold">
Picture descriptions
</div>
<div className="text-sm text-muted-foreground">
Adds captions for images. Ingest is more expensive when enabled.
</div>
</div>
<Switch
checked={currentSettings.pictureDescriptions}
onCheckedChange={(checked) =>
handleSettingsChange({ pictureDescriptions: checked })
}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
<div className="flex items-center justify-between">
<div>
<div className="text-sm pb-2 font-semibold">
Picture descriptions
</div>
<div className="text-sm text-muted-foreground">
Adds captions for images. Ingest is more expensive when enabled.
</div>
</div>
<Switch
checked={currentSettings.pictureDescriptions}
onCheckedChange={(checked) =>
handleSettingsChange({ pictureDescriptions: checked })
}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openrag"
version = "0.1.32"
version = "0.1.33"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"

View file

@ -750,4 +750,4 @@ def get_agent_config():
def get_embedding_model() -> str:
"""Return the currently configured embedding model."""
return get_openrag_config().knowledge.embedding_model
return get_openrag_config().knowledge.embedding_model or EMBED_MODEL if DISABLE_INGEST_WITH_LANGFLOW else ""

View file

@ -1,7 +1,7 @@
import copy
from typing import Any, Dict
from agentd.tool_decorator import tool
from config.settings import clients, INDEX_NAME, get_embedding_model
from config.settings import EMBED_MODEL, clients, INDEX_NAME, get_embedding_model
from auth_context import get_auth_context
from utils.logging_config import get_logger
@ -34,8 +34,8 @@ class SearchService:
# Strategy: Use provided model, or default to the configured embedding
# model. This assumes documents are embedded with that model by default.
# Future enhancement: Could auto-detect available models in corpus
embedding_model = embedding_model or get_embedding_model()
# Future enhancement: Could auto-detect available models in corpus.
embedding_model = embedding_model or get_embedding_model() or EMBED_MODEL
embedding_field_name = get_embedding_field_name(embedding_model)
logger.info(

View file

@ -47,7 +47,8 @@ async def onboard_system():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
onboarding_payload = {
"model_provider": "openai",
"llm_provider": "openai",
"embedding_provider": "openai",
"embedding_model": "text-embedding-3-small",
"llm_model": "gpt-4o-mini",
"sample_data": False,

View file

@ -114,6 +114,8 @@ async def test_upload_and_search_endpoint(tmp_path: Path, disable_langflow_inges
# Ensure we route uploads to traditional processor and disable startup ingest
os.environ["DISABLE_INGEST_WITH_LANGFLOW"] = "true" if disable_langflow_ingest else "false"
os.environ["DISABLE_STARTUP_INGEST"] = "true"
os.environ["EMBEDDING_MODEL"] = "text-embedding-3-small"
os.environ["EMBEDDING_PROVIDER"] = "openai"
# Force no-auth mode so endpoints bypass authentication
os.environ["GOOGLE_OAUTH_CLIENT_ID"] = ""
os.environ["GOOGLE_OAUTH_CLIENT_SECRET"] = ""

2
uv.lock generated
View file

@ -2352,7 +2352,7 @@ wheels = [
[[package]]
name = "openrag"
version = "0.1.32"
version = "0.1.33"
source = { editable = "." }
dependencies = [
{ name = "agentd" },