diff --git a/docs/docs/configuration/genai/config.md b/docs/docs/configuration/genai/config.md index a512943c90..9f396d3ccc 100644 --- a/docs/docs/configuration/genai/config.md +++ b/docs/docs/configuration/genai/config.md @@ -49,15 +49,14 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru ### Model Types: Instruct vs Thinking -Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions. +Vision-language models come in **instruct** variants (fine-tuned to follow instructions and respond concisely), **thinking** variants (fine-tuned for free-form, speculative reasoning), and **hybrid** variants that support both modes per request. Most modern vision-language models are hybrid. -- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case. -- **Reasoning / Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models. +Frigate manages reasoning per task automatically: -Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, it is recommended to disable reasoning / thinking, which is generally model specific (see your models documentation). +- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle. +- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it. -**Recommendation:** -Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider's documentation or model library for guidance on the correct model variant to use. +You can use a pure instruct, hybrid, or thinking-capable model with Frigate — no extra configuration is required to disable thinking for descriptions. ### llama.cpp diff --git a/docs/docs/configuration/review.md b/docs/docs/configuration/review.md index 4f39611dbe..be02bdd8e9 100644 --- a/docs/docs/configuration/review.md +++ b/docs/docs/configuration/review.md @@ -23,7 +23,7 @@ In 0.14 and later, all of that is bundled into a single review item which starts ## Alerts and Detections -Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate 0.14 categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them. +Not every segment of video captured by Frigate may be of the same level of interest to you. Video of people who enter your property may be a different priority than those walking by on the sidewalk. For this reason, Frigate categorizes review items as _alerts_ and _detections_. By default, all person and car objects are considered alerts. You can refine categorization of your review items by configuring required zones for them. :::note diff --git a/docs/docs/troubleshooting/dummy-camera.md b/docs/docs/troubleshooting/dummy-camera.md index aed0af5e68..7e9831e4be 100644 --- a/docs/docs/troubleshooting/dummy-camera.md +++ b/docs/docs/troubleshooting/dummy-camera.md @@ -56,6 +56,7 @@ Only one replay session can be active at a time. If a session is already running - The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking. - Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector. - Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs. +- In cases where a detection is short and a replay may only be a small number of frames, it is recommended to manually add some padding before and after the detection so that the motion and object detectors have time to settle into the scene. Rather than starting Debug Replay from Explore, navigate to History for your camera, choose Debug Replay from the Actions menu, and click the "From Timeline" or "Custom" option. Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior. diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py index b137e24b93..ea95e153c1 100644 --- a/frigate/debug_replay.py +++ b/frigate/debug_replay.py @@ -238,6 +238,10 @@ class DebugReplayManager: zone_dump.setdefault("coordinates", zone_config.coordinates) zones_dict[zone_name] = zone_dump + # Extract LPR and face recognition configs + lpr_dict = source_config.lpr.model_dump() + face_recognition_dict = source_config.face_recognition.model_dump() + # Extract motion config (exclude runtime fields) motion_dict = {} if source_config.motion is not None: @@ -287,8 +291,8 @@ class DebugReplayManager: }, "birdseye": {"enabled": False}, "audio": {"enabled": False}, - "lpr": {"enabled": False}, - "face_recognition": {"enabled": False}, + "lpr": lpr_dict, + "face_recognition": face_recognition_dict, } def _cleanup_db(self, camera_name: str) -> None: diff --git a/frigate/genai/plugins/gemini.py b/frigate/genai/plugins/gemini.py index 8c05e0b1ad..9efd241893 100644 --- a/frigate/genai/plugins/gemini.py +++ b/frigate/genai/plugins/gemini.py @@ -1,5 +1,7 @@ """Gemini Provider for Frigate AI.""" +import base64 +import binascii import json import logging from typing import Any, AsyncGenerator, Optional @@ -14,6 +16,27 @@ from frigate.genai import GenAIClient, register_genai_provider logger = logging.getLogger(__name__) +def _decode_thought_signature(value: Any) -> Optional[bytes]: + """Decode a base64-encoded thought_signature carried across conversation turns.""" + if not value: + return None + if isinstance(value, bytes): + return value + if isinstance(value, str): + try: + return base64.b64decode(value) + except (binascii.Error, ValueError): + return None + return None + + +def _encode_thought_signature(signature: Optional[bytes]) -> Optional[str]: + """Encode bytes thought_signature as base64 so it survives JSON-friendly transport.""" + if not signature: + return None + return base64.b64encode(signature).decode("ascii") + + def _stats_from_gemini_usage(usage: Any) -> Optional[dict[str, Any]]: """Build a stats dict from a Gemini usage_metadata object.""" prompt_tokens = getattr(usage, "prompt_token_count", None) @@ -169,11 +192,17 @@ class GeminiClient(GenAIClient): if not isinstance(tc_args, dict): tc_args = {} if tc_name: - parts.append( - types.Part.from_function_call( - name=tc_name, args=tc_args - ) + fc_part = types.Part.from_function_call( + name=tc_name, args=tc_args ) + # Thinking-capable Gemini models require the original + # thought_signature to be echoed back on functionCall + # parts after a tool response, or the next request + # fails with INVALID_ARGUMENT. + sig = _decode_thought_signature(tc.get("thought_signature")) + if sig: + fc_part.thought_signature = sig + parts.append(fc_part) if not parts: parts.append(types.Part.from_text(text=" ")) gemini_messages.append(types.Content(role="model", parts=parts)) @@ -310,6 +339,9 @@ class GeminiClient(GenAIClient): "id": part.function_call.name or "", "name": part.function_call.name or "", "arguments": arguments, + "thought_signature": _encode_thought_signature( + getattr(part, "thought_signature", None) + ), } ) @@ -418,11 +450,17 @@ class GeminiClient(GenAIClient): if not isinstance(tc_args, dict): tc_args = {} if tc_name: - parts.append( - types.Part.from_function_call( - name=tc_name, args=tc_args - ) + fc_part = types.Part.from_function_call( + name=tc_name, args=tc_args ) + # Thinking-capable Gemini models require the original + # thought_signature to be echoed back on functionCall + # parts after a tool response, or the next request + # fails with INVALID_ARGUMENT. + sig = _decode_thought_signature(tc.get("thought_signature")) + if sig: + fc_part.thought_signature = sig + parts.append(fc_part) if not parts: parts.append(types.Part.from_text(text=" ")) gemini_messages.append(types.Content(role="model", parts=parts)) @@ -588,6 +626,7 @@ class GeminiClient(GenAIClient): "id": tool_call_id, "name": tool_call_name, "arguments": "", + "thought_signature": None, } # Accumulate arguments @@ -598,6 +637,13 @@ class GeminiClient(GenAIClient): else str(arguments) ) + # Capture latest thought_signature for this call + chunk_sig = getattr(part, "thought_signature", None) + if chunk_sig: + tool_calls_by_index[found_index][ + "thought_signature" + ] = chunk_sig + # Build final message full_content = "".join(content_parts).strip() or None full_reasoning = "".join(reasoning_parts).strip() or None @@ -618,6 +664,9 @@ class GeminiClient(GenAIClient): "id": tc["id"], "name": tc["name"], "arguments": parsed_args, + "thought_signature": _encode_thought_signature( + tc.get("thought_signature") + ), } ) finish_reason = "tool_calls" diff --git a/frigate/genai/utils.py b/frigate/genai/utils.py index 44f982059b..a382647cb9 100644 --- a/frigate/genai/utils.py +++ b/frigate/genai/utils.py @@ -69,6 +69,14 @@ def build_assistant_message_for_conversation( "name": tc["name"], "arguments": json.dumps(tc.get("arguments") or {}), }, + # Gemini-only: opaque signature that must be echoed back on + # the same functionCall part in the next turn. Other providers + # do not set or read this. + **( + {"thought_signature": tc["thought_signature"]} + if tc.get("thought_signature") + else {} + ), } for tc in tool_calls_raw ] diff --git a/frigate/object_detection/base.py b/frigate/object_detection/base.py index a62fe48431..f2336f3da8 100644 --- a/frigate/object_detection/base.py +++ b/frigate/object_detection/base.py @@ -167,8 +167,9 @@ class DetectorRunner(FrigateProcess): # detect and send the output self.start_time.value = datetime.datetime.now().timestamp() + mono_start = time.monotonic() detections = object_detector.detect_raw(input_frame) - duration = datetime.datetime.now().timestamp() - self.start_time.value + duration = time.monotonic() - mono_start frame_manager.close(connection_id) if connection_id not in self.outputs: diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index 1a45f619c2..fb76f6718d 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -1331,6 +1331,8 @@ class PtzAutoTracker: return self.tracked_object[camera]["region"] def autotrack_object(self, camera: str, obj: TrackedObject): + if camera not in self.config.cameras: + return camera_config = self.config.cameras[camera] if camera_config.onvif.autotracking.enabled: diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 65a3430269..7bb582120b 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -484,11 +484,15 @@ "reorderHandle": "Drag to reorder", "saving": "Saving…", "saved": "Saved", - "friendlyName": { - "edit": "Edit camera display name", - "title": "Edit Display Name", - "description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", - "rename": "Rename" + "details": { + "edit": "Edit camera details", + "title": "Edit Camera Details", + "description": "Update the display name and external URL used for this camera throughout the Frigate UI.", + "friendlyNameLabel": "Display Name", + "friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", + "webuiUrlLabel": "Camera Web UI URL", + "webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.", + "webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)." } }, "cameraConfig": { @@ -1816,7 +1820,8 @@ "mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}." }, "semanticSearch": { - "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended." + "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended.", + "modelSizeIgnoredForProvider": "Model size only applies to the built-in Jina models. This value will be ignored when using a GenAI embedding provider." } } } diff --git a/web/src/components/config-form/FieldMessagesContext.ts b/web/src/components/config-form/FieldMessagesContext.ts new file mode 100644 index 0000000000..5d45f7d79b --- /dev/null +++ b/web/src/components/config-form/FieldMessagesContext.ts @@ -0,0 +1,13 @@ +import { createContext } from "react"; +import type { FieldConditionalMessage } from "./section-configs/types"; + +// Provides currently-active field messages to FieldTemplate without going +// through RJSF's per-field uiSchema. RJSF caches state.uiSchema across renders +// in a way that can leave stale ui:messages attached to a field when the +// triggering condition flips back to false (see processPendingChange in +// @rjsf/core Form.js — formData is updated immediately, uiSchema is not). +// useContext re-runs consumers directly on provider value change, sidestepping +// that staleness. +export const FieldMessagesContext = createContext( + [], +); 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 884401b7d2..3f9bbfaec1 100644 --- a/web/src/components/config-form/section-configs/semantic_search.ts +++ b/web/src/components/config-form/section-configs/semantic_search.ts @@ -29,6 +29,22 @@ const semanticSearch: SectionConfigOverrides = { ctx.formData?.model === "jinav2" && ctx.formData?.model_size === "small", }, + { + key: "model-size-ignored-for-provider", + field: "model_size", + messageKey: "configMessages.semanticSearch.modelSizeIgnoredForProvider", + severity: "info", + position: "after", + condition: (ctx) => { + const model = ctx.formData?.model; + return ( + typeof model === "string" && + model !== "" && + model !== "jinav1" && + model !== "jinav2" + ); + }, + }, ], uiSchema: { model: { diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index b4b566fc51..5bacd2d80c 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -86,6 +86,7 @@ import type { } from "../section-configs/types"; import { useConfigMessages } from "@/hooks/use-config-messages"; import { ConfigMessageBanner } from "../ConfigMessageBanner"; +import { FieldMessagesContext } from "../FieldMessagesContext"; export interface SectionConfig { /** Field ordering within the section */ @@ -627,44 +628,6 @@ export function ConfigSection({ messageContext, ); - // Merge field-level conditional messages into uiSchema - const effectiveUiSchema = useMemo(() => { - if (activeFieldMessages.length === 0) return sectionConfig.uiSchema; - const merged = { ...(sectionConfig.uiSchema ?? {}) }; - for (const msg of activeFieldMessages) { - const segments = msg.field.split("."); - // Navigate to the nested uiSchema node, shallow-cloning along the way - let node = merged; - for (let i = 0; i < segments.length - 1; i++) { - const seg = segments[i]; - node[seg] = { ...(node[seg] as Record) }; - node = node[seg] as Record; - } - const leafKey = segments[segments.length - 1]; - const existing = node[leafKey] as Record | undefined; - const existingMessages = ((existing?.["ui:messages"] as unknown[]) ?? - []) as Array<{ - key: string; - messageKey: string; - severity: string; - position?: string; - }>; - node[leafKey] = { - ...existing, - "ui:messages": [ - ...existingMessages, - { - key: msg.key, - messageKey: msg.messageKey, - severity: msg.severity, - position: msg.position ?? "before", - }, - ], - }; - } - return merged; - }, [sectionConfig.uiSchema, activeFieldMessages]); - const currentOverrides = useMemo(() => { if (!currentFormData || typeof currentFormData !== "object") { return undefined; @@ -1034,59 +997,61 @@ export function ConfigSection({ const sectionContent = (
- handleChange(data), - // For widgets that need access to full camera config (e.g., zone names) - fullCameraConfig: - effectiveLevel === "camera" && cameraName - ? config?.cameras?.[cameraName] - : undefined, - fullConfig: config, - // When rendering camera-level sections, provide the section path so - // field templates can look up keys under the `config/cameras` namespace - // When using a consolidated global namespace, keys are nested - // under the section name (e.g., `audio.label`) so provide the - // section prefix to templates so they can attempt `${section}.${field}` lookups. - sectionI18nPrefix: sectionPath, - t, - renderers: wrappedRenderers, - sectionDocs: sectionConfig.sectionDocs, - fieldDocs: sectionConfig.fieldDocs, - hiddenFields: effectiveHiddenFields, - restartRequired: sectionConfig.restartRequired, - requiresRestart, - isProfile: !!profileName, - }} - /> + + handleChange(data), + // For widgets that need access to full camera config (e.g., zone names) + fullCameraConfig: + effectiveLevel === "camera" && cameraName + ? config?.cameras?.[cameraName] + : undefined, + fullConfig: config, + // When rendering camera-level sections, provide the section path so + // field templates can look up keys under the `config/cameras` namespace + // When using a consolidated global namespace, keys are nested + // under the section name (e.g., `audio.label`) so provide the + // section prefix to templates so they can attempt `${section}.${field}` lookups. + sectionI18nPrefix: sectionPath, + t, + renderers: wrappedRenderers, + sectionDocs: sectionConfig.sectionDocs, + fieldDocs: sectionConfig.fieldDocs, + hiddenFields: effectiveHiddenFields, + restartRequired: sectionConfig.restartRequired, + requiresRestart, + isProfile: !!profileName, + }} + /> + {!embedded && (
{children}
; @@ -384,21 +386,15 @@ export function FieldTemplate(props: FieldTemplateProps) { const beforeContent = renderCustom(beforeSpec); const afterContent = renderCustom(afterSpec); - // Render conditional field messages from ui:messages - const fieldMessageSpecs = uiSchema?.["ui:messages"] as - | Array<{ - key: string; - messageKey: string; - severity: string; - position?: string; - }> - | undefined; - const beforeMessages = fieldMessageSpecs?.filter( + // Read field-level conditional messages from FieldMessagesContext + const fieldPathStr = pathSegments.join("."); + const fieldMessageSpecs = allFieldMessages.filter( + (m) => m.field === fieldPathStr, + ); + const beforeMessages = fieldMessageSpecs.filter( (m) => (m.position ?? "before") === "before", ); - const afterMessages = fieldMessageSpecs?.filter( - (m) => m.position === "after", - ); + const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after"); const beforeMessagesContent = beforeMessages && beforeMessages.length > 0 ? (
diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index b43baf170f..212b32389a 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -36,7 +36,15 @@ import axios from "axios"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; -import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, @@ -53,6 +61,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; const REORDER_SAVED_INDICATOR_MS = 1500; @@ -482,7 +501,7 @@ function EnabledCameraRow({ - @@ -519,25 +538,91 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) { ); } -type CameraFriendlyNameEditorProps = { +type CameraDetailsEditorProps = { cameraName: string; onConfigChanged: () => Promise; }; -function CameraFriendlyNameEditor({ +type CameraDetailsFormValues = { + friendlyName: string; + webuiUrl: string; +}; + +function CameraDetailsEditor({ cameraName, onConfigChanged, -}: CameraFriendlyNameEditorProps) { +}: CameraDetailsEditorProps) { const { t } = useTranslation(["views/settings", "common"]); const { data: config } = useSWR("config"); const [open, setOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name; + const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url; - const onSave = useCallback( - async (text: string) => { + const formSchema = useMemo( + () => + z.object({ + friendlyName: z.string(), + webuiUrl: z.string().refine( + (val) => { + const trimmed = val.trim(); + if (!trimmed) return true; + try { + new URL(trimmed); + return true; + } catch { + return false; + } + }, + { + message: t("cameraManagement.streams.details.webuiUrlInvalid", { + ns: "views/settings", + }), + }, + ), + }), + [t], + ); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + friendlyName: currentFriendlyName ?? "", + webuiUrl: currentWebuiUrl ?? "", + }, + }); + + // Reset form values from config whenever the dialog is opened. + useEffect(() => { + if (open) { + form.reset({ + friendlyName: currentFriendlyName ?? "", + webuiUrl: currentWebuiUrl ?? "", + }); + } + }, [open, currentFriendlyName, currentWebuiUrl, form]); + + const onSubmit = useCallback( + async (values: CameraDetailsFormValues) => { if (isSaving) return; + + // only send fields the user actually changed + const newFriendly = values.friendlyName.trim() || null; + const newWebui = values.webuiUrl.trim() || null; + const cameraUpdate: Record = {}; + if (newFriendly !== (currentFriendlyName ?? null)) { + cameraUpdate.friendly_name = newFriendly; + } + if (newWebui !== (currentWebuiUrl ?? null)) { + cameraUpdate.webui_url = newWebui; + } + + if (Object.keys(cameraUpdate).length === 0) { + setOpen(false); + return; + } + setIsSaving(true); try { @@ -545,9 +630,7 @@ function CameraFriendlyNameEditor({ requires_restart: 0, config_data: { cameras: { - [cameraName]: { - friendly_name: text.trim() || null, - }, + [cameraName]: cameraUpdate, }, }, }); @@ -573,10 +656,17 @@ function CameraFriendlyNameEditor({ setIsSaving(false); } }, - [cameraName, isSaving, onConfigChanged, t], + [ + cameraName, + currentFriendlyName, + currentWebuiUrl, + isSaving, + onConfigChanged, + t, + ], ); - const renameLabel = t("cameraManagement.streams.friendlyName.rename", { + const editLabel = t("cameraManagement.streams.details.edit", { ns: "views/settings", }); @@ -588,30 +678,107 @@ function CameraFriendlyNameEditor({ variant="ghost" size="icon" className="size-7" - aria-label={renameLabel} + aria-label={editLabel} onClick={() => setOpen(true)} disabled={isSaving} > - {renameLabel} + {editLabel} - + + + + + {t("cameraManagement.streams.details.title", { + ns: "views/settings", + })} + + + {t("cameraManagement.streams.details.description", { + ns: "views/settings", + })} + + +
+ + ( + + + {t("cameraManagement.streams.details.friendlyNameLabel", { + ns: "views/settings", + })} + + + + +

+ {t("cameraManagement.streams.details.friendlyNameHelp", { + ns: "views/settings", + })} +

+ +
+ )} + /> + ( + + + {t("cameraManagement.streams.details.webuiUrlLabel", { + ns: "views/settings", + })} + + + + +

+ {t("cameraManagement.streams.details.webuiUrlHelp", { + ns: "views/settings", + })} +

+ +
+ )} + /> + + + + + + +
+
); }