Files
frigate/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx
T
d02a1156b7
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
Miscellaneous fixes (0.18 beta) (#23736)
* Catch faces that become empty after cropping

* don't drop batched camera add/remove config updates

TrackedObjectProcessor drained all pending camera config updates at once but handled them in a mutually exclusive if/elif on enabled/add/remove, so only one topic was processed per drain. When an add arrived in the same batch as an enabled update, the add was skipped and the new camera never got a camera state. Adding a camera reliably produced that batch: config_set now re-applies runtime overrides, which republishes an enabled update for every previously toggled camera immediately before the add, in the same request. The dashboard and camera capture still saw the camera (the maintainer does not subscribe to enabled, so it got a clean add-only batch), but object_processing did not, and disabling the camera then crashed with a KeyError on the unguarded camera_states lookup.

Handle add and remove independently instead of as exclusive branches so a batched add is no longer dropped, and guard the remove lookup so a missing state is skipped rather than raising. Drop the enabled branch entirely: it only ever set prev_enabled when it was None, but prev_enabled is seeded to a bool at camera state creation and is never None (mypy flags the body as unreachable), and the actual enable/disable transition is already driven by the disabled-state loop from config.enabled.

* Don't stay on motion search page when user cancels flow

* fix notification test button being blocked by websocket auth

* fix overflowing model names in settings genai widget

* add note about auth debugging

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-07-17 08:00:15 -06:00

356 lines
12 KiB
TypeScript

// Combobox widget for genai *.model fields.
// Fetches available models from the provider's backend and shows them in a dropdown.
import { useState, useMemo, useEffect, useRef } from "react";
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
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,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ConfigFormContext, JsonObject } from "@/types/configForm";
import type { GenAIModelsResponse } from "@/types/chat";
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".
*/
function getProviderKey(widgetId: string): string | undefined {
const prefix = "root_";
const suffix = "_model";
if (!widgetId.startsWith(prefix) || !widgetId.endsWith(suffix)) {
return undefined;
}
return widgetId.slice(prefix.length, -suffix.length) || undefined;
}
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]);
const formContext = registry?.formContext as ConfigFormContext | undefined;
// Build a fingerprint from the saved config's provider + base_url so the
// SWR key changes (and models are refetched) whenever those fields are saved.
const configFingerprint = useMemo(() => {
if (!providerKey) return "";
const genai = (
formContext?.fullConfig as Record<string, unknown> | undefined
)?.genai;
if (!genai || typeof genai !== "object" || Array.isArray(genai)) return "";
const entry = (genai as Record<string, unknown>)[providerKey];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "";
const e = entry as Record<string, unknown>;
return `${e.provider ?? ""}|${e.base_url ?? ""}`;
}, [providerKey, formContext?.fullConfig]);
const { data: allModels, mutate: mutateModels } = useSWR<GenAIModelsResponse>(
"genai/models",
{
revalidateOnFocus: false,
},
);
// Revalidate models when the saved config fingerprint changes (e.g. after
// switching provider or base_url and saving).
const prevFingerprint = useRef(configFingerprint);
useEffect(() => {
if (configFingerprint !== prevFingerprint.current) {
prevFingerprint.current = configFingerprint;
mutateModels();
}
}, [configFingerprint, mutateModels]);
const fetchedModels = useMemo<string[]>(() => {
if (!allModels || !providerKey) return [];
return allModels[providerKey]?.models ?? [];
}, [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 (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"min-w-0 justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
<span className="truncate">
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "Select or enter a model…",
})}
</span>
<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 shrink-0" />
<span className="truncate">
{t("configForm.genaiModel.useCustom", {
ns: "views/settings",
value: trimmedSearch,
defaultValue: 'Use "{{value}}"',
})}
</span>
</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 shrink-0",
value === model ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{model}</span>
</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>
);
}