From b4fb90bcbdfa3f611241b0202083f9841964a1dc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:52:41 -0500 Subject: [PATCH] fix genai settings ui - add roles widget to select roles for genai providers - add dropdown in semantic search to allow selection of embeddings genai provider --- .../config-form/section-configs/genai.ts | 55 +++--- .../section-configs/semantic_search.ts | 5 + .../config-form/theme/frigateTheme.ts | 4 + .../theme/widgets/GenAIRolesWidget.tsx | 109 ++++++++++++ .../widgets/SemanticSearchModelWidget.tsx | 159 ++++++++++++++++++ web/src/lib/config-schema/transformer.ts | 35 +++- 6 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 web/src/components/config-form/theme/widgets/GenAIRolesWidget.tsx create mode 100644 web/src/components/config-form/theme/widgets/SemanticSearchModelWidget.tsx diff --git a/web/src/components/config-form/section-configs/genai.ts b/web/src/components/config-form/section-configs/genai.ts index 7396594963..e37478f11e 100644 --- a/web/src/components/config-form/section-configs/genai.ts +++ b/web/src/components/config-form/section-configs/genai.ts @@ -4,39 +4,50 @@ const genai: SectionConfigOverrides = { base: { sectionDocs: "/configuration/genai/config", restartRequired: [ - "provider", - "api_key", - "base_url", - "model", - "provider_options", - "runtime_options", + "*.provider", + "*.api_key", + "*.base_url", + "*.model", + "*.provider_options", + "*.runtime_options", ], - fieldOrder: [ - "provider", - "api_key", - "base_url", - "model", - "provider_options", - "runtime_options", - ], - advancedFields: ["base_url", "provider_options", "runtime_options"], + advancedFields: ["*.base_url", "*.provider_options", "*.runtime_options"], hiddenFields: ["genai.enabled_in_config"], uiSchema: { - api_key: { - "ui:options": { size: "md" }, + "ui:options": { disableNestedCard: true }, + "*": { + "ui:options": { disableNestedCard: true }, + "ui:order": [ + "provider", + "api_key", + "base_url", + "model", + "provider_options", + "runtime_options", + "*", + ], }, - base_url: { + "*.roles": { + "ui:widget": "genaiRoles", + }, + "*.api_key": { "ui:options": { size: "lg" }, }, - model: { - "ui:options": { size: "md" }, + "*.base_url": { + "ui:options": { size: "lg" }, }, - provider_options: { + "*.model": { + "ui:options": { size: "xs" }, + }, + "*.provider": { + "ui:options": { size: "xs" }, + }, + "*.provider_options": { additionalProperties: { "ui:options": { size: "lg" }, }, }, - runtime_options: { + "*.runtime_options": { additionalProperties: { "ui:options": { size: "lg" }, }, diff --git a/web/src/components/config-form/section-configs/semantic_search.ts b/web/src/components/config-form/section-configs/semantic_search.ts index 2fea467826..34c1e149fa 100644 --- a/web/src/components/config-form/section-configs/semantic_search.ts +++ b/web/src/components/config-form/section-configs/semantic_search.ts @@ -18,6 +18,11 @@ const semanticSearch: SectionConfigOverrides = { advancedFields: ["reindex", "device"], restartRequired: ["enabled", "model", "model_size", "device"], hiddenFields: ["reindex"], + uiSchema: { + model: { + "ui:widget": "semanticSearchModel", + }, + }, }, }; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 79bc14b848..5df8564f26 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -23,10 +23,12 @@ import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget"; import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget"; import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget"; import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget"; +import { GenAIRolesWidget } from "./widgets/GenAIRolesWidget"; import { InputRolesWidget } from "./widgets/InputRolesWidget"; import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget"; import { CameraPathWidget } from "./widgets/CameraPathWidget"; import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget"; +import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget"; import { FieldTemplate } from "./templates/FieldTemplate"; import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate"; @@ -60,6 +62,7 @@ export const frigateTheme: FrigateTheme = { ArrayAsTextWidget: ArrayAsTextWidget, FfmpegArgsWidget: FfmpegArgsWidget, CameraPathWidget: CameraPathWidget, + genaiRoles: GenAIRolesWidget, inputRoles: InputRolesWidget, // Custom widgets switch: SwitchWidget, @@ -75,6 +78,7 @@ export const frigateTheme: FrigateTheme = { zoneNames: ZoneSwitchesWidget, timezoneSelect: TimezoneSelectWidget, optionalField: OptionalFieldWidget, + semanticSearchModel: SemanticSearchModelWidget, }, templates: { FieldTemplate: FieldTemplate as React.ComponentType, diff --git a/web/src/components/config-form/theme/widgets/GenAIRolesWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIRolesWidget.tsx new file mode 100644 index 0000000000..92b265b7d8 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/GenAIRolesWidget.tsx @@ -0,0 +1,109 @@ +import type { WidgetProps } from "@rjsf/utils"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Switch } from "@/components/ui/switch"; +import type { ConfigFormContext } from "@/types/configForm"; + +const GENAI_ROLES = ["embeddings", "vision", "tools"] as const; + +function normalizeValue(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string"); + } + + if (typeof value === "string" && value.trim()) { + return [value.trim()]; + } + + return []; +} + +function getProviderKey(widgetId: string): string | undefined { + const prefix = "root_"; + const suffix = "_roles"; + + if (!widgetId.startsWith(prefix) || !widgetId.endsWith(suffix)) { + return undefined; + } + + return widgetId.slice(prefix.length, -suffix.length) || undefined; +} + +export function GenAIRolesWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, registry } = props; + const { t } = useTranslation(["views/settings"]); + + const formContext = registry?.formContext as ConfigFormContext | undefined; + const selectedRoles = useMemo(() => normalizeValue(value), [value]); + const providerKey = useMemo(() => getProviderKey(id), [id]); + + // Compute occupied roles directly from formData. The computation is + // trivially cheap (iterate providers × 3 roles max) so we skip an + // intermediate memoization layer whose formData dependency would + // never produce a cache hit (new object reference on every change). + const occupiedRoles = useMemo(() => { + const occupied = new Set(); + const fd = formContext?.formData; + + if (!fd || typeof fd !== "object") return occupied; + + for (const [provider, config] of Object.entries( + fd as Record, + )) { + if (provider === providerKey) continue; + if (!config || typeof config !== "object" || Array.isArray(config)) + continue; + + for (const role of normalizeValue( + (config as Record).roles, + )) { + occupied.add(role); + } + } + + return occupied; + }, [formContext?.formData, providerKey]); + + const toggleRole = (role: string, enabled: boolean) => { + if (enabled) { + if (!selectedRoles.includes(role)) { + onChange([...selectedRoles, role]); + } + return; + } + + onChange(selectedRoles.filter((item) => item !== role)); + }; + + return ( +
+
+ {GENAI_ROLES.map((role) => { + const checked = selectedRoles.includes(role); + const roleDisabled = !checked && occupiedRoles.has(role); + const label = t(`configForm.genaiRoles.options.${role}`, { + ns: "views/settings", + defaultValue: role, + }); + + return ( +
+ + toggleRole(role, !!enabled)} + /> +
+ ); + })} +
+
+ ); +} diff --git a/web/src/components/config-form/theme/widgets/SemanticSearchModelWidget.tsx b/web/src/components/config-form/theme/widgets/SemanticSearchModelWidget.tsx new file mode 100644 index 0000000000..a2f35ec9f1 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/SemanticSearchModelWidget.tsx @@ -0,0 +1,159 @@ +// Combobox widget for semantic_search.model field. +// Shows built-in model enum values and GenAI providers with the embeddings role. +import { useState, useMemo } from "react"; +import type { WidgetProps } from "@rjsf/utils"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { ConfigFormContext } from "@/types/configForm"; +import { getSizedFieldClassName } from "../utils"; + +interface ProviderOption { + value: string; + label: string; +} + +export function SemanticSearchModelWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, schema, registry, options } = + props; + const { t } = useTranslation(["views/settings"]); + const [open, setOpen] = useState(false); + + const formContext = registry?.formContext as ConfigFormContext | undefined; + const fieldClassName = getSizedFieldClassName(options, "sm"); + + // Built-in model options from schema.examples (populated by transformer + // collapsing the anyOf enum+string union) + const builtInModels: ProviderOption[] = useMemo(() => { + const examples = (schema as Record).examples; + if (!Array.isArray(examples)) return []; + return examples + .filter((v): v is string => typeof v === "string") + .map((v) => ({ value: v, label: v })); + }, [schema]); + + // GenAI providers that have the "embeddings" role + const embeddingsProviders: ProviderOption[] = useMemo(() => { + const genai = ( + formContext?.fullConfig as Record | undefined + )?.genai; + if (!genai || typeof genai !== "object" || Array.isArray(genai)) return []; + + const providers: ProviderOption[] = []; + for (const [key, config] of Object.entries( + genai as Record, + )) { + if (!config || typeof config !== "object" || Array.isArray(config)) + continue; + const roles = (config as Record).roles; + if (Array.isArray(roles) && roles.includes("embeddings")) { + providers.push({ value: key, label: key }); + } + } + return providers; + }, [formContext?.fullConfig]); + + const currentLabel = + builtInModels.find((m) => m.value === value)?.label ?? + embeddingsProviders.find((p) => p.value === value)?.label ?? + (typeof value === "string" && value ? value : undefined); + + return ( + + + + + + + + {builtInModels.length > 0 && ( + + {builtInModels.map((model) => ( + { + onChange(model.value); + setOpen(false); + }} + > + + {model.label} + + ))} + + )} + {embeddingsProviders.length > 0 && ( + + {embeddingsProviders.map((provider) => ( + { + onChange(provider.value); + setOpen(false); + }} + > + + {provider.label} + + ))} + + )} + + + + + ); +} diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts index b7c0e8c35d..22b6e83c84 100644 --- a/web/src/lib/config-schema/transformer.ts +++ b/web/src/lib/config-schema/transformer.ts @@ -98,8 +98,8 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema { : ["null"]; const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj; const merged: Record = { - ...rest, ...normalizedNonNullObj, + ...rest, type: mergedType, }; // When unwrapping a nullable enum, add null to the enum list so @@ -110,6 +110,37 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema { return merged as RJSFSchema; } + // Handle anyOf where a plain string branch subsumes a string-enum branch + // (e.g. Union[StrEnum, str] or Union[StrEnum, str, None]). + // Collapse to a single string type with enum values preserved as `examples`. + const stringBranches = anyOf.filter( + (item) => + isSchemaObject(item) && + (item as Record).type === "string", + ); + const enumBranch = stringBranches.find((item) => + Array.isArray((item as Record).enum), + ); + const plainStringBranch = stringBranches.find( + (item) => !Array.isArray((item as Record).enum), + ); + + if ( + enumBranch && + plainStringBranch && + anyOf.length === stringBranches.length + (hasNull ? 1 : 0) + ) { + const enumValues = (enumBranch as Record).enum as + | unknown[] + | undefined; + const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj; + return { + ...rest, + type: hasNull ? ["string", "null"] : "string", + ...(enumValues && enumValues.length > 0 ? { examples: enumValues } : {}), + } as RJSFSchema; + } + return { ...schemaObj, anyOf: anyOf @@ -142,8 +173,8 @@ function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema { : ["null"]; const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj; const merged: Record = { - ...rest, ...normalizedNonNullObj, + ...rest, type: mergedType, }; // When unwrapping a nullable oneOf enum, add null to the enum list.