From 3f1ab668999d3c27ee77e251200c0f3da81ec22f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 15 Oct 2024 18:25:59 -0600 Subject: [PATCH] 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(() => {