diff --git a/web/src/components/overlay/chip/GenAISummaryChip.tsx b/web/src/components/overlay/chip/GenAISummaryChip.tsx new file mode 100644 index 000000000..138582b21 --- /dev/null +++ b/web/src/components/overlay/chip/GenAISummaryChip.tsx @@ -0,0 +1,112 @@ +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; +import { cn } from "@/lib/utils"; +import { ReviewSegment, ThreatLevel } from "@/types/review"; +import { useEffect, useMemo, useState } from "react"; +import { isDesktop } from "react-device-detect"; +import { useTranslation } from "react-i18next"; +import { MdAutoAwesome } from "react-icons/md"; + +type GenAISummaryChipProps = { + review?: ReviewSegment; + onClick: () => void; +}; +export function GenAISummaryChip({ review, onClick }: GenAISummaryChipProps) { + if (!review?.data?.metadata) { + return null; + } + + return ( +
+ + {review.data.metadata.title} +
+ ); +} + +type GenAISummaryDialogProps = { + review?: ReviewSegment; + onOpen?: (open: boolean) => void; +}; +export function GenAISummaryDialog({ + review, + onOpen, +}: GenAISummaryDialogProps) { + const { t } = useTranslation(["views/explore"]); + + // data + + const aiAnalysis = useMemo(() => review?.data?.metadata, [review]); + const aiThreatLevel = useMemo(() => { + if ( + !aiAnalysis || + (!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns) + ) { + return "None"; + } + + let concerns = ""; + switch (aiAnalysis.potential_threat_level) { + case ThreatLevel.SUSPICIOUS: + concerns = `• ${t("suspiciousActivity", { ns: "views/events" })}\n`; + break; + case ThreatLevel.DANGER: + concerns = `• ${t("threateningActivity", { ns: "views/events" })}\n`; + break; + } + + (aiAnalysis.other_concerns ?? []).forEach((c) => { + concerns += `• ${c}\n`; + }); + + return concerns || "None"; + }, [aiAnalysis, t]); + + // layout + + const [open, setOpen] = useState(false); + const Overlay = isDesktop ? Dialog : Drawer; + const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; + const Content = isDesktop ? DialogContent : DrawerContent; + + useEffect(() => { + if (onOpen) { + onOpen(open); + } + }, [open, onOpen]); + + if (!aiAnalysis) { + return null; + } + + return ( + + + setOpen(true)} /> + + + {t("aiAnalysis.title")} +
+ {t("details.description.label")} +
+
{aiAnalysis.scene}
+
+ {t("details.score.label")} +
+
{aiAnalysis.confidence * 100}%
+
{t("concerns.label")}
+
{aiThreatLevel}
+
+
+ ); +} diff --git a/web/src/types/review.ts b/web/src/types/review.ts index e0385ae83..cd1aefff5 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -19,6 +19,7 @@ export type ReviewData = { significant_motion_areas: number[]; zones: string[]; metadata?: { + title: string; scene: string; confidence: number; potential_threat_level?: number; diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 9af2284ce..6ae284cd7 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -66,6 +66,7 @@ import { } from "@/components/ui/tooltip"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; +import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip"; type RecordingViewProps = { startCamera: string; @@ -458,6 +459,29 @@ export function RecordingView({ [visiblePreviewObserver.current], ); + const activeReviewItem = useMemo(() => { + if (!config?.cameras?.[mainCamera].review.genai?.enabled_in_config) { + return undefined; + } + + return mainCameraReviewItems.find( + (rev) => + rev.start_time < currentTime && + rev.end_time && + currentTime < rev.end_time, + ); + }, [config, currentTime, mainCameraReviewItems, mainCamera]); + const onAnalysisOpen = useCallback( + (open: boolean) => { + if (open) { + mainControllerRef.current?.pause(); + } else { + mainControllerRef.current?.play(); + } + }, + [mainControllerRef], + ); + return (
@@ -654,6 +678,11 @@ export function RecordingView({ : Math.max(1, getCameraAspect(mainCamera) ?? 0), }} > + +