Compare commits

..

2 Commits

Author SHA1 Message Date
dependabot[bot]
645c0b2213 Bump python-multipart from 0.0.20 to 0.0.22 in /docker/main
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.20 to 0.0.22.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.20...0.0.22)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.22
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 23:47:36 +00:00
Josh Hawkins
50ac5a1483 Miscellaneous fixes (0.17 beta) (#21764)
* Add 640x640 Intel NPU stats

* use css instead of js for reviewed button hover state in filmstrip

* update copilot instructions to copy HA's format

* Set json schema for genai

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-01-25 18:59:25 -07:00
9 changed files with 120 additions and 62 deletions

View File

@@ -11,7 +11,7 @@ joserfc == 1.2.*
cryptography == 44.0.*
pathvalidate == 3.3.*
markupsafe == 3.0.*
python-multipart == 0.0.20
python-multipart == 0.0.22
# Classification Model Training
tensorflow == 2.19.* ; platform_machine == 'aarch64'
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'

View File

@@ -140,7 +140,12 @@ Each line represents a detection state, not necessarily unique individuals. Pare
) as f:
f.write(context_prompt)
response = self._send(context_prompt, thumbnails)
json_schema = {
"name": "review_metadata",
"schema": ReviewMetadata.model_json_schema(),
"strict": True,
}
response = self._send(context_prompt, thumbnails, json_schema=json_schema)
if debug_save and response:
with open(
@@ -152,6 +157,8 @@ Each line represents a detection state, not necessarily unique individuals. Pare
f.write(response)
if response:
# With JSON schema, response should already be valid JSON
# But keep regex cleanup as fallback for providers without schema support
clean_json = re.sub(
r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response)
)
@@ -284,8 +291,16 @@ Guidelines:
"""Initialize the client."""
return None
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
"""Submit a request to the provider."""
def _send(
self, prompt: str, images: list[bytes], json_schema: Optional[dict] = None
) -> Optional[str]:
"""Submit a request to the provider.
Args:
prompt: The text prompt to send
images: List of image bytes to include
json_schema: Optional JSON schema for structured output (provider-specific support)
"""
return None
def get_context_size(self) -> int:

View File

