Add ability to manually run Review Descriptions from the UI / API (#24222)

* Add ability to manually run Review Descriptions from the UI / API

* Fix not handling None type for call
This commit is contained in:
Nicolas Mowen
2026-09-12 07:30:04 -06:00
parent 82be9fff5e
commit f9347967eb
11 changed files with 409 additions and 39 deletions
@@ -21,7 +21,9 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useReviewDescriptions } from "@/hooks/use-review-descriptions";
import MultiExportDialog from "../overlay/MultiExportDialog";
import { MdAutoAwesome } from "react-icons/md";
type ReviewActionGroupProps = {
selectedReviews: ReviewSegment[];
@@ -45,6 +47,13 @@ export default function ReviewActionGroup({
(review) => review.has_been_reviewed,
);
const { canGenerateDescription, generateDescription } =
useReviewDescriptions();
// only a single item can be sent through the descriptions process at a time
const showGenerateDescription =
selectedReviews.length == 1 && canGenerateDescription(selectedReviews[0]);
const onToggleReviewed = useCallback(async () => {
const ids = selectedReviews.map((review) => review.id);
await axios.post(`reviews/viewed`, {
@@ -166,6 +175,24 @@ export default function ReviewActionGroup({
)}
</Button>
)}
{showGenerateDescription && (
<Button
className="flex items-center gap-2 p-2"
aria-label={t("recording.button.generateDescription")}
size="sm"
onClick={() => {
generateDescription(selectedReviews[0]);
onClearSelected();
}}
>
<MdAutoAwesome className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("recording.button.generateDescription")}
</div>
)}
</Button>
)}
{selectedReviews.length >= 2 &&
selectedReviews.length <= MAX_BATCH_EXPORT_ITEMS && (
<MultiExportDialog
+68
View File
@@ -0,0 +1,68 @@
import axios from "axios";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import useSWR from "swr";
import { useIsAdmin } from "./use-is-admin";
import { GenAIRolesResponse } from "@/types/chat";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
// recordings are the only source used for on demand runs, and their frames
// cost far more tokens than preview frames do
export const MIN_REVIEW_DESCRIPTION_CONTEXT = 32000;
/**
* Gating and dispatch for re-running a review item through GenAI descriptions.
*/
export function useReviewDescriptions() {
const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
const isAdmin = useIsAdmin();
const { data: genaiRoles } = useSWR<GenAIRolesResponse>(
isAdmin ? "genai/roles" : null,
{ revalidateOnFocus: false },
);
const hasSufficientContext = useMemo(
() =>
(genaiRoles?.descriptions?.context_size ?? 0) >=
MIN_REVIEW_DESCRIPTION_CONTEXT,
[genaiRoles],
);
const canGenerateDescription = useCallback(
(review: ReviewSegment) =>
isAdmin &&
hasSufficientContext &&
!!review.end_time &&
!!config?.cameras[review.camera]?.review?.genai?.enabled,
[config, hasSufficientContext, isAdmin],
);
const generateDescription = useCallback(
(review: ReviewSegment) => {
axios
.put(`review/${review.id}/regenerate_description`)
.then(() => {
toast.success(t("recording.genaiDescription.toast.success"), {
position: "top-center",
});
})
.catch((error) => {
const errorMessage =
error.response?.data?.message || error.message || "Unknown error";
toast.error(
t("recording.genaiDescription.toast.error", {
error: errorMessage,
}),
{ position: "top-center" },
);
});
},
[t],
);
return { canGenerateDescription, generateDescription };
}
+9
View File
@@ -1,3 +1,4 @@
import { GenAIRole } from "@/types/frigateConfig";
export type ToolCallFunction = {
name: string;
arguments: string;
@@ -57,3 +58,11 @@ export type GenAIProviderInfo = {
};
export type GenAIModelsResponse = Record<string, GenAIProviderInfo>;
export type GenAIRoleInfo = {
name: string;
model: string;
context_size: number;
};
export type GenAIRolesResponse = Partial<Record<GenAIRole, GenAIRoleInfo>>;