mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-01 17:41:13 +03:00
Compare commits
4 Commits
fb182533d1
...
0884abb402
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0884abb402 | ||
|
|
66a2417229 | ||
|
|
555ef89800 | ||
|
|
01c82d6921 |
@ -1173,6 +1173,7 @@ async def chat_completion(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
@ -1267,6 +1268,7 @@ async def chat_completion(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
)
|
||||
|
||||
if response.get("finish_reason") == "error":
|
||||
|
||||
@ -36,3 +36,10 @@ class ChatCompletionRequest(BaseModel):
|
||||
default=False,
|
||||
description="If true, stream the final assistant response in the body as newline-delimited JSON.",
|
||||
)
|
||||
enable_thinking: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Per-request thinking toggle. None means use the provider default. "
|
||||
"Ignored by providers that do not expose a per-request thinking switch."
|
||||
),
|
||||
)
|
||||
|
||||
@ -169,6 +169,7 @@ class DebugReplayManager:
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||
frigate_config.cameras[replay_name],
|
||||
)
|
||||
frigate_config.cameras.pop(replay_name, None)
|
||||
|
||||
if replay_name is not None:
|
||||
self._cleanup_db(replay_name)
|
||||
|
||||
@ -98,10 +98,17 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.detect,
|
||||
CameraConfigUpdateEnum.face_recognition,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
CameraConfigUpdateEnum.lpr,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.object_genai,
|
||||
CameraConfigUpdateEnum.review,
|
||||
CameraConfigUpdateEnum.review_genai,
|
||||
CameraConfigUpdateEnum.semantic_search,
|
||||
CameraConfigUpdateEnum.zones,
|
||||
],
|
||||
)
|
||||
self.enrichment_config_subscriber = ConfigSubscriber("config/")
|
||||
|
||||
@ -222,8 +222,15 @@ class GenAIClient:
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to the provider."""
|
||||
"""Submit a request to the provider.
|
||||
|
||||
``enable_thinking`` is honored only by providers that report
|
||||
``supports_toggleable_thinking``. Description-style callers leave it
|
||||
at the default (off) since synthesis tasks don't benefit from
|
||||
reasoning traces.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
@ -235,6 +242,11 @@ class GenAIClient:
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_toggleable_thinking(self) -> bool:
|
||||
"""Whether the configured model exposes a per-request thinking toggle."""
|
||||
return False
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return the list of model names available from this provider.
|
||||
|
||||
@ -278,6 +290,7 @@ class GenAIClient:
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send chat messages to LLM with optional tool definitions.
|
||||
@ -301,7 +314,9 @@ class GenAIClient:
|
||||
- 'none': Model must not call tools
|
||||
- 'required': Model must call at least one tool
|
||||
- Or a dict specifying a specific tool to call
|
||||
**kwargs: Additional provider-specific parameters.
|
||||
enable_thinking: Per-request thinking toggle. None means use the
|
||||
provider default. Ignored by providers without a per-request
|
||||
toggle (see `supports_toggleable_thinking`).
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
|
||||
@ -6,7 +6,7 @@ no chat feature is active) are never initialized.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.genai import GenAIConfig, GenAIRoleEnum
|
||||
@ -108,11 +108,16 @@ class GenAIClientManager:
|
||||
name = self._role_map.get(GenAIRoleEnum.embeddings)
|
||||
return self._get_client(name) if name else None
|
||||
|
||||
def list_models(self) -> dict[str, list[str]]:
|
||||
"""Return available models keyed by config entry name."""
|
||||
result: dict[str, list[str]] = {}
|
||||
for name in self._configs:
|
||||
def list_models(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return per-entry model lists and capabilities, keyed by config entry name."""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for name, genai_cfg in self._configs.items():
|
||||
client = self._get_client(name)
|
||||
if client:
|
||||
result[name] = client.list_models()
|
||||
if not client:
|
||||
continue
|
||||
result[name] = {
|
||||
"models": client.list_models(),
|
||||
"roles": [r.value for r in genai_cfg.roles],
|
||||
"supports_toggleable_thinking": client.supports_toggleable_thinking,
|
||||
}
|
||||
return result
|
||||
|
||||
@ -62,6 +62,7 @@ class GeminiClient(GenAIClient):
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to Gemini."""
|
||||
contents = [prompt] + [
|
||||
@ -119,11 +120,14 @@ class GeminiClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send chat messages to Gemini with optional tool definitions.
|
||||
|
||||
Implements function calling/tool usage for Gemini models.
|
||||
Implements function calling/tool usage for Gemini models. Thinking is
|
||||
configured at the model level for Gemini, so ``enable_thinking`` is
|
||||
accepted for interface parity and ignored.
|
||||
"""
|
||||
try:
|
||||
# Convert messages to Gemini format
|
||||
|
||||
@ -122,6 +122,7 @@ class LlamaCppClient(GenAIClient):
|
||||
_supports_vision: bool
|
||||
_supports_audio: bool
|
||||
_supports_tools: bool
|
||||
_supports_reasoning: bool
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_text_baseline_tokens: int | None
|
||||
_media_marker: str
|
||||
@ -135,6 +136,7 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_vision = False
|
||||
self._supports_audio = False
|
||||
self._supports_tools = False
|
||||
self._supports_reasoning = False
|
||||
self._image_token_cache = {}
|
||||
self._text_baseline_tokens = None
|
||||
self._media_marker = "<__media__>"
|
||||
@ -164,15 +166,17 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_vision = info["supports_vision"]
|
||||
self._supports_audio = info["supports_audio"]
|
||||
self._supports_tools = info["supports_tools"]
|
||||
self._supports_reasoning = info["supports_reasoning"]
|
||||
self._media_marker = info["media_marker"]
|
||||
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||
configured_model,
|
||||
self._context_size or "unknown",
|
||||
self._supports_vision,
|
||||
self._supports_audio,
|
||||
self._supports_tools,
|
||||
self._supports_reasoning,
|
||||
)
|
||||
|
||||
return base_url
|
||||
@ -200,6 +204,7 @@ class LlamaCppClient(GenAIClient):
|
||||
"supports_vision": False,
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
|
||||
@ -279,10 +284,17 @@ class LlamaCppClient(GenAIClient):
|
||||
info["supports_vision"] = bool(modalities.get("vision", False))
|
||||
info["supports_audio"] = bool(modalities.get("audio", False))
|
||||
|
||||
chat_caps = props.get("chat_template_caps") or {}
|
||||
|
||||
if not info["supports_tools"]:
|
||||
chat_caps = props.get("chat_template_caps", {})
|
||||
info["supports_tools"] = bool(chat_caps.get("supports_tools", False))
|
||||
|
||||
# llama.cpp does not advertise per-template reasoning support, so
|
||||
# detect it by looking for the `enable_thinking` toggle variable
|
||||
# in the Jinja chat template itself.
|
||||
chat_template = props.get("chat_template") or ""
|
||||
info["supports_reasoning"] = "enable_thinking" in chat_template
|
||||
|
||||
media_marker = props.get("media_marker")
|
||||
if isinstance(media_marker, str) and media_marker:
|
||||
info["media_marker"] = media_marker
|
||||
@ -300,6 +312,7 @@ class LlamaCppClient(GenAIClient):
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to llama.cpp server."""
|
||||
if self.provider is None:
|
||||
@ -327,7 +340,7 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
|
||||
# Build request payload with llama.cpp native options
|
||||
payload = {
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": [
|
||||
{
|
||||
@ -341,6 +354,9 @@ class LlamaCppClient(GenAIClient):
|
||||
if response_format:
|
||||
payload["response_format"] = response_format
|
||||
|
||||
if self.supports_toggleable_thinking:
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
@ -377,6 +393,10 @@ class LlamaCppClient(GenAIClient):
|
||||
"""Whether the loaded model supports tool/function calling."""
|
||||
return self._supports_tools
|
||||
|
||||
@property
|
||||
def supports_toggleable_thinking(self) -> bool:
|
||||
return self._supports_reasoning
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model IDs from the llama.cpp server."""
|
||||
base_url = self.provider or (
|
||||
@ -504,6 +524,7 @@ class LlamaCppClient(GenAIClient):
|
||||
tools: Optional[list[dict[str, Any]]],
|
||||
tool_choice: Optional[str],
|
||||
stream: bool = False,
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build request payload for chat completions (sync or stream)."""
|
||||
openai_tool_choice = None
|
||||
@ -519,14 +540,21 @@ class LlamaCppClient(GenAIClient):
|
||||
"messages": messages,
|
||||
"model": self.genai_config.model,
|
||||
}
|
||||
|
||||
if stream:
|
||||
payload["stream"] = True
|
||||
payload["stream_options"] = {"include_usage": True}
|
||||
payload["timings_per_token"] = True
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
if openai_tool_choice is not None:
|
||||
payload["tool_choice"] = openai_tool_choice
|
||||
|
||||
if enable_thinking is not None and self._supports_reasoning:
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
|
||||
|
||||
provider_opts = {
|
||||
k: v for k, v in self.provider_options.items() if k != "context_size"
|
||||
}
|
||||
@ -732,6 +760,7 @@ class LlamaCppClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send chat messages to llama.cpp server with optional tool definitions.
|
||||
@ -749,7 +778,13 @@ class LlamaCppClient(GenAIClient):
|
||||
"finish_reason": "error",
|
||||
}
|
||||
try:
|
||||
payload = self._build_payload(messages, tools, tool_choice, stream=False)
|
||||
payload = self._build_payload(
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=False,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
response = requests.post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
@ -797,6 +832,7 @@ class LlamaCppClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Stream chat with tools via OpenAI-compatible streaming API."""
|
||||
if self.provider is None:
|
||||
@ -813,7 +849,13 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
return
|
||||
try:
|
||||
payload = self._build_payload(messages, tools, tool_choice, stream=True)
|
||||
payload = self._build_payload(
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=True,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
|
||||
@ -98,6 +98,22 @@ class OllamaClient(GenAIClient):
|
||||
|
||||
provider: ApiClient | None
|
||||
provider_options: dict[str, Any]
|
||||
_supports_thinking_cache: Optional[bool] = None
|
||||
|
||||
@property
|
||||
def supports_toggleable_thinking(self) -> bool:
|
||||
if self._supports_thinking_cache is not None:
|
||||
return self._supports_thinking_cache
|
||||
if self.provider is None:
|
||||
return False
|
||||
try:
|
||||
response = self.provider.show(self.genai_config.model)
|
||||
capabilities = response.get("capabilities") or []
|
||||
self._supports_thinking_cache = "thinking" in capabilities
|
||||
except Exception as e:
|
||||
logger.debug("Failed to query Ollama model capabilities: %s", e)
|
||||
self._supports_thinking_cache = False
|
||||
return self._supports_thinking_cache
|
||||
|
||||
def _auth_headers(self) -> dict | None:
|
||||
if self.genai_config.api_key:
|
||||
@ -178,6 +194,7 @@ class OllamaClient(GenAIClient):
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to Ollama"""
|
||||
if self.provider is None:
|
||||
@ -194,6 +211,8 @@ class OllamaClient(GenAIClient):
|
||||
schema = response_format.get("json_schema", {}).get("schema")
|
||||
if schema:
|
||||
ollama_options["format"] = self._clean_schema_for_ollama(schema)
|
||||
if self.supports_toggleable_thinking:
|
||||
ollama_options["think"] = enable_thinking
|
||||
logger.debug(
|
||||
"Ollama generate request: model=%s, prompt_len=%s, image_count=%s, "
|
||||
"has_format=%s, options=%s",
|
||||
@ -274,6 +293,7 @@ class OllamaClient(GenAIClient):
|
||||
tools: Optional[list[dict[str, Any]]],
|
||||
tool_choice: Optional[str],
|
||||
stream: bool = False,
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build request_messages and params for chat (sync or stream)."""
|
||||
request_messages = []
|
||||
@ -318,6 +338,8 @@ class OllamaClient(GenAIClient):
|
||||
request_params["stream"] = True
|
||||
if tools:
|
||||
request_params["tools"] = tools
|
||||
if enable_thinking is not None and self.supports_toggleable_thinking:
|
||||
request_params["think"] = enable_thinking
|
||||
return request_params
|
||||
|
||||
def _message_from_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
@ -365,6 +387,7 @@ class OllamaClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
@ -377,7 +400,11 @@ class OllamaClient(GenAIClient):
|
||||
}
|
||||
try:
|
||||
request_params = self._build_request_params(
|
||||
messages, tools, tool_choice, stream=False
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=False,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
response = self.provider.chat(**request_params)
|
||||
return self._message_from_response(response)
|
||||
@ -401,6 +428,7 @@ class OllamaClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
@ -430,7 +458,11 @@ class OllamaClient(GenAIClient):
|
||||
"Ollama: tools provided, using non-streaming call for tool support"
|
||||
)
|
||||
request_params = self._build_request_params(
|
||||
messages, tools, tool_choice, stream=False
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=False,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
async_client = OllamaAsyncClient(
|
||||
host=self.genai_config.base_url,
|
||||
@ -452,7 +484,11 @@ class OllamaClient(GenAIClient):
|
||||
return
|
||||
|
||||
request_params = self._build_request_params(
|
||||
messages, tools, tool_choice, stream=True
|
||||
messages,
|
||||
tools,
|
||||
tool_choice,
|
||||
stream=True,
|
||||
enable_thinking=enable_thinking,
|
||||
)
|
||||
async_client = OllamaAsyncClient(
|
||||
host=self.genai_config.base_url,
|
||||
|
||||
@ -61,6 +61,7 @@ class OpenAIClient(GenAIClient):
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
enable_thinking: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to OpenAI."""
|
||||
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
|
||||
@ -187,11 +188,14 @@ class OpenAIClient(GenAIClient):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
enable_thinking: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Send chat messages to OpenAI with optional tool definitions.
|
||||
|
||||
Implements function calling/tool usage for OpenAI models.
|
||||
Implements function calling/tool usage for OpenAI models. The OpenAI
|
||||
chat completions API does not expose a per-request thinking toggle,
|
||||
so ``enable_thinking`` is accepted for interface parity and ignored.
|
||||
"""
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
|
||||
@ -63,8 +63,8 @@ Describe the scene based on observable actions and movements, evaluate the activ
|
||||
## Analysis Guidelines
|
||||
|
||||
When forming your description:
|
||||
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
|
||||
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
|
||||
- **Treat "Objects in Scene" as the list of tracked subjects to describe.** Do not introduce additional people or vehicles that are not present in this list. You may freely reference other items, surfaces, and environmental details visible in the frames when describing what the listed subjects are doing.
|
||||
- **Describe the most likely activity from visible cues across the sequence** — the subject's path, what they are carrying, and what they interact with. Avoid asserting completed outcomes you do not observe; describe in-progress actions rather than results.
|
||||
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
|
||||
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
|
||||
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
|
||||
|
||||
@ -65,5 +65,8 @@
|
||||
"active": "Reasoning…",
|
||||
"show": "Show reasoning",
|
||||
"hide": "Hide reasoning"
|
||||
},
|
||||
"thinking": {
|
||||
"toggle": "Toggle thinking"
|
||||
}
|
||||
}
|
||||
|
||||
147
web/src/components/chat/ChatComposer.tsx
Normal file
147
web/src/components/chat/ChatComposer.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FaArrowUpLong, FaStop } from "react-icons/fa6";
|
||||
import { LuBrain } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies";
|
||||
import { ChatPaperclipButton } from "@/components/chat/ChatPaperclipButton";
|
||||
|
||||
type ChatComposerProps = {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
sendMessage: (textOverride?: string) => void;
|
||||
placeholder: string;
|
||||
|
||||
supportsThinking: boolean;
|
||||
thinkingEnabled: boolean;
|
||||
setThinkingEnabled: (value: boolean | undefined) => void;
|
||||
|
||||
isLoading?: boolean;
|
||||
onStop?: () => void;
|
||||
|
||||
attachedEventId?: string | null;
|
||||
onClearAttachment?: () => void;
|
||||
onAttach?: (eventId: string) => void;
|
||||
recentEventIds?: string[];
|
||||
|
||||
large?: boolean;
|
||||
};
|
||||
|
||||
export function ChatComposer({
|
||||
input,
|
||||
setInput,
|
||||
sendMessage,
|
||||
placeholder,
|
||||
supportsThinking,
|
||||
thinkingEnabled,
|
||||
setThinkingEnabled,
|
||||
isLoading = false,
|
||||
onStop,
|
||||
attachedEventId,
|
||||
onClearAttachment,
|
||||
onAttach,
|
||||
recentEventIds,
|
||||
large = false,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const showPaperclip = !!onAttach;
|
||||
const showStop = isLoading && !!onStop;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
{attachedEventId && onClearAttachment && (
|
||||
<div className="flex items-center">
|
||||
<ChatAttachmentChip
|
||||
eventId={attachedEventId}
|
||||
mode="composer"
|
||||
onRemove={onClearAttachment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{attachedEventId && (
|
||||
<ChatQuickReplies
|
||||
onSend={(text) => sendMessage(text)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-row items-center gap-2">
|
||||
{showPaperclip && (
|
||||
<ChatPaperclipButton
|
||||
recentEventIds={recentEventIds ?? []}
|
||||
onAttach={onAttach!}
|
||||
disabled={isLoading || attachedEventId != null}
|
||||
/>
|
||||
)}
|
||||
{supportsThinking && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={thinkingEnabled ? "select" : "ghost"}
|
||||
aria-pressed={thinkingEnabled}
|
||||
aria-label={t("thinking.toggle")}
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full p-0",
|
||||
!thinkingEnabled && "text-secondary-foreground",
|
||||
)}
|
||||
onClick={() => setThinkingEnabled(!thinkingEnabled)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuBrain className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("thinking.toggle")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Input
|
||||
className={cn(
|
||||
"w-full flex-1 border-transparent bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
large && "h-12 text-base",
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-busy={isLoading}
|
||||
/>
|
||||
{showStop ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
onClick={onStop}
|
||||
>
|
||||
<FaStop className="size-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="select"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={() => sendMessage()}
|
||||
>
|
||||
<FaArrowUpLong className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,15 +1,22 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FaArrowUpLong } from "react-icons/fa6";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import type { StartingRequest } from "@/types/chat";
|
||||
import { ChatComposer } from "@/components/chat/ChatComposer";
|
||||
|
||||
type ChatStartingStateProps = {
|
||||
onSendMessage: (message: string) => void;
|
||||
supportsThinking: boolean;
|
||||
thinkingEnabled: boolean;
|
||||
setThinkingEnabled: (value: boolean | undefined) => void;
|
||||
};
|
||||
|
||||
export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
|
||||
export function ChatStartingState({
|
||||
onSendMessage,
|
||||
supportsThinking,
|
||||
thinkingEnabled,
|
||||
setThinkingEnabled,
|
||||
}: ChatStartingStateProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
@ -36,20 +43,13 @@ export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
|
||||
onSendMessage(prompt);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const text = input.trim();
|
||||
const handleSend = (textOverride?: string) => {
|
||||
const text = (textOverride ?? input).trim();
|
||||
if (!text) return;
|
||||
onSendMessage(text);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-6 p-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@ -77,22 +77,17 @@ export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-2xl flex-row items-center gap-2 rounded-xl bg-secondary p-3">
|
||||
<Input
|
||||
className="h-12 w-full flex-1 border-transparent bg-transparent text-base shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||
<div className="w-full max-w-2xl">
|
||||
<ChatComposer
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
sendMessage={handleSend}
|
||||
placeholder={t("placeholder")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
supportsThinking={supportsThinking}
|
||||
thinkingEnabled={thinkingEnabled}
|
||||
setThinkingEnabled={setThinkingEnabled}
|
||||
large
|
||||
/>
|
||||
<Button
|
||||
variant="select"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
disabled={!input.trim()}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<FaArrowUpLong size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -8,6 +8,12 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type ReasoningBubbleProps = {
|
||||
/** The accumulated reasoning text from the model. */
|
||||
@ -54,34 +60,42 @@ export function ReasoningBubble({
|
||||
|
||||
return (
|
||||
<div className="self-start rounded-2xl bg-muted/60 px-3 py-2 text-muted-foreground">
|
||||
<Collapsible open={open} onOpenChange={handleOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto w-full min-w-0 justify-start gap-2 whitespace-normal p-0 text-left text-xs hover:bg-transparent"
|
||||
>
|
||||
<LuBrain
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
!answerStarted && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
<span className="break-words font-medium">{label}</span>
|
||||
{answerStarted &&
|
||||
(open ? (
|
||||
<LuChevronDown className="ml-auto size-3 shrink-0" />
|
||||
) : (
|
||||
<LuChevronRight className="ml-auto size-3 shrink-0" />
|
||||
))}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="scrollbar-container mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-sans text-xs leading-relaxed">
|
||||
{reasoning}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<TooltipProvider>
|
||||
<Collapsible open={open} onOpenChange={handleOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto w-full min-w-0 justify-start gap-2 whitespace-normal p-0 text-left text-xs hover:bg-transparent"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<LuBrain
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
!answerStarted && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
{answerStarted &&
|
||||
(open ? (
|
||||
<LuChevronDown className="ml-auto size-3 shrink-0" />
|
||||
) : (
|
||||
<LuChevronRight className="ml-auto size-3 shrink-0" />
|
||||
))}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="scrollbar-container mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-sans text-xs leading-relaxed">
|
||||
{reasoning}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import Konva from "konva";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { useApiHost } from "@/api";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -67,6 +68,7 @@ export default function Step2StateArea({
|
||||
([name, cam]) =>
|
||||
cam.enabled &&
|
||||
cam.enabled_in_config &&
|
||||
!isReplayCamera(name) &&
|
||||
!selectedCameraNames.includes(name),
|
||||
)
|
||||
.map(([name]) => ({
|
||||
|
||||
@ -57,6 +57,7 @@ import isEqual from "lodash/isEqual";
|
||||
import set from "lodash/set";
|
||||
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
|
||||
import { sanitizeSectionData } from "@/utils/configUtil";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import type { SectionRendererProps } from "./registry";
|
||||
|
||||
const NOTIFICATION_SERVICE_WORKER = "/notifications-worker.js";
|
||||
@ -94,7 +95,7 @@ export default function NotificationsSettingsExtras({
|
||||
|
||||
return Object.values(config.cameras)
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order)
|
||||
.filter((c) => c.enabled_in_config);
|
||||
.filter((c) => c.enabled_in_config && !isReplayCamera(c.name));
|
||||
}, [config]);
|
||||
|
||||
const notificationCameras = useMemo(() => {
|
||||
@ -106,6 +107,7 @@ export default function NotificationsSettingsExtras({
|
||||
.filter(
|
||||
(conf) =>
|
||||
conf.enabled_in_config &&
|
||||
!isReplayCamera(conf.name) &&
|
||||
conf.notifications &&
|
||||
conf.notifications.enabled_in_config,
|
||||
)
|
||||
@ -359,6 +361,7 @@ export default function NotificationsSettingsExtras({
|
||||
Object.values(config.cameras).some(
|
||||
(c) =>
|
||||
c.enabled_in_config &&
|
||||
!isReplayCamera(c.name) &&
|
||||
c.notifications &&
|
||||
c.notifications.enabled_in_config,
|
||||
),
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||
import type { GenAIModelsResponse } from "@/types/chat";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
type ProbeResponse =
|
||||
@ -73,11 +74,12 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
return `${e.provider ?? ""}|${e.base_url ?? ""}`;
|
||||
}, [providerKey, formContext?.fullConfig]);
|
||||
|
||||
const { data: allModels, mutate: mutateModels } = useSWR<
|
||||
Record<string, string[]>
|
||||
>("genai/models", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: allModels, mutate: mutateModels } = useSWR<GenAIModelsResponse>(
|
||||
"genai/models",
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Revalidate models when the saved config fingerprint changes (e.g. after
|
||||
// switching provider or base_url and saving).
|
||||
@ -89,9 +91,9 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
}
|
||||
}, [configFingerprint, mutateModels]);
|
||||
|
||||
const fetchedModels = useMemo(() => {
|
||||
const fetchedModels = useMemo<string[]>(() => {
|
||||
if (!allModels || !providerKey) return [];
|
||||
return allModels[providerKey] ?? [];
|
||||
return allModels[providerKey]?.models ?? [];
|
||||
}, [allModels, providerKey]);
|
||||
|
||||
const [probeStatus, setProbeStatus] = useState<ProbeStatus>("idle");
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@ -52,7 +53,9 @@ export default function CreateRoleDialog({
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const cameras = Object.keys(config.cameras || {});
|
||||
const cameras = Object.keys(config.cameras || {}).filter(
|
||||
(name) => !isReplayCamera(name),
|
||||
);
|
||||
|
||||
const existingRoles = Object.keys(config.auth?.roles || {});
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type EditRoleCamerasOverlayProps = {
|
||||
show: boolean;
|
||||
@ -46,7 +47,9 @@ export default function EditRoleCamerasDialog({
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const cameras = Object.keys(config.cameras || {});
|
||||
const cameras = Object.keys(config.cameras || {}).filter(
|
||||
(name) => !isReplayCamera(name),
|
||||
);
|
||||
|
||||
const formSchema = z.object({
|
||||
cameras: z
|
||||
|
||||
@ -54,6 +54,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
"1",
|
||||
@ -448,7 +449,9 @@ export function ExportContent({
|
||||
);
|
||||
|
||||
const cameraActivities = useMemo<CameraActivity[]>(() => {
|
||||
const allCameraIds = Object.keys(config?.cameras ?? {});
|
||||
const allCameraIds = Object.keys(config?.cameras ?? {}).filter(
|
||||
(name) => !isReplayCamera(name),
|
||||
);
|
||||
const byCamera = new Map<string, Event[]>();
|
||||
|
||||
events?.forEach((event) => {
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuX } from "react-icons/lu";
|
||||
@ -36,11 +37,16 @@ export default function ObjectPathPlotter() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const eventsPerPage = 20;
|
||||
|
||||
const cameraNames = useMemo(() => {
|
||||
if (!config) return [];
|
||||
return Object.keys(config.cameras).filter((name) => !isReplayCamera(name));
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config && !selectedCamera) {
|
||||
setSelectedCamera(Object.keys(config.cameras)[0]);
|
||||
if (cameraNames.length > 0 && !selectedCamera) {
|
||||
setSelectedCamera(cameraNames[0]);
|
||||
}
|
||||
}, [config, selectedCamera]);
|
||||
}, [cameraNames, selectedCamera]);
|
||||
|
||||
const searchQuery = useMemo(() => {
|
||||
if (!selectedCamera) return null;
|
||||
@ -143,12 +149,11 @@ export default function ObjectPathPlotter() {
|
||||
<SelectValue placeholder="Select camera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config &&
|
||||
Object.keys(config.cameras).map((cameraName) => (
|
||||
<SelectItem key={cameraName} value={cameraName}>
|
||||
{cameraName}
|
||||
</SelectItem>
|
||||
))}
|
||||
{cameraNames.map((cameraName) => (
|
||||
<SelectItem key={cameraName} value={cameraName}>
|
||||
{cameraName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
} from "@/utils/configUtil";
|
||||
import { extractSectionSchema } from "@/hooks/use-config-schema";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
|
||||
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
|
||||
|
||||
@ -602,9 +603,13 @@ function getEffectiveGlobalBaseline(
|
||||
return normalizeConfigValue(defaults as JsonValue);
|
||||
}
|
||||
}
|
||||
const cameraSectionValues = Object.keys(config.cameras ?? {}).map((name) =>
|
||||
normalizeConfigValue(getBaseCameraSectionValue(config, name, sectionPath)),
|
||||
);
|
||||
const cameraSectionValues = Object.keys(config.cameras ?? {})
|
||||
.filter((name) => !isReplayCamera(name))
|
||||
.map((name) =>
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, name, sectionPath),
|
||||
),
|
||||
);
|
||||
return deriveSyntheticGlobalValue(cameraSectionValues, compareFields);
|
||||
}
|
||||
|
||||
@ -684,7 +689,9 @@ export function useCamerasOverridingSection(
|
||||
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||
const compareFields = sectionMeta?.compareFields;
|
||||
|
||||
const cameraNames = Object.keys(config.cameras);
|
||||
const cameraNames = Object.keys(config.cameras).filter(
|
||||
(name) => !isReplayCamera(name),
|
||||
);
|
||||
const cameraSectionValues = cameraNames.map((name) =>
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, name, sectionPath),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
|
||||
/**
|
||||
* Returns true if the current user has access to all cameras.
|
||||
@ -16,7 +17,7 @@ export function useHasFullCameraAccess() {
|
||||
if (!config?.cameras) return false;
|
||||
|
||||
const enabledCameraNames = Object.entries(config.cameras)
|
||||
.filter(([, cam]) => cam.enabled_in_config)
|
||||
.filter(([name, cam]) => cam.enabled_in_config && !isReplayCamera(name))
|
||||
.map(([name]) => name);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FaArrowUpLong, FaStop } from "react-icons/fa6";
|
||||
import { LuCircleAlert, LuMessageSquarePlus } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import axios from "axios";
|
||||
import useSWR from "swr";
|
||||
import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow";
|
||||
import { MessageBubble } from "@/components/chat/ChatMessage";
|
||||
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
|
||||
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
|
||||
import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies";
|
||||
import { ChatPaperclipButton } from "@/components/chat/ChatPaperclipButton";
|
||||
import { ChatComposer } from "@/components/chat/ChatComposer";
|
||||
import ChatSettings from "@/components/chat/ChatSettings";
|
||||
import type { ChatMessage, ShowStatsMode } from "@/types/chat";
|
||||
import type {
|
||||
ChatMessage,
|
||||
GenAIModelsResponse,
|
||||
ShowStatsMode,
|
||||
} from "@/types/chat";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
getEventIdsFromSearchObjectsToolCalls,
|
||||
@ -38,9 +39,26 @@ export default function ChatPage() {
|
||||
"chat-auto-scroll",
|
||||
true,
|
||||
);
|
||||
const [thinkingEnabled, setThinkingEnabled] = usePersistence<boolean>(
|
||||
"chat-thinking-enabled",
|
||||
false,
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const { data: genaiInfo } = useSWR<GenAIModelsResponse>("genai/models", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const supportsThinking = useMemo(() => {
|
||||
if (!genaiInfo) return false;
|
||||
for (const entry of Object.values(genaiInfo)) {
|
||||
if (entry.roles?.includes("chat") && entry.supports_toggleable_thinking) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [genaiInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle");
|
||||
}, [t]);
|
||||
@ -100,9 +118,10 @@ export default function ChatPage() {
|
||||
defaultErrorMessage: t("error"),
|
||||
},
|
||||
controller.signal,
|
||||
supportsThinking ? { enableThinking: !!thinkingEnabled } : {},
|
||||
);
|
||||
},
|
||||
[isLoading, t],
|
||||
[isLoading, supportsThinking, t, thinkingEnabled],
|
||||
);
|
||||
|
||||
const recentEventIds = useMemo(() => {
|
||||
@ -305,6 +324,9 @@ export default function ChatPage() {
|
||||
setInput("");
|
||||
submitConversation([{ role: "user", content: message }]);
|
||||
}}
|
||||
supportsThinking={supportsThinking}
|
||||
thinkingEnabled={!!thinkingEnabled}
|
||||
setThinkingEnabled={setThinkingEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -313,7 +335,7 @@ export default function ChatPage() {
|
||||
{hasStarted && (
|
||||
<div className="flex shrink-0 justify-center p-2 md:px-4 md:pb-4">
|
||||
<div className="flex w-full xl:w-[50%] 3xl:w-[35%]">
|
||||
<ChatEntry
|
||||
<ChatComposer
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
sendMessage={sendMessage}
|
||||
@ -324,6 +346,9 @@ export default function ChatPage() {
|
||||
onAttach={setAttachedEventId}
|
||||
onStop={stopGeneration}
|
||||
recentEventIds={recentEventIds}
|
||||
supportsThinking={supportsThinking}
|
||||
thinkingEnabled={!!thinkingEnabled}
|
||||
setThinkingEnabled={setThinkingEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -331,89 +356,3 @@ export default function ChatPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChatEntryProps = {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
sendMessage: (textOverride?: string) => void;
|
||||
isLoading: boolean;
|
||||
placeholder: string;
|
||||
attachedEventId: string | null;
|
||||
onClearAttachment: () => void;
|
||||
onAttach: (eventId: string) => void;
|
||||
onStop: () => void;
|
||||
recentEventIds: string[];
|
||||
};
|
||||
|
||||
function ChatEntry({
|
||||
input,
|
||||
setInput,
|
||||
sendMessage,
|
||||
isLoading,
|
||||
placeholder,
|
||||
attachedEventId,
|
||||
onClearAttachment,
|
||||
onAttach,
|
||||
onStop,
|
||||
recentEventIds,
|
||||
}: ChatEntryProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
{attachedEventId && (
|
||||
<div className="flex items-center">
|
||||
<ChatAttachmentChip
|
||||
eventId={attachedEventId}
|
||||
mode="composer"
|
||||
onRemove={onClearAttachment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{attachedEventId && (
|
||||
<ChatQuickReplies
|
||||
onSend={(text) => sendMessage(text)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-row items-center gap-2">
|
||||
<ChatPaperclipButton
|
||||
recentEventIds={recentEventIds}
|
||||
onAttach={onAttach}
|
||||
disabled={isLoading || attachedEventId != null}
|
||||
/>
|
||||
<Input
|
||||
className="w-full flex-1 border-transparent bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-busy={isLoading}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
onClick={onStop}
|
||||
>
|
||||
<FaStop className="size-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="select"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
disabled={!input.trim()}
|
||||
onClick={() => sendMessage()}
|
||||
>
|
||||
<FaArrowUpLong className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -637,7 +637,7 @@ export default function Events() {
|
||||
}
|
||||
|
||||
setStartTime(recording.startTime);
|
||||
const allCameras = reviewFilter?.cameras ?? Object.keys(config.cameras);
|
||||
const allCameras = reviewFilter?.cameras ?? allowedCameras;
|
||||
|
||||
return {
|
||||
camera: recording.camera,
|
||||
|
||||
@ -378,6 +378,34 @@ export default function Replay() {
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
{config?.face_recognition?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="face_recognition"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
{config?.lpr?.enabled && (
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="lpr"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -100,6 +100,7 @@ import {
|
||||
} from "@/utils/configUtil";
|
||||
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
@ -661,7 +662,12 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
return Object.values(config.cameras)
|
||||
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
|
||||
.filter(
|
||||
(conf) =>
|
||||
conf.ui.dashboard &&
|
||||
conf.enabled_in_config &&
|
||||
!isReplayCamera(conf.name),
|
||||
)
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
|
||||
@ -25,3 +25,11 @@ export type ChatStats = {
|
||||
};
|
||||
|
||||
export type ShowStatsMode = "while_generating" | "always";
|
||||
|
||||
export type GenAIProviderInfo = {
|
||||
models: string[];
|
||||
roles: string[];
|
||||
supports_toggleable_thinking: boolean;
|
||||
};
|
||||
|
||||
export type GenAIModelsResponse = Record<string, GenAIProviderInfo>;
|
||||
|
||||
@ -34,12 +34,17 @@ type StreamChunk =
|
||||
* POST to chat/completion with stream: true, parse NDJSON stream, and invoke
|
||||
* callbacks so the caller can update UI (e.g. React state).
|
||||
*/
|
||||
export type StreamChatOptions = {
|
||||
enableThinking?: boolean;
|
||||
};
|
||||
|
||||
export async function streamChatCompletion(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
apiMessages: { role: string; content: string }[],
|
||||
callbacks: StreamChatCallbacks,
|
||||
signal?: AbortSignal,
|
||||
options: StreamChatOptions = {},
|
||||
): Promise<void> {
|
||||
const {
|
||||
updateMessages,
|
||||
@ -50,10 +55,17 @@ export async function streamChatCompletion(
|
||||
} = callbacks;
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
messages: apiMessages,
|
||||
stream: true,
|
||||
};
|
||||
if (options.enableThinking !== undefined) {
|
||||
body.enable_thinking = options.enableThinking;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ messages: apiMessages, stream: true }),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
ZoomLevel,
|
||||
} from "@/types/review";
|
||||
import { getChunkedTimeRange } from "@/utils/timelineUtil";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||
import axios from "axios";
|
||||
import {
|
||||
@ -1015,12 +1016,14 @@ function MotionReview({
|
||||
|
||||
let cameras;
|
||||
if (!filter || !filter.cameras) {
|
||||
cameras = Object.values(config.cameras);
|
||||
cameras = Object.values(config.cameras).filter(
|
||||
(cam) => !isReplayCamera(cam.name),
|
||||
);
|
||||
} else {
|
||||
const filteredCams = filter.cameras;
|
||||
|
||||
cameras = Object.values(config.cameras).filter((cam) =>
|
||||
filteredCams.includes(cam.name),
|
||||
cameras = Object.values(config.cameras).filter(
|
||||
(cam) => filteredCams.includes(cam.name) && !isReplayCamera(cam.name),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ProfileState } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
@ -87,7 +88,10 @@ export default function CameraManagementView({
|
||||
const enabledCameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras)
|
||||
.filter((camera) => config.cameras[camera].enabled_in_config)
|
||||
.filter(
|
||||
(camera) =>
|
||||
config.cameras[camera].enabled_in_config && !isReplayCamera(camera),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const orderA = config.cameras[a].ui?.order ?? 0;
|
||||
const orderB = config.cameras[b].ui?.order ?? 0;
|
||||
@ -180,7 +184,11 @@ export default function CameraManagementView({
|
||||
const disabledCameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras)
|
||||
.filter((camera) => !config.cameras[camera].enabled_in_config)
|
||||
.filter(
|
||||
(camera) =>
|
||||
!config.cameras[camera].enabled_in_config &&
|
||||
!isReplayCamera(camera),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
return [];
|
||||
@ -188,7 +196,9 @@ export default function CameraManagementView({
|
||||
|
||||
const allCameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras).sort();
|
||||
return Object.keys(config.cameras)
|
||||
.filter((camera) => !isReplayCamera(camera))
|
||||
.sort();
|
||||
}
|
||||
return [];
|
||||
}, [config]);
|
||||
|
||||
@ -16,6 +16,7 @@ import FrigatePlusCurrentModelSummary from "@/views/settings/components/FrigateP
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
||||
|
||||
export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
@ -139,8 +140,9 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(config.cameras).map(
|
||||
([name, camera]) => (
|
||||
{Object.entries(config.cameras)
|
||||
.filter(([name]) => !isReplayCamera(name))
|
||||
.map(([name, camera]) => (
|
||||
<tr
|
||||
key={name}
|
||||
className="border-b border-secondary"
|
||||
@ -156,8 +158,7 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -19,6 +19,7 @@ import type { JsonObject } from "@/types/configForm";
|
||||
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -145,7 +146,9 @@ export default function ProfilesView({
|
||||
if (!config || allProfileNames.length === 0) return {};
|
||||
|
||||
const data: Record<string, Record<string, string[]>> = {};
|
||||
const cameras = Object.keys(config.cameras).sort();
|
||||
const cameras = Object.keys(config.cameras)
|
||||
.filter((name) => !isReplayCamera(name))
|
||||
.sort();
|
||||
|
||||
for (const profile of allProfileNames) {
|
||||
data[profile] = {};
|
||||
|
||||
@ -25,6 +25,7 @@ import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type CameraMetricsProps = {
|
||||
lastUpdated: number;
|
||||
@ -316,7 +317,7 @@ export default function CameraMetrics({
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{config &&
|
||||
Object.values(config.cameras).map((camera) => {
|
||||
if (camera.enabled) {
|
||||
if (camera.enabled && !isReplayCamera(camera.name)) {
|
||||
return (
|
||||
<Fragment key={camera.name}>
|
||||
{probeCameraName == camera.name && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user