@@ -41,29 +41,46 @@ class OpenAIClient(GenAIClient):
azure_endpoint=azure_endpoint,
)
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
def _send(
self, prompt: str, images: list[bytes], json_schema: Optional[dict] = None
) -> Optional[str]:
"""Submit a request to Azure OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
request_params = {
"model": self.genai_config.model,
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": prompt}]
+ [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
for image in encoded_images
],
},
],
"timeout": self.timeout,
}
if json_schema:
request_params["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": json_schema.get("name", "response"),
"schema": json_schema.get("schema", {}),
"strict": json_schema.get("strict", True),
},
}
try:
result = self.provider.chat.completions.create(
model=self.genai_config.model,
messages=[
{
"role": "user",
"content": [{"type": "text", "text": prompt}]
+ [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
for image in encoded_images
],
},
],
timeout=self.timeout,
**request_params,
**self.genai_config.runtime_options,
)
except Exception as e:

View File

@@ -41,7 +41,9 @@ class GeminiClient(GenAIClient):
http_options=types.HttpOptions(**http_options_dict),
)
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
def _send(
self, prompt: str, images: list[bytes], json_schema: Optional[dict] = None
) -> Optional[str]:
"""Submit a request to Gemini."""
contents = [
types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images
@@ -51,6 +53,12 @@ class GeminiClient(GenAIClient):
generation_config_dict = {"candidate_count": 1}
generation_config_dict.update(self.genai_config.runtime_options)
if json_schema and "schema" in json_schema:
generation_config_dict["response_mime_type"] = "application/json"
generation_config_dict["response_schema"] = types.Schema(
json_schema=json_schema["schema"]
)
response = self.provider.models.generate_content(
model=self.genai_config.model,
contents=contents,

View File

@@ -50,7 +50,9 @@ class OllamaClient(GenAIClient):
logger.warning("Error initializing Ollama: %s", str(e))
return None
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
def _send(
self, prompt: str, images: list[bytes], json_schema: Optional[dict] = None
) -> Optional[str]:
"""Submit a request to Ollama"""
if self.provider is None:
logger.warning(
@@ -62,6 +64,10 @@ class OllamaClient(GenAIClient):
**self.provider_options,
**self.genai_config.runtime_options,
}
if json_schema and "schema" in json_schema:
ollama_options["format"] = json_schema["schema"]
result = self.provider.generate(
self.genai_config.model,
prompt,

View File

@@ -31,7 +31,9 @@ class OpenAIClient(GenAIClient):
}
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
def _send(
self, prompt: str, images: list[bytes], json_schema: Optional[dict] = None
) -> Optional[str]:
"""Submit a request to OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
messages_content = []
@@ -51,16 +53,31 @@ class OpenAIClient(GenAIClient):
"text": prompt,
}
)
request_params = {
"model": self.genai_config.model,
"messages": [
{
"role": "user",
"content": messages_content,
},
],
"timeout": self.timeout,
}
if json_schema:
request_params["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": json_schema.get("name", "response"),
"schema": json_schema.get("schema", {}),
"strict": json_schema.get("strict", True),
},
}
try:
result = self.provider.chat.completions.create(
model=self.genai_config.model,
messages=[
{
"role": "user",
"content": messages_content,
},
],
timeout=self.timeout,
**request_params,
**self.genai_config.runtime_options,
)
if (

View File

@@ -12,7 +12,7 @@ import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { baseUrl } from "@/api/baseUrl";
import { VideoPreview } from "../preview/ScrubbablePreview";
import { useApiHost } from "@/api";
import { isDesktop, isSafari } from "react-device-detect";
import { isSafari } from "react-device-detect";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button";
@@ -87,7 +87,6 @@ export function AnimatedEventCard({
}, [visibilityListener]);
const [isLoaded, setIsLoaded] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// interaction
@@ -134,31 +133,27 @@ export function AnimatedEventCard({
<Tooltip>
<TooltipTrigger asChild>
<div
className="relative h-24 flex-shrink-0 overflow-hidden rounded md:rounded-lg 4k:h-32"
className="group relative h-24 flex-shrink-0 overflow-hidden rounded md:rounded-lg 4k:h-32"
style={{
aspectRatio: alertVideos ? aspectRatio : undefined,
}}
onMouseEnter={isDesktop ? () => setIsHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setIsHovered(false) : undefined}
>
{isHovered && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="absolute left-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
size="xs"
aria-label={t("markAsReviewed")}
onClick={async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents();
}}
>
<FaCircleCheck className="size-3 text-white" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("markAsReviewed")}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
className="pointer-events-none absolute left-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"
size="xs"
aria-label={t("markAsReviewed")}
onClick={async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents();
}}
>
<FaCircleCheck className="size-3 text-white" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("markAsReviewed")}</TooltipContent>
</Tooltip>
{previews != undefined && alertVideosLoaded && (
<div
className="size-full cursor-pointer"

View File

@@ -173,9 +173,9 @@ function getVerifiedIcon(
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
return (
<div key={label} className="relative flex items-center">
<div key={label} className="flex items-center">
{getIconForLabel(simpleLabel, type, className)}
<FaCheckCircle className="absolute -bottom-0.5 -right-0.5 size-2" />
<FaCheckCircle className="absolute size-2 translate-x-[80%] translate-y-3/4" />
</div>
);
}
@@ -188,9 +188,9 @@ function getRecognizedPlateIcon(
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
return (
<div key={label} className="relative inline-flex items-center">
<div key={label} className="flex items-center">
{getIconForLabel(simpleLabel, type, className)}
<LuScanBarcode className="absolute -bottom-0.5 -right-0.5 size-2" />
<LuScanBarcode className="absolute size-2.5 translate-x-[50%] translate-y-3/4" />
</div>
);
}

View File

@@ -391,7 +391,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
);
return (
<div className="scrollbar-container relative flex w-full flex-col overflow-y-auto">
<div className="scrollbar-container flex w-full flex-col overflow-y-auto">
{objects && objects.length > 0 ? (
objects.map((obj: ObjectType) => {
return (