From 68de18f10dd2455f340b5f8cd8a44593679fd334 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:24:34 -0500 Subject: [PATCH] Settings UI tweaks (#22547) * 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 * tweak grouping to prioritize fieldOrder before groups previously, groups were always rendered first. now fieldOrder is respected, and any fields in a group will cause the group and all the fields in that group to be rendered in order. this allows moving the enabled switches to the top of the section * mobile tweaks stack buttons, add more space on profiles pane, and move the overridden badge beneath the description * language consistency * prevent camera config sections from being regenerated for profiles * conditionally import axengine module to match other detectors * i18n * update vscode launch.json for new integrated browser * formatting --- .vscode/launch.json | 17 ++ frigate/config/camera/detect.py | 4 +- frigate/config/camera/snapshots.py | 2 +- frigate/config/config.py | 2 +- frigate/detectors/plugins/axengine.py | 7 +- generate_config_translations.py | 9 + web/public/locales/en/config/cameras.json | 10 +- web/public/locales/en/config/global.json | 20 ++- web/public/locales/en/views/settings.json | 12 ++ .../config-form/section-configs/audio.ts | 2 +- .../config-form/section-configs/detect.ts | 2 +- .../section-configs/face_recognition.ts | 2 +- .../config-form/section-configs/genai.ts | 55 +++--- .../config-form/section-configs/lpr.ts | 4 +- .../config-form/section-configs/motion.ts | 2 +- .../config-form/section-configs/record.ts | 2 +- .../section-configs/semantic_search.ts | 5 + .../config-form/section-configs/snapshots.ts | 2 +- .../config-form/sections/BaseSection.tsx | 2 +- .../config-form/theme/frigateTheme.ts | 4 + .../theme/templates/ObjectFieldTemplate.tsx | 109 ++++++------ .../theme/widgets/GenAIRolesWidget.tsx | 109 ++++++++++++ .../widgets/SemanticSearchModelWidget.tsx | 159 ++++++++++++++++++ web/src/lib/config-schema/transformer.ts | 37 +++- web/src/views/settings/ProfilesView.tsx | 20 +-- web/src/views/settings/SingleSectionPage.tsx | 91 +++++++--- 26 files changed, 552 insertions(+), 138 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/.vscode/launch.json b/.vscode/launch.json index 5c858267d..2d7b6c8fb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,6 +6,23 @@ "type": "debugpy", "request": "launch", "module": "frigate" + }, + { + "type": "editor-browser", + "request": "launch", + "name": "Vite: Launch in integrated browser", + "url": "http://localhost:5173" + }, + { + "type": "editor-browser", + "request": "launch", + "name": "Nginx: Launch in integrated browser", + "url": "http://localhost:5000" + }, + { + "type": "editor-browser", + "request": "attach", + "name": "Attach to integrated browser" } ] } diff --git a/frigate/config/camera/detect.py b/frigate/config/camera/detect.py index 19ba670a6..c0a2e7036 100644 --- a/frigate/config/camera/detect.py +++ b/frigate/config/camera/detect.py @@ -49,8 +49,8 @@ class StationaryConfig(FrigateBaseModel): class DetectConfig(FrigateBaseModel): enabled: bool = Field( default=False, - title="Detection enabled", - description="Enable or disable object detection for all cameras; can be overridden per-camera. Detection must be enabled for object tracking to run.", + title="Enable object detection", + description="Enable or disable object detection for all cameras; can be overridden per-camera.", ) height: Optional[int] = Field( default=None, diff --git a/frigate/config/camera/snapshots.py b/frigate/config/camera/snapshots.py index c367aad8e..5a7f8480c 100644 --- a/frigate/config/camera/snapshots.py +++ b/frigate/config/camera/snapshots.py @@ -29,7 +29,7 @@ class RetainConfig(FrigateBaseModel): class SnapshotsConfig(FrigateBaseModel): enabled: bool = Field( default=False, - title="Snapshots enabled", + title="Enable snapshots", description="Enable or disable saving snapshots for all cameras; can be overridden per-camera.", ) clean_copy: bool = Field( diff --git a/frigate/config/config.py b/frigate/config/config.py index ea21fa831..699092d7d 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -444,7 +444,7 @@ class FrigateConfig(FrigateBaseModel): # GenAI config (named provider configs: name -> GenAIConfig) genai: Dict[str, GenAIConfig] = Field( default_factory=dict, - title="Generative AI configuration (named providers).", + title="Generative AI configuration", description="Settings for integrated generative AI providers used to generate object descriptions and review summaries.", ) diff --git a/frigate/detectors/plugins/axengine.py b/frigate/detectors/plugins/axengine.py index 1752188d9..383fcd0bf 100644 --- a/frigate/detectors/plugins/axengine.py +++ b/frigate/detectors/plugins/axengine.py @@ -4,7 +4,6 @@ import re import urllib.request from typing import Literal -import axengine as axe from pydantic import ConfigDict from frigate.const import MODEL_CACHE_DIR @@ -37,6 +36,12 @@ class Axengine(DetectionApi): type_key = DETECTOR_KEY def __init__(self, config: AxengineDetectorConfig): + try: + import axengine as axe + except ModuleNotFoundError: + raise ImportError("AXEngine is not installed.") + return + logger.info("__init__ axengine") super().__init__(config) self.height = config.model.height diff --git a/generate_config_translations.py b/generate_config_translations.py index f41957561..df6c18f99 100644 --- a/generate_config_translations.py +++ b/generate_config_translations.py @@ -518,6 +518,15 @@ def main(): sanitize_camera_descriptions(camera_translations) + # Profiles contain the same sections as the camera itself; only keep + # label and description to avoid duplicating every camera section. + if "profiles" in camera_translations: + camera_translations["profiles"] = { + k: v + for k, v in camera_translations["profiles"].items() + if k in ("label", "description") + } + with open(cameras_file, "w", encoding="utf-8") as f: json.dump(camera_translations, f, indent=2, ensure_ascii=False) f.write("\n") diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index 0ae231c37..f14599e14 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -79,8 +79,8 @@ "label": "Object Detection", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "enabled": { - "label": "Detection enabled", - "description": "Enable or disable object detection for this camera. Detection must be enabled for object tracking to run." + "label": "Enable object detection", + "description": "Enable or disable object detection for this camera." }, "height": { "label": "Detect height", @@ -628,7 +628,7 @@ "label": "Snapshots", "description": "Settings for saved JPEG snapshots of tracked objects for this camera.", "enabled": { - "label": "Snapshots enabled", + "label": "Enable snapshots", "description": "Enable or disable saving snapshots for this camera." }, "clean_copy": { @@ -860,6 +860,10 @@ "label": "Camera URL", "description": "URL to visit the camera directly from system page" }, + "profiles": { + "label": "Profiles", + "description": "Named config profiles with partial overrides that can be activated at runtime." + }, "zones": { "label": "Zones", "description": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.", diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index fdfc4b389..8e3439528 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -1174,7 +1174,7 @@ } }, "genai": { - "label": "Generative AI configuration (named providers).", + "label": "Generative AI configuration", "description": "Settings for integrated generative AI providers used to generate object descriptions and review summaries.", "api_key": { "label": "API key", @@ -1293,8 +1293,8 @@ "label": "Object Detection", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "enabled": { - "label": "Detection enabled", - "description": "Enable or disable object detection for all cameras; can be overridden per-camera. Detection must be enabled for object tracking to run." + "label": "Enable object detection", + "description": "Enable or disable object detection for all cameras; can be overridden per-camera." }, "height": { "label": "Detect height", @@ -1778,7 +1778,7 @@ "label": "Snapshots", "description": "Settings for saved JPEG snapshots of tracked objects for all cameras; can be overridden per-camera.", "enabled": { - "label": "Snapshots enabled", + "label": "Enable snapshots", "description": "Enable or disable saving snapshots for all cameras; can be overridden per-camera." }, "clean_copy": { @@ -2128,6 +2128,18 @@ "description": "Numeric order used to sort camera groups in the UI; larger numbers appear later." } }, + "profiles": { + "label": "Profiles", + "description": "Named profile definitions with friendly names. Camera profiles must reference names defined here.", + "friendly_name": { + "label": "Friendly name", + "description": "Display name for this profile shown in the UI." + } + }, + "active_profile": { + "label": "Active profile", + "description": "Currently active profile name. Runtime-only, not persisted in YAML." + }, "camera_mqtt": { "label": "MQTT", "description": "MQTT image publishing settings.", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index f93439244..0dd96acbb 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1402,6 +1402,18 @@ "audio": "Audio" } }, + "genaiRoles": { + "options": { + "embeddings": "Embedding", + "vision": "Vision", + "tools": "Tools" + } + }, + "semanticSearchModel": { + "placeholder": "Select model…", + "builtIn": "Built-in Models", + "genaiProviders": "GenAI Providers" + }, "review": { "title": "Review Settings" }, diff --git a/web/src/components/config-form/section-configs/audio.ts b/web/src/components/config-form/section-configs/audio.ts index 09fe4e974..740d76f78 100644 --- a/web/src/components/config-form/section-configs/audio.ts +++ b/web/src/components/config-form/section-configs/audio.ts @@ -13,7 +13,7 @@ const audio: SectionConfigOverrides = { "num_threads", ], fieldGroups: { - detection: ["enabled", "listen", "filters"], + detection: ["listen", "filters"], sensitivity: ["min_volume", "max_not_heard"], }, hiddenFields: ["enabled_in_config"], diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index ecf5102b4..778620f1c 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -18,7 +18,7 @@ const detect: SectionConfigOverrides = { ], restartRequired: [], fieldGroups: { - resolution: ["enabled", "width", "height", "fps"], + resolution: ["width", "height", "fps"], tracking: ["min_initialized", "max_disappeared"], }, hiddenFields: ["enabled_in_config"], diff --git a/web/src/components/config-form/section-configs/face_recognition.ts b/web/src/components/config-form/section-configs/face_recognition.ts index 18e963940..ef9e43506 100644 --- a/web/src/components/config-form/section-configs/face_recognition.ts +++ b/web/src/components/config-form/section-configs/face_recognition.ts @@ -6,7 +6,7 @@ const faceRecognition: SectionConfigOverrides = { restartRequired: [], fieldOrder: ["enabled", "min_area"], hiddenFields: [], - advancedFields: ["min_area"], + advancedFields: [], overrideFields: ["enabled", "min_area"], }, global: { diff --git a/web/src/components/config-form/section-configs/genai.ts b/web/src/components/config-form/section-configs/genai.ts index 739659496..e37478f11 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/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index c5e16eb23..514dba9be 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -7,9 +7,9 @@ const lpr: SectionConfigOverrides = { enhancement: "/configuration/license_plate_recognition#enhancement", }, restartRequired: [], - fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"], + fieldOrder: ["enabled", "min_area", "enhancement", "expire_time"], hiddenFields: [], - advancedFields: ["expire_time", "min_area", "enhancement"], + advancedFields: ["expire_time", "enhancement"], overrideFields: ["enabled", "min_area", "enhancement"], }, global: { diff --git a/web/src/components/config-form/section-configs/motion.ts b/web/src/components/config-form/section-configs/motion.ts index 38755ee20..4e9676bb2 100644 --- a/web/src/components/config-form/section-configs/motion.ts +++ b/web/src/components/config-form/section-configs/motion.ts @@ -23,7 +23,7 @@ const motion: SectionConfigOverrides = { "mqtt_off_delay", ], fieldGroups: { - sensitivity: ["enabled", "threshold", "contour_area"], + sensitivity: ["threshold", "contour_area"], algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"], }, uiSchema: { diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 53803eed9..9cfc92127 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -15,7 +15,7 @@ const record: SectionConfigOverrides = { "export", ], fieldGroups: { - retention: ["enabled", "continuous", "motion"], + retention: ["continuous", "motion"], events: ["alerts", "detections"], }, hiddenFields: ["enabled_in_config", "sync_recordings"], 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 2fea46782..34c1e149f 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/section-configs/snapshots.ts b/web/src/components/config-form/section-configs/snapshots.ts index 8f08fa843..126ecd496 100644 --- a/web/src/components/config-form/section-configs/snapshots.ts +++ b/web/src/components/config-form/section-configs/snapshots.ts @@ -13,7 +13,7 @@ const snapshots: SectionConfigOverrides = { "retain", ], fieldGroups: { - display: ["enabled", "bounding_box", "crop", "quality", "timestamp"], + display: ["bounding_box", "crop", "quality", "timestamp"], }, hiddenFields: ["enabled_in_config"], advancedFields: ["height", "quality", "retain"], diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index d2be6ded4..f171d9fe1 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -936,7 +936,7 @@ export function ConfigSection({ )} -
+
{((effectiveLevel === "camera" && isOverridden) || effectiveLevel === "global") && !hasChanges && diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 79bc14b84..5df8564f2 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/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 3ba4cb0bc..f028a566f 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -311,51 +311,54 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { return null; } - const grouped = new Set(); - const groups = Object.entries(groupDefinitions) - .map(([groupKey, fields]) => { - const ordered = fields - .map((field) => items.find((item) => item.name === field)) - .filter(Boolean) as (typeof properties)[number][]; + // Build a lookup: field name → group info + const fieldToGroup = new Map< + string, + { groupKey: string; label: string; items: (typeof properties)[number][] } + >(); + const hasGroups = Object.keys(groupDefinitions).length > 0; - if (ordered.length === 0) { - return null; - } + for (const [groupKey, fields] of Object.entries(groupDefinitions)) { + const ordered = fields + .map((field) => items.find((item) => item.name === field)) + .filter(Boolean) as (typeof properties)[number][]; - ordered.forEach((item) => grouped.add(item.name)); + if (ordered.length === 0) continue; - const label = domain - ? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, { - ns: "config/groups", - defaultValue: humanizeKey(groupKey), - }) - : t(`groups.${groupKey}`, { - defaultValue: humanizeKey(groupKey), - }); + const label = domain + ? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, { + ns: "config/groups", + defaultValue: humanizeKey(groupKey), + }) + : t(`groups.${groupKey}`, { + defaultValue: humanizeKey(groupKey), + }); - return { - key: groupKey, - label, - items: ordered, - }; - }) - .filter(Boolean) as Array<{ - key: string; - label: string; - items: (typeof properties)[number][]; - }>; + const groupInfo = { groupKey, label, items: ordered }; + for (const item of ordered) { + fieldToGroup.set(item.name, groupInfo); + } + } - const ungrouped = items.filter((item) => !grouped.has(item.name)); const isObjectLikeField = (item: (typeof properties)[number]) => { const fieldSchema = (item.content.props as RjsfElementProps)?.schema; return fieldSchema?.type === "object"; }; - return ( -
- {groups.map((group) => ( + // Walk items in order (respects fieldOrder / ui:order). + // When we hit the first field of a group, render the whole group block. + // Skip subsequent fields that belong to an already-rendered group. + const renderedGroups = new Set(); + const elements: React.ReactNode[] = []; + + for (const item of items) { + const group = fieldToGroup.get(item.name); + if (group) { + if (renderedGroups.has(group.groupKey)) continue; + renderedGroups.add(group.groupKey); + elements.push(
@@ -366,25 +369,21 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
{element.content}
))}
-
- ))} +
, + ); + } else { + elements.push( +
+ {item.content} +
, + ); + } + } - {ungrouped.length > 0 && ( -
0 && "pt-2")}> - {ungrouped.map((element) => ( -
0 && !isObjectLikeField(element) && "px-4", - )} - > - {element.content} -
- ))} -
- )} -
- ); + return
{elements}
; }; // Root level renders children directly @@ -456,7 +455,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
-
+
{isOpen ? ( - + ) : ( - + )}
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 000000000..92b265b7d --- /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 000000000..a2f35ec9f --- /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 b7c0e8c35..eb57d4c94 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,39 @@ 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 +175,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. diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx index 3c4436015..d40972816 100644 --- a/web/src/views/settings/ProfilesView.tsx +++ b/web/src/views/settings/ProfilesView.tsx @@ -385,7 +385,7 @@ export default function ProfilesView({ {/* Active Profile + Add Profile bar */} {(hasProfiles || profilesUIEnabled) && ( -
+
{hasProfiles && (
@@ -470,12 +470,12 @@ export default function ProfilesView({ )} > -
-
+
+
{isExpanded ? ( - + ) : ( - + )} - + {profileFriendlyNames?.get(profile) ?? profile} +
+
{isActive && ( )} -
-
{cameras.length > 0 ? t("profiles.cameraCount", { @@ -523,7 +523,7 @@ export default function ProfilesView({