Compare commits

...

3 Commits

Author SHA1 Message Date
Josh Hawkins
c35cee2d2f
Review labels widget (#22664)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* add review labels widget

* register widget and add to review section

* i18n

* add border to switches widget

* padding tweaks

* don't show audio labels if audio is not enabled

* add docs links

* ability to add custom labels to review

* add hint for empty selection in review labels and SwitchesWidget

* language consistency
2026-03-27 08:45:50 -06:00
Nicolas Mowen
1a01513223
Improve chat features (#22663)
* Improve notification messaging

* Improve wake behavior when a zone is not specified

* Fix prompt ordering for generate calls
2026-03-27 08:48:50 -05:00
GuoQing Liu
06ad72860c
feat: add axera npu load (#22662) 2026-03-27 05:07:07 -06:00
14 changed files with 283 additions and 36 deletions

View File

@ -542,9 +542,9 @@ class WebPushClient(Communicator):
self.check_registrations() self.check_registrations()
reasoning: str = payload.get("reasoning", "") text: str = payload.get("message") or payload.get("reasoning", "")
title = f"{camera_name}: Monitoring Alert" title = f"{camera_name}: Monitoring Alert"
message = (reasoning[:197] + "...") if len(reasoning) > 200 else reasoning message = (text[:197] + "...") if len(text) > 200 else text
logger.debug(f"Sending camera monitoring push notification for {camera_name}") logger.debug(f"Sending camera monitoring push notification for {camera_name}")

View File

@ -188,7 +188,7 @@ class ReviewConfig(FrigateBaseModel):
detections: DetectionsConfig = Field( detections: DetectionsConfig = Field(
default_factory=DetectionsConfig, default_factory=DetectionsConfig,
title="Detections config", title="Detections config",
description="Settings for creating detection events (non-alert) and how long to keep them.", description="Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
) )
genai: GenAIReviewConfig = Field( genai: GenAIReviewConfig = Field(
default_factory=GenAIReviewConfig, default_factory=GenAIReviewConfig,

View File

@ -50,9 +50,9 @@ class GeminiClient(GenAIClient):
response_format: Optional[dict] = None, response_format: Optional[dict] = None,
) -> Optional[str]: ) -> Optional[str]:
"""Submit a request to Gemini.""" """Submit a request to Gemini."""
contents = [ contents = [prompt] + [
types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images
] + [prompt] ]
try: try:
# Merge runtime_options into generation_config if provided # Merge runtime_options into generation_config if provided
generation_config_dict: dict[str, Any] = {"candidate_count": 1} generation_config_dict: dict[str, Any] = {"candidate_count": 1}

View File

@ -44,7 +44,12 @@ class OpenAIClient(GenAIClient):
) -> Optional[str]: ) -> Optional[str]:
"""Submit a request to OpenAI.""" """Submit a request to OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images] encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
messages_content = [] messages_content: list[dict] = [
{
"type": "text",
"text": prompt,
}
]
for image in encoded_images: for image in encoded_images:
messages_content.append( messages_content.append(
{ {
@ -55,12 +60,6 @@ class OpenAIClient(GenAIClient):
}, },
} }
) )
messages_content.append(
{
"type": "text",
"text": prompt,
}
)
try: try:
request_params = { request_params = {
"model": self.genai_config.model, "model": self.genai_config.model,

View File

@ -25,6 +25,9 @@ logger = logging.getLogger(__name__)
_MIN_INTERVAL = 1 _MIN_INTERVAL = 1
_MAX_INTERVAL = 300 _MAX_INTERVAL = 300
# Minimum seconds between VLM iterations when woken by detections (no zone filter)
_DETECTION_COOLDOWN_WITHOUT_ZONE = 10
# Max user/assistant turn pairs to keep in conversation history # Max user/assistant turn pairs to keep in conversation history
_MAX_HISTORY = 10 _MAX_HISTORY = 10
@ -40,6 +43,7 @@ class VLMWatchJob(Job):
labels: list = field(default_factory=list) labels: list = field(default_factory=list)
zones: list = field(default_factory=list) zones: list = field(default_factory=list)
last_reasoning: str = "" last_reasoning: str = ""
notification_message: str = ""
iteration_count: int = 0 iteration_count: int = 0
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
@ -196,6 +200,7 @@ class VLMWatchRunner(threading.Thread):
min(_MAX_INTERVAL, int(parsed.get("next_run_in", 30))), min(_MAX_INTERVAL, int(parsed.get("next_run_in", 30))),
) )
reasoning = str(parsed.get("reasoning", "")) reasoning = str(parsed.get("reasoning", ""))
notification_message = str(parsed.get("notification_message", ""))
except (json.JSONDecodeError, ValueError, TypeError) as e: except (json.JSONDecodeError, ValueError, TypeError) as e:
logger.warning( logger.warning(
"VLM watch job %s: failed to parse VLM response: %s", self.job.id, e "VLM watch job %s: failed to parse VLM response: %s", self.job.id, e
@ -203,6 +208,7 @@ class VLMWatchRunner(threading.Thread):
return 30 return 30
self.job.last_reasoning = reasoning self.job.last_reasoning = reasoning
self.job.notification_message = notification_message
self.job.iteration_count += 1 self.job.iteration_count += 1
self._broadcast_status() self._broadcast_status()
@ -213,19 +219,35 @@ class VLMWatchRunner(threading.Thread):
self.job.camera, self.job.camera,
reasoning, reasoning,
) )
self._send_notification(reasoning) self._send_notification(notification_message or reasoning)
self.job.status = JobStatusTypesEnum.success self.job.status = JobStatusTypesEnum.success
return 0 return 0
return next_run_in return next_run_in
def _wait_for_trigger(self, max_wait: float) -> None: def _wait_for_trigger(self, max_wait: float) -> None:
"""Wait up to max_wait seconds, returning early if a relevant detection fires on the target camera.""" """Wait up to max_wait seconds, returning early if a relevant detection fires on the target camera.
deadline = time.time() + max_wait
With zones configured, a matching detection wakes immediately (events
are already filtered). Without zones, detections are frequent so a
cooldown is enforced: messages are continuously drained to prevent
queue backup, but the loop only exits once a match has been seen
*and* the cooldown period has elapsed.
"""
now = time.time()
deadline = now + max_wait
use_cooldown = not self.job.zones
earliest_wake = now + _DETECTION_COOLDOWN_WITHOUT_ZONE if use_cooldown else 0
triggered = False
while not self.cancel_event.is_set(): while not self.cancel_event.is_set():
remaining = deadline - time.time() remaining = deadline - time.time()
if remaining <= 0: if remaining <= 0:
break break
if triggered and time.time() >= earliest_wake:
break
result = self.detection_subscriber.check_for_update( result = self.detection_subscriber.check_for_update(
timeout=min(1.0, remaining) timeout=min(1.0, remaining)
) )
@ -250,6 +272,7 @@ class VLMWatchRunner(threading.Thread):
if cam != self.job.camera or not tracked_objects: if cam != self.job.camera or not tracked_objects:
continue continue
if self._detection_matches_filters(tracked_objects): if self._detection_matches_filters(tracked_objects):
if not use_cooldown:
logger.debug( logger.debug(
"VLM watch job %s: woken early by detection event on %s", "VLM watch job %s: woken early by detection event on %s",
self.job.id, self.job.id,
@ -257,6 +280,15 @@ class VLMWatchRunner(threading.Thread):
) )
break break
if not triggered:
logger.debug(
"VLM watch job %s: detection match on %s, draining for %.0fs",
self.job.id,
self.job.camera,
max(0, earliest_wake - time.time()),
)
triggered = True
def _detection_matches_filters(self, tracked_objects: list) -> bool: def _detection_matches_filters(self, tracked_objects: list) -> bool:
"""Return True if any tracked object passes the label and zone filters.""" """Return True if any tracked object passes the label and zone filters."""
labels = self.job.labels labels = self.job.labels
@ -284,7 +316,11 @@ class VLMWatchRunner(threading.Thread):
f"You will receive a sequence of frames over time. Use the conversation history to understand " f"You will receive a sequence of frames over time. Use the conversation history to understand "
f"what is stationary vs. actively changing.\n\n" f"what is stationary vs. actively changing.\n\n"
f"For each frame respond with JSON only:\n" f"For each frame respond with JSON only:\n"
f'{{"condition_met": <true/false>, "next_run_in": <integer seconds 1-300>, "reasoning": "<brief explanation>"}}\n\n' f'{{"condition_met": <true/false>, "next_run_in": <integer seconds 1-300>, "reasoning": "<brief explanation>", "notification_message": "<natural language notification>"}}\n\n'
f"Guidelines for notification_message:\n"
f"- Only required when condition_met is true.\n"
f"- Write a short, natural notification a user would want to receive on their phone.\n"
f'- Example: "Your package has been delivered to the front porch."\n\n'
f"Guidelines for next_run_in:\n" f"Guidelines for next_run_in:\n"
f"- Scene is empty / nothing of interest visible: 60-300.\n" f"- Scene is empty / nothing of interest visible: 60-300.\n"
f"- Relevant object(s) visible anywhere in frame (even outside the target zone): 3-10. " f"- Relevant object(s) visible anywhere in frame (even outside the target zone): 3-10. "
@ -294,12 +330,13 @@ class VLMWatchRunner(threading.Thread):
f"- Keep reasoning to 1-2 sentences." f"- Keep reasoning to 1-2 sentences."
) )
def _send_notification(self, reasoning: str) -> None: def _send_notification(self, message: str) -> None:
"""Publish a camera_monitoring event so downstream handlers (web push, MQTT) can notify users.""" """Publish a camera_monitoring event so downstream handlers (web push, MQTT) can notify users."""
payload = { payload = {
"camera": self.job.camera, "camera": self.job.camera,
"condition": self.job.condition, "condition": self.job.condition,
"reasoning": reasoning, "message": message,
"reasoning": self.job.last_reasoning,
"job_id": self.job.id, "job_id": self.job.id,
} }

View File

@ -19,6 +19,7 @@ from frigate.types import StatsTrackingTypes
from frigate.util.services import ( from frigate.util.services import (
calculate_shm_requirements, calculate_shm_requirements,
get_amd_gpu_stats, get_amd_gpu_stats,
get_axcl_npu_stats,
get_bandwidth_stats, get_bandwidth_stats,
get_cpu_stats, get_cpu_stats,
get_fs_type, get_fs_type,
@ -324,6 +325,10 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No
# OpenVINO NPU usage # OpenVINO NPU usage
ov_usage = get_openvino_npu_stats() ov_usage = get_openvino_npu_stats()
stats["openvino"] = ov_usage stats["openvino"] = ov_usage
elif detector.type == "axengine":
# AXERA NPU usage
axcl_usage = get_axcl_npu_stats()
stats["axengine"] = axcl_usage
if stats: if stats:
all_stats["npu_usages"] = stats all_stats["npu_usages"] = stats

View File

@ -488,6 +488,43 @@ def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]:
return stats return stats
def get_axcl_npu_stats() -> Optional[dict[str, str | float]]:
"""Get NPU stats using axcl."""
# Check if axcl-smi exists
axcl_smi_path = "/usr/bin/axcl/axcl-smi"
if not os.path.exists(axcl_smi_path):
return None
try:
# Run axcl-smi command to get NPU stats
axcl_command = [axcl_smi_path, "sh", "cat", "/proc/ax_proc/npu/top"]
p = sp.run(
axcl_command,
capture_output=True,
text=True,
)
if p.returncode != 0:
pass
else:
utilization = None
for line in p.stdout.strip().splitlines():
line = line.strip()
if line.startswith("utilization:"):
match = re.search(r"utilization:(\d+)%", line)
if match:
utilization = float(match.group(1))
if utilization is not None:
stats: dict[str, str | float] = {"npu": utilization, "mem": "-%"}
return stats
except Exception:
pass
return None
def try_get_info(f, h, default="N/A", sensor=None): def try_get_info(f, h, default="N/A", sensor=None):
try: try:
if h: if h:

View File

@ -529,7 +529,7 @@
}, },
"detections": { "detections": {
"label": "Detections config", "label": "Detections config",
"description": "Settings for creating detection events (non-alert) and how long to keep them.", "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
"enabled": { "enabled": {
"label": "Enable detections", "label": "Enable detections",
"description": "Enable or disable detection events for this camera." "description": "Enable or disable detection events for this camera."

View File

@ -1044,7 +1044,7 @@
}, },
"detections": { "detections": {
"label": "Detections config", "label": "Detections config",
"description": "Settings for creating detection events (non-alert) and how long to keep them.", "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
"enabled": { "enabled": {
"label": "Enable detections", "label": "Enable detections",
"description": "Enable or disable detection events for all cameras; can be overridden per-camera." "description": "Enable or disable detection events for all cameras; can be overridden per-camera."

View File

@ -1431,6 +1431,11 @@
"summary": "{{count}} object types selected", "summary": "{{count}} object types selected",
"empty": "No object labels available" "empty": "No object labels available"
}, },
"reviewLabels": {
"summary": "{{count}} labels selected",
"empty": "No labels available",
"allNonAlertDetections": "All non-alert activity will be included as detections."
},
"filters": { "filters": {
"objectFieldLabel": "{{field}} for {{label}}" "objectFieldLabel": "{{field}} for {{label}}"
}, },
@ -1474,7 +1479,8 @@
"timestamp_style": { "timestamp_style": {
"title": "Timestamp Settings" "title": "Timestamp Settings"
}, },
"searchPlaceholder": "Search..." "searchPlaceholder": "Search...",
"addCustomLabel": "Add custom label..."
}, },
"globalConfig": { "globalConfig": {
"title": "Global Configuration", "title": "Global Configuration",

View File

@ -3,14 +3,16 @@ import type { SectionConfigOverrides } from "./types";
const review: SectionConfigOverrides = { const review: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/review", sectionDocs: "/configuration/review",
fieldDocs: {
"alerts.labels": "/configuration/review/#alerts-and-detections",
"detections.labels": "/configuration/review/#alerts-and-detections",
},
restartRequired: [], restartRequired: [],
fieldOrder: ["alerts", "detections", "genai"], fieldOrder: ["alerts", "detections", "genai"],
fieldGroups: {}, fieldGroups: {},
hiddenFields: [ hiddenFields: [
"enabled_in_config", "enabled_in_config",
"alerts.labels",
"alerts.enabled_in_config", "alerts.enabled_in_config",
"detections.labels",
"detections.enabled_in_config", "detections.enabled_in_config",
"genai.enabled_in_config", "genai.enabled_in_config",
], ],
@ -18,11 +20,25 @@ const review: SectionConfigOverrides = {
uiSchema: { uiSchema: {
alerts: { alerts: {
"ui:before": { render: "CameraReviewStatusToggles" }, "ui:before": { render: "CameraReviewStatusToggles" },
labels: {
"ui:widget": "reviewLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
required_zones: { required_zones: {
"ui:widget": "hidden", "ui:widget": "hidden",
}, },
}, },
detections: { detections: {
labels: {
"ui:widget": "reviewLabels",
"ui:options": {
suppressMultiSchema: true,
emptySelectionHintKey:
"configForm.reviewLabels.allNonAlertDetections",
},
},
required_zones: { required_zones: {
"ui:widget": "hidden", "ui:widget": "hidden",
}, },

View File

@ -20,6 +20,7 @@ import { TextareaWidget } from "./widgets/TextareaWidget";
import { SwitchesWidget } from "./widgets/SwitchesWidget"; import { SwitchesWidget } from "./widgets/SwitchesWidget";
import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget"; import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget"; import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
import { ReviewLabelSwitchesWidget } from "./widgets/ReviewLabelSwitchesWidget";
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget"; import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget"; import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget"; import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
@ -76,6 +77,7 @@ export const frigateTheme: FrigateTheme = {
switches: SwitchesWidget, switches: SwitchesWidget,
objectLabels: ObjectLabelSwitchesWidget, objectLabels: ObjectLabelSwitchesWidget,
audioLabels: AudioLabelSwitchesWidget, audioLabels: AudioLabelSwitchesWidget,
reviewLabels: ReviewLabelSwitchesWidget,
zoneNames: ZoneSwitchesWidget, zoneNames: ZoneSwitchesWidget,
timezoneSelect: TimezoneSelectWidget, timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget, optionalField: OptionalFieldWidget,

View File

@ -0,0 +1,84 @@
// Review Label Switches Widget - For selecting review alert/detection labels via switches.
// Combines object labels (from objects.track) and audio labels (from audio.listen)
// since review labels can include both types.
import type { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { JsonObject } from "@/types/configForm";
function getReviewLabels(context: FormContext): string[] {
const labels = new Set<string>();
const fullConfig = context.fullConfig as FrigateConfig | undefined;
const fullCameraConfig = context.fullCameraConfig;
// Object labels from tracked objects (camera-level, falling back to global)
const trackLabels =
fullCameraConfig?.objects?.track ?? fullConfig?.objects?.track;
if (Array.isArray(trackLabels)) {
trackLabels.forEach((label: string) => labels.add(label));
}
// Audio labels from listen config, only if audio detection is enabled
const audioEnabled =
fullCameraConfig?.audio?.enabled_in_config ??
fullConfig?.audio?.enabled_in_config;
if (audioEnabled) {
const audioLabels =
fullCameraConfig?.audio?.listen ?? fullConfig?.audio?.listen;
if (Array.isArray(audioLabels)) {
audioLabels.forEach((label: string) => labels.add(label));
}
}
// Include any labels already in the review form data (alerts + detections)
// so that previously saved labels remain visible even if tracking config changed
if (context.formData && typeof context.formData === "object") {
const formData = context.formData as JsonObject;
for (const section of ["alerts", "detections"] as const) {
const sectionData = formData[section];
if (sectionData && typeof sectionData === "object") {
const sectionLabels = (sectionData as JsonObject).labels;
if (Array.isArray(sectionLabels)) {
sectionLabels.forEach((label) => {
if (typeof label === "string") {
labels.add(label);
}
});
}
}
}
}
return [...labels].sort();
}
function getReviewLabelDisplayName(
label: string,
context?: FormContext,
): string {
const fullCameraConfig = context?.fullCameraConfig;
const fullConfig = context?.fullConfig as FrigateConfig | undefined;
const audioLabels =
fullCameraConfig?.audio?.listen ?? fullConfig?.audio?.listen;
const isAudio = Array.isArray(audioLabels) && audioLabels.includes(label);
return getTranslatedLabel(label, isAudio ? "audio" : "object");
}
export function ReviewLabelSwitchesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getReviewLabels,
getDisplayLabel: getReviewLabelDisplayName,
i18nKey: "reviewLabels",
allowCustomEntries: true,
listClassName:
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
}}
/>
);
}

View File

@ -1,6 +1,6 @@
// Generic Switches Widget - Reusable component for selecting from any list of entities // Generic Switches Widget - Reusable component for selecting from any list of entities
import { WidgetProps } from "@rjsf/utils"; import { WidgetProps } from "@rjsf/utils";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -43,6 +43,10 @@ export type SwitchesWidgetOptions = {
listClassName?: string; listClassName?: string;
/** Enable search input to filter the list */ /** Enable search input to filter the list */
enableSearch?: boolean; enableSearch?: boolean;
/** Allow users to add custom entries not in the predefined list */
allowCustomEntries?: boolean;
/** i18n key for a hint shown when no entities are selected */
emptySelectionHintKey?: string;
}; };
function normalizeValue(value: unknown): string[] { function normalizeValue(value: unknown): string[] {
@ -122,20 +126,51 @@ export function SwitchesWidget(props: WidgetProps) {
[props.options], [props.options],
); );
const allowCustomEntries = useMemo(
() => props.options?.allowCustomEntries as boolean | undefined,
[props.options],
);
const emptySelectionHintKey = useMemo(
() => props.options?.emptySelectionHintKey as string | undefined,
[props.options],
);
const selectedEntities = useMemo(() => normalizeValue(value), [value]); const selectedEntities = useMemo(() => normalizeValue(value), [value]);
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0); const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [customEntries, setCustomEntries] = useState<string[]>([]);
const [customInput, setCustomInput] = useState("");
const allEntities = useMemo(() => {
if (customEntries.length === 0) {
return availableEntities;
}
const merged = new Set([...availableEntities, ...customEntries]);
return [...merged].sort();
}, [availableEntities, customEntries]);
const filteredEntities = useMemo(() => { const filteredEntities = useMemo(() => {
if (!enableSearch || !searchTerm.trim()) { if (!enableSearch || !searchTerm.trim()) {
return availableEntities; return allEntities;
} }
const term = searchTerm.toLowerCase(); const term = searchTerm.toLowerCase();
return availableEntities.filter((entity) => { return allEntities.filter((entity) => {
const displayLabel = getDisplayLabel(entity, context); const displayLabel = getDisplayLabel(entity, context);
return displayLabel.toLowerCase().includes(term); return displayLabel.toLowerCase().includes(term);
}); });
}, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]); }, [allEntities, searchTerm, enableSearch, getDisplayLabel, context]);
const addCustomEntry = useCallback(() => {
const trimmed = customInput.trim().toLowerCase();
if (!trimmed || allEntities.includes(trimmed)) {
setCustomInput("");
return;
}
setCustomEntries((prev) => [...prev, trimmed]);
onChange([...selectedEntities, trimmed]);
setCustomInput("");
}, [customInput, allEntities, selectedEntities, onChange]);
const toggleEntity = (entity: string, enabled: boolean) => { const toggleEntity = (entity: string, enabled: boolean) => {
if (enabled) { if (enabled) {
@ -163,7 +198,7 @@ export function SwitchesWidget(props: WidgetProps) {
return ( return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}> <Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="space-y-3"> <div className="space-y-2">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
type="button" type="button"
@ -180,8 +215,14 @@ export function SwitchesWidget(props: WidgetProps) {
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="rounded-lg bg-secondary p-2 pr-0 md:max-w-md"> {emptySelectionHintKey && selectedEntities.length === 0 && t && (
{availableEntities.length === 0 ? ( <div className="mt-0 pb-2 text-sm text-success">
{t(emptySelectionHintKey, { ns: namespace })}
</div>
)}
<CollapsibleContent className="rounded-lg border border-input bg-secondary pb-1 pr-0 pt-2 md:max-w-md">
{allEntities.length === 0 && !allowCustomEntries ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div> <div className="text-sm text-muted-foreground">{emptyMessage}</div>
) : ( ) : (
<> <>
@ -223,6 +264,26 @@ export function SwitchesWidget(props: WidgetProps) {
); );
})} })}
</div> </div>
{allowCustomEntries && !disabled && !readonly && (
<div className="mx-2 mt-2 pb-1">
<Input
type="text"
placeholder={t?.("configForm.addCustomLabel", {
ns: "views/settings",
defaultValue: "Add custom label...",
})}
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomEntry();
}
}}
onBlur={addCustomEntry}
/>
</div>
)}
</> </>
)} )}
</CollapsibleContent> </CollapsibleContent>