ForcePilot/backend/package/yuxi/channel/message/block_reply_pipeline.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

241 lines
7.7 KiB
Python

import asyncio
import logging
from collections.abc import Callable, Coroutine
from typing import Any
from yuxi.channel.message.models import ChunkType, ReplyPayload, ReplyStage, StreamingChunk, UnifiedMessage
from yuxi.channel.message.reply_dispatcher import ReplyDispatcher
from yuxi.channel.streaming.block_chunker import BlockReplyCoalescer
from yuxi.channel.streaming.models import BlockReplyCoalescing
logger = logging.getLogger(__name__)
_DEFAULT_COALESCE_MIN_CHARS = 80
_DEFAULT_COALESCE_MAX_CHARS = 2000
_DEFAULT_COALESCE_IDLE_MS = 150.0
_DEFAULT_TIMEOUT_MS = 10_000.0
def _handle_pipeline_task_exception(task: asyncio.Task) -> None:
try:
task.result()
except Exception:
logger.exception("Unhandled exception in pipeline background task")
class BlockReplyPipeline:
def __init__(
self,
dispatcher: ReplyDispatcher,
*,
min_chars: int = _DEFAULT_COALESCE_MIN_CHARS,
max_chars: int = _DEFAULT_COALESCE_MAX_CHARS,
debounce_ms: float = _DEFAULT_COALESCE_IDLE_MS,
timeout_ms: float = _DEFAULT_TIMEOUT_MS,
):
self._dispatcher = dispatcher
self._timeout_ms = timeout_ms
self._sent_payload_keys: set[str] = set()
self._sent_content_keys: set[str] = set()
self._sent_media_urls: set[str] = set()
self._pending_keys: set[str] = set()
self._payload_seq: int = 0
self._streamed_text_fragments: list[str] = []
self._aborted = False
self._did_stream = False
self._did_log_timeout = False
self._send_chain = asyncio.Lock()
coalesce_config = BlockReplyCoalescing(
min_chars=max(1, min_chars),
max_chars=max(max(1, min_chars), max_chars),
idle_ms=int(debounce_ms),
joiner="",
)
self._coalescer = BlockReplyCoalescer(
config=coalesce_config,
on_flush=self._on_coalesced_flush,
is_stopped=lambda: self._aborted,
)
async def enqueue(self, chunk: StreamingChunk) -> None:
if self._aborted:
return
if chunk.stage == ReplyStage.TOOL:
self._coalescer.flush()
await self._enqueue_tool(chunk)
return
if chunk.stage == ReplyStage.BLOCK and chunk.chunk_type == ChunkType.TEXT_DELTA:
if chunk.content:
self._coalescer.enqueue(chunk.content)
return
if chunk.stage == ReplyStage.FINAL:
self._coalescer.flush()
await self._dispatcher.enqueue(chunk)
return
self._coalescer.flush()
await self._dispatcher.enqueue(chunk)
def _on_coalesced_flush(self, text: str) -> None:
payload = ReplyPayload(target_id="", content=text)
task = asyncio.create_task(self._send_payload(payload))
task.add_done_callback(_handle_pipeline_task_exception)
async def _enqueue_tool(self, chunk: StreamingChunk) -> None:
payload = ReplyPayload(
target_id="",
content=chunk.content or "",
)
await self._send_payload(payload)
async def _send_payload(self, payload: ReplyPayload) -> None:
if self._aborted:
return
self._payload_seq += 1
dedup_key = f"{self._payload_seq}|{payload.payload_key}"
content_key = payload.content_key
if dedup_key in self._sent_payload_keys or dedup_key in self._pending_keys:
return
self._pending_keys.add(dedup_key)
try:
async with self._send_chain:
if self._aborted:
return
chunk = StreamingChunk(
stage=ReplyStage.BLOCK,
chunk_type=ChunkType.TEXT_DELTA,
content=payload.content,
)
try:
await asyncio.wait_for(
self._dispatcher.enqueue(chunk),
timeout=self._timeout_ms / 1000,
)
except TimeoutError:
self._aborted = True
if not self._did_log_timeout:
self._did_log_timeout = True
logger.warning(
"Block reply delivery timed out after %.0fms; "
"aborting remaining replies to preserve ordering",
self._timeout_ms,
)
return
self._sent_payload_keys.add(dedup_key)
self._sent_content_keys.add(content_key)
for url in payload.media_urls:
self._sent_media_urls.add(url)
if not payload.media_urls and payload.content.strip():
self._streamed_text_fragments.append(payload.content.strip())
self._did_stream = True
finally:
self._pending_keys.discard(dedup_key)
async def flush(self, *, force: bool = False) -> None:
if force or self._coalescer.has_buffered():
self._coalescer.flush()
async with self._send_chain:
pass
def stop(self) -> None:
self._coalescer.stop()
def has_buffered(self) -> bool:
return self._coalescer.has_buffered()
def did_stream(self) -> bool:
return self._did_stream
def is_aborted(self) -> bool:
return self._aborted
def get_sent_media_urls(self) -> list[str]:
return list(self._sent_media_urls)
def has_sent_payload(self, content: str) -> bool:
stripped = content.strip()
if stripped in self._sent_content_keys:
return True
if not self._did_stream or not self._streamed_text_fragments:
return False
def _normalize(s: str) -> str:
return "".join(s.split())
return _normalize("".join(self._streamed_text_fragments)) == _normalize(stripped)
def track_media_sent(self, media_url: str) -> None:
self._sent_media_urls.add(media_url)
def has_sent_media(self, media_url: str) -> bool:
return media_url in self._sent_media_urls
@property
def streamed_text(self) -> str:
return "".join(self._streamed_text_fragments)
@property
def sent_payload_count(self) -> int:
return len(self._sent_payload_keys)
@property
def sent_content_count(self) -> int:
return len(self._sent_content_keys)
@property
def sent_media_count(self) -> int:
return len(self._sent_media_urls)
async def abort(self) -> None:
self._aborted = True
await self._dispatcher.abort()
async def wait_idle(self, timeout_ms: float | None = None) -> None:
await self._dispatcher.wait_idle(timeout_ms)
def create_block_reply_pipeline(
send_fn: Callable[..., Coroutine[Any, Any, str | None]],
msg: UnifiedMessage,
*,
response_prefix: str = "",
human_delay: tuple[float, float] | None = None,
min_chars: int = _DEFAULT_COALESCE_MIN_CHARS,
max_chars: int = _DEFAULT_COALESCE_MAX_CHARS,
debounce_ms: float = _DEFAULT_COALESCE_IDLE_MS,
timeout_ms: float = _DEFAULT_TIMEOUT_MS,
on_idle: Callable[[], None] | None = None,
on_error: Callable[[Exception], None] | None = None,
before_deliver: Callable[[str], Coroutine[Any, Any, str | None]] | None = None,
) -> BlockReplyPipeline:
dispatcher = ReplyDispatcher(
send_fn=send_fn,
msg=msg,
response_prefix=response_prefix,
human_delay=human_delay,
timeout_ms=timeout_ms,
on_idle=on_idle,
on_error=on_error,
before_deliver=before_deliver,
)
return BlockReplyPipeline(
dispatcher,
min_chars=min_chars,
max_chars=max_chars,
debounce_ms=debounce_ms,
timeout_ms=timeout_ms,
)