这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
429 lines
15 KiB
Python
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,
|
|
}
|