diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py index de45deadb..24accdc02 100644 --- a/frigate/genai/llama_cpp.py +++ b/frigate/genai/llama_cpp.py @@ -18,6 +18,17 @@ from frigate.genai.utils import parse_tool_calls_from_message logger = logging.getLogger(__name__) +def _parse_launch_arg(args: list[str], flag: str) -> str | None: + """Return the value following `flag` in a positional argv list, or None.""" + try: + idx = args.index(flag) + except ValueError: + return None + if idx + 1 >= len(args): + return None + return args[idx + 1] + + def _to_jpeg(img_bytes: bytes) -> bytes | None: """Convert image bytes to JPEG. llama.cpp/STB does not support WebP.""" try: @@ -71,26 +82,69 @@ class LlamaCppClient(GenAIClient): base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url configured_model = self.genai_config.model + info = self._get_model_info(base_url, configured_model) - # Query /v1/models to validate the configured model exists + if info is None: + return None + + self._context_size = info["context_size"] + self._supports_vision = info["supports_vision"] + self._supports_audio = info["supports_audio"] + self._supports_tools = info["supports_tools"] + self._media_marker = info["media_marker"] + + logger.info( + "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s", + configured_model, + self._context_size or "unknown", + self._supports_vision, + self._supports_audio, + self._supports_tools, + ) + + return base_url + + def _get_model_info( + self, base_url: str, configured_model: str + ) -> dict[str, Any] | None: + """Resolve model metadata from /v1/models with /props fallback. + + Returns a dict of capability fields, or None if the server's model + registry was reachable and reported the configured model as missing. + A reachable-but-unparseable /v1/models is treated as soft-pass and + falls through to /props, matching prior behavior. + + After ggml-org/llama.cpp#22952, /v1/models exposes per-model + `architecture.input_modalities` (text/image/audio) — the primary + source. When proxied through llama-swap, the same entry carries + `status.args` (server launch argv) and, for the loaded model, + `meta.n_ctx`. /props remains the only source for `media_marker`, + which the server randomizes per startup unless LLAMA_MEDIA_MARKER + is set. + """ + info: dict[str, Any] = { + "context_size": None, + "supports_vision": False, + "supports_audio": False, + "supports_tools": False, + "media_marker": "<__media__>", + } + + model_entry: dict[str, Any] | None = None try: - response = requests.get( - f"{base_url}/v1/models", - timeout=10, - ) + response = requests.get(f"{base_url}/v1/models", timeout=10) response.raise_for_status() models_data = response.json() - model_found = False for model in models_data.get("data", []): model_ids = {model.get("id")} for alias in model.get("aliases", []): model_ids.add(alias) if configured_model in model_ids: - model_found = True + model_entry = model break - if not model_found: + if model_entry is None: available = [] for m in models_data.get("data", []): available.append(m.get("id", "unknown")) @@ -109,10 +163,35 @@ class LlamaCppClient(GenAIClient): e, ) - # Query /props for context size, modalities, and tool support. - # The standard /props?model= endpoint works with llama-server. - # If it fails, try the llama-swap per-model passthrough endpoint which - # returns props for a specific model without requiring it to be loaded. + if model_entry is not None: + architecture = model_entry.get("architecture") or {} + input_modalities = architecture.get("input_modalities") or [] + + if isinstance(input_modalities, list): + info["supports_vision"] = "image" in input_modalities + info["supports_audio"] = "audio" in input_modalities + + status = model_entry.get("status") or {} + launch_args = status.get("args") if isinstance(status, dict) else None + if not isinstance(launch_args, list): + launch_args = [] + + meta = model_entry.get("meta") if isinstance(model_entry, dict) else None + n_ctx = meta.get("n_ctx") if isinstance(meta, dict) else None + + if not n_ctx: + n_ctx = _parse_launch_arg(launch_args, "--ctx-size") + + if n_ctx: + try: + info["context_size"] = int(n_ctx) + except (TypeError, ValueError): + pass + + # Tool calling on llama-server requires --jinja. + if "--jinja" in launch_args: + info["supports_tools"] = True + try: try: response = requests.get( @@ -130,44 +209,32 @@ class LlamaCppClient(GenAIClient): response.raise_for_status() props = response.json() - # Context size from server runtime config - default_settings = props.get("default_generation_settings", {}) - n_ctx = default_settings.get("n_ctx") - if n_ctx: - self._context_size = int(n_ctx) + if info["context_size"] is None: + default_settings = props.get("default_generation_settings", {}) + n_ctx = default_settings.get("n_ctx") + if n_ctx: + info["context_size"] = int(n_ctx) - # Modalities (vision, audio) - modalities = props.get("modalities", {}) - self._supports_vision = modalities.get("vision", False) - self._supports_audio = modalities.get("audio", False) + if not (info["supports_vision"] or info["supports_audio"]): + modalities = props.get("modalities", {}) + info["supports_vision"] = bool(modalities.get("vision", False)) + info["supports_audio"] = bool(modalities.get("audio", False)) - # Tool support from chat template capabilities - chat_caps = props.get("chat_template_caps", {}) - self._supports_tools = chat_caps.get("supports_tools", False) + if not info["supports_tools"]: + chat_caps = props.get("chat_template_caps", {}) + info["supports_tools"] = bool(chat_caps.get("supports_tools", False)) - # Media marker for multimodal embeddings; the server randomizes this - # per startup unless LLAMA_MEDIA_MARKER is set, so we must read it - # from /props rather than hardcoding "<__media__>". media_marker = props.get("media_marker") if isinstance(media_marker, str) and media_marker: - self._media_marker = media_marker - - logger.info( - "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s", - configured_model, - self._context_size or "unknown", - self._supports_vision, - self._supports_audio, - self._supports_tools, - ) + info["media_marker"] = media_marker except Exception as e: logger.warning( "Failed to query llama.cpp /props endpoint: %s. " - "Using defaults for context size and capabilities.", + "Image embeddings may fail if the server randomized its media marker.", e, ) - return base_url + return info def _send( self, diff --git a/web/public/locales/en/objects.json b/web/public/locales/en/objects.json index fc02ed0f5..d48f609a1 100644 --- a/web/public/locales/en/objects.json +++ b/web/public/locales/en/objects.json @@ -125,5 +125,5 @@ "baby": "Baby", "baby_stroller": "Baby Stroller", "rickshaw": "Rickshaw", - "Rodent": "Rodent" -} \ No newline at end of file + "rodent": "Rodent" +} diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 756e2ddb3..0f32d7e65 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -19,8 +19,14 @@ "button": { "overriddenGlobal": "Overridden (Global)", "overriddenGlobalTooltip": "This camera overrides global configuration settings in this section", + "overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:", + "overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:", + "overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.", "overriddenBaseConfig": "Overridden (Base Config)", "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section", + "overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:", + "overriddenBaseConfigHeading_other": "The {{profile}} profile overrides {{count}} fields from the base config:", + "overriddenBaseConfigNoDeltas": "The {{profile}} profile overrides this section, but no field values differ from the base config.", "overriddenInCameras": { "label_one": "Overridden in {{count}} camera", "label_other": "Overridden in {{count}} cameras", diff --git a/web/src/components/chat/ChatMessage.tsx b/web/src/components/chat/ChatMessage.tsx index 6478b48fc..9a91d7035 100644 --- a/web/src/components/chat/ChatMessage.tsx +++ b/web/src/components/chat/ChatMessage.tsx @@ -156,7 +156,7 @@ export function MessageBubble({
p:last-child]:inline after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']", + "after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-[''] [&>p:last-child]:inline", )} > @@ -1253,33 +1254,22 @@ export function ConfigSection({ {title} {showOverrideIndicator && effectiveLevel === "camera" && - (profileOverridesSection || isOverridden) && ( - - - - {overrideSource === "profile" - ? t("button.overriddenBaseConfig", { - ns: "views/settings", - defaultValue: "Overridden (Base Config)", - }) - : t("button.overriddenGlobal", { - ns: "views/settings", - defaultValue: "Overridden (Global)", - })} - - - - {overrideSource === "profile" - ? t("button.overriddenBaseConfigTooltip", { - ns: "views/settings", - profile: profileFriendlyName ?? profileName, - }) - : t("button.overriddenGlobalTooltip", { - ns: "views/settings", - })} - - - )} + (profileOverridesSection || isOverridden) && + cameraName && + (overrideSource === "profile" && profileName ? ( + + ) : ( + + ))} {showOverrideIndicator && effectiveLevel === "global" && ( )} @@ -1319,41 +1309,22 @@ export function ConfigSection({ {title} {showOverrideIndicator && effectiveLevel === "camera" && - (profileOverridesSection || isOverridden) && ( - - - - {overrideSource === "profile" - ? t("button.overriddenBaseConfig", { - ns: "views/settings", - defaultValue: "Overridden (Base Config)", - }) - : t("button.overriddenGlobal", { - ns: "views/settings", - defaultValue: "Overridden (Global)", - })} - - - - {overrideSource === "profile" - ? t("button.overriddenBaseConfigTooltip", { - ns: "views/settings", - profile: profileFriendlyName ?? profileName, - }) - : t("button.overriddenGlobalTooltip", { - ns: "views/settings", - })} - - - )} + (profileOverridesSection || isOverridden) && + cameraName && + (overrideSource === "profile" && profileName ? ( + + ) : ( + + ))} {showOverrideIndicator && effectiveLevel === "global" && ( )} diff --git a/web/src/components/config-form/sections/CameraOverridesBadge.tsx b/web/src/components/config-form/sections/CameraOverridesBadge.tsx index 6ccbb028c..466934a77 100644 --- a/web/src/components/config-form/sections/CameraOverridesBadge.tsx +++ b/web/src/components/config-form/sections/CameraOverridesBadge.tsx @@ -17,10 +17,13 @@ import { } from "@/hooks/use-config-override"; import type { FrigateConfig } from "@/types/frigateConfig"; import type { ProfilesApiResponse } from "@/types/profile"; -import { humanizeKey } from "@/components/config-form/theme/utils/i18n"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { formatList } from "@/utils/stringUtil"; -import { getEffectiveHiddenFields } from "@/utils/configUtil"; +import { + getEffectiveHiddenFields, + pathMatchesHiddenPattern, +} from "@/utils/configUtil"; +import { useOverrideFieldLabel } from "./useOverrideFieldLabel"; const CAMERA_PAGE_BY_SECTION: Record = { detect: "cameraDetect", @@ -72,26 +75,6 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([ "model", ]); -/** - * Match a delta path against a hidden-field pattern. Supports literal prefixes - * (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards - * matching exactly one path segment (e.g. "filters.*.mask"). - */ -function pathMatchesHiddenPattern(path: string, pattern: string): boolean { - if (!pattern) return false; - if (!pattern.includes("*")) { - return path === pattern || path.startsWith(`${pattern}.`); - } - const patternSegments = pattern.split("."); - const pathSegments = path.split("."); - if (pathSegments.length < patternSegments.length) return false; - for (let i = 0; i < patternSegments.length; i += 1) { - if (patternSegments[i] === "*") continue; - if (patternSegments[i] !== pathSegments[i]) return false; - } - return true; -} - type CameraEntryProps = { sectionPath: string; entry: CameraOverrideEntry; @@ -127,11 +110,8 @@ function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] { } function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) { - const { t, i18n } = useTranslation([ - "config/global", - "views/settings", - "objects", - ]); + const { t } = useTranslation(["views/settings"]); + const fieldLabel = useOverrideFieldLabel(sectionPath); const friendlyName = useCameraFriendlyName(entry.camera); const { data: profilesData } = useSWR("profiles"); @@ -141,49 +121,6 @@ function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) { return map; }, [profilesData]); - const fieldLabel = (fieldPath: string) => { - if (!fieldPath) { - const sectionKey = `${sectionPath}.label`; - return i18n.exists(sectionKey, { ns: "config/global" }) - ? t(sectionKey, { ns: "config/global" }) - : humanizeKey(sectionPath); - } - - const segments = fieldPath.split("."); - - // Most specific: try the full nested path - const fullKey = `${sectionPath}.${fieldPath}.label`; - if (i18n.exists(fullKey, { ns: "config/global" })) { - return t(fullKey, { ns: "config/global" }); - } - - // Try dropping each intermediate segment in turn — those are typically - // user-defined dict keys (object class names, zone names, etc.) that - // don't have their own label entries. Prepend the dropped segment as - // context to disambiguate (e.g. "Person · Minimum object area"). - for (let i = 0; i < segments.length; i++) { - const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join( - ".", - ); - if (!reduced) continue; - const reducedKey = `${sectionPath}.${reduced}.label`; - if (i18n.exists(reducedKey, { ns: "config/global" })) { - const resolvedLabel = t(reducedKey, { ns: "config/global" }); - const dropped = segments[i]; - // Object class names ("person", "car", "fox") have translations in - // the `objects` namespace; fall back to humanizing the raw key for - // anything that isn't a known label. - const droppedLabel = i18n.exists(dropped, { ns: "objects" }) - ? t(dropped, { ns: "objects" }) - : humanizeKey(dropped); - return `${droppedLabel} · ${resolvedLabel}`; - } - } - - // Last resort: humanize the leaf segment - return humanizeKey(segments[segments.length - 1]); - }; - const formatDeltas = (deltas: FieldDelta[]) => { const visibleLabels = deltas .slice(0, MAX_FIELDS_PER_CAMERA) diff --git a/web/src/components/config-form/sections/GlobalOverridesBadge.tsx b/web/src/components/config-form/sections/GlobalOverridesBadge.tsx new file mode 100644 index 000000000..d6bc9b3e4 --- /dev/null +++ b/web/src/components/config-form/sections/GlobalOverridesBadge.tsx @@ -0,0 +1,44 @@ +import useSWR from "swr"; +import { useTranslation } from "react-i18next"; + +import { useCameraSectionDeltas } from "@/hooks/use-config-override"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import { OverrideDeltaPopover } from "./OverrideDeltaPopover"; + +type Props = { + sectionPath: string; + cameraName: string; + className?: string; +}; + +export function GlobalOverridesBadge({ + sectionPath, + cameraName, + className, +}: Props) { + const { data: config } = useSWR("config"); + const { t } = useTranslation(["views/settings"]); + const deltas = useCameraSectionDeltas(config, cameraName, sectionPath); + + return ( + + ); +} diff --git a/web/src/components/config-form/sections/OverrideDeltaPopover.tsx b/web/src/components/config-form/sections/OverrideDeltaPopover.tsx new file mode 100644 index 000000000..5cd22a521 --- /dev/null +++ b/web/src/components/config-form/sections/OverrideDeltaPopover.tsx @@ -0,0 +1,78 @@ +import { LuChevronDown } from "react-icons/lu"; + +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { FieldDelta } from "@/hooks/use-config-override"; +import { cn } from "@/lib/utils"; +import { useOverrideFieldLabel } from "./useOverrideFieldLabel"; + +type Props = { + sectionPath: string; + deltas: FieldDelta[]; + /** Translated label shown inside the badge */ + badgeLabel: string; + /** Accessible label for the badge trigger */ + ariaLabel: string; + /** Heading rendered at the top of the popover content */ + heading: string; + /** Message shown when there are zero field deltas */ + noDeltasMessage: string; + /** Border color class for the badge (defaults to selected) */ + borderColorClass?: string; + className?: string; +}; + +/** + * Shared popover layout for "this scope overrides these fields" badges + * (e.g. profile overrides base config, camera overrides global config). + */ +export function OverrideDeltaPopover({ + sectionPath, + deltas, + badgeLabel, + ariaLabel, + heading, + noDeltasMessage, + borderColorClass, + className, +}: Props) { + const fieldLabel = useOverrideFieldLabel(sectionPath); + const count = deltas.length; + + return ( + + e.stopPropagation()}> + + {badgeLabel} + + + + +
+
+ {count > 0 ? heading : noDeltasMessage} +
+ {count > 0 && ( +
    + {deltas.map((delta) => ( +
  • {fieldLabel(delta.fieldPath)}
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/web/src/components/config-form/sections/ProfileOverridesBadge.tsx b/web/src/components/config-form/sections/ProfileOverridesBadge.tsx new file mode 100644 index 000000000..2d8d5d7e1 --- /dev/null +++ b/web/src/components/config-form/sections/ProfileOverridesBadge.tsx @@ -0,0 +1,62 @@ +import useSWR from "swr"; +import { useTranslation } from "react-i18next"; + +import { useProfileSectionDeltas } from "@/hooks/use-config-override"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import { OverrideDeltaPopover } from "./OverrideDeltaPopover"; + +type Props = { + sectionPath: string; + cameraName: string; + profileName: string; + profileFriendlyName?: string; + /** Border color class for profile-themed badge (e.g., "border-amber-500") */ + profileBorderColor?: string; + className?: string; +}; + +export function ProfileOverridesBadge({ + sectionPath, + cameraName, + profileName, + profileFriendlyName, + profileBorderColor, + className, +}: Props) { + const { data: config } = useSWR("config"); + const { t } = useTranslation(["views/settings"]); + const deltas = useProfileSectionDeltas( + config, + cameraName, + profileName, + sectionPath, + ); + + const displayProfile = profileFriendlyName ?? profileName; + + return ( + + ); +} diff --git a/web/src/components/config-form/sections/useOverrideFieldLabel.ts b/web/src/components/config-form/sections/useOverrideFieldLabel.ts new file mode 100644 index 000000000..0ec7398de --- /dev/null +++ b/web/src/components/config-form/sections/useOverrideFieldLabel.ts @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import { humanizeKey } from "@/components/config-form/theme/utils/i18n"; + +/** + * Resolve a translated label for a config field path within a section, falling + * back through reduced paths (dropping each intermediate segment in turn) so + * dict-keyed paths like `filters.person.threshold` still surface a meaningful + * label. Dropped segments are prepended as context (e.g. "Person · Threshold"). + * + * Shared between override badges that need to render field labels (e.g. + * CameraOverridesBadge, ProfileOverridesBadge). + */ +export function useOverrideFieldLabel(sectionPath: string) { + const { t, i18n } = useTranslation([ + "config/global", + "views/settings", + "objects", + ]); + + return (fieldPath: string): string => { + if (!fieldPath) { + const sectionKey = `${sectionPath}.label`; + return i18n.exists(sectionKey, { ns: "config/global" }) + ? t(sectionKey, { ns: "config/global" }) + : humanizeKey(sectionPath); + } + + const segments = fieldPath.split("."); + + const fullKey = `${sectionPath}.${fieldPath}.label`; + if (i18n.exists(fullKey, { ns: "config/global" })) { + return t(fullKey, { ns: "config/global" }); + } + + for (let i = 0; i < segments.length; i++) { + const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join( + ".", + ); + if (!reduced) continue; + const reducedKey = `${sectionPath}.${reduced}.label`; + if (i18n.exists(reducedKey, { ns: "config/global" })) { + const resolvedLabel = t(reducedKey, { ns: "config/global" }); + const dropped = segments[i]; + const droppedLabel = i18n.exists(dropped, { ns: "objects" }) + ? t(dropped, { ns: "objects" }) + : humanizeKey(dropped); + return `${droppedLabel} · ${resolvedLabel}`; + } + } + + return humanizeKey(segments[segments.length - 1]); + }; +} diff --git a/web/src/components/config-form/theme/widgets/OptionalFieldWidget.tsx b/web/src/components/config-form/theme/widgets/OptionalFieldWidget.tsx index 7f05d6466..9a89f8971 100644 --- a/web/src/components/config-form/theme/widgets/OptionalFieldWidget.tsx +++ b/web/src/components/config-form/theme/widgets/OptionalFieldWidget.tsx @@ -6,6 +6,7 @@ import { getWidget } from "@rjsf/utils"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { getNonNullSchema } from "../fields/nullableUtils"; +import type { ConfigFormContext } from "@/types/configForm"; export function OptionalFieldWidget(props: WidgetProps) { const { id, value, disabled, readonly, onChange, schema, options, registry } = @@ -13,6 +14,8 @@ export function OptionalFieldWidget(props: WidgetProps) { const innerWidgetName = (options.innerWidget as string) || undefined; const isEnabled = value !== undefined && value !== null; + const formContext = registry?.formContext as ConfigFormContext | undefined; + const isProfile = !!formContext?.isProfile; // Extract the non-null branch from anyOf [Type, null] const innerSchema = getNonNullSchema(schema) ?? schema; @@ -42,10 +45,17 @@ export function OptionalFieldWidget(props: WidgetProps) { const innerProps: WidgetProps = { ...props, schema: innerSchema, - disabled: disabled || readonly || !isEnabled, + disabled: disabled || readonly || (!isProfile && !isEnabled), value: isEnabled ? value : getDefaultValue(), }; + // don't show the switch if we're editing in a profile + // to disable in a profile, users should edit the config manually, eg: + // skip_motion_threshold: None + if (isProfile) { + return ; + } + return (
{ + if (!el || !isDesktop) return; + const handleWheel = (e: WheelEvent) => { + if (el.scrollHeight <= el.clientHeight) return; + e.preventDefault(); + el.scrollTop += e.deltaY; + }; + el.addEventListener("wheel", handleWheel, { passive: false }); + return () => el.removeEventListener("wheel", handleWheel); + }, []); + // components const Selector = isDesktop ? DropdownMenu : Drawer; const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; @@ -114,6 +127,8 @@ export default function ClassificationSelectionDialog({ ); + // keep modal false on desktop to prevent dismissable layer pointer events + // issue with dialog auto-close return (
onCategorizeImage(newCat)} /> - - - - - {children} - - e.preventDefault()} - > - {isMobile && ( - - Details - Details - - )} - - {dialogLabel ?? t("categorizeImageAs")} - -
+ + + {children} + + + {tooltipLabel ?? t("categorizeImage")} + + + e.preventDefault()} + > + {isMobile && ( + + Details + Details + + )} + + {dialogLabel ?? t("categorizeImageAs")} + +
+ {filteredClasses + .sort((a, b) => { + if (a === "none") return 1; + if (b === "none") return -1; + return a.localeCompare(b); + }) + .map((category) => ( + onCategorizeImage(category)} + > + {category === "none" + ? t("details.none") + : category.replaceAll("_", " ")} + + ))} + + setNewClass(true)} > - {filteredClasses - .sort((a, b) => { - if (a === "none") return 1; - if (b === "none") return -1; - return a.localeCompare(b); - }) - .map((category) => ( - onCategorizeImage(category)} - > - {category === "none" - ? t("details.none") - : category.replaceAll("_", " ")} - - ))} - - setNewClass(true)} - > - {t("createCategory.new")} - -
-
- - {tooltipLabel ?? t("categorizeImage")} - + {t("createCategory.new")} + +
+
+
); } diff --git a/web/src/components/overlay/FaceSelectionDialog.tsx b/web/src/components/overlay/FaceSelectionDialog.tsx index 1b049f422..4c355f3d3 100644 --- a/web/src/components/overlay/FaceSelectionDialog.tsx +++ b/web/src/components/overlay/FaceSelectionDialog.tsx @@ -23,7 +23,7 @@ import { import { isDesktop, isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; -import React, { ReactNode, useMemo, useState } from "react"; +import React, { ReactNode, useCallback, useMemo, useState } from "react"; import TextEntryDialog from "./dialog/TextEntryDialog"; import { Button } from "../ui/button"; @@ -61,6 +61,19 @@ export default function FaceSelectionDialog({ // control const [newFace, setNewFace] = useState(false); + // Non-modal Radix DropdownMenu doesn't propagate wheel events to nested + // scroll containers, so attach a non-passive listener that scrolls manually. + const scrollContainerRef = useCallback((el: HTMLDivElement | null) => { + if (!el || !isDesktop) return; + const handleWheel = (e: WheelEvent) => { + if (el.scrollHeight <= el.clientHeight) return; + e.preventDefault(); + el.scrollTop += e.deltaY; + }; + el.addEventListener("wheel", handleWheel, { passive: false }); + return () => el.removeEventListener("wheel", handleWheel); + }, []); + // components const Selector = isDesktop ? DropdownMenu : Drawer; const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; @@ -73,6 +86,8 @@ export default function FaceSelectionDialog({ ); + // keep modal false on desktop to prevent dismissable layer pointer events + // issue with dialog auto-close return (
{newFace && ( @@ -83,52 +98,56 @@ export default function FaceSelectionDialog({ onSave={(newName) => onTrainAttempt(newName)} /> )} - - - - - {children} - - e.preventDefault()} - > - {isMobile && ( - - Details - Details - + + + + {children} + + {tooltipLabel ?? t("trainFace")} + + e.preventDefault()} + > + {isMobile && ( + + Details + Details + + )} + + {dialogLabel ?? t("trainFaceAs")} + +
- {dialogLabel ?? t("trainFaceAs")} - -
- {filteredNames.sort().map((faceName) => ( - onTrainAttempt(faceName)} - > - {faceName} - - ))} - + > + {filteredNames.sort().map((faceName) => ( setNewFace(true)} + onClick={() => onTrainAttempt(faceName)} > - {t("createFaceLibrary.new")} + {faceName} -
- - - {tooltipLabel ?? t("trainFace")} - + ))} + + setNewFace(true)} + > + {t("createFaceLibrary.new")} + +
+
+
); } diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 8e0732e05..689e99f4b 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -12,6 +12,7 @@ import { isJsonObject } from "@/lib/utils"; import { getBaseCameraSectionValue, getEffectiveHiddenFields, + pathMatchesHiddenPattern, unsetWithWildcard, } from "@/utils/configUtil"; import { extractSectionSchema } from "@/hooks/use-config-schema"; @@ -663,3 +664,138 @@ export function useCamerasOverridingSection( return entries; }, [config, sectionPath, schema]); } + +/** + * Hook returning the field-level deltas between a single camera's base + * (pre-profile) section value and the effective global baseline. Mirrors + * `useConfigOverride`'s comparison logic but exposes per-field deltas so a + * popover can list the overridden fields. + * + * @example + * ```tsx + * const deltas = useCameraSectionDeltas(config, "front_door", "detect"); + * // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10 }] + * ``` + */ +export function useCameraSectionDeltas( + config: FrigateConfig | undefined, + cameraName: string | undefined, + sectionPath: string, +): FieldDelta[] { + const { data: schema } = useSWR("config/schema.json"); + return useMemo(() => { + if (!config?.cameras || !cameraName || !sectionPath) { + return []; + } + const cameraConfig = config.cameras[cameraName]; + if (!cameraConfig) return []; + + const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath); + const compareFields = sectionMeta?.compareFields; + + const globalValue = collapseEmpty( + getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema), + ); + const cameraValue = collapseEmpty( + normalizeConfigValue( + getBaseCameraSectionValue(config, cameraName, sectionPath), + ), + ); + + const hiddenFields = getEffectiveHiddenFields( + sectionPath, + "camera", + config, + ); + + const deltas: FieldDelta[] = []; + for (const delta of collectFieldDeltas( + globalValue, + cameraValue, + compareFields, + )) { + if ( + hiddenFields.some((pattern) => + pathMatchesHiddenPattern(delta.fieldPath, pattern), + ) + ) { + continue; + } + deltas.push(delta); + } + return deltas; + }, [config, cameraName, sectionPath, schema]); +} + +/** + * Hook returning the field-level deltas between a single profile's overrides + * and the camera's base (pre-profile) section value. Honors per-section + * `compareFields` filters and hidden-field patterns so the result matches + * what's actually exposed in the UI. + * + * @example + * ```tsx + * const deltas = useProfileSectionDeltas(config, "front_door", "night", "detect"); + * // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10, profileName: "night" }] + * ``` + */ +export function useProfileSectionDeltas( + config: FrigateConfig | undefined, + cameraName: string | undefined, + profileName: string | undefined, + sectionPath: string, +): FieldDelta[] { + return useMemo(() => { + if (!config?.cameras || !cameraName || !profileName || !sectionPath) { + return []; + } + const cameraConfig = config.cameras[cameraName]; + if (!cameraConfig) return []; + + const profileSection = ( + cameraConfig.profiles?.[profileName] as + | Record + | undefined + )?.[sectionPath]; + if (profileSection == null) return []; + + const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath); + const compareFields = sectionMeta?.compareFields; + + const baseValue = collapseEmpty( + normalizeConfigValue( + getBaseCameraSectionValue(config, cameraName, sectionPath), + ), + ); + const profileValue = collapseEmpty( + normalizeConfigValue(profileSection as JsonValue), + ); + + const hiddenFields = getEffectiveHiddenFields( + sectionPath, + "camera", + config, + ); + + const deltas: FieldDelta[] = []; + for (const path of collectDefinedLeafPaths(profileValue)) { + if (!isPathAllowed(path, compareFields)) continue; + if ( + hiddenFields.some((pattern) => pathMatchesHiddenPattern(path, pattern)) + ) { + continue; + } + const baseField = get(baseValue, path); + const profileField = get(profileValue, path); + if (!isEqual(baseField, profileField)) { + deltas.push({ + fieldPath: path, + globalValue: baseField, + cameraValue: profileField, + profileName, + }); + } + } + return deltas; + }, [config, cameraName, profileName, sectionPath]); +} diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index 9e6181266..03ecd3e4d 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -44,4 +44,5 @@ export type ConfigFormContext = { requiresRestart?: boolean; t?: (key: string, options?: Record) => string; renderers?: Record; + isProfile?: boolean; }; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index ee33e59b7..80c940cb7 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -6,7 +6,6 @@ import get from "lodash/get"; import cloneDeep from "lodash/cloneDeep"; -import merge from "lodash/merge"; import unset from "lodash/unset"; import isEqual from "lodash/isEqual"; import mergeWith from "lodash/mergeWith"; @@ -92,6 +91,32 @@ export function getBaseCameraSectionValue( return base !== undefined ? base : get(cam, sectionPath); } +// mergeWith customizer that replaces arrays wholesale instead of merging them +// positionally by index. Used when the source value is meant to fully replace +// the destination (e.g. profile overrides, section config overrides), so an +// empty source array correctly clears the destination array. +const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => { + if (Array.isArray(objValue) || Array.isArray(srcValue)) { + return srcValue !== undefined ? srcValue : objValue; + } + return undefined; +}; + +// Merge profile overrides on top of base config values. Matches the backend's +// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by +// the profile's value rather than merged positionally, so an empty array in a +// profile clears the base array instead of leaving stale entries behind. +export function mergeProfileOverrides( + baseValue: T, + profileOverrides: object, +): T { + return mergeWith( + cloneDeep(baseValue), + cloneDeep(profileOverrides), + replaceArraysCustomizer, + ) as T; +} + /** Sections that can appear inside a camera profile definition. */ export const PROFILE_ELIGIBLE_SECTIONS = new Set([ "audio", @@ -564,9 +589,9 @@ export function prepareSectionSavePayload(opts: { baseValue && typeof baseValue === "object" ) { - rawSectionValue = merge( - cloneDeep(baseValue), - cloneDeep(profileOverrides), + rawSectionValue = mergeProfileOverrides( + baseValue as object, + profileOverrides as object, ); } else { rawSectionValue = baseValue; @@ -675,13 +700,12 @@ const mergeSectionConfig = ( overrides: Partial | undefined, ): SectionConfig => mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => { - if (Array.isArray(objValue) || Array.isArray(srcValue)) { - return srcValue ?? objValue; - } + const arrayResult = replaceArraysCustomizer(objValue, srcValue); + if (arrayResult !== undefined) return arrayResult; if (key === "uiSchema") { if (objValue && srcValue) { - return merge({}, objValue, srcValue); + return mergeWith({}, objValue, srcValue, replaceArraysCustomizer); } return srcValue ?? objValue; } @@ -739,3 +763,26 @@ export function resolveHiddenFieldEntries( } return result; } + +/** + * Match a delta path against a hidden-field pattern. Supports literal prefixes + * (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards + * matching exactly one path segment (e.g. "filters.*.mask"). + */ +export function pathMatchesHiddenPattern( + path: string, + pattern: string, +): boolean { + if (!pattern) return false; + if (!pattern.includes("*")) { + return path === pattern || path.startsWith(`${pattern}.`); + } + const patternSegments = pattern.split("."); + const pathSegments = path.split("."); + if (pathSegments.length < patternSegments.length) return false; + for (let i = 0; i < patternSegments.length; i += 1) { + if (patternSegments[i] === "*") continue; + if (patternSegments[i] !== pathSegments[i]) return false; + } + return true; +} diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx index 3179fb85c..d30036b60 100644 --- a/web/src/views/settings/ObjectSettingsView.tsx +++ b/web/src/views/settings/ObjectSettingsView.tsx @@ -33,6 +33,7 @@ import { getTranslatedLabel } from "@/utils/i18n"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph"; import { useWs } from "@/api/ws"; +import { cn } from "@/lib/utils"; type ObjectSettingsViewProps = { selectedCamera?: string; @@ -200,15 +201,18 @@ export default function ObjectSettingsView({ input.roles.includes("audio")) ? "grid-cols-3" : "grid-cols-2"}`} + className={cn( + "grid w-full", + cameraConfig.audio.enabled_in_config + ? "grid-cols-3" + : "grid-cols-2", + )} > {t("debug.debugging")} {t("debug.objectList")} - {cameraConfig.ffmpeg.inputs.some((input) => - input.roles.includes("audio"), - ) && ( + {cameraConfig.audio.enabled_in_config && ( {t("debug.audio.title")} )} @@ -325,9 +329,7 @@ export default function ObjectSettingsView({ - {cameraConfig.ffmpeg.inputs.some((input) => - input.roles.includes("audio"), - ) && ( + {cameraConfig.audio.enabled_in_config && ( - - - {sectionStatus.overrideSource === "profile" - ? t("button.overriddenBaseConfig", { - ns: "views/settings", - defaultValue: "Overridden (Base Config)", - }) - : t("button.overriddenGlobal", { - ns: "views/settings", - defaultValue: "Overridden (Global)", - })} - - - - {sectionStatus.overrideSource === "profile" - ? t("button.overriddenBaseConfigTooltip", { - ns: "views/settings", - profile: currentEditingProfile - ? (profileState?.profileFriendlyNames.get( - currentEditingProfile, - ) ?? currentEditingProfile) - : "", - }) - : t("button.overriddenGlobalTooltip", { - ns: "views/settings", - })} - - - )} + sectionStatus.isOverridden && + selectedCamera && + (sectionStatus.overrideSource === "profile" && + currentEditingProfile ? ( + + ) : ( + + ))} {sectionStatus.hasChanges && ( - {sectionStatus.overrideSource === "profile" - ? t("button.overriddenBaseConfig", { - ns: "views/settings", - defaultValue: "Overridden (Base Config)", - }) - : t("button.overriddenGlobal", { - ns: "views/settings", - defaultValue: "Overridden (Global)", - })} - - )} + profileBorderColor={profileColor?.border} + /> + ) : ( + + ))} {sectionStatus.hasChanges && (