diff --git a/frigate/api/chat.py b/frigate/api/chat.py index 415f422da..7957ab7af 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -3,12 +3,13 @@ import base64 import json import logging -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +import time +from datetime import datetime +from typing import Any, Dict, Generator, List, Optional import cv2 from fastapi import APIRouter, Body, Depends, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel from frigate.api.auth import ( @@ -20,15 +21,60 @@ from frigate.api.defs.request.chat_body import ChatCompletionRequest from frigate.api.defs.response.chat_response import ( ChatCompletionResponse, ChatMessageResponse, + ToolCall, ) from frigate.api.defs.tags import Tags from frigate.api.event import events +from frigate.genai.utils import build_assistant_message_for_conversation logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.chat]) +def _chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, None]: + """Yield content in word-aware chunks for streaming.""" + if not content: + return + words = content.split(" ") + current: List[str] = [] + current_len = 0 + for w in words: + current.append(w) + current_len += len(w) + 1 + if current_len >= chunk_size: + yield " ".join(current) + " " + current = [] + current_len = 0 + if current: + yield " ".join(current) + + +def _format_events_with_local_time( + events_list: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Add human-readable local start/end times to each event for the LLM.""" + result = [] + for evt in events_list: + if not isinstance(evt, dict): + result.append(evt) + continue + copy_evt = dict(evt) + try: + start_ts = evt.get("start_time") + end_ts = evt.get("end_time") + if start_ts is not None: + dt_start = datetime.fromtimestamp(start_ts) + copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p") + if end_ts is not None: + dt_end = datetime.fromtimestamp(end_ts) + copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p") + except (TypeError, ValueError, OSError): + pass + result.append(copy_evt) + return result + + class ToolExecuteRequest(BaseModel): """Request model for tool execution.""" @@ -52,19 +98,25 @@ def get_tool_definitions() -> List[Dict[str, Any]]: "Search for detected objects in Frigate by camera, object label, time range, " "zones, and other filters. Use this to answer questions about when " "objects were detected, what objects appeared, or to find specific object detections. " - "An 'object' in Frigate represents a tracked detection (e.g., a person, package, car)." + "An 'object' in Frigate represents a tracked detection (e.g., a person, package, car). " + "When the user asks about a specific name (person, delivery company, animal, etc.), " + "filter by sub_label only and do not set label." ), "parameters": { "type": "object", "properties": { "camera": { "type": "string", - "description": "Camera name to filter by (optional). Use 'all' for all cameras.", + "description": "Camera name to filter by (optional).", }, "label": { "type": "string", "description": "Object label to filter by (e.g., 'person', 'package', 'car').", }, + "sub_label": { + "type": "string", + "description": "Name of a person, delivery company, animal, etc. When filtering by a specific name, use only sub_label; do not set label.", + }, "after": { "type": "string", "description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').", @@ -80,8 +132,8 @@ def get_tool_definitions() -> List[Dict[str, Any]]: }, "limit": { "type": "integer", - "description": "Maximum number of objects to return (default: 10).", - "default": 10, + "description": "Maximum number of objects to return (default: 25).", + "default": 25, }, }, }, @@ -119,14 +171,13 @@ def get_tool_definitions() -> List[Dict[str, Any]]: summary="Get available tools", description="Returns OpenAI-compatible tool definitions for function calling.", ) -def get_tools(request: Request) -> JSONResponse: +def get_tools() -> JSONResponse: """Get list of available tools for LLM function calling.""" tools = get_tool_definitions() return JSONResponse(content={"tools": tools}) async def _execute_search_objects( - request: Request, arguments: Dict[str, Any], allowed_cameras: List[str], ) -> JSONResponse: @@ -136,23 +187,26 @@ async def _execute_search_objects( This searches for detected objects (events) in Frigate using the same logic as the events API endpoint. """ - # Parse ISO 8601 timestamps to Unix timestamps if provided + # Parse after/before as server local time; convert to Unix timestamp after = arguments.get("after") before = arguments.get("before") + def _parse_as_local_timestamp(s: str): + s = s.replace("Z", "").strip()[:19] + dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S") + return time.mktime(dt.timetuple()) + if after: try: - after_dt = datetime.fromisoformat(after.replace("Z", "+00:00")) - after = after_dt.timestamp() - except (ValueError, AttributeError): + after = _parse_as_local_timestamp(after) + except (ValueError, AttributeError, TypeError): logger.warning(f"Invalid 'after' timestamp format: {after}") after = None if before: try: - before_dt = datetime.fromisoformat(before.replace("Z", "+00:00")) - before = before_dt.timestamp() - except (ValueError, AttributeError): + before = _parse_as_local_timestamp(before) + except (ValueError, AttributeError, TypeError): logger.warning(f"Invalid 'before' timestamp format: {before}") before = None @@ -165,15 +219,14 @@ async def _execute_search_objects( # Build query parameters compatible with EventsQueryParams query_params = EventsQueryParams( - camera=arguments.get("camera", "all"), cameras=arguments.get("camera", "all"), - label=arguments.get("label", "all"), labels=arguments.get("label", "all"), + sub_labels=arguments.get("sub_label", "all").lower(), zones=zones, zone=zones, after=after, before=before, - limit=arguments.get("limit", 10), + limit=arguments.get("limit", 25), ) try: @@ -202,7 +255,6 @@ async def _execute_search_objects( description="Execute a tool function call from an LLM.", ) async def execute_tool( - request: Request, body: ToolExecuteRequest = Body(...), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), ) -> JSONResponse: @@ -218,7 +270,7 @@ async def execute_tool( logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}") if tool_name == "search_objects": - return await _execute_search_objects(request, arguments, allowed_cameras) + return await _execute_search_objects(arguments, allowed_cameras) return JSONResponse( content={ @@ -334,7 +386,7 @@ async def _execute_tool_internal( This is used by the chat completion endpoint to execute tools. """ if tool_name == "search_objects": - response = await _execute_search_objects(request, arguments, allowed_cameras) + response = await _execute_search_objects(arguments, allowed_cameras) try: if hasattr(response, "body"): body_str = response.body.decode("utf-8") @@ -349,15 +401,109 @@ async def _execute_tool_internal( elif tool_name == "get_live_context": camera = arguments.get("camera") if not camera: + logger.error( + "Tool get_live_context failed: camera parameter is required. " + "Arguments: %s", + json.dumps(arguments), + ) return {"error": "Camera parameter is required"} return await _execute_get_live_context(request, camera, allowed_cameras) else: + logger.error( + "Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context. " + "Arguments received: %s", + tool_name, + json.dumps(arguments), + ) return {"error": f"Unknown tool: {tool_name}"} +async def _execute_pending_tools( + pending_tool_calls: List[Dict[str, Any]], + request: Request, + allowed_cameras: List[str], +) -> tuple[List[ToolCall], List[Dict[str, Any]]]: + """ + Execute a list of tool calls; return (ToolCall list for API response, tool result dicts for conversation). + """ + tool_calls_out: List[ToolCall] = [] + tool_results: List[Dict[str, Any]] = [] + for tool_call in pending_tool_calls: + tool_name = tool_call["name"] + tool_args = tool_call.get("arguments") or {} + tool_call_id = tool_call["id"] + logger.debug( + f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}" + ) + try: + tool_result = await _execute_tool_internal( + tool_name, tool_args, request, allowed_cameras + ) + if isinstance(tool_result, dict) and tool_result.get("error"): + logger.error( + "Tool call %s (id: %s) returned error: %s. Arguments: %s", + tool_name, + tool_call_id, + tool_result.get("error"), + json.dumps(tool_args), + ) + if tool_name == "search_objects" and isinstance(tool_result, list): + tool_result = _format_events_with_local_time(tool_result) + _keys = { + "id", + "camera", + "label", + "zones", + "start_time_local", + "end_time_local", + "sub_label", + "event_count", + } + tool_result = [ + {k: evt[k] for k in _keys if k in evt} + for evt in tool_result + if isinstance(evt, dict) + ] + result_content = ( + json.dumps(tool_result) + if isinstance(tool_result, (dict, list)) + else (tool_result if isinstance(tool_result, str) else str(tool_result)) + ) + tool_calls_out.append( + ToolCall(name=tool_name, arguments=tool_args, response=result_content) + ) + tool_results.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": result_content, + } + ) + except Exception as e: + logger.error( + "Error executing tool %s (id: %s): %s. Arguments: %s", + tool_name, + tool_call_id, + e, + json.dumps(tool_args), + exc_info=True, + ) + error_content = json.dumps({"error": f"Tool execution failed: {str(e)}"}) + tool_calls_out.append( + ToolCall(name=tool_name, arguments=tool_args, response=error_content) + ) + tool_results.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": error_content, + } + ) + return (tool_calls_out, tool_results) + + @router.post( "/chat/completion", - response_model=ChatCompletionResponse, dependencies=[Depends(allow_any_authenticated())], summary="Chat completion with tool calling", description=( @@ -369,7 +515,7 @@ async def chat_completion( request: Request, body: ChatCompletionRequest = Body(...), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), -) -> JSONResponse: +): """ Chat completion endpoint with tool calling support. @@ -394,9 +540,9 @@ async def chat_completion( tools = get_tool_definitions() conversation = [] - current_datetime = datetime.now(timezone.utc) + current_datetime = datetime.now() current_date_str = current_datetime.strftime("%Y-%m-%d") - current_time_str = current_datetime.strftime("%H:%M:%S %Z") + current_time_str = current_datetime.strftime("%I:%M:%S %p") cameras_info = [] config = request.app.frigate_config @@ -429,9 +575,12 @@ async def chat_completion( system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events. -Current date and time: {current_date_str} at {current_time_str} (UTC) +Current server local date and time: {current_date_str} at {current_time_str} -When users ask questions about "today", "yesterday", "this week", etc., use the current date above as reference. +Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly. + +Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields. +When users ask about "today", "yesterday", "this week", etc., use the current date above as reference. When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today). Always be accurate with time calculations based on the current date provided.{cameras_section}{live_image_note}""" @@ -471,6 +620,7 @@ Always be accurate with time calculations based on the current date provided.{ca conversation.append(msg_dict) tool_iterations = 0 + tool_calls: List[ToolCall] = [] max_iterations = body.max_tool_iterations logger.debug( @@ -478,6 +628,81 @@ Always be accurate with time calculations based on the current date provided.{ca f"{len(tools)} tool(s) available, max_iterations={max_iterations}" ) + # True LLM streaming when client supports it and stream requested + if body.stream and hasattr(genai_client, "chat_with_tools_stream"): + stream_tool_calls: List[ToolCall] = [] + stream_iterations = 0 + + async def stream_body_llm(): + nonlocal conversation, stream_tool_calls, stream_iterations + while stream_iterations < max_iterations: + logger.debug( + f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) " + f"with {len(conversation)} message(s)" + ) + async for event in genai_client.chat_with_tools_stream( + messages=conversation, + tools=tools if tools else None, + tool_choice="auto", + ): + kind, value = event + if kind == "content_delta": + yield ( + json.dumps({"type": "content", "delta": value}).encode( + "utf-8" + ) + + b"\n" + ) + elif kind == "message": + msg = value + if msg.get("finish_reason") == "error": + yield ( + json.dumps( + { + "type": "error", + "error": "An error occurred while processing your request.", + } + ).encode("utf-8") + + b"\n" + ) + return + pending = msg.get("tool_calls") + if pending: + stream_iterations += 1 + conversation.append( + build_assistant_message_for_conversation( + msg.get("content"), pending + ) + ) + executed_calls, tool_results = await _execute_pending_tools( + pending, request, allowed_cameras + ) + stream_tool_calls.extend(executed_calls) + conversation.extend(tool_results) + yield ( + json.dumps( + { + "type": "tool_calls", + "tool_calls": [ + tc.model_dump() for tc in stream_tool_calls + ], + } + ).encode("utf-8") + + b"\n" + ) + break + else: + yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n") + return + else: + yield json.dumps({"type": "done"}).encode("utf-8") + b"\n" + + return StreamingResponse( + stream_body_llm(), + media_type="application/x-ndjson", + headers={"X-Accel-Buffering": "no"}, + ) + try: while tool_iterations < max_iterations: logger.debug( @@ -499,117 +724,71 @@ Always be accurate with time calculations based on the current date provided.{ca status_code=500, ) - assistant_message = { - "role": "assistant", - "content": response.get("content"), - } - if response.get("tool_calls"): - assistant_message["tool_calls"] = [ - { - "id": tc["id"], - "type": "function", - "function": { - "name": tc["name"], - "arguments": json.dumps(tc["arguments"]), - }, - } - for tc in response["tool_calls"] - ] - conversation.append(assistant_message) + conversation.append( + build_assistant_message_for_conversation( + response.get("content"), response.get("tool_calls") + ) + ) - tool_calls = response.get("tool_calls") - if not tool_calls: + pending_tool_calls = response.get("tool_calls") + if not pending_tool_calls: logger.debug( f"Chat completion finished with final answer (iterations: {tool_iterations})" ) + final_content = response.get("content") or "" + + if body.stream: + + async def stream_body() -> Any: + if tool_calls: + yield ( + json.dumps( + { + "type": "tool_calls", + "tool_calls": [ + tc.model_dump() for tc in tool_calls + ], + } + ).encode("utf-8") + + b"\n" + ) + # Stream content in word-sized chunks for smooth UX + for part in _chunk_content(final_content): + yield ( + json.dumps({"type": "content", "delta": part}).encode( + "utf-8" + ) + + b"\n" + ) + yield json.dumps({"type": "done"}).encode("utf-8") + b"\n" + + return StreamingResponse( + stream_body(), + media_type="application/x-ndjson", + ) + return JSONResponse( content=ChatCompletionResponse( message=ChatMessageResponse( role="assistant", - content=response.get("content"), + content=final_content, tool_calls=None, ), finish_reason=response.get("finish_reason", "stop"), tool_iterations=tool_iterations, + tool_calls=tool_calls, ).model_dump(), ) - # Execute tools tool_iterations += 1 logger.debug( f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): " - f"{len(tool_calls)} tool(s) to execute" + f"{len(pending_tool_calls)} tool(s) to execute" ) - tool_results = [] - - for tool_call in tool_calls: - tool_name = tool_call["name"] - tool_args = tool_call["arguments"] - tool_call_id = tool_call["id"] - - logger.debug( - f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}" - ) - - try: - tool_result = await _execute_tool_internal( - tool_name, tool_args, request, allowed_cameras - ) - - if isinstance(tool_result, dict): - result_content = json.dumps(tool_result) - result_summary = tool_result - if isinstance(tool_result, dict) and isinstance( - tool_result.get("content"), list - ): - result_count = len(tool_result.get("content", [])) - result_summary = { - "count": result_count, - "sample": tool_result.get("content", [])[:2] - if result_count > 0 - else [], - } - logger.debug( - f"Tool {tool_name} (id: {tool_call_id}) completed successfully. " - f"Result: {json.dumps(result_summary, indent=2)}" - ) - elif isinstance(tool_result, str): - result_content = tool_result - logger.debug( - f"Tool {tool_name} (id: {tool_call_id}) completed successfully. " - f"Result length: {len(result_content)} characters" - ) - else: - result_content = str(tool_result) - logger.debug( - f"Tool {tool_name} (id: {tool_call_id}) completed successfully. " - f"Result type: {type(tool_result).__name__}" - ) - - tool_results.append( - { - "role": "tool", - "tool_call_id": tool_call_id, - "content": result_content, - } - ) - except Exception as e: - logger.error( - f"Error executing tool {tool_name} (id: {tool_call_id}): {e}", - exc_info=True, - ) - error_content = json.dumps({"error": "Tool execution failed"}) - tool_results.append( - { - "role": "tool", - "tool_call_id": tool_call_id, - "content": error_content, - } - ) - logger.debug( - f"Tool {tool_name} (id: {tool_call_id}) failed. Error result added to conversation." - ) - + executed_calls, tool_results = await _execute_pending_tools( + pending_tool_calls, request, allowed_cameras + ) + tool_calls.extend(executed_calls) conversation.extend(tool_results) logger.debug( f"Added {len(tool_results)} tool result(s) to conversation. " @@ -628,6 +807,7 @@ Always be accurate with time calculations based on the current date provided.{ca ), finish_reason="length", tool_iterations=tool_iterations, + tool_calls=tool_calls, ).model_dump(), ) diff --git a/frigate/api/defs/request/chat_body.py b/frigate/api/defs/request/chat_body.py index fa3c3860a..3a67cd038 100644 --- a/frigate/api/defs/request/chat_body.py +++ b/frigate/api/defs/request/chat_body.py @@ -39,3 +39,7 @@ class ChatCompletionRequest(BaseModel): "user message as multimodal content. Use with get_live_context for detection info." ), ) + stream: bool = Field( + default=False, + description="If true, stream the final assistant response in the body as newline-delimited JSON.", + ) diff --git a/frigate/api/defs/response/chat_response.py b/frigate/api/defs/response/chat_response.py index f1cc9194b..0bc864ba6 100644 --- a/frigate/api/defs/response/chat_response.py +++ b/frigate/api/defs/response/chat_response.py @@ -5,8 +5,8 @@ from typing import Any, Optional from pydantic import BaseModel, Field -class ToolCall(BaseModel): - """A tool call from the LLM.""" +class ToolCallInvocation(BaseModel): + """A tool call requested by the LLM (before execution).""" id: str = Field(description="Unique identifier for this tool call") name: str = Field(description="Tool name to call") @@ -20,11 +20,24 @@ class ChatMessageResponse(BaseModel): content: Optional[str] = Field( default=None, description="Message content (None if tool calls present)" ) - tool_calls: Optional[list[ToolCall]] = Field( + tool_calls: Optional[list[ToolCallInvocation]] = Field( default=None, description="Tool calls if LLM wants to call tools" ) +class ToolCall(BaseModel): + """A tool that was executed during the completion, with its response.""" + + name: str = Field(description="Tool name that was called") + arguments: dict[str, Any] = Field( + default_factory=dict, description="Arguments passed to the tool" + ) + response: str = Field( + default="", + description="The response or result returned from the tool execution", + ) + + class ChatCompletionResponse(BaseModel): """Response from chat completion.""" @@ -35,3 +48,7 @@ class ChatCompletionResponse(BaseModel): tool_iterations: int = Field( default=0, description="Number of tool call iterations performed" ) + tool_calls: list[ToolCall] = Field( + default_factory=list, + description="List of tool calls that were executed during this completion", + ) diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py index 70a94eec5..69b4cac28 100644 --- a/frigate/genai/llama_cpp.py +++ b/frigate/genai/llama_cpp.py @@ -5,10 +5,12 @@ import json import logging from typing import Any, Optional +import httpx import requests from frigate.config import GenAIProviderEnum from frigate.genai import GenAIClient, register_genai_provider +from frigate.genai.utils import parse_tool_calls_from_message logger = logging.getLogger(__name__) @@ -100,7 +102,79 @@ class LlamaCppClient(GenAIClient): def get_context_size(self) -> int: """Get the context window size for llama.cpp.""" - return self.genai_config.provider_options.get("context_size", 4096) + return self.provider_options.get("context_size", 4096) + + def _build_payload( + self, + messages: list[dict[str, Any]], + tools: Optional[list[dict[str, Any]]], + tool_choice: Optional[str], + stream: bool = False, + ) -> dict[str, Any]: + """Build request payload for chat completions (sync or stream).""" + openai_tool_choice = None + if tool_choice: + if tool_choice == "none": + openai_tool_choice = "none" + elif tool_choice == "auto": + openai_tool_choice = "auto" + elif tool_choice == "required": + openai_tool_choice = "required" + + payload: dict[str, Any] = { + "messages": messages, + "model": self.genai_config.model, + } + if stream: + payload["stream"] = True + if tools: + payload["tools"] = tools + if openai_tool_choice is not None: + payload["tool_choice"] = openai_tool_choice + provider_opts = { + k: v for k, v in self.provider_options.items() if k != "context_size" + } + payload.update(provider_opts) + return payload + + def _message_from_choice(self, choice: dict[str, Any]) -> dict[str, Any]: + """Parse OpenAI-style choice into {content, tool_calls, finish_reason}.""" + message = choice.get("message", {}) + content = message.get("content") + content = content.strip() if content else None + tool_calls = parse_tool_calls_from_message(message) + finish_reason = choice.get("finish_reason") or ( + "tool_calls" if tool_calls else "stop" if content else "error" + ) + return { + "content": content, + "tool_calls": tool_calls, + "finish_reason": finish_reason, + } + + @staticmethod + def _streamed_tool_calls_to_list( + tool_calls_by_index: dict[int, dict[str, Any]], + ) -> Optional[list[dict[str, Any]]]: + """Convert streamed tool_calls index map to list of {id, name, arguments}.""" + if not tool_calls_by_index: + return None + result = [] + for idx in sorted(tool_calls_by_index.keys()): + t = tool_calls_by_index[idx] + args_str = t.get("arguments") or "{}" + try: + arguments = json.loads(args_str) + except json.JSONDecodeError: + arguments = {} + result.append( + { + "id": t.get("id", ""), + "name": t.get("name", ""), + "arguments": arguments, + } + ) + return result if result else None def chat_with_tools( self, @@ -123,32 +197,8 @@ class LlamaCppClient(GenAIClient): "tool_calls": None, "finish_reason": "error", } - try: - openai_tool_choice = None - if tool_choice: - if tool_choice == "none": - openai_tool_choice = "none" - elif tool_choice == "auto": - openai_tool_choice = "auto" - elif tool_choice == "required": - openai_tool_choice = "required" - - payload = { - "model": self.genai_config.model, - "messages": messages, - } - - if tools: - payload["tools"] = tools - if openai_tool_choice is not None: - payload["tool_choice"] = openai_tool_choice - - provider_opts = { - k: v for k, v in self.provider_options.items() if k != "context_size" - } - payload.update(provider_opts) - + payload = self._build_payload(messages, tools, tool_choice, stream=False) response = requests.post( f"{self.provider}/v1/chat/completions", json=payload, @@ -156,60 +206,13 @@ class LlamaCppClient(GenAIClient): ) response.raise_for_status() result = response.json() - if result is None or "choices" not in result or len(result["choices"]) == 0: return { "content": None, "tool_calls": None, "finish_reason": "error", } - - choice = result["choices"][0] - message = choice.get("message", {}) - - content = message.get("content") - if content: - content = content.strip() - else: - content = None - - tool_calls = None - if "tool_calls" in message and message["tool_calls"]: - tool_calls = [] - for tool_call in message["tool_calls"]: - try: - function_data = tool_call.get("function", {}) - arguments_str = function_data.get("arguments", "{}") - arguments = json.loads(arguments_str) - except (json.JSONDecodeError, KeyError, TypeError) as e: - logger.warning( - f"Failed to parse tool call arguments: {e}, " - f"tool: {function_data.get('name', 'unknown')}" - ) - arguments = {} - - tool_calls.append( - { - "id": tool_call.get("id", ""), - "name": function_data.get("name", ""), - "arguments": arguments, - } - ) - - finish_reason = "error" - if "finish_reason" in choice and choice["finish_reason"]: - finish_reason = choice["finish_reason"] - elif tool_calls: - finish_reason = "tool_calls" - elif content: - finish_reason = "stop" - - return { - "content": content, - "tool_calls": tool_calls, - "finish_reason": finish_reason, - } - + return self._message_from_choice(result["choices"][0]) except requests.exceptions.Timeout as e: logger.warning("llama.cpp request timed out: %s", str(e)) return { @@ -221,8 +224,7 @@ class LlamaCppClient(GenAIClient): error_detail = str(e) if hasattr(e, "response") and e.response is not None: try: - error_body = e.response.text - error_detail = f"{str(e)} - Response: {error_body[:500]}" + error_detail = f"{str(e)} - Response: {e.response.text[:500]}" except Exception: pass logger.warning("llama.cpp returned an error: %s", error_detail) @@ -238,3 +240,111 @@ class LlamaCppClient(GenAIClient): "tool_calls": None, "finish_reason": "error", } + + async def chat_with_tools_stream( + self, + messages: list[dict[str, Any]], + tools: Optional[list[dict[str, Any]]] = None, + tool_choice: Optional[str] = "auto", + ): + """Stream chat with tools via OpenAI-compatible streaming API.""" + if self.provider is None: + logger.warning( + "llama.cpp provider has not been initialized. Check your llama.cpp configuration." + ) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + return + try: + payload = self._build_payload(messages, tools, tool_choice, stream=True) + content_parts: list[str] = [] + tool_calls_by_index: dict[int, dict[str, Any]] = {} + finish_reason = "stop" + + async with httpx.AsyncClient(timeout=float(self.timeout)) as client: + async with client.stream( + "POST", + f"{self.provider}/v1/chat/completions", + json=payload, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data: "): + continue + data_str = line[6:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + except json.JSONDecodeError: + continue + choices = data.get("choices") or [] + if not choices: + continue + delta = choices[0].get("delta", {}) + if choices[0].get("finish_reason"): + finish_reason = choices[0]["finish_reason"] + if delta.get("content"): + content_parts.append(delta["content"]) + yield ("content_delta", delta["content"]) + for tc in delta.get("tool_calls") or []: + idx = tc.get("index", 0) + fn = tc.get("function") or {} + if idx not in tool_calls_by_index: + tool_calls_by_index[idx] = { + "id": tc.get("id", ""), + "name": tc.get("name") or fn.get("name", ""), + "arguments": "", + } + t = tool_calls_by_index[idx] + if tc.get("id"): + t["id"] = tc["id"] + name = tc.get("name") or fn.get("name") + if name: + t["name"] = name + arg = tc.get("arguments") or fn.get("arguments") + if arg is not None: + t["arguments"] += ( + arg if isinstance(arg, str) else json.dumps(arg) + ) + + full_content = "".join(content_parts).strip() or None + tool_calls_list = self._streamed_tool_calls_to_list(tool_calls_by_index) + if tool_calls_list: + finish_reason = "tool_calls" + yield ( + "message", + { + "content": full_content, + "tool_calls": tool_calls_list, + "finish_reason": finish_reason, + }, + ) + except httpx.HTTPStatusError as e: + logger.warning("llama.cpp streaming HTTP error: %s", e) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + except Exception as e: + logger.warning( + "Unexpected error in llama.cpp chat_with_tools_stream: %s", str(e) + ) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) diff --git a/frigate/genai/ollama.py b/frigate/genai/ollama.py index 6e9a4f5d5..4efedf64a 100644 --- a/frigate/genai/ollama.py +++ b/frigate/genai/ollama.py @@ -1,15 +1,16 @@ """Ollama Provider for Frigate AI.""" -import json import logging from typing import Any, Optional from httpx import RemoteProtocolError, TimeoutException +from ollama import AsyncClient as OllamaAsyncClient from ollama import Client as ApiClient from ollama import ResponseError from frigate.config import GenAIProviderEnum from frigate.genai import GenAIClient, register_genai_provider +from frigate.genai.utils import parse_tool_calls_from_message logger = logging.getLogger(__name__) @@ -88,6 +89,73 @@ class OllamaClient(GenAIClient): "num_ctx", 4096 ) + def _build_request_params( + self, + messages: list[dict[str, Any]], + tools: Optional[list[dict[str, Any]]], + tool_choice: Optional[str], + stream: bool = False, + ) -> dict[str, Any]: + """Build request_messages and params for chat (sync or stream).""" + request_messages = [] + for msg in messages: + msg_dict = { + "role": msg.get("role"), + "content": msg.get("content", ""), + } + if msg.get("tool_call_id"): + msg_dict["tool_call_id"] = msg["tool_call_id"] + if msg.get("name"): + msg_dict["name"] = msg["name"] + if msg.get("tool_calls"): + msg_dict["tool_calls"] = msg["tool_calls"] + request_messages.append(msg_dict) + + request_params: dict[str, Any] = { + "model": self.genai_config.model, + "messages": request_messages, + **self.provider_options, + } + if stream: + request_params["stream"] = True + if tools: + request_params["tools"] = tools + if tool_choice: + request_params["tool_choice"] = ( + "none" + if tool_choice == "none" + else "required" + if tool_choice == "required" + else "auto" + ) + return request_params + + def _message_from_response(self, response: dict[str, Any]) -> dict[str, Any]: + """Parse Ollama chat response into {content, tool_calls, finish_reason}.""" + if not response or "message" not in response: + return { + "content": None, + "tool_calls": None, + "finish_reason": "error", + } + message = response["message"] + content = message.get("content", "").strip() if message.get("content") else None + tool_calls = parse_tool_calls_from_message(message) + finish_reason = "error" + if response.get("done"): + finish_reason = ( + "tool_calls" if tool_calls else "stop" if content else "error" + ) + elif tool_calls: + finish_reason = "tool_calls" + elif content: + finish_reason = "stop" + return { + "content": content, + "tool_calls": tool_calls, + "finish_reason": finish_reason, + } + def chat_with_tools( self, messages: list[dict[str, Any]], @@ -103,93 +171,12 @@ class OllamaClient(GenAIClient): "tool_calls": None, "finish_reason": "error", } - try: - request_messages = [] - for msg in messages: - msg_dict = { - "role": msg.get("role"), - "content": msg.get("content", ""), - } - if msg.get("tool_call_id"): - msg_dict["tool_call_id"] = msg["tool_call_id"] - if msg.get("name"): - msg_dict["name"] = msg["name"] - if msg.get("tool_calls"): - msg_dict["tool_calls"] = msg["tool_calls"] - request_messages.append(msg_dict) - - request_params = { - "model": self.genai_config.model, - "messages": request_messages, - } - - if tools: - request_params["tools"] = tools - if tool_choice: - if tool_choice == "none": - request_params["tool_choice"] = "none" - elif tool_choice == "required": - request_params["tool_choice"] = "required" - elif tool_choice == "auto": - request_params["tool_choice"] = "auto" - - request_params.update(self.provider_options) - - response = self.provider.chat(**request_params) - - if not response or "message" not in response: - return { - "content": None, - "tool_calls": None, - "finish_reason": "error", - } - - message = response["message"] - content = ( - message.get("content", "").strip() if message.get("content") else None + request_params = self._build_request_params( + messages, tools, tool_choice, stream=False ) - - tool_calls = None - if "tool_calls" in message and message["tool_calls"]: - tool_calls = [] - for tool_call in message["tool_calls"]: - try: - function_data = tool_call.get("function", {}) - arguments_str = function_data.get("arguments", "{}") - arguments = json.loads(arguments_str) - except (json.JSONDecodeError, KeyError, TypeError) as e: - logger.warning( - f"Failed to parse tool call arguments: {e}, " - f"tool: {function_data.get('name', 'unknown')}" - ) - arguments = {} - - tool_calls.append( - { - "id": tool_call.get("id", ""), - "name": function_data.get("name", ""), - "arguments": arguments, - } - ) - - finish_reason = "error" - if "done" in response and response["done"]: - if tool_calls: - finish_reason = "tool_calls" - elif content: - finish_reason = "stop" - elif tool_calls: - finish_reason = "tool_calls" - elif content: - finish_reason = "stop" - - return { - "content": content, - "tool_calls": tool_calls, - "finish_reason": finish_reason, - } - + response = self.provider.chat(**request_params) + return self._message_from_response(response) except (TimeoutException, ResponseError, ConnectionError) as e: logger.warning("Ollama returned an error: %s", str(e)) return { @@ -204,3 +191,89 @@ class OllamaClient(GenAIClient): "tool_calls": None, "finish_reason": "error", } + + async def chat_with_tools_stream( + self, + messages: list[dict[str, Any]], + tools: Optional[list[dict[str, Any]]] = None, + tool_choice: Optional[str] = "auto", + ): + """Stream chat with tools; yields content deltas then final message.""" + if self.provider is None: + logger.warning( + "Ollama provider has not been initialized. Check your Ollama configuration." + ) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + return + try: + request_params = self._build_request_params( + messages, tools, tool_choice, stream=True + ) + async_client = OllamaAsyncClient( + host=self.genai_config.base_url, + timeout=self.timeout, + ) + content_parts: list[str] = [] + final_message: dict[str, Any] | None = None + try: + stream = await async_client.chat(**request_params) + async for chunk in stream: + if not chunk or "message" not in chunk: + continue + msg = chunk.get("message", {}) + delta = msg.get("content") or "" + if delta: + content_parts.append(delta) + yield ("content_delta", delta) + if chunk.get("done"): + full_content = "".join(content_parts).strip() or None + tool_calls = parse_tool_calls_from_message(msg) + final_message = { + "content": full_content, + "tool_calls": tool_calls, + "finish_reason": "tool_calls" if tool_calls else "stop", + } + break + finally: + await async_client.close() + + if final_message is not None: + yield ("message", final_message) + else: + yield ( + "message", + { + "content": "".join(content_parts).strip() or None, + "tool_calls": None, + "finish_reason": "stop", + }, + ) + except (TimeoutException, ResponseError, ConnectionError) as e: + logger.warning("Ollama streaming error: %s", str(e)) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) + except Exception as e: + logger.warning( + "Unexpected error in Ollama chat_with_tools_stream: %s", str(e) + ) + yield ( + "message", + { + "content": None, + "tool_calls": None, + "finish_reason": "error", + }, + ) diff --git a/frigate/genai/utils.py b/frigate/genai/utils.py new file mode 100644 index 000000000..93d4552b9 --- /dev/null +++ b/frigate/genai/utils.py @@ -0,0 +1,70 @@ +"""Shared helpers for GenAI providers and chat (OpenAI-style messages, tool call parsing).""" + +import json +import logging +from typing import Any, List, Optional + +logger = logging.getLogger(__name__) + + +def parse_tool_calls_from_message( + message: dict[str, Any], +) -> Optional[list[dict[str, Any]]]: + """ + Parse tool_calls from an OpenAI-style message dict. + + Message may have "tool_calls" as a list of: + {"id": str, "function": {"name": str, "arguments": str}, ...} + + Returns a list of {"id", "name", "arguments"} with arguments parsed as dict, + or None if no tool_calls. Used by Ollama and LlamaCpp (non-stream) responses. + """ + raw = message.get("tool_calls") + if not raw or not isinstance(raw, list): + return None + result = [] + for tool_call in raw: + function_data = tool_call.get("function") or {} + try: + arguments_str = function_data.get("arguments") or "{}" + arguments = json.loads(arguments_str) + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.warning( + "Failed to parse tool call arguments: %s, tool: %s", + e, + function_data.get("name", "unknown"), + ) + arguments = {} + result.append( + { + "id": tool_call.get("id", ""), + "name": function_data.get("name", ""), + "arguments": arguments, + } + ) + return result if result else None + + +def build_assistant_message_for_conversation( + content: Any, + tool_calls_raw: Optional[List[dict[str, Any]]], +) -> dict[str, Any]: + """ + Build the assistant message dict in OpenAI format for appending to a conversation. + + tool_calls_raw: list of {"id", "name", "arguments"} (arguments as dict), or None. + """ + msg: dict[str, Any] = {"role": "assistant", "content": content} + if tool_calls_raw: + msg["tool_calls"] = [ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": json.dumps(tc.get("arguments") or {}), + }, + } + for tc in tool_calls_raw + ] + return msg diff --git a/web/package-lock.json b/web/package-lock.json index e0e36bc8a..9f7382839 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -68,6 +68,7 @@ "react-i18next": "^15.2.0", "react-icons": "^5.5.0", "react-konva": "^18.2.10", + "react-markdown": "^9.0.1", "react-router-dom": "^6.30.3", "react-swipeable": "^7.0.2", "react-tracked": "^2.0.1", @@ -75,6 +76,7 @@ "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "3.4.4", "recoil": "^0.7.7", + "remark-gfm": "^4.0.0", "scroll-into-view-if-needed": "^3.1.0", "sonner": "^1.5.0", "sort-by": "^1.2.0", @@ -5124,11 +5126,19 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -5137,6 +5147,24 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.12", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz", @@ -5144,6 +5172,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.14.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", @@ -5236,6 +5279,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.12.0.tgz", @@ -5460,7 +5509,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react-swc": { @@ -5903,6 +5951,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6068,6 +6126,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -6101,6 +6169,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -6616,6 +6724,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -6752,7 +6870,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6772,6 +6889,19 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -6906,6 +7036,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -7415,6 +7558,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -7445,6 +7598,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fake-indexeddb": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.0.0.tgz", @@ -7928,6 +8087,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/headers-polyfill": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.2.tgz", @@ -7977,6 +8176,16 @@ "void-elements": "3.1.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -8119,6 +8328,36 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -8141,6 +8380,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -8184,6 +8433,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -8226,6 +8485,18 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8590,6 +8861,16 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8662,6 +8943,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8671,6 +8962,288 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8685,6 +9258,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", @@ -8862,7 +9998,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -9220,6 +10355,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -9645,6 +10805,16 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-compare": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz", @@ -9917,6 +11087,33 @@ "react-dom": ">=18.0.0" } }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-reconciler": { "version": "0.29.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz", @@ -10171,6 +11368,72 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10568,6 +11831,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -10637,6 +11910,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10700,6 +11987,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", @@ -11183,6 +12488,26 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -11275,6 +12600,93 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -11446,6 +12858,34 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/virtua": { "version": "0.39.3", "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.39.3.tgz", @@ -12012,6 +13452,16 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/web/package.json b/web/package.json index b412bb695..32d27ab5e 100644 --- a/web/package.json +++ b/web/package.json @@ -75,6 +75,8 @@ "react-icons": "^5.5.0", "react-konva": "^18.2.10", "react-router-dom": "^6.30.3", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0", "react-swipeable": "^7.0.2", "react-tracked": "^2.0.1", "react-transition-group": "^4.4.5", diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 9dec7b048..1181554af 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -129,6 +129,7 @@ "cancel": "Cancel", "close": "Close", "copy": "Copy", + "copiedToClipboard": "Copied to clipboard", "back": "Back", "history": "History", "fullscreen": "Fullscreen", @@ -254,6 +255,7 @@ "uiPlayground": "UI Playground", "faceLibrary": "Face Library", "classification": "Classification", + "chat": "Chat", "user": { "title": "User", "account": "Account", diff --git a/web/public/locales/en/views/chat.json b/web/public/locales/en/views/chat.json new file mode 100644 index 000000000..ec9e65e6e --- /dev/null +++ b/web/public/locales/en/views/chat.json @@ -0,0 +1,24 @@ +{ + "title": "Frigate Chat", + "subtitle": "Your AI assistant for camera management and insights", + "placeholder": "Ask anything...", + "error": "Something went wrong. Please try again.", + "processing": "Processing...", + "toolsUsed": "Used: {{tools}}", + "showTools": "Show tools ({{count}})", + "hideTools": "Hide tools", + "call": "Call", + "result": "Result", + "arguments": "Arguments:", + "response": "Response:", + "send": "Send", + "suggested_requests": "Try asking:", + "starting_requests": { + "show_recent_events": "Show recent events", + "show_camera_status": "Show camera status" + }, + "starting_requests_prompts": { + "show_recent_events": "Show me the recent events from the last hour", + "show_camera_status": "What is the current status of my cameras?" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index d7a9ec3e9..82ca2b1e0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -27,6 +27,7 @@ const Settings = lazy(() => import("@/pages/Settings")); const UIPlayground = lazy(() => import("@/pages/UIPlayground")); const FaceLibrary = lazy(() => import("@/pages/FaceLibrary")); const Classification = lazy(() => import("@/pages/ClassificationModel")); +const Chat = lazy(() => import("@/pages/Chat")); const Logs = lazy(() => import("@/pages/Logs")); const AccessDenied = lazy(() => import("@/pages/AccessDenied")); @@ -106,6 +107,7 @@ function DefaultAppView() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/components/chat/ChatEventThumbnailsRow.tsx b/web/src/components/chat/ChatEventThumbnailsRow.tsx new file mode 100644 index 000000000..bf2c5e88f --- /dev/null +++ b/web/src/components/chat/ChatEventThumbnailsRow.tsx @@ -0,0 +1,42 @@ +import { useApiHost } from "@/api"; + +type ChatEventThumbnailsRowProps = { + events: { id: string }[]; +}; + +/** + * Horizontal scroll row of event thumbnail images for chat (e.g. after search_objects). + * Renders nothing when events is empty. + */ +export function ChatEventThumbnailsRow({ + events, +}: ChatEventThumbnailsRowProps) { + const apiHost = useApiHost(); + + if (events.length === 0) return null; + + return ( +
+
+
+ {events.map((event) => ( + + + + ))} +
+
+
+ ); +} diff --git a/web/src/components/chat/ChatMessage.tsx b/web/src/components/chat/ChatMessage.tsx new file mode 100644 index 000000000..a644a9d7d --- /dev/null +++ b/web/src/components/chat/ChatMessage.tsx @@ -0,0 +1,208 @@ +import { useState, useEffect, useRef } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { useTranslation } from "react-i18next"; +import copy from "copy-to-clipboard"; +import { toast } from "sonner"; +import { FaCopy, FaPencilAlt } from "react-icons/fa"; +import { FaArrowUpLong } from "react-icons/fa6"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +type MessageBubbleProps = { + role: "user" | "assistant"; + content: string; + messageIndex?: number; + onEditSubmit?: (messageIndex: number, newContent: string) => void; + isComplete?: boolean; +}; + +export function MessageBubble({ + role, + content, + messageIndex = 0, + onEditSubmit, + isComplete = true, +}: MessageBubbleProps) { + const { t } = useTranslation(["views/chat", "common"]); + const isUser = role === "user"; + const [isEditing, setIsEditing] = useState(false); + const [draftContent, setDraftContent] = useState(content); + const editInputRef = useRef(null); + + useEffect(() => { + setDraftContent(content); + }, [content]); + + useEffect(() => { + if (isEditing) { + editInputRef.current?.focus(); + editInputRef.current?.setSelectionRange( + editInputRef.current.value.length, + editInputRef.current.value.length, + ); + } + }, [isEditing]); + + const handleCopy = () => { + const text = content?.trim() || ""; + if (!text) return; + if (copy(text)) { + toast.success(t("button.copiedToClipboard", { ns: "common" })); + } + }; + + const handleEditClick = () => { + setDraftContent(content); + setIsEditing(true); + }; + + const handleEditSubmit = () => { + const trimmed = draftContent.trim(); + if (!trimmed || onEditSubmit == null) return; + onEditSubmit(messageIndex, trimmed); + setIsEditing(false); + }; + + const handleEditCancel = () => { + setDraftContent(content); + setIsEditing(false); + }; + + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleEditSubmit(); + } + if (e.key === "Escape") { + handleEditCancel(); + } + }; + + if (isUser && isEditing) { + return ( +
+