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

View file

@ -15,7 +15,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [currentStep, setCurrentStep] = useState<number | null>(null); 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 // Query tasks to track completion
const { data: tasks } = useGetTasksQuery({ const { data: tasks } = useGetTasksQuery({

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -750,4 +750,4 @@ def get_agent_config():
def get_embedding_model() -> str: def get_embedding_model() -> str:
"""Return the currently configured embedding model.""" """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 import copy
from typing import Any, Dict from typing import Any, Dict
from agentd.tool_decorator import tool 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 auth_context import get_auth_context
from utils.logging_config import get_logger from utils.logging_config import get_logger
@ -34,8 +34,8 @@ class SearchService:
# Strategy: Use provided model, or default to the configured embedding # Strategy: Use provided model, or default to the configured embedding
# model. This assumes documents are embedded with that model by default. # model. This assumes documents are embedded with that model by default.
# Future enhancement: Could auto-detect available models in corpus # Future enhancement: Could auto-detect available models in corpus.
embedding_model = embedding_model or get_embedding_model() embedding_model = embedding_model or get_embedding_model() or EMBED_MODEL
embedding_field_name = get_embedding_field_name(embedding_model) embedding_field_name = get_embedding_field_name(embedding_model)
logger.info( logger.info(

View file

@ -47,7 +47,8 @@ async def onboard_system():
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
onboarding_payload = { onboarding_payload = {
"model_provider": "openai", "llm_provider": "openai",
"embedding_provider": "openai",
"embedding_model": "text-embedding-3-small", "embedding_model": "text-embedding-3-small",
"llm_model": "gpt-4o-mini", "llm_model": "gpt-4o-mini",
"sample_data": False, "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 # 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_INGEST_WITH_LANGFLOW"] = "true" if disable_langflow_ingest else "false"
os.environ["DISABLE_STARTUP_INGEST"] = "true" 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 # Force no-auth mode so endpoints bypass authentication
os.environ["GOOGLE_OAUTH_CLIENT_ID"] = "" os.environ["GOOGLE_OAUTH_CLIENT_ID"] = ""
os.environ["GOOGLE_OAUTH_CLIENT_SECRET"] = "" os.environ["GOOGLE_OAUTH_CLIENT_SECRET"] = ""

2
uv.lock generated
View file

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