mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
2 Commits
6a2b914b10
...
b6fd86a066
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6fd86a066 | ||
|
|
147cd5cc2b |
@ -201,7 +201,7 @@ Cloud Generative AI providers require an active internet connection to send imag
|
|||||||
|
|
||||||
### Ollama Cloud
|
### Ollama Cloud
|
||||||
|
|
||||||
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
Ollama also supports [cloud models](https://ollama.com/cloud), where model inference is performed in the cloud. You can connect directly to Ollama Cloud by setting `base_url` to `https://ollama.com` and providing an API key. Alternatively, you can run Ollama locally and use a cloud model name so your local instance forwards requests to the cloud. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
||||||
|
|
||||||
#### Configuration
|
#### Configuration
|
||||||
|
|
||||||
@ -210,7 +210,8 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where your local
|
|||||||
|
|
||||||
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
|
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
|
||||||
- Set **Provider** to `ollama`
|
- Set **Provider** to `ollama`
|
||||||
- Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`)
|
- Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`) or `https://ollama.com` for direct cloud inference
|
||||||
|
- Set **API key** if required by your endpoint (e.g., when using `https://ollama.com`)
|
||||||
- Set **Model** to the cloud model name
|
- Set **Model** to the cloud model name
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
@ -223,6 +224,16 @@ genai:
|
|||||||
model: cloud-model-name
|
model: cloud-model-name
|
||||||
```
|
```
|
||||||
|
|
||||||
|
or when using Ollama Cloud directly
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
genai:
|
||||||
|
provider: ollama
|
||||||
|
base_url: https://ollama.com
|
||||||
|
model: cloud-model-name
|
||||||
|
api_key: your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</ConfigTabs>
|
</ConfigTabs>
|
||||||
|
|
||||||
|
|||||||
@ -429,7 +429,10 @@ class WebPushClient(Communicator):
|
|||||||
else:
|
else:
|
||||||
title = base_title
|
title = base_title
|
||||||
|
|
||||||
message = payload["after"]["data"]["metadata"]["shortSummary"]
|
if payload["after"]["data"]["metadata"].get("shortSummary"):
|
||||||
|
message = payload["after"]["data"]["metadata"]["shortSummary"]
|
||||||
|
else:
|
||||||
|
message = f"Detected on {camera_name}"
|
||||||
else:
|
else:
|
||||||
zone_names = payload["after"]["data"]["zones"]
|
zone_names = payload["after"]["data"]["zones"]
|
||||||
formatted_zone_names = []
|
formatted_zone_names = []
|
||||||
|
|||||||
@ -1073,10 +1073,6 @@ class LicensePlateProcessingMixin:
|
|||||||
top_score = score
|
top_score = score
|
||||||
top_box = bbox
|
top_box = bbox
|
||||||
|
|
||||||
if score > top_score:
|
|
||||||
top_score = score
|
|
||||||
top_box = bbox
|
|
||||||
|
|
||||||
# Return the top scoring bounding box if found
|
# Return the top scoring bounding box if found
|
||||||
if top_box is not None:
|
if top_box is not None:
|
||||||
# expand box by 5% to help with OCR
|
# expand box by 5% to help with OCR
|
||||||
@ -1092,9 +1088,6 @@ class LicensePlateProcessingMixin:
|
|||||||
]
|
]
|
||||||
).clip(0, [input.shape[1], input.shape[0]] * 2)
|
).clip(0, [input.shape[1], input.shape[0]] * 2)
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}"
|
|
||||||
)
|
|
||||||
return tuple(int(x) for x in expanded_box) # type: ignore[return-value]
|
return tuple(int(x) for x in expanded_box) # type: ignore[return-value]
|
||||||
else:
|
else:
|
||||||
return None # No detection above the threshold
|
return None # No detection above the threshold
|
||||||
@ -1360,8 +1353,8 @@ class LicensePlateProcessingMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# check that license plate is valid
|
# check that license plate is valid
|
||||||
# double the value because we've doubled the size of the car
|
# quadruple the value because we've doubled both dimensions of the car
|
||||||
if license_plate_area < self.config.cameras[camera].lpr.min_area * 2:
|
if license_plate_area < self.config.cameras[camera].lpr.min_area * 4:
|
||||||
logger.debug(f"{camera}: License plate is less than min_area")
|
logger.debug(f"{camera}: License plate is less than min_area")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -1465,6 +1458,7 @@ class LicensePlateProcessingMixin:
|
|||||||
license_plate_frame,
|
license_plate_frame,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.debug(f"{camera}: Found license plate. Bounding box: {list(plate_box)}")
|
||||||
logger.debug(f"{camera}: Running plate recognition for id: {id}.")
|
logger.debug(f"{camera}: Running plate recognition for id: {id}.")
|
||||||
|
|
||||||
# run detection, returns results sorted by confidence, best first
|
# run detection, returns results sorted by confidence, best first
|
||||||
|
|||||||
@ -31,6 +31,12 @@ class OllamaClient(GenAIClient):
|
|||||||
provider: ApiClient | None
|
provider: ApiClient | None
|
||||||
provider_options: dict[str, Any]
|
provider_options: dict[str, Any]
|
||||||
|
|
||||||
|
def _auth_headers(self) -> dict | None:
|
||||||
|
if self.genai_config.api_key:
|
||||||
|
return {"Authorization": "Bearer " + self.genai_config.api_key}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _init_provider(self) -> ApiClient | None:
|
def _init_provider(self) -> ApiClient | None:
|
||||||
"""Initialize the client."""
|
"""Initialize the client."""
|
||||||
self.provider_options = {
|
self.provider_options = {
|
||||||
@ -39,7 +45,11 @@ class OllamaClient(GenAIClient):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout)
|
client = ApiClient(
|
||||||
|
host=self.genai_config.base_url,
|
||||||
|
timeout=self.timeout,
|
||||||
|
headers=self._auth_headers(),
|
||||||
|
)
|
||||||
# ensure the model is available locally
|
# ensure the model is available locally
|
||||||
response = client.show(self.genai_config.model)
|
response = client.show(self.genai_config.model)
|
||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
@ -166,7 +176,9 @@ class OllamaClient(GenAIClient):
|
|||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
client = ApiClient(
|
client = ApiClient(
|
||||||
host=self.genai_config.base_url, timeout=self.timeout
|
host=self.genai_config.base_url,
|
||||||
|
timeout=self.timeout,
|
||||||
|
headers=self._auth_headers(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
@ -344,6 +356,7 @@ class OllamaClient(GenAIClient):
|
|||||||
async_client = OllamaAsyncClient(
|
async_client = OllamaAsyncClient(
|
||||||
host=self.genai_config.base_url,
|
host=self.genai_config.base_url,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
|
headers=self._auth_headers(),
|
||||||
)
|
)
|
||||||
response = await async_client.chat(**request_params)
|
response = await async_client.chat(**request_params)
|
||||||
result = self._message_from_response(response)
|
result = self._message_from_response(response)
|
||||||
@ -359,6 +372,7 @@ class OllamaClient(GenAIClient):
|
|||||||
async_client = OllamaAsyncClient(
|
async_client = OllamaAsyncClient(
|
||||||
host=self.genai_config.base_url,
|
host=self.genai_config.base_url,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
|
headers=self._auth_headers(),
|
||||||
)
|
)
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
final_message: dict[str, Any] | None = None
|
final_message: dict[str, Any] | None = None
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import pytz # type: ignore[import-untyped]
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
from frigate.config import FfmpegConfig, FrigateConfig
|
from frigate.config import FfmpegConfig, FrigateConfig
|
||||||
@ -344,7 +345,19 @@ class RecordingExporter(threading.Thread):
|
|||||||
return proc.returncode, "".join(captured)
|
return proc.returncode, "".join(captured)
|
||||||
|
|
||||||
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
||||||
# return in iso format
|
# return in iso format using the configured ui.timezone when set,
|
||||||
|
# so the auto-generated export name reflects local time rather
|
||||||
|
# than the container's UTC clock
|
||||||
|
tz_name = self.config.ui.timezone
|
||||||
|
if tz_name:
|
||||||
|
try:
|
||||||
|
tz = pytz.timezone(tz_name)
|
||||||
|
except pytz.UnknownTimeZoneError:
|
||||||
|
tz = None
|
||||||
|
if tz is not None:
|
||||||
|
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
def _chapter_metadata_path(self) -> str:
|
def _chapter_metadata_path(self) -> str:
|
||||||
@ -538,12 +551,18 @@ class RecordingExporter(threading.Thread):
|
|||||||
start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}"
|
start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}"
|
||||||
end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}"
|
end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}"
|
||||||
selected_preview = None
|
selected_preview = None
|
||||||
|
# Preview frames are written at most 1-2 fps during activity
|
||||||
|
# and as little as one every 30s during quiet periods, so a
|
||||||
|
# short export window can contain zero frames. Track the most
|
||||||
|
# recent frame before the window as a fallback.
|
||||||
|
fallback_preview = None
|
||||||
|
|
||||||
for file in sorted(os.listdir(preview_dir)):
|
for file in sorted(os.listdir(preview_dir)):
|
||||||
if not file.startswith(file_start):
|
if not file.startswith(file_start):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if file < start_file:
|
if file < start_file:
|
||||||
|
fallback_preview = os.path.join(preview_dir, file)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if file > end_file:
|
if file > end_file:
|
||||||
@ -552,6 +571,9 @@ class RecordingExporter(threading.Thread):
|
|||||||
selected_preview = os.path.join(preview_dir, file)
|
selected_preview = os.path.join(preview_dir, file)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not selected_preview:
|
||||||
|
selected_preview = fallback_preview
|
||||||
|
|
||||||
if not selected_preview:
|
if not selected_preview:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
"""Tests for export progress tracking, broadcast, and FFmpeg parsing."""
|
"""Tests for export progress tracking, broadcast, and FFmpeg parsing."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@ -363,6 +366,121 @@ class TestBroadcastAggregation(unittest.TestCase):
|
|||||||
assert job.progress_percent == 33.0
|
assert job.progress_percent == 33.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDatetimeFromTimestamp(unittest.TestCase):
|
||||||
|
"""Auto-generated export name should honor config.ui.timezone, not
|
||||||
|
fall back to the container's UTC clock when a timezone is configured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_uses_configured_ui_timezone(self) -> None:
|
||||||
|
exporter = _make_exporter()
|
||||||
|
exporter.config.ui.timezone = "America/New_York"
|
||||||
|
# 2025-01-15 12:00:00 UTC is 07:00:00 EST
|
||||||
|
assert exporter.get_datetime_from_timestamp(1736942400) == "2025-01-15 07:00:00"
|
||||||
|
|
||||||
|
def test_falls_back_to_local_when_timezone_unset(self) -> None:
|
||||||
|
exporter = _make_exporter()
|
||||||
|
exporter.config.ui.timezone = None
|
||||||
|
# No assertion on the exact wall-clock value — just confirm no
|
||||||
|
# exception and that pytz isn't required when the field is unset.
|
||||||
|
assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str)
|
||||||
|
|
||||||
|
def test_invalid_timezone_falls_back_to_local(self) -> None:
|
||||||
|
exporter = _make_exporter()
|
||||||
|
exporter.config.ui.timezone = "Not/A_Real_Zone"
|
||||||
|
assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveThumbnailFromPreviewFrames(unittest.TestCase):
|
||||||
|
"""Short exports in the current hour can fall between preview frame
|
||||||
|
writes (1-2 fps during activity, every 30s otherwise). When no frame
|
||||||
|
falls inside the export window, save_thumbnail should fall back to
|
||||||
|
the most recent prior frame instead of returning no thumbnail."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp_root = tempfile.mkdtemp(prefix="frigate_thumb_test_")
|
||||||
|
self.preview_dir = os.path.join(self.tmp_root, "cache", "preview_frames")
|
||||||
|
self.export_clips = os.path.join(self.tmp_root, "clips", "export")
|
||||||
|
os.makedirs(self.preview_dir, exist_ok=True)
|
||||||
|
os.makedirs(self.export_clips, exist_ok=True)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
shutil.rmtree(self.tmp_root, ignore_errors=True)
|
||||||
|
|
||||||
|
def _write_frame(self, camera: str, frame_time: float) -> str:
|
||||||
|
path = os.path.join(self.preview_dir, f"preview_{camera}-{frame_time}.webp")
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(b"fake-webp-bytes")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _make_short_current_hour_exporter(self) -> RecordingExporter:
|
||||||
|
# Use a "now-ish" timestamp so save_thumbnail's start-of-hour
|
||||||
|
# comparison takes the current-hour branch (preview frames).
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
|
||||||
|
exporter = _make_exporter()
|
||||||
|
exporter.export_id = "thumb_short"
|
||||||
|
exporter.start_time = now
|
||||||
|
exporter.end_time = now + 3
|
||||||
|
return exporter
|
||||||
|
|
||||||
|
def test_short_export_falls_back_to_prior_preview_frame(self) -> None:
|
||||||
|
exporter = self._make_short_current_hour_exporter()
|
||||||
|
# Most recent preview frame is 10s before the export window
|
||||||
|
prior = self._write_frame(exporter.camera, exporter.start_time - 10.0)
|
||||||
|
thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache")
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = exporter.save_thumbnail(exporter.export_id)
|
||||||
|
|
||||||
|
assert result == thumb_target
|
||||||
|
assert os.path.isfile(thumb_target)
|
||||||
|
with open(thumb_target, "rb") as f, open(prior, "rb") as src:
|
||||||
|
assert f.read() == src.read()
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_preview_frames_exist(self) -> None:
|
||||||
|
exporter = self._make_short_current_hour_exporter()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache")
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = exporter.save_thumbnail(exporter.export_id)
|
||||||
|
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_prefers_in_window_frame_over_prior_frame(self) -> None:
|
||||||
|
exporter = self._make_short_current_hour_exporter()
|
||||||
|
self._write_frame(exporter.camera, exporter.start_time - 10.0)
|
||||||
|
in_window = self._write_frame(exporter.camera, exporter.start_time + 1.0)
|
||||||
|
thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache")
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = exporter.save_thumbnail(exporter.export_id)
|
||||||
|
|
||||||
|
assert result == thumb_target
|
||||||
|
with open(thumb_target, "rb") as f, open(in_window, "rb") as src:
|
||||||
|
assert f.read() == src.read()
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulesCleanup(unittest.TestCase):
|
class TestSchedulesCleanup(unittest.TestCase):
|
||||||
def test_schedule_job_cleanup_removes_after_delay(self) -> None:
|
def test_schedule_job_cleanup_removes_after_delay(self) -> None:
|
||||||
config = MagicMock()
|
config = MagicMock()
|
||||||
|
|||||||
@ -69,17 +69,18 @@ test.describe("Navigation — conditional items @critical", () => {
|
|||||||
).toBeVisible();
|
).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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
config: {
|
config: {
|
||||||
genai: {
|
genai: {
|
||||||
enabled: false,
|
descriptions_only: {
|
||||||
provider: "ollama",
|
provider: "ollama",
|
||||||
model: "none",
|
model: "llava",
|
||||||
base_url: "",
|
roles: ["descriptions"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -89,12 +90,20 @@ test.describe("Navigation — conditional items @critical", () => {
|
|||||||
).toHaveCount(0);
|
).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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||||
await frigateApp.installDefaults({
|
await frigateApp.installDefaults({
|
||||||
config: { genai: { enabled: true, model: "llava" } },
|
config: {
|
||||||
|
genai: {
|
||||||
|
chat_agent: {
|
||||||
|
provider: "ollama",
|
||||||
|
model: "llava",
|
||||||
|
roles: ["chat"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/");
|
await frigateApp.goto("/");
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@ -93,6 +93,14 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
useSWR<ProfilesApiResponse>("profiles");
|
useSWR<ProfilesApiResponse>("profiles");
|
||||||
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
||||||
|
|
||||||
|
const hasChatAgent = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(config?.genai ?? {}).some((agent) =>
|
||||||
|
agent?.roles?.includes("chat"),
|
||||||
|
),
|
||||||
|
[config?.genai],
|
||||||
|
);
|
||||||
|
|
||||||
// languages
|
// languages
|
||||||
|
|
||||||
const languages = useMemo(() => {
|
const languages = useMemo(() => {
|
||||||
@ -511,7 +519,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
<span>{t("menu.classification")}</span>
|
<span>{t("menu.classification")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
{config?.genai?.model !== "none" && (
|
{hasChatAgent && (
|
||||||
<Link to="/chat">
|
<Link to="/chat">
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className="flex w-full items-center p-2 text-sm"
|
className="flex w-full items-center p-2 text-sm"
|
||||||
|
|||||||
@ -90,6 +90,10 @@ export default function SearchResultActions({
|
|||||||
const handleDebugReplay = useCallback(
|
const handleDebugReplay = useCallback(
|
||||||
(event: SearchResult) => {
|
(event: SearchResult) => {
|
||||||
setIsStarting(true);
|
setIsStarting(true);
|
||||||
|
const toastId = toast.loading(
|
||||||
|
t("dialog.starting", { ns: "views/replay" }),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post("debug_replay/start", {
|
.post("debug_replay/start", {
|
||||||
@ -100,6 +104,7 @@ export default function SearchResultActions({
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||||
|
id: toastId,
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
navigate("/replay");
|
navigate("/replay");
|
||||||
@ -115,6 +120,7 @@ export default function SearchResultActions({
|
|||||||
toast.error(
|
toast.error(
|
||||||
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
|
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
|
||||||
{
|
{
|
||||||
|
id: toastId,
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
dismissible: false,
|
dismissible: false,
|
||||||
@ -129,6 +135,7 @@ export default function SearchResultActions({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||||
|
id: toastId,
|
||||||
position: "top-center",
|
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 { Slider } from "@/components/ui/slider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
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 { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
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 { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
@ -31,31 +43,62 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
|||||||
const { t } = useTranslation(["views/explore"]);
|
const { t } = useTranslation(["views/explore"]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
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(
|
const handleChange = useCallback(
|
||||||
(values: number[]) => {
|
(values: number[]) => {
|
||||||
if (!values || values.length === 0) return;
|
if (!values || values.length === 0) return;
|
||||||
const valueMs = values[0];
|
throttledApplyOffset(values[0]);
|
||||||
setAnnotationOffset(valueMs);
|
|
||||||
},
|
},
|
||||||
[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(
|
const stepOffset = useCallback(
|
||||||
(delta: number) => {
|
(delta: number) => {
|
||||||
setAnnotationOffset((prev) => {
|
const next = Math.max(
|
||||||
const next = prev + delta;
|
ANNOTATION_OFFSET_MIN,
|
||||||
return Math.max(
|
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
||||||
ANNOTATION_OFFSET_MIN,
|
);
|
||||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
throttledApplyOffset.cancel();
|
||||||
);
|
applyOffset(next);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[setAnnotationOffset],
|
[annotationOffset, applyOffset, throttledApplyOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setAnnotationOffset(0);
|
throttledApplyOffset.cancel();
|
||||||
}, [setAnnotationOffset]);
|
applyOffset(0);
|
||||||
|
}, [applyOffset, throttledApplyOffset]);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@ -130,6 +173,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
|||||||
max={ANNOTATION_OFFSET_MAX}
|
max={ANNOTATION_OFFSET_MAX}
|
||||||
step={ANNOTATION_OFFSET_STEP}
|
step={ANNOTATION_OFFSET_STEP}
|
||||||
onValueChange={handleChange}
|
onValueChange={handleChange}
|
||||||
|
onValueCommit={handleCommit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import axios from "axios";
|
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 { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -19,6 +21,8 @@ import {
|
|||||||
ANNOTATION_OFFSET_STEP,
|
ANNOTATION_OFFSET_STEP,
|
||||||
} from "@/lib/const";
|
} from "@/lib/const";
|
||||||
|
|
||||||
|
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||||
|
|
||||||
type AnnotationSettingsPaneProps = {
|
type AnnotationSettingsPaneProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
annotationOffset: number;
|
annotationOffset: number;
|
||||||
@ -38,30 +42,64 @@ export function AnnotationSettingsPane({
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleSliderChange = useCallback(
|
// flushSync ensures setAnnotationOffset commits synchronously so the
|
||||||
(values: number[]) => {
|
// useLayoutEffect in TrackingDetails (which seeks the video and sets
|
||||||
if (!values || values.length === 0) return;
|
// currentTime in response) runs before the browser paints — preventing a
|
||||||
setAnnotationOffset(values[0]);
|
// one-frame overlay mismatch where annotationOffset has changed but
|
||||||
},
|
// currentTime has not.
|
||||||
[setAnnotationOffset],
|
const applyOffset = useCallback(
|
||||||
);
|
(newOffset: number) => {
|
||||||
|
flushSync(() => {
|
||||||
const stepOffset = useCallback(
|
setAnnotationOffset(newOffset);
|
||||||
(delta: number) => {
|
|
||||||
setAnnotationOffset((prev) => {
|
|
||||||
const next = prev + delta;
|
|
||||||
return Math.max(
|
|
||||||
ANNOTATION_OFFSET_MIN,
|
|
||||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setAnnotationOffset],
|
[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(() => {
|
const reset = useCallback(() => {
|
||||||
setAnnotationOffset(0);
|
throttledApplyOffset.cancel();
|
||||||
}, [setAnnotationOffset]);
|
applyOffset(0);
|
||||||
|
}, [applyOffset, throttledApplyOffset]);
|
||||||
|
|
||||||
const saveToConfig = useCallback(async () => {
|
const saveToConfig = useCallback(async () => {
|
||||||
if (!config || !event) return;
|
if (!config || !event) return;
|
||||||
@ -143,6 +181,7 @@ export function AnnotationSettingsPane({
|
|||||||
max={ANNOTATION_OFFSET_MAX}
|
max={ANNOTATION_OFFSET_MAX}
|
||||||
step={ANNOTATION_OFFSET_STEP}
|
step={ANNOTATION_OFFSET_STEP}
|
||||||
onValueChange={handleSliderChange}
|
onValueChange={handleSliderChange}
|
||||||
|
onValueCommit={handleSliderCommit}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export default function DetailActionsMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<div className="rounded" role="button">
|
<div className="rounded" role="button">
|
||||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||||
|
|||||||
@ -957,8 +957,9 @@ function ObjectDetailsTab({
|
|||||||
toast.success(
|
toast.success(
|
||||||
t("details.item.toast.success.regenerate", {
|
t("details.item.toast.success.regenerate", {
|
||||||
provider: capitalizeAll(
|
provider: capitalizeAll(
|
||||||
config?.genai.provider.replaceAll("_", " ") ??
|
Object.values(config?.genai ?? {})
|
||||||
t("generativeAI"),
|
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||||
|
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@ -976,8 +977,9 @@ function ObjectDetailsTab({
|
|||||||
toast.error(
|
toast.error(
|
||||||
t("details.item.toast.error.regenerate", {
|
t("details.item.toast.error.regenerate", {
|
||||||
provider: capitalizeAll(
|
provider: capitalizeAll(
|
||||||
config?.genai.provider.replaceAll("_", " ") ??
|
Object.values(config?.genai ?? {})
|
||||||
t("generativeAI"),
|
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||||
|
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||||
),
|
),
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import useSWR from "swr";
|
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 { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
@ -389,7 +397,12 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
// When the pinned timestamp or offset changes, re-seek the video and
|
// When the pinned timestamp or offset changes, re-seek the video and
|
||||||
// explicitly update currentTime so the overlay shows the pinned event's box.
|
// 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;
|
const pinned = pinnedDetectTimestampRef.current;
|
||||||
if (!isAnnotationSettingsOpen || pinned == null) return;
|
if (!isAnnotationSettingsOpen || pinned == null) return;
|
||||||
if (!videoRef.current || displaySource !== "video") return;
|
if (!videoRef.current || displaySource !== "video") return;
|
||||||
@ -398,10 +411,9 @@ export function TrackingDetails({
|
|||||||
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
||||||
videoRef.current.currentTime = relativeTime;
|
videoRef.current.currentTime = relativeTime;
|
||||||
|
|
||||||
// Explicitly update currentTime state so the overlay's effectiveCurrentTime
|
flushSync(() => {
|
||||||
// resolves back to the pinned detect timestamp:
|
setCurrentTime(targetTimeRecord);
|
||||||
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned
|
});
|
||||||
setCurrentTime(targetTimeRecord);
|
|
||||||
}, [
|
}, [
|
||||||
isAnnotationSettingsOpen,
|
isAnnotationSettingsOpen,
|
||||||
annotationOffset,
|
annotationOffset,
|
||||||
@ -1204,7 +1216,11 @@ function LifecycleIconRow({
|
|||||||
<div className="flex flex-row items-center gap-3">
|
<div className="flex flex-row items-center gap-3">
|
||||||
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
||||||
{isAdmin && (config?.plus?.enabled || item.data.box) && (
|
{isAdmin && (config?.plus?.enabled || item.data.box) && (
|
||||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
<DropdownMenu
|
||||||
|
modal={false}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<div className="rounded p-1 pr-2" role="button">
|
<div className="rounded p-1 pr-2" role="button">
|
||||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||||
|
|||||||
@ -126,13 +126,20 @@ export default function DetailStream({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [controlsExpanded]);
|
}, [controlsExpanded]);
|
||||||
|
|
||||||
// Re-seek on annotation offset change while settings panel is open
|
// The slider invokes this atomically with setAnnotationOffset (inside the
|
||||||
useEffect(() => {
|
// same flushSync) so currentTime advances in the same React commit as the
|
||||||
const pinned = pinnedDetectTimestampRef.current;
|
// offset. Without this, the overlay would render one frame with the new
|
||||||
if (!controlsExpanded || pinned == null) return;
|
// offset but the old currentTime, briefly resolving effectiveCurrentTime to
|
||||||
const recordTime = pinned + annotationOffset / 1000;
|
// the wrong detect-stream timestamp and making the bounding box vanish or
|
||||||
onSeek(recordTime, false);
|
// jump.
|
||||||
}, [controlsExpanded, annotationOffset, onSeek]);
|
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.
|
// Ensure we initialize the active review when reviewItems first arrive.
|
||||||
// This helps when the component mounts while the video is already
|
// This helps when the component mounts while the video is already
|
||||||
@ -337,7 +344,7 @@ export default function DetailStream({
|
|||||||
</button>
|
</button>
|
||||||
{controlsExpanded && (
|
{controlsExpanded && (
|
||||||
<div className="space-y-4 px-3 pb-5 pt-2">
|
<div className="space-y-4 px-3 pb-5 pt-2">
|
||||||
<AnnotationOffsetSlider />
|
<AnnotationOffsetSlider onApplyOffset={handleApplyOffset} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@ -53,6 +53,10 @@ export default function EventMenu({
|
|||||||
const handleDebugReplay = useCallback(
|
const handleDebugReplay = useCallback(
|
||||||
(event: Event) => {
|
(event: Event) => {
|
||||||
setIsStarting(true);
|
setIsStarting(true);
|
||||||
|
const toastId = toast.loading(
|
||||||
|
t("dialog.starting", { ns: "views/replay" }),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post("debug_replay/start", {
|
.post("debug_replay/start", {
|
||||||
@ -63,6 +67,7 @@ export default function EventMenu({
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||||
|
id: toastId,
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
navigate("/replay");
|
navigate("/replay");
|
||||||
@ -78,6 +83,7 @@ export default function EventMenu({
|
|||||||
toast.error(
|
toast.error(
|
||||||
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
|
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
|
||||||
{
|
{
|
||||||
|
id: toastId,
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
dismissible: false,
|
dismissible: false,
|
||||||
@ -92,6 +98,7 @@ export default function EventMenu({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||||
|
id: toastId,
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -106,7 +113,7 @@ export default function EventMenu({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span tabIndex={0} className="sr-only" />
|
<span tabIndex={0} className="sr-only" />
|
||||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<div className="rounded p-1 pr-2" role="button">
|
<div className="rounded p-1 pr-2" role="button">
|
||||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||||
|
|||||||
@ -28,6 +28,14 @@ export default function useNavigation(
|
|||||||
});
|
});
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
|
const hasChatAgent = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(config?.genai ?? {}).some((agent) =>
|
||||||
|
agent?.roles?.includes("chat"),
|
||||||
|
),
|
||||||
|
[config?.genai],
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
@ -89,9 +97,9 @@ export default function useNavigation(
|
|||||||
icon: MdChat,
|
icon: MdChat,
|
||||||
title: "menu.chat",
|
title: "menu.chat",
|
||||||
url: "/chat",
|
url: "/chat",
|
||||||
enabled: isDesktop && isAdmin && config?.genai?.model !== "none",
|
enabled: isDesktop && isAdmin && hasChatAgent,
|
||||||
},
|
},
|
||||||
] as NavData[],
|
] 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;
|
[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 {
|
export interface FrigateConfig {
|
||||||
version: string;
|
version: string;
|
||||||
safe_mode: boolean;
|
safe_mode: boolean;
|
||||||
@ -478,12 +490,7 @@ export interface FrigateConfig {
|
|||||||
retry_interval: number;
|
retry_interval: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
genai: {
|
genai: Record<string, GenAIAgentConfig>;
|
||||||
provider: string;
|
|
||||||
base_url?: string;
|
|
||||||
api_key?: string;
|
|
||||||
model: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
go2rtc: {
|
go2rtc: {
|
||||||
streams: Record<string, string | string[]>;
|
streams: Record<string, string | string[]>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user