ForcePilot/backend/package/yuxi/channels/adapters/bluebubbles/ai_vision.py
Kris 6aa1d52a8b feat(bluebubbles): 实现完整的BlueBubbles适配器基础组件
新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
2026-05-12 00:42:57 +08:00

137 lines
4.8 KiB
Python

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