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),
}}
>
+
+