mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-28 14:49:01 +03:00
Miscellaneous fixes (#23258)
* render orphaned filter entries as collapsibles instead of the Key/Value editor * Symlink for various AI files * change replay confg dialog to platform aware sheet * change agents title * fix test * tweak collapsible * remove camera ui section in settings no point to having it anymore with profiles and camera management settings * fix admin response cache leak to non-admin users via nginx proxy_cache * add model fetcher endpoint for genai config ui --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
co-authored by
Nicolas Mowen
parent
03f4f76b72
commit
8ea46e7c6c
@@ -4,8 +4,11 @@ import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { Check, ChevronsUpDown, Plus, RefreshCw } from "lucide-react";
|
||||
import { LuCheck } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -19,9 +22,17 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
type ProbeResponse =
|
||||
| { success: true; models: string[] }
|
||||
| { success: false; message: string };
|
||||
|
||||
type ProbeStatus = "idle" | "probing" | "success" | "error";
|
||||
|
||||
const PROBE_SUCCESS_INDICATOR_MS = 3000;
|
||||
|
||||
/**
|
||||
* Extract the provider config entry name from the RJSF widget id.
|
||||
* Widget ids look like "root_myProvider_model".
|
||||
@@ -41,6 +52,7 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, options, registry } = props;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
const providerKey = useMemo(() => getProviderKey(id), [id]);
|
||||
@@ -77,78 +89,261 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
}
|
||||
}, [configFingerprint, mutateModels]);
|
||||
|
||||
const models = useMemo(() => {
|
||||
const fetchedModels = useMemo(() => {
|
||||
if (!allModels || !providerKey) return [];
|
||||
return allModels[providerKey] ?? [];
|
||||
}, [allModels, providerKey]);
|
||||
|
||||
const [probeStatus, setProbeStatus] = useState<ProbeStatus>("idle");
|
||||
const [probeError, setProbeError] = useState<string | null>(null);
|
||||
const [probedModels, setProbedModels] = useState<string[] | null>(null);
|
||||
const probeSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const probing = probeStatus === "probing";
|
||||
|
||||
// Reset probe results if the provider entry name changes
|
||||
useEffect(() => {
|
||||
setProbedModels(null);
|
||||
setProbeError(null);
|
||||
setProbeStatus("idle");
|
||||
if (probeSuccessTimerRef.current) {
|
||||
clearTimeout(probeSuccessTimerRef.current);
|
||||
probeSuccessTimerRef.current = null;
|
||||
}
|
||||
}, [providerKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (probeSuccessTimerRef.current) {
|
||||
clearTimeout(probeSuccessTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const models = probedModels ?? fetchedModels;
|
||||
|
||||
const trimmedSearch = searchValue.trim();
|
||||
const matchesFetched = useMemo(
|
||||
() => models.some((m) => m.toLowerCase() === trimmedSearch.toLowerCase()),
|
||||
[models, trimmedSearch],
|
||||
);
|
||||
const showCustomOption = trimmedSearch.length > 0 && !matchesFetched;
|
||||
|
||||
// Read the live form values for this provider so probe sends the user's
|
||||
// in-flight edits, not the saved config (which may not exist yet).
|
||||
const formEntry = useMemo<JsonObject | null>(() => {
|
||||
if (!providerKey) return null;
|
||||
const formData = formContext?.formData as JsonObject | undefined;
|
||||
const entry = formData?.[providerKey];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return null;
|
||||
}
|
||||
return entry as JsonObject;
|
||||
}, [providerKey, formContext?.formData]);
|
||||
|
||||
const formProvider =
|
||||
typeof formEntry?.provider === "string" ? formEntry.provider : null;
|
||||
const canProbe = Boolean(formProvider) && !probing;
|
||||
|
||||
const probe = async () => {
|
||||
if (!formEntry || !formProvider) return;
|
||||
if (probeSuccessTimerRef.current) {
|
||||
clearTimeout(probeSuccessTimerRef.current);
|
||||
probeSuccessTimerRef.current = null;
|
||||
}
|
||||
setProbeStatus("probing");
|
||||
setProbeError(null);
|
||||
try {
|
||||
const res = await axios.post<ProbeResponse>("genai/probe", {
|
||||
provider: formProvider,
|
||||
api_key:
|
||||
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
|
||||
base_url:
|
||||
typeof formEntry.base_url === "string" ? formEntry.base_url : null,
|
||||
provider_options:
|
||||
formEntry.provider_options &&
|
||||
typeof formEntry.provider_options === "object" &&
|
||||
!Array.isArray(formEntry.provider_options)
|
||||
? (formEntry.provider_options as JsonObject)
|
||||
: {},
|
||||
});
|
||||
if (res.data.success) {
|
||||
setProbedModels(res.data.models);
|
||||
setProbeStatus("success");
|
||||
probeSuccessTimerRef.current = setTimeout(() => {
|
||||
setProbeStatus("idle");
|
||||
probeSuccessTimerRef.current = null;
|
||||
}, PROBE_SUCCESS_INDICATOR_MS);
|
||||
} else {
|
||||
setProbedModels([]);
|
||||
setProbeError(res.data.message);
|
||||
setProbeStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setProbedModels(null);
|
||||
setProbeError(
|
||||
t("configForm.genaiModel.probeFailed", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Failed to probe models",
|
||||
}),
|
||||
);
|
||||
setProbeStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const commit = (next: string) => {
|
||||
onChange(next);
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const currentLabel = typeof value === "string" && value ? value : undefined;
|
||||
|
||||
const refreshLabel = t("configForm.genaiModel.refresh", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Refresh models",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearchValue("");
|
||||
}}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select model…",
|
||||
})}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("configForm.genaiModel.search", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Search models…",
|
||||
})}
|
||||
/>
|
||||
<CommandList>
|
||||
{models.length > 0 ? (
|
||||
<CommandGroup>
|
||||
{models.map((model) => (
|
||||
<CommandItem
|
||||
key={model}
|
||||
value={model}
|
||||
onSelect={() => {
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === model ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t("configForm.genaiModel.noModels", {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No models available",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("configForm.genaiModel.search", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Search or enter a model…",
|
||||
})}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && showCustomOption) {
|
||||
e.preventDefault();
|
||||
commit(trimmedSearch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
{showCustomOption && (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={trimmedSearch}
|
||||
onSelect={() => commit(trimmedSearch)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{models.length > 0 ? (
|
||||
<CommandGroup
|
||||
heading={t("configForm.genaiModel.available", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Available models",
|
||||
})}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<CommandItem
|
||||
key={model}
|
||||
value={model}
|
||||
onSelect={() => commit(model)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === model ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : !showCustomOption ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t("configForm.genaiModel.noModels", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No models available",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
disabled={!canProbe || disabled || readonly}
|
||||
onClick={probe}
|
||||
title={refreshLabel}
|
||||
aria-label={refreshLabel}
|
||||
>
|
||||
{probing ? (
|
||||
<ActivityIndicator className="h-4 w-4" size={16} />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-1 text-xs transition-opacity duration-200",
|
||||
probeStatus === "idle" || probeStatus === "probing"
|
||||
? "opacity-0"
|
||||
: "opacity-100",
|
||||
)}
|
||||
>
|
||||
{probeStatus === "success" && (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<LuCheck className="size-3.5" />
|
||||
{t("configForm.genaiModel.fetchedModels", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Successfully fetched model list",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{probeStatus === "error" && probeError && (
|
||||
<span className="text-destructive">{probeError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user