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:
parent
a553fb7d9b
commit
1385fd5d5c
14 changed files with 1906 additions and 1798 deletions
|
|
@ -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,14 +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) => (
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.options.map((option) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
onSelect={(currentValue) => {
|
onSelect={(currentValue) => {
|
||||||
if (currentValue !== value) {
|
if (currentValue !== value) {
|
||||||
onValueChange(currentValue);
|
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
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
if (currentValue !== value) {
|
||||||
|
onValueChange(currentValue, option.provider);
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|
@ -133,17 +186,14 @@ export function ModelSelector({
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{option.label}
|
{option.label}
|
||||||
{/* {option.default && (
|
|
||||||
<span className="text-xs text-foreground p-1 rounded-md bg-muted"> // DISABLING DEFAULT TAG FOR NOW
|
|
||||||
Default
|
|
||||||
</span>
|
|
||||||
)} */}
|
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
{custom &&
|
{custom &&
|
||||||
searchValue &&
|
searchValue &&
|
||||||
!options.find((option) => option.value === searchValue) && (
|
!allOptions.find(
|
||||||
|
(option) => option.value === searchValue
|
||||||
|
) && (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onSelect={(currentValue) => {
|
onSelect={(currentValue) => {
|
||||||
|
|
@ -168,6 +218,7 @@ export function ModelSelector({
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)}
|
)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -14,7 +15,6 @@ import {
|
||||||
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,
|
||||||
|
|
@ -28,6 +28,8 @@ const AnthropicSettingsDialog = ({
|
||||||
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",
|
||||||
|
|
@ -36,24 +38,18 @@ const AnthropicSettingsDialog = ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
|
||||||
} = useGetAnthropicModelsQuery(
|
|
||||||
{
|
{
|
||||||
apiKey: debouncedApiKey,
|
apiKey: apiKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!debouncedApiKey && open,
|
enabled: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasValidationError = !!modelsError || !!formState.errors.apiKey;
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -64,12 +60,29 @@ const AnthropicSettingsDialog = ({
|
||||||
};
|
};
|
||||||
queryClient.setQueryData(["provider", "health"], healthData);
|
queryClient.setQueryData(["provider", "health"], healthData);
|
||||||
|
|
||||||
toast.success("Anthropic credentials saved. Configure models in the Settings page.");
|
toast.success(
|
||||||
|
"Anthropic credentials saved. Configure models in the Settings page."
|
||||||
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: AnthropicSettingsFormData) => {
|
const onSubmit = async (data: AnthropicSettingsFormData) => {
|
||||||
|
// Clear any previous validation errors
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
// Only validate if a new API key was entered
|
||||||
|
if (data.apiKey) {
|
||||||
|
setIsValidating(true);
|
||||||
|
const result = await validateCredentials();
|
||||||
|
setIsValidating(false);
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
setValidationError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload: {
|
const payload: {
|
||||||
anthropic_api_key?: string;
|
anthropic_api_key?: string;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
@ -98,8 +111,8 @@ const AnthropicSettingsDialog = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<AnthropicSettingsForm
|
<AnthropicSettingsForm
|
||||||
modelsError={modelsError}
|
modelsError={validationError}
|
||||||
isLoadingModels={isLoadingModels}
|
isLoadingModels={isValidating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|
@ -126,9 +139,13 @@ const AnthropicSettingsDialog = ({
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -14,7 +15,6 @@ import {
|
||||||
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,
|
||||||
|
|
@ -28,6 +28,8 @@ const OpenAISettingsDialog = ({
|
||||||
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",
|
||||||
|
|
@ -36,24 +38,18 @@ const OpenAISettingsDialog = ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
|
||||||
} = useGetOpenAIModelsQuery(
|
|
||||||
{
|
{
|
||||||
apiKey: debouncedApiKey,
|
apiKey: apiKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!debouncedApiKey && open,
|
enabled: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasValidationError = !!modelsError || !!formState.errors.apiKey;
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -64,12 +60,29 @@ const OpenAISettingsDialog = ({
|
||||||
};
|
};
|
||||||
queryClient.setQueryData(["provider", "health"], healthData);
|
queryClient.setQueryData(["provider", "health"], healthData);
|
||||||
|
|
||||||
toast.success("OpenAI credentials saved. Configure models in the Settings page.");
|
toast.success(
|
||||||
|
"OpenAI credentials saved. Configure models in the Settings page."
|
||||||
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: OpenAISettingsFormData) => {
|
const onSubmit = async (data: OpenAISettingsFormData) => {
|
||||||
|
// Clear any previous validation errors
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
// Only validate if a new API key was entered
|
||||||
|
if (data.apiKey) {
|
||||||
|
setIsValidating(true);
|
||||||
|
const result = await validateCredentials();
|
||||||
|
setIsValidating(false);
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
setValidationError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload: {
|
const payload: {
|
||||||
openai_api_key?: string;
|
openai_api_key?: string;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
@ -98,8 +111,8 @@ const OpenAISettingsDialog = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<OpenAISettingsForm
|
<OpenAISettingsForm
|
||||||
modelsError={modelsError}
|
modelsError={validationError}
|
||||||
isLoadingModels={isLoadingModels}
|
isLoadingModels={isValidating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|
@ -126,9 +139,13 @@ const OpenAISettingsDialog = ({
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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,8 +119,8 @@ const WatsonxSettingsDialog = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<WatsonxSettingsForm
|
<WatsonxSettingsForm
|
||||||
modelsError={modelsError}
|
modelsError={validationError}
|
||||||
isLoadingModels={isLoadingModels}
|
isLoadingModels={isValidating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,12 @@ 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 { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useGetOpenAIModelsQuery, useGetAnthropicModelsQuery, useGetOllamaModelsQuery, useGetIBMModelsQuery } from "@/app/api/queries/useGetModelsQuery";
|
import {
|
||||||
|
useGetOpenAIModelsQuery,
|
||||||
|
useGetAnthropicModelsQuery,
|
||||||
|
useGetOllamaModelsQuery,
|
||||||
|
useGetIBMModelsQuery,
|
||||||
|
} 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";
|
||||||
|
|
@ -116,90 +121,125 @@ function KnowledgeSourcesPage() {
|
||||||
enabled: isAuthenticated || isNoAuthMode,
|
enabled: isAuthenticated || isNoAuthMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the current providers from settings
|
|
||||||
const currentLlmProvider = (settings.agent?.llm_provider ||
|
|
||||||
"openai") as ModelProvider;
|
|
||||||
const currentEmbeddingProvider = (settings.knowledge?.embedding_provider ||
|
|
||||||
"openai") as ModelProvider;
|
|
||||||
|
|
||||||
// State for selected providers (for changing provider on the fly)
|
|
||||||
const [selectedLlmProvider, setSelectedLlmProvider] = useState<ModelProvider>(currentLlmProvider);
|
|
||||||
const [selectedEmbeddingProvider, setSelectedEmbeddingProvider] = useState<ModelProvider>(currentEmbeddingProvider);
|
|
||||||
|
|
||||||
// Sync state with settings when they change
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings.agent?.llm_provider) {
|
|
||||||
setSelectedLlmProvider(settings.agent.llm_provider as ModelProvider);
|
|
||||||
}
|
|
||||||
}, [settings.agent?.llm_provider]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings.knowledge?.embedding_provider) {
|
|
||||||
setSelectedEmbeddingProvider(settings.knowledge.embedding_provider as ModelProvider);
|
|
||||||
}
|
|
||||||
}, [settings.knowledge?.embedding_provider]);
|
|
||||||
|
|
||||||
// Fetch models for each provider
|
// Fetch models for each provider
|
||||||
const { data: openaiModels, isLoading: openaiLoading } = useGetOpenAIModelsQuery(
|
const { data: openaiModels, isLoading: openaiLoading } =
|
||||||
|
useGetOpenAIModelsQuery(
|
||||||
{ apiKey: "" },
|
{ apiKey: "" },
|
||||||
{ enabled: settings?.providers?.openai?.configured === true }
|
{ enabled: settings?.providers?.openai?.configured === true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: anthropicModels, isLoading: anthropicLoading } = useGetAnthropicModelsQuery(
|
const { data: anthropicModels, isLoading: anthropicLoading } =
|
||||||
|
useGetAnthropicModelsQuery(
|
||||||
{ apiKey: "" },
|
{ apiKey: "" },
|
||||||
{ enabled: settings?.providers?.anthropic?.configured === true }
|
{ enabled: settings?.providers?.anthropic?.configured === true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: ollamaModels, isLoading: ollamaLoading } = useGetOllamaModelsQuery(
|
const { data: ollamaModels, isLoading: ollamaLoading } =
|
||||||
|
useGetOllamaModelsQuery(
|
||||||
{ endpoint: settings?.providers?.ollama?.endpoint },
|
{ endpoint: settings?.providers?.ollama?.endpoint },
|
||||||
{ enabled: settings?.providers?.ollama?.configured === true && !!settings?.providers?.ollama?.endpoint }
|
{
|
||||||
|
enabled:
|
||||||
|
settings?.providers?.ollama?.configured === true &&
|
||||||
|
!!settings?.providers?.ollama?.endpoint,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: watsonxModels, isLoading: watsonxLoading } = useGetIBMModelsQuery(
|
const { data: watsonxModels, isLoading: watsonxLoading } =
|
||||||
|
useGetIBMModelsQuery(
|
||||||
{
|
{
|
||||||
endpoint: settings?.providers?.watsonx?.endpoint,
|
endpoint: settings?.providers?.watsonx?.endpoint,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
projectId: settings?.providers?.watsonx?.project_id,
|
projectId: settings?.providers?.watsonx?.project_id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: settings?.providers?.watsonx?.configured === true &&
|
enabled:
|
||||||
|
settings?.providers?.watsonx?.configured === true &&
|
||||||
!!settings?.providers?.watsonx?.endpoint &&
|
!!settings?.providers?.watsonx?.endpoint &&
|
||||||
!!settings?.providers?.watsonx?.project_id
|
!!settings?.providers?.watsonx?.project_id,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get models for selected LLM provider
|
// Build grouped LLM model options from all configured providers
|
||||||
const getModelsForProvider = (provider: ModelProvider) => {
|
const groupedLlmModels = [
|
||||||
switch (provider) {
|
{
|
||||||
case "openai":
|
group: "OpenAI",
|
||||||
return { data: openaiModels, isLoading: openaiLoading };
|
provider: "openai",
|
||||||
case "anthropic":
|
icon: getModelLogo("", "openai"),
|
||||||
return { data: anthropicModels, isLoading: anthropicLoading };
|
models: openaiModels?.language_models || [],
|
||||||
case "ollama":
|
configured: settings.providers?.openai?.configured === true,
|
||||||
return { data: ollamaModels, isLoading: ollamaLoading };
|
},
|
||||||
case "watsonx":
|
{
|
||||||
return { data: watsonxModels, isLoading: watsonxLoading };
|
group: "Anthropic",
|
||||||
default:
|
provider: "anthropic",
|
||||||
return { data: undefined, isLoading: false };
|
icon: getModelLogo("", "anthropic"),
|
||||||
}
|
models: anthropicModels?.language_models || [],
|
||||||
};
|
configured: settings.providers?.anthropic?.configured === true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Ollama",
|
||||||
|
provider: "ollama",
|
||||||
|
icon: getModelLogo("", "ollama"),
|
||||||
|
models: ollamaModels?.language_models || [],
|
||||||
|
configured: settings.providers?.ollama?.configured === true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "IBM watsonx.ai",
|
||||||
|
provider: "watsonx",
|
||||||
|
icon: getModelLogo("", "watsonx"),
|
||||||
|
models: watsonxModels?.language_models || [],
|
||||||
|
configured: settings.providers?.watsonx?.configured === true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.filter((provider) => provider.configured)
|
||||||
|
.map((provider) => ({
|
||||||
|
group: provider.group,
|
||||||
|
icon: provider.icon,
|
||||||
|
options: provider.models.map((model) => ({
|
||||||
|
...model,
|
||||||
|
provider: provider.provider,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.filter((provider) => provider.options.length > 0);
|
||||||
|
|
||||||
const llmModelsQuery = getModelsForProvider(selectedLlmProvider);
|
// Build grouped embedding model options from all configured providers (excluding Anthropic)
|
||||||
const embeddingModelsQuery = getModelsForProvider(selectedEmbeddingProvider);
|
const groupedEmbeddingModels = [
|
||||||
|
{
|
||||||
|
group: "OpenAI",
|
||||||
|
provider: "openai",
|
||||||
|
icon: getModelLogo("", "openai"),
|
||||||
|
models: openaiModels?.embedding_models || [],
|
||||||
|
configured: settings.providers?.openai?.configured === true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Ollama",
|
||||||
|
provider: "ollama",
|
||||||
|
icon: getModelLogo("", "ollama"),
|
||||||
|
models: ollamaModels?.embedding_models || [],
|
||||||
|
configured: settings.providers?.ollama?.configured === true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "IBM watsonx.ai",
|
||||||
|
provider: "watsonx",
|
||||||
|
icon: getModelLogo("", "watsonx"),
|
||||||
|
models: watsonxModels?.embedding_models || [],
|
||||||
|
configured: settings.providers?.watsonx?.configured === true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.filter((provider) => provider.configured)
|
||||||
|
.map((provider) => ({
|
||||||
|
group: provider.group,
|
||||||
|
icon: provider.icon,
|
||||||
|
options: provider.models.map((model) => ({
|
||||||
|
...model,
|
||||||
|
provider: provider.provider,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.filter((provider) => provider.options.length > 0);
|
||||||
|
|
||||||
// Filter provider options to only show configured ones
|
const isLoadingAnyLlmModels =
|
||||||
const configuredLlmProviders = [
|
openaiLoading || anthropicLoading || ollamaLoading || watsonxLoading;
|
||||||
{ value: "openai", label: "OpenAI", default: selectedLlmProvider === "openai" },
|
const isLoadingAnyEmbeddingModels =
|
||||||
{ value: "anthropic", label: "Anthropic", default: selectedLlmProvider === "anthropic" },
|
openaiLoading || ollamaLoading || watsonxLoading;
|
||||||
{ value: "ollama", label: "Ollama", default: selectedLlmProvider === "ollama" },
|
|
||||||
{ value: "watsonx", label: "IBM watsonx.ai", default: selectedLlmProvider === "watsonx" },
|
|
||||||
].filter((option) => settings.providers?.[option.value as ModelProvider]?.configured === true);
|
|
||||||
|
|
||||||
const configuredEmbeddingProviders = [
|
|
||||||
{ value: "openai", label: "OpenAI", default: selectedEmbeddingProvider === "openai" },
|
|
||||||
{ value: "ollama", label: "Ollama", default: selectedEmbeddingProvider === "ollama" },
|
|
||||||
{ value: "watsonx", label: "IBM watsonx.ai", default: selectedEmbeddingProvider === "watsonx" },
|
|
||||||
].filter((option) => settings.providers?.[option.value as ModelProvider]?.configured === true);
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateSettingsMutation = useUpdateSettingsMutation({
|
const updateSettingsMutation = useUpdateSettingsMutation({
|
||||||
|
|
@ -218,7 +258,7 @@ function KnowledgeSourcesPage() {
|
||||||
(variables: Parameters<typeof updateSettingsMutation.mutate>[0]) => {
|
(variables: Parameters<typeof updateSettingsMutation.mutate>[0]) => {
|
||||||
updateSettingsMutation.mutate(variables);
|
updateSettingsMutation.mutate(variables);
|
||||||
},
|
},
|
||||||
500,
|
500
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sync system prompt state with settings data
|
// Sync system prompt state with settings data
|
||||||
|
|
@ -260,28 +300,15 @@ function KnowledgeSourcesPage() {
|
||||||
}
|
}
|
||||||
}, [settings.knowledge?.picture_descriptions]);
|
}, [settings.knowledge?.picture_descriptions]);
|
||||||
|
|
||||||
// Update model selection immediately
|
// Update model selection immediately (also updates provider)
|
||||||
const handleModelChange = (newModel: string) => {
|
const handleModelChange = (newModel: string, provider?: string) => {
|
||||||
if (newModel) updateSettingsMutation.mutate({ llm_model: newModel });
|
if (newModel && provider) {
|
||||||
};
|
|
||||||
|
|
||||||
// Update LLM provider selection
|
|
||||||
const handleLlmProviderChange = (newProvider: string) => {
|
|
||||||
setSelectedLlmProvider(newProvider as ModelProvider);
|
|
||||||
|
|
||||||
// Get models for the new provider
|
|
||||||
const modelsForProvider = getModelsForProvider(newProvider as ModelProvider);
|
|
||||||
const models = modelsForProvider.data?.language_models;
|
|
||||||
|
|
||||||
// If models are available, select the first one along with the provider
|
|
||||||
if (models && models.length > 0 && models[0].value) {
|
|
||||||
updateSettingsMutation.mutate({
|
updateSettingsMutation.mutate({
|
||||||
llm_provider: newProvider,
|
llm_model: newModel,
|
||||||
llm_model: models[0].value
|
llm_provider: provider,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (newModel) {
|
||||||
// If models aren't loaded yet, just update the provider
|
updateSettingsMutation.mutate({ llm_model: newModel });
|
||||||
updateSettingsMutation.mutate({ llm_provider: newProvider });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -290,33 +317,18 @@ function KnowledgeSourcesPage() {
|
||||||
updateSettingsMutation.mutate({ system_prompt: systemPrompt });
|
updateSettingsMutation.mutate({ system_prompt: systemPrompt });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update embedding model selection immediately
|
// Update embedding model selection immediately (also updates provider)
|
||||||
const handleEmbeddingModelChange = (newModel: string) => {
|
const handleEmbeddingModelChange = (newModel: string, provider?: string) => {
|
||||||
if (newModel) updateSettingsMutation.mutate({ embedding_model: newModel });
|
if (newModel && provider) {
|
||||||
};
|
|
||||||
|
|
||||||
// Update embedding provider selection
|
|
||||||
const handleEmbeddingProviderChange = (newProvider: string) => {
|
|
||||||
setSelectedEmbeddingProvider(newProvider as ModelProvider);
|
|
||||||
|
|
||||||
// Get models for the new provider
|
|
||||||
const modelsForProvider = getModelsForProvider(newProvider as ModelProvider);
|
|
||||||
const models = modelsForProvider.data?.embedding_models;
|
|
||||||
|
|
||||||
// If models are available, select the first one along with the provider
|
|
||||||
if (models && models.length > 0 && models[0].value) {
|
|
||||||
updateSettingsMutation.mutate({
|
updateSettingsMutation.mutate({
|
||||||
embedding_provider: newProvider,
|
embedding_model: newModel,
|
||||||
embedding_model: models[0].value
|
embedding_provider: provider,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (newModel) {
|
||||||
// If models aren't loaded yet, just update the provider
|
updateSettingsMutation.mutate({ embedding_model: newModel });
|
||||||
updateSettingsMutation.mutate({ embedding_provider: newProvider });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmbeddingModelSelectDisabled = updateSettingsMutation.isPending;
|
|
||||||
|
|
||||||
// Update chunk size setting with debounce
|
// Update chunk size setting with debounce
|
||||||
const handleChunkSizeChange = (value: string) => {
|
const handleChunkSizeChange = (value: string) => {
|
||||||
const numValue = Math.max(0, parseInt(value) || 0);
|
const numValue = Math.max(0, parseInt(value) || 0);
|
||||||
|
|
@ -398,7 +410,7 @@ function KnowledgeSourcesPage() {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const connections = data.connections || [];
|
const connections = data.connections || [];
|
||||||
const activeConnection = connections.find(
|
const activeConnection = connections.find(
|
||||||
(conn: Connection) => conn.is_active,
|
(conn: Connection) => conn.is_active
|
||||||
);
|
);
|
||||||
const isConnected = activeConnection !== undefined;
|
const isConnected = activeConnection !== undefined;
|
||||||
|
|
||||||
|
|
@ -410,8 +422,8 @@ function KnowledgeSourcesPage() {
|
||||||
status: isConnected ? "connected" : "not_connected",
|
status: isConnected ? "connected" : "not_connected",
|
||||||
connectionId: activeConnection?.connection_id,
|
connectionId: activeConnection?.connection_id,
|
||||||
}
|
}
|
||||||
: c,
|
: c
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -454,7 +466,7 @@ function KnowledgeSourcesPage() {
|
||||||
`response_type=code&` +
|
`response_type=code&` +
|
||||||
`scope=${result.oauth_config.scopes.join(" ")}&` +
|
`scope=${result.oauth_config.scopes.join(" ")}&` +
|
||||||
`redirect_uri=${encodeURIComponent(
|
`redirect_uri=${encodeURIComponent(
|
||||||
result.oauth_config.redirect_uri,
|
result.oauth_config.redirect_uri
|
||||||
)}&` +
|
)}&` +
|
||||||
`access_type=offline&` +
|
`access_type=offline&` +
|
||||||
`prompt=consent&` +
|
`prompt=consent&` +
|
||||||
|
|
@ -593,7 +605,7 @@ function KnowledgeSourcesPage() {
|
||||||
|
|
||||||
const handleEditInLangflow = (
|
const handleEditInLangflow = (
|
||||||
flowType: "chat" | "ingest",
|
flowType: "chat" | "ingest",
|
||||||
closeDialog: () => void,
|
closeDialog: () => void
|
||||||
) => {
|
) => {
|
||||||
// Select the appropriate flow ID and edit URL based on flow type
|
// Select the appropriate flow ID and edit URL based on flow type
|
||||||
const targetFlowId =
|
const targetFlowId =
|
||||||
|
|
@ -968,22 +980,6 @@ function KnowledgeSourcesPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Language model provider"
|
|
||||||
helperText="Choose which provider to use for chat"
|
|
||||||
id="llm-provider"
|
|
||||||
required={true}
|
|
||||||
>
|
|
||||||
<ModelSelector
|
|
||||||
options={configuredLlmProviders}
|
|
||||||
noOptionsPlaceholder="No providers available"
|
|
||||||
icon={getModelLogo("", selectedLlmProvider)}
|
|
||||||
value={selectedLlmProvider}
|
|
||||||
onValueChange={handleLlmProviderChange}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<LabelWrapper
|
<LabelWrapper
|
||||||
label="Language model"
|
label="Language model"
|
||||||
|
|
@ -992,13 +988,12 @@ function KnowledgeSourcesPage() {
|
||||||
required={true}
|
required={true}
|
||||||
>
|
>
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
options={llmModelsQuery.data?.language_models || []}
|
groupedOptions={groupedLlmModels}
|
||||||
noOptionsPlaceholder={
|
noOptionsPlaceholder={
|
||||||
llmModelsQuery.isLoading
|
isLoadingAnyLlmModels
|
||||||
? "Loading models..."
|
? "Loading models..."
|
||||||
: "No language models detected. Configure this provider first."
|
: "No language models detected. Configure a provider first."
|
||||||
}
|
}
|
||||||
icon={getModelLogo("", selectedLlmProvider)}
|
|
||||||
value={settings.agent?.llm_model || ""}
|
value={settings.agent?.llm_model || ""}
|
||||||
onValueChange={handleModelChange}
|
onValueChange={handleModelChange}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1130,22 +1125,6 @@ function KnowledgeSourcesPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Embedding model provider"
|
|
||||||
helperText="Choose which provider to use for embeddings"
|
|
||||||
id="embedding-provider"
|
|
||||||
required={true}
|
|
||||||
>
|
|
||||||
<ModelSelector
|
|
||||||
options={configuredEmbeddingProviders}
|
|
||||||
noOptionsPlaceholder="No providers available"
|
|
||||||
icon={getModelLogo("", selectedEmbeddingProvider)}
|
|
||||||
value={selectedEmbeddingProvider}
|
|
||||||
onValueChange={handleEmbeddingProviderChange}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<LabelWrapper
|
<LabelWrapper
|
||||||
helperText="Model used for knowledge ingest and retrieval"
|
helperText="Model used for knowledge ingest and retrieval"
|
||||||
|
|
@ -1154,13 +1133,12 @@ function KnowledgeSourcesPage() {
|
||||||
required={true}
|
required={true}
|
||||||
>
|
>
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
options={embeddingModelsQuery.data?.embedding_models || []}
|
groupedOptions={groupedEmbeddingModels}
|
||||||
noOptionsPlaceholder={
|
noOptionsPlaceholder={
|
||||||
embeddingModelsQuery.isLoading
|
isLoadingAnyEmbeddingModels
|
||||||
? "Loading models..."
|
? "Loading models..."
|
||||||
: "No embedding models detected. Configure this provider first."
|
: "No embedding models detected. Configure a provider first."
|
||||||
}
|
}
|
||||||
icon={getModelLogo("", selectedEmbeddingProvider)}
|
|
||||||
value={settings.knowledge?.embedding_model || ""}
|
value={settings.knowledge?.embedding_model || ""}
|
||||||
onValueChange={handleEmbeddingModelChange}
|
onValueChange={handleEmbeddingModelChange}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1233,7 +1211,7 @@ function KnowledgeSourcesPage() {
|
||||||
size="iconSm"
|
size="iconSm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleChunkOverlapChange(
|
handleChunkOverlapChange(
|
||||||
(chunkOverlap + 1).toString(),
|
(chunkOverlap + 1).toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -1246,7 +1224,7 @@ function KnowledgeSourcesPage() {
|
||||||
size="iconSm"
|
size="iconSm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleChunkOverlapChange(
|
handleChunkOverlapChange(
|
||||||
(chunkOverlap - 1).toString(),
|
(chunkOverlap - 1).toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,40 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
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 {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { NumberInput } from "@/components/ui/inputs/number-input";
|
||||||
import { IngestSettings as IngestSettingsType } from "./types";
|
|
||||||
import { LabelWrapper } from "@/components/label-wrapper";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} 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 { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
|
||||||
import {
|
|
||||||
useGetOpenAIModelsQuery,
|
|
||||||
useGetOllamaModelsQuery,
|
|
||||||
useGetIBMModelsQuery,
|
|
||||||
} from "@/app/api/queries/useGetModelsQuery";
|
|
||||||
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;
|
||||||
|
|
@ -53,7 +57,7 @@ export const IngestSettings = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -99,10 +103,16 @@ export const IngestSettings = ({
|
||||||
|
|
||||||
// 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)
|
||||||
|
) {
|
||||||
|
onSettingsChange?.({
|
||||||
|
...currentSettings,
|
||||||
|
embeddingModel: apiEmbeddingModel,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [apiEmbeddingModel]);
|
}, [apiEmbeddingModel, settings, onSettingsChange, currentSettings]);
|
||||||
|
|
||||||
const handleSettingsChange = (newSettings: Partial<IngestSettingsType>) => {
|
const handleSettingsChange = (newSettings: Partial<IngestSettingsType>) => {
|
||||||
const updatedSettings = { ...currentSettings, ...newSettings };
|
const updatedSettings = { ...currentSettings, ...newSettings };
|
||||||
|
|
@ -137,7 +147,9 @@ export const IngestSettings = ({
|
||||||
<Select
|
<Select
|
||||||
disabled={false}
|
disabled={false}
|
||||||
value={currentSettings.embeddingModel}
|
value={currentSettings.embeddingModel}
|
||||||
onValueChange={(value) => handleSettingsChange({ embeddingModel: value })}
|
onValueChange={(value) =>
|
||||||
|
handleSettingsChange({ embeddingModel: value })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -152,7 +164,10 @@ export const IngestSettings = ({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<ModelSelectItems
|
<ModelSelectItems
|
||||||
models={modelsData?.embedding_models}
|
models={modelsData?.embedding_models}
|
||||||
fallbackModels={getFallbackModels(currentProvider).embedding}
|
fallbackModels={
|
||||||
|
getFallbackModels(currentProvider)
|
||||||
|
.embedding as ModelOption[]
|
||||||
|
}
|
||||||
provider={currentProvider}
|
provider={currentProvider}
|
||||||
/>
|
/>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue