diff --git a/docs/docs/configuration/genai/review_summaries.md b/docs/docs/configuration/genai/review_summaries.md index 99ee48d0ff..9851ec2f62 100644 --- a/docs/docs/configuration/genai/review_summaries.md +++ b/docs/docs/configuration/genai/review_summaries.md @@ -112,6 +112,17 @@ review: - animals in the garden ``` +### Preferred Language + +By default, review summaries are generated in English. You can configure Frigate to generate summaries in your preferred language by setting the `preferred_language` option: + +```yaml +review: + genai: + enabled: true + preferred_language: Spanish +``` + ## Review Reports Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review. diff --git a/web/src/App.tsx b/web/src/App.tsx index b458d9ec3c..d7a9ec3e9d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import { AuthProvider } from "@/context/auth-context"; import useSWR from "swr"; import { FrigateConfig } from "./types/frigateConfig"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { isRedirectingToLogin } from "@/api/auth-redirect"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -58,6 +59,16 @@ function DefaultAppView() { ? Object.keys(config.auth.roles) : undefined; + // Show loading indicator during redirect to prevent React from attempting to render + // lazy components, which would cause error #426 (suspension during synchronous navigation) + if (isRedirectingToLogin()) { + return ( +
+ +
+ ); + } + return (
{isDesktop && } diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx index 55edc60bd1..cedf5a15ac 100644 --- a/web/src/components/auth/ProtectedRoute.tsx +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -28,6 +28,14 @@ export default function ProtectedRoute({ } }, [auth.isLoading, auth.isAuthenticated, auth.user]); + // Show loading indicator during redirect to prevent React from attempting to render + // lazy components, which would cause error #426 (suspension during synchronous navigation) + if (isRedirectingToLogin()) { + return ( + + ); + } + if (auth.isLoading) { return ( diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index 85b5df235c..b5ba5cfea1 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -181,7 +181,7 @@ export default function ReviewCard({ key={`${object}-${idx}`} className="rounded-full bg-muted-foreground p-1" > - {getIconForLabel(object, "size-3 text-white")} + {getIconForLabel(object, "object", "size-3 text-white")}
))} {event.data.audio.map((audio, idx) => ( @@ -189,7 +189,7 @@ export default function ReviewCard({ key={`${audio}-${idx}`} className="rounded-full bg-muted-foreground p-1" > - {getIconForLabel(audio, "size-3 text-white")} + {getIconForLabel(audio, "audio", "size-3 text-white")} ))} diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index 0b82475c84..66f58f4fd9 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -133,7 +133,11 @@ export default function SearchThumbnail({ className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`} onClick={() => onClick(searchResult, false, true)} > - {getIconForLabel(objectLabel, "size-3 text-white")} + {getIconForLabel( + objectLabel, + searchResult.data.type, + "size-3 text-white", + )} {Math.floor( (searchResult.data.score ?? searchResult.data.top_score ?? diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index bd4368ebe2..01e211eec5 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -1296,7 +1296,11 @@ function ObjectDetailsTab({ {t("details.label")}
- {getIconForLabel(search.label, "size-4 text-primary")} + {getIconForLabel( + search.label, + search.data.type, + "size-4 text-primary", + )} {getTranslatedLabel(search.label, search.data.type)} {search.sub_label && ` (${search.sub_label})`} {isAdmin && search.end_time && ( diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 80471b8bdf..a5a3cc33f8 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -665,6 +665,7 @@ export function TrackingDetails({ > {getIconForLabel( event.sub_label ? event.label + "-verified" : event.label, + event.data.type, "size-4 text-white", )}
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 9500688f57..3dc1b9e34f 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -358,7 +358,11 @@ export default function LivePlayer({ ]), ] .map((label) => { - return getIconForLabel(label, "size-3 text-white"); + return getIconForLabel( + label, + "object", + "size-3 text-white", + ); }) .sort()} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 872f7c98a7..30339599ab 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -262,10 +262,18 @@ export default function PreviewThumbnailPlayer({ onClick={() => onClick(review, false, true)} > {review.data.objects.sort().map((object) => { - return getIconForLabel(object, "size-3 text-white"); + return getIconForLabel( + object, + "object", + "size-3 text-white", + ); })} {review.data.audio.map((audio) => { - return getIconForLabel(audio, "size-3 text-white"); + return getIconForLabel( + audio, + "audio", + "size-3 text-white", + ); })} diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index c6413ed976..ac560a4df0 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -14,6 +14,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import ActivityIndicator from "../indicators/activity-indicator"; import { Event } from "@/types/event"; +import { EventType } from "@/types/search"; import { getIconForLabel } from "@/utils/iconUtil"; import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu"; @@ -346,22 +347,29 @@ function ReviewGroup({ : null, ); - const rawIconLabels: string[] = [ + const rawIconLabels: Array<{ label: string; type: EventType }> = [ ...(fetchedEvents - ? fetchedEvents.map((e) => - e.sub_label ? e.label + "-verified" : e.label, - ) - : (review.data?.objects ?? [])), - ...(review.data?.audio ?? []), + ? fetchedEvents.map((e) => ({ + label: e.sub_label ? e.label + "-verified" : e.label, + type: e.data.type, + })) + : (review.data?.objects ?? []).map((obj) => ({ + label: obj, + type: "object" as EventType, + }))), + ...(review.data?.audio ?? []).map((audio) => ({ + label: audio, + type: "audio" as EventType, + })), ]; // limit to 5 icons const seen = new Set(); - const iconLabels: string[] = []; - for (const lbl of rawIconLabels) { - if (!seen.has(lbl)) { - seen.add(lbl); - iconLabels.push(lbl); + const iconLabels: Array<{ label: string; type: EventType }> = []; + for (const item of rawIconLabels) { + if (!seen.has(item.label)) { + seen.add(item.label); + iconLabels.push(item); if (iconLabels.length >= 5) break; } } @@ -418,12 +426,12 @@ function ReviewGroup({
{displayTime}
- {iconLabels.slice(0, 5).map((lbl, idx) => ( + {iconLabels.slice(0, 5).map(({ label: lbl, type }, idx) => (
- {getIconForLabel(lbl, "size-3 text-white")} + {getIconForLabel(lbl, type, "size-3 text-white")}
))}
@@ -516,7 +524,11 @@ function ReviewGroup({ >
- {getIconForLabel(audioLabel, "size-3 text-white")} + {getIconForLabel( + audioLabel, + "audio", + "size-3 text-white", + )}
{getTranslatedLabel(audioLabel, "audio")}
@@ -618,6 +630,7 @@ function EventList({ > {getIconForLabel( event.sub_label ? event.label + "-verified" : event.label, + event.data.type, "size-3 text-white", )}
diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index 11be7cb9e7..156b4529bd 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -1,5 +1,6 @@ import { IconName } from "@/components/icons/IconPicker"; import { FrigateConfig } from "@/types/frigateConfig"; +import { EventType } from "@/types/search"; import { BsPersonWalking } from "react-icons/bs"; import { FaAmazon, @@ -32,6 +33,7 @@ import { GiRabbit, GiRaccoonHead, GiSailboat, + GiSoundWaves, GiSquirrel, } from "react-icons/gi"; import { LuBox, LuLassoSelect, LuScanBarcode } from "react-icons/lu"; @@ -56,11 +58,15 @@ export function isValidIconName(value: string): value is IconName { return Object.keys(LuIcons).includes(value as IconName); } -export function getIconForLabel(label: string, className?: string) { +export function getIconForLabel( + label: string, + type: EventType = "object", + className?: string, +) { if (label.endsWith("-verified")) { - return getVerifiedIcon(label, className); + return getVerifiedIcon(label, className, type); } else if (label.endsWith("-plate")) { - return getRecognizedPlateIcon(label, className); + return getRecognizedPlateIcon(label, className, type); } switch (label) { @@ -152,27 +158,38 @@ export function getIconForLabel(label: string, className?: string) { case "usps": return ; default: + if (type === "audio") { + return ; + } return ; } } -function getVerifiedIcon(label: string, className?: string) { +function getVerifiedIcon( + label: string, + className?: string, + type: EventType = "object", +) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return (
- {getIconForLabel(simpleLabel, className)} + {getIconForLabel(simpleLabel, type, className)}
); } -function getRecognizedPlateIcon(label: string, className?: string) { +function getRecognizedPlateIcon( + label: string, + className?: string, + type: EventType = "object", +) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return (
- {getIconForLabel(simpleLabel, className)} + {getIconForLabel(simpleLabel, type, className)}
); diff --git a/web/src/views/classification/ModelTrainingView.tsx b/web/src/views/classification/ModelTrainingView.tsx index ec7ce0472c..10d52075d6 100644 --- a/web/src/views/classification/ModelTrainingView.tsx +++ b/web/src/views/classification/ModelTrainingView.tsx @@ -88,7 +88,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { // title useEffect(() => { - document.title = `${model.name} - ${t("documentTitle")}`; + document.title = `${model.name.toUpperCase()} - ${t("documentTitle")}`; }, [model.name, t]); // model state diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx index 82977e80c3..3c03269b50 100644 --- a/web/src/views/settings/ObjectSettingsView.tsx +++ b/web/src/views/settings/ObjectSettingsView.tsx @@ -406,7 +406,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) { : getColorForObjectName(obj.label), }} > - {getIconForLabel(obj.label, "size-5 text-white")} + {getIconForLabel(obj.label, "object", "size-5 text-white")}
{getTranslatedLabel(obj.label)} @@ -494,7 +494,7 @@ function AudioList({ cameraConfig, audioDetections }: AudioListProps) {
- {getIconForLabel(key, "size-5 text-white")} + {getIconForLabel(key, "audio", "size-5 text-white")}
{getTranslatedLabel(key)}