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 == 2024.3.*
# Embeddings
chromadb == 0.5.0
chromadb == 0.5.7
onnx_clip == 4.0.*
# Generative AI
google-generativeai == 0.6.*

View File

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

View File

@ -357,6 +357,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
zones = params.zones
after = params.after
before = params.before
time_range = params.time_range
# for similarity search
event_id = params.event_id
@ -403,36 +404,85 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
if include_thumbnails:
selected_columns.append(Event.thumbnail)
# Build the where clause for the embeddings query
embeddings_filters = []
# Build the initial SQLite query filters
event_filters = []
if cameras != "all":
camera_list = cameras.split(",")
embeddings_filters.append({"camera": {"$in": camera_list}})
event_filters.append((Event.camera << camera_list))
if labels != "all":
label_list = labels.split(",")
embeddings_filters.append({"label": {"$in": label_list}})
event_filters.append((Event.label << label_list))
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(",")
zone_filters = [{f"zones_{zone}": {"$eq": True}} for zone in filtered_zones]
if len(zone_filters) > 1:
embeddings_filters.append({"$or": zone_filters})
else:
embeddings_filters.append(zone_filters[0])
if "None" in filtered_zones:
filtered_zones.remove("None")
zone_clauses.append((Event.zones.length() == 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:
embeddings_filters.append({"start_time": {"$gt": after}})
event_filters.append((Event.start_time > after))
if before:
embeddings_filters.append({"start_time": {"$lt": before}})
event_filters.append((Event.start_time < before))
where = None
if len(embeddings_filters) > 1:
where = {"$and": embeddings_filters}
elif len(embeddings_filters) == 1:
where = embeddings_filters[0]
if time_range != DEFAULT_TIME_RANGE:
# get timezone arg to ensure browser times are used
tz_name = params.timezone
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
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 = {}
desc_ids = {}

View File

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

View File

@ -15,6 +15,7 @@ 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;
@ -95,16 +96,18 @@ export default function SearchThumbnail({
</div>
</TooltipTrigger>
</div>
<TooltipContent className="capitalize">
{[...new Set([searchResult.label])]
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
.map((text) => capitalizeFirstLetter(text))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
<TooltipPortal>
<TooltipContent className="capitalize">
{[...new Set([searchResult.label])]
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
.map((text) => capitalizeFirstLetter(text))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</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
key={name}
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}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,13 +11,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import {
DEFAULT_SEARCH_FILTERS,
SearchFilter,
SearchFilters,
SearchResult,
SearchSource,
} from "@/types/search";
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isMobileOnly } from "react-device-detect";
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 { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type SearchViewProps = {
search: string;
@ -144,20 +139,6 @@ export default function SearchView({
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
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",
)}
filter={searchFilter}
filters={selectedFilters as SearchFilters[]}
onUpdateFilter={onUpdateFilter}
/>
<ScrollBar orientation="horizontal" className="h-0" />
@ -401,14 +381,16 @@ export default function SearchView({
%
</Chip>
</TooltipTrigger>
<TooltipContent>
Matched {value.search_source} at{" "}
{zScoreToConfidence(
value.search_distance,
value.search_source,
)}
%
</TooltipContent>
<TooltipPortal>
<TooltipContent>
Matched {value.search_source} at{" "}
{zScoreToConfidence(
value.search_distance,
value.search_source,
)}
%
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
)}