Compare commits

..

14 Commits

Author SHA1 Message Date
Hosted Weblate
6df655698e
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (809 of 809 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1277 of 1277 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (49 of 49 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1276 of 1276 strings)

Co-authored-by: GuoQing Liu <842607283@qq.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Yechi Yang <yechiyang93@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-search/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/zh_Hans/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/views-search
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-13 16:52:33 +02:00
Hosted Weblate
1767cd8cd4
Translated using Weblate (Swedish)
Currently translated at 2.7% (13 of 475 strings)

Translated using Weblate (Swedish)

Currently translated at 0.6% (5 of 809 strings)

Translated using Weblate (Swedish)

Currently translated at 50.7% (648 of 1277 strings)

Translated using Weblate (Swedish)

Currently translated at 0.1% (1 of 809 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Swedish)

Currently translated at 0.6% (3 of 475 strings)

Translated using Weblate (Swedish)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (101 of 101 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (239 of 239 strings)

Co-authored-by: Christian Bengtsson <bnccnb@gmail.com>
Co-authored-by: Fredrik Tuomas <fredrik.tuomas@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kristian Johansson <knmjohansson@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/sv/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-settings
2026-06-13 16:52:33 +02:00
Hosted Weblate
8bdaf9c847
Translated using Weblate (French)
Currently translated at 56.4% (35 of 62 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: LeBuzzy <bwinster2@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/fr/
Translation: Frigate NVR/views-motionSearch
2026-06-13 16:52:33 +02:00
Hosted Weblate
9c6059479f
Translated using Weblate (Spanish)
Currently translated at 100.0% (809 of 809 strings)

Translated using Weblate (Spanish)

Currently translated at 99.4% (187 of 188 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (1277 of 1277 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (475 of 475 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Libre <6n0n1m0s@proton.me>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/es/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/es/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-13 16:52:33 +02:00
Hosted Weblate
5eacd9b056
Translated using Weblate (Indonesian)
Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Indonesian)

Currently translated at 57.4% (58 of 101 strings)

Translated using Weblate (Indonesian)

Currently translated at 43.5% (44 of 101 strings)

Translated using Weblate (Indonesian)

Currently translated at 94.0% (47 of 50 strings)

Translated using Weblate (Indonesian)

Currently translated at 42.5% (43 of 101 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Indonesian)

Currently translated at 90.0% (45 of 50 strings)

Translated using Weblate (Indonesian)

Currently translated at 90.0% (45 of 50 strings)

Co-authored-by: Alberto-Audrix <alberto.suiwidjaya6@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Naufal F <fadhlurrahmannf0812@gmail.com>
Co-authored-by: Yeni Setiawan <yenisetiawan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/id/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
2026-06-13 16:52:33 +02:00
Hosted Weblate
75a8c196da
Translated using Weblate (Italian)
Currently translated at 73.0% (591 of 809 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (1276 of 1276 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Italian)

Currently translated at 67.7% (548 of 809 strings)

Translated using Weblate (Italian)

Currently translated at 55.7% (451 of 809 strings)

Translated using Weblate (Italian)

Currently translated at 76.0% (361 of 475 strings)

Co-authored-by: Edoardo Sorrenti <ed.sorrenti@gmail.com>
Co-authored-by: Filippo-riccardo Franzin (filippo franzin) <filric01@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/it/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
2026-06-13 16:52:32 +02:00
Hosted Weblate
2c8975e8d5
Translated using Weblate (Polish)
Currently translated at 31.3% (149 of 475 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (101 of 101 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Polish)

Currently translated at 98.4% (127 of 129 strings)

Translated using Weblate (Polish)

Currently translated at 9.1% (74 of 809 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Paweł Kapeluszny <cyberitsec@proton.me>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/pl/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
2026-06-13 16:52:32 +02:00
Hosted Weblate
662d965fbf
Translated using Weblate (Catalan)
Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (809 of 809 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1277 of 1277 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ca/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-13 16:52:32 +02:00
Hosted Weblate
1043acc3bc
Translated using Weblate (Ukrainian)
Currently translated at 1.6% (8 of 475 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.1% (25 of 26 strings)

Translated using Weblate (Ukrainian)

Currently translated at 0.1% (1 of 809 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (50 of 50 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Vitaliy Kreminskiy <vkrmk13@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/uk/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/uk/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-player
2026-06-13 16:52:32 +02:00
Hosted Weblate
856c7329a5
Translated using Weblate (Romanian)
Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (809 of 809 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1277 of 1277 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/ro/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-06-13 16:52:32 +02:00
Hosted Weblate
7b18f99997
Translated using Weblate (Russian)
Currently translated at 92.4% (221 of 239 strings)

Co-authored-by: Artem Vladimirov <artyomka71@mail.ru>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/ru/
Translation: Frigate NVR/common
2026-06-13 16:52:32 +02:00
Hosted Weblate
4b00de49c5
Translated using Weblate (Greek)
Currently translated at 14.3% (72 of 501 strings)

Translated using Weblate (Greek)

Currently translated at 49.7% (119 of 239 strings)

Co-authored-by: George Rovolis <georgios@rovolis.co.uk>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/el/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/el/
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
2026-06-13 16:52:32 +02:00
Hosted Weblate
b2d1ccd3ac
Translated using Weblate (German)
Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (German)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (German)

Currently translated at 100.0% (809 of 809 strings)

Translated using Weblate (German)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (German)

Currently translated at 100.0% (1276 of 1276 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sebastian Sie <sebastian.neuplanitz@googlemail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/de/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-settings
2026-06-13 16:52:31 +02:00
Nicolas Mowen
d7ad3ba699
Fix chat tool calling and prompt breaking (#23457)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Implement tool call history keeping

* Refactor to match single message implementation

* Simplify data representation

* Cleanup chat page rendering

* Include system message to not break cache

* Formatting

* Update tests and update .gitignore
2026-06-12 07:48:43 -05:00
27 changed files with 587 additions and 337 deletions

View File

@ -7,7 +7,7 @@ import operator
import time
from datetime import datetime
from functools import reduce
from typing import Any, Dict, List, Optional
from typing import Any, Optional
import cv2
from fastapi import APIRouter, Body, Depends, HTTPException, Request
@ -59,7 +59,7 @@ class ToolExecuteRequest(BaseModel):
"""Request model for tool execution."""
tool_name: str
arguments: Dict[str, Any]
arguments: dict[str, Any]
class VLMMonitorRequest(BaseModel):
@ -68,8 +68,8 @@ class VLMMonitorRequest(BaseModel):
camera: str
condition: str
max_duration_minutes: int = 60
labels: List[str] = []
zones: List[str] = []
labels: list[str] = []
zones: list[str] = []
@router.get(
@ -91,10 +91,10 @@ def get_tools(request: Request) -> JSONResponse:
def _resolve_zones(
zones: List[str],
zones: list[str],
config: FrigateConfig,
target_cameras: List[str],
) -> List[str]:
target_cameras: list[str],
) -> list[str]:
"""Map zone names to their canonical config keys, case-insensitively.
LLMs frequently echo a user's casing ("Front Yard") instead of the
@ -107,7 +107,7 @@ def _resolve_zones(
if not zones:
return zones
lookup: Dict[str, str] = {}
lookup: dict[str, str] = {}
for camera_id in target_cameras:
camera_config = config.cameras.get(camera_id)
if camera_config is None:
@ -120,8 +120,8 @@ def _resolve_zones(
async def _execute_search_objects(
request: Request,
arguments: Dict[str, Any],
allowed_cameras: List[str],
arguments: dict[str, Any],
allowed_cameras: list[str],
) -> JSONResponse:
"""
Execute the search_objects tool.
@ -213,8 +213,8 @@ async def _execute_search_objects(
async def _execute_search_objects_semantic(
request: Request,
arguments: Dict[str, Any],
allowed_cameras: List[str],
arguments: dict[str, Any],
allowed_cameras: list[str],
semantic_query: str,
) -> JSONResponse:
"""Search objects via fused thumbnail + description embeddings.
@ -263,8 +263,8 @@ async def _execute_search_objects_semantic(
limit = int(arguments.get("limit", 25))
limit = max(1, min(limit, 100))
visual_distances: Dict[str, float] = {}
description_distances: Dict[str, float] = {}
visual_distances: dict[str, float] = {}
description_distances: dict[str, float] = {}
try:
rows = context.search_thumbnail(semantic_query)
visual_distances = {row[0]: row[1] for row in rows}
@ -305,7 +305,7 @@ async def _execute_search_objects_semantic(
eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))}
scored: List[tuple[str, float]] = []
scored: list[tuple[str, float]] = []
for eid in eligible:
v_score = (
distance_to_score(visual_distances[eid], context.thumb_stats)
@ -331,9 +331,9 @@ async def _execute_search_objects_semantic(
async def _execute_find_similar_objects(
request: Request,
arguments: Dict[str, Any],
allowed_cameras: List[str],
) -> Dict[str, Any]:
arguments: dict[str, Any],
allowed_cameras: list[str],
) -> dict[str, Any]:
"""Execute the find_similar_objects tool.
Returns a plain dict (not JSONResponse) so the chat loop can embed it
@ -403,8 +403,8 @@ async def _execute_find_similar_objects(
# version (see frigate/embeddings/__init__.py). Mirror the pattern used by
# frigate/api/event.py events_search: fetch top-k globally, then intersect
# with the structured filters via Peewee.
visual_distances: Dict[str, float] = {}
description_distances: Dict[str, float] = {}
visual_distances: dict[str, float] = {}
description_distances: dict[str, float] = {}
try:
if similarity_mode in ("visual", "fused"):
@ -462,7 +462,7 @@ async def _execute_find_similar_objects(
eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))}
# 6. Fuse and rank.
scored: List[tuple[str, float]] = []
scored: list[tuple[str, float]] = []
for eid in eligible:
v_score = (
distance_to_score(visual_distances[eid], context.thumb_stats)
@ -503,7 +503,7 @@ async def _execute_find_similar_objects(
async def execute_tool(
request: Request,
body: ToolExecuteRequest = Body(...),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
) -> JSONResponse:
"""
Execute a tool function call.
@ -545,8 +545,8 @@ async def execute_tool(
async def _execute_get_live_context(
request: Request,
camera: str,
allowed_cameras: List[str],
) -> Dict[str, Any]:
allowed_cameras: list[str],
) -> dict[str, Any]:
# Reject wildcards explicitly so models retry with a real camera name
# instead of silently fanning out across every camera.
if camera in ("*", "all"):
@ -593,7 +593,7 @@ async def _execute_get_live_context(
"stationary": obj_dict.get("stationary", False),
}
result: Dict[str, Any] = {
result: dict[str, Any] = {
"camera": camera,
"timestamp": frame_time,
"detections": list(tracked_objects_dict.values()),
@ -620,7 +620,7 @@ async def _execute_get_live_context(
async def _get_live_frame_image_url(
request: Request,
camera: str,
allowed_cameras: List[str],
allowed_cameras: list[str],
) -> Optional[str]:
"""
Fetch the current live frame for a camera as a base64 data URL.
@ -659,8 +659,8 @@ async def _get_live_frame_image_url(
async def _execute_set_camera_state(
request: Request,
arguments: Dict[str, Any],
) -> Dict[str, Any]:
arguments: dict[str, Any],
) -> dict[str, Any]:
role = request.headers.get("remote-role", "")
if "admin" not in [r.strip() for r in role.split(",")]:
return {"error": "Admin privileges required to change camera settings."}
@ -699,10 +699,10 @@ async def _execute_set_camera_state(
async def _execute_tool_internal(
tool_name: str,
arguments: Dict[str, Any],
arguments: dict[str, Any],
request: Request,
allowed_cameras: List[str],
) -> Dict[str, Any]:
allowed_cameras: list[str],
) -> dict[str, Any]:
"""
Internal helper to execute a tool and return the result as a dict.
@ -763,8 +763,8 @@ async def _execute_tool_internal(
async def _execute_start_camera_watch(
request: Request,
arguments: Dict[str, Any],
) -> Dict[str, Any]:
arguments: dict[str, Any],
) -> dict[str, Any]:
camera = arguments.get("camera", "").strip()
condition = arguments.get("condition", "").strip()
max_duration_minutes = int(arguments.get("max_duration_minutes", 60))
@ -814,14 +814,14 @@ async def _execute_start_camera_watch(
}
def _execute_stop_camera_watch() -> Dict[str, Any]:
def _execute_stop_camera_watch() -> dict[str, Any]:
cancelled = stop_vlm_watch_job()
if cancelled:
return {"success": True, "message": "Watch job cancelled."}
return {"success": False, "message": "No active watch job to cancel."}
def _execute_get_profile_status(request: Request) -> Dict[str, Any]:
def _execute_get_profile_status(request: Request) -> dict[str, Any]:
"""Return profile status including active profile and activation timestamps."""
profile_manager = getattr(request.app, "profile_manager", None)
if profile_manager is None:
@ -846,9 +846,9 @@ def _execute_get_profile_status(request: Request) -> Dict[str, Any]:
def _execute_get_recap(
arguments: Dict[str, Any],
allowed_cameras: List[str],
) -> Dict[str, Any]:
arguments: dict[str, Any],
allowed_cameras: list[str],
) -> dict[str, Any]:
"""Fetch review segments with GenAI metadata for a time period."""
from functools import reduce
@ -909,7 +909,7 @@ def _execute_get_recap(
.iterator()
)
events: List[Dict[str, Any]] = []
events: list[dict[str, Any]] = []
for row in rows:
data = row.get("data") or {}
@ -920,7 +920,7 @@ def _execute_get_recap(
data = {}
camera = row["camera"]
event: Dict[str, Any] = {
event: dict[str, Any] = {
"camera": camera.replace("_", " ").title(),
"severity": row.get("severity", "detection"),
}
@ -984,10 +984,10 @@ def _execute_get_recap(
async def _execute_pending_tools(
pending_tool_calls: List[Dict[str, Any]],
pending_tool_calls: list[dict[str, Any]],
request: Request,
allowed_cameras: List[str],
) -> tuple[List[ToolCall], List[Dict[str, Any]], List[Dict[str, Any]]]:
allowed_cameras: list[str],
) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]:
"""
Execute a list of tool calls.
@ -996,9 +996,9 @@ async def _execute_pending_tools(
tool result dicts for conversation,
extra messages to inject after tool results e.g. user messages with images)
"""
tool_calls_out: List[ToolCall] = []
tool_results: List[Dict[str, Any]] = []
extra_messages: List[Dict[str, Any]] = []
tool_calls_out: list[ToolCall] = []
tool_results: list[dict[str, Any]] = []
extra_messages: list[dict[str, Any]] = []
for tool_call in pending_tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call.get("arguments") or {}
@ -1106,7 +1106,7 @@ async def _execute_pending_tools(
async def chat_completion(
request: Request,
body: ChatCompletionRequest = Body(...),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""
Chat completion endpoint with tool calling support.
@ -1138,19 +1138,23 @@ async def chat_completion(
)
conversation = []
system_prompt = build_chat_system_prompt(
config=config,
allowed_cameras=allowed_cameras,
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
)
conversation.append(
{
"role": "system",
"content": system_prompt,
}
)
# Build the system message only when the client hasn't already pinned one.
# The first turn has no system message; we generate it (with the current
# timestamp) and return the whole chain so the client persists it. Later
# turns send it back verbatim, freezing the timestamp so the prompt prefix
# stays byte-identical and the model server's prompt cache keeps hitting.
if not body.messages or body.messages[0].role != "system":
conversation.append(
{
"role": "system",
"content": build_chat_system_prompt(
config=config,
allowed_cameras=allowed_cameras,
semantic_search_enabled=semantic_search_enabled,
attribute_classifications=attribute_classifications,
),
}
)
for msg in body.messages:
msg_dict = {
@ -1161,11 +1165,13 @@ async def chat_completion(
msg_dict["tool_call_id"] = msg.tool_call_id
if msg.name:
msg_dict["name"] = msg.name
if msg.tool_calls is not None:
msg_dict["tool_calls"] = msg.tool_calls
conversation.append(msg_dict)
tool_iterations = 0
tool_calls: List[ToolCall] = []
tool_calls: list[ToolCall] = []
max_iterations = body.max_tool_iterations
logger.debug(
@ -1175,11 +1181,20 @@ async def chat_completion(
# True LLM streaming when client supports it and stream requested
if body.stream and hasattr(genai_client, "chat_with_tools_stream"):
stream_tool_calls: List[ToolCall] = []
stream_iterations = 0
async def stream_body_llm():
nonlocal conversation, stream_tool_calls, stream_iterations
nonlocal conversation, stream_iterations
def _emit_chain(extra: Optional[list[dict[str, Any]]] = None):
# Return the full conversation (including the system message) so
# the client persists and replays it verbatim next turn.
chain = conversation + (extra or [])
return (
json.dumps({"type": "messages", "messages": chain}).encode("utf-8")
+ b"\n"
)
while stream_iterations < max_iterations:
if await request.is_disconnected():
logger.debug("Client disconnected, stopping chat stream")
@ -1244,31 +1259,33 @@ async def chat_completion(
)
return
(
executed_calls,
_executed_calls,
tool_results,
extra_msgs,
) = await _execute_pending_tools(
pending, request, allowed_cameras
)
stream_tool_calls.extend(executed_calls)
conversation.extend(tool_results)
conversation.extend(extra_msgs)
yield (
json.dumps(
{
"type": "tool_calls",
"tool_calls": [
tc.model_dump() for tc in stream_tool_calls
],
}
).encode("utf-8")
+ b"\n"
)
# Emit the running chain so the client can render tool
# calls live and replay them verbatim next turn.
yield _emit_chain()
break
else:
# Streaming never appends the final assistant message
# to the conversation, so add it to the chain.
yield _emit_chain(
extra=[
{
"role": "assistant",
"content": msg.get("content"),
}
]
)
yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n")
return
else:
yield _emit_chain()
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
return StreamingResponse(
@ -1315,19 +1332,15 @@ async def chat_completion(
if body.stream:
final_reasoning = response.get("reasoning")
chain = list(conversation)
async def stream_body() -> Any:
if tool_calls:
yield (
json.dumps(
{
"type": "tool_calls",
"tool_calls": [
tc.model_dump() for tc in tool_calls
],
}
).encode("utf-8")
+ b"\n"
yield (
json.dumps({"type": "messages", "messages": chain}).encode(
"utf-8"
)
+ b"\n"
)
# Emit the full reasoning trace up front when the
# underlying client did not stream it
if final_reasoning:
@ -1363,6 +1376,7 @@ async def chat_completion(
finish_reason=response.get("finish_reason", "stop"),
tool_iterations=tool_iterations,
tool_calls=tool_calls,
messages=list(conversation),
).model_dump(),
)
@ -1395,6 +1409,7 @@ async def chat_completion(
finish_reason="length",
tool_iterations=tool_iterations,
tool_calls=tool_calls,
messages=list(conversation),
).model_dump(),
)

View File

@ -1,6 +1,6 @@
"""Chat API request models."""
from typing import Optional
from typing import Any, Optional
from pydantic import BaseModel, Field
@ -11,13 +11,29 @@ class ChatMessage(BaseModel):
role: str = Field(
description="Message role: 'user', 'assistant', 'system', or 'tool'"
)
content: str = Field(description="Message content")
content: Optional[Any] = Field(
default=None,
description=(
"Message content. Usually a string, but may be a multimodal content "
"list (e.g. text + image_url) or null for assistant turns that only "
"request tool calls."
),
)
tool_call_id: Optional[str] = Field(
default=None, description="For tool messages, the ID of the tool call"
)
name: Optional[str] = Field(
default=None, description="For tool messages, the tool name"
)
tool_calls: Optional[list[dict[str, Any]]] = Field(
default=None,
description=(
"For assistant messages replayed from prior turns, the OpenAI-format "
"tool calls the model previously requested. Replaying these verbatim "
"keeps the conversation prefix byte-for-byte identical so the model "
"server's prompt cache hits on follow-up turns."
),
)
class ChatCompletionRequest(BaseModel):

View File

@ -56,3 +56,12 @@ class ChatCompletionResponse(BaseModel):
default_factory=list,
description="List of tool calls that were executed during this completion",
)
messages: list[dict[str, Any]] = Field(
default_factory=list,
description=(
"The full conversation chain, including the system message. Persist "
"and replay this verbatim on the next request so the prompt prefix "
"stays byte-identical and the model server's prompt cache keeps "
"hitting."
),
)

4
web/.gitignore vendored
View File

@ -12,6 +12,10 @@ dist
dist-ssr
*.local
# Playwright
playwright-report
test-results
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@ -92,6 +92,15 @@ test.describe("Chat — streaming @medium", () => {
await installChatStreamOverride(frigateApp, [
{ type: "content", delta: "Hel" },
{ type: "content", delta: "lo" },
{
type: "messages",
messages: [
{ role: "system", content: "sys" },
{ role: "user", content: "hello chat" },
{ role: "assistant", content: "Hello" },
],
},
{ type: "done" },
]);
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
@ -137,6 +146,15 @@ test.describe("Chat — streaming @medium", () => {
{ type: "content", delta: "Hel" },
{ type: "content", delta: "lo, " },
{ type: "content", delta: "world!" },
{
type: "messages",
messages: [
{ role: "system", content: "sys" },
{ role: "user", content: "greet me" },
{ role: "assistant", content: "Hello, world!" },
],
},
{ type: "done" },
],
{ chunkDelayMs: 50 },
);
@ -151,19 +169,39 @@ test.describe("Chat — streaming @medium", () => {
});
});
test("tool_calls chunks render a ToolCallsGroup", async ({ frigateApp }) => {
await installChatStreamOverride(frigateApp, [
test("tool calls in the chain render a ToolCallsGroup", async ({
frigateApp,
}) => {
const toolTurn = [
{ role: "system", content: "sys" },
{ role: "user", content: "find people" },
{
type: "tool_calls",
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
name: "search_objects",
arguments: { label: "person" },
type: "function",
function: {
name: "search_objects",
arguments: '{"label":"person"}',
},
},
],
},
{ role: "tool", tool_call_id: "call_1", content: "[]" },
];
await installChatStreamOverride(frigateApp, [
{ type: "messages", messages: toolTurn },
{ type: "content", delta: "Searching for people." },
{
type: "messages",
messages: [
...toolTurn,
{ role: "assistant", content: "Searching for people." },
],
},
{ type: "done" },
]);
await frigateApp.goto("/chat");
const input = frigateApp.page.getByPlaceholder(/ask/i);
@ -253,6 +291,15 @@ test.describe("Chat — attachment chip @medium", () => {
// We use the stream override so the first message completes quickly.
await installChatStreamOverride(frigateApp, [
{ type: "content", delta: "Done." },
{
type: "messages",
messages: [
{ role: "system", content: "sys" },
{ role: "user", content: "hello" },
{ role: "assistant", content: "Done." },
],
},
{ type: "done" },
]);
await frigateApp.goto("/chat");

View File

@ -191,7 +191,22 @@
},
"audio": "Àudio:",
"cameraProbeInfo": "Informació del sondeig de la càmera {{camera}}",
"streamDataFromFFPROBE": "Les dades de la transmissió són obtingudes mitjançant <code>ffprobe</code>."
"streamDataFromFFPROBE": "Les dades de la transmissió són obtingudes mitjançant <code>ffprobe</code>.",
"keyframes": {
"title": "Anàlisi de fotogrames clau",
"analyzing": "S'estan analitzant els fotogrames clau... queden {{seconds}} segons",
"stillAnalyzing": "Encara s'estan analitzant els fotogrames clau...",
"recordStream": "Registre de flux:",
"keyframeCount": "Fotogrames clau observats:",
"observedDuration": "Durada observada:",
"gap": "Espai de fotogrames clau (mín / avg / max):",
"segmentLength": "Longitud del segment d'enregistrament:",
"ok": "Fotogrames clau cada ,{{seconds}}s, bons per enregistrar i reproduir.",
"warning": "Els fotogrames clau dispersos o variables (espai més llarg .{{seconds}}s), probablement un còdec intel·ligent (H.264+/H.265+), això no és recomanable.",
"error": "El buit dels fotogrames clau ( the{{seconds}}s) excedeix la longitud del segment d'enregistrament ({{segmentTime}}s). Alguns segments poden no tenir un fotograma clau, el qual trenca la reproducció. Desactiva el còdec intel·ligent/+ a la càmera o escurça el seu interval de fotogrames clau.",
"unknown": "No s'ha pogut determinar l'espaiat dels fotogrames clau.",
"recordDisabled": "L'enregistrament està desactivat per a aquesta càmera."
}
},
"title": "Càmeres",
"overview": "Visió general",

View File

@ -24,7 +24,7 @@
},
"listen": {
"label": "Tipos de escucha",
"description": "Lista de tipos de eventos de audio a detectar (por ejemplo: ladrido, alarma de incendios, grito, voz, alarido)."
"description": "Lista de tipos de eventos de audio a detectar (por ejemplo: ladrar, alarma de incendio, habla, gritar)."
},
"filters": {
"label": "Filtros de audio",
@ -744,7 +744,7 @@
"label": "Tiempo de reintento de FFmpeg"
},
"path": {
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\").",
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"7.0\" o \"8.0\").",
"label": "Ruta de FFmpeg"
},
"output_args": {

View File

@ -39,7 +39,7 @@
},
"listen": {
"label": "Tipos de escucha",
"description": "Lista de tipos de eventos de audio a detectar (por ejemplo: ladrido, alarma de incendios, grito, voz, alarido)."
"description": "Lista de tipos de eventos de audio a detectar (por ejemplo: ladrar, alarma de incendio, habla, gritar)."
},
"filters": {
"label": "Filtros de audio",
@ -1234,7 +1234,7 @@
"label": "Tiempo de reintento de FFmpeg"
},
"path": {
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"5.0\" o \"7.0\").",
"description": "Ruta al binario de FFmpeg que se va a utilizar o un alias de versión (\"7.0\" o \"8.0\").",
"label": "Ruta de FFmpeg"
},
"output_args": {

View File

@ -2020,6 +2020,9 @@
},
"onvif": {
"autotrackingNoZones": "El seguimiento automático requiere al menos una zona. Define una zona para esta cámara en Máscaras / Zonas y, a continuación, establécela como zona obligatoria a continuación."
},
"ffmpeg": {
"hwaccelManualNotRecommended": "No son remontados los argumentos de aceleración por hardware manual. A no ser que un requisito específico exista, seleccione la preselección que coincida con su hardware."
}
},
"resetToDefaultDescription": "Esto restablecerá todos los ajustes de esta sección a sus valores predeterminados. Esta acción no se puede deshacer.",

View File

@ -81,16 +81,16 @@
"gpuInfo": {
"vainfoOutput": {
"title": "Salida de Vainfo",
"returnCode": "Código de Retorno: {{code}}",
"processOutput": "Salida del Proceso:",
"processError": "Error del Proceso:"
"returnCode": "Código de retorno: {{code}}",
"processOutput": "Salida del proceso:",
"processError": "Error del proceso:"
},
"nvidiaSMIOutput": {
"cudaComputerCapability": "Capacidad de Cómputo CUDA: {{cuda_compute}}",
"cudaComputerCapability": "Capacidad de cómputo CUDA: {{cuda_compute}}",
"title": "Salida de Nvidia SMI",
"driver": "Controlador: {{driver}}",
"name": "Nombre: {{name}}",
"vbios": "Información de VBios: {{vbios}}"
"vbios": "Informe de VBios: {{vbios}}"
},
"toast": {
"success": "Información de GPU copiada al portapapeles"
@ -104,7 +104,7 @@
},
"gpuMemory": "Memoria de GPU",
"npuMemory": "Memoria de NPU",
"npuUsage": "Uso de NPU",
"npuUsage": "Modo de empleo de NPU",
"intelGpuWarning": {
"title": "Aviso de estadísticas Intel GPU",
"message": "Estadísticas de GPU no disponibles",
@ -122,7 +122,7 @@
"go2rtc": "go2rtc",
"recording": "grabación",
"review_segment": "revisar segmento",
"embeddings": "embeddings",
"embeddings": "empotrados",
"audio_detector": "detector de audio"
}
}
@ -165,8 +165,8 @@
"codec": "Codec:",
"fetching": "Obteniendo Datos de la Cámara",
"stream": "Flujo {{idx}}",
"video": "Video:",
"fps": "FPS:",
"video": "Vídeo:",
"fps": "CPS:",
"resolution": "Resolución:",
"error": "Error: {{error}}",
"unknown": "Desconocido",
@ -174,7 +174,21 @@
"tips": {
"title": "Información de Sondeo de la Cámara"
},
"aspectRatio": "Relación de aspecto"
"aspectRatio": "Relación de aspecto",
"keyframes": {
"title": "Análisis de clave fotograma",
"recordStream": "Flujo de grabación:",
"segmentLength": "Longitud de segmento en grabación:",
"unknown": "No pudo determinar el espaciado del fotograma.",
"analyzing": "Analizando fotogramas clave… {{seconds}} segundos restantes",
"stillAnalyzing": "Todavía analizando fotogramas clave…",
"keyframeCount": "Fotogramas clave observados:",
"observedDuration": "Duración observada:",
"gap": "Brecha de fotogramas clave (mín / med / máx):",
"recordDisabled": "La grabación está desactivada para esta cámara.",
"ok": "Cuadros cada ~{{seconds}}s, bueno para grabación y reproducción.",
"warning": "Fotograma clave escaso o variable (hueco más largo ~{{seconds}}s), probablemente un códec inteligente (H.264+/H.265+), esto no es recomendado."
}
},
"framesAndDetections": "Fotogramas / Detecciones",
"label": {
@ -204,9 +218,9 @@
},
"connectionQuality": {
"excellent": "Excelente",
"poor": "Debil",
"title": "Calidad de la conexión",
"fps": "Cuadros por segundo",
"poor": "Pobre",
"title": "Calidad de Conexión",
"fps": "CPS",
"expectedFps": "Cuadros por segundo esperados",
"reconnectsLastHour": "Reconexiones (última hora)",
"unusable": "No usable",
@ -222,25 +236,25 @@
"infPerSecond": "Inferencias Por Segundo",
"embeddings": {
"plate_recognition_speed": "Velocidad de Reconocimiento de Matrículas",
"face_embedding_speed": "Velocidad de Incrustación de Rostros",
"image_embedding_speed": "Velocidad de Incrustación de Imágenes",
"text_embedding_speed": "Velocidad de Incrustación de Texto",
"face_embedding_speed": "Velocidad de Empotrado Facial",
"image_embedding_speed": "Velocidad de Empotrado de Imagen",
"text_embedding_speed": "Velocidad de Empotrado de Texto",
"face_recognition_speed": "Velocidad de Reconocimiento Facial",
"text_embedding": "Incrustación de Texto",
"text_embedding": "Empotrado de Texto",
"face_recognition": "Reconocimiento Facial",
"plate_recognition": "Reconocimiento de Matrículas",
"yolov9_plate_detection": "Detección de Matrículas YOLOv9",
"image_embedding": "Incrustación de Imágenes",
"image_embedding": "Empotrado de Imagen",
"yolov9_plate_detection_speed": "Velocidad de Detección de Matrículas YOLOv9",
"review_description": "Revisión de descripción",
"review_description_speed": "Velocidad de revisión de la descripción",
"review_description_events_per_second": "Revisión de la descripción",
"review_description_speed": "Revisión de velocidad de descripción",
"review_description_events_per_second": "Revisión de Descripción",
"object_description": "Descripción de Objeto",
"object_description_speed": "Velocidad de descripción de objeto",
"object_description_events_per_second": "Descripción de objeto",
"object_description_speed": "Velocidad de Descripción de Objeto",
"object_description_events_per_second": "Descripción de Objeto",
"classification": "Clasificación de {{name}}",
"classification_speed": "Velocidad de clasificación de {{name}}",
"classification_events_per_second": "Clasificacion de eventos por segundo de {{name}}"
"classification_speed": "Velocidad de Clasificación de {{name}}",
"classification_events_per_second": "Clasificación de Eventos por Segundo de {{name}}"
},
"title": "Enriquecimientos",
"averageInf": "Tiempo promedio de inferencia"
@ -249,7 +263,7 @@
"ffmpegHighCpuUsage": "{{camera}} tiene un uso elevado de CPU por FFmpeg ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} tiene un uso elevado de CPU por detección ({{detectAvg}}%)",
"healthy": "El sistema está saludable",
"reindexingEmbeddings": "Reindexando incrustaciones ({{processed}}% completado)",
"reindexingEmbeddings": "Reindexando empotrados ({{processed}}% completado)",
"detectIsSlow": "{{detect}} es lento ({{speed}} ms)",
"cameraIsOffline": "{{camera}} está desconectada",
"detectIsVerySlow": "{{detect}} es muy lento ({{speed}} ms)",

View File

@ -201,7 +201,22 @@
"title": "Info Sondă Cameră"
},
"fps": "FPS:",
"unknown": "Necunoscut"
"unknown": "Necunoscut",
"keyframes": {
"title": "Analiză keyframe",
"analyzing": "Se analizează keyframe-urile... {{seconds}} secunde rămase",
"stillAnalyzing": "Încă se analizează keyframe-urile...",
"keyframeCount": "Keyframe-uri observate:",
"recordStream": "Stream de înregistrare:",
"observedDuration": "Durată observată:",
"gap": "Interval keyframe (min / med / max):",
"ok": "Keyframe-uri la fiecare ~{{seconds}}s, bune pentru înregistrare și redare.",
"segmentLength": "Lungime segment de înregistrare:",
"warning": "Keyframe-uri rare sau variabile (cel mai lung interval ~{{seconds}}s), probabil un codec smart (H.264+/H.265+), acest lucru nu este recomandat.",
"error": "Intervalul keyframe (~{{seconds}}s) depășește lungimea segmentului de înregistrare ({{segmentTime}}s). Unele segmente pot să nu aibă niciun keyframe, ceea ce întrerupe redarea. Dezactivează codecul smart/+ de pe cameră sau scurtează intervalul keyframe.",
"unknown": "Nu s-a putut determina distanțarea keyframe-urilor.",
"recordDisabled": "Înregistrarea este dezactivată pentru această cameră."
}
},
"label": {
"capture": "captură",

View File

@ -130,7 +130,11 @@
"export": "Экспортировать",
"deleteNow": "Удалить сейчас",
"next": "Следующий",
"continue": "Продолжить"
"continue": "Продолжить",
"add": "Добавить",
"applying": "Применяется…",
"undo": "Отменить",
"copiedToClipboard": "Скопировано в буфер обмена"
},
"label": {
"back": "Вернуться",

View File

@ -3,5 +3,27 @@
"name": {
"label": "Kameranamn",
"description": "Kameranamn krävs"
},
"friendly_name": {
"label": "Visningsnamn",
"description": "Visningsnamn för kamera i Frigate UI"
},
"enabled": {
"label": "Aktiverad",
"description": "Aktiverad"
},
"audio": {
"label": "Ljuddetektering",
"description": "Inställningar för ljudbaserad händelsedetektering för denna kamera.",
"enabled": {
"label": "Aktivera ljuddetektering",
"description": "Aktivera eller avaktivera ljudbaserad detektering för denna kamera."
},
"max_not_heard": {
"description": "Antal sekunder utan den konfigurerade ljudtypen innan en ljudbaserad händelse slutar."
},
"min_volume": {
"label": "Minsta ljudvolym"
}
}
}

View File

@ -1,5 +1,17 @@
{
"version": {
"label": "Nuvarande konfigurations version"
},
"audio": {
"label": "Ljuddetektering",
"enabled": {
"label": "Aktivera ljuddetektering"
},
"max_not_heard": {
"description": "Antal sekunder utan den konfigurerade ljudtypen innan en ljudbaserad händelse slutar."
},
"min_volume": {
"label": "Minsta ljudvolym"
}
}
}

View File

@ -179,7 +179,8 @@
"id": "Bahasa Indonesia (Індонезійська)",
"ur": "اردو (Урду)",
"hr": "Hrvatski (Хорватська)",
"bs": "Bosanski (Боснійська)"
"bs": "Bosanski (Боснійська)",
"zhHant": "繁體中文 (Традиційна китайська)"
},
"system": "Система",
"systemMetrics": "Системна метріка",

View File

@ -68,7 +68,10 @@
"label": "Камери",
"desc": "Виберіть камери для цієї групи."
},
"icon": "Значок"
"icon": "Значок",
"showAll": "Відобразити всі групи камер",
"showLess": "Показати менше",
"editGroups": "Редагувати групи камер"
},
"debug": {
"zones": "Зони",

View File

@ -47,5 +47,6 @@
"error": {
"submitFrigatePlusFailed": "Не вдалося надіслати фрейм Frigate+"
}
}
},
"cameraOff": "Камера вимкнена"
}

View File

@ -11,5 +11,8 @@
"enabled": {
"label": "Увімкнено",
"description": "Увімкнено"
},
"audio": {
"label": "Виявлення звуку"
}
}

View File

@ -1 +1,5 @@
{}
{
"audio": {
"label": "Виявлення звуку"
}
}

View File

@ -33,7 +33,7 @@
},
"listen": {
"label": "监听类型",
"description": "要检测的音频事件类型列表例如bark、fire_alarm、scream、speech、yell。"
"description": "要检测的音频事件类型列表例如bark、fire_alarm、speech、yell。"
},
"filters": {
"label": "音频过滤器",
@ -156,7 +156,7 @@
"description": "FFmpeg 编解码相关设置,包含可执行文件路径、命令行参数、硬件加速选项,以及按不同功能划分的输出参数。",
"path": {
"label": "FFmpeg 路径",
"description": "要使用的 FFmpeg 可执行文件路径,或版本别名(如 \"5.0\" 或 \"7.0\")。"
"description": "要使用的 FFmpeg 可执行文件路径,或版本别名(如 \"7.0\" 或 \"8.0\")。"
},
"global_args": {
"label": "FFmpeg 全局参数",

View File

@ -44,7 +44,7 @@
},
"listen": {
"label": "监听类型",
"description": "要检测的音频事件类型列表例如bark、fire_alarm、scream、speech、yell。"
"description": "要检测的音频事件类型列表例如bark、fire_alarm、speech、yell。"
},
"filters": {
"label": "音频过滤器",
@ -292,7 +292,7 @@
"description": "FFmpeg 编解码相关设置,包含可执行文件路径、命令行参数、硬件加速选项,以及按不同功能划分的输出参数。",
"path": {
"label": "FFmpeg 路径",
"description": "要使用的 FFmpeg 可执行文件路径,或版本别名(如 \"5.0\" 或 \"7.0\")。"
"description": "要使用的 FFmpeg 可执行文件路径,或版本别名(如 \"7.0\" 或 \"8.0\")。"
},
"global_args": {
"label": "FFmpeg 全局参数",

View File

@ -46,7 +46,7 @@
"tips": {
"title": "如何使用文本筛选器",
"desc": {
"text": "筛选器可帮助您缩小搜索范围。注意,目前还暂不支持中文搜索。以下是在输入字段中使用筛选器的方法:",
"text": "筛选器可帮助您缩小搜索范围。注意,Jina v1 不支持中文搜索。以下是在输入字段中使用筛选器的方法:",
"step": "<ul className=\"list-disc pl-5 text-sm text-primary-variant\"><li>输入筛选器名称后跟一个冒号例如“cameras:”)。</li><li>从建议中选择一个值或输入您自己的值。</li><li>使用多个筛选器时,可以在它们之间用空格分隔。</li><li>日期筛选器before: 和 after:)使用 <em>{{DateFormat}}</em> 格式。</li><li>时间范围筛选器使用 <em>{{exampleTime}}</em> 格式。</li><li>点击筛选器旁边的“x”即可移除筛选条件。</li></ul>",
"example": "示例:<code className=\"text-primary\">cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM</code>",
"step2": "选择给出的建议值或自行输入;",

View File

@ -2115,6 +2115,9 @@
},
"onvif": {
"autotrackingNoZones": "自动追踪至少需要一个区域。请先在“遮罩 / 区域”中为此摄像头定义一个区域,然后在下方将其设置为必需区域。"
},
"ffmpeg": {
"hwaccelManualNotRecommended": "不建议手动硬件加速参数。除非存在特定需求,否则选择与你的硬件匹配的预设。"
}
},
"birdseye": {

View File

@ -175,7 +175,22 @@
"tips": {
"title": "摄像头信息"
},
"aspectRatio": "宽高比"
"aspectRatio": "宽高比",
"keyframes": {
"title": "关键帧分析",
"analyzing": "正在分析关键帧... 剩余 {{seconds}} 秒",
"stillAnalyzing": "仍在分析关键帧...",
"recordStream": "录制视频流:",
"keyframeCount": "观察到的关键帧:",
"observedDuration": "观测持续时间:",
"gap": "关键帧间隔(最小值 / 平均值 / 最大值):",
"segmentLength": "录制片段长度:",
"ok": "每 {{seconds}} 秒取一帧,适用于录制和回放。",
"warning": "稀疏或不均匀的关键帧(最长间隔约{{seconds}}秒可能是使用了智能编码器例如H.264+/H.265+),不建议开启该功能。",
"error": "关键帧间隔(~{{seconds}}秒)超过了录制片段长度({{segmentTime}}秒)。某些片段可能没有关键帧,这会导致播放中断。请尝试禁用摄像头的智能或 + 编解码器或缩短其关键帧间隔。",
"unknown": "无法确定关键帧间隔。",
"recordDisabled": "此摄像头的录制功能已禁用。"
}
},
"framesAndDetections": "帧数/检测次数",
"label": {

View File

@ -13,6 +13,7 @@ import { ChatComposer } from "@/components/chat/ChatComposer";
import ChatSettings from "@/components/chat/ChatSettings";
import type {
ChatMessage,
ChatStats,
GenAIModelsResponse,
ShowStatsMode,
} from "@/types/chat";
@ -22,12 +23,28 @@ import {
getFindSimilarObjectsFromToolCalls,
prependAttachment,
streamChatCompletion,
toolCallsForMessage,
toolResponsesById,
} from "@/utils/chatUtil";
type StreamingTurn = {
content: string;
reasoning: string;
chain: ChatMessage[];
stats?: ChatStats;
};
const hasText = (content: unknown): content is string =>
typeof content === "string" && content.trim().length > 0;
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest);
export default function ChatPage() {
const { t } = useTranslation(["views/chat"]);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streaming, setStreaming] = useState<StreamingTurn | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
@ -72,28 +89,19 @@ export default function ChatPage() {
if (isNearBottom) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
}, [messages, autoScroll]);
}, [messages, streaming, autoScroll]);
const submitConversation = useCallback(
async (messagesToSend: ChatMessage[]) => {
if (isLoading) return;
const last = messagesToSend[messagesToSend.length - 1];
if (!last || last.role !== "user" || !last.content.trim()) return;
if (!last || last.role !== "user" || !hasText(last.content)) return;
setError(null);
const assistantPlaceholder: ChatMessage = {
role: "assistant",
content: "",
toolCalls: undefined,
};
setMessages([...messagesToSend, assistantPlaceholder]);
setMessages(messagesToSend);
setStreaming({ content: "", reasoning: "", chain: [] });
setIsLoading(true);
const apiMessages = messagesToSend.map((m) => ({
role: m.role,
content: m.content,
}));
const baseURL = axios.defaults.baseURL ?? "";
const url = `${baseURL}chat/completion`;
const headers: Record<string, string> = {
@ -104,16 +112,50 @@ export default function ChatPage() {
const controller = new AbortController();
abortRef.current = controller;
let chain: ChatMessage[] = [];
let stats: ChatStats | undefined;
let reasoning = "";
let hadError = false;
await streamChatCompletion(
url,
headers,
apiMessages,
toWire(messagesToSend),
{
updateMessages: (updater) => setMessages(updater),
onError: (message) => setError(message),
onContentDelta: (delta) =>
setStreaming((s) => (s ? { ...s, content: s.content + delta } : s)),
onReasoningDelta: (delta) => {
reasoning += delta;
setStreaming((s) =>
s ? { ...s, reasoning: s.reasoning + delta } : s,
);
},
onChain: (fullChain) => {
chain = fullChain;
setStreaming((s) => (s ? { ...s, chain: fullChain } : s));
},
onStats: (s) => {
stats = s;
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
},
onError: (message) => {
hadError = true;
setError(message);
},
onDone: () => {
abortRef.current = null;
setIsLoading(false);
setStreaming(null);
const lastMsg = chain[chain.length - 1];
if (!hadError && lastMsg?.role === "assistant") {
setMessages(
chain.map((m, i) =>
i === chain.length - 1
? { ...m, reasoning: reasoning || undefined, stats }
: m,
),
);
}
},
defaultErrorMessage: t("error"),
},
@ -125,12 +167,14 @@ export default function ChatPage() {
);
const recentEventIds = useMemo(() => {
const responses = toolResponsesById(messages);
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant" || !msg.toolCalls) continue;
const similar = getFindSimilarObjectsFromToolCalls(msg.toolCalls);
if (msg.role !== "assistant" || !msg.tool_calls?.length) continue;
const calls = toolCallsForMessage(msg, responses);
const similar = getFindSimilarObjectsFromToolCalls(calls);
if (similar) return similar.results.map((e) => e.id);
const events = getEventIdsFromSearchObjectsToolCalls(msg.toolCalls);
const events = getEventIdsFromSearchObjectsToolCalls(calls);
if (events.length > 0) return events.map((e) => e.id);
}
return [];
@ -154,12 +198,14 @@ export default function ChatPage() {
abortRef.current?.abort();
abortRef.current = null;
setIsLoading(false);
setStreaming(null);
}, []);
const startNewChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsLoading(false);
setStreaming(null);
setMessages([]);
setInput("");
setAttachedEventId(null);
@ -181,7 +227,83 @@ export default function ChatPage() {
setAttachedEventId(null);
}, []);
const hasStarted = messages.length > 0;
const hasStarted = messages.length > 0 || streaming != null;
// While streaming, the backend's in-flight chain is the source of truth;
// otherwise the committed conversation is.
const renderList =
streaming && streaming.chain.length ? streaming.chain : messages;
const responses = toolResponsesById(renderList);
const renderTail = renderList[renderList.length - 1];
const finalShown =
renderTail?.role === "assistant" && hasText(renderTail.content);
const renderMessage = (msg: ChatMessage, i: number) => {
if (msg.role === "system" || msg.role === "tool") return null;
if (msg.role === "user") {
if (!hasText(msg.content)) return null;
return (
<div key={i} className="flex flex-col gap-2">
<MessageBubble
role="user"
content={msg.content}
messageIndex={i}
onEditSubmit={handleEditSubmit}
isComplete
showStats={showStats}
/>
</div>
);
}
const calls = toolCallsForMessage(msg, responses);
const contentText = hasText(msg.content) ? msg.content : "";
const similar = getFindSimilarObjectsFromToolCalls(calls);
const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls);
return (
<div key={i} className="flex flex-col gap-2">
{calls.length > 0 && <ToolCallsGroup toolCalls={calls} />}
{hasText(msg.reasoning) && (
<ReasoningBubble
reasoning={msg.reasoning}
answerStarted={!!contentText}
/>
)}
{contentText && (
<MessageBubble
role="assistant"
content={contentText}
messageIndex={i}
isComplete
stats={msg.stats}
showStats={showStats}
/>
)}
{similar ? (
<ChatEventThumbnailsRow
events={similar.results}
anchor={similar.anchor}
onAttach={setAttachedEventId}
/>
) : (
<ChatEventThumbnailsRow
events={events}
onAttach={setAttachedEventId}
/>
)}
</div>
);
};
const processingDots = (
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
);
return (
<div className="flex size-full flex-col">
@ -212,102 +334,31 @@ export default function ChatPage() {
<div className="flex w-full flex-col xl:w-[50%] 3xl:w-[35%]">
{hasStarted ? (
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
{messages.map((msg, i) => {
const isLastAssistant =
i === messages.length - 1 && msg.role === "assistant";
const isComplete =
msg.role === "user" || !isLoading || !isLastAssistant;
const hasToolCalls =
msg.toolCalls && msg.toolCalls.length > 0;
const hasContent = !!msg.content?.trim();
const hasReasoning = !!msg.reasoning?.trim();
const showProcessing =
isLastAssistant &&
isLoading &&
!hasContent &&
!hasReasoning;
// Hide empty placeholder only when there are no tool calls
// and no reasoning streaming yet
if (
isLastAssistant &&
isLoading &&
!hasContent &&
!hasToolCalls &&
!hasReasoning
)
return (
<div
key={i}
className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4"
>
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
);
return (
<div key={i} className="flex flex-col gap-2">
{msg.role === "assistant" && hasToolCalls && (
<ToolCallsGroup toolCalls={msg.toolCalls!} />
)}
{msg.role === "assistant" && hasReasoning && (
{renderList.map((msg, i) => renderMessage(msg, i))}
{streaming &&
!finalShown &&
(streaming.content || streaming.reasoning ? (
<div className="flex flex-col gap-2">
{hasText(streaming.reasoning) && (
<ReasoningBubble
reasoning={msg.reasoning!}
answerStarted={hasContent}
reasoning={streaming.reasoning}
answerStarted={!!streaming.content}
/>
)}
{showProcessing ? (
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
) : msg.role === "assistant" &&
!hasContent &&
hasReasoning &&
!isComplete ? null : (
{streaming.content && (
<MessageBubble
role={msg.role}
content={msg.content}
messageIndex={i}
onEditSubmit={
msg.role === "user" ? handleEditSubmit : undefined
}
isComplete={isComplete}
stats={msg.stats}
role="assistant"
content={streaming.content}
messageIndex={-1}
isComplete={false}
stats={streaming.stats}
showStats={showStats}
/>
)}
{msg.role === "assistant" &&
isComplete &&
(() => {
const similar = getFindSimilarObjectsFromToolCalls(
msg.toolCalls,
);
if (similar) {
return (
<ChatEventThumbnailsRow
events={similar.results}
anchor={similar.anchor}
onAttach={setAttachedEventId}
/>
);
}
const events = getEventIdsFromSearchObjectsToolCalls(
msg.toolCalls,
);
return (
<ChatEventThumbnailsRow
events={events}
onAttach={setAttachedEventId}
/>
);
})()}
</div>
);
})}
) : (
processingDots
))}
{error && (
<p
className="flex items-center gap-1.5 self-start text-sm text-destructive"

View File

@ -1,17 +1,30 @@
export type ToolCallFunction = {
name: string;
arguments: string;
};
export type WireToolCall = {
id: string;
type?: string;
function: ToolCallFunction;
};
export type ChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content: unknown;
tool_call_id?: string;
name?: string;
tool_calls?: WireToolCall[];
reasoning?: string;
stats?: ChatStats;
};
export type ToolCall = {
name: string;
arguments?: Record<string, unknown>;
response?: string;
};
export type ChatMessage = {
role: "user" | "assistant";
content: string;
reasoning?: string;
toolCalls?: ToolCall[];
stats?: ChatStats;
};
export type StartingRequest = {
label: string;
prompt: string;

View File

@ -1,16 +1,20 @@
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
export type StreamChatCallbacks = {
/** Update the messages array (e.g. pass to setState). */
updateMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
/** Streamed delta of the assistant's final answer text. */
onContentDelta: (delta: string) => void;
/** Streamed delta of the assistant's reasoning trace. */
onReasoningDelta: (delta: string) => void;
/** The full conversation chain so far (system message, history, this turn's
* tool-call turns, tool results, and on the final emission the final
* assistant message). */
onChain: (chain: ChatMessage[]) => void;
/** Token/timing stats for the turn. */
onStats: (stats: ChatStats) => void;
/** Called when the stream sends an error or fetch fails. */
onError: (message: string) => void;
/** Called when the stream finishes (success or error). */
onDone: () => void;
/** Called when the stream emits token/timing stats. The stats are also
* attached to the last assistant message in updateMessages, so consumers
* can usually rely on the message itself rather than wiring this up. */
onStats?: (stats: ChatStats) => void;
/** Message used when fetch throws and no server error is available. */
defaultErrorMessage?: string;
};
@ -25,7 +29,7 @@ type StatsChunk = {
type StreamChunk =
| { type: "error"; error: string }
| { type: "tool_calls"; tool_calls: ToolCall[] }
| { type: "messages"; messages: ChatMessage[] }
| { type: "content"; delta: string }
| { type: "reasoning"; delta: string }
| StatsChunk;
@ -41,16 +45,18 @@ export type StreamChatOptions = {
export async function streamChatCompletion(
url: string,
headers: Record<string, string>,
apiMessages: { role: string; content: string }[],
apiMessages: ChatMessage[],
callbacks: StreamChatCallbacks,
signal?: AbortSignal,
options: StreamChatOptions = {},
): Promise<void> {
const {
updateMessages,
onContentDelta,
onReasoningDelta,
onChain,
onStats,
onError,
onDone,
onStats,
defaultErrorMessage = "Something went wrong. Please try again.",
} = callbacks;
@ -91,65 +97,27 @@ export async function streamChatCompletion(
const applyChunk = (data: StreamChunk) => {
if (data.type === "error") {
onError(data.error);
updateMessages((prev) =>
prev.filter((m) => !(m.role === "assistant" && m.content === "")),
);
return "break";
}
if (data.type === "tool_calls" && data.tool_calls?.length) {
updateMessages((prev) => {
const next = [...prev];
const lastMsg = next[next.length - 1];
if (lastMsg?.role === "assistant")
next[next.length - 1] = {
...lastMsg,
toolCalls: data.tool_calls,
};
return next;
});
if (data.type === "messages") {
onChain(data.messages ?? []);
return "continue";
}
if (data.type === "content" && data.delta !== undefined) {
updateMessages((prev) => {
const next = [...prev];
const lastMsg = next[next.length - 1];
if (lastMsg?.role === "assistant")
next[next.length - 1] = {
...lastMsg,
content: lastMsg.content + data.delta,
};
return next;
});
onContentDelta(data.delta);
return "continue";
}
if (data.type === "reasoning" && data.delta !== undefined) {
updateMessages((prev) => {
const next = [...prev];
const lastMsg = next[next.length - 1];
if (lastMsg?.role === "assistant")
next[next.length - 1] = {
...lastMsg,
reasoning: (lastMsg.reasoning ?? "") + data.delta,
};
return next;
});
onReasoningDelta(data.delta);
return "continue";
}
if (data.type === "stats") {
const stats: ChatStats = {
onStats({
promptTokens: data.prompt_tokens,
completionTokens: data.completion_tokens,
completionDurationMs: data.completion_duration_ms,
tokensPerSecond: data.tokens_per_second,
};
updateMessages((prev) => {
const next = [...prev];
const lastMsg = next[next.length - 1];
if (lastMsg?.role === "assistant")
next[next.length - 1] = { ...lastMsg, stats };
return next;
});
onStats?.(stats);
return "continue";
}
return "continue";
@ -165,9 +133,8 @@ export async function streamChatCompletion(
const trimmed = line.trim();
if (!trimmed) continue;
try {
const data = JSON.parse(trimmed) as StreamChunk & { type: string };
const result = applyChunk(data as StreamChunk);
if (result === "break") {
const data = JSON.parse(trimmed) as StreamChunk;
if (applyChunk(data) === "break") {
hadStreamError = true;
break;
}
@ -181,50 +148,63 @@ export async function streamChatCompletion(
// Flush remaining buffer
if (!hadStreamError && buffer.trim()) {
try {
const data = JSON.parse(buffer.trim()) as StreamChunk & {
type: string;
delta?: string;
};
if (data.type === "content" && data.delta !== undefined) {
updateMessages((prev) => {
const next = [...prev];
const lastMsg = next[next.length - 1];
if (lastMsg?.role === "assistant")
next[next.length - 1] = {
...lastMsg,
content: lastMsg.content + data.delta!,
};
return next;
});
}
const data = JSON.parse(buffer.trim()) as StreamChunk;
applyChunk(data);
} catch {
// ignore final malformed chunk
}
}
if (!hadStreamError) {
updateMessages((prev) => {
const next = [...prev];
const lastMsg = next[next.length - 1];
if (lastMsg?.role === "assistant" && lastMsg.content === "")
next[next.length - 1] = { ...lastMsg, content: " " };
return next;
});
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// User stopped generation — not an error
} else {
onError(defaultErrorMessage);
updateMessages((prev) =>
prev.filter((m) => !(m.role === "assistant" && m.content === "")),
);
}
} finally {
onDone();
}
}
/** Map each tool result message to its tool_call_id for response lookup. */
export function toolResponsesById(
messages: ChatMessage[],
): Map<string, string> {
const map = new Map<string, string>();
for (const m of messages) {
if (m.role === "tool" && typeof m.tool_call_id === "string") {
map.set(
m.tool_call_id,
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
);
}
}
return map;
}
/** Derive the display tool calls for one assistant message. */
export function toolCallsForMessage(
message: ChatMessage,
responses: Map<string, string>,
): ToolCall[] {
if (!message.tool_calls?.length) return [];
return message.tool_calls.map((tc) => {
let args: Record<string, unknown> | undefined;
const raw = tc.function?.arguments;
if (typeof raw === "string") {
try {
args = JSON.parse(raw) as Record<string, unknown>;
} catch {
args = undefined;
}
}
return {
name: tc.function?.name ?? "",
arguments: args,
response: responses.get(tc.id),
};
});
}
/**
* Parse search_objects tool call response(s) into event ids for thumbnails.
*/