import type { ErrorSchema, FieldProps, RJSFSchema, UiSchema, } from "@rjsf/utils"; import { toFieldPathId } from "@rjsf/utils"; import { cloneDeep } from "lodash"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuChevronDown, LuChevronRight, LuPlus, LuTrash2, } from "react-icons/lu"; import { applySchemaDefaults } from "@/lib/config-schema"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { ConfigFormContext } from "@/types/configForm"; import useSWR from "swr"; import { DetectionHardware } from "@/types/hardware"; import { summarizeDevices } from "@/utils/detectionHardware"; import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil"; import { HardwarePicker } from "./HardwarePicker"; import { ModelSourcePicker } from "./ModelSourcePicker"; type DetectionModel = { scene?: string; devices?: string[]; [key: string]: unknown; }; // scene and devices get dedicated controls; everything else is the model itself const CUSTOM_MODEL_FIELDS = [ "path", "labelmap_path", "width", "height", "input_pixel_format", "input_tensor", "input_dtype", "model_type", ]; /** * The fields a model can edit. A Frigate+ model's size, format, type, and * labels come from its model info, so only its path stays editable. */ const editableFields = (model: DetectionModel): string[] => typeof model.path === "string" && model.path.startsWith("plus://") ? ["path"] : CUSTOM_MODEL_FIELDS; /** The detector a model runs on, which is the prefix of its device strings. */ const detectorForModel = (model: DetectionModel): string | undefined => model.devices?.[0]?.split(":")[0]; const asModelList = (formData: unknown): DetectionModel[] => { if (!Array.isArray(formData)) { return []; } return formData.filter( (item): item is DetectionModel => typeof item === "object" && item !== null, ); }; const getItemSchema = (schema: RJSFSchema): RJSFSchema | undefined => { const items = schema.items; if (!items || typeof items !== "object" || Array.isArray(items)) { return undefined; } return items as RJSFSchema; }; const getItemProperties = ( schema: RJSFSchema | undefined, ): Record => { if (!schema || typeof schema.properties !== "object" || !schema.properties) { return {}; } return schema.properties as Record; }; export function ModelsField(props: FieldProps) { const { schema, uiSchema, formData, onChange, fieldPathId, registry, idSchema, errorSchema, disabled, readonly, hideError, onBlur, onFocus, } = props; const { t } = useTranslation(["views/settings", "common"]); const formContext = registry?.formContext as ConfigFormContext | undefined; const models = useMemo(() => asModelList(formData), [formData]); const itemSchema = useMemo( () => getItemSchema(schema as RJSFSchema), [schema], ); const itemProperties = useMemo( () => getItemProperties(itemSchema), [itemSchema], ); const itemUiSchema = useMemo( () => ((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema, [uiSchema], ); const SchemaField = registry.fields.SchemaField; const [openByIndex, setOpenByIndex] = useState>({}); // scenes are edited in place, so cards need a key that survives a rename and // doesn't hand a deleted card's state (such as the model source tab) to the // next one const [cardKeys, setCardKeys] = useState(() => models.map((_, index) => index), ); // shared with HardwarePicker through the SWR cache, so this is not a second // request const { data: hardware } = useSWR("hardware/probe"); useEffect(() => { setOpenByIndex((previous) => { const next: Record = {}; for (let index = 0; index < models.length; index += 1) { next[index] = previous[index] ?? true; } return next; }); setCardKeys((previous) => { if (previous.length === models.length) { return previous; } const next = previous.slice(0, models.length); let key = Math.max(-1, ...previous); while (next.length < models.length) { key += 1; next.push(key); } return next; }); }, [models.length]); const cameras = formContext?.fullConfig?.cameras; const savedModels = formContext?.fullConfig?.models; // `plus` is a readonly field stripped from the form data, so read it from the // full config. Match on path rather than index, which shifts when a model is // added or removed, or scene, which can be renamed. const savedPlusForPath = useCallback( (path: unknown) => typeof path === "string" ? savedModels?.find((saved) => saved.path === path)?.plus : undefined, [savedModels], ); // a model serves the cameras naming its scene, and like the backend, the // default model also serves every camera whose scene has no model of its own const cameraCountForScene = useCallback( (scene: string | undefined): number => { if (!cameras) { return 0; } const modelScenes = new Set( models.map((model) => model.scene ?? DEFAULT_SCENE), ); return Object.values(cameras).filter((camera) => { const cameraScene = camera?.detect?.scene ?? DEFAULT_SCENE; const servedBy = modelScenes.has(cameraScene) ? cameraScene : DEFAULT_SCENE; return servedBy === (scene ?? DEFAULT_SCENE); }).length; }, [cameras, models], ); const claimedByOtherModels = useCallback( (index: number): Record => { const claimed: Record = {}; models.forEach((model, currentIndex) => { if (currentIndex === index) { return; } (model.devices ?? []).forEach((device) => { claimed[device] = model.scene ? getSceneLabel(t, model.scene) : String(currentIndex + 1); }); }); return claimed; }, [models, t], ); const updateModel = useCallback( (index: number, partial: Partial) => { const next = cloneDeep(models); next[index] = { ...next[index], ...partial }; onChange(next, fieldPathId.path); }, [models, onChange, fieldPathId.path], ); const handleAddModel = useCallback(() => { const base = itemSchema ? (applySchemaDefaults(itemSchema) as DetectionModel) : ({} as DetectionModel); // the default scene is almost always taken, so leave the new model's // scene for the user to name onChange( [...models, { ...base, scene: "", devices: [] }], fieldPathId.path, ); setOpenByIndex((previous) => ({ ...previous, [models.length]: true })); setCardKeys((previous) => [...previous, Math.max(-1, ...previous) + 1]); }, [models, itemSchema, onChange, fieldPathId.path]); const handleRemoveModel = useCallback( (index: number) => { onChange( models.filter((_, currentIndex) => currentIndex !== index), fieldPathId.path, ); setOpenByIndex((previous) => { const next: Record = {}; Object.entries(previous).forEach(([key, value]) => { const current = Number(key); if (Number.isNaN(current) || current === index) { return; } next[current > index ? current - 1 : current] = value; }); return next; }); setCardKeys((previous) => previous.filter((_, currentIndex) => currentIndex !== index), ); }, [models, onChange, fieldPathId.path], ); const renderField = useCallback( (index: number, fieldName: string) => { const fieldSchema = itemProperties[fieldName]; if (!SchemaField || !fieldSchema) { return null; } const itemFieldPathId = toFieldPathId( fieldName, registry.globalFormOptions, [...fieldPathId.path, index], ); const itemErrors = ( errorSchema as Record | undefined )?.[index] as Record | undefined; return ( )?.[fieldName]} errorSchema={itemErrors?.[fieldName]} onChange={(nextValue: unknown) => updateModel(index, { [fieldName]: nextValue }) } onBlur={onBlur} onFocus={onFocus} registry={registry} disabled={disabled} readonly={readonly} hideError={hideError} /> ); }, [ SchemaField, itemProperties, itemUiSchema, models, registry, fieldPathId.path, errorSchema, updateModel, onBlur, onFocus, disabled, readonly, hideError, ], ); const baseId = idSchema?.$id ?? "models"; return (
{models.map((model, index) => { const open = openByIndex[index] ?? true; const sceneErrors = ( (errorSchema as Record | undefined)?.[index] as Record | undefined )?.scene?.__errors; return ( setOpenByIndex((previous) => ({ ...previous, [index]: nextOpen, })) } >
{getSceneLabel(t, model.scene)} {summarizeDevices( hardware ?? [], model.devices ?? [], ) ?? t("detectionModels.hardware.none")} {" • "} {t("detectionModels.cameras", { count: cameraCountForScene(model.scene), })}
{models.length > 1 ? ( {t("button.delete", { ns: "common" })} ) : null}
updateModel(index, { scene: event.target.value.trim() }) } disabled={disabled || readonly} /> {!hideError && sceneErrors?.length ? (

{sceneErrors.join(", ")}

) : null}

{t("detectionModels.scene.description")}

updateModel(index, { devices })} /> updateModel(index, { path })} customFields={editableFields(model).map((fieldName) => renderField(index, fieldName), )} />
); })}
); } export default ModelsField;