From 72aa68cedcb5138eeec0f96eecee2fce58f27365 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 14 Oct 2024 07:23:10 -0500 Subject: [PATCH 01/12] Fix genai labels (#14330) * Publish model state and embeddings reindex in dispatcher onConnect * remove unneeded from explore * add embeddings reindex progress to statusbar * don't allow right click or show similar button if semantic search is disabled * fix status bar * Convert peewee model to dict before formatting for genai description * add embeddings reindex progress to statusbar * fix status bar * Convert peewee model to dict before formatting for genai description --- frigate/genai/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index dccb74c1d..e2d509383 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -4,6 +4,8 @@ import importlib import os from typing import Optional +from playhouse.shortcuts import model_to_dict + from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum from frigate.models import Event @@ -36,8 +38,9 @@ class GenAIClient: ) -> Optional[str]: """Generate a description for the frame.""" prompt = camera_config.genai.object_prompts.get( - event.label, camera_config.genai.prompt - ).format(**event) + event.label, + camera_config.genai.prompt, + ).format(**model_to_dict(event)) return self._send(prompt, thumbnails) def _init_provider(self): From 0ee32cf11006e91863b917c4faabee32e901aff8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 14 Oct 2024 09:23:08 -0500 Subject: [PATCH 02/12] Fix yaml bug and ensure embeddings progress doesn't show until all models are loaded (#14338) --- frigate/util/builtin.py | 15 +++++---------- web/src/pages/Explore.tsx | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 7c2c5790e..6dab16206 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -183,16 +183,11 @@ def update_yaml_from_url(file_path, url): update_yaml_file(file_path, key_path, new_value_list) else: value = new_value_list[0] - if "," in value: - # Skip conversion if we're a mask or zone string - update_yaml_file(file_path, key_path, value) - else: - try: - value = ast.literal_eval(value) - except (ValueError, SyntaxError): - pass - update_yaml_file(file_path, key_path, value) - + try: + # no need to convert if we have a mask/zone string + value = ast.literal_eval(value) if "," not in value else value + except (ValueError, SyntaxError): + pass update_yaml_file(file_path, key_path, value) diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 03a60a8d0..816618fe5 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -275,7 +275,7 @@ export default function Explore() {
Search Unavailable
- {embeddingsReindexing && ( + {embeddingsReindexing && allModelsLoaded && ( <>
Search can be used after tracked object embeddings have From dd7a07bd0d01a7bef5a689ccc88661a938dff3ea Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 14 Oct 2024 10:27:50 -0500 Subject: [PATCH 03/12] Add ability to rename camera groups (#14339) * Add ability to rename camera groups * clean up * ampersand consistency --- web/src/components/filter/CameraGroupSelector.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 731f440e4..d63fcd9bf 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -643,6 +643,11 @@ export function CameraGroupEdit({ setIsLoading(true); + let renamingQuery = ""; + if (editingGroup && editingGroup[0] !== values.name) { + renamingQuery = `camera_groups.${editingGroup[0]}&`; + } + const order = editingGroup === undefined ? currentGroups.length + 1 @@ -655,9 +660,12 @@ export function CameraGroupEdit({ .join(""); axios - .put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, { - requires_restart: 0, - }) + .put( + `config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}`, + { + requires_restart: 0, + }, + ) .then((res) => { if (res.status === 200) { toast.success(`Camera group (${values.name}) has been saved.`, { @@ -712,7 +720,6 @@ export function CameraGroupEdit({ From 887433fc6ab8cdab17806c2d028c16bf3b05d534 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 14 Oct 2024 15:23:02 -0600 Subject: [PATCH 04/12] Streaming download (#14346) * Send downloaded mp4 as a streaming response instead of a file * Add download button to UI * Formatting * Fix CSS and text Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * download video button component * use download button component in review detail dialog * better filename --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- frigate/api/media.py | 132 ++++++++---------- .../components/button/DownloadVideoButton.tsx | 75 ++++++++++ .../overlay/detail/ReviewDetailDialog.tsx | 22 ++- 3 files changed, 151 insertions(+), 78 deletions(-) create mode 100644 web/src/components/button/DownloadVideoButton.tsx diff --git a/frigate/api/media.py b/frigate/api/media.py index 5915875ab..d89774a6d 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -7,6 +7,7 @@ import os import subprocess as sp import time from datetime import datetime, timedelta, timezone +from pathlib import Path as FilePath from urllib.parse import unquote import cv2 @@ -450,8 +451,27 @@ def recording_clip( camera_name: str, start_ts: float, end_ts: float, - download: bool = False, ): + def run_download(ffmpeg_cmd: list[str], file_path: str): + with sp.Popen( + ffmpeg_cmd, + stderr=sp.PIPE, + stdout=sp.PIPE, + text=False, + ) as ffmpeg: + while True: + data = ffmpeg.stdout.read(1024) + if data is not None: + yield data + else: + if ffmpeg.returncode and ffmpeg.returncode != 0: + logger.error( + f"Failed to generate clip, ffmpeg logs: {ffmpeg.stderr.read()}" + ) + else: + FilePath(file_path).unlink(missing_ok=True) + break + recordings = ( Recordings.select( Recordings.path, @@ -467,18 +487,18 @@ def recording_clip( .order_by(Recordings.start_time.asc()) ) - playlist_lines = [] - clip: Recordings - for clip in recordings: - playlist_lines.append(f"file '{clip.path}'") - # if this is the starting clip, add an inpoint - if clip.start_time < start_ts: - playlist_lines.append(f"inpoint {int(start_ts - clip.start_time)}") - # if this is the ending clip, add an outpoint - if clip.end_time > end_ts: - playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}") - - file_name = sanitize_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4") + file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt") + file_path = f"/tmp/cache/{file_name}" + with open(file_path, "w") as file: + clip: Recordings + for clip in recordings: + file.write(f"file '{clip.path}'\n") + # if this is the starting clip, add an inpoint + if clip.start_time < start_ts: + file.write(f"inpoint {int(start_ts - clip.start_time)}\n") + # if this is the ending clip, add an outpoint + if clip.end_time > end_ts: + file.write(f"outpoint {int(end_ts - clip.start_time)}\n") if len(file_name) > 1000: return JSONResponse( @@ -489,67 +509,32 @@ def recording_clip( status_code=403, ) - path = os.path.join(CLIPS_DIR, f"cache/{file_name}") - config: FrigateConfig = request.app.frigate_config - if not os.path.exists(path): - ffmpeg_cmd = [ - config.ffmpeg.ffmpeg_path, - "-hide_banner", - "-y", - "-protocol_whitelist", - "pipe,file", - "-f", - "concat", - "-safe", - "0", - "-i", - "/dev/stdin", - "-c", - "copy", - "-movflags", - "+faststart", - path, - ] - p = sp.run( - ffmpeg_cmd, - input="\n".join(playlist_lines), - encoding="ascii", - capture_output=True, - ) + ffmpeg_cmd = [ + config.ffmpeg.ffmpeg_path, + "-hide_banner", + "-y", + "-protocol_whitelist", + "pipe,file", + "-f", + "concat", + "-safe", + "0", + "-i", + file_path, + "-c", + "copy", + "-movflags", + "frag_keyframe+empty_moov", + "-f", + "mp4", + "pipe:", + ] - if p.returncode != 0: - logger.error(p.stderr) - return JSONResponse( - content={ - "success": False, - "message": "Could not create clip from recordings", - }, - status_code=500, - ) - else: - logger.debug( - f"Ignoring subsequent request for {path} as it already exists in the cache." - ) - - headers = { - "Content-Description": "File Transfer", - "Cache-Control": "no-cache", - "Content-Type": "video/mp4", - "Content-Length": str(os.path.getsize(path)), - # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers - "X-Accel-Redirect": f"/clips/cache/{file_name}", - } - - if download: - headers["Content-Disposition"] = "attachment; filename=%s" % file_name - - return FileResponse( - path, + return StreamingResponse( + run_download(ffmpeg_cmd, file_path), media_type="video/mp4", - filename=file_name, - headers=headers, ) @@ -1028,7 +1013,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False @router.get("/events/{event_id}/clip.mp4") -def event_clip(request: Request, event_id: str, download: bool = False): +def event_clip(request: Request, event_id: str): try: event: Event = Event.get(Event.id == event_id) except DoesNotExist: @@ -1048,7 +1033,7 @@ def event_clip(request: Request, event_id: str, download: bool = False): end_ts = ( datetime.now().timestamp() if event.end_time is None else event.end_time ) - return recording_clip(request, event.camera, event.start_time, end_ts, download) + return recording_clip(request, event.camera, event.start_time, end_ts) headers = { "Content-Description": "File Transfer", @@ -1059,9 +1044,6 @@ def event_clip(request: Request, event_id: str, download: bool = False): "X-Accel-Redirect": f"/clips/{file_name}", } - if download: - headers["Content-Disposition"] = "attachment; filename=%s" % file_name - return FileResponse( clip_path, media_type="video/mp4", diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx new file mode 100644 index 000000000..ffb50098e --- /dev/null +++ b/web/src/components/button/DownloadVideoButton.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { FaDownload } from "react-icons/fa"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; + +type DownloadVideoButtonProps = { + source: string; + camera: string; + startTime: number; +}; + +export function DownloadVideoButton({ + source, + camera, + startTime, +}: DownloadVideoButtonProps) { + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = async () => { + setIsDownloading(true); + const formattedDate = formatUnixTimestampToDateTime(startTime, { + strftime_fmt: "%D-%T", + time_style: "medium", + date_style: "medium", + }); + const filename = `${camera}_${formattedDate}.mp4`; + + try { + const response = await fetch(source); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + toast.success( + "Your review item video has been downloaded successfully.", + { + position: "top-center", + }, + ); + } catch (error) { + toast.error( + "There was an error downloading the review item video. Please try again.", + { + position: "top-center", + }, + ); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+ +
+ ); +} diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index ae0397470..fb3a95b57 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -38,6 +38,8 @@ import { MobilePageTitle, } from "@/components/mobile/MobilePage"; import { useOverlayState } from "@/hooks/use-overlay-state"; +import { DownloadVideoButton } from "@/components/button/DownloadVideoButton"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; type ReviewDetailDialogProps = { review?: ReviewSegment; @@ -143,7 +145,7 @@ export default function ReviewDetailDialog({ Review item details
- Share this review item + + Share this review item + + + + + + + + Download +
@@ -180,7 +196,7 @@ export default function ReviewDetailDialog({
-
+
Objects
{events?.map((event) => { From 3879fde06d5f0b9402528efe141902f8d6434e3f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 14 Oct 2024 16:11:43 -0600 Subject: [PATCH 05/12] Don't allow unlimited unprocessed segments to stay in cache (#14341) * Don't allow unlimited unprocessed frames to stay in cache * Formatting --- frigate/record/maintainer.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index f43d1424f..314ff3646 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -142,6 +142,8 @@ class RecordingMaintainer(threading.Thread): ) ) ) + + # see if the recording mover is too slow and segments need to be deleted if processed_segment_count > keep_count: logger.warning( f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {processed_segment_count} and discarding the rest..." @@ -153,6 +155,21 @@ class RecordingMaintainer(threading.Thread): self.end_time_cache.pop(cache_path, None) grouped_recordings[camera] = grouped_recordings[camera][-keep_count:] + # see if detection has failed and unprocessed segments need to be deleted + unprocessed_segment_count = ( + len(grouped_recordings[camera]) - processed_segment_count + ) + if unprocessed_segment_count > keep_count: + logger.warning( + f"Too many unprocessed recording segments in cache for {camera}. This likely indicates an issue with the detect stream, keeping the {keep_count} most recent segments out of {unprocessed_segment_count} and discarding the rest..." + ) + to_remove = grouped_recordings[camera][:-keep_count] + for rec in to_remove: + cache_path = rec["cache_path"] + Path(cache_path).unlink(missing_ok=True) + self.end_time_cache.pop(cache_path, None) + grouped_recordings[camera] = grouped_recordings[camera][-keep_count:] + tasks = [] for camera, recordings in grouped_recordings.items(): # clear out all the object recording info for old frames From 0abd514064e9ad3e324cc4d9c364e8d5729cc711 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 14 Oct 2024 18:53:25 -0500 Subject: [PATCH 06/12] Use direct download link instead of blob method (#14347) --- .../components/button/DownloadVideoButton.tsx | 72 ++++++++----------- 1 file changed, 31 insertions(+), 41 deletions(-) diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx index ffb50098e..8a8e541fa 100644 --- a/web/src/components/button/DownloadVideoButton.tsx +++ b/web/src/components/button/DownloadVideoButton.tsx @@ -18,57 +18,47 @@ export function DownloadVideoButton({ }: DownloadVideoButtonProps) { const [isDownloading, setIsDownloading] = useState(false); - const handleDownload = async () => { - setIsDownloading(true); - const formattedDate = formatUnixTimestampToDateTime(startTime, { - strftime_fmt: "%D-%T", - time_style: "medium", - date_style: "medium", - }); - const filename = `${camera}_${formattedDate}.mp4`; + const formattedDate = formatUnixTimestampToDateTime(startTime, { + strftime_fmt: "%D-%T", + time_style: "medium", + date_style: "medium", + }); + const filename = `${camera}_${formattedDate}.mp4`; - try { - const response = await fetch(source); - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.style.display = "none"; - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - toast.success( - "Your review item video has been downloaded successfully.", - { - position: "top-center", - }, - ); - } catch (error) { - toast.error( - "There was an error downloading the review item video. Please try again.", - { - position: "top-center", - }, - ); - } finally { - setIsDownloading(false); - } + const handleDownloadStart = () => { + setIsDownloading(true); + toast.success("Your review item video has started downloading.", { + position: "top-center", + }); + }; + + const handleDownloadEnd = () => { + setIsDownloading(false); + toast.success("Download completed successfully.", { + position: "top-center", + }); }; return (
); From 0eccb6a610232394f60044d7dbe54c0c0b7df14f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 15 Oct 2024 07:17:54 -0600 Subject: [PATCH 07/12] Db fixes (#14364) * Handle case where embeddings overflow token limit * Set notification tokens * Fix sort --- frigate/api/auth.py | 1 + frigate/embeddings/embeddings.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 5276eb71e..8976469f5 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -357,6 +357,7 @@ def create_user(request: Request, body: AppPostUsersBody): { User.username: body.username, User.password_hash: password_hash, + User.notification_tokens: [], } ).execute() return JSONResponse(content={"username": body.username}) diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index cb0626f7b..e4937f955 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -6,6 +6,7 @@ import logging import os import time +import onnxruntime as ort from numpy import ndarray from PIL import Image from playhouse.shortcuts import model_to_dict @@ -174,7 +175,16 @@ class Embeddings: return embedding def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray: - embeddings = self.text_embedding(list(event_descriptions.values())) + descs = list(event_descriptions.values()) + + try: + embeddings = self.text_embedding(descs) + except ort.RuntimeException: + half_size = len(descs) / 2 + embeddings = [] + embeddings.extend(self.text_embedding(descs[0:half_size])) + embeddings.extend(self.text_embedding(descs[half_size:])) + ids = list(event_descriptions.keys()) items = [] From 644069fb239f2774d500b5a4e9b2477c1c8737c0 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 15 Oct 2024 08:24:47 -0500 Subject: [PATCH 08/12] Explore layout changes (#14348) * Reset selected index on new searches * Remove right click for similarity search * Fix sub label icon * add card footer * Add Frigate+ dialog * Move buttons and menu to thumbnail footer * Add similarity search * Show object score * Implement download buttons * remove confidence score * conditionally show submenu items * Implement delete * fix icon color * Add object lifecycle button * fix score * delete confirmation * small tweaks * consistent icons --------- Co-authored-by: Nicolas Mowen --- web/src/components/card/SearchThumbnail.tsx | 57 +++-- .../components/card/SearchThumbnailFooter.tsx | 198 ++++++++++++++++++ web/src/components/input/InputWithTags.tsx | 6 +- .../overlay/detail/SearchDetailDialog.tsx | 17 +- web/src/pages/Explore.tsx | 3 +- web/src/types/frigateConfig.ts | 1 + web/src/views/search/SearchView.tsx | 97 ++++----- 7 files changed, 283 insertions(+), 96 deletions(-) create mode 100644 web/src/components/card/SearchThumbnailFooter.tsx diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index fe174e968..4ad7d7c5c 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -1,50 +1,56 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useApiHost } from "@/api"; import { getIconForLabel } from "@/utils/iconUtil"; -import TimeAgo from "../dynamic/TimeAgo"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { isIOS, isSafari } from "react-device-detect"; import Chip from "@/components/indicators/Chip"; -import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useImageLoaded from "@/hooks/use-image-loaded"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; -import ActivityIndicator from "../indicators/activity-indicator"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { SearchResult } from "@/types/search"; -import useContextMenu from "@/hooks/use-contextmenu"; import { cn } from "@/lib/utils"; import { TooltipPortal } from "@radix-ui/react-tooltip"; type SearchThumbnailProps = { searchResult: SearchResult; - findSimilar: () => void; onClick: (searchResult: SearchResult) => void; }; export default function SearchThumbnail({ searchResult, - findSimilar, onClick, }: SearchThumbnailProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); - useContextMenu(imgRef, findSimilar); + // interactions const handleOnClick = useCallback(() => { onClick(searchResult); }, [searchResult, onClick]); - // date + const objectLabel = useMemo(() => { + if ( + !config || + !searchResult.sub_label || + !config.model.attributes_map[searchResult.label] + ) { + return searchResult.label; + } - const formattedDate = useFormattedTimestamp( - searchResult.start_time, - config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", - config?.ui.timezone, - ); + if ( + config.model.attributes_map[searchResult.label].includes( + searchResult.sub_label, + ) + ) { + return searchResult.sub_label; + } + + return `${searchResult.label}-verified`; + }, [config, searchResult]); return (
@@ -80,17 +86,21 @@ export default function SearchThumbnail({
onClick(searchResult)} > - {getIconForLabel(searchResult.label, "size-3 text-white")} + {getIconForLabel(objectLabel, "size-3 text-white")} + {Math.floor( + searchResult.score ?? searchResult.data.top_score * 100, + )} + %
- {[...new Set([searchResult.label])] + {[objectLabel] .filter( (item) => item !== undefined && !item.includes("-verified"), ) @@ -103,18 +113,7 @@ export default function SearchThumbnail({
-
-
- {searchResult.end_time ? ( - - ) : ( -
- -
- )} - {formattedDate} -
-
+
); diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx new file mode 100644 index 000000000..3e5dbe236 --- /dev/null +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -0,0 +1,198 @@ +import { useCallback, useState } from "react"; +import TimeAgo from "../dynamic/TimeAgo"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { SearchResult } from "@/types/search"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu"; +import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; +import { FrigatePlusDialog } from "../overlay/dialog/FrigatePlusDialog"; +import { Event } from "@/types/event"; +import { FaArrowsRotate } from "react-icons/fa6"; +import { baseUrl } from "@/api/baseUrl"; +import axios from "axios"; +import { toast } from "sonner"; +import { MdImageSearch } from "react-icons/md"; + +type SearchThumbnailProps = { + searchResult: SearchResult; + findSimilar: () => void; + refreshResults: () => void; + showObjectLifecycle: () => void; +}; + +export default function SearchThumbnailFooter({ + searchResult, + findSimilar, + refreshResults, + showObjectLifecycle, +}: SearchThumbnailProps) { + const { data: config } = useSWR("config"); + + // interactions + + const [showFrigatePlus, setShowFrigatePlus] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const handleDelete = useCallback(() => { + axios + .delete(`events/${searchResult.id}`) + .then((resp) => { + if (resp.status == 200) { + toast.success("Tracked object deleted successfully.", { + position: "top-center", + }); + refreshResults(); + } + }) + .catch(() => { + toast.error("Failed to delete tracked object.", { + position: "top-center", + }); + }); + }, [searchResult, refreshResults]); + + // date + + const formattedDate = useFormattedTimestamp( + searchResult.start_time, + config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", + config?.ui.timezone, + ); + + return ( + <> + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Are you sure you want to delete this tracked object? + + + Cancel + + Delete + + + + + setShowFrigatePlus(false)} + onEventUploaded={() => {}} + /> + +
+ {searchResult.end_time ? ( + + ) : ( +
+ +
+ )} + {formattedDate} +
+
+ {config?.plus?.enabled && + searchResult.has_snapshot && + searchResult.end_time && ( + + + setShowFrigatePlus(true)} + /> + + Submit to Frigate+ + + )} + + {config?.semantic_search?.enabled && ( + + + + + Find similar + + )} + + + + + + + + Tracked Object Actions + + + {searchResult.has_clip && ( + + + + Download video + + + )} + {searchResult.has_snapshot && ( + + + + Download snapshot + + + )} + + + View object lifecycle + + setDeleteDialogOpen(true)}> + + Delete + + + +
+ + ); +} diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx index 5d0786346..9ca1e4093 100644 --- a/web/src/components/input/InputWithTags.tsx +++ b/web/src/components/input/InputWithTags.tsx @@ -2,7 +2,6 @@ import React, { useState, useRef, useEffect, useCallback } from "react"; import { LuX, LuFilter, - LuImage, LuChevronDown, LuChevronUp, LuTrash2, @@ -44,6 +43,7 @@ import { import { toast } from "sonner"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; +import { MdImageSearch } from "react-icons/md"; type InputWithTagsProps = { inputFocused: boolean; @@ -514,7 +514,7 @@ export default function InputWithTags({ onFocus={handleInputFocus} onBlur={handleInputBlur} onKeyDown={handleInputKeyDown} - className="text-md h-9 pr-24" + className="text-md h-9 pr-32" placeholder="Search..." />
@@ -549,7 +549,7 @@ export default function InputWithTags({ {isSimilaritySearch && ( - diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 1cee70aaa..23734ea90 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -69,16 +69,20 @@ const SEARCH_TABS = [ "video", "object lifecycle", ] as const; -type SearchTab = (typeof SEARCH_TABS)[number]; +export type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { search?: SearchResult; + page: SearchTab; setSearch: (search: SearchResult | undefined) => void; + setSearchPage: (page: SearchTab) => void; setSimilarity?: () => void; }; export default function SearchDetailDialog({ search, + page, setSearch, + setSearchPage, setSimilarity, }: SearchDetailDialogProps) { const { data: config } = useSWR("config", { @@ -87,8 +91,11 @@ export default function SearchDetailDialog({ // tabs - const [page, setPage] = useState("details"); - const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + const [pageToggle, setPageToggle] = useOptimisticState( + page, + setSearchPage, + 100, + ); // dialog and mobile page @@ -130,9 +137,9 @@ export default function SearchDetailDialog({ } if (!searchTabs.includes(pageToggle)) { - setPage("details"); + setSearchPage("details"); } - }, [pageToggle, searchTabs]); + }, [pageToggle, searchTabs, setSearchPage]); if (!search) { return; diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 816618fe5..ffbef1060 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -384,6 +384,7 @@ export default function Explore() { searchFilter={searchFilter} searchResults={searchResults} isLoading={(isLoadingInitialData || isLoadingMore) ?? true} + hasMore={!isReachingEnd} setSearch={setSearch} setSimilaritySearch={(search) => { setSearchFilter({ @@ -395,7 +396,7 @@ export default function Explore() { setSearchFilter={setSearchFilter} onUpdateFilter={setSearchFilter} loadMore={loadMore} - hasMore={!isReachingEnd} + refresh={mutate} /> )} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index fe889ed9d..2c54b289e 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -340,6 +340,7 @@ export interface FrigateConfig { path: string | null; width: number; colormap: { [key: string]: [number, number, number] }; + attributes_map: { [key: string]: [string] }; }; motion: Record | null; diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index e64affa36..bc4f5b54d 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -1,8 +1,9 @@ import SearchThumbnail from "@/components/card/SearchThumbnail"; import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import ActivityIndicator from "@/components/indicators/activity-indicator"; -import Chip from "@/components/indicators/Chip"; -import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog"; +import SearchDetailDialog, { + SearchTab, +} from "@/components/overlay/detail/SearchDetailDialog"; import { Toaster } from "@/components/ui/sonner"; import { Tooltip, @@ -14,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobileOnly } from "react-device-detect"; -import { LuColumns, LuImage, LuSearchX, LuText } from "react-icons/lu"; +import { LuColumns, LuSearchX } from "react-icons/lu"; import useSWR from "swr"; import ExploreView from "../explore/ExploreView"; import useKeyboardListener, { @@ -25,7 +26,6 @@ import InputWithTags from "@/components/input/InputWithTags"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { isEqual } from "lodash"; import { formatDateToLocaleString } from "@/utils/dateUtil"; -import { TooltipPortal } from "@radix-ui/react-tooltip"; import { Slider } from "@/components/ui/slider"; import { Popover, @@ -33,6 +33,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { usePersistence } from "@/hooks/use-persistence"; +import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter"; type SearchViewProps = { search: string; @@ -40,12 +41,13 @@ type SearchViewProps = { searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; + hasMore: boolean; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; setSearchFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void; loadMore: () => void; - hasMore: boolean; + refresh: () => void; }; export default function SearchView({ search, @@ -53,12 +55,13 @@ export default function SearchView({ searchFilter, searchResults, isLoading, + hasMore, setSearch, setSimilaritySearch, setSearchFilter, onUpdateFilter, loadMore, - hasMore, + refresh, }: SearchViewProps) { const contentRef = useRef(null); const { data: config } = useSWR("config", { @@ -76,8 +79,6 @@ export default function SearchView({ "sm:grid-cols-4": effectiveColumnCount === 4, "sm:grid-cols-5": effectiveColumnCount === 5, "sm:grid-cols-6": effectiveColumnCount === 6, - "sm:grid-cols-7": effectiveColumnCount === 7, - "sm:grid-cols-8": effectiveColumnCount >= 8, }); // suggestions values @@ -161,16 +162,25 @@ export default function SearchView({ // detail const [searchDetail, setSearchDetail] = useState(); + const [page, setPage] = useState("details"); // search interaction const [selectedIndex, setSelectedIndex] = useState(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); - const onSelectSearch = useCallback((item: SearchResult, index: number) => { - setSearchDetail(item); - setSelectedIndex(index); - }, []); + const onSelectSearch = useCallback( + (item: SearchResult, index: number, page: SearchTab = "details") => { + setPage(page); + setSearchDetail(item); + setSelectedIndex(index); + }, + [], + ); + + useEffect(() => { + setSelectedIndex(0); + }, [searchTerm, searchFilter]); // update search detail when results change @@ -187,21 +197,6 @@ export default function SearchView({ } }, [searchResults, searchDetail]); - // confidence score - - const zScoreToConfidence = (score: number) => { - // Normalizing is not needed for similarity searches - // Sigmoid function for normalized: 1 / (1 + e^x) - // Cosine for similarity - if (searchFilter) { - const notNormalized = searchFilter?.search_type?.includes("similarity"); - - const confidence = notNormalized ? 1 - score : 1 / (1 + Math.exp(score)); - - return Math.round(confidence * 100); - } - }; - const hasExistingSearch = useMemo( () => searchResults != undefined || searchFilter != undefined, [searchResults, searchFilter], @@ -310,7 +305,9 @@ export default function SearchView({ setSimilaritySearch(searchDetail)) } @@ -388,47 +385,31 @@ export default function SearchView({ >
onSelectSearch(value, index)} + /> +
+
+
+ { if (config?.semantic_search.enabled) { setSimilaritySearch(value); } }} - onClick={() => onSelectSearch(value, index)} + refreshResults={refresh} + showObjectLifecycle={() => + onSelectSearch(value, index, "object lifecycle") + } /> - {(searchTerm || - searchFilter?.search_type?.includes("similarity")) && ( -
- - - - {value.search_source == "thumbnail" ? ( - - ) : ( - - )} - {zScoreToConfidence(value.search_distance)}% - - - - - Matched {value.search_source} at{" "} - {zScoreToConfidence(value.search_distance)}% - - - -
- )}
-
); })} @@ -467,7 +448,7 @@ export default function SearchView({ setColumnCount(value)} - max={8} + max={6} min={2} step={1} className="flex-grow" From 25043278ab9a05cf7b21a37ea6ce5c5dea5cf5d9 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 15 Oct 2024 07:40:45 -0600 Subject: [PATCH 09/12] Always run embedding descs one by one (#14365) --- frigate/embeddings/embeddings.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index e4937f955..f6901614f 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -175,15 +175,11 @@ class Embeddings: return embedding def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray: - descs = list(event_descriptions.values()) + # upsert embeddings one by one to avoid token limit + embeddings = [] - try: - embeddings = self.text_embedding(descs) - except ort.RuntimeException: - half_size = len(descs) / 2 - embeddings = [] - embeddings.extend(self.text_embedding(descs[0:half_size])) - embeddings.extend(self.text_embedding(descs[half_size:])) + for desc in event_descriptions.values(): + embeddings.append(self.text_embedding([desc])) ids = list(event_descriptions.keys()) From b75efcbca2fbacfd119cf4171e422bd16f7f7f6a Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 15 Oct 2024 09:37:04 -0600 Subject: [PATCH 10/12] UI tweaks (#14369) * Adjust text size * Make cursor consistent * Fix lint --- frigate/embeddings/embeddings.py | 1 - web/src/components/card/SearchThumbnailFooter.tsx | 12 +++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index f6901614f..19b28c6f8 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -6,7 +6,6 @@ import logging import os import time -import onnxruntime as ort from numpy import ndarray from PIL import Image from playhouse.shortcuts import model_to_dict diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx index 3e5dbe236..7947b7642 100644 --- a/web/src/components/card/SearchThumbnailFooter.tsx +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -112,7 +112,7 @@ export default function SearchThumbnailFooter({ onEventUploaded={() => {}} /> -
+
{searchResult.end_time ? ( ) : ( @@ -182,11 +182,17 @@ export default function SearchThumbnailFooter({ )} - + View object lifecycle - setDeleteDialogOpen(true)}> + setDeleteDialogOpen(true)} + > Delete From 3f1ab668999d3c27ee77e251200c0f3da81ec22f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 15 Oct 2024 18:25:59 -0600 Subject: [PATCH 11/12] Embeddings UI updates (#14378) * Handle Frigate+ submitted case * Add search settings and rename general to ui settings * Add platform aware sheet component * use two columns on mobile view * Add cameras page to more filters * clean up search settings view * Add time range to side filter * better match with ui settings * fix icon size * use two columns on mobile view * clean up search settings view * Add zones and saving logic * Add all filters to side panel * better match with ui settings * fix icon size * Fix mobile fitler page * Fix embeddings access * Cleanup * Fix scroll * fix double scrollbars and add separators on mobile too * two columns on mobile * italics for emphasis --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- frigate/embeddings/embeddings.py | 2 +- .../components/card/SearchThumbnailFooter.tsx | 33 +- .../components/filter/CamerasFilterButton.tsx | 100 ++- .../components/filter/SearchFilterGroup.tsx | 754 +----------------- .../overlay/dialog/PlatformAwareDialog.tsx | 52 ++ .../overlay/dialog/SearchFilterDialog.tsx | 448 +++++++++++ web/src/pages/Settings.tsx | 15 +- web/src/types/frigateConfig.ts | 5 +- web/src/views/search/SearchView.tsx | 18 +- web/src/views/settings/SearchSettingsView.tsx | 288 +++++++ ...ralSettingsView.tsx => UiSettingsView.tsx} | 2 +- 11 files changed, 919 insertions(+), 798 deletions(-) create mode 100644 web/src/components/overlay/dialog/SearchFilterDialog.tsx create mode 100644 web/src/views/settings/SearchSettingsView.tsx rename web/src/views/settings/{GeneralSettingsView.tsx => UiSettingsView.tsx} (99%) diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index 19b28c6f8..9ee508823 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -178,7 +178,7 @@ class Embeddings: embeddings = [] for desc in event_descriptions.values(): - embeddings.append(self.text_embedding([desc])) + embeddings.append(self.text_embedding([desc])[0]) ids = list(event_descriptions.keys()) diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx index 7947b7642..1a16b3ad0 100644 --- a/web/src/components/card/SearchThumbnailFooter.tsx +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -10,8 +10,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -33,6 +31,7 @@ import { baseUrl } from "@/api/baseUrl"; import axios from "axios"; import { toast } from "sonner"; import { MdImageSearch } from "react-icons/md"; +import { isMobileOnly } from "react-device-detect"; type SearchThumbnailProps = { searchResult: SearchResult; @@ -109,7 +108,9 @@ export default function SearchThumbnailFooter({ showFrigatePlus ? (searchResult as unknown as Event) : undefined } onClose={() => setShowFrigatePlus(false)} - onEventUploaded={() => {}} + onEventUploaded={() => { + searchResult.plus_id = "submitted"; + }} />
@@ -122,10 +123,12 @@ export default function SearchThumbnailFooter({ )} {formattedDate}
-
- {config?.plus?.enabled && +
+ {!isMobileOnly && + config?.plus?.enabled && searchResult.has_snapshot && - searchResult.end_time && ( + searchResult.end_time && + !searchResult.plus_id && ( - - Tracked Object Actions - - {searchResult.has_clip && ( View object lifecycle + + {isMobileOnly && + config?.plus?.enabled && + searchResult.has_snapshot && + searchResult.end_time && + !searchResult.plus_id && ( + setShowFrigatePlus(true)} + > + + Submit to Frigate+ + + )} setDeleteDialogOpen(true)} diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index 563af8752..94f1a838e 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -69,6 +69,70 @@ export function CamerasFilterButton({ ); const content = ( + + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type CamerasFilterContentProps = { + allCameras: string[]; + currentCameras: string[] | undefined; + groups: [string, CameraGroupConfig][]; + setCurrentCameras: (cameras: string[] | undefined) => void; + setOpen: (open: boolean) => void; + updateCameraFilter: (cameras: string[] | undefined) => void; +}; +export function CamerasFilterContent({ + allCameras, + currentCameras, + groups, + setCurrentCameras, + setOpen, + updateCameraFilter, +}: CamerasFilterContentProps) { + return ( <> {isMobile && ( <> @@ -158,40 +222,4 @@ export function CamerasFilterButton({
); - - if (isMobile) { - return ( - { - if (!open) { - setCurrentCameras(selectedCameras); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - - return ( - { - if (!open) { - setCurrentCameras(selectedCameras); - } - setOpen(open); - }} - > - {trigger} - {content} - - ); } diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 8ddb3fee6..5fe301f19 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -1,5 +1,4 @@ import { Button } from "../ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -10,25 +9,19 @@ import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; import FilterSwitch from "./FilterSwitch"; import { FilterList } from "@/types/filter"; -import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton"; import { DEFAULT_SEARCH_FILTERS, SearchFilter, SearchFilters, SearchSource, - DEFAULT_TIME_RANGE_AFTER, - DEFAULT_TIME_RANGE_BEFORE, } from "@/types/search"; import { DateRange } from "react-day-picker"; import { cn } from "@/lib/utils"; -import SubFilterIcon from "../icons/SubFilterIcon"; -import { FaLocationDot } from "react-icons/fa6"; import { MdLabel } from "react-icons/md"; -import SearchSourceIcon from "../icons/SearchSourceIcon"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; -import { FaArrowRight, FaClock } from "react-icons/fa"; -import { useFormattedHour } from "@/hooks/use-date-utils"; +import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog"; +import { CalendarRangeFilterButton } from "./CalendarFilterButton"; type SearchFilterGroupProps = { className: string; @@ -79,8 +72,6 @@ export default function SearchFilterGroup({ return [...labels].sort(); }, [config, filterList, filter]); - const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]); - const allZones = useMemo(() => { if (filterList?.zones) { return filterList.zones; @@ -159,6 +150,15 @@ export default function SearchFilterGroup({ }} /> )} + {filters.includes("general") && ( + { + onUpdateFilter({ ...filter, labels: newLabels }); + }} + /> + )} {filters.includes("date") && ( )} - {filters.includes("time") && ( - - onUpdateFilter({ ...filter, time_range }) - } - /> - )} - {filters.includes("zone") && allZones.length > 0 && ( - - onUpdateFilter({ ...filter, zones: newZones }) - } - /> - )} - {filters.includes("general") && ( - { - onUpdateFilter({ ...filter, labels: newLabels }); - }} - /> - )} - {filters.includes("sub") && ( - - onUpdateFilter({ ...filter, sub_labels: newSubLabels }) - } - /> - )} - {config?.semantic_search?.enabled && - filters.includes("source") && - !filter?.search_type?.includes("similarity") && ( - - onUpdateFilter({ ...filter, search_type: newSearchSource }) - } - /> - )} +
); } @@ -397,681 +355,3 @@ export function GeneralFilterContent({ ); } - -type TimeRangeFilterButtonProps = { - config?: FrigateConfig; - timeRange?: string; - updateTimeRange: (range: string | undefined) => void; -}; -function TimeRangeFilterButton({ - config, - timeRange, - updateTimeRange, -}: TimeRangeFilterButtonProps) { - const [open, setOpen] = useState(false); - const [startOpen, setStartOpen] = useState(false); - const [endOpen, setEndOpen] = useState(false); - - const [afterHour, beforeHour] = useMemo(() => { - if (!timeRange || !timeRange.includes(",")) { - return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE]; - } - - return timeRange.split(","); - }, [timeRange]); - - const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour); - const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour); - - // format based on locale - - const formattedAfter = useFormattedHour(config, afterHour); - const formattedBefore = useFormattedHour(config, beforeHour); - const formattedSelectedAfter = useFormattedHour(config, selectedAfterHour); - const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour); - - useEffect(() => { - setSelectedAfterHour(afterHour); - setSelectedBeforeHour(beforeHour); - // only refresh when state changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timeRange]); - - const trigger = ( - - ); - const content = ( -
-
- { - if (!open) { - setStartOpen(false); - } - }} - > - - - - - { - const clock = e.target.value; - const [hour, minute, _] = clock.split(":"); - setSelectedAfterHour(`${hour}:${minute}`); - }} - /> - - - - { - if (!open) { - setEndOpen(false); - } - }} - > - - - - - { - const clock = e.target.value; - const [hour, minute, _] = clock.split(":"); - setSelectedBeforeHour(`${hour}:${minute}`); - }} - /> - - -
- -
- - -
-
- ); - - return ( - { - setOpen(open); - }} - /> - ); -} - -type ZoneFilterButtonProps = { - allZones: string[]; - selectedZones?: string[]; - updateZoneFilter: (zones: string[] | undefined) => void; -}; -function ZoneFilterButton({ - allZones, - selectedZones, - updateZoneFilter, -}: ZoneFilterButtonProps) { - const [open, setOpen] = useState(false); - - const [currentZones, setCurrentZones] = useState( - selectedZones, - ); - - const buttonText = useMemo(() => { - if (isMobile) { - return "Zones"; - } - - if (!selectedZones || selectedZones.length == 0) { - return "All Zones"; - } - - if (selectedZones.length == 1) { - return selectedZones[0]; - } - - return `${selectedZones.length} Zones`; - }, [selectedZones]); - - // ui - - useEffect(() => { - setCurrentZones(selectedZones); - // only refresh when state changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedZones]); - - const trigger = ( - - ); - const content = ( - setOpen(false)} - /> - ); - - return ( - { - if (!open) { - setCurrentZones(selectedZones); - } - - setOpen(open); - }} - /> - ); -} - -type ZoneFilterContentProps = { - allZones?: string[]; - selectedZones?: string[]; - currentZones?: string[]; - updateZoneFilter?: (zones: string[] | undefined) => void; - setCurrentZones?: (zones: string[] | undefined) => void; - onClose: () => void; -}; -export function ZoneFilterContent({ - allZones, - selectedZones, - currentZones, - updateZoneFilter, - setCurrentZones, - onClose, -}: ZoneFilterContentProps) { - return ( - <> -
- {allZones && setCurrentZones && ( - <> - {isDesktop && } -
- - { - if (isChecked) { - setCurrentZones(undefined); - } - }} - /> -
-
- {allZones.map((item) => ( - { - if (isChecked) { - const updatedZones = currentZones - ? [...currentZones] - : []; - - updatedZones.push(item); - setCurrentZones(updatedZones); - } else { - const updatedZones = currentZones - ? [...currentZones] - : []; - - // can not deselect the last item - if (updatedZones.length > 1) { - updatedZones.splice(updatedZones.indexOf(item), 1); - setCurrentZones(updatedZones); - } - } - }} - /> - ))} -
- - )} -
- {isDesktop && } -
- - -
- - ); -} - -type SubFilterButtonProps = { - allSubLabels: string[]; - selectedSubLabels: string[] | undefined; - updateSubLabelFilter: (labels: string[] | undefined) => void; -}; -function SubFilterButton({ - allSubLabels, - selectedSubLabels, - updateSubLabelFilter, -}: SubFilterButtonProps) { - const [open, setOpen] = useState(false); - const [currentSubLabels, setCurrentSubLabels] = useState< - string[] | undefined - >(selectedSubLabels); - - const buttonText = useMemo(() => { - if (isMobile) { - return "Sub Labels"; - } - - if (!selectedSubLabels || selectedSubLabels.length == 0) { - return "All Sub Labels"; - } - - if (selectedSubLabels.length == 1) { - return selectedSubLabels[0]; - } - - return `${selectedSubLabels.length} Sub Labels`; - }, [selectedSubLabels]); - - const trigger = ( - - ); - const content = ( - setOpen(false)} - /> - ); - - return ( - { - if (!open) { - setCurrentSubLabels(selectedSubLabels); - } - - setOpen(open); - }} - /> - ); -} - -type SubFilterContentProps = { - allSubLabels: string[]; - selectedSubLabels: string[] | undefined; - currentSubLabels: string[] | undefined; - updateSubLabelFilter: (labels: string[] | undefined) => void; - setCurrentSubLabels: (labels: string[] | undefined) => void; - onClose: () => void; -}; -export function SubFilterContent({ - allSubLabels, - selectedSubLabels, - currentSubLabels, - updateSubLabelFilter, - setCurrentSubLabels, - onClose, -}: SubFilterContentProps) { - return ( - <> -
-
- - { - if (isChecked) { - setCurrentSubLabels(undefined); - } - }} - /> -
-
- {allSubLabels.map((item) => ( - { - if (isChecked) { - const updatedLabels = currentSubLabels - ? [...currentSubLabels] - : []; - - updatedLabels.push(item); - setCurrentSubLabels(updatedLabels); - } else { - const updatedLabels = currentSubLabels - ? [...currentSubLabels] - : []; - - // can not deselect the last item - if (updatedLabels.length > 1) { - updatedLabels.splice(updatedLabels.indexOf(item), 1); - setCurrentSubLabels(updatedLabels); - } - } - }} - /> - ))} -
-
- {isDesktop && } -
- - -
- - ); -} - -type SearchTypeButtonProps = { - selectedSearchSources: SearchSource[] | undefined; - updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void; -}; -function SearchTypeButton({ - selectedSearchSources, - updateSearchSourceFilter, -}: SearchTypeButtonProps) { - const [open, setOpen] = useState(false); - - const buttonText = useMemo(() => { - if (isMobile) { - return "Sources"; - } - - if ( - !selectedSearchSources || - selectedSearchSources.length == 0 || - selectedSearchSources.length == 2 - ) { - return "All Search Sources"; - } - - if (selectedSearchSources.length == 1) { - return selectedSearchSources[0]; - } - - return `${selectedSearchSources.length} Search Sources`; - }, [selectedSearchSources]); - - const trigger = ( - - ); - const content = ( - setOpen(false)} - /> - ); - - return ( - - ); -} - -type SearchTypeContentProps = { - selectedSearchSources: SearchSource[] | undefined; - updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void; - onClose: () => void; -}; -export function SearchTypeContent({ - selectedSearchSources, - updateSearchSourceFilter, - onClose, -}: SearchTypeContentProps) { - const [currentSearchSources, setCurrentSearchSources] = useState< - SearchSource[] | undefined - >(selectedSearchSources); - - return ( - <> -
-
- { - const updatedSources = currentSearchSources - ? [...currentSearchSources] - : []; - - if (isChecked) { - updatedSources.push("thumbnail"); - setCurrentSearchSources(updatedSources); - } else { - if (updatedSources.length > 1) { - const index = updatedSources.indexOf("thumbnail"); - if (index !== -1) updatedSources.splice(index, 1); - setCurrentSearchSources(updatedSources); - } - } - }} - /> - { - const updatedSources = currentSearchSources - ? [...currentSearchSources] - : []; - - if (isChecked) { - updatedSources.push("description"); - setCurrentSearchSources(updatedSources); - } else { - if (updatedSources.length > 1) { - const index = updatedSources.indexOf("description"); - if (index !== -1) updatedSources.splice(index, 1); - setCurrentSearchSources(updatedSources); - } - } - }} - /> -
- {isDesktop && } -
- - -
-
- - ); -} diff --git a/web/src/components/overlay/dialog/PlatformAwareDialog.tsx b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx index cd84d299c..79ee64f71 100644 --- a/web/src/components/overlay/dialog/PlatformAwareDialog.tsx +++ b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx @@ -1,9 +1,16 @@ +import { + MobilePage, + MobilePageContent, + MobilePageHeader, + MobilePageTitle, +} from "@/components/mobile/MobilePage"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { isMobile } from "react-device-detect"; type PlatformAwareDialogProps = { @@ -42,3 +49,48 @@ export default function PlatformAwareDialog({ ); } + +type PlatformAwareSheetProps = { + trigger: JSX.Element; + content: JSX.Element; + triggerClassName?: string; + contentClassName?: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}; +export function PlatformAwareSheet({ + trigger, + content, + triggerClassName = "", + contentClassName = "", + open, + onOpenChange, +}: PlatformAwareSheetProps) { + if (isMobile) { + return ( +
+
onOpenChange(true)}>{trigger}
+ + + onOpenChange(false)} + > + More Filters + +
{content}
+
+
+
+ ); + } + + return ( + + + {trigger} + + {content} + + ); +} diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx new file mode 100644 index 000000000..676e86cff --- /dev/null +++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx @@ -0,0 +1,448 @@ +import { FaArrowRight, FaCog } from "react-icons/fa"; + +import { useEffect, useMemo, useState } from "react"; +import { PlatformAwareSheet } from "./PlatformAwareDialog"; +import { Button } from "@/components/ui/button"; +import useSWR from "swr"; +import { + DEFAULT_TIME_RANGE_AFTER, + DEFAULT_TIME_RANGE_BEFORE, + SearchFilter, + SearchSource, +} from "@/types/search"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { isDesktop, isMobileOnly } from "react-device-detect"; +import { useFormattedHour } from "@/hooks/use-date-utils"; +import Heading from "@/components/ui/heading"; +import FilterSwitch from "@/components/filter/FilterSwitch"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +type SearchFilterDialogProps = { + config?: FrigateConfig; + filter?: SearchFilter; + filterValues: { + cameras: string[]; + labels: string[]; + zones: string[]; + search_type: SearchSource[]; + }; + onUpdateFilter: (filter: SearchFilter) => void; +}; +export default function SearchFilterDialog({ + config, + filter, + filterValues, + onUpdateFilter, +}: SearchFilterDialogProps) { + // data + + const [currentFilter, setCurrentFilter] = useState(filter ?? {}); + const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]); + + // state + + const [open, setOpen] = useState(false); + + const trigger = ( + + ); + const content = ( +
+ + setCurrentFilter({ ...currentFilter, time_range: newRange }) + } + /> + + setCurrentFilter({ ...currentFilter, zones: newZones }) + } + /> + + setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels }) + } + /> + + onUpdateFilter({ ...currentFilter, search_type: newSearchSource }) + } + /> + {isDesktop && } +
+ + +
+
+ ); + + return ( + { + if (!open) { + setCurrentFilter(filter ?? {}); + } + + setOpen(open); + }} + /> + ); +} + +type TimeRangeFilterContentProps = { + config?: FrigateConfig; + timeRange?: string; + updateTimeRange: (range: string | undefined) => void; +}; +function TimeRangeFilterContent({ + config, + timeRange, + updateTimeRange, +}: TimeRangeFilterContentProps) { + const [startOpen, setStartOpen] = useState(false); + const [endOpen, setEndOpen] = useState(false); + + const [afterHour, beforeHour] = useMemo(() => { + if (!timeRange || !timeRange.includes(",")) { + return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE]; + } + + return timeRange.split(","); + }, [timeRange]); + + const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour); + const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour); + + // format based on locale + + const formattedSelectedAfter = useFormattedHour(config, selectedAfterHour); + const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour); + + useEffect(() => { + setSelectedAfterHour(afterHour); + setSelectedBeforeHour(beforeHour); + // only refresh when state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeRange]); + + useEffect(() => { + if ( + selectedAfterHour == DEFAULT_TIME_RANGE_AFTER && + selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE + ) { + updateTimeRange(undefined); + } else { + updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`); + } + // only refresh when state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedAfterHour, selectedBeforeHour]); + + return ( +
+ Time Range +
+ { + if (!open) { + setStartOpen(false); + } + }} + > + + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedAfterHour(`${hour}:${minute}`); + }} + /> + + + + { + if (!open) { + setEndOpen(false); + } + }} + > + + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedBeforeHour(`${hour}:${minute}`); + }} + /> + + +
+
+ ); +} + +type ZoneFilterContentProps = { + allZones?: string[]; + zones?: string[]; + updateZones: (zones: string[] | undefined) => void; +}; +export function ZoneFilterContent({ + allZones, + zones, + updateZones, +}: ZoneFilterContentProps) { + return ( + <> +
+ + Zones + {allZones && ( + <> +
+ + { + if (isChecked) { + updateZones(undefined); + } + }} + /> +
+
+ {allZones.map((item) => ( + { + if (isChecked) { + const updatedZones = zones ? [...zones] : []; + + updatedZones.push(item); + updateZones(updatedZones); + } else { + const updatedZones = zones ? [...zones] : []; + + // can not deselect the last item + if (updatedZones.length > 1) { + updatedZones.splice(updatedZones.indexOf(item), 1); + updateZones(updatedZones); + } + } + }} + /> + ))} +
+ + )} +
+ + ); +} + +type SubFilterContentProps = { + allSubLabels: string[]; + subLabels: string[] | undefined; + setSubLabels: (labels: string[] | undefined) => void; +}; +export function SubFilterContent({ + allSubLabels, + subLabels, + setSubLabels, +}: SubFilterContentProps) { + return ( +
+ + Sub Labels +
+ + { + if (isChecked) { + setSubLabels(undefined); + } + }} + /> +
+
+ {allSubLabels.map((item) => ( + { + if (isChecked) { + const updatedLabels = subLabels ? [...subLabels] : []; + + updatedLabels.push(item); + setSubLabels(updatedLabels); + } else { + const updatedLabels = subLabels ? [...subLabels] : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setSubLabels(updatedLabels); + } + } + }} + /> + ))} +
+
+ ); +} + +type SearchTypeContentProps = { + searchSources: SearchSource[] | undefined; + setSearchSources: (sources: SearchSource[] | undefined) => void; +}; +export function SearchTypeContent({ + searchSources, + setSearchSources, +}: SearchTypeContentProps) { + return ( + <> +
+ + Search Sources +
+ { + const updatedSources = searchSources ? [...searchSources] : []; + + if (isChecked) { + updatedSources.push("thumbnail"); + setSearchSources(updatedSources); + } else { + if (updatedSources.length > 1) { + const index = updatedSources.indexOf("thumbnail"); + if (index !== -1) updatedSources.splice(index, 1); + setSearchSources(updatedSources); + } + } + }} + /> + { + const updatedSources = searchSources ? [...searchSources] : []; + + if (isChecked) { + updatedSources.push("description"); + setSearchSources(updatedSources); + } else { + if (updatedSources.length > 1) { + const index = updatedSources.indexOf("description"); + if (index !== -1) updatedSources.splice(index, 1); + setSearchSources(updatedSources); + } + } + }} + /> +
+
+ + ); +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 6d147b332..4ba29dd08 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -29,16 +29,18 @@ import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { PolygonType } from "@/types/canvas"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import scrollIntoView from "scroll-into-view-if-needed"; -import GeneralSettingsView from "@/views/settings/GeneralSettingsView"; import CameraSettingsView from "@/views/settings/CameraSettingsView"; import ObjectSettingsView from "@/views/settings/ObjectSettingsView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import AuthenticationView from "@/views/settings/AuthenticationView"; import NotificationView from "@/views/settings/NotificationsSettingsView"; +import SearchSettingsView from "@/views/settings/SearchSettingsView"; +import UiSettingsView from "@/views/settings/UiSettingsView"; const allSettingsViews = [ - "general", + "UI settings", + "search settings", "camera settings", "masks / zones", "motion tuner", @@ -49,7 +51,7 @@ const allSettingsViews = [ type SettingsType = (typeof allSettingsViews)[number]; export default function Settings() { - const [page, setPage] = useState("general"); + const [page, setPage] = useState("UI settings"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const tabsRef = useRef(null); @@ -140,7 +142,7 @@ export default function Settings() { {Object.values(settingsViews).map((item) => (
- {page == "general" && } + {page == "UI settings" && } + {page == "search settings" && ( + + )} {page == "debug" && ( )} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 2c54b289e..76d9cfa67 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -27,6 +27,8 @@ export const ATTRIBUTE_LABELS = [ "ups", ]; +export type SearchModelSize = "small" | "large"; + export interface CameraConfig { audio: { enabled: boolean; @@ -418,7 +420,8 @@ export interface FrigateConfig { semantic_search: { enabled: boolean; - model_size: string; + reindex: boolean; + model_size: SearchModelSize; }; snapshots: { diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index bc4f5b54d..9427cdcff 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -73,13 +73,17 @@ export default function SearchView({ const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4); const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]); - const gridClassName = cn("grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", { - "sm:grid-cols-2": effectiveColumnCount <= 2, - "sm:grid-cols-3": effectiveColumnCount === 3, - "sm:grid-cols-4": effectiveColumnCount === 4, - "sm:grid-cols-5": effectiveColumnCount === 5, - "sm:grid-cols-6": effectiveColumnCount === 6, - }); + const gridClassName = cn( + "grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", + isMobileOnly && "grid-cols-2", + { + "sm:grid-cols-2": effectiveColumnCount <= 2, + "sm:grid-cols-3": effectiveColumnCount === 3, + "sm:grid-cols-4": effectiveColumnCount === 4, + "sm:grid-cols-5": effectiveColumnCount === 5, + "sm:grid-cols-6": effectiveColumnCount === 6, + }, + ); // suggestions values diff --git a/web/src/views/settings/SearchSettingsView.tsx b/web/src/views/settings/SearchSettingsView.tsx new file mode 100644 index 000000000..a08816675 --- /dev/null +++ b/web/src/views/settings/SearchSettingsView.tsx @@ -0,0 +1,288 @@ +import Heading from "@/components/ui/heading"; +import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig"; +import useSWR from "swr"; +import axios from "axios"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useCallback, useContext, useEffect, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import { Separator } from "@/components/ui/separator"; +import { Link } from "react-router-dom"; +import { LuExternalLink } from "react-icons/lu"; +import { StatusBarMessagesContext } from "@/context/statusbar-provider"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; + +type SearchSettingsViewProps = { + setUnsavedChanges: React.Dispatch>; +}; + +type SearchSettings = { + enabled?: boolean; + reindex?: boolean; + model_size?: SearchModelSize; +}; + +export default function SearchSettingsView({ + setUnsavedChanges, +}: SearchSettingsViewProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + const [changedValue, setChangedValue] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + + const [searchSettings, setSearchSettings] = useState({ + enabled: undefined, + reindex: undefined, + model_size: undefined, + }); + + const [origSearchSettings, setOrigSearchSettings] = useState({ + enabled: undefined, + reindex: undefined, + model_size: undefined, + }); + + useEffect(() => { + if (config) { + if (searchSettings?.enabled == undefined) { + setSearchSettings({ + enabled: config.semantic_search.enabled, + reindex: config.semantic_search.reindex, + model_size: config.semantic_search.model_size, + }); + } + + setOrigSearchSettings({ + enabled: config.semantic_search.enabled, + reindex: config.semantic_search.reindex, + model_size: config.semantic_search.model_size, + }); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); + + const handleSearchConfigChange = (newConfig: Partial) => { + setSearchSettings((prevConfig) => ({ ...prevConfig, ...newConfig })); + setUnsavedChanges(true); + setChangedValue(true); + }; + + const saveToConfig = useCallback(async () => { + setIsLoading(true); + + axios + .put( + `config/set?semantic_search.enabled=${searchSettings.enabled}&semantic_search.reindex=${searchSettings.reindex}&semantic_search.model_size=${searchSettings.model_size}`, + ) + .then((res) => { + if (res.status === 200) { + toast.success("Search settings have been saved.", { + position: "top-center", + }); + setChangedValue(false); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, [ + updateConfig, + searchSettings.enabled, + searchSettings.reindex, + searchSettings.model_size, + ]); + + const onCancel = useCallback(() => { + setSearchSettings(origSearchSettings); + setChangedValue(false); + removeMessage("search_settings", "search_settings"); + }, [origSearchSettings, removeMessage]); + + useEffect(() => { + if (changedValue) { + addMessage( + "search_settings", + `Unsaved search settings changes`, + undefined, + "search_settings", + ); + } else { + removeMessage("search_settings", "search_settings"); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedValue]); + + useEffect(() => { + document.title = "Search Settings - Frigate"; + }, []); + + if (!config) { + return ; + } + + return ( +
+ +
+ + Search Settings + + + + Semantic Search + +
+
+

+ Semantic Search in Frigate allows you to find tracked objects + within your review items using either the image itself, a + user-defined text description, or an automatically generated one. +

+ +
+ + Read the Documentation + + +
+
+
+ +
+
+ { + handleSearchConfigChange({ enabled: isChecked }); + }} + /> +
+ +
+
+
+
+ { + handleSearchConfigChange({ reindex: isChecked }); + }} + /> +
+ +
+
+
+ Re-indexing will reprocess all thumbnails and descriptions (if + enabled) and apply the embeddings on each startup.{" "} + Don't forget to disable the option after restarting! +
+
+
+
+
Model Size
+
+

+ The size of the model used for semantic search embeddings. +

+
    +
  • + Using small employs a quantized version of the + model that uses less RAM and runs faster on CPU with a very + negligible difference in embedding quality. +
  • +
  • + Using large employs the full Jina model and will + automatically run on the GPU if applicable. +
  • +
+
+
+ +
+
+ + +
+ + +
+
+
+ ); +} diff --git a/web/src/views/settings/GeneralSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx similarity index 99% rename from web/src/views/settings/GeneralSettingsView.tsx rename to web/src/views/settings/UiSettingsView.tsx index 0cb7689f6..c212073c1 100644 --- a/web/src/views/settings/GeneralSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -22,7 +22,7 @@ import { const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; const WEEK_STARTS_ON = ["Sunday", "Monday"]; -export default function GeneralSettingsView() { +export default function UiSettingsView() { const { data: config } = useSWR("config"); const clearStoredLayouts = useCallback(() => { From eda52a3b8273961db60d367c25f552522c514bd6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 16 Oct 2024 07:15:25 -0500 Subject: [PATCH 12/12] Search and search filter UI tweaks (#14381) * fix search type switches * select/unselect style for more filters button * fix reset button * fix labels scrollbar * set min width and remove modal to allow scrolling with filters open * hover colors * better match of font size * stop sheet from displaying console errors * fix detail dialog behavior --- .../components/card/SearchThumbnailFooter.tsx | 8 +-- .../components/filter/SearchFilterGroup.tsx | 8 ++- .../overlay/detail/SearchDetailDialog.tsx | 19 ++--- .../overlay/dialog/PlatformAwareDialog.tsx | 25 ++++++- .../overlay/dialog/SearchFilterDialog.tsx | 71 +++++++++++++------ 5 files changed, 86 insertions(+), 45 deletions(-) diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx index 1a16b3ad0..af7606b37 100644 --- a/web/src/components/card/SearchThumbnailFooter.tsx +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -113,7 +113,7 @@ export default function SearchThumbnailFooter({ }} /> -
+
{searchResult.end_time ? ( ) : ( @@ -132,7 +132,7 @@ export default function SearchThumbnailFooter({ setShowFrigatePlus(true)} /> @@ -144,7 +144,7 @@ export default function SearchThumbnailFooter({ @@ -154,7 +154,7 @@ export default function SearchThumbnailFooter({ - + {searchResult.has_clip && ( diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 5fe301f19..ef816bb9f 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -253,7 +253,11 @@ function GeneralFilterButton({ { if (!open) { @@ -284,7 +288,7 @@ export function GeneralFilterContent({ }: GeneralFilterContentProps) { return ( <> -
+