Search functionality and UI tweaks (#13978)

* Portal tooltips

* Add ability to time_range filter chroma searches

* centering and padding consistency

* add event id back to chroma metadata

* query sqlite first and pass those ids to chroma for embeddings search

* ensure we pass timezone to the api call

* remove object lifecycle from search details for non-object events

* simplify hour calculation

* fix query without filters

* bump chroma version

* chroma 0.5.7

* fix selecting camera group in cameras filter button
This commit is contained in:
Josh Hawkins 2024-09-26 15:30:56 -05:00 committed by GitHub
parent 20fd1db0f4
commit 40fe3b4358
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 127 additions and 84 deletions

View File

@ -32,7 +32,7 @@ unidecode == 1.3.*
# OpenVino (ONNX installed in wheels-post) # OpenVino (ONNX installed in wheels-post)
openvino == 2024.3.* openvino == 2024.3.*
# Embeddings # Embeddings
chromadb == 0.5.0 chromadb == 0.5.7
onnx_clip == 4.0.* onnx_clip == 4.0.*
# Generative AI # Generative AI
google-generativeai == 0.6.* google-generativeai == 0.6.*

View File

@ -43,6 +43,7 @@ class EventsSearchQueryParams(BaseModel):
zones: Optional[str] = "all" zones: Optional[str] = "all"
after: Optional[float] = None after: Optional[float] = None
before: Optional[float] = None before: Optional[float] = None
time_range: Optional[str] = DEFAULT_TIME_RANGE
timezone: Optional[str] = "utc" timezone: Optional[str] = "utc"

View File

@ -357,6 +357,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
zones = params.zones zones = params.zones
after = params.after after = params.after
before = params.before before = params.before
time_range = params.time_range
# for similarity search # for similarity search
event_id = params.event_id event_id = params.event_id
@ -403,36 +404,85 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
if include_thumbnails: if include_thumbnails:
selected_columns.append(Event.thumbnail) selected_columns.append(Event.thumbnail)
# Build the where clause for the embeddings query # Build the initial SQLite query filters
embeddings_filters = [] event_filters = []
if cameras != "all": if cameras != "all":
camera_list = cameras.split(",") camera_list = cameras.split(",")
embeddings_filters.append({"camera": {"$in": camera_list}}) event_filters.append((Event.camera << camera_list))
if labels != "all": if labels != "all":
label_list = labels.split(",") label_list = labels.split(",")
embeddings_filters.append({"label": {"$in": label_list}}) event_filters.append((Event.label << label_list))
if zones != "all": if zones != "all":
# use matching so events with multiple zones
# still match on a search where any zone matches
zone_clauses = []
filtered_zones = zones.split(",") filtered_zones = zones.split(",")
zone_filters = [{f"zones_{zone}": {"$eq": True}} for zone in filtered_zones]
if len(zone_filters) > 1: if "None" in filtered_zones:
embeddings_filters.append({"$or": zone_filters}) filtered_zones.remove("None")
else: zone_clauses.append((Event.zones.length() == 0))
embeddings_filters.append(zone_filters[0])
for zone in filtered_zones:
zone_clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
zone_clause = reduce(operator.or_, zone_clauses)
event_filters.append((zone_clause))
if after: if after:
embeddings_filters.append({"start_time": {"$gt": after}}) event_filters.append((Event.start_time > after))
if before: if before:
embeddings_filters.append({"start_time": {"$lt": before}}) event_filters.append((Event.start_time < before))
where = None if time_range != DEFAULT_TIME_RANGE:
if len(embeddings_filters) > 1: # get timezone arg to ensure browser times are used
where = {"$and": embeddings_filters} tz_name = params.timezone
elif len(embeddings_filters) == 1: hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
where = embeddings_filters[0]
times = time_range.split(",")
time_after = times[0]
time_before = times[1]
start_hour_fun = fn.strftime(
"%H:%M",
fn.datetime(Event.start_time, "unixepoch", hour_modifier, minute_modifier),
)
# cases where user wants events overnight, ex: from 20:00 to 06:00
# should use or operator
if time_after > time_before:
event_filters.append(
(
reduce(
operator.or_,
[(start_hour_fun > time_after), (start_hour_fun < time_before)],
)
)
)
# all other cases should be and operator
else:
event_filters.append((start_hour_fun > time_after))
event_filters.append((start_hour_fun < time_before))
if event_filters:
filtered_event_ids = (
Event.select(Event.id)
.where(reduce(operator.and_, event_filters))
.tuples()
.iterator()
)
event_ids = [event_id[0] for event_id in filtered_event_ids]
if not event_ids:
return JSONResponse(content=[]) # No events to search on
else:
event_ids = []
# Build the Chroma where clause based on the event IDs
where = {"id": {"$in": event_ids}} if event_ids else {}
thumb_ids = {} thumb_ids = {}
desc_ids = {} desc_ids = {}

View File

@ -43,7 +43,7 @@ def get_metadata(event: Event) -> dict:
{ {
k: v k: v
for k, v in event_dict.items() for k, v in event_dict.items()
if k not in ["id", "thumbnail"] if k not in ["thumbnail"]
and v is not None and v is not None
and isinstance(v, (str, int, float, bool)) and isinstance(v, (str, int, float, bool))
} }

View File

@ -15,6 +15,7 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import useContextMenu from "@/hooks/use-contextmenu"; import useContextMenu from "@/hooks/use-contextmenu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type SearchThumbnailProps = { type SearchThumbnailProps = {
searchResult: SearchResult; searchResult: SearchResult;
@ -95,16 +96,18 @@ export default function SearchThumbnail({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipContent className="capitalize"> <TooltipPortal>
{[...new Set([searchResult.label])] <TooltipContent className="capitalize">
.filter( {[...new Set([searchResult.label])]
(item) => item !== undefined && !item.includes("-verified"), .filter(
) (item) => item !== undefined && !item.includes("-verified"),
.map((text) => capitalizeFirstLetter(text)) )
.sort() .map((text) => capitalizeFirstLetter(text))
.join(", ") .sort()
.replaceAll("-verified", "")} .join(", ")
</TooltipContent> .replaceAll("-verified", "")}
</TooltipContent>
</TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
<div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div> <div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>

View File

@ -112,7 +112,10 @@ export function CamerasFilterButton({
<div <div
key={name} key={name}
className="w-full cursor-pointer rounded-lg px-2 py-0.5 text-sm capitalize text-primary hover:bg-muted" className="w-full cursor-pointer rounded-lg px-2 py-0.5 text-sm capitalize text-primary hover:bg-muted"
onClick={() => setCurrentCameras([...conf.cameras])} onClick={() => {
setAllCamerasSelected(false);
setCurrentCameras([...conf.cameras]);
}}
> >
{name} {name}
</div> </div>

View File

@ -27,7 +27,7 @@ function Bottombar() {
isPWA && isIOS isPWA && isIOS
? "portrait:items-start portrait:pt-1 landscape:items-center" ? "portrait:items-start portrait:pt-1 landscape:items-center"
: "items-center", : "items-center",
isMobile && !isPWA && "h-12 landscape:md:h-16", isMobile && !isPWA && "h-12 md:h-16",
)} )}
> >
{navItems.map((item) => ( {navItems.map((item) => (

View File

@ -201,21 +201,24 @@ export default function MobileReviewSettingsDrawer({
Calendar Calendar
</div> </div>
</div> </div>
<ReviewActivityCalendar <div className="flex w-full flex-row justify-center">
reviewSummary={reviewSummary} <ReviewActivityCalendar
selectedDay={ reviewSummary={reviewSummary}
filter?.after == undefined selectedDay={
? undefined filter?.after == undefined
: new Date(filter.after * 1000) ? undefined
} : new Date(filter.after * 1000)
onSelect={(day) => { }
onUpdateFilter({ onSelect={(day) => {
...filter, onUpdateFilter({
after: day == undefined ? undefined : day.getTime() / 1000, ...filter,
before: day == undefined ? undefined : getEndOfDayTimestamp(day), after: day == undefined ? undefined : day.getTime() / 1000,
}); before:
}} day == undefined ? undefined : getEndOfDayTimestamp(day),
/> });
}}
/>
</div>
<SelectSeparator /> <SelectSeparator />
<div className="flex items-center justify-center p-2"> <div className="flex items-center justify-center p-2">
<Button <Button

View File

@ -95,6 +95,11 @@ export default function SearchDetailDialog({
views.splice(index, 1); views.splice(index, 1);
} }
if (search.data.type != "object") {
const index = views.indexOf("object lifecycle");
views.splice(index, 1);
}
// TODO implement // TODO implement
//if (!config.semantic_search.enabled) { //if (!config.semantic_search.enabled) {
// const index = views.indexOf("similar-calendar"); // const index = views.indexOf("similar-calendar");

View File

@ -26,7 +26,7 @@ export default function PlatformAwareDialog({
return ( return (
<Drawer open={open} onOpenChange={onOpenChange}> <Drawer open={open} onOpenChange={onOpenChange}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger> <DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4"> <DrawerContent className="max-h-[75dvh] overflow-hidden px-4">
{content} {content}
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>

View File

@ -82,14 +82,9 @@ export function useFormattedHour(
const [hour, minute] = time.includes(":") ? time.split(":") : [time, "00"]; const [hour, minute] = time.includes(":") ? time.split(":") : [time, "00"];
const hourNum = parseInt(hour); const hourNum = parseInt(hour);
if (hourNum < 12) { const adjustedHour = hourNum % 12 || 12;
if (hourNum == 0) { const period = hourNum < 12 ? "AM" : "PM";
return `12:${minute} AM`;
}
return `${hourNum}:${minute} AM`; return `${adjustedHour}:${minute} ${period}`;
} else {
return `${hourNum - 12}:${minute} PM`;
}
}, [hour24, time]); }, [hour24, time]);
} }

View File

@ -103,6 +103,7 @@ export default function Explore() {
time_range: searchSearchParams["time_range"], time_range: searchSearchParams["time_range"],
search_type: searchSearchParams["search_type"], search_type: searchSearchParams["search_type"],
event_id: searchSearchParams["event_id"], event_id: searchSearchParams["event_id"],
timezone,
include_thumbnails: 0, include_thumbnails: 0,
}, },
]; ];

View File

@ -11,13 +11,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
DEFAULT_SEARCH_FILTERS,
SearchFilter,
SearchFilters,
SearchResult,
SearchSource,
} from "@/types/search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { LuImage, LuSearchX, LuText } from "react-icons/lu"; import { LuImage, LuSearchX, LuText } from "react-icons/lu";
@ -31,6 +25,7 @@ import InputWithTags from "@/components/input/InputWithTags";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil"; import { formatDateToLocaleString } from "@/utils/dateUtil";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -144,20 +139,6 @@ export default function SearchView({
const [searchDetail, setSearchDetail] = useState<SearchResult>(); const [searchDetail, setSearchDetail] = useState<SearchResult>();
const selectedFilters = useMemo<SearchFilters[]>(() => {
const filters = [...DEFAULT_SEARCH_FILTERS];
if (
searchFilter &&
(searchFilter?.query?.length || searchFilter?.event_id?.length)
) {
const index = filters.indexOf("time");
filters.splice(index, 1);
}
return filters;
}, [searchFilter]);
// search interaction // search interaction
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
@ -335,7 +316,6 @@ export default function SearchView({
"w-full justify-between md:justify-start lg:justify-end", "w-full justify-between md:justify-start lg:justify-end",
)} )}
filter={searchFilter} filter={searchFilter}
filters={selectedFilters as SearchFilters[]}
onUpdateFilter={onUpdateFilter} onUpdateFilter={onUpdateFilter}
/> />
<ScrollBar orientation="horizontal" className="h-0" /> <ScrollBar orientation="horizontal" className="h-0" />
@ -401,14 +381,16 @@ export default function SearchView({
% %
</Chip> </Chip>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipPortal>
Matched {value.search_source} at{" "} <TooltipContent>
{zScoreToConfidence( Matched {value.search_source} at{" "}
value.search_distance, {zScoreToConfidence(
value.search_source, value.search_distance,
)} value.search_source,
% )}
</TooltipContent> %
</TooltipContent>
</TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
)} )}