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 (
+
+
+ {label}
+
+ 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 (
+
+
+
+ {currentLabel ??
+ t("configForm.semanticSearchModel.placeholder", {
+ ns: "views/settings",
+ defaultValue: "Select model…",
+ })}
+
+
+
+
+
+
+ {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}
{
e.stopPropagation();
setRenameProfile(profile);
@@ -500,6 +500,8 @@ export default function ProfilesView({
>
+
+
{isActive && (
)}
-
-
{cameras.length > 0
? t("profiles.cameraCount", {
@@ -523,7 +523,7 @@ export default function ProfilesView({
{
e.stopPropagation();
diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx
index ee87a9e76..d9c07917b 100644
--- a/web/src/views/settings/SingleSectionPage.tsx
+++ b/web/src/views/settings/SingleSectionPage.tsx
@@ -131,34 +131,35 @@ export function SingleSectionPage({
return (
-
-
-
- {t(`${sectionKey}.label`, { ns: sectionNamespace })}
-
- {i18n.exists(`${sectionKey}.description`, {
- ns: sectionNamespace,
- }) && (
-
- {t(`${sectionKey}.description`, { ns: sectionNamespace })}
-
- )}
- {sectionDocsUrl && (
-
-
- {t("readTheDocumentation", { ns: "common" })}
-
-
-
- )}
-
-
-
+
+
+
+
+ {t(`${sectionKey}.label`, { ns: sectionNamespace })}
+
+ {i18n.exists(`${sectionKey}.description`, {
+ ns: sectionNamespace,
+ }) && (
+
+ {t(`${sectionKey}.description`, { ns: sectionNamespace })}
+
+ )}
+ {sectionDocsUrl && (
+
+
+ {t("readTheDocumentation", { ns: "common" })}
+
+
+
+ )}
+
+ {/* Desktop: badge inline next to title */}
+
{level === "camera" &&
showOverrideIndicator &&
sectionStatus.isOverridden && (
@@ -211,6 +212,40 @@ export function SingleSectionPage({
)}
+ {/* Mobile: badge below title/description */}
+
+ {level === "camera" &&
+ showOverrideIndicator &&
+ sectionStatus.isOverridden && (
+
+ {sectionStatus.overrideSource === "profile"
+ ? t("button.overriddenBaseConfig", {
+ ns: "views/settings",
+ defaultValue: "Overridden (Base Config)",
+ })
+ : t("button.overriddenGlobal", {
+ ns: "views/settings",
+ defaultValue: "Overridden (Global)",
+ })}
+
+ )}
+ {sectionStatus.hasChanges && (
+
+ {t("modified", { ns: "common", defaultValue: "Modified" })}
+
+ )}
+