mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-10 00:32:48 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e506e176e6 | ||
|
|
ee823f8488 | ||
|
|
85e16eb31b |
@@ -72,10 +72,10 @@ Variables prefixed with `FRIGATE_` can be referenced in config fields that suppo
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Environment variables" /> to add or edit environment variables.
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | --------------------------------------------------------- |
|
||||
| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
|
||||
| **Value** | The value for the variable |
|
||||
| Field | Description |
|
||||
| --------- | --------------------------------------------------------- |
|
||||
| **Key** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
|
||||
| **Value** | The value for the variable |
|
||||
|
||||
Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
## Configuration
|
||||
|
||||
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 4 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below.
|
||||
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently several native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below.
|
||||
|
||||
To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
|
||||
|
||||
@@ -385,3 +385,37 @@ genai:
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### TwelveLabs (Marengo embeddings)
|
||||
|
||||
[TwelveLabs](https://twelvelabs.io) provides the Marengo multimodal model, which embeds text and images into a shared vector space. This makes it usable as the `embeddings` provider for Frigate's [Semantic Search](/configuration/semantic_search), letting a natural-language query match stored event thumbnails directly.
|
||||
|
||||
This provider implements only the `embeddings` role. Use a separate provider (such as Ollama, Gemini, or OpenAI) for the `descriptions` and `chat` roles if you want generated descriptions as well.
|
||||
|
||||
#### Get API Key
|
||||
|
||||
Create an API key from the [TwelveLabs dashboard](https://playground.twelvelabs.io/dashboard/api-key). There is a generous free tier.
|
||||
|
||||
#### Configuration
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
|
||||
- Set **Provider** to `twelvelabs`
|
||||
- Set **API key** to your TwelveLabs API key (or use an environment variable such as `{FRIGATE_TWELVELABS_API_KEY}`)
|
||||
- Optionally set **Model** to override the default Marengo model (`marengo3.0`)
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: twelvelabs
|
||||
api_key: "{FRIGATE_TWELVELABS_API_KEY}"
|
||||
roles:
|
||||
- embeddings
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
@@ -15,6 +15,7 @@ class GenAIProviderEnum(str, Enum):
|
||||
gemini = "gemini"
|
||||
ollama = "ollama"
|
||||
llamacpp = "llamacpp"
|
||||
twelvelabs = "twelvelabs"
|
||||
|
||||
|
||||
class GenAIRoleEnum(str, Enum):
|
||||
|
||||
@@ -200,9 +200,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
)
|
||||
|
||||
for model_config in self.config.classification.custom.values():
|
||||
if not model_config.enabled:
|
||||
continue
|
||||
|
||||
self.realtime_processors.append(
|
||||
CustomStateClassificationProcessor(
|
||||
self.config, model_config, self.requestor, self.metrics
|
||||
@@ -335,25 +332,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
for processor in self.post_processors:
|
||||
processor.update_config(topic, payload)
|
||||
|
||||
def _remove_custom_classification_processor(self, model_name: str) -> None:
|
||||
"""Shut down and drop any running processor for a custom model."""
|
||||
remaining = []
|
||||
for processor in self.realtime_processors:
|
||||
if (
|
||||
isinstance(
|
||||
processor,
|
||||
(
|
||||
CustomStateClassificationProcessor,
|
||||
CustomObjectClassificationProcessor,
|
||||
),
|
||||
)
|
||||
and processor.model_config.name == model_name
|
||||
):
|
||||
processor.shutdown()
|
||||
else:
|
||||
remaining.append(processor)
|
||||
self.realtime_processors = remaining
|
||||
|
||||
def _handle_custom_classification_update(
|
||||
self, topic: str, model_config: Any
|
||||
) -> None:
|
||||
@@ -361,7 +339,23 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
model_name = topic.split("/")[-1]
|
||||
|
||||
if model_config is None:
|
||||
self._remove_custom_classification_processor(model_name)
|
||||
remaining = []
|
||||
for processor in self.realtime_processors:
|
||||
if (
|
||||
isinstance(
|
||||
processor,
|
||||
(
|
||||
CustomStateClassificationProcessor,
|
||||
CustomObjectClassificationProcessor,
|
||||
),
|
||||
)
|
||||
and processor.model_config.name == model_name
|
||||
):
|
||||
processor.shutdown()
|
||||
else:
|
||||
remaining.append(processor)
|
||||
self.realtime_processors = remaining
|
||||
|
||||
logger.info(
|
||||
f"Successfully removed classification processor for model: {model_name}"
|
||||
)
|
||||
@@ -369,13 +363,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
|
||||
self.config.classification.custom[model_name] = model_config
|
||||
|
||||
# A disabled model must not run; tear down any existing processor and
|
||||
# do not register a new one.
|
||||
if not model_config.enabled:
|
||||
self._remove_custom_classification_processor(model_name)
|
||||
logger.info(f"Disabled classification processor for model: {model_name}")
|
||||
return
|
||||
|
||||
# Check if processor already exists
|
||||
for processor in self.realtime_processors:
|
||||
if isinstance(
|
||||
@@ -715,11 +702,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
and "license_plate" not in camera_config.objects.track
|
||||
)
|
||||
|
||||
has_enabled_custom = any(
|
||||
c.enabled for c in self.config.classification.custom.values()
|
||||
)
|
||||
|
||||
if not dedicated_lpr_enabled and not has_enabled_custom:
|
||||
if not dedicated_lpr_enabled and len(self.config.classification.custom) == 0:
|
||||
# no active features that use this data
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""TwelveLabs provider for Frigate AI.
|
||||
|
||||
Provides multimodal embeddings for Frigate's semantic search via TwelveLabs'
|
||||
Marengo model. Marengo produces text and image embeddings in a shared vector
|
||||
space, so a natural-language query and a stored event thumbnail can be matched
|
||||
directly — which is exactly what the ``embeddings`` GenAI role drives.
|
||||
|
||||
This provider is opt-in: it is only used when a ``genai`` config entry sets
|
||||
``provider: twelvelabs`` and includes the ``embeddings`` role. It does not
|
||||
implement the ``descriptions``/``chat`` roles — Marengo is an embedding model,
|
||||
and TwelveLabs' Pegasus description model operates on whole video clips rather
|
||||
than the per-frame thumbnails Frigate hands to ``_send``.
|
||||
|
||||
Marengo embeddings are 512-dimensional; Frigate's semantic search schema
|
||||
expects 768 dimensions. The shared :class:`~frigate.embeddings.genai_embedding.GenAIEmbedding`
|
||||
adapter zero-pads shorter vectors, so the dimension difference is handled
|
||||
upstream and is consistent for both text and image inputs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default Marengo model. Overridable via the `model` config field.
|
||||
DEFAULT_MODEL = "marengo3.0"
|
||||
|
||||
# Marengo embed REST endpoint. No SDK is needed — this is a plain multipart POST
|
||||
# made through Frigate's existing `requests` dependency.
|
||||
EMBED_URL = "https://api.twelvelabs.io/v1.3/embed"
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.twelvelabs)
|
||||
class TwelveLabsClient(GenAIClient):
|
||||
"""GenAI client for Frigate using TwelveLabs Marengo embeddings."""
|
||||
|
||||
def _init_provider(self):
|
||||
"""Validate config for the TwelveLabs REST provider.
|
||||
|
||||
The provider is just an HTTPS API, so there is no client object to
|
||||
build — the API key is the only thing required. A non-None sentinel is
|
||||
returned so the shared ``ensure_provider``/initialization machinery
|
||||
treats the provider as available.
|
||||
"""
|
||||
if not self.genai_config.api_key:
|
||||
logger.error("TwelveLabs provider requires an api_key.")
|
||||
return None
|
||||
|
||||
return self.genai_config.api_key
|
||||
|
||||
@property
|
||||
def _model(self) -> str:
|
||||
return self.genai_config.model or DEFAULT_MODEL
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Marengo is the embedding model exposed by this provider."""
|
||||
return [DEFAULT_MODEL]
|
||||
|
||||
def embed(
|
||||
self,
|
||||
texts: list[str] | None = None,
|
||||
images: list[bytes] | None = None,
|
||||
) -> list[np.ndarray]:
|
||||
"""Generate Marengo embeddings for text and/or images.
|
||||
|
||||
The TwelveLabs embed API embeds a single input per call, so inputs are
|
||||
sent one at a time. Returns one 512-dim float32 vector per input, in
|
||||
order (texts first, then images). The shared GenAIEmbedding adapter
|
||||
pads these to Frigate's 768-dim search schema.
|
||||
|
||||
Calls the Marengo REST endpoint directly via ``requests`` — no SDK.
|
||||
"""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
"TwelveLabs provider has not been initialized. Check your configuration."
|
||||
)
|
||||
return []
|
||||
|
||||
results: list[np.ndarray] = []
|
||||
|
||||
for text in texts or []:
|
||||
vector = self._embed_one(text=text)
|
||||
if vector is not None:
|
||||
results.append(vector)
|
||||
|
||||
for image in images or []:
|
||||
vector = self._embed_one(image=image)
|
||||
if vector is not None:
|
||||
results.append(vector)
|
||||
|
||||
return results
|
||||
|
||||
def _embed_one(
|
||||
self, text: str | None = None, image: bytes | None = None
|
||||
) -> np.ndarray | None:
|
||||
"""Embed a single text or image input, returning a float32 vector.
|
||||
|
||||
Posts a multipart form to the Marengo embed endpoint (``model_name`` plus
|
||||
either a ``text`` or an ``image_file`` part). The endpoint requires
|
||||
multipart/form-data, so every field — including text — is passed via
|
||||
``files`` (the ``(None, value)`` form makes requests emit a multipart
|
||||
text part). ``self.provider`` holds the validated API key. The 512-dim
|
||||
vector is at ``<text|image>_embedding.segments[0].float`` in the JSON
|
||||
response.
|
||||
"""
|
||||
headers = {"x-api-key": self.provider}
|
||||
files: dict = {"model_name": (None, self._model)}
|
||||
|
||||
if text is not None:
|
||||
files["text"] = (None, text)
|
||||
result_key = "text_embedding"
|
||||
else:
|
||||
files["image_file"] = ("image.jpg", image, "image/jpeg")
|
||||
result_key = "image_embedding"
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
EMBED_URL,
|
||||
headers=headers,
|
||||
files=files,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json().get(result_key) or {}
|
||||
segments = result.get("segments") or []
|
||||
|
||||
if not segments:
|
||||
logger.warning("TwelveLabs returned no embedding for input.")
|
||||
return None
|
||||
|
||||
return np.array(segments[0]["float"], dtype=np.float32)
|
||||
except Exception as e:
|
||||
logger.warning("TwelveLabs returned an error: %s", e)
|
||||
return None
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Tests that disabled custom classification models are not registered or run."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock TFLite before importing the maintainer / classification modules
|
||||
_MOCK_MODULES = [
|
||||
"tflite_runtime",
|
||||
"tflite_runtime.interpreter",
|
||||
"ai_edge_litert",
|
||||
"ai_edge_litert.interpreter",
|
||||
]
|
||||
for mod in _MOCK_MODULES:
|
||||
if mod not in sys.modules:
|
||||
sys.modules[mod] = MagicMock()
|
||||
|
||||
from frigate.data_processing.real_time.custom_classification import ( # noqa: E402
|
||||
CustomObjectClassificationProcessor,
|
||||
)
|
||||
from frigate.embeddings.maintainer import EmbeddingMaintainer # noqa: E402
|
||||
|
||||
|
||||
class TestCustomClassificationEnabledGating(unittest.TestCase):
|
||||
"""A model with enabled: false must not keep a processor registered."""
|
||||
|
||||
def _make_maintainer(self) -> EmbeddingMaintainer:
|
||||
# Bypass the heavy __init__; only the attributes touched by the
|
||||
# config update path are needed for these tests.
|
||||
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
|
||||
maintainer.realtime_processors = []
|
||||
maintainer.config = MagicMock()
|
||||
maintainer.config.classification.custom = {}
|
||||
maintainer.requestor = MagicMock()
|
||||
maintainer.metrics = MagicMock()
|
||||
maintainer.event_metadata_publisher = MagicMock()
|
||||
return maintainer
|
||||
|
||||
def _make_model_config(self, name: str, enabled: bool) -> MagicMock:
|
||||
model_config = MagicMock()
|
||||
model_config.name = name
|
||||
model_config.enabled = enabled
|
||||
model_config.state_config = None
|
||||
return model_config
|
||||
|
||||
def _make_processor(self, name: str) -> MagicMock:
|
||||
processor = MagicMock(spec=CustomObjectClassificationProcessor)
|
||||
processor.model_config = MagicMock()
|
||||
processor.model_config.name = name
|
||||
return processor
|
||||
|
||||
def test_disabled_update_tears_down_existing_processor(self):
|
||||
"""Toggling a running model to disabled shuts down and drops its processor."""
|
||||
maintainer = self._make_maintainer()
|
||||
processor = self._make_processor("atli")
|
||||
maintainer.realtime_processors = [processor]
|
||||
|
||||
maintainer._handle_custom_classification_update(
|
||||
"config/classification/custom/atli",
|
||||
self._make_model_config("atli", enabled=False),
|
||||
)
|
||||
|
||||
processor.shutdown.assert_called_once()
|
||||
self.assertEqual(maintainer.realtime_processors, [])
|
||||
|
||||
def test_disabled_update_does_not_register_processor(self):
|
||||
"""A disabled model that has no processor is never registered."""
|
||||
maintainer = self._make_maintainer()
|
||||
|
||||
maintainer._handle_custom_classification_update(
|
||||
"config/classification/custom/atli",
|
||||
self._make_model_config("atli", enabled=False),
|
||||
)
|
||||
|
||||
self.assertEqual(maintainer.realtime_processors, [])
|
||||
|
||||
def test_disabled_update_leaves_other_processors_untouched(self):
|
||||
"""Disabling one model must not affect other running processors."""
|
||||
maintainer = self._make_maintainer()
|
||||
other = self._make_processor("simbi")
|
||||
maintainer.realtime_processors = [other]
|
||||
|
||||
maintainer._handle_custom_classification_update(
|
||||
"config/classification/custom/atli",
|
||||
self._make_model_config("atli", enabled=False),
|
||||
)
|
||||
|
||||
other.shutdown.assert_not_called()
|
||||
self.assertEqual(maintainer.realtime_processors, [other])
|
||||
|
||||
def test_removed_model_tears_down_processor(self):
|
||||
"""A None payload (model deleted) still shuts down its processor."""
|
||||
maintainer = self._make_maintainer()
|
||||
processor = self._make_processor("atli")
|
||||
maintainer.realtime_processors = [processor]
|
||||
|
||||
maintainer._handle_custom_classification_update(
|
||||
"config/classification/custom/atli", None
|
||||
)
|
||||
|
||||
processor.shutdown.assert_called_once()
|
||||
self.assertEqual(maintainer.realtime_processors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for the TwelveLabs GenAI provider (Marengo embeddings)."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.config.camera.genai import (
|
||||
GenAIConfig,
|
||||
GenAIProviderEnum,
|
||||
GenAIRoleEnum,
|
||||
)
|
||||
from frigate.genai.plugins.twelvelabs import DEFAULT_MODEL, TwelveLabsClient
|
||||
|
||||
|
||||
def _make_config(model: str = "") -> GenAIConfig:
|
||||
return GenAIConfig(
|
||||
provider=GenAIProviderEnum.twelvelabs,
|
||||
api_key="test-key",
|
||||
model=model,
|
||||
roles=[GenAIRoleEnum.embeddings],
|
||||
)
|
||||
|
||||
|
||||
def _response(key: str, values):
|
||||
"""Mimic the Marengo REST JSON: ``{<key>: {segments: [{float: [...]}]}}``."""
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
resp.json.return_value = {key: {"segments": [{"float": values}]}}
|
||||
return resp
|
||||
|
||||
|
||||
class TestTwelveLabsEmbedNoNetwork(unittest.TestCase):
|
||||
"""Unit tests with ``requests`` mocked — no network access, no SDK."""
|
||||
|
||||
def _client(self) -> TwelveLabsClient:
|
||||
client = TwelveLabsClient.__new__(TwelveLabsClient)
|
||||
client.genai_config = _make_config()
|
||||
client.timeout = 120
|
||||
client.provider = "test-key"
|
||||
return client
|
||||
|
||||
@patch("frigate.genai.plugins.twelvelabs.requests.post")
|
||||
def test_text_embedding_returns_vector(self, post):
|
||||
post.return_value = _response("text_embedding", [0.1, 0.2, 0.3])
|
||||
|
||||
out = self._client().embed(texts=["a person walking a dog"])
|
||||
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertIsInstance(out[0], np.ndarray)
|
||||
self.assertEqual(out[0].dtype, np.float32)
|
||||
np.testing.assert_allclose(out[0], [0.1, 0.2, 0.3], rtol=1e-6)
|
||||
|
||||
_, kwargs = post.call_args
|
||||
self.assertEqual(kwargs["files"]["model_name"][1], DEFAULT_MODEL)
|
||||
self.assertEqual(kwargs["files"]["text"][1], "a person walking a dog")
|
||||
self.assertEqual(kwargs["headers"]["x-api-key"], "test-key")
|
||||
self.assertNotIn("image_file", kwargs["files"])
|
||||
|
||||
@patch("frigate.genai.plugins.twelvelabs.requests.post")
|
||||
def test_image_embedding_uses_image_file(self, post):
|
||||
post.return_value = _response("image_embedding", [1.0, 2.0])
|
||||
|
||||
out = self._client().embed(images=[b"\xff\xd8\xff jpeg bytes"])
|
||||
|
||||
self.assertEqual(len(out), 1)
|
||||
_, kwargs = post.call_args
|
||||
self.assertEqual(kwargs["files"]["image_file"][1], b"\xff\xd8\xff jpeg bytes")
|
||||
self.assertNotIn("text", kwargs["files"])
|
||||
|
||||
@patch("frigate.genai.plugins.twelvelabs.requests.post")
|
||||
def test_custom_model_name_is_used(self, post):
|
||||
post.return_value = _response("text_embedding", [0.0])
|
||||
|
||||
client = self._client()
|
||||
client.genai_config = _make_config(model="marengo-custom")
|
||||
client.embed(texts=["x"])
|
||||
|
||||
_, kwargs = post.call_args
|
||||
self.assertEqual(kwargs["files"]["model_name"][1], "marengo-custom")
|
||||
|
||||
@patch("frigate.genai.plugins.twelvelabs.requests.post")
|
||||
def test_empty_segments_are_skipped(self, post):
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
resp.json.return_value = {"text_embedding": {"segments": []}}
|
||||
post.return_value = resp
|
||||
|
||||
self.assertEqual(self._client().embed(texts=["x"]), [])
|
||||
|
||||
@patch("frigate.genai.plugins.twelvelabs.requests.post")
|
||||
def test_api_error_is_swallowed(self, post):
|
||||
post.side_effect = RuntimeError("boom")
|
||||
|
||||
self.assertEqual(self._client().embed(texts=["x"]), [])
|
||||
|
||||
def test_no_provider_returns_empty(self):
|
||||
client = self._client()
|
||||
client.provider = None
|
||||
self.assertEqual(client.embed(texts=["x"]), [])
|
||||
|
||||
def test_no_inputs_returns_empty(self):
|
||||
self.assertEqual(self._client().embed(), [])
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get("TWELVELABS_API_KEY"),
|
||||
"TWELVELABS_API_KEY not set; skipping live TwelveLabs API test",
|
||||
)
|
||||
class TestTwelveLabsEmbedLive(unittest.TestCase):
|
||||
"""Live smoke test against the real TwelveLabs API (Marengo)."""
|
||||
|
||||
def _client(self) -> TwelveLabsClient:
|
||||
config = _make_config()
|
||||
config.api_key = os.environ["TWELVELABS_API_KEY"]
|
||||
return TwelveLabsClient(config)
|
||||
|
||||
def test_text_embedding_dim(self):
|
||||
out = self._client().embed(texts=["a delivery person at the front door"])
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0].shape, (512,))
|
||||
|
||||
def test_image_embedding_dim(self):
|
||||
from PIL import Image
|
||||
|
||||
arr = (np.random.rand(224, 224, 3) * 255).astype("uint8")
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr, "RGB").save(buf, format="JPEG")
|
||||
|
||||
out = self._client().embed(images=[buf.getvalue()])
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0].shape, (512,))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1490,14 +1490,7 @@
|
||||
"keyLabel": "Key",
|
||||
"valueLabel": "Value",
|
||||
"keyPlaceholder": "New key",
|
||||
"remove": "Remove",
|
||||
"providerNameLabel": "Provider name",
|
||||
"providerNamePlaceholder": "e.g., openai",
|
||||
"variableNameLabel": "Variable name",
|
||||
"variableNamePlaceholder": "e.g., MY_VARIABLE",
|
||||
"loggerNameLabel": "Logger name",
|
||||
"loggerNamePlaceholder": "e.g., frigate.record",
|
||||
"keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)"
|
||||
"remove": "Remove"
|
||||
},
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "e.g., Wife's Car",
|
||||
|
||||
@@ -7,13 +7,7 @@ const environmentVars: SectionConfigOverrides = {
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
additionalProperties: {
|
||||
"ui:options": {
|
||||
size: "lg",
|
||||
additionalPropertyKeyLabel:
|
||||
"configForm.additionalProperties.variableNameLabel",
|
||||
additionalPropertyKeyPlaceholder:
|
||||
"configForm.additionalProperties.variableNamePlaceholder",
|
||||
},
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,15 +9,7 @@ const genai: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
"ui:options": { disableNestedCard: true },
|
||||
"*": {
|
||||
"ui:options": {
|
||||
disableNestedCard: true,
|
||||
additionalPropertyKeyLabel:
|
||||
"configForm.additionalProperties.providerNameLabel",
|
||||
additionalPropertyKeyPlaceholder:
|
||||
"configForm.additionalProperties.providerNamePlaceholder",
|
||||
additionalPropertyKeyPattern: "^[a-zA-Z0-9_-]+$",
|
||||
preventKeyRename: true,
|
||||
},
|
||||
"ui:options": { disableNestedCard: true },
|
||||
"ui:order": [
|
||||
"provider",
|
||||
"api_key",
|
||||
|
||||
@@ -12,13 +12,7 @@ const logger: SectionConfigOverrides = {
|
||||
},
|
||||
logs: {
|
||||
additionalProperties: {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "logger.logLevel",
|
||||
additionalPropertyKeyLabel:
|
||||
"configForm.additionalProperties.loggerNameLabel",
|
||||
additionalPropertyKeyPlaceholder:
|
||||
"configForm.additionalProperties.loggerNamePlaceholder",
|
||||
},
|
||||
"ui:options": { enumI18nPrefix: "logger.logLevel" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,14 +6,12 @@ import {
|
||||
StrictRJSFSchema,
|
||||
WrapIfAdditionalTemplateProps,
|
||||
} from "@rjsf/utils";
|
||||
import { useEffect, useMemo, useState, type FocusEvent } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
export function WrapIfAdditionalTemplate<
|
||||
T = unknown,
|
||||
@@ -32,7 +30,6 @@ export function WrapIfAdditionalTemplate<
|
||||
onKeyRenameBlur,
|
||||
readonly,
|
||||
required,
|
||||
registry,
|
||||
schema,
|
||||
uiSchema,
|
||||
} = props;
|
||||
@@ -41,55 +38,6 @@ export function WrapIfAdditionalTemplate<
|
||||
|
||||
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
|
||||
|
||||
const uiOptions = getUiOptions(uiSchema);
|
||||
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
|
||||
|
||||
const keyLabelKey =
|
||||
typeof uiOptions.additionalPropertyKeyLabel === "string"
|
||||
? uiOptions.additionalPropertyKeyLabel
|
||||
: undefined;
|
||||
const keyPlaceholderKey =
|
||||
typeof uiOptions.additionalPropertyKeyPlaceholder === "string"
|
||||
? uiOptions.additionalPropertyKeyPlaceholder
|
||||
: undefined;
|
||||
const keyPattern =
|
||||
typeof uiOptions.additionalPropertyKeyPattern === "string"
|
||||
? uiOptions.additionalPropertyKeyPattern
|
||||
: undefined;
|
||||
const preventKeyRename = uiOptions.preventKeyRename === true;
|
||||
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
|
||||
// optionally, lock the key once it's been saved
|
||||
const baseline = formContext?.baselineFormData;
|
||||
const keyLocked =
|
||||
preventKeyRename &&
|
||||
typeof label === "string" &&
|
||||
!!baseline &&
|
||||
Object.prototype.hasOwnProperty.call(baseline, label);
|
||||
|
||||
// controlled key value so we can validate live and block invalid renames.
|
||||
const [keyValue, setKeyValue] = useState<string>(label ?? "");
|
||||
useEffect(() => {
|
||||
setKeyValue(label ?? "");
|
||||
}, [label]);
|
||||
|
||||
const keyRegex = useMemo(
|
||||
() => (keyPattern ? new RegExp(keyPattern) : undefined),
|
||||
[keyPattern],
|
||||
);
|
||||
const keyError = useMemo(() => {
|
||||
if (!keyRegex || keyLocked) return null;
|
||||
if (!keyRegex.test(keyValue)) {
|
||||
return t("configForm.additionalProperties.keyPatternError", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
"Use only letters, numbers, hyphens, and underscores (no spaces)",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [keyRegex, keyLocked, keyValue, t]);
|
||||
|
||||
if (!additional) {
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
@@ -99,26 +47,20 @@ export function WrapIfAdditionalTemplate<
|
||||
}
|
||||
|
||||
const keyId = `${id}-key`;
|
||||
const keyLabel = keyLabelKey
|
||||
? t(keyLabelKey, { ns: "views/settings" })
|
||||
: t("configForm.additionalProperties.keyLabel", { ns: "views/settings" });
|
||||
const keyLabel = t("configForm.additionalProperties.keyLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const valueLabel = t("configForm.additionalProperties.valueLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const keyPlaceholder = keyPlaceholderKey
|
||||
? t(keyPlaceholderKey, { ns: "views/settings" })
|
||||
: t("configForm.additionalProperties.keyPlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const keyPlaceholder = t("configForm.additionalProperties.keyPlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const removeLabel = t("configForm.additionalProperties.remove", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
const commitKeyRename = (e: FocusEvent<HTMLInputElement>) => {
|
||||
if (readonly) return;
|
||||
if (keyError) return;
|
||||
onKeyRenameBlur?.(e);
|
||||
};
|
||||
const uiOptions = getUiOptions(uiSchema);
|
||||
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -128,30 +70,23 @@ export function WrapIfAdditionalTemplate<
|
||||
{!keyIsReadonly && (
|
||||
<div className="col-span-12 space-y-2 md:col-span-2">
|
||||
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
|
||||
{keyLocked ? (
|
||||
{keyIsReadonly ? (
|
||||
<div
|
||||
id={keyId}
|
||||
className="flex items-center break-all text-sm text-primary-variant"
|
||||
className="flex items-center text-sm text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id={keyId}
|
||||
name={keyId}
|
||||
required={required}
|
||||
value={keyValue}
|
||||
placeholder={keyPlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) => setKeyValue(e.target.value)}
|
||||
onBlur={!readonly ? commitKeyRename : undefined}
|
||||
aria-invalid={keyError ? true : undefined}
|
||||
/>
|
||||
{keyError && (
|
||||
<p className="text-xs text-destructive">{keyError}</p>
|
||||
)}
|
||||
</>
|
||||
<Input
|
||||
id={keyId}
|
||||
name={keyId}
|
||||
required={required}
|
||||
defaultValue={label}
|
||||
placeholder={keyPlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onBlur={!readonly ? onKeyRenameBlur : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user