ForcePilot/backend/package/yuxi/channels/adapters/msteams/streaming.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

429 lines
15 KiB
Python

from __future__ import annotations
import asyncio
import random
import time
from dataclasses import dataclass
from enum import Enum
from typing import Any, TYPE_CHECKING
from collections.abc import Callable, Awaitable
if TYPE_CHECKING:
from .send import MessageSender
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
STREAM_UPDATE_MIN_INTERVAL_MS = 1500
STREAM_MIN_INITIAL_CHARS = 20
STREAM_MAX_AGE_MS = 45000
STREAM_FALLBACK_CHUNK_SIZE = 2000
DEFAULT_STREAM_CHUNK_LIMIT = 4000
_STREAM_INFORMATIVE_TEXTS = [
"Thinking about this...",
"Working on it...",
"Checking some details...",
"Putting things together...",
]
_SIMPLE_INFORMATIVE_TEXTS = [
"Let me think about that...",
"One moment please...",
"Looking into this...",
]
class StreamPhase(Enum):
INIT = "init"
INFORMATIVE = "informative"
STREAMING = "streaming"
FINAL = "final"
CANCELLED = "cancelled"
@dataclass
class StreamState:
phase: StreamPhase = StreamPhase.INIT
stream_id: str = ""
message_id: str = ""
chat_id: str = ""
accumulated_text: str = ""
last_update_time: float = 0.0
created_at: float = 0.0
chunk_count: int = 0
total_send_count: int = 0
has_fallback: bool = False
cancelled: bool = False
class ReplyStreamController:
"""流与 ReplyDispatcher 协调层。
在 Agent 生成回复过程中,管理流的生命周期回调。
注册 on_reply_start / on_partial_reply / prepare_payload / finalize。
"""
def __init__(self):
self._on_reply_start: Callable[[str], Awaitable[DeliveryResult]] | None = None
self._on_partial_reply: Callable[[str, str], Awaitable[DeliveryResult]] | None = None
self._on_finalize: Callable[[str, str, bool], Awaitable[DeliveryResult]] | None = None
self._prepare_payload: Callable[[str], dict[str, Any]] | None = None
self._active_chats: dict[str, str] = {}
def set_callbacks(
self,
on_reply_start: Callable[[str], Awaitable[DeliveryResult]] | None = None,
on_partial_reply: Callable[[str, str], Awaitable[DeliveryResult]] | None = None,
on_finalize: Callable[[str, str, bool], Awaitable[DeliveryResult]] | None = None,
prepare_payload: Callable[[str], dict[str, Any]] | None = None,
) -> None:
self._on_reply_start = on_reply_start
self._on_partial_reply = on_partial_reply
self._on_finalize = on_finalize
self._prepare_payload = prepare_payload
async def start_reply(self, chat_id: str, initial_text: str) -> str | None:
if self._on_reply_start:
result = await self._on_reply_start(initial_text)
if result.success and result.message_id:
self._active_chats[chat_id] = result.message_id
return result.message_id
return None
async def partial_reply(self, chat_id: str, text: str) -> DeliveryResult | None:
if self._on_partial_reply:
return await self._on_partial_reply(chat_id, text)
return None
async def finalize(self, chat_id: str, text: str, cancelled: bool = False) -> DeliveryResult | None:
if self._on_finalize:
result = await self._on_finalize(chat_id, text, cancelled)
self._active_chats.pop(chat_id, None)
return result
return None
def get_message_id(self, chat_id: str) -> str | None:
return self._active_chats.get(chat_id)
def prepare_activity(self, text: str) -> dict[str, Any]:
if self._prepare_payload:
return self._prepare_payload(text)
return {"type": "message", "text": text, "textFormat": "markdown"}
class TeamsHttpStream:
"""TeamsHttpStream 三阶段流式传输。
三阶段协议:
1. informative — 蓝色进度条阶段,发送状态文本(随机选取)
2. streaming — 流式编辑阶段,持续 update_activity
3. final — 最终消息,流结束
限制: 仅 personal (direct) 聊天支持流式传输,
群组/频道聊天自动降级为块发送。
失败回退: 流中断/超时自动降级为块发送。
"""
def __init__(
self,
sender: MessageSender,
throttle_ms: int = STREAM_UPDATE_MIN_INTERVAL_MS,
min_initial_chars: int = STREAM_MIN_INITIAL_CHARS,
max_stream_age_ms: int = STREAM_MAX_AGE_MS,
chunk_limit: int = DEFAULT_STREAM_CHUNK_LIMIT,
informative_texts: list[str] | None = None,
):
self._sender = sender
self._throttle_ms = throttle_ms
self._min_initial_chars = min_initial_chars
self._max_stream_age_ms = max_stream_age_ms
self._chunk_limit = chunk_limit
self._informative_texts = informative_texts or _STREAM_INFORMATIVE_TEXTS
self._states: dict[str, StreamState] = {}
self._reply_controller = ReplyStreamController()
self._chat_types: dict[str, str] = {}
@property
def reply_controller(self) -> ReplyStreamController:
return self._reply_controller
def register_chat(self, chat_id: str, chat_type: str) -> None:
self._chat_types[chat_id] = chat_type
def supports_streaming(self, chat_id: str) -> bool:
chat_type = self._chat_types.get(chat_id, "group")
return chat_type == "direct"
def _random_informative_text(self) -> str:
return random.choice(self._informative_texts)
async def send_informative(self, chat_id: str) -> DeliveryResult:
state = self._states.get(chat_id)
if state and state.phase == StreamPhase.STREAMING:
return DeliveryResult(success=True, message_id=state.message_id)
if not self.supports_streaming(chat_id):
return DeliveryResult(success=False, error="Streaming not supported for this chat type, fallback to block")
text = self._random_informative_text()
activity = {
"type": "message",
"text": text,
"textFormat": "markdown",
"channelData": {"streamId": str(time.monotonic()).replace(".", ""), "streamType": "informative"},
"entities": [
{
"type": "streaminfo",
"streamId": str(time.monotonic()).replace(".", ""),
"streamType": "informative",
}
],
}
result = await self._sender.send_activity(chat_id, activity)
if result.success and result.message_id:
now = time.monotonic()
self._states[chat_id] = StreamState(
phase=StreamPhase.INFORMATIVE,
stream_id=str(int(now * 1000)),
message_id=result.message_id,
chat_id=chat_id,
created_at=now,
last_update_time=now,
)
return result
async def stream_chunk(self, chat_id: str, chunk: str, finished: bool = False) -> DeliveryResult:
state = self._states.get(chat_id)
if state is None or state.cancelled:
return DeliveryResult(success=False, error="No active stream")
now = time.monotonic()
stream_age_ms = (now - state.created_at) * 1000
if stream_age_ms > self._max_stream_age_ms:
logger.warning(f"MSTeams stream aged out for {chat_id} ({stream_age_ms:.0f}ms)")
state.cancelled = True
error_activity = {
"type": "message",
"text": "⏰ 响应生成超时,请稍后重试。",
"textFormat": "markdown",
}
try:
await self._sender.update_activity(state.chat_id, state.message_id, error_activity)
except Exception:
pass
self._states.pop(chat_id, None)
return DeliveryResult(success=False, error="Stream aged out, fallback to block")
state.accumulated_text += chunk
state.chunk_count += 1
if state.phase == StreamPhase.INFORMATIVE:
if len(state.accumulated_text) >= self._min_initial_chars:
state.phase = StreamPhase.STREAMING
else:
return DeliveryResult(success=True, message_id=state.message_id)
elapsed_since_update = (now - state.last_update_time) * 1000
if elapsed_since_update < self._throttle_ms and not finished:
state.total_send_count += 1
return DeliveryResult(success=True, message_id=state.message_id)
result = await self._send_stream_update(state, finished)
state.last_update_time = time.monotonic()
state.total_send_count += 1
return result
async def _send_stream_update(self, state: StreamState, finished: bool) -> DeliveryResult:
text = state.accumulated_text[: self._chunk_limit]
activity = {
"type": "message",
"text": text,
"textFormat": "markdown",
}
if state.phase == StreamPhase.STREAMING:
activity["entities"] = [
{
"type": "streaminfo",
"streamId": state.stream_id,
"streamType": "streaming",
}
]
elif finished:
activity["entities"] = [
{
"type": "streaminfo",
"streamId": state.stream_id,
"streamType": "final",
}
]
result = await self._sender.update_activity(state.chat_id, state.message_id, activity)
if finished:
state.phase = StreamPhase.FINAL
self._states.pop(state.chat_id, None)
else:
state.last_update_time = time.monotonic()
return result
async def finalize(self, chat_id: str) -> DeliveryResult:
state = self._states.get(chat_id)
if not state:
return DeliveryResult(success=False, error="No active stream")
result = await self._send_stream_update(state, finished=True)
self._states.pop(chat_id, None)
return result
async def cancel(self, chat_id: str) -> None:
state = self._states.get(chat_id)
if state:
state.cancelled = True
cancel_activity = {
"type": "message",
"text": "🔄 响应已取消。",
"textFormat": "markdown",
}
try:
await self._sender.update_activity(state.chat_id, state.message_id, cancel_activity)
except Exception:
pass
self._states.pop(chat_id, None)
def get_message_id(self, chat_id: str) -> str | None:
state = self._states.get(chat_id)
return state.message_id if state else None
def is_active(self, chat_id: str) -> bool:
return chat_id in self._states and not self._states[chat_id].cancelled
def clear(self) -> None:
self._states.clear()
@property
def active_count(self) -> int:
return len([s for s in self._states.values() if not s.cancelled])
class StreamManager:
"""兼容层:保持旧 StreamManager 接口,内部委托给 TeamsHttpStream。
逐步迁移到 TeamsHttpStream。
"""
def __init__(self, sender: MessageSender | None = None):
self._messages: dict[str, str] = {}
self._texts: dict[str, str] = {}
self._last_update: dict[str, float] = {}
self._locks: dict[str, asyncio.Lock] = {}
self._stream: TeamsHttpStream | None = None
self._sender: MessageSender | None = sender
def _get_lock(self, chat_id: str) -> asyncio.Lock:
if chat_id not in self._locks:
self._locks[chat_id] = asyncio.Lock()
return self._locks[chat_id]
def set_sender(self, sender: MessageSender) -> None:
self._sender = sender
@property
def http_stream(self) -> TeamsHttpStream | None:
return self._stream
def ensure_http_stream(self) -> TeamsHttpStream:
if self._stream is None:
if self._sender is None:
raise RuntimeError("StreamManager: sender not set")
self._stream = TeamsHttpStream(self._sender)
return self._stream
def has_pending(self, chat_id: str) -> bool:
return chat_id in self._messages
def get_message_id(self, chat_id: str) -> str | None:
return self._messages.get(chat_id)
def register_message(self, chat_id: str, message_id: str, text: str) -> None:
self._messages[chat_id] = message_id
self._texts[chat_id] = text
self._last_update[chat_id] = time.monotonic()
async def append_text(self, chat_id: str, chunk: str) -> str:
async with self._get_lock(chat_id):
current = self._texts.get(chat_id, "")
current += chunk
self._texts[chat_id] = current
return current
def should_update(self, chat_id: str) -> bool:
last = self._last_update.get(chat_id, 0)
elapsed_ms = (time.monotonic() - last) * 1000
return elapsed_ms >= STREAM_UPDATE_MIN_INTERVAL_MS
def mark_update(self, chat_id: str) -> None:
self._last_update[chat_id] = time.monotonic()
async def send_update(
self,
sender: MessageSender,
chat_id: str,
finished: bool = False,
chunk_limit: int = DEFAULT_STREAM_CHUNK_LIMIT,
) -> DeliveryResult | None:
async with self._get_lock(chat_id):
message_id = self._messages.get(chat_id)
text = self._texts.get(chat_id)
if not message_id or text is None:
return None
activity = {
"type": "message",
"text": text[:chunk_limit],
"textFormat": "markdown",
}
if finished:
self._messages.pop(chat_id, None)
self._texts.pop(chat_id, None)
self._last_update.pop(chat_id, None)
self._locks.pop(chat_id, None)
else:
self._last_update[chat_id] = time.monotonic()
return await sender.update_activity(chat_id, message_id, activity)
def clear(self) -> None:
self._messages.clear()
self._texts.clear()
self._last_update.clear()
self._locks.clear()
if self._stream:
self._stream.clear()
@property
def pending_count(self) -> int:
return len(self._messages)
def register_chat_type(self, chat_id: str, chat_type: str) -> None:
stream = self.ensure_http_stream()
stream.register_chat(chat_id, chat_type)
def supports_streaming(self, chat_id: str) -> bool:
if self._stream:
return self._stream.supports_streaming(chat_id)
return True
_DEFAULT_BLOCK_STREAMING_COALESCE = {
"minChars": 1500,
"idleMs": 1000,
}