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