import { ReactNode, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import useSWR from "swr"; import axios from "axios"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { FrigateConfig } from "@/types/frigateConfig"; export type FrigatePlusModel = { id: string; name: string; baseModel: string; trainDate: string; isBaseModel: boolean; supportedDetectors: string[]; width: number; height: number; }; const PLUS_PREFIX = "plus://"; /** The Frigate+ model id a path refers to, if it is a Frigate+ path at all. */ function plusModelId(path: unknown): string | undefined { return typeof path === "string" && path.startsWith(PLUS_PREFIX) ? path.slice(PLUS_PREFIX.length) : undefined; } type ModelSourcePickerProps = { path: unknown; // Frigate+ metadata the backend attaches to a saved model, and the only // reliable signal that one is active: it resolves `plus://` to a local // cache path before serving the config back plus?: { id: string } | null; // the detector this model runs on, used to filter incompatible Plus models detector?: string; disabled?: boolean; onPathChange: (path: string | undefined) => void; // the schema-driven fields for a custom model customFields: ReactNode; }; export function ModelSourcePicker({ path, plus, detector, disabled, onPathChange, customFields, }: ModelSourcePickerProps) { const { t } = useTranslation(["views/settings"]); const { data: config } = useSWR("config"); const plusEnabled = Boolean(config?.plus?.enabled); // an unsaved pick still carries the plus:// path, which wins over the // metadata of whatever model was saved before it const selectedId = plusModelId(path) ?? plus?.id; const { data: availableModels, isLoading } = useSWR< Record >(plusEnabled ? "/plus/models" : null, { fetcher: async (url) => { const res = await axios.get(url, { withCredentials: true }); return res.data.reduce( (obj: Record, model: FrigatePlusModel) => { obj[model.id] = model; return obj; }, {}, ); }, }); const entries = useMemo( () => Object.entries(availableModels ?? {}), [availableModels], ); // the tab cannot be derived from the path alone: switching to Frigate+ // leaves the path untouched until a model is picked const [tab, setTab] = useState<"plus" | "custom">( selectedId ? "plus" : "custom", ); const handleTabChange = (value: string) => { setTab(value as "plus" | "custom"); // a resolved Frigate+ path is meaningless as a custom path, so drop it if (value === "custom" && selectedId) { onPathChange(undefined); } }; const isCompatible = (model: FrigatePlusModel) => !detector || model.supportedDetectors.includes(detector); if (!plusEnabled) { return
{customFields}
; } const describe = (model: FrigatePlusModel) => `${new Date(model.trainDate).toLocaleString()} ${model.baseModel} (${ model.isBaseModel ? t("frigatePlus.modelInfo.plusModelType.baseModel") : t("frigatePlus.modelInfo.plusModelType.userModel") }) ${model.name} (${model.width}x${model.height})`; return ( {t("detectionModels.tabs.plus")} {t("detectionModels.tabs.custom")}

frigatePlus.modelInfo.modelSelect

{customFields}
); } export default ModelSourcePicker;