ForcePilot/backend/package/yuxi/channel/message/reply_dispatcher.py
Kris 0b19470c70 feat(channel/message): 新增消息处理核心模块及相关工具类
本次提交新增了渠道消息处理的完整核心模块,包含以下核心功能:
1.  新增会话围栏类,实现会话并发控制与过期清理
2.  新增媒体清理器,实现过期媒体文件自动清理
3.  新增熔断器组件,实现服务降级与故障隔离
4.  新增消息处理器,完成渠道消息的完整流转处理
5.  新增限流器组件,实现渠道级和账户级流量控制
6.  新增链路追踪模块,集成Langfuse实现调用链路监控
7.  新增指标统计模块,实现消息处理全链路指标采集
8.  新增统一消息模型,封装全渠道消息格式
9.  新增块回复流水线,实现流式回复的合并与去重
10. 新增本地媒体存储模块,实现媒体文件的本地管理
11. 新增回复分发器,实现回复内容的有序发送与延迟处理
12. 完善__init__.py导出所有核心模块与工具类
2026-05-21 10:27:16 +08:00

270 lines
8.6 KiB
Python

import asyncio
import logging
import random
import time
from collections.abc import Callable, Coroutine
from typing import Any
from yuxi.channel.message.models import ReplyPayload, ReplyStage, StreamingChunk, UnifiedMessage
logger = logging.getLogger(__name__)
_DEFAULT_MIN_CHARS = 80
_DEFAULT_DEBOUNCE_MS = 150.0
_DEFAULT_TIMEOUT_MS = 10_000.0
OnIdleCallback = Callable[[], None]
OnErrorCallback = Callable[[Exception], None]
BeforeDeliverCallback = Callable[[str], Coroutine[Any, Any, str | None]]
class ReplyDispatcher:
def __init__(
self,
send_fn: Callable[..., Coroutine[Any, Any, str | None]],
msg: UnifiedMessage,
*,
response_prefix: str = "",
human_delay: tuple[float, float] | None = None,
timeout_ms: float = _DEFAULT_TIMEOUT_MS,
on_idle: OnIdleCallback | None = None,
on_error: OnErrorCallback | None = None,
before_deliver: BeforeDeliverCallback | None = None,
):
self._send_fn = send_fn
self._msg = msg
self._response_prefix = response_prefix
self._human_delay = human_delay
self._timeout_ms = timeout_ms
self._on_idle = on_idle
self._on_error = on_error
self._before_deliver = before_deliver
self._send_chain: asyncio.Lock = asyncio.Lock()
self._flush_lock: asyncio.Lock = asyncio.Lock()
self._pending = 0
self._complete = asyncio.Event()
self._done = False
self._blocks: list[str] = []
self._block_deadline: float = 0.0
self._block_timer: asyncio.Task | None = None
self._sent_payload_keys: set[str] = set()
self._sent_content_keys: set[str] = set()
self._tool_count = 0
self._block_count = 0
self._final_count = 0
self._failed_count = 0
self._cancelled_count = 0
self._started_at: float | None = None
self._abort_event = asyncio.Event()
async def enqueue(self, chunk: StreamingChunk) -> bool:
if self._abort_event.is_set():
self._cancelled_count += 1
return False
if chunk.stage == ReplyStage.TOOL:
return await self._dispatch_tool(chunk)
elif chunk.stage == ReplyStage.BLOCK:
return await self._dispatch_block(chunk)
elif chunk.stage == ReplyStage.FINAL:
return await self._dispatch_final(chunk)
return False
async def _dispatch_tool(self, chunk: StreamingChunk) -> bool:
self._pending += 1
self._tool_count += 1
try:
prefix = self._response_prefix if self._response_prefix else ""
content = f"{prefix}{chunk.content}" if chunk.content else ""
if not content:
return False
await self._send_ordered(content)
self._block_count += 1
return True
except Exception as e:
logger.exception("Tool dispatch failed")
self._failed_count += 1
if self._on_error:
self._on_error(e)
raise
finally:
self._pending -= 1
self._maybe_fire_idle()
async def _dispatch_block(self, chunk: StreamingChunk) -> bool:
if not chunk.content:
return False
async with self._flush_lock:
self._blocks.append(chunk.content)
now = time.monotonic()
if self._block_deadline == 0.0:
self._block_deadline = now + _DEFAULT_DEBOUNCE_MS / 1000.0
self._block_timer = asyncio.create_task(self._flush_after_debounce())
if sum(len(b) for b in self._blocks) >= _DEFAULT_MIN_CHARS:
await self._flush_blocks()
return True
async def _flush_after_debounce(self) -> None:
try:
await asyncio.sleep(_DEFAULT_DEBOUNCE_MS / 1000.0)
except asyncio.CancelledError:
return
async with self._flush_lock:
await self._flush_blocks()
async def _flush_blocks(self) -> None:
if not self._blocks:
return
combined = "".join(self._blocks)
self._blocks.clear()
self._block_deadline = 0.0
if self._block_timer and not self._block_timer.done():
self._block_timer.cancel()
self._block_timer = None
payload = ReplyPayload(
target_id=self._target_id,
content=combined,
reply_to_id=self._msg.msg_id if self._block_count == 0 else None,
)
if payload.content_key in self._sent_content_keys:
return
self._sent_content_keys.add(payload.content_key)
self._pending += 1
self._block_count += 1
try:
await self._send_ordered(combined)
except Exception as e:
logger.exception("Block dispatch failed")
self._failed_count += 1
if self._on_error:
self._on_error(e)
raise
finally:
self._pending -= 1
self._maybe_fire_idle()
async def _dispatch_final(self, chunk: StreamingChunk) -> bool:
async with self._flush_lock:
await self._flush_blocks()
if chunk.content and chunk.content != "finished":
self._pending += 1
self._final_count += 1
try:
await self._send_ordered(chunk.content)
except Exception as e:
logger.exception("Final dispatch failed")
self._failed_count += 1
if self._on_error:
self._on_error(e)
raise
finally:
self._pending -= 1
self._maybe_fire_idle()
self._done = True
self._complete.set()
return True
self._done = True
self._complete.set()
self._maybe_fire_idle()
return True
async def _send_ordered(self, content: str) -> None:
async with self._send_chain:
if self._abort_event.is_set():
return
if self._human_delay and self._block_count > 0:
delay = self._human_delay[0] + (self._human_delay[1] - self._human_delay[0]) * random.random()
await asyncio.sleep(delay / 1000.0)
if self._response_prefix and self._block_count == 0:
prefix = self._response_prefix.format(
agent_name=self._msg.channel_type,
model="",
)
content = f"{prefix}{content}"
if self._before_deliver:
try:
content = await self._before_deliver(content)
except Exception as e:
logger.exception("before_deliver hook failed")
if self._on_error:
self._on_error(e)
return
if content is None:
self._cancelled_count += 1
return
await self._send_fn(content)
@property
def _target_id(self) -> str:
return self._msg.group.id if self._msg.group and self._msg.group.id else self._msg.sender.id
async def wait_idle(self, timeout_ms: float | None = None) -> None:
timeout = timeout_ms or self._timeout_ms
try:
await asyncio.wait_for(self._complete.wait(), timeout=timeout / 1000.0)
except TimeoutError:
self._abort_event.set()
self._done = True
self._complete.set()
logger.warning("ReplyDispatcher idle timeout after %.0fms, aborting", timeout)
def mark_complete(self) -> None:
self._done = True
self._complete.set()
self._maybe_fire_idle()
async def abort(self) -> None:
async with self._flush_lock:
if self._blocks:
await self._flush_blocks()
self._abort_event.set()
self._complete.set()
def _maybe_fire_idle(self) -> None:
if self._done and self._pending == 0 and self._on_idle:
self._on_idle()
@property
def tool_count(self) -> int:
return self._tool_count
@property
def block_count(self) -> int:
return self._block_count
@property
def final_count(self) -> int:
return self._final_count
@property
def failed_count(self) -> int:
return self._failed_count
@property
def cancelled_count(self) -> int:
return self._cancelled_count
@property
def queued_counts(self) -> dict[str, int]:
return {"tool": self._tool_count, "block": self._block_count, "final": self._final_count}
@property
def is_idle(self) -> bool:
return self._done and self._pending == 0