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 { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; 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 { 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 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; }; const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => { const scene = getItemProperties(itemSchema).scene as | Record | undefined; const values = scene?.enum; return Array.isArray(values) ? values.filter((v): v is string => typeof v === "string") : []; }; 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 sceneOptions = useMemo(() => getSceneOptions(itemSchema), [itemSchema]); const SchemaField = registry.fields.SchemaField; const [openByIndex, setOpenByIndex] = useState>({}); // 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; }); }, [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 scene rather than index, which shifts when a model is // added or removed. const savedPlusForScene = useCallback( (scene: string | undefined) => savedModels?.find((saved) => saved.scene === scene)?.plus, [savedModels], ); // a model serves the cameras naming its scene, plus every camera that names // no scene at all when it is the "all" model const cameraCountForScene = useCallback( (scene: string | undefined): number => { if (!cameras) { return 0; } return Object.values(cameras).filter((camera) => { const cameraScene = camera?.detect?.scene; return cameraScene ? cameraScene === scene : scene === "all"; }).length; }, [cameras], ); 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 ?? String(currentIndex + 1); }); }); return claimed; }, [models], ); 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); const taken = new Set(models.map((model) => model.scene)); const scene = sceneOptions.find((option) => !taken.has(option)); onChange([...models, { ...base, scene, devices: [] }], fieldPathId.path); setOpenByIndex((previous) => ({ ...previous, [models.length]: true })); }, [models, itemSchema, sceneOptions, 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; }); }, [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 takenScenes = new Set( models .filter((_, currentIndex) => currentIndex !== index) .map((other) => other.scene), ); return ( setOpenByIndex((previous) => ({ ...previous, [index]: nextOpen, })) } >
{t(`detectionModels.scenes.${model.scene ?? "all"}`)} {summarizeDevices( hardware ?? [], model.devices ?? [], ) ?? t("detectionModels.hardware.none")} {" • "} {t("detectionModels.cameras", { count: cameraCountForScene(model.scene), })}
{models.length > 1 ? ( {t("button.delete", { ns: "common" })} ) : null}

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

updateModel(index, { devices })} /> updateModel(index, { path })} customFields={CUSTOM_MODEL_FIELDS.map((fieldName) => renderField(index, fieldName), )} />
); })} {models.length < sceneOptions.length ? ( ) : null}
); } export default ModelsField;