mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 10:18:58 +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
|
description: Per-request thinking toggle. None means use the provider
|
||||||
default. Ignored by providers that do not expose a per-request
|
default. Ignored by providers that do not expose a per-request
|
||||||
thinking switch.
|
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
|
type: object
|
||||||
required:
|
required:
|
||||||
- messages
|
- messages
|
||||||
|
|||||||
+495
-160
@@ -10,6 +10,7 @@ from functools import reduce
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
|
import numpy as np
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -23,6 +24,7 @@ from frigate.api.chat_util import (
|
|||||||
chunk_content,
|
chunk_content,
|
||||||
distance_to_score,
|
distance_to_score,
|
||||||
format_events_with_local_time,
|
format_events_with_local_time,
|
||||||
|
format_local_time,
|
||||||
fuse_scores,
|
fuse_scores,
|
||||||
hydrate_event,
|
hydrate_event,
|
||||||
parse_iso_to_timestamp,
|
parse_iso_to_timestamp,
|
||||||
@@ -33,29 +35,44 @@ from frigate.api.defs.response.chat_response import (
|
|||||||
ChatCompletionResponse,
|
ChatCompletionResponse,
|
||||||
ChatMessageResponse,
|
ChatMessageResponse,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
|
ToolCallInvocation,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.api.event import _build_attribute_filter_clause, events
|
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 import FrigateConfig
|
||||||
from frigate.config.classification import SemanticSearchModelEnum
|
from frigate.config.classification import SemanticSearchModelEnum
|
||||||
from frigate.genai.prompts import (
|
from frigate.genai.prompts import (
|
||||||
build_chat_system_prompt,
|
build_chat_system_prompt,
|
||||||
get_attribute_classifications,
|
get_attribute_classifications,
|
||||||
get_tool_definitions,
|
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 (
|
from frigate.jobs.vlm_watch import (
|
||||||
get_vlm_watch_job,
|
get_vlm_watch_job,
|
||||||
start_vlm_watch_job,
|
start_vlm_watch_job,
|
||||||
stop_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
|
from frigate.util.object_names import get_categorized_object_names
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=[Tags.chat])
|
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):
|
class ToolExecuteRequest(BaseModel):
|
||||||
"""Request model for tool execution."""
|
"""Request model for tool execution."""
|
||||||
@@ -666,29 +683,39 @@ async def _get_live_frame_image_url(
|
|||||||
frame = frame_processor.get_current_frame(camera, {})
|
frame = frame_processor.get_current_frame(camera, {})
|
||||||
if frame is None:
|
if frame is None:
|
||||||
return None
|
return None
|
||||||
height, width = frame.shape[:2]
|
return _encode_frame_data_url(frame)
|
||||||
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}"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to get live frame for %s: %s", camera, e)
|
logger.debug("Failed to get live frame for %s: %s", camera, e)
|
||||||
return None
|
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(
|
async def _execute_set_camera_state(
|
||||||
request: Request,
|
request: Request,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
role = request.headers.get("remote-role", "")
|
if "admin" not in _request_roles(request):
|
||||||
if "admin" not in [r.strip() for r in role.split(",")]:
|
|
||||||
return {"error": "Admin privileges required to change camera settings."}
|
return {"error": "Admin privileges required to change camera settings."}
|
||||||
|
|
||||||
camera = arguments.get("camera", "").strip()
|
camera = arguments.get("camera", "").strip()
|
||||||
@@ -738,6 +765,189 @@ def _execute_get_categorized_object_names(
|
|||||||
return {"names": 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(
|
async def _execute_tool_internal(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
@@ -793,11 +1003,18 @@ async def _execute_tool_internal(
|
|||||||
return _execute_get_profile_status(request)
|
return _execute_get_profile_status(request)
|
||||||
elif tool_name == "get_recap":
|
elif tool_name == "get_recap":
|
||||||
return _execute_get_recap(arguments, allowed_cameras)
|
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:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
|
"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_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,
|
tool_name,
|
||||||
json.dumps(arguments),
|
json.dumps(arguments),
|
||||||
)
|
)
|
||||||
@@ -1026,14 +1243,74 @@ def _execute_get_recap(
|
|||||||
return {"error": "Failed to fetch recap data."}
|
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(
|
async def _execute_pending_tools(
|
||||||
pending_tool_calls: list[dict[str, Any]],
|
pending_tool_calls: list[dict[str, Any]],
|
||||||
request: Request,
|
request: Request,
|
||||||
allowed_cameras: list[str],
|
allowed_cameras: list[str],
|
||||||
|
decisions: dict[str, str] | None = None,
|
||||||
) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]:
|
) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Execute a list of tool calls.
|
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:
|
Returns:
|
||||||
(ToolCall list for API response,
|
(ToolCall list for API response,
|
||||||
tool result dicts for conversation,
|
tool result dicts for conversation,
|
||||||
@@ -1042,10 +1319,28 @@ async def _execute_pending_tools(
|
|||||||
tool_calls_out: list[ToolCall] = []
|
tool_calls_out: list[ToolCall] = []
|
||||||
tool_results: list[dict[str, Any]] = []
|
tool_results: list[dict[str, Any]] = []
|
||||||
extra_messages: list[dict[str, Any]] = []
|
extra_messages: list[dict[str, Any]] = []
|
||||||
|
rejected_tools: list[str] = []
|
||||||
for tool_call in pending_tool_calls:
|
for tool_call in pending_tool_calls:
|
||||||
tool_name = tool_call["name"]
|
tool_name = tool_call["name"]
|
||||||
tool_args = tool_call.get("arguments") or {}
|
tool_args = tool_call.get("arguments") or {}
|
||||||
tool_call_id = tool_call["id"]
|
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(
|
logger.debug(
|
||||||
f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}"
|
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)
|
if isinstance(evt, dict)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Extract _image_url from get_live_context results — images can
|
# Extract _image_url from tool results — images can only be sent
|
||||||
# only be sent in user messages, not tool results
|
# in user messages, not tool results
|
||||||
if isinstance(tool_result, dict) and "_image_url" in tool_result:
|
if isinstance(tool_result, dict) and "_image_url" in tool_result:
|
||||||
image_url = tool_result.pop("_image_url")
|
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(
|
extra_messages.append(
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": f"Here is the current live image from camera '{tool_result.get('camera', 'unknown')}'.",
|
"text": image_text,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
@@ -1133,6 +1432,8 @@ async def _execute_pending_tools(
|
|||||||
"content": error_content,
|
"content": error_content,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if rejected_tools:
|
||||||
|
extra_messages.append(_rejection_message(rejected_tools))
|
||||||
return (tool_calls_out, tool_results, extra_messages)
|
return (tool_calls_out, tool_results, extra_messages)
|
||||||
|
|
||||||
|
|
||||||
@@ -1179,6 +1480,8 @@ async def chat_completion(
|
|||||||
attribute_classifications=attribute_classifications,
|
attribute_classifications=attribute_classifications,
|
||||||
embeddings_language=_embeddings_language(config),
|
embeddings_language=_embeddings_language(config),
|
||||||
)
|
)
|
||||||
|
write_tools = get_write_tool_names(tools)
|
||||||
|
llm_tools = strip_tool_access(tools)
|
||||||
conversation = []
|
conversation = []
|
||||||
|
|
||||||
# Build the system message only when the client hasn't already pinned one.
|
# 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] = []
|
tool_calls: list[ToolCall] = []
|
||||||
max_iterations = body.max_tool_iterations
|
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(
|
logger.debug(
|
||||||
f"Starting chat completion with {len(conversation)} message(s), "
|
f"Starting chat completion with {len(conversation)} message(s), "
|
||||||
f"{len(tools)} tool(s) available, max_iterations={max_iterations}"
|
f"{len(tools)} tool(s) available, max_iterations={max_iterations}"
|
||||||
@@ -1228,93 +1535,64 @@ async def chat_completion(
|
|||||||
|
|
||||||
async def stream_body_llm():
|
async def stream_body_llm():
|
||||||
nonlocal conversation, stream_iterations
|
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
|
# Return the full conversation (including the system message) so
|
||||||
# the client persists and replays it verbatim next turn.
|
# the client persists and replays it verbatim next turn.
|
||||||
chain = conversation + (extra or [])
|
return _emit(
|
||||||
return (
|
{"type": "messages", "messages": conversation + (extra or [])}
|
||||||
json.dumps({"type": "messages", "messages": chain}).encode("utf-8")
|
|
||||||
+ b"\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
while stream_iterations < max_iterations:
|
while stream_iterations < max_iterations:
|
||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
logger.debug("Client disconnected, stopping chat stream")
|
logger.debug("Client disconnected, stopping chat stream")
|
||||||
return
|
return
|
||||||
logger.debug(
|
|
||||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
if pending is None:
|
||||||
f"with {len(conversation)} message(s)"
|
logger.debug(
|
||||||
)
|
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||||
async for event in genai_client.chat_with_tools_stream(
|
f"with {len(conversation)} message(s)"
|
||||||
messages=conversation,
|
)
|
||||||
tools=tools if tools else None,
|
async for event in genai_client.chat_with_tools_stream(
|
||||||
tool_choice="auto",
|
messages=conversation,
|
||||||
enable_thinking=body.enable_thinking,
|
tools=llm_tools if llm_tools else None,
|
||||||
):
|
tool_choice="auto",
|
||||||
if await request.is_disconnected():
|
enable_thinking=body.enable_thinking,
|
||||||
logger.debug("Client disconnected, stopping chat stream")
|
):
|
||||||
return
|
if await request.is_disconnected():
|
||||||
kind, value = event
|
logger.debug("Client disconnected, stopping chat stream")
|
||||||
if kind == "content_delta":
|
return
|
||||||
yield (
|
kind, value = event
|
||||||
json.dumps({"type": "content", "delta": value}).encode(
|
if kind == "content_delta":
|
||||||
"utf-8"
|
yield _emit({"type": "content", "delta": value})
|
||||||
)
|
elif kind == "reasoning_delta":
|
||||||
+ b"\n"
|
yield _emit({"type": "reasoning", "delta": value})
|
||||||
)
|
elif kind == "stats":
|
||||||
elif kind == "reasoning_delta":
|
yield _emit({"type": "stats", **value})
|
||||||
yield (
|
elif kind == "message":
|
||||||
json.dumps({"type": "reasoning", "delta": value}).encode(
|
msg = value
|
||||||
"utf-8"
|
if msg.get("finish_reason") == "error":
|
||||||
)
|
yield _emit(
|
||||||
+ 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(
|
|
||||||
{
|
{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"error": "An error occurred while processing your request.",
|
"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
|
return
|
||||||
(
|
requested = msg.get("tool_calls")
|
||||||
_executed_calls,
|
if requested:
|
||||||
tool_results,
|
stream_iterations += 1
|
||||||
extra_msgs,
|
conversation.append(
|
||||||
) = await _execute_pending_tools(
|
build_assistant_message_for_conversation(
|
||||||
pending, request, allowed_cameras
|
msg.get("content"), requested
|
||||||
)
|
)
|
||||||
conversation.extend(tool_results)
|
)
|
||||||
conversation.extend(extra_msgs)
|
pending = requested
|
||||||
# Emit the running chain so the client can render tool
|
break
|
||||||
# calls live and replay them verbatim next turn.
|
|
||||||
yield _emit_chain()
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Streaming never appends the final assistant message
|
# Streaming never appends the final assistant message
|
||||||
# to the conversation, so add it to the chain.
|
# to the conversation, so add it to the chain.
|
||||||
yield _emit_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
|
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 _emit_chain()
|
||||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
|
||||||
|
yield _emit_chain()
|
||||||
|
yield _emit({"type": "done"})
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_body_llm(),
|
stream_body_llm(),
|
||||||
@@ -1338,102 +1646,129 @@ async def chat_completion(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
pending_tool_calls = resume_pending
|
||||||
while tool_iterations < max_iterations:
|
while tool_iterations < max_iterations:
|
||||||
logger.debug(
|
if pending_tool_calls is None:
|
||||||
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:
|
|
||||||
logger.debug(
|
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:
|
if response.get("finish_reason") == "error":
|
||||||
final_reasoning = response.get("reasoning")
|
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:
|
pending_tool_calls = response.get("tool_calls")
|
||||||
yield (
|
if not pending_tool_calls:
|
||||||
json.dumps({"type": "messages", "messages": chain}).encode(
|
logger.debug(
|
||||||
"utf-8"
|
f"Chat completion finished with final answer (iterations: {tool_iterations})"
|
||||||
)
|
)
|
||||||
+ b"\n"
|
final_content = response.get("content") or ""
|
||||||
)
|
|
||||||
# Emit the full reasoning trace up front when the
|
if body.stream:
|
||||||
# underlying client did not stream it
|
final_reasoning = response.get("reasoning")
|
||||||
if final_reasoning:
|
|
||||||
|
chain = list(conversation)
|
||||||
|
|
||||||
|
async def stream_body() -> Any:
|
||||||
yield (
|
yield (
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{"type": "reasoning", "delta": final_reasoning}
|
{"type": "messages", "messages": chain}
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
+ b"\n"
|
+ b"\n"
|
||||||
)
|
)
|
||||||
# Stream content in word-sized chunks for smooth UX
|
# Emit the full reasoning trace up front when the
|
||||||
for part in chunk_content(final_content):
|
# underlying client did not stream it
|
||||||
yield (
|
if final_reasoning:
|
||||||
json.dumps({"type": "content", "delta": part}).encode(
|
yield (
|
||||||
"utf-8"
|
json.dumps(
|
||||||
|
{"type": "reasoning", "delta": final_reasoning}
|
||||||
|
).encode("utf-8")
|
||||||
|
+ b"\n"
|
||||||
)
|
)
|
||||||
+ b"\n"
|
# Stream content in word-sized chunks for smooth UX
|
||||||
)
|
for part in chunk_content(final_content):
|
||||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
yield (
|
||||||
|
json.dumps(
|
||||||
|
{"type": "content", "delta": part}
|
||||||
|
).encode("utf-8")
|
||||||
|
+ b"\n"
|
||||||
|
)
|
||||||
|
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_body(),
|
stream_body(),
|
||||||
media_type="application/x-ndjson",
|
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(
|
return JSONResponse(
|
||||||
content=ChatCompletionResponse(
|
content=ChatCompletionResponse(
|
||||||
message=ChatMessageResponse(
|
message=ChatMessageResponse(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=final_content,
|
content=None,
|
||||||
reasoning=response.get("reasoning"),
|
tool_calls=[ToolCallInvocation(**tc) for tc in awaiting],
|
||||||
tool_calls=None,
|
|
||||||
),
|
),
|
||||||
finish_reason=response.get("finish_reason", "stop"),
|
finish_reason="approval_required",
|
||||||
tool_iterations=tool_iterations,
|
tool_iterations=tool_iterations,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
messages=list(conversation),
|
messages=list(conversation),
|
||||||
).model_dump(),
|
).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(
|
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)
|
tool_calls.extend(executed_calls)
|
||||||
conversation.extend(tool_results)
|
conversation.extend(tool_results)
|
||||||
conversation.extend(extra_msgs)
|
conversation.extend(extra_msgs)
|
||||||
|
pending_tool_calls = None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Added {len(tool_results)} tool result(s) to conversation. "
|
f"Added {len(tool_results)} tool result(s) to conversation. "
|
||||||
f"Continuing with next LLM call..."
|
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)
|
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(
|
def format_events_with_local_time(
|
||||||
events_list: list[dict[str, Any]],
|
events_list: list[dict[str, Any]],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
@@ -58,11 +63,9 @@ def format_events_with_local_time(
|
|||||||
start_ts = evt.get("start_time")
|
start_ts = evt.get("start_time")
|
||||||
end_ts = evt.get("end_time")
|
end_ts = evt.get("end_time")
|
||||||
if start_ts is not None:
|
if start_ts is not None:
|
||||||
dt_start = datetime.fromtimestamp(start_ts)
|
copy_evt["start_time_local"] = format_local_time(start_ts)
|
||||||
copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p")
|
|
||||||
if end_ts is not None:
|
if end_ts is not None:
|
||||||
dt_end = datetime.fromtimestamp(end_ts)
|
copy_evt["end_time_local"] = format_local_time(end_ts)
|
||||||
copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p")
|
|
||||||
except (TypeError, ValueError, OSError):
|
except (TypeError, ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
result.append(copy_evt)
|
result.append(copy_evt)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Chat API request models."""
|
"""Chat API request models."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -59,3 +59,12 @@ class ChatCompletionRequest(BaseModel):
|
|||||||
"Ignored by providers that do not expose a per-request thinking switch."
|
"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
|
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
|
filters relate to each other, is stated once in the system prompt so the
|
||||||
guidance is not paid for twice on every request.
|
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] = {
|
search_objects_properties: dict[str, Any] = {
|
||||||
"camera": {
|
"camera": {
|
||||||
@@ -382,6 +386,7 @@ def get_tool_definitions(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "read",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "search_objects",
|
"name": "search_objects",
|
||||||
"description": search_objects_description,
|
"description": search_objects_description,
|
||||||
@@ -394,6 +399,7 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "read",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "get_categorized_object_names",
|
"name": "get_categorized_object_names",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -411,6 +417,7 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "read",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "find_similar_objects",
|
"name": "find_similar_objects",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -474,6 +481,7 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "write",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "set_camera_state",
|
"name": "set_camera_state",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -529,6 +537,7 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "read",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "get_live_context",
|
"name": "get_live_context",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -553,6 +562,7 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "write",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "start_camera_watch",
|
"name": "start_camera_watch",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -596,6 +606,7 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "write",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "stop_camera_watch",
|
"name": "stop_camera_watch",
|
||||||
"description": "Cancel the currently running watch job.",
|
"description": "Cancel the currently running watch job.",
|
||||||
@@ -608,6 +619,7 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "read",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "get_profile_status",
|
"name": "get_profile_status",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -624,6 +636,7 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
"access": "read",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "get_recap",
|
"name": "get_recap",
|
||||||
"description": (
|
"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(
|
def build_chat_system_prompt(
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
allowed_cameras: list[str],
|
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": {
|
"auto_scroll": {
|
||||||
"title": "Auto-scroll",
|
"title": "Auto-scroll",
|
||||||
"desc": "Follow new messages as they arrive."
|
"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": {
|
"stats": {
|
||||||
@@ -68,5 +74,15 @@
|
|||||||
},
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"toggle": "Toggle 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;
|
isLoading?: boolean;
|
||||||
onStop?: () => void;
|
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;
|
attachedEventId?: string | null;
|
||||||
onClearAttachment?: () => void;
|
onClearAttachment?: () => void;
|
||||||
@@ -45,6 +48,7 @@ export function ChatComposer({
|
|||||||
setThinkingEnabled,
|
setThinkingEnabled,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onStop,
|
onStop,
|
||||||
|
disabled = false,
|
||||||
attachedEventId,
|
attachedEventId,
|
||||||
onClearAttachment,
|
onClearAttachment,
|
||||||
onAttach,
|
onAttach,
|
||||||
@@ -62,6 +66,7 @@ export function ChatComposer({
|
|||||||
|
|
||||||
const showPaperclip = !!onAttach;
|
const showPaperclip = !!onAttach;
|
||||||
const showStop = isLoading && !!onStop;
|
const showStop = isLoading && !!onStop;
|
||||||
|
const inputBlocked = isLoading || disabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
<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 && (
|
{attachedEventId && (
|
||||||
<ChatQuickReplies
|
<ChatQuickReplies
|
||||||
onSend={(text) => sendMessage(text)}
|
onSend={(text) => sendMessage(text)}
|
||||||
disabled={isLoading}
|
disabled={inputBlocked}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full flex-row items-center gap-2">
|
<div className="flex w-full flex-row items-center gap-2">
|
||||||
@@ -85,7 +90,7 @@ export function ChatComposer({
|
|||||||
<ChatPaperclipButton
|
<ChatPaperclipButton
|
||||||
recentEventIds={recentEventIds ?? []}
|
recentEventIds={recentEventIds ?? []}
|
||||||
onAttach={onAttach!}
|
onAttach={onAttach!}
|
||||||
disabled={isLoading || attachedEventId != null}
|
disabled={inputBlocked || attachedEventId != null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{supportsThinking && (
|
{supportsThinking && (
|
||||||
@@ -103,7 +108,7 @@ export function ChatComposer({
|
|||||||
!thinkingEnabled && "text-secondary-foreground",
|
!thinkingEnabled && "text-secondary-foreground",
|
||||||
)}
|
)}
|
||||||
onClick={() => setThinkingEnabled(!thinkingEnabled)}
|
onClick={() => setThinkingEnabled(!thinkingEnabled)}
|
||||||
disabled={isLoading}
|
disabled={inputBlocked}
|
||||||
>
|
>
|
||||||
<LuBrain className="size-4" />
|
<LuBrain className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -122,6 +127,7 @@ export function ChatComposer({
|
|||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
aria-busy={isLoading}
|
aria-busy={isLoading}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
{showStop ? (
|
{showStop ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -135,7 +141,7 @@ export function ChatComposer({
|
|||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
className="size-10 shrink-0 rounded-full"
|
className="size-10 shrink-0 rounded-full"
|
||||||
disabled={!input.trim() || isLoading}
|
disabled={!input.trim() || inputBlocked}
|
||||||
onClick={() => sendMessage()}
|
onClick={() => sendMessage()}
|
||||||
>
|
>
|
||||||
<FaArrowUpLong className="size-4" />
|
<FaArrowUpLong className="size-4" />
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ShowStatsMode } from "@/types/chat";
|
import type { ShowStatsMode } from "@/types/chat";
|
||||||
|
import { formatToolName } from "@/utils/chatUtil";
|
||||||
|
|
||||||
type ChatSettingsProps = {
|
type ChatSettingsProps = {
|
||||||
showStats: ShowStatsMode;
|
showStats: ShowStatsMode;
|
||||||
setShowStats: (mode: ShowStatsMode) => void;
|
setShowStats: (mode: ShowStatsMode) => void;
|
||||||
autoScroll: boolean;
|
autoScroll: boolean;
|
||||||
setAutoScroll: (enabled: boolean) => void;
|
setAutoScroll: (enabled: boolean) => void;
|
||||||
|
alwaysAllowTools: string[];
|
||||||
|
clearAlwaysAllowTools: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ChatSettings({
|
export default function ChatSettings({
|
||||||
@@ -29,6 +32,8 @@ export default function ChatSettings({
|
|||||||
setShowStats,
|
setShowStats,
|
||||||
autoScroll,
|
autoScroll,
|
||||||
setAutoScroll,
|
setAutoScroll,
|
||||||
|
alwaysAllowTools,
|
||||||
|
clearAlwaysAllowTools,
|
||||||
}: ChatSettingsProps) {
|
}: ChatSettingsProps) {
|
||||||
const { t } = useTranslation(["views/chat"]);
|
const { t } = useTranslation(["views/chat"]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -90,6 +95,40 @@ export default function ChatSettings({
|
|||||||
onCheckedChange={setAutoScroll}
|
onCheckedChange={setAutoScroll}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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";
|
} from "@/components/ui/collapsible";
|
||||||
import { LuChevronsUpDown } from "react-icons/lu";
|
import { LuChevronsUpDown } from "react-icons/lu";
|
||||||
import type { ToolCall } from "@/types/chat";
|
import type { ToolCall } from "@/types/chat";
|
||||||
|
import { formatToolName } from "@/utils/chatUtil";
|
||||||
|
|
||||||
type ToolCallsGroupProps = {
|
type ToolCallsGroupProps = {
|
||||||
toolCalls: ToolCall[];
|
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) {
|
export function ToolCallsGroup({ toolCalls }: ToolCallsGroupProps) {
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<string, ToolCall[]>();
|
const map = new Map<string, ToolCall[]>();
|
||||||
@@ -53,7 +46,7 @@ type ToolCallRowProps = {
|
|||||||
function ToolCallRow({ name, calls }: ToolCallRowProps) {
|
function ToolCallRow({ name, calls }: ToolCallRowProps) {
|
||||||
const { t } = useTranslation(["views/chat"]);
|
const { t } = useTranslation(["views/chat"]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const displayName = normalizeName(name);
|
const displayName = formatToolName(name);
|
||||||
const label =
|
const label =
|
||||||
calls.length > 1 ? `${displayName} (\u00d7${calls.length})` : displayName;
|
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 { MessageBubble } from "@/components/chat/ChatMessage";
|
||||||
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
|
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
|
||||||
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
|
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
|
||||||
|
import { ToolApprovalCard } from "@/components/chat/ToolApprovalCard";
|
||||||
import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
||||||
import { ChatComposer } from "@/components/chat/ChatComposer";
|
import { ChatComposer } from "@/components/chat/ChatComposer";
|
||||||
import ChatSettings from "@/components/chat/ChatSettings";
|
import ChatSettings from "@/components/chat/ChatSettings";
|
||||||
@@ -15,11 +16,13 @@ import type {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatStats,
|
ChatStats,
|
||||||
GenAIModelsResponse,
|
GenAIModelsResponse,
|
||||||
|
PendingToolCall,
|
||||||
ShowStatsMode,
|
ShowStatsMode,
|
||||||
|
ToolDecision,
|
||||||
} from "@/types/chat";
|
} from "@/types/chat";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { usePersistence } from "@/hooks/use-persistence";
|
||||||
import {
|
import {
|
||||||
getEventIdsFromSearchObjectsToolCalls,
|
getEventIdsFromToolCalls,
|
||||||
getFindSimilarObjectsFromToolCalls,
|
getFindSimilarObjectsFromToolCalls,
|
||||||
prependAttachment,
|
prependAttachment,
|
||||||
streamChatCompletion,
|
streamChatCompletion,
|
||||||
@@ -40,6 +43,13 @@ const hasText = (content: unknown): content is string =>
|
|||||||
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
|
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
|
||||||
messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest);
|
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() {
|
export default function ChatPage() {
|
||||||
const { t } = useTranslation(["views/chat"]);
|
const { t } = useTranslation(["views/chat"]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@@ -48,6 +58,21 @@ export default function ChatPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [attachedEventId, setAttachedEventId] = 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>(
|
const [showStats, setShowStats] = usePersistence<ShowStatsMode>(
|
||||||
"chat-show-stats",
|
"chat-show-stats",
|
||||||
"while_generating",
|
"while_generating",
|
||||||
@@ -62,6 +87,7 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
const { data: genaiInfo } = useSWR<GenAIModelsResponse>("genai/models", {
|
const { data: genaiInfo } = useSWR<GenAIModelsResponse>("genai/models", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@@ -92,14 +118,27 @@ export default function ChatPage() {
|
|||||||
}, [messages, streaming, autoScroll]);
|
}, [messages, streaming, autoScroll]);
|
||||||
|
|
||||||
const submitConversation = useCallback(
|
const submitConversation = useCallback(
|
||||||
async (messagesToSend: ChatMessage[]) => {
|
async function submit(
|
||||||
if (isLoading) return;
|
messagesToSend: ChatMessage[],
|
||||||
|
resume?: ResumeOptions,
|
||||||
|
) {
|
||||||
|
if (loadingRef.current) return;
|
||||||
const last = messagesToSend[messagesToSend.length - 1];
|
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);
|
setError(null);
|
||||||
|
setPendingApprovals(null);
|
||||||
|
setApprovalDecisions({});
|
||||||
setMessages(messagesToSend);
|
setMessages(messagesToSend);
|
||||||
setStreaming({ content: "", reasoning: "", chain: [] });
|
setStreaming({ content: "", reasoning: "", chain: [] });
|
||||||
|
loadingRef.current = true;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const baseURL = axios.defaults.baseURL ?? "";
|
const baseURL = axios.defaults.baseURL ?? "";
|
||||||
@@ -116,6 +155,7 @@ export default function ChatPage() {
|
|||||||
let stats: ChatStats | undefined;
|
let stats: ChatStats | undefined;
|
||||||
let reasoning = "";
|
let reasoning = "";
|
||||||
let hadError = false;
|
let hadError = false;
|
||||||
|
let approvals: PendingToolCall[] | null = null;
|
||||||
|
|
||||||
await streamChatCompletion(
|
await streamChatCompletion(
|
||||||
url,
|
url,
|
||||||
@@ -138,32 +178,99 @@ export default function ChatPage() {
|
|||||||
stats = s;
|
stats = s;
|
||||||
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
|
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
|
||||||
},
|
},
|
||||||
|
onApprovalRequired: (toolCalls) => {
|
||||||
|
approvals = toolCalls;
|
||||||
|
},
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
setError(message);
|
setError(message);
|
||||||
},
|
},
|
||||||
onDone: () => {
|
onDone: () => {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setStreaming(null);
|
setStreaming(null);
|
||||||
const lastMsg = chain[chain.length - 1];
|
const lastMsg = chain[chain.length - 1];
|
||||||
if (!hadError && lastMsg?.role === "assistant") {
|
if (!hadError && lastMsg?.role === "assistant") {
|
||||||
setMessages(
|
const committed = chain.map((m, i) =>
|
||||||
chain.map((m, i) =>
|
i === chain.length - 1
|
||||||
i === chain.length - 1
|
? { ...m, reasoning: reasoning || undefined, stats }
|
||||||
? { ...m, reasoning: reasoning || undefined, stats }
|
: m,
|
||||||
: 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"),
|
defaultErrorMessage: t("error"),
|
||||||
},
|
},
|
||||||
controller.signal,
|
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(() => {
|
const recentEventIds = useMemo(() => {
|
||||||
@@ -174,7 +281,7 @@ export default function ChatPage() {
|
|||||||
const calls = toolCallsForMessage(msg, responses);
|
const calls = toolCallsForMessage(msg, responses);
|
||||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||||
if (similar) return similar.results.map((e) => e.id);
|
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);
|
if (events.length > 0) return events.map((e) => e.id);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -197,19 +304,25 @@ export default function ChatPage() {
|
|||||||
const stopGeneration = useCallback(() => {
|
const stopGeneration = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setStreaming(null);
|
setStreaming(null);
|
||||||
|
setPendingApprovals(null);
|
||||||
|
setApprovalDecisions({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startNewChat = useCallback(() => {
|
const startNewChat = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setStreaming(null);
|
setStreaming(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInput("");
|
setInput("");
|
||||||
setAttachedEventId(null);
|
setAttachedEventId(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setPendingApprovals(null);
|
||||||
|
setApprovalDecisions({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditSubmit = useCallback(
|
const handleEditSubmit = useCallback(
|
||||||
@@ -260,7 +373,7 @@ export default function ChatPage() {
|
|||||||
const calls = toolCallsForMessage(msg, responses);
|
const calls = toolCallsForMessage(msg, responses);
|
||||||
const contentText = hasText(msg.content) ? msg.content : "";
|
const contentText = hasText(msg.content) ? msg.content : "";
|
||||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||||
const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls);
|
const events = similar ? [] : getEventIdsFromToolCalls(calls);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex flex-col gap-2">
|
<div key={i} className="flex flex-col gap-2">
|
||||||
@@ -324,6 +437,8 @@ export default function ChatPage() {
|
|||||||
setShowStats={setShowStats}
|
setShowStats={setShowStats}
|
||||||
autoScroll={autoScroll ?? true}
|
autoScroll={autoScroll ?? true}
|
||||||
setAutoScroll={setAutoScroll}
|
setAutoScroll={setAutoScroll}
|
||||||
|
alwaysAllowTools={alwaysAllowTools ?? NO_TOOLS}
|
||||||
|
clearAlwaysAllowTools={clearAlwaysAllowTools}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -335,6 +450,20 @@ export default function ChatPage() {
|
|||||||
{hasStarted ? (
|
{hasStarted ? (
|
||||||
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
||||||
{renderList.map((msg, i) => renderMessage(msg, i))}
|
{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 &&
|
{streaming &&
|
||||||
!finalShown &&
|
!finalShown &&
|
||||||
(streaming.content || streaming.reasoning ? (
|
(streaming.content || streaming.reasoning ? (
|
||||||
@@ -391,7 +520,12 @@ export default function ChatPage() {
|
|||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
placeholder={t("placeholder")}
|
disabled={pendingApprovals != null}
|
||||||
|
placeholder={
|
||||||
|
pendingApprovals != null
|
||||||
|
? t("approval.placeholder")
|
||||||
|
: t("placeholder")
|
||||||
|
}
|
||||||
attachedEventId={attachedEventId}
|
attachedEventId={attachedEventId}
|
||||||
onClearAttachment={handleClearAttachment}
|
onClearAttachment={handleClearAttachment}
|
||||||
onAttach={setAttachedEventId}
|
onAttach={setAttachedEventId}
|
||||||
|
|||||||
@@ -20,11 +20,21 @@ export type ChatMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ToolCall = {
|
export type ToolCall = {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
arguments?: Record<string, unknown>;
|
arguments?: Record<string, unknown>;
|
||||||
response?: string;
|
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 = {
|
export type StartingRequest = {
|
||||||
label: string;
|
label: string;
|
||||||
prompt: 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 = {
|
export type StreamChatCallbacks = {
|
||||||
/** Streamed delta of the assistant's final answer text. */
|
/** Streamed delta of the assistant's final answer text. */
|
||||||
@@ -11,6 +17,10 @@ export type StreamChatCallbacks = {
|
|||||||
onChain: (chain: ChatMessage[]) => void;
|
onChain: (chain: ChatMessage[]) => void;
|
||||||
/** Token/timing stats for the turn. */
|
/** Token/timing stats for the turn. */
|
||||||
onStats: (stats: ChatStats) => void;
|
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. */
|
/** Called when the stream sends an error or fetch fails. */
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
/** Called when the stream finishes (success or error). */
|
/** Called when the stream finishes (success or error). */
|
||||||
@@ -30,6 +40,7 @@ type StatsChunk = {
|
|||||||
type StreamChunk =
|
type StreamChunk =
|
||||||
| { type: "error"; error: string }
|
| { type: "error"; error: string }
|
||||||
| { type: "messages"; messages: ChatMessage[] }
|
| { type: "messages"; messages: ChatMessage[] }
|
||||||
|
| { type: "approval_required"; tool_calls: PendingToolCall[] }
|
||||||
| { type: "content"; delta: string }
|
| { type: "content"; delta: string }
|
||||||
| { type: "reasoning"; delta: string }
|
| { type: "reasoning"; delta: string }
|
||||||
| StatsChunk;
|
| StatsChunk;
|
||||||
@@ -40,6 +51,8 @@ type StreamChunk =
|
|||||||
*/
|
*/
|
||||||
export type StreamChatOptions = {
|
export type StreamChatOptions = {
|
||||||
enableThinking?: boolean;
|
enableThinking?: boolean;
|
||||||
|
/** Decisions for tool calls that paused for approval, keyed by call id. */
|
||||||
|
toolDecisions?: Record<string, ToolDecision>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function streamChatCompletion(
|
export async function streamChatCompletion(
|
||||||
@@ -55,6 +68,7 @@ export async function streamChatCompletion(
|
|||||||
onReasoningDelta,
|
onReasoningDelta,
|
||||||
onChain,
|
onChain,
|
||||||
onStats,
|
onStats,
|
||||||
|
onApprovalRequired,
|
||||||
onError,
|
onError,
|
||||||
onDone,
|
onDone,
|
||||||
defaultErrorMessage = "Something went wrong. Please try again.",
|
defaultErrorMessage = "Something went wrong. Please try again.",
|
||||||
@@ -68,6 +82,9 @@ export async function streamChatCompletion(
|
|||||||
if (options.enableThinking !== undefined) {
|
if (options.enableThinking !== undefined) {
|
||||||
body.enable_thinking = options.enableThinking;
|
body.enable_thinking = options.enableThinking;
|
||||||
}
|
}
|
||||||
|
if (options.toolDecisions && Object.keys(options.toolDecisions).length) {
|
||||||
|
body.tool_decisions = options.toolDecisions;
|
||||||
|
}
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
@@ -103,6 +120,10 @@ export async function streamChatCompletion(
|
|||||||
onChain(data.messages ?? []);
|
onChain(data.messages ?? []);
|
||||||
return "continue";
|
return "continue";
|
||||||
}
|
}
|
||||||
|
if (data.type === "approval_required") {
|
||||||
|
onApprovalRequired?.(data.tool_calls ?? []);
|
||||||
|
return "continue";
|
||||||
|
}
|
||||||
if (data.type === "content" && data.delta !== undefined) {
|
if (data.type === "content" && data.delta !== undefined) {
|
||||||
onContentDelta(data.delta);
|
onContentDelta(data.delta);
|
||||||
return "continue";
|
return "continue";
|
||||||
@@ -198,6 +219,7 @@ export function toolCallsForMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
id: tc.id,
|
||||||
name: tc.function?.name ?? "",
|
name: tc.function?.name ?? "",
|
||||||
arguments: args,
|
arguments: args,
|
||||||
response: responses.get(tc.id),
|
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,
|
toolCalls: ToolCall[] | undefined,
|
||||||
): { id: string }[] {
|
): { id: string }[] {
|
||||||
if (!toolCalls?.length) return [];
|
if (!toolCalls?.length) return [];
|
||||||
const results: { id: string }[] = [];
|
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) {
|
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 {
|
try {
|
||||||
const parsed = JSON.parse(tc.response) as unknown;
|
const parsed = JSON.parse(tc.response) as unknown;
|
||||||
if (!Array.isArray(parsed)) continue;
|
if (Array.isArray(parsed)) {
|
||||||
for (const item of parsed) {
|
parsed.forEach(push);
|
||||||
if (
|
} else {
|
||||||
item &&
|
push(parsed);
|
||||||
typeof item === "object" &&
|
|
||||||
"id" in item &&
|
|
||||||
typeof (item as { id: unknown }).id === "string"
|
|
||||||
) {
|
|
||||||
results.push({ id: (item as { id: string }).id });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
|
|||||||
Reference in New Issue
Block a user