mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-24 12:49:01 +03:00
Miscellaneous fixes (#23092)
* lpr fixes - remove duplicate code - fix min_area check for non frigate+ code path - move log outside of non frigate+ code path * only show chat link when a genai provider is configured with the chat role * respect ui.timezone when generating fallback export names * reapply radix pointer events fix to call sites that use navigate() * formatting * fall back to prior preview frame for short export thumbnails * fix typing * fix e2e test for chat navigation * batch annotation offset to seek atomically and throttle slider drag * add debug replay loading toast for explore actions * Improve handling of webpush missing shortSummary --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
co-authored by
Nicolas Mowen
parent
6a2b914b10
commit
147cd5cc2b
@@ -69,17 +69,18 @@ test.describe("Navigation — conditional items @critical", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("/chat is hidden when genai.model is none (desktop)", async ({
|
||||
test("/chat is hidden when no agent has the chat role (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
genai: {
|
||||
enabled: false,
|
||||
provider: "ollama",
|
||||
model: "none",
|
||||
base_url: "",
|
||||
descriptions_only: {
|
||||
provider: "ollama",
|
||||
model: "llava",
|
||||
roles: ["descriptions"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -89,12 +90,20 @@ test.describe("Navigation — conditional items @critical", () => {
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("/chat is visible when genai.model is set (desktop)", async ({
|
||||
test("/chat is visible when an agent has the chat role (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: { genai: { enabled: true, model: "llava" } },
|
||||
config: {
|
||||
genai: {
|
||||
chat_agent: {
|
||||
provider: "ollama",
|
||||
model: "llava",
|
||||
roles: ["chat"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
await expect(
|
||||
|
||||
@@ -93,6 +93,14 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
useSWR<ProfilesApiResponse>("profiles");
|
||||
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
||||
|
||||
const hasChatAgent = useMemo(
|
||||
() =>
|
||||
Object.values(config?.genai ?? {}).some((agent) =>
|
||||
agent?.roles?.includes("chat"),
|
||||
),
|
||||
[config?.genai],
|
||||
);
|
||||
|
||||
// languages
|
||||
|
||||
const languages = useMemo(() => {
|
||||
@@ -511,7 +519,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>{t("menu.classification")}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
{config?.genai?.model !== "none" && (
|
||||
{hasChatAgent && (
|
||||
<Link to="/chat">
|
||||
<MenuItem
|
||||
className="flex w-full items-center p-2 text-sm"
|
||||
|
||||
@@ -90,6 +90,10 @@ export default function SearchResultActions({
|
||||
const handleDebugReplay = useCallback(
|
||||
(event: SearchResult) => {
|
||||
setIsStarting(true);
|
||||
const toastId = toast.loading(
|
||||
t("dialog.starting", { ns: "views/replay" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
@@ -100,6 +104,7 @@ export default function SearchResultActions({
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
id: toastId,
|
||||
position: "top-center",
|
||||
});
|
||||
navigate("/replay");
|
||||
@@ -115,6 +120,7 @@ export default function SearchResultActions({
|
||||
toast.error(
|
||||
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
|
||||
{
|
||||
id: toastId,
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
dismissible: false,
|
||||
@@ -129,6 +135,7 @@ export default function SearchResultActions({
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
id: toastId,
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { throttle } from "lodash";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
||||
@@ -19,11 +21,21 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
// Optional side-effect invoked atomically with setAnnotationOffset (inside
|
||||
// flushSync) so callers like the timeline panel can re-seek the video in the
|
||||
// same React commit as the offset state update — preventing a one-frame
|
||||
// overlay mismatch where annotationOffset has changed but currentTime has not.
|
||||
onApplyOffset?: (newOffset: number) => void;
|
||||
};
|
||||
|
||||
export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
export default function AnnotationOffsetSlider({
|
||||
className,
|
||||
onApplyOffset,
|
||||
}: Props) {
|
||||
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
|
||||
const isAdmin = useIsAdmin();
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
@@ -31,31 +43,62 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const applyOffset = useCallback(
|
||||
(newOffset: number) => {
|
||||
flushSync(() => {
|
||||
setAnnotationOffset(newOffset);
|
||||
onApplyOffset?.(newOffset);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset, onApplyOffset],
|
||||
);
|
||||
|
||||
const throttledApplyOffset = useMemo(
|
||||
() =>
|
||||
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
}),
|
||||
[applyOffset],
|
||||
);
|
||||
|
||||
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
const valueMs = values[0];
|
||||
setAnnotationOffset(valueMs);
|
||||
throttledApplyOffset(values[0]);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
[throttledApplyOffset],
|
||||
);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
// Ensure the final value lands even if it would otherwise be discarded
|
||||
// by the trailing edge of the throttle window.
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset, applyOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
});
|
||||
const next = Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
||||
);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(next);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
[annotationOffset, applyOffset, throttledApplyOffset],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAnnotationOffset(0);
|
||||
}, [setAnnotationOffset]);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(0);
|
||||
}, [applyOffset, throttledApplyOffset]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -130,6 +173,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleChange}
|
||||
onValueCommit={handleCommit}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Event } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import axios from "axios";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { throttle } from "lodash";
|
||||
import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
ANNOTATION_OFFSET_STEP,
|
||||
} from "@/lib/const";
|
||||
|
||||
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||
|
||||
type AnnotationSettingsPaneProps = {
|
||||
event: Event;
|
||||
annotationOffset: number;
|
||||
@@ -38,30 +42,64 @@ export function AnnotationSettingsPane({
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
setAnnotationOffset(values[0]);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
// flushSync ensures setAnnotationOffset commits synchronously so the
|
||||
// useLayoutEffect in TrackingDetails (which seeks the video and sets
|
||||
// currentTime in response) runs before the browser paints — preventing a
|
||||
// one-frame overlay mismatch where annotationOffset has changed but
|
||||
// currentTime has not.
|
||||
const applyOffset = useCallback(
|
||||
(newOffset: number) => {
|
||||
flushSync(() => {
|
||||
setAnnotationOffset(newOffset);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
);
|
||||
|
||||
const throttledApplyOffset = useMemo(
|
||||
() =>
|
||||
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
}),
|
||||
[applyOffset],
|
||||
);
|
||||
|
||||
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
throttledApplyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset],
|
||||
);
|
||||
|
||||
const handleSliderCommit = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset, applyOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
const next = Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
||||
);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(next);
|
||||
},
|
||||
[annotationOffset, applyOffset, throttledApplyOffset],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAnnotationOffset(0);
|
||||
}, [setAnnotationOffset]);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(0);
|
||||
}, [applyOffset, throttledApplyOffset]);
|
||||
|
||||
const saveToConfig = useCallback(async () => {
|
||||
if (!config || !event) return;
|
||||
@@ -143,6 +181,7 @@ export function AnnotationSettingsPane({
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleSliderChange}
|
||||
onValueCommit={handleSliderCommit}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function DetailActionsMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -957,8 +957,9 @@ function ObjectDetailsTab({
|
||||
toast.success(
|
||||
t("details.item.toast.success.regenerate", {
|
||||
provider: capitalizeAll(
|
||||
config?.genai.provider.replaceAll("_", " ") ??
|
||||
t("generativeAI"),
|
||||
Object.values(config?.genai ?? {})
|
||||
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||
),
|
||||
}),
|
||||
{
|
||||
@@ -976,8 +977,9 @@ function ObjectDetailsTab({
|
||||
toast.error(
|
||||
t("details.item.toast.error.regenerate", {
|
||||
provider: capitalizeAll(
|
||||
config?.genai.provider.replaceAll("_", " ") ??
|
||||
t("generativeAI"),
|
||||
Object.values(config?.genai ?? {})
|
||||
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||
),
|
||||
errorMessage,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { Event } from "@/types/event";
|
||||
@@ -389,7 +397,12 @@ export function TrackingDetails({
|
||||
|
||||
// When the pinned timestamp or offset changes, re-seek the video and
|
||||
// explicitly update currentTime so the overlay shows the pinned event's box.
|
||||
useEffect(() => {
|
||||
// useLayoutEffect + flushSync force the setCurrentTime commit to land before
|
||||
// the browser paints, so the overlay never shows a frame where
|
||||
// annotationOffset has changed but currentTime has not — that mismatch would
|
||||
// resolve effectiveCurrentTime away from the pinned detect timestamp and
|
||||
// make the bounding box disappear or jump for one frame.
|
||||
useLayoutEffect(() => {
|
||||
const pinned = pinnedDetectTimestampRef.current;
|
||||
if (!isAnnotationSettingsOpen || pinned == null) return;
|
||||
if (!videoRef.current || displaySource !== "video") return;
|
||||
@@ -398,10 +411,9 @@ export function TrackingDetails({
|
||||
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
||||
videoRef.current.currentTime = relativeTime;
|
||||
|
||||
// Explicitly update currentTime state so the overlay's effectiveCurrentTime
|
||||
// resolves back to the pinned detect timestamp:
|
||||
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned
|
||||
setCurrentTime(targetTimeRecord);
|
||||
flushSync(() => {
|
||||
setCurrentTime(targetTimeRecord);
|
||||
});
|
||||
}, [
|
||||
isAnnotationSettingsOpen,
|
||||
annotationOffset,
|
||||
@@ -1204,7 +1216,11 @@ function LifecycleIconRow({
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
||||
{isAdmin && (config?.plus?.enabled || item.data.box) && (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -126,13 +126,20 @@ export default function DetailStream({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlsExpanded]);
|
||||
|
||||
// Re-seek on annotation offset change while settings panel is open
|
||||
useEffect(() => {
|
||||
const pinned = pinnedDetectTimestampRef.current;
|
||||
if (!controlsExpanded || pinned == null) return;
|
||||
const recordTime = pinned + annotationOffset / 1000;
|
||||
onSeek(recordTime, false);
|
||||
}, [controlsExpanded, annotationOffset, onSeek]);
|
||||
// The slider invokes this atomically with setAnnotationOffset (inside the
|
||||
// same flushSync) so currentTime advances in the same React commit as the
|
||||
// offset. Without this, the overlay would render one frame with the new
|
||||
// offset but the old currentTime, briefly resolving effectiveCurrentTime to
|
||||
// the wrong detect-stream timestamp and making the bounding box vanish or
|
||||
// jump.
|
||||
const handleApplyOffset = useCallback(
|
||||
(newOffset: number) => {
|
||||
const pinned = pinnedDetectTimestampRef.current;
|
||||
if (!controlsExpanded || pinned == null) return;
|
||||
onSeek(pinned + newOffset / 1000, false);
|
||||
},
|
||||
[controlsExpanded, onSeek],
|
||||
);
|
||||
|
||||
// Ensure we initialize the active review when reviewItems first arrive.
|
||||
// This helps when the component mounts while the video is already
|
||||
@@ -337,7 +344,7 @@ export default function DetailStream({
|
||||
</button>
|
||||
{controlsExpanded && (
|
||||
<div className="space-y-4 px-3 pb-5 pt-2">
|
||||
<AnnotationOffsetSlider />
|
||||
<AnnotationOffsetSlider onApplyOffset={handleApplyOffset} />
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -53,6 +53,10 @@ export default function EventMenu({
|
||||
const handleDebugReplay = useCallback(
|
||||
(event: Event) => {
|
||||
setIsStarting(true);
|
||||
const toastId = toast.loading(
|
||||
t("dialog.starting", { ns: "views/replay" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
@@ -63,6 +67,7 @@ export default function EventMenu({
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
id: toastId,
|
||||
position: "top-center",
|
||||
});
|
||||
navigate("/replay");
|
||||
@@ -78,6 +83,7 @@ export default function EventMenu({
|
||||
toast.error(
|
||||
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
|
||||
{
|
||||
id: toastId,
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
dismissible: false,
|
||||
@@ -92,6 +98,7 @@ export default function EventMenu({
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
id: toastId,
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
@@ -106,7 +113,7 @@ export default function EventMenu({
|
||||
return (
|
||||
<>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -28,6 +28,14 @@ export default function useNavigation(
|
||||
});
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const hasChatAgent = useMemo(
|
||||
() =>
|
||||
Object.values(config?.genai ?? {}).some((agent) =>
|
||||
agent?.roles?.includes("chat"),
|
||||
),
|
||||
[config?.genai],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
@@ -89,9 +97,9 @@ export default function useNavigation(
|
||||
icon: MdChat,
|
||||
title: "menu.chat",
|
||||
url: "/chat",
|
||||
enabled: isDesktop && isAdmin && config?.genai?.model !== "none",
|
||||
enabled: isDesktop && isAdmin && hasChatAgent,
|
||||
},
|
||||
] as NavData[],
|
||||
[config?.face_recognition?.enabled, config?.genai?.model, variant, isAdmin],
|
||||
[config?.face_recognition?.enabled, hasChatAgent, variant, isAdmin],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,6 +382,18 @@ export type AllGroupsStreamingSettings = {
|
||||
[groupName: string]: GroupStreamingSettings;
|
||||
};
|
||||
|
||||
export type GenAIRole = "chat" | "descriptions" | "embeddings";
|
||||
|
||||
export type GenAIAgentConfig = {
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
model: string;
|
||||
provider?: string;
|
||||
roles: GenAIRole[];
|
||||
provider_options?: Record<string, unknown>;
|
||||
runtime_options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface FrigateConfig {
|
||||
version: string;
|
||||
safe_mode: boolean;
|
||||
@@ -478,12 +490,7 @@ export interface FrigateConfig {
|
||||
retry_interval: number;
|
||||
};
|
||||
|
||||
genai: {
|
||||
provider: string;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
model: string;
|
||||
};
|
||||
genai: Record<string, GenAIAgentConfig>;
|
||||
|
||||
go2rtc: {
|
||||
streams: Record<string, string | string[]>;
|
||||
|
||||
Reference in New Issue
Block a user