frigate/web/src/components/classification/wizard/Step1NameAndDefine.tsx
Nicolas Mowen 338b681ed0
Various Tweaks (#20742)
* Pull context size from openai models

* Adjust wording based on type of model

* Instruct to not use parenthesis

* Simplify genai config

* Don't use GPU for training
2025-10-31 12:40:31 -06:00

501 lines
18 KiB
TypeScript

import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { LuX, LuPlus, LuInfo, LuExternalLink } from "react-icons/lu";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { getTranslatedLabel } from "@/utils/i18n";
import { useDocDomain } from "@/hooks/use-doc-domain";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export type ModelType = "state" | "object";
export type ObjectClassificationType = "sub_label" | "attribute";
export type Step1FormData = {
modelName: string;
modelType: ModelType;
objectLabel?: string;
objectType?: ObjectClassificationType;
classes: string[];
};
type Step1NameAndDefineProps = {
initialData?: Partial<Step1FormData>;
defaultModelType?: "state" | "object";
onNext: (data: Step1FormData) => void;
onCancel: () => void;
};
export default function Step1NameAndDefine({
initialData,
defaultModelType,
onNext,
onCancel,
}: Step1NameAndDefineProps) {
const { t } = useTranslation(["views/classificationModel"]);
const { data: config } = useSWR<FrigateConfig>("config");
const { getLocaleDocUrl } = useDocDomain();
const objectLabels = useMemo(() => {
if (!config) return [];
const labels = new Set<string>();
Object.values(config.cameras).forEach((cameraConfig) => {
if (!cameraConfig.enabled || !cameraConfig.enabled_in_config) {
return;
}
cameraConfig.objects.track.forEach((label) => {
if (!config.model.all_attributes.includes(label)) {
labels.add(label);
}
});
});
return [...labels].sort();
}, [config]);
const step1FormData = z
.object({
modelName: z
.string()
.min(1, t("wizard.step1.errors.nameRequired"))
.max(64, t("wizard.step1.errors.nameLength"))
.refine((value) => !/^\d+$/.test(value), {
message: t("wizard.step1.errors.nameOnlyNumbers"),
}),
modelType: z.enum(["state", "object"]),
objectLabel: z.string().optional(),
objectType: z.enum(["sub_label", "attribute"]).optional(),
classes: z
.array(z.string())
.min(1, t("wizard.step1.errors.classRequired"))
.refine(
(classes) => {
const nonEmpty = classes.filter((c) => c.trim().length > 0);
return nonEmpty.length >= 1;
},
{ message: t("wizard.step1.errors.classRequired") },
)
.refine(
(classes) => {
const nonEmpty = classes.filter((c) => c.trim().length > 0);
const unique = new Set(nonEmpty.map((c) => c.toLowerCase()));
return unique.size === nonEmpty.length;
},
{ message: t("wizard.step1.errors.classesUnique") },
),
})
.refine(
(data) => {
// State models require at least 2 classes
if (data.modelType === "state") {
const nonEmpty = data.classes.filter((c) => c.trim().length > 0);
return nonEmpty.length >= 2;
}
return true;
},
{
message: t("wizard.step1.errors.stateRequiresTwoClasses"),
path: ["classes"],
},
)
.refine(
(data) => {
if (data.modelType === "object") {
return data.objectLabel !== undefined && data.objectLabel !== "";
}
return true;
},
{
message: t("wizard.step1.errors.objectLabelRequired"),
path: ["objectLabel"],
},
)
.refine(
(data) => {
if (data.modelType === "object") {
return data.objectType !== undefined;
}
return true;
},
{
message: t("wizard.step1.errors.objectTypeRequired"),
path: ["objectType"],
},
);
const form = useForm<z.infer<typeof step1FormData>>({
resolver: zodResolver(step1FormData),
defaultValues: {
modelName: initialData?.modelName || "",
modelType: initialData?.modelType || defaultModelType || "state",
objectLabel: initialData?.objectLabel,
objectType: initialData?.objectType || "sub_label",
classes: initialData?.classes?.length ? initialData.classes : [""],
},
mode: "onChange",
});
const watchedClasses = form.watch("classes");
const watchedModelType = form.watch("modelType");
const watchedObjectType = form.watch("objectType");
const handleAddClass = () => {
const currentClasses = form.getValues("classes");
form.setValue("classes", [...currentClasses, ""], { shouldValidate: true });
};
const handleRemoveClass = (index: number) => {
const currentClasses = form.getValues("classes");
const newClasses = currentClasses.filter((_, i) => i !== index);
// Ensure at least one field remains (even if empty)
if (newClasses.length === 0) {
form.setValue("classes", [""], { shouldValidate: true });
} else {
form.setValue("classes", newClasses, { shouldValidate: true });
}
};
const onSubmit = (data: z.infer<typeof step1FormData>) => {
// Filter out empty classes
const filteredClasses = data.classes.filter((c) => c.trim().length > 0);
onNext({
...data,
classes: filteredClasses,
});
};
return (
<div className="space-y-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="modelName"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("wizard.step1.name")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder={t("wizard.step1.namePlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="modelType"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("wizard.step1.type")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col gap-4 pt-2"
>
<div className="flex items-center gap-2">
<RadioGroupItem
className={
watchedModelType === "state"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id="state"
value="state"
/>
<Label className="cursor-pointer" htmlFor="state">
{t("wizard.step1.typeState")}
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
className={
watchedModelType === "object"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id="object"
value="object"
/>
<Label className="cursor-pointer" htmlFor="object">
{t("wizard.step1.typeObject")}
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{watchedModelType === "object" && (
<>
<FormField
control={form.control}
name="objectLabel"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("wizard.step1.objectLabel")}
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="h-8">
<SelectValue
placeholder={t(
"wizard.step1.objectLabelPlaceholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{objectLabels.map((label) => (
<SelectItem
key={label}
value={label}
className="cursor-pointer hover:bg-secondary-highlight"
>
{getTranslatedLabel(label)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="objectType"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1">
<FormLabel className="text-primary-variant">
{t("wizard.step1.classificationType")}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0"
>
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="flex flex-col gap-2">
<div className="text-sm">
{t("wizard.step1.classificationTypeDesc")}
</div>
<div className="mt-3 flex items-center text-primary">
<a
href={getLocaleDocUrl(
"configuration/custom_classification/object_classification#classification-type",
)}
target="_blank"
rel="noopener noreferrer"
className="inline cursor-pointer"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</a>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col gap-4 pt-2"
>
<div className="flex items-center gap-2">
<RadioGroupItem
className={
watchedObjectType === "sub_label"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id="sub_label"
value="sub_label"
/>
<Label className="cursor-pointer" htmlFor="sub_label">
{t("wizard.step1.classificationSubLabel")}
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
className={
watchedObjectType === "attribute"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id="attribute"
value="attribute"
/>
<Label className="cursor-pointer" htmlFor="attribute">
{t("wizard.step1.classificationAttribute")}
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<FormLabel className="text-primary-variant">
{watchedModelType === "state"
? t("wizard.step1.states")
: t("wizard.step1.classes")}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="flex flex-col gap-2">
<div className="text-sm">
{watchedModelType === "state"
? t("wizard.step1.classesStateDesc")
: t("wizard.step1.classesObjectDesc")}
</div>
<div className="mt-3 flex items-center text-primary">
<a
href={getLocaleDocUrl(
watchedModelType === "state"
? "configuration/custom_classification/state_classification"
: "configuration/custom_classification/object_classification",
)}
target="_blank"
rel="noopener noreferrer"
className="inline cursor-pointer"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</a>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
onClick={handleAddClass}
>
<LuPlus />
</Button>
</div>
<div className="space-y-2">
{watchedClasses.map((_, index) => (
<FormField
key={index}
control={form.control}
name={`classes.${index}`}
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Input
className="text-md h-8"
placeholder={t("wizard.step1.classPlaceholder")}
{...field}
/>
{watchedClasses.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveClass(index)}
>
<LuX className="size-4" />
</Button>
)}
</div>
</FormControl>
</FormItem>
)}
/>
))}
</div>
{form.formState.errors.classes && (
<p className="text-sm font-medium text-destructive">
{form.formState.errors.classes.message}
</p>
)}
</div>
</form>
</Form>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onCancel} className="sm:flex-1">
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
disabled={!form.formState.isValid}
>
{t("button.continue", { ns: "common" })}
</Button>
</div>
</div>
);
}