ForcePilot/backend/package/yuxi/channels/adapters/bluebubbles/streaming_enhanced.py
Kris 3cfb7dee25 refactor(bluebubbles): 重构并新增多项蓝泡泡适配器功能
本次提交对蓝泡泡适配器进行了多维度改进:
1. 新增缓存超限自动清理逻辑,防止内存溢出
2. 增强流式打字同步功能,增加超时自动停止逻辑
3. 重构markdown渲染逻辑,修复格式匹配问题并支持更多语法
4. 新增配置校验工具函数,增加streaming_mode和auth_strategy的合法性校验
5. 新增多项环境配置参数,完善配置项
6. 重构初始化流程,新增多个工具类实例
7. 优化TTS禁用逻辑,新增aiohttp依赖检查
8. 重构流式消息发送逻辑,整合打字指示器逻辑
9. 新增健康状态检查的问题检测功能
2026-05-13 16:05:25 +08:00

132 lines
4.6 KiB
Python

from __future__ import annotations
import asyncio
import time
from typing import Any
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
from yuxi.channels.adapters.bluebubbles.send import send_typing_indicator
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
class StreamingTypingSync:
TYPING_REFRESH_INTERVAL = 8.0
DEFAULT_STREAM_TIMEOUT = 60.0
def __init__(
self,
client: BlueBubblesClient | None = None,
stream_timeout: float = DEFAULT_STREAM_TIMEOUT,
):
self._client = client
self._active_chats: set[str] = set()
self._last_typing: dict[str, float] = {}
self._keepalive_task: asyncio.Task | None = None
self._running = False
self._stream_timeout = stream_timeout
self._stream_started: dict[str, float] = {}
def set_client(self, client: BlueBubblesClient) -> None:
self._client = client
async def start_typing(self, chat_id: str) -> None:
if not self._client:
return
self._active_chats.add(chat_id)
self._stream_started.setdefault(chat_id, time.monotonic())
try:
await send_typing_indicator(self._client, chat_id, display=True)
self._last_typing[chat_id] = time.monotonic()
except Exception:
logger.debug(f"[BlueBubbles] Failed to start typing indicator for {chat_id}")
if not self._running:
self._running = True
self._keepalive_task = asyncio.create_task(self._typing_keepalive())
async def stop_typing(self, chat_id: str) -> None:
self._active_chats.discard(chat_id)
self._stream_started.pop(chat_id, None)
if not self._client:
return
try:
await send_typing_indicator(self._client, chat_id, display=False)
self._last_typing.pop(chat_id, None)
except Exception:
logger.debug(f"[BlueBubbles] Failed to stop typing indicator for {chat_id}")
if not self._active_chats:
self._running = False
if self._keepalive_task and not self._keepalive_task.done():
self._keepalive_task.cancel()
self._keepalive_task = None
async def stop_all(self) -> None:
chats = list(self._active_chats)
for chat_id in chats:
await self.stop_typing(chat_id)
async def _typing_keepalive(self) -> None:
while self._running and self._active_chats:
await asyncio.sleep(self.TYPING_REFRESH_INTERVAL)
if not self._client:
continue
now = time.monotonic()
for chat_id in list(self._active_chats):
last = self._last_typing.get(chat_id, 0)
if now - last >= self.TYPING_REFRESH_INTERVAL:
try:
await send_typing_indicator(self._client, chat_id, display=True)
self._last_typing[chat_id] = now
except Exception:
logger.debug(f"[BlueBubbles] Typing keepalive failed for {chat_id}")
stream_started = self._stream_started.get(chat_id)
if stream_started and (now - stream_started) > self._stream_timeout:
logger.warning(
f"[BlueBubbles] Stream timeout for {chat_id}, "
f"({now - stream_started:.0f}s), auto-stopping typing"
)
await self.stop_typing(chat_id)
async def send_stream_with_typing(
adapter: Any,
chat_id: str,
msg_id: str,
chunk: str,
finished: bool,
typing_sync: StreamingTypingSync | None = None,
) -> DeliveryResult:
if typing_sync and not finished:
await typing_sync.start_typing(chat_id)
result = await adapter.send_stream_chunk(chat_id, msg_id, chunk, finished)
if finished and typing_sync:
await typing_sync.stop_typing(chat_id)
return result
class StreamBuffer:
def __init__(self, max_buffer_size: int = 65536):
self._max_buffer_size = max_buffer_size
self._buffers: dict[str, str] = {}
def append(self, stream_id: str, text: str) -> str:
current = self._buffers.get(stream_id, "")
new_content = current + text
if len(new_content) > self._max_buffer_size:
new_content = new_content[-self._max_buffer_size :]
self._buffers[stream_id] = new_content
return new_content
def get(self, stream_id: str) -> str:
return self._buffers.get(stream_id, "")
def clear(self, stream_id: str) -> None:
self._buffers.pop(stream_id, None)