mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-25 10:46:51 +03:00
GenAI Chat Improvements (#24173)
* Initial tool approval implementation * Cleanups and fixes * Improve robustness of loading case
This commit is contained in:
Vendored
+12
@@ -7762,6 +7762,18 @@ components:
|
||||
description: Per-request thinking toggle. None means use the provider
|
||||
default. Ignored by providers that do not expose a per-request
|
||||
thinking switch.
|
||||
tool_decisions:
|
||||
additionalProperties:
|
||||
type: string
|
||||
enum:
|
||||
- approve
|
||||
- reject
|
||||
type: object
|
||||
title: Tool Decisions
|
||||
description: Decisions for tool calls that paused for approval, keyed
|
||||
by tool call ID. Send these with the conversation chain returned
|
||||
alongside an approval request; rejected calls are reported to the
|
||||
model as declined instead of being executed.
|
||||
type: object
|
||||
required:
|
||||
- messages
|
||||
|
||||
+495
-160
@@ -10,6 +10,7 @@ from functools import reduce
|
||||
from typing import Any, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -23,6 +24,7 @@ from frigate.api.chat_util import (
|
||||
chunk_content,
|
||||
distance_to_score,
|
||||
format_events_with_local_time,
|
||||
format_local_time,
|
||||
fuse_scores,
|
||||
hydrate_event,
|
||||
parse_iso_to_timestamp,
|
||||
@@ -33,29 +35,44 @@ from frigate.api.defs.response.chat_response import (
|
||||
ChatCompletionResponse,
|
||||
ChatMessageResponse,
|
||||
ToolCall,
|
||||
ToolCallInvocation,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import _build_attribute_filter_clause, events
|
||||
from frigate.api.export import _build_export_job, _validate_export_source
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import SemanticSearchModelEnum
|
||||
from frigate.genai.prompts import (
|
||||
build_chat_system_prompt,
|
||||
get_attribute_classifications,
|
||||
get_tool_definitions,
|
||||
get_write_tool_names,
|
||||
strip_tool_access,
|
||||
)
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.genai.utils import (
|
||||
build_assistant_message_for_conversation,
|
||||
parse_tool_calls_from_message,
|
||||
)
|
||||
from frigate.jobs.export import ExportQueueFullError, start_export_job
|
||||
from frigate.jobs.vlm_watch import (
|
||||
get_vlm_watch_job,
|
||||
start_vlm_watch_job,
|
||||
stop_vlm_watch_job,
|
||||
)
|
||||
from frigate.models import Event
|
||||
from frigate.models import Event, Export, ExportCase
|
||||
from frigate.record.export import PlaybackSourceEnum
|
||||
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
|
||||
from frigate.util.object_names import get_categorized_object_names
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.chat])
|
||||
|
||||
# Tool result recorded for a rejected write tool call. Providers require a
|
||||
# result for every requested call; the user's intent is conveyed in a
|
||||
# follow-up user message built by _rejection_message.
|
||||
TOOL_REJECTED_RESULT: dict[str, str] = {"error": "user_rejected"}
|
||||
|
||||
|
||||
class ToolExecuteRequest(BaseModel):
|
||||
"""Request model for tool execution."""
|
||||
@@ -666,29 +683,39 @@ async def _get_live_frame_image_url(
|
||||
frame = frame_processor.get_current_frame(camera, {})
|
||||
if frame is None:
|
||||
return None
|
||||
height, width = frame.shape[:2]
|
||||
target_height = 480
|
||||
if height > target_height:
|
||||
scale = target_height / height
|
||||
frame = cv2.resize(
|
||||
frame,
|
||||
(int(width * scale), target_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
return _encode_frame_data_url(frame)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to get live frame for %s: %s", camera, e)
|
||||
return None
|
||||
|
||||
|
||||
def _encode_frame_data_url(frame: np.ndarray, target_height: int = 480) -> str:
|
||||
"""Downscale a BGR frame and encode it as a JPEG data URL for the model."""
|
||||
height, width = frame.shape[:2]
|
||||
if height > target_height:
|
||||
scale = target_height / height
|
||||
frame = cv2.resize(
|
||||
frame,
|
||||
(int(width * scale), target_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def _request_roles(request: Request) -> list[str]:
|
||||
"""Roles from the auth proxy header, split on the configured separator."""
|
||||
separator = request.app.frigate_config.proxy.separator
|
||||
header = request.headers.get("remote-role", "")
|
||||
return [r.strip() for r in header.split(separator) if r.strip()]
|
||||
|
||||
|
||||
async def _execute_set_camera_state(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
role = request.headers.get("remote-role", "")
|
||||
if "admin" not in [r.strip() for r in role.split(",")]:
|
||||
if "admin" not in _request_roles(request):
|
||||
return {"error": "Admin privileges required to change camera settings."}
|
||||
|
||||
camera = arguments.get("camera", "").strip()
|
||||
@@ -738,6 +765,189 @@ def _execute_get_categorized_object_names(
|
||||
return {"names": names}
|
||||
|
||||
|
||||
def _execute_get_export_cases(allowed_cameras: list[str]) -> dict[str, Any]:
|
||||
"""List export cases with how many accessible exports each one holds."""
|
||||
from peewee import fn
|
||||
|
||||
count_rows = (
|
||||
Export.select(Export.export_case, fn.COUNT(Export.id))
|
||||
.where(Export.camera << allowed_cameras, Export.export_case.is_null(False))
|
||||
.group_by(Export.export_case)
|
||||
.tuples()
|
||||
)
|
||||
counts = {case_id: count for case_id, count in count_rows}
|
||||
|
||||
cases: list[dict[str, Any]] = []
|
||||
for case in ExportCase.select().order_by(ExportCase.created_at.desc()):
|
||||
created_at = case.created_at
|
||||
cases.append(
|
||||
{
|
||||
"id": case.id,
|
||||
"name": case.name,
|
||||
"description": case.description,
|
||||
"created_at_local": format_local_time(created_at.timestamp())
|
||||
if isinstance(created_at, datetime)
|
||||
else str(created_at),
|
||||
"export_count": counts.get(case.id, 0),
|
||||
}
|
||||
)
|
||||
|
||||
if not cases:
|
||||
return {"cases": [], "message": "No export cases exist yet."}
|
||||
|
||||
return {"cases": cases}
|
||||
|
||||
|
||||
async def _execute_create_export(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Queue a recording export, optionally attached to an existing case."""
|
||||
config = request.app.frigate_config
|
||||
camera = (arguments.get("camera") or "").strip()
|
||||
start_time = parse_iso_to_timestamp(arguments.get("start_time"))
|
||||
end_time = parse_iso_to_timestamp(arguments.get("end_time"))
|
||||
name = (arguments.get("name") or "").strip() or None
|
||||
|
||||
if not camera or start_time is None or end_time is None:
|
||||
return {"error": "camera, start_time, and end_time are all required."}
|
||||
|
||||
if camera not in config.cameras:
|
||||
return {"error": f"Camera '{camera}' not found."}
|
||||
|
||||
if camera not in allowed_cameras:
|
||||
return {"error": f"Camera '{camera}' not found or access denied"}
|
||||
|
||||
if end_time <= start_time:
|
||||
return {"error": "end_time must be after start_time."}
|
||||
|
||||
try:
|
||||
playback_source = PlaybackSourceEnum(arguments.get("source") or "recordings")
|
||||
except ValueError:
|
||||
return {"error": "source must be 'recordings' or 'preview'."}
|
||||
|
||||
# Mirror the export API: attaching to an existing case is admin-only
|
||||
# until case-level ACLs exist.
|
||||
export_case_id = (arguments.get("export_case_id") or "").strip() or None
|
||||
if export_case_id is not None:
|
||||
if "admin" not in _request_roles(request):
|
||||
return {"error": "Only admins can attach exports to an existing case."}
|
||||
try:
|
||||
ExportCase.get(ExportCase.id == export_case_id)
|
||||
except ExportCase.DoesNotExist:
|
||||
return {"error": f"Export case '{export_case_id}' not found."}
|
||||
|
||||
source_error = _validate_export_source(
|
||||
camera, start_time, end_time, playback_source
|
||||
)
|
||||
if source_error is not None:
|
||||
return {"error": source_error}
|
||||
|
||||
export_job = _build_export_job(
|
||||
camera,
|
||||
start_time,
|
||||
end_time,
|
||||
name,
|
||||
None,
|
||||
playback_source,
|
||||
export_case_id,
|
||||
chapters=config.cameras[camera].record.export.chapters,
|
||||
)
|
||||
try:
|
||||
start_export_job(config, export_job)
|
||||
except ExportQueueFullError:
|
||||
return {"error": "Export queue is full. Try again once current exports finish."}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"export_id": export_job.id,
|
||||
"status": "queued",
|
||||
"camera": camera,
|
||||
"name": name,
|
||||
"source": playback_source.value,
|
||||
"start_time_local": format_local_time(start_time),
|
||||
"end_time_local": format_local_time(end_time),
|
||||
"export_case_id": export_case_id,
|
||||
"message": "Export queued. It will appear on the Export page when finished.",
|
||||
}
|
||||
|
||||
|
||||
async def _execute_get_event_image(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Attach an event's thumbnail or snapshot for a vision model to view."""
|
||||
event_id = (arguments.get("event_id") or "").strip()
|
||||
if not event_id:
|
||||
return {"error": "event_id is required."}
|
||||
|
||||
image_type = arguments.get("image") or "thumbnail"
|
||||
if image_type not in ("thumbnail", "snapshot"):
|
||||
return {"error": "image must be 'thumbnail' or 'snapshot'."}
|
||||
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
except Event.DoesNotExist:
|
||||
return {"error": f"Could not find event {event_id}."}
|
||||
|
||||
if event.camera not in allowed_cameras:
|
||||
return {"error": f"Event {event_id} not found or access denied"}
|
||||
|
||||
chat_client = request.app.genai_manager.chat_client
|
||||
if chat_client is None or not chat_client.supports_vision:
|
||||
return {
|
||||
"error": (
|
||||
"The configured chat model does not support vision, so images "
|
||||
"cannot be viewed."
|
||||
)
|
||||
}
|
||||
|
||||
note = None
|
||||
frame = None
|
||||
if image_type == "snapshot":
|
||||
if event.has_snapshot:
|
||||
frame, _ = load_event_snapshot_image(event)
|
||||
if frame is None:
|
||||
note = "Snapshot not available; returning the thumbnail instead."
|
||||
image_type = "thumbnail"
|
||||
|
||||
if frame is None:
|
||||
thumbnail = get_event_thumbnail_bytes(event)
|
||||
if thumbnail:
|
||||
frame = cv2.imdecode(
|
||||
np.frombuffer(thumbnail, dtype=np.uint8), cv2.IMREAD_COLOR
|
||||
)
|
||||
|
||||
if frame is None:
|
||||
return {"error": f"No image is available for event {event_id}."}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"id": event.id,
|
||||
"camera": event.camera,
|
||||
"label": event.label,
|
||||
"sub_label": event.sub_label,
|
||||
"zones": event.zones,
|
||||
"start_time_local": format_local_time(event.start_time),
|
||||
"image": image_type,
|
||||
}
|
||||
if event.end_time is not None:
|
||||
result["end_time_local"] = format_local_time(event.end_time)
|
||||
description = (event.data or {}).get("description")
|
||||
if description:
|
||||
result["description"] = description
|
||||
if note:
|
||||
result["note"] = note
|
||||
|
||||
result["_image_url"] = _encode_frame_data_url(frame)
|
||||
result["_image_text"] = (
|
||||
f"Here is the {image_type} for event {event.id} "
|
||||
f"({event.sub_label or event.label} on {event.camera})."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _execute_tool_internal(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
@@ -793,11 +1003,18 @@ async def _execute_tool_internal(
|
||||
return _execute_get_profile_status(request)
|
||||
elif tool_name == "get_recap":
|
||||
return _execute_get_recap(arguments, allowed_cameras)
|
||||
elif tool_name == "get_export_cases":
|
||||
return _execute_get_export_cases(allowed_cameras)
|
||||
elif tool_name == "create_export":
|
||||
return await _execute_create_export(request, arguments, allowed_cameras)
|
||||
elif tool_name == "get_event_image":
|
||||
return await _execute_get_event_image(request, arguments, allowed_cameras)
|
||||
else:
|
||||
logger.error(
|
||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
|
||||
"get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, "
|
||||
"get_profile_status, get_recap. Arguments received: %s",
|
||||
"get_profile_status, get_recap, get_export_cases, create_export, get_event_image. "
|
||||
"Arguments received: %s",
|
||||
tool_name,
|
||||
json.dumps(arguments),
|
||||
)
|
||||
@@ -1026,14 +1243,74 @@ def _execute_get_recap(
|
||||
return {"error": "Failed to fetch recap data."}
|
||||
|
||||
|
||||
def _pending_tool_calls_from_tail(
|
||||
conversation: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Return the tool calls of a trailing assistant message, if any.
|
||||
|
||||
A conversation that ends with an assistant message requesting tools is a
|
||||
resume after an approval pause: the client sends the chain back with its
|
||||
decisions and the loop runs those calls before asking the model again.
|
||||
"""
|
||||
if not conversation:
|
||||
return None
|
||||
tail = conversation[-1]
|
||||
if tail.get("role") != "assistant" or not tail.get("tool_calls"):
|
||||
return None
|
||||
return parse_tool_calls_from_message(tail)
|
||||
|
||||
|
||||
def _tool_calls_awaiting_approval(
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
body: ChatCompletionRequest,
|
||||
write_tools: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the write tool calls the user still has to decide on."""
|
||||
return [
|
||||
{
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"arguments": tc.get("arguments") or {},
|
||||
}
|
||||
for tc in pending_tool_calls
|
||||
if tc["name"] in write_tools and tc["id"] not in body.tool_decisions
|
||||
]
|
||||
|
||||
|
||||
def _rejection_message(tool_names: list[str]) -> dict[str, Any]:
|
||||
"""User message telling the model a rejected call should not proceed.
|
||||
|
||||
Uses list-form content so the UI, which only renders string user
|
||||
content, does not show it as something the user typed.
|
||||
"""
|
||||
names = ", ".join(name.replace("_", " ") for name in tool_names)
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"I do not want to proceed with the {names} call. Ask me for "
|
||||
"clarification or suggest adjustments instead of running it."
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _execute_pending_tools(
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
request: Request,
|
||||
allowed_cameras: list[str],
|
||||
decisions: dict[str, str] | None = None,
|
||||
) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""
|
||||
Execute a list of tool calls.
|
||||
|
||||
Calls the user rejected (per `decisions`) are not executed; they get a
|
||||
placeholder result and a user message saying not to proceed is appended
|
||||
after the tool results.
|
||||
|
||||
Returns:
|
||||
(ToolCall list for API response,
|
||||
tool result dicts for conversation,
|
||||
@@ -1042,10 +1319,28 @@ async def _execute_pending_tools(
|
||||
tool_calls_out: list[ToolCall] = []
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
extra_messages: list[dict[str, Any]] = []
|
||||
rejected_tools: list[str] = []
|
||||
for tool_call in pending_tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
tool_args = tool_call.get("arguments") or {}
|
||||
tool_call_id = tool_call["id"]
|
||||
if decisions and decisions.get(tool_call_id) == "reject":
|
||||
logger.debug(
|
||||
"Tool %s (id: %s) was rejected by the user", tool_name, tool_call_id
|
||||
)
|
||||
rejected_tools.append(tool_name)
|
||||
rejected_content = json.dumps(TOOL_REJECTED_RESULT)
|
||||
tool_calls_out.append(
|
||||
ToolCall(name=tool_name, arguments=tool_args, response=rejected_content)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": rejected_content,
|
||||
}
|
||||
)
|
||||
continue
|
||||
logger.debug(
|
||||
f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}"
|
||||
)
|
||||
@@ -1079,17 +1374,21 @@ async def _execute_pending_tools(
|
||||
if isinstance(evt, dict)
|
||||
]
|
||||
|
||||
# Extract _image_url from get_live_context results — images can
|
||||
# only be sent in user messages, not tool results
|
||||
# Extract _image_url from tool results — images can only be sent
|
||||
# in user messages, not tool results
|
||||
if isinstance(tool_result, dict) and "_image_url" in tool_result:
|
||||
image_url = tool_result.pop("_image_url")
|
||||
image_text = tool_result.pop("_image_text", None) or (
|
||||
"Here is the current live image from camera "
|
||||
f"'{tool_result.get('camera', 'unknown')}'."
|
||||
)
|
||||
extra_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Here is the current live image from camera '{tool_result.get('camera', 'unknown')}'.",
|
||||
"text": image_text,
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
@@ -1133,6 +1432,8 @@ async def _execute_pending_tools(
|
||||
"content": error_content,
|
||||
}
|
||||
)
|
||||
if rejected_tools:
|
||||
extra_messages.append(_rejection_message(rejected_tools))
|
||||
return (tool_calls_out, tool_results, extra_messages)
|
||||
|
||||
|
||||
@@ -1179,6 +1480,8 @@ async def chat_completion(
|
||||
attribute_classifications=attribute_classifications,
|
||||
embeddings_language=_embeddings_language(config),
|
||||
)
|
||||
write_tools = get_write_tool_names(tools)
|
||||
llm_tools = strip_tool_access(tools)
|
||||
conversation = []
|
||||
|
||||
# Build the system message only when the client hasn't already pinned one.
|
||||
@@ -1217,6 +1520,10 @@ async def chat_completion(
|
||||
tool_calls: list[ToolCall] = []
|
||||
max_iterations = body.max_tool_iterations
|
||||
|
||||
# Resume after an approval pause: run the tail's tool calls (honoring the
|
||||
# client's decisions) before asking the model for anything new.
|
||||
resume_pending = _pending_tool_calls_from_tail(conversation)
|
||||
|
||||
logger.debug(
|
||||
f"Starting chat completion with {len(conversation)} message(s), "
|
||||
f"{len(tools)} tool(s) available, max_iterations={max_iterations}"
|
||||
@@ -1228,93 +1535,64 @@ async def chat_completion(
|
||||
|
||||
async def stream_body_llm():
|
||||
nonlocal conversation, stream_iterations
|
||||
pending: list[dict[str, Any]] | None = resume_pending
|
||||
|
||||
def _emit_chain(extra: list[dict[str, Any]] | None = None):
|
||||
def _emit(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload).encode("utf-8") + b"\n"
|
||||
|
||||
def _emit_chain(extra: list[dict[str, Any]] | None = None) -> bytes:
|
||||
# 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"
|
||||
return _emit(
|
||||
{"type": "messages", "messages": conversation + (extra or [])}
|
||||
)
|
||||
|
||||
while stream_iterations < max_iterations:
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
logger.debug(
|
||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s)"
|
||||
)
|
||||
async for event in genai_client.chat_with_tools_stream(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
kind, value = event
|
||||
if kind == "content_delta":
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "reasoning_delta":
|
||||
yield (
|
||||
json.dumps({"type": "reasoning", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "stats":
|
||||
yield (
|
||||
json.dumps({"type": "stats", **value}).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "message":
|
||||
msg = value
|
||||
if msg.get("finish_reason") == "error":
|
||||
yield (
|
||||
json.dumps(
|
||||
|
||||
if pending is None:
|
||||
logger.debug(
|
||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s)"
|
||||
)
|
||||
async for event in genai_client.chat_with_tools_stream(
|
||||
messages=conversation,
|
||||
tools=llm_tools if llm_tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
kind, value = event
|
||||
if kind == "content_delta":
|
||||
yield _emit({"type": "content", "delta": value})
|
||||
elif kind == "reasoning_delta":
|
||||
yield _emit({"type": "reasoning", "delta": value})
|
||||
elif kind == "stats":
|
||||
yield _emit({"type": "stats", **value})
|
||||
elif kind == "message":
|
||||
msg = value
|
||||
if msg.get("finish_reason") == "error":
|
||||
yield _emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "An error occurred while processing your request.",
|
||||
}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
return
|
||||
pending = msg.get("tool_calls")
|
||||
if pending:
|
||||
stream_iterations += 1
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
msg.get("content"), pending
|
||||
)
|
||||
)
|
||||
if await request.is_disconnected():
|
||||
logger.debug(
|
||||
"Client disconnected before tool execution"
|
||||
)
|
||||
return
|
||||
(
|
||||
_executed_calls,
|
||||
tool_results,
|
||||
extra_msgs,
|
||||
) = await _execute_pending_tools(
|
||||
pending, request, allowed_cameras
|
||||
)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
# Emit the running chain so the client can render tool
|
||||
# calls live and replay them verbatim next turn.
|
||||
yield _emit_chain()
|
||||
break
|
||||
else:
|
||||
requested = msg.get("tool_calls")
|
||||
if requested:
|
||||
stream_iterations += 1
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
msg.get("content"), requested
|
||||
)
|
||||
)
|
||||
pending = requested
|
||||
break
|
||||
# Streaming never appends the final assistant message
|
||||
# to the conversation, so add it to the chain.
|
||||
yield _emit_chain(
|
||||
@@ -1325,11 +1603,41 @@ async def chat_completion(
|
||||
}
|
||||
]
|
||||
)
|
||||
yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n")
|
||||
yield _emit({"type": "done"})
|
||||
return
|
||||
else:
|
||||
if pending is None:
|
||||
# The stream ended without a final message; nothing
|
||||
# more to run.
|
||||
break
|
||||
|
||||
awaiting = _tool_calls_awaiting_approval(pending, body, write_tools)
|
||||
if awaiting:
|
||||
# Pause before running write tools. The client shows the
|
||||
# calls, collects decisions, and resends the chain.
|
||||
yield _emit_chain()
|
||||
yield _emit({"type": "approval_required", "tool_calls": awaiting})
|
||||
yield _emit({"type": "done"})
|
||||
return
|
||||
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected before tool execution")
|
||||
return
|
||||
(
|
||||
_executed_calls,
|
||||
tool_results,
|
||||
extra_msgs,
|
||||
) = await _execute_pending_tools(
|
||||
pending, request, allowed_cameras, decisions=body.tool_decisions
|
||||
)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
pending = None
|
||||
# Emit the running chain so the client can render tool
|
||||
# calls live and replay them verbatim next turn.
|
||||
yield _emit_chain()
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
yield _emit_chain()
|
||||
yield _emit({"type": "done"})
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body_llm(),
|
||||
@@ -1338,102 +1646,129 @@ async def chat_completion(
|
||||
)
|
||||
|
||||
try:
|
||||
pending_tool_calls = resume_pending
|
||||
while tool_iterations < max_iterations:
|
||||
logger.debug(
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
)
|
||||
|
||||
if response.get("finish_reason") == "error":
|
||||
logger.error("GenAI client returned an error")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "An error occurred while processing your request.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
response.get("content"), response.get("tool_calls")
|
||||
)
|
||||
)
|
||||
|
||||
pending_tool_calls = response.get("tool_calls")
|
||||
if not pending_tool_calls:
|
||||
if pending_tool_calls is None:
|
||||
logger.debug(
|
||||
f"Chat completion finished with final answer (iterations: {tool_iterations})"
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=llm_tools if llm_tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
)
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
if response.get("finish_reason") == "error":
|
||||
logger.error("GenAI client returned an error")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "An error occurred while processing your request.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
chain = list(conversation)
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
response.get("content"), response.get("tool_calls")
|
||||
)
|
||||
)
|
||||
|
||||
async def stream_body() -> Any:
|
||||
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:
|
||||
pending_tool_calls = response.get("tool_calls")
|
||||
if not pending_tool_calls:
|
||||
logger.debug(
|
||||
f"Chat completion finished with final answer (iterations: {tool_iterations})"
|
||||
)
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
|
||||
chain = list(conversation)
|
||||
|
||||
async def stream_body() -> Any:
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "reasoning", "delta": final_reasoning}
|
||||
{"type": "messages", "messages": chain}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": part}).encode(
|
||||
"utf-8"
|
||||
# Emit the full reasoning trace up front when the
|
||||
# underlying client did not stream it
|
||||
if final_reasoning:
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "reasoning", "delta": final_reasoning}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "content", "delta": part}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body(),
|
||||
media_type="application/x-ndjson",
|
||||
return StreamingResponse(
|
||||
stream_body(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=ChatCompletionResponse(
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
reasoning=response.get("reasoning"),
|
||||
tool_calls=None,
|
||||
),
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
messages=list(conversation),
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
tool_iterations += 1
|
||||
logger.debug(
|
||||
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
|
||||
f"{len(pending_tool_calls)} tool(s) to execute"
|
||||
)
|
||||
|
||||
awaiting = _tool_calls_awaiting_approval(
|
||||
pending_tool_calls, body, write_tools
|
||||
)
|
||||
if awaiting:
|
||||
# Pause before running write tools; the client resends the
|
||||
# returned chain with its decisions to continue.
|
||||
return JSONResponse(
|
||||
content=ChatCompletionResponse(
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
reasoning=response.get("reasoning"),
|
||||
tool_calls=None,
|
||||
content=None,
|
||||
tool_calls=[ToolCallInvocation(**tc) for tc in awaiting],
|
||||
),
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
finish_reason="approval_required",
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
messages=list(conversation),
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
tool_iterations += 1
|
||||
logger.debug(
|
||||
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
|
||||
f"{len(pending_tool_calls)} tool(s) to execute"
|
||||
)
|
||||
executed_calls, tool_results, extra_msgs = await _execute_pending_tools(
|
||||
pending_tool_calls, request, allowed_cameras
|
||||
pending_tool_calls,
|
||||
request,
|
||||
allowed_cameras,
|
||||
decisions=body.tool_decisions,
|
||||
)
|
||||
tool_calls.extend(executed_calls)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
pending_tool_calls = None
|
||||
logger.debug(
|
||||
f"Added {len(tool_results)} tool result(s) to conversation. "
|
||||
f"Continuing with next LLM call..."
|
||||
|
||||
@@ -44,6 +44,11 @@ def chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, No
|
||||
yield " ".join(current)
|
||||
|
||||
|
||||
def format_local_time(timestamp: float) -> str:
|
||||
"""Format a unix timestamp as the server-local string quoted to users."""
|
||||
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
|
||||
|
||||
def format_events_with_local_time(
|
||||
events_list: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -58,11 +63,9 @@ def format_events_with_local_time(
|
||||
start_ts = evt.get("start_time")
|
||||
end_ts = evt.get("end_time")
|
||||
if start_ts is not None:
|
||||
dt_start = datetime.fromtimestamp(start_ts)
|
||||
copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
copy_evt["start_time_local"] = format_local_time(start_ts)
|
||||
if end_ts is not None:
|
||||
dt_end = datetime.fromtimestamp(end_ts)
|
||||
copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
copy_evt["end_time_local"] = format_local_time(end_ts)
|
||||
except (TypeError, ValueError, OSError):
|
||||
pass
|
||||
result.append(copy_evt)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Chat API request models."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -59,3 +59,12 @@ class ChatCompletionRequest(BaseModel):
|
||||
"Ignored by providers that do not expose a per-request thinking switch."
|
||||
),
|
||||
)
|
||||
tool_decisions: dict[str, Literal["approve", "reject"]] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Decisions for tool calls that paused for approval, keyed by tool "
|
||||
"call ID. Send these with the conversation chain returned alongside "
|
||||
"an approval request; rejected calls are reported to the model as "
|
||||
"declined instead of being executed."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -311,6 +311,10 @@ def get_tool_definitions(
|
||||
Descriptions here stay mechanical: which tool to reach for, and how the
|
||||
filters relate to each other, is stated once in the system prompt so the
|
||||
guidance is not paid for twice on every request.
|
||||
|
||||
Each definition carries a Frigate-only `access` field ("read" or "write");
|
||||
write tools pause for user approval in the chat loop. Strip it with
|
||||
`strip_tool_access` before sending the list to a provider.
|
||||
"""
|
||||
search_objects_properties: dict[str, Any] = {
|
||||
"camera": {
|
||||
@@ -382,6 +386,7 @@ def get_tool_definitions(
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": search_objects_description,
|
||||
@@ -394,6 +399,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_categorized_object_names",
|
||||
"description": (
|
||||
@@ -411,6 +417,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "find_similar_objects",
|
||||
"description": (
|
||||
@@ -474,6 +481,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "set_camera_state",
|
||||
"description": (
|
||||
@@ -529,6 +537,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
@@ -553,6 +562,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "start_camera_watch",
|
||||
"description": (
|
||||
@@ -596,6 +606,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "stop_camera_watch",
|
||||
"description": "Cancel the currently running watch job.",
|
||||
@@ -608,6 +619,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_profile_status",
|
||||
"description": (
|
||||
@@ -624,6 +636,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_recap",
|
||||
"description": (
|
||||
@@ -656,9 +669,120 @@ def get_tool_definitions(
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_export_cases",
|
||||
"description": (
|
||||
"List the export cases (named groups of exported clips) with "
|
||||
"their IDs, descriptions, and how many exports each holds. "
|
||||
"Call this before create_export when the user wants a clip "
|
||||
"added to an existing case."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "create_export",
|
||||
"description": (
|
||||
"Export a camera's recording for a time range to a "
|
||||
"downloadable file, optionally attached to an existing export "
|
||||
"case. Only call this when the user explicitly asks to export "
|
||||
"or save a clip."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera ID to export from.",
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"description": "Start of the clip in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"description": "End of the clip in ISO 8601 format (e.g. '2025-03-15T08:05:00').",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Friendly name for the export (optional).",
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["recordings", "preview"],
|
||||
"description": (
|
||||
"'recordings' (default) exports full-quality footage; "
|
||||
"'preview' builds a low-resolution timelapse."
|
||||
),
|
||||
"default": "recordings",
|
||||
},
|
||||
"export_case_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"ID of an existing export case to attach the export "
|
||||
"to. Use get_export_cases to find it."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["camera", "start_time", "end_time"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_event_image",
|
||||
"description": (
|
||||
"View the thumbnail or snapshot image of a specific tracked "
|
||||
"object so you can describe what it shows. Use the event id "
|
||||
"from search_objects, find_similar_objects, or an attached "
|
||||
"event."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the tracked object to view.",
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"enum": ["thumbnail", "snapshot"],
|
||||
"description": (
|
||||
"'thumbnail' (default) is a small crop of the object; "
|
||||
"'snapshot' is the full camera frame."
|
||||
),
|
||||
"default": "thumbnail",
|
||||
},
|
||||
},
|
||||
"required": ["event_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_write_tool_names(tools: list[dict[str, Any]]) -> set[str]:
|
||||
"""Names of the tools whose `access` is "write" (they change state)."""
|
||||
return {tool["function"]["name"] for tool in tools if tool.get("access") == "write"}
|
||||
|
||||
|
||||
def strip_tool_access(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Drop the Frigate-only `access` field before handing tools to a provider."""
|
||||
return [{k: v for k, v in tool.items() if k != "access"} for tool in tools]
|
||||
|
||||
|
||||
def build_chat_system_prompt(
|
||||
config: FrigateConfig,
|
||||
allowed_cameras: list[str],
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Tests for chat tool approval and the export and event image tools."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
|
||||
from frigate.api.chat import (
|
||||
TOOL_REJECTED_RESULT,
|
||||
_execute_create_export,
|
||||
_execute_get_event_image,
|
||||
_execute_get_export_cases,
|
||||
_execute_pending_tools,
|
||||
_pending_tool_calls_from_tail,
|
||||
_tool_calls_awaiting_approval,
|
||||
)
|
||||
from frigate.api.defs.request.chat_body import ChatCompletionRequest
|
||||
from frigate.genai.prompts import (
|
||||
get_tool_definitions,
|
||||
get_write_tool_names,
|
||||
strip_tool_access,
|
||||
)
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.jobs.export import ExportQueueFullError
|
||||
from frigate.models import Event, Export, ExportCase, Previews, Recordings
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _request(role: str = "admin", supports_vision: bool = True):
|
||||
camera = SimpleNamespace(
|
||||
record=SimpleNamespace(export=SimpleNamespace(chapters=None)),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
frigate_config=SimpleNamespace(
|
||||
cameras={"driveway": camera, "garage": camera},
|
||||
proxy=SimpleNamespace(separator=","),
|
||||
),
|
||||
genai_manager=SimpleNamespace(
|
||||
chat_client=SimpleNamespace(supports_vision=supports_vision),
|
||||
),
|
||||
)
|
||||
return SimpleNamespace(app=app, headers={"remote-role": role})
|
||||
|
||||
|
||||
def _body(**kwargs) -> ChatCompletionRequest:
|
||||
return ChatCompletionRequest(messages=[], **kwargs)
|
||||
|
||||
|
||||
WRITE_TOOLS = get_write_tool_names(get_tool_definitions())
|
||||
|
||||
|
||||
class TestToolRegistry(unittest.TestCase):
|
||||
def test_every_tool_declares_access(self):
|
||||
for tool in get_tool_definitions():
|
||||
self.assertIn(tool.get("access"), ("read", "write"), tool)
|
||||
|
||||
def test_write_tools(self):
|
||||
self.assertEqual(
|
||||
WRITE_TOOLS,
|
||||
{
|
||||
"set_camera_state",
|
||||
"start_camera_watch",
|
||||
"stop_camera_watch",
|
||||
"create_export",
|
||||
},
|
||||
)
|
||||
|
||||
def test_strip_tool_access_removes_frigate_field(self):
|
||||
for tool in strip_tool_access(get_tool_definitions()):
|
||||
self.assertNotIn("access", tool)
|
||||
self.assertEqual(set(tool), {"type", "function"})
|
||||
|
||||
def test_new_tools_are_registered(self):
|
||||
names = {t["function"]["name"] for t in get_tool_definitions()}
|
||||
self.assertIn("get_export_cases", names)
|
||||
self.assertIn("create_export", names)
|
||||
self.assertIn("get_event_image", names)
|
||||
|
||||
def test_create_export_requires_time_range(self):
|
||||
tool = next(
|
||||
t
|
||||
for t in get_tool_definitions()
|
||||
if t["function"]["name"] == "create_export"
|
||||
)
|
||||
params = tool["function"]["parameters"]
|
||||
self.assertEqual(params["required"], ["camera", "start_time", "end_time"])
|
||||
self.assertNotIn("event_id", params["properties"])
|
||||
self.assertNotIn("new_case_name", params["properties"])
|
||||
|
||||
def test_get_event_image_requires_event_id(self):
|
||||
tool = next(
|
||||
t
|
||||
for t in get_tool_definitions()
|
||||
if t["function"]["name"] == "get_event_image"
|
||||
)
|
||||
self.assertEqual(tool["function"]["parameters"]["required"], ["event_id"])
|
||||
|
||||
|
||||
class TestApprovalHelpers(unittest.TestCase):
|
||||
def test_tail_without_tool_calls_is_not_pending(self):
|
||||
self.assertIsNone(_pending_tool_calls_from_tail([]))
|
||||
self.assertIsNone(
|
||||
_pending_tool_calls_from_tail([{"role": "user", "content": "hi"}])
|
||||
)
|
||||
self.assertIsNone(
|
||||
_pending_tool_calls_from_tail([{"role": "assistant", "content": "ok"}])
|
||||
)
|
||||
|
||||
def test_tail_with_tool_calls_is_parsed(self):
|
||||
tail = build_assistant_message_for_conversation(
|
||||
None, [{"id": "call_1", "name": "create_export", "arguments": {"a": 1}}]
|
||||
)
|
||||
pending = _pending_tool_calls_from_tail([{"role": "user"}, tail])
|
||||
self.assertEqual(
|
||||
pending, [{"id": "call_1", "name": "create_export", "arguments": {"a": 1}}]
|
||||
)
|
||||
|
||||
def test_read_tools_never_await_approval(self):
|
||||
pending = [{"id": "c1", "name": "search_objects", "arguments": {}}]
|
||||
self.assertEqual(
|
||||
_tool_calls_awaiting_approval(pending, _body(), WRITE_TOOLS), []
|
||||
)
|
||||
|
||||
def test_write_tools_await_approval(self):
|
||||
pending = [
|
||||
{"id": "c1", "name": "search_objects", "arguments": {}},
|
||||
{"id": "c2", "name": "create_export", "arguments": {"camera": "x"}},
|
||||
]
|
||||
awaiting = _tool_calls_awaiting_approval(pending, _body(), WRITE_TOOLS)
|
||||
self.assertEqual(
|
||||
awaiting,
|
||||
[{"id": "c2", "name": "create_export", "arguments": {"camera": "x"}}],
|
||||
)
|
||||
|
||||
def test_decided_calls_skip_approval(self):
|
||||
pending = [
|
||||
{"id": "c2", "name": "create_export", "arguments": {}},
|
||||
{"id": "c3", "name": "set_camera_state", "arguments": {}},
|
||||
]
|
||||
body = _body(tool_decisions={"c2": "approve", "c3": "reject"})
|
||||
self.assertEqual(_tool_calls_awaiting_approval(pending, body, WRITE_TOOLS), [])
|
||||
|
||||
|
||||
class TestExecutePendingTools(unittest.TestCase):
|
||||
def test_rejected_call_is_not_executed(self):
|
||||
execute = AsyncMock(return_value={"success": True})
|
||||
pending = [{"id": "c1", "name": "create_export", "arguments": {}}]
|
||||
with patch("frigate.api.chat._execute_tool_internal", execute):
|
||||
calls, results, extra = _run(
|
||||
_execute_pending_tools(
|
||||
pending, _request(), ["driveway"], decisions={"c1": "reject"}
|
||||
)
|
||||
)
|
||||
execute.assert_not_called()
|
||||
self.assertEqual(json.loads(results[0]["content"]), TOOL_REJECTED_RESULT)
|
||||
self.assertEqual(results[0]["tool_call_id"], "c1")
|
||||
self.assertEqual(calls[0].name, "create_export")
|
||||
# The user's intent goes to the model as a follow-up user message.
|
||||
self.assertEqual(len(extra), 1)
|
||||
self.assertEqual(extra[0]["role"], "user")
|
||||
text = extra[0]["content"][0]["text"]
|
||||
self.assertIn("do not want to proceed", text)
|
||||
self.assertIn("create export", text)
|
||||
self.assertIn("clarification", text)
|
||||
|
||||
def test_approved_call_is_executed(self):
|
||||
execute = AsyncMock(return_value={"success": True})
|
||||
pending = [{"id": "c1", "name": "create_export", "arguments": {}}]
|
||||
with patch("frigate.api.chat._execute_tool_internal", execute):
|
||||
_calls, results, _extra = _run(
|
||||
_execute_pending_tools(
|
||||
pending, _request(), ["driveway"], decisions={"c1": "approve"}
|
||||
)
|
||||
)
|
||||
execute.assert_awaited_once()
|
||||
self.assertEqual(json.loads(results[0]["content"]), {"success": True})
|
||||
|
||||
def test_image_text_becomes_user_message(self):
|
||||
execute = AsyncMock(
|
||||
return_value={
|
||||
"id": "evt",
|
||||
"_image_url": "data:image/jpeg;base64,xx",
|
||||
"_image_text": "Here is the thumbnail.",
|
||||
}
|
||||
)
|
||||
pending = [{"id": "c1", "name": "get_event_image", "arguments": {}}]
|
||||
with patch("frigate.api.chat._execute_tool_internal", execute):
|
||||
_calls, results, extra = _run(
|
||||
_execute_pending_tools(pending, _request(), ["driveway"])
|
||||
)
|
||||
self.assertEqual(json.loads(results[0]["content"]), {"id": "evt"})
|
||||
self.assertEqual(extra[0]["role"], "user")
|
||||
self.assertEqual(extra[0]["content"][0]["text"], "Here is the thumbnail.")
|
||||
self.assertEqual(
|
||||
extra[0]["content"][1]["image_url"]["url"], "data:image/jpeg;base64,xx"
|
||||
)
|
||||
|
||||
|
||||
class DatabaseTestCase(unittest.TestCase):
|
||||
models = [Event, Export, ExportCase, Recordings, Previews]
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
self.tmp.close()
|
||||
self.db = SqliteExtDatabase(self.tmp.name)
|
||||
for model in self.models:
|
||||
model.bind(self.db, bind_refs=False, bind_backrefs=False)
|
||||
self.db.connect()
|
||||
self.db.create_tables(self.models)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
os.unlink(self.tmp.name)
|
||||
|
||||
def make_event(self, event_id, camera="driveway", thumbnail="", **overrides):
|
||||
fields = dict(
|
||||
id=event_id,
|
||||
label="car",
|
||||
sub_label=None,
|
||||
camera=camera,
|
||||
start_time=1_700_000_100,
|
||||
end_time=1_700_000_110,
|
||||
top_score=0.9,
|
||||
score=0.9,
|
||||
false_positive=False,
|
||||
zones=[],
|
||||
thumbnail=thumbnail,
|
||||
has_clip=True,
|
||||
has_snapshot=False,
|
||||
region=[0, 0, 1, 1],
|
||||
box=[0, 0, 1, 1],
|
||||
area=1,
|
||||
retain_indefinitely=False,
|
||||
ratio=1.0,
|
||||
plus_id="",
|
||||
model_hash="",
|
||||
detector_type="",
|
||||
model_type="",
|
||||
data={},
|
||||
)
|
||||
fields.update(overrides)
|
||||
return Event.create(**fields)
|
||||
|
||||
def make_case(self, case_id, name="Case"):
|
||||
now = datetime.fromtimestamp(1_700_000_000)
|
||||
return ExportCase.create(
|
||||
id=case_id, name=name, description=None, created_at=now, updated_at=now
|
||||
)
|
||||
|
||||
|
||||
def _jpeg_base64() -> str:
|
||||
frame = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||
_, encoded = cv2.imencode(".jpg", frame)
|
||||
return base64.b64encode(encoded.tobytes()).decode("utf-8")
|
||||
|
||||
|
||||
class TestGetEventImage(DatabaseTestCase):
|
||||
def test_requires_vision(self):
|
||||
self.make_event("evt", thumbnail=_jpeg_base64())
|
||||
result = _run(
|
||||
_execute_get_event_image(
|
||||
_request(supports_vision=False), {"event_id": "evt"}, ["driveway"]
|
||||
)
|
||||
)
|
||||
self.assertIn("vision", result["error"])
|
||||
|
||||
def test_unknown_event(self):
|
||||
result = _run(
|
||||
_execute_get_event_image(_request(), {"event_id": "nope"}, ["driveway"])
|
||||
)
|
||||
self.assertIn("nope", result["error"])
|
||||
|
||||
def test_camera_access_denied(self):
|
||||
self.make_event("evt", camera="garage", thumbnail=_jpeg_base64())
|
||||
result = _run(
|
||||
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
|
||||
)
|
||||
self.assertIn("access denied", result["error"])
|
||||
|
||||
def test_thumbnail_is_attached(self):
|
||||
self.make_event(
|
||||
"evt", thumbnail=_jpeg_base64(), data={"description": "a red car"}
|
||||
)
|
||||
result = _run(
|
||||
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
|
||||
)
|
||||
self.assertEqual(result["id"], "evt")
|
||||
self.assertEqual(result["image"], "thumbnail")
|
||||
self.assertEqual(result["description"], "a red car")
|
||||
self.assertIn("start_time_local", result)
|
||||
self.assertTrue(result["_image_url"].startswith("data:image/jpeg;base64,"))
|
||||
self.assertIn("thumbnail", result["_image_text"])
|
||||
|
||||
def test_snapshot_falls_back_to_thumbnail(self):
|
||||
self.make_event("evt", thumbnail=_jpeg_base64(), has_snapshot=False)
|
||||
result = _run(
|
||||
_execute_get_event_image(
|
||||
_request(), {"event_id": "evt", "image": "snapshot"}, ["driveway"]
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["image"], "thumbnail")
|
||||
self.assertIn("note", result)
|
||||
|
||||
def test_no_image_available(self):
|
||||
self.make_event("evt", thumbnail="")
|
||||
with patch("frigate.api.chat.get_event_thumbnail_bytes", return_value=None):
|
||||
result = _run(
|
||||
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
|
||||
)
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
class TestGetExportCases(DatabaseTestCase):
|
||||
def test_no_cases(self):
|
||||
result = _execute_get_export_cases(["driveway"])
|
||||
self.assertEqual(result["cases"], [])
|
||||
self.assertIn("message", result)
|
||||
|
||||
def test_counts_only_accessible_exports(self):
|
||||
self.make_case("case_a", name="Break-in")
|
||||
self.make_case("case_b", name="Empty")
|
||||
for idx, camera in enumerate(["driveway", "driveway", "garage"]):
|
||||
Export.create(
|
||||
id=f"exp_{idx}",
|
||||
camera=camera,
|
||||
name=f"Export {idx}",
|
||||
date=datetime.fromtimestamp(1_700_000_000 + idx),
|
||||
video_path=f"/exports/{idx}.mp4",
|
||||
thumb_path=f"/exports/{idx}.jpg",
|
||||
in_progress=False,
|
||||
export_case="case_a",
|
||||
)
|
||||
|
||||
result = _execute_get_export_cases(["driveway"])
|
||||
by_id = {c["id"]: c for c in result["cases"]}
|
||||
self.assertEqual(by_id["case_a"]["export_count"], 2)
|
||||
self.assertEqual(by_id["case_b"]["export_count"], 0)
|
||||
self.assertEqual(by_id["case_a"]["name"], "Break-in")
|
||||
self.assertIn("created_at_local", by_id["case_a"])
|
||||
|
||||
|
||||
class TestCreateExport(DatabaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Recordings.create(
|
||||
id="rec_1",
|
||||
camera="driveway",
|
||||
path="/recordings/rec_1.mp4",
|
||||
start_time=1_700_000_000,
|
||||
end_time=1_700_001_000,
|
||||
duration=1000,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _iso(timestamp: int) -> str:
|
||||
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
def _args(self, **overrides):
|
||||
args = {
|
||||
"camera": "driveway",
|
||||
"start_time": self._iso(1_700_000_100),
|
||||
"end_time": self._iso(1_700_000_200),
|
||||
}
|
||||
args.update(overrides)
|
||||
return args
|
||||
|
||||
def test_requires_camera_and_range(self):
|
||||
result = _run(_execute_create_export(_request(), {}, ["driveway"]))
|
||||
self.assertIn("required", result["error"])
|
||||
|
||||
def test_camera_access_denied(self):
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(), self._args(camera="garage"), ["driveway"]
|
||||
)
|
||||
)
|
||||
self.assertIn("access denied", result["error"])
|
||||
|
||||
def test_end_before_start(self):
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(),
|
||||
self._args(end_time=self._iso(1_700_000_050)),
|
||||
["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertIn("after start_time", result["error"])
|
||||
|
||||
def test_existing_case_requires_admin(self):
|
||||
self.make_case("case_a")
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(role="viewer"),
|
||||
self._args(export_case_id="case_a"),
|
||||
["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertIn("admins", result["error"])
|
||||
|
||||
def test_unknown_case(self):
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(), self._args(export_case_id="nope"), ["driveway"]
|
||||
)
|
||||
)
|
||||
self.assertIn("nope", result["error"])
|
||||
|
||||
def test_export_is_queued(self):
|
||||
self.make_case("case_a")
|
||||
with patch("frigate.api.chat.start_export_job") as start:
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(),
|
||||
self._args(name="Delivery", export_case_id="case_a"),
|
||||
["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["status"], "queued")
|
||||
self.assertEqual(result["camera"], "driveway")
|
||||
self.assertEqual(result["export_case_id"], "case_a")
|
||||
job = start.call_args.args[1]
|
||||
self.assertEqual(job.camera, "driveway")
|
||||
self.assertEqual(job.request_start_time, 1_700_000_100)
|
||||
self.assertEqual(job.request_end_time, 1_700_000_200)
|
||||
self.assertEqual(job.name, "Delivery")
|
||||
self.assertEqual(job.export_case_id, "case_a")
|
||||
|
||||
def test_no_recordings_in_range(self):
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(),
|
||||
{
|
||||
"camera": "driveway",
|
||||
"start_time": "2030-01-01T00:00:00",
|
||||
"end_time": "2030-01-01T01:00:00",
|
||||
},
|
||||
["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertIn("No recordings", result["error"])
|
||||
|
||||
def test_queue_full(self):
|
||||
with patch(
|
||||
"frigate.api.chat.start_export_job", side_effect=ExportQueueFullError()
|
||||
):
|
||||
result = _run(
|
||||
_execute_create_export(_request(), self._args(), ["driveway"])
|
||||
)
|
||||
self.assertIn("queue is full", result["error"])
|
||||
|
||||
|
||||
class TestSetCameraStateRoles(unittest.TestCase):
|
||||
def test_non_admin_is_rejected(self):
|
||||
from frigate.api.chat import _execute_set_camera_state
|
||||
|
||||
result = _run(
|
||||
_execute_set_camera_state(
|
||||
_request(role="viewer"),
|
||||
{"camera": "driveway", "feature": "detect", "value": "OFF"},
|
||||
)
|
||||
)
|
||||
self.assertIn("Admin", result["error"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -55,6 +55,12 @@
|
||||
"auto_scroll": {
|
||||
"title": "Auto-scroll",
|
||||
"desc": "Follow new messages as they arrive."
|
||||
},
|
||||
"always_allow": {
|
||||
"title": "Always allowed actions",
|
||||
"desc": "Actions the assistant may run without asking first.",
|
||||
"none": "None",
|
||||
"reset": "Reset"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
@@ -68,5 +74,15 @@
|
||||
},
|
||||
"thinking": {
|
||||
"toggle": "Toggle thinking"
|
||||
},
|
||||
"approval": {
|
||||
"title": "Approve {{tool}}?",
|
||||
"desc": "This action changes something in Frigate. Review the details before approving.",
|
||||
"approve": "Approve",
|
||||
"always_allow": "Always allow",
|
||||
"reject": "Reject",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"placeholder": "Approve or reject the pending action to continue"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ type ChatComposerProps = {
|
||||
|
||||
isLoading?: boolean;
|
||||
onStop?: () => void;
|
||||
/** Blocks input without showing the stop button, e.g. while a tool call
|
||||
* is waiting for the user's approval. */
|
||||
disabled?: boolean;
|
||||
|
||||
attachedEventId?: string | null;
|
||||
onClearAttachment?: () => void;
|
||||
@@ -45,6 +48,7 @@ export function ChatComposer({
|
||||
setThinkingEnabled,
|
||||
isLoading = false,
|
||||
onStop,
|
||||
disabled = false,
|
||||
attachedEventId,
|
||||
onClearAttachment,
|
||||
onAttach,
|
||||
@@ -62,6 +66,7 @@ export function ChatComposer({
|
||||
|
||||
const showPaperclip = !!onAttach;
|
||||
const showStop = isLoading && !!onStop;
|
||||
const inputBlocked = isLoading || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
@@ -77,7 +82,7 @@ export function ChatComposer({
|
||||
{attachedEventId && (
|
||||
<ChatQuickReplies
|
||||
onSend={(text) => sendMessage(text)}
|
||||
disabled={isLoading}
|
||||
disabled={inputBlocked}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-row items-center gap-2">
|
||||
@@ -85,7 +90,7 @@ export function ChatComposer({
|
||||
<ChatPaperclipButton
|
||||
recentEventIds={recentEventIds ?? []}
|
||||
onAttach={onAttach!}
|
||||
disabled={isLoading || attachedEventId != null}
|
||||
disabled={inputBlocked || attachedEventId != null}
|
||||
/>
|
||||
)}
|
||||
{supportsThinking && (
|
||||
@@ -103,7 +108,7 @@ export function ChatComposer({
|
||||
!thinkingEnabled && "text-secondary-foreground",
|
||||
)}
|
||||
onClick={() => setThinkingEnabled(!thinkingEnabled)}
|
||||
disabled={isLoading}
|
||||
disabled={inputBlocked}
|
||||
>
|
||||
<LuBrain className="size-4" />
|
||||
</Button>
|
||||
@@ -122,6 +127,7 @@ export function ChatComposer({
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-busy={isLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showStop ? (
|
||||
<Button
|
||||
@@ -135,7 +141,7 @@ export function ChatComposer({
|
||||
<Button
|
||||
variant="select"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
disabled={!input.trim() || isLoading}
|
||||
disabled={!input.trim() || inputBlocked}
|
||||
onClick={() => sendMessage()}
|
||||
>
|
||||
<FaArrowUpLong className="size-4" />
|
||||
|
||||
@@ -16,12 +16,15 @@ import { Label } from "@/components/ui/label";
|
||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ShowStatsMode } from "@/types/chat";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
|
||||
type ChatSettingsProps = {
|
||||
showStats: ShowStatsMode;
|
||||
setShowStats: (mode: ShowStatsMode) => void;
|
||||
autoScroll: boolean;
|
||||
setAutoScroll: (enabled: boolean) => void;
|
||||
alwaysAllowTools: string[];
|
||||
clearAlwaysAllowTools: () => void;
|
||||
};
|
||||
|
||||
export default function ChatSettings({
|
||||
@@ -29,6 +32,8 @@ export default function ChatSettings({
|
||||
setShowStats,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
alwaysAllowTools,
|
||||
clearAlwaysAllowTools,
|
||||
}: ChatSettingsProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -90,6 +95,40 @@ export default function ChatSettings({
|
||||
onCheckedChange={setAutoScroll}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("settings.always_allow.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.always_allow.desc")}
|
||||
</div>
|
||||
</div>
|
||||
{alwaysAllowTools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alwaysAllowTools.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="rounded-md bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
|
||||
>
|
||||
{formatToolName(name)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.always_allow.none")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={alwaysAllowTools.length === 0}
|
||||
onClick={clearAlwaysAllowTools}
|
||||
>
|
||||
{t("settings.always_allow.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuShieldAlert, LuCheck, LuX } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
import type { PendingToolCall, ToolDecision } from "@/types/chat";
|
||||
|
||||
type ToolApprovalCardProps = {
|
||||
toolCall: PendingToolCall;
|
||||
decision?: ToolDecision;
|
||||
onApprove: (id: string) => void;
|
||||
onAlwaysAllow: (id: string, name: string) => void;
|
||||
onReject: (id: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompt shown when the assistant wants to run a state-changing tool.
|
||||
* Renders the call's arguments and approve / always allow / reject actions;
|
||||
* once decided it collapses into a status line.
|
||||
*/
|
||||
export function ToolApprovalCard({
|
||||
toolCall,
|
||||
decision,
|
||||
onApprove,
|
||||
onAlwaysAllow,
|
||||
onReject,
|
||||
}: ToolApprovalCardProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const displayName = formatToolName(toolCall.name);
|
||||
const hasArguments = Object.keys(toolCall.arguments ?? {}).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full max-w-[85%] flex-col gap-3 self-start rounded-xl border border-border bg-muted px-4 py-3"
|
||||
role="group"
|
||||
aria-label={t("approval.title", { tool: displayName })}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<LuShieldAlert className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{t("approval.title", { tool: displayName })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("approval.desc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasArguments && (
|
||||
<pre className="scrollbar-container max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-background/50 p-2 text-[10px]">
|
||||
{JSON.stringify(toolCall.arguments, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{decision ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-medium",
|
||||
decision === "approve" ? "text-success" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
{decision === "approve" ? (
|
||||
<LuCheck className="size-3.5" />
|
||||
) : (
|
||||
<LuX className="size-3.5" />
|
||||
)}
|
||||
{decision === "approve"
|
||||
? t("approval.approved")
|
||||
: t("approval.rejected")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onApprove(toolCall.id)}
|
||||
>
|
||||
{t("approval.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="select"
|
||||
onClick={() => onAlwaysAllow(toolCall.id, toolCall.name)}
|
||||
>
|
||||
{t("approval.always_allow")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onReject(toolCall.id)}
|
||||
>
|
||||
{t("approval.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,19 +7,12 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { LuChevronsUpDown } from "react-icons/lu";
|
||||
import type { ToolCall } from "@/types/chat";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
|
||||
type ToolCallsGroupProps = {
|
||||
toolCalls: ToolCall[];
|
||||
};
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function ToolCallsGroup({ toolCalls }: ToolCallsGroupProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ToolCall[]>();
|
||||
@@ -53,7 +46,7 @@ type ToolCallRowProps = {
|
||||
function ToolCallRow({ name, calls }: ToolCallRowProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const displayName = normalizeName(name);
|
||||
const displayName = formatToolName(name);
|
||||
const label =
|
||||
calls.length > 1 ? `${displayName} (\u00d7${calls.length})` : displayName;
|
||||
|
||||
|
||||
+149
-15
@@ -8,6 +8,7 @@ import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow
|
||||
import { MessageBubble } from "@/components/chat/ChatMessage";
|
||||
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
|
||||
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
|
||||
import { ToolApprovalCard } from "@/components/chat/ToolApprovalCard";
|
||||
import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
||||
import { ChatComposer } from "@/components/chat/ChatComposer";
|
||||
import ChatSettings from "@/components/chat/ChatSettings";
|
||||
@@ -15,11 +16,13 @@ import type {
|
||||
ChatMessage,
|
||||
ChatStats,
|
||||
GenAIModelsResponse,
|
||||
PendingToolCall,
|
||||
ShowStatsMode,
|
||||
ToolDecision,
|
||||
} from "@/types/chat";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
getEventIdsFromSearchObjectsToolCalls,
|
||||
getEventIdsFromToolCalls,
|
||||
getFindSimilarObjectsFromToolCalls,
|
||||
prependAttachment,
|
||||
streamChatCompletion,
|
||||
@@ -40,6 +43,13 @@ const hasText = (content: unknown): content is string =>
|
||||
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
|
||||
messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest);
|
||||
|
||||
// Stable default so usePersistence does not reload on every render.
|
||||
const NO_TOOLS: string[] = [];
|
||||
|
||||
type ResumeOptions = {
|
||||
toolDecisions: Record<string, ToolDecision>;
|
||||
};
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [input, setInput] = useState("");
|
||||
@@ -48,6 +58,21 @@ export default function ChatPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
|
||||
// Write tool calls the backend paused on, plus the user's decisions so far.
|
||||
const [pendingApprovals, setPendingApprovals] = useState<
|
||||
PendingToolCall[] | null
|
||||
>(null);
|
||||
const [approvalDecisions, setApprovalDecisions] = useState<
|
||||
Record<string, ToolDecision>
|
||||
>({});
|
||||
// Tools the user chose to always allow. Kept only in this browser; the
|
||||
// backend never sees the list, the client just answers for them.
|
||||
const [alwaysAllowTools, setAlwaysAllowTools] = usePersistence<string[]>(
|
||||
"chat-always-allow-tools",
|
||||
NO_TOOLS,
|
||||
);
|
||||
const alwaysAllowRef = useRef<string[]>(NO_TOOLS);
|
||||
alwaysAllowRef.current = alwaysAllowTools ?? NO_TOOLS;
|
||||
const [showStats, setShowStats] = usePersistence<ShowStatsMode>(
|
||||
"chat-show-stats",
|
||||
"while_generating",
|
||||
@@ -62,6 +87,7 @@ export default function ChatPage() {
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const { data: genaiInfo } = useSWR<GenAIModelsResponse>("genai/models", {
|
||||
revalidateOnFocus: false,
|
||||
@@ -92,14 +118,27 @@ export default function ChatPage() {
|
||||
}, [messages, streaming, autoScroll]);
|
||||
|
||||
const submitConversation = useCallback(
|
||||
async (messagesToSend: ChatMessage[]) => {
|
||||
if (isLoading) return;
|
||||
async function submit(
|
||||
messagesToSend: ChatMessage[],
|
||||
resume?: ResumeOptions,
|
||||
) {
|
||||
if (loadingRef.current) return;
|
||||
const last = messagesToSend[messagesToSend.length - 1];
|
||||
if (!last || last.role !== "user" || !hasText(last.content)) return;
|
||||
if (!last) return;
|
||||
// A normal turn ends with the user's message; a resume after an
|
||||
// approval pause ends with the assistant's pending tool calls.
|
||||
if (resume) {
|
||||
if (last.role !== "assistant" || !last.tool_calls?.length) return;
|
||||
} else if (last.role !== "user" || !hasText(last.content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setPendingApprovals(null);
|
||||
setApprovalDecisions({});
|
||||
setMessages(messagesToSend);
|
||||
setStreaming({ content: "", reasoning: "", chain: [] });
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
const baseURL = axios.defaults.baseURL ?? "";
|
||||
@@ -116,6 +155,7 @@ export default function ChatPage() {
|
||||
let stats: ChatStats | undefined;
|
||||
let reasoning = "";
|
||||
let hadError = false;
|
||||
let approvals: PendingToolCall[] | null = null;
|
||||
|
||||
await streamChatCompletion(
|
||||
url,
|
||||
@@ -138,32 +178,99 @@ export default function ChatPage() {
|
||||
stats = s;
|
||||
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
|
||||
},
|
||||
onApprovalRequired: (toolCalls) => {
|
||||
approvals = toolCalls;
|
||||
},
|
||||
onError: (message) => {
|
||||
hadError = true;
|
||||
setError(message);
|
||||
},
|
||||
onDone: () => {
|
||||
abortRef.current = null;
|
||||
loadingRef.current = false;
|
||||
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,
|
||||
),
|
||||
const committed = chain.map((m, i) =>
|
||||
i === chain.length - 1
|
||||
? { ...m, reasoning: reasoning || undefined, stats }
|
||||
: m,
|
||||
);
|
||||
setMessages(committed);
|
||||
if (approvals?.length) {
|
||||
// Calls to always-allowed tools are answered here without
|
||||
// prompting; anything else waits for the user.
|
||||
const allowed = alwaysAllowRef.current;
|
||||
const auto: Record<string, ToolDecision> = {};
|
||||
for (const tc of approvals) {
|
||||
if (allowed.includes(tc.name)) auto[tc.id] = "approve";
|
||||
}
|
||||
if (Object.keys(auto).length === approvals.length) {
|
||||
submit(committed, { toolDecisions: auto });
|
||||
} else {
|
||||
setApprovalDecisions(auto);
|
||||
setPendingApprovals(approvals);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultErrorMessage: t("error"),
|
||||
},
|
||||
controller.signal,
|
||||
supportsThinking ? { enableThinking: !!thinkingEnabled } : {},
|
||||
{
|
||||
...(supportsThinking ? { enableThinking: !!thinkingEnabled } : {}),
|
||||
toolDecisions: resume?.toolDecisions,
|
||||
},
|
||||
);
|
||||
},
|
||||
[isLoading, supportsThinking, t, thinkingEnabled],
|
||||
[supportsThinking, t, thinkingEnabled],
|
||||
);
|
||||
|
||||
// Resume the paused turn once every pending call has a decision.
|
||||
const applyDecisions = useCallback(
|
||||
(next: Record<string, ToolDecision>) => {
|
||||
setApprovalDecisions(next);
|
||||
if (!pendingApprovals) return;
|
||||
if (!pendingApprovals.every((tc) => next[tc.id] !== undefined)) return;
|
||||
submitConversation(messages, { toolDecisions: next });
|
||||
},
|
||||
[messages, pendingApprovals, submitConversation],
|
||||
);
|
||||
|
||||
const handleApprove = useCallback(
|
||||
(id: string) => applyDecisions({ ...approvalDecisions, [id]: "approve" }),
|
||||
[applyDecisions, approvalDecisions],
|
||||
);
|
||||
|
||||
const handleReject = useCallback(
|
||||
(id: string) => applyDecisions({ ...approvalDecisions, [id]: "reject" }),
|
||||
[applyDecisions, approvalDecisions],
|
||||
);
|
||||
|
||||
const handleAlwaysAllow = useCallback(
|
||||
(id: string, name: string) => {
|
||||
const current = alwaysAllowTools ?? NO_TOOLS;
|
||||
const allowed = current.includes(name) ? current : [...current, name];
|
||||
setAlwaysAllowTools(allowed);
|
||||
const next = { ...approvalDecisions, [id]: "approve" as const };
|
||||
for (const tc of pendingApprovals ?? []) {
|
||||
if (tc.name === name) next[tc.id] = "approve";
|
||||
}
|
||||
applyDecisions(next);
|
||||
},
|
||||
[
|
||||
alwaysAllowTools,
|
||||
applyDecisions,
|
||||
approvalDecisions,
|
||||
pendingApprovals,
|
||||
setAlwaysAllowTools,
|
||||
],
|
||||
);
|
||||
|
||||
const clearAlwaysAllowTools = useCallback(
|
||||
() => setAlwaysAllowTools(NO_TOOLS),
|
||||
[setAlwaysAllowTools],
|
||||
);
|
||||
|
||||
const recentEventIds = useMemo(() => {
|
||||
@@ -174,7 +281,7 @@ export default function ChatPage() {
|
||||
const calls = toolCallsForMessage(msg, responses);
|
||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||
if (similar) return similar.results.map((e) => e.id);
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(calls);
|
||||
const events = getEventIdsFromToolCalls(calls);
|
||||
if (events.length > 0) return events.map((e) => e.id);
|
||||
}
|
||||
return [];
|
||||
@@ -197,19 +304,25 @@ export default function ChatPage() {
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setStreaming(null);
|
||||
setPendingApprovals(null);
|
||||
setApprovalDecisions({});
|
||||
}, []);
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setStreaming(null);
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setAttachedEventId(null);
|
||||
setError(null);
|
||||
setPendingApprovals(null);
|
||||
setApprovalDecisions({});
|
||||
}, []);
|
||||
|
||||
const handleEditSubmit = useCallback(
|
||||
@@ -260,7 +373,7 @@ export default function ChatPage() {
|
||||
const calls = toolCallsForMessage(msg, responses);
|
||||
const contentText = hasText(msg.content) ? msg.content : "";
|
||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||
const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls);
|
||||
const events = similar ? [] : getEventIdsFromToolCalls(calls);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
@@ -324,6 +437,8 @@ export default function ChatPage() {
|
||||
setShowStats={setShowStats}
|
||||
autoScroll={autoScroll ?? true}
|
||||
setAutoScroll={setAutoScroll}
|
||||
alwaysAllowTools={alwaysAllowTools ?? NO_TOOLS}
|
||||
clearAlwaysAllowTools={clearAlwaysAllowTools}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -335,6 +450,20 @@ export default function ChatPage() {
|
||||
{hasStarted ? (
|
||||
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
||||
{renderList.map((msg, i) => renderMessage(msg, i))}
|
||||
{pendingApprovals && !streaming && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pendingApprovals.map((tc) => (
|
||||
<ToolApprovalCard
|
||||
key={tc.id}
|
||||
toolCall={tc}
|
||||
decision={approvalDecisions[tc.id]}
|
||||
onApprove={handleApprove}
|
||||
onAlwaysAllow={handleAlwaysAllow}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{streaming &&
|
||||
!finalShown &&
|
||||
(streaming.content || streaming.reasoning ? (
|
||||
@@ -391,7 +520,12 @@ export default function ChatPage() {
|
||||
setInput={setInput}
|
||||
sendMessage={sendMessage}
|
||||
isLoading={isLoading}
|
||||
placeholder={t("placeholder")}
|
||||
disabled={pendingApprovals != null}
|
||||
placeholder={
|
||||
pendingApprovals != null
|
||||
? t("approval.placeholder")
|
||||
: t("placeholder")
|
||||
}
|
||||
attachedEventId={attachedEventId}
|
||||
onClearAttachment={handleClearAttachment}
|
||||
onAttach={setAttachedEventId}
|
||||
|
||||
@@ -20,11 +20,21 @@ export type ChatMessage = {
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
id?: string;
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
response?: string;
|
||||
};
|
||||
|
||||
export type ToolDecision = "approve" | "reject";
|
||||
|
||||
/** A state-changing tool call the backend paused on, awaiting the user. */
|
||||
export type PendingToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type StartingRequest = {
|
||||
label: string;
|
||||
prompt: string;
|
||||
|
||||
+56
-14
@@ -1,4 +1,10 @@
|
||||
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatStats,
|
||||
PendingToolCall,
|
||||
ToolCall,
|
||||
ToolDecision,
|
||||
} from "@/types/chat";
|
||||
|
||||
export type StreamChatCallbacks = {
|
||||
/** Streamed delta of the assistant's final answer text. */
|
||||
@@ -11,6 +17,10 @@ export type StreamChatCallbacks = {
|
||||
onChain: (chain: ChatMessage[]) => void;
|
||||
/** Token/timing stats for the turn. */
|
||||
onStats: (stats: ChatStats) => void;
|
||||
/** The backend paused before running state-changing tools; the chain
|
||||
* emitted just before this ends with the assistant message requesting
|
||||
* them. Resend that chain with `toolDecisions` to continue. */
|
||||
onApprovalRequired?: (toolCalls: PendingToolCall[]) => void;
|
||||
/** Called when the stream sends an error or fetch fails. */
|
||||
onError: (message: string) => void;
|
||||
/** Called when the stream finishes (success or error). */
|
||||
@@ -30,6 +40,7 @@ type StatsChunk = {
|
||||
type StreamChunk =
|
||||
| { type: "error"; error: string }
|
||||
| { type: "messages"; messages: ChatMessage[] }
|
||||
| { type: "approval_required"; tool_calls: PendingToolCall[] }
|
||||
| { type: "content"; delta: string }
|
||||
| { type: "reasoning"; delta: string }
|
||||
| StatsChunk;
|
||||
@@ -40,6 +51,8 @@ type StreamChunk =
|
||||
*/
|
||||
export type StreamChatOptions = {
|
||||
enableThinking?: boolean;
|
||||
/** Decisions for tool calls that paused for approval, keyed by call id. */
|
||||
toolDecisions?: Record<string, ToolDecision>;
|
||||
};
|
||||
|
||||
export async function streamChatCompletion(
|
||||
@@ -55,6 +68,7 @@ export async function streamChatCompletion(
|
||||
onReasoningDelta,
|
||||
onChain,
|
||||
onStats,
|
||||
onApprovalRequired,
|
||||
onError,
|
||||
onDone,
|
||||
defaultErrorMessage = "Something went wrong. Please try again.",
|
||||
@@ -68,6 +82,9 @@ export async function streamChatCompletion(
|
||||
if (options.enableThinking !== undefined) {
|
||||
body.enable_thinking = options.enableThinking;
|
||||
}
|
||||
if (options.toolDecisions && Object.keys(options.toolDecisions).length) {
|
||||
body.tool_decisions = options.toolDecisions;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
@@ -103,6 +120,10 @@ export async function streamChatCompletion(
|
||||
onChain(data.messages ?? []);
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "approval_required") {
|
||||
onApprovalRequired?.(data.tool_calls ?? []);
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "content" && data.delta !== undefined) {
|
||||
onContentDelta(data.delta);
|
||||
return "continue";
|
||||
@@ -198,6 +219,7 @@ export function toolCallsForMessage(
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: tc.id,
|
||||
name: tc.function?.name ?? "",
|
||||
arguments: args,
|
||||
response: responses.get(tc.id),
|
||||
@@ -205,28 +227,48 @@ export function toolCallsForMessage(
|
||||
});
|
||||
}
|
||||
|
||||
/** Human-readable tool name: "search_objects" -> "Search Objects". */
|
||||
export function formatToolName(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const hasStringId = (item: unknown): item is { id: string } =>
|
||||
!!item &&
|
||||
typeof item === "object" &&
|
||||
"id" in item &&
|
||||
typeof (item as { id: unknown }).id === "string";
|
||||
|
||||
/**
|
||||
* Parse search_objects tool call response(s) into event ids for thumbnails.
|
||||
* Collect event ids from tool responses that reference tracked objects:
|
||||
* search_objects returns a list of events and get_event_image a single one.
|
||||
*/
|
||||
export function getEventIdsFromSearchObjectsToolCalls(
|
||||
export function getEventIdsFromToolCalls(
|
||||
toolCalls: ToolCall[] | undefined,
|
||||
): { id: string }[] {
|
||||
if (!toolCalls?.length) return [];
|
||||
const results: { id: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (item: unknown) => {
|
||||
if (hasStringId(item) && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
results.push({ id: item.id });
|
||||
}
|
||||
};
|
||||
for (const tc of toolCalls) {
|
||||
if (tc.name !== "search_objects" || !tc.response?.trim()) continue;
|
||||
if (!tc.response?.trim()) continue;
|
||||
if (tc.name !== "search_objects" && tc.name !== "get_event_image") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(tc.response) as unknown;
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"id" in item &&
|
||||
typeof (item as { id: unknown }).id === "string"
|
||||
) {
|
||||
results.push({ id: (item as { id: string }).id });
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(push);
|
||||
} else {
|
||||
push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
|
||||
Reference in New Issue
Block a user