from __future__ import annotations import base64 import hashlib from typing import Any from yuxi.utils.logging_config import logger class VisionCache: def __init__(self, max_entries: int = 512): self._cache: dict[str, dict[str, Any]] = {} self._max_entries = max_entries def get(self, file_hash: str) -> dict[str, Any] | None: return self._cache.get(file_hash) def put(self, file_hash: str, metadata: dict[str, Any]) -> None: if len(self._cache) >= self._max_entries: oldest = next(iter(self._cache)) del self._cache[oldest] self._cache[file_hash] = metadata def clear(self) -> None: self._cache.clear() @property def size(self) -> int: return len(self._cache) class BlueBubblesVision: def __init__(self, config: dict[str, Any] | None = None): self._config = config or {} self._enabled = self._config.get("ai_vision_enabled", False) self._model = self._config.get("ai_vision_model", "gpt-4o-mini") self._api_url = self._config.get("ai_vision_api_url", "") self._api_key = self._config.get("ai_vision_api_key", "") self._max_tokens = self._config.get("ai_vision_max_tokens", 150) self._timeout = self._config.get("ai_vision_timeout_sec", 15) max_cache = self._config.get("max_cache_entries", 512) self._cache = VisionCache(max_entries=max_cache) @property def enabled(self) -> bool: return self._enabled and bool(self._api_url) async def describe_image(self, image_data: bytes, filename: str = "image.jpg") -> str: if not self._enabled: return "" file_hash = hashlib.sha256(image_data).hexdigest()[:32] cached = self._cache.get(file_hash) if cached: logger.debug(f"[BlueBubbles] Vision cache hit: {file_hash}") return cached.get("description", "") try: description = await self._call_vision_api(image_data, filename) if description: self._cache.put(file_hash, {"description": description, "filename": filename}) return description except Exception as e: logger.warning(f"[BlueBubbles] Vision API failed: {e}") return "" async def _call_vision_api(self, image_data: bytes, filename: str) -> str: encoded = base64.b64encode(image_data).decode("utf-8") mime_type = "image/jpeg" if filename.lower().endswith(".png"): mime_type = "image/png" elif filename.lower().endswith(".gif"): mime_type = "image/gif" elif filename.lower().endswith(".webp"): mime_type = "image/webp" payload = { "model": self._model, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image concisely in one sentence. Focus on what is depicted.", }, { "type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{encoded}"}, }, ], } ], "max_tokens": self._max_tokens, } headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {} try: import aiohttp async with aiohttp.ClientSession() as session: async with session.post( self._api_url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=self._timeout), ) as resp: if resp.status == 200: data = await resp.json() return data["choices"][0]["message"]["content"] logger.warning(f"[BlueBubbles] Vision API returned status {resp.status}") return "" except ImportError: logger.warning("[BlueBubbles] aiohttp not available for vision API") return "" except Exception as e: logger.warning(f"[BlueBubbles] Vision API call failed: {e}") return "" async def analyze_message_media( vision: BlueBubblesVision | None, media_data: bytes, filename: str = "attachment", mime_type: str = "", ) -> str | None: if vision is None or not vision.enabled: return None if not mime_type.startswith("image/"): return None try: return await vision.describe_image(media_data, filename) except Exception as e: logger.warning(f"[BlueBubbles] Media analysis failed: {e}") return None