mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-25 18:55:25 -06:00
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:
parent
20fd1db0f4
commit
40fe3b4358
@ -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.*
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
@ -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 = {}
|
||||||
|
@ -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))
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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) => (
|
||||||
|
@ -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
|
||||||
|
@ -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");
|
||||||
|
@ -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>
|
||||||
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
Loading…
Reference in New Issue
Block a user