ForcePilot/backend/package/yuxi/channel/capabilities.py
Kris a48f7ebf2a feat(channel): 新增通道基础模块与UI schema定义
本次提交新增了通道模块的完整基础实现:
1.  搭建了channel包的顶层导出结构,整合所有核心子模块接口
2.  实现了错误处理工具类与重试退避逻辑
3.  新增UI表单/页面/字段的schema定义与自动生成工具
4.  完成上下文管理模块,支持会话上下文、可见性过滤与运行时状态注册
5.  实现通道能力配置类,支持流式传输、功能开关等多维度能力定义
2026-05-21 10:35:30 +08:00

438 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import warnings
from dataclasses import MISSING, dataclass, field, fields
from typing import ClassVar
from yuxi.channel.config.defaults import (
BLOCK_STREAMING_BREAK,
BLOCK_STREAMING_CHUNK_BREAK_PREFERENCE,
BLOCK_STREAMING_CHUNK_MAX_CHARS,
BLOCK_STREAMING_CHUNK_MIN_CHARS,
BLOCK_STREAMING_COALESCE_IDLE_MS,
BLOCK_STREAMING_COALESCE_MAX_CHARS,
BLOCK_STREAMING_COALESCE_MIN_CHARS,
SSE_MAX_REASONING_CHARS,
STREAMING_PREVIEW_MIN_INITIAL_CHARS,
STREAMING_PREVIEW_THROTTLE_MS,
)
# 哨兵对象,用于标记字段尚未被显式设置
_UNSET = object()
# 支持的流式传输模式
STREAMING_MODES: tuple[str, ...] = ("card_kit", "block", "c2c_stream_api", "raw")
# 块级流式传输的分割断点类型
BLOCK_STREAMING_BREAKS: tuple[str, ...] = ("text_end", "paragraph", "sentence", "token")
@dataclass
class ChannelCapabilities:
"""定义通道Channel支持的各种能力配置。"""
# 聊天类型,默认支持私聊和群聊
chat_types: list[str] = field(default_factory=lambda: ["direct", "group"])
# 消息类型,默认仅支持文本
message_types: list[str] = field(default_factory=lambda: ["text"])
# 交互方式列表
interactions: list[str] = field(default_factory=list)
# 以下为各类功能开关,默认使用 _UNSET 表示未显式设置
reactions: bool = field(default=_UNSET) # 是否支持消息表情反应
typing_indicator: bool = field(default=_UNSET) # 是否支持输入状态指示
threads: bool = field(default=_UNSET) # 是否支持话题线程
edit: bool = field(default=_UNSET) # 是否支持编辑消息
unsend: bool = field(default=_UNSET) # 是否支持撤回消息
reply: bool = field(default=_UNSET) # 是否支持回复消息
media: bool = field(default=_UNSET) # 是否支持发送媒体
effects: bool = field(default=_UNSET) # 是否支持消息特效
native_commands: bool = field(default=_UNSET) # 是否支持原生命令
polls: bool = field(default=_UNSET) # 是否支持投票
pins: bool = field(default=_UNSET) # 是否支持置顶消息
group_management: bool = field(default=_UNSET) # 是否支持群组管理
adaptive_cards: bool = field(default=_UNSET) # 是否支持自适应卡片
tasks: bool = field(default=_UNSET) # 是否支持任务
events: bool = field(default=_UNSET) # 是否支持事件
notes: bool = field(default=_UNSET) # 是否支持笔记
task_modules: bool = field(default=_UNSET) # 是否支持任务模块
messaging_extensions: bool = field(default=_UNSET) # 是否支持消息扩展
incoming_webhooks: bool = field(default=_UNSET) # 是否支持传入的 Webhook
bot_state: bool = field(default=_UNSET) # 是否支持机器人状态
# 流式传输相关配置
streaming: bool = field(default=_UNSET) # 是否启用流式传输
streaming_mode: str | None = None # 流式传输模式
preview_stream_throttle_ms: int = field(default=_UNSET) # 预览流节流间隔(毫秒)
preview_min_initial_chars: int = field(default=_UNSET) # 预览最小初始字符数
# 块级流式传输配置
block_streaming: bool = field(default=_UNSET) # 是否启用块级流式传输
block_streaming_break: str = field(default=_UNSET) # 块级流式分割断点
block_streaming_chunk_min_chars: int = field(default=_UNSET) # 块最小字符数
block_streaming_chunk_max_chars: int = field(default=_UNSET) # 块最大字符数
block_streaming_chunk_break_preference: str = field(default=_UNSET) # 块分割偏好
block_streaming_coalesce_min_chars: int | None = None # 合并块最小字符数
block_streaming_coalesce_max_chars: int | None = None # 合并块最大字符数
block_streaming_coalesce_idle_ms: int = field(default=_UNSET) # 合并空闲等待时间(毫秒)
# SSE 与轮询配置
sse_support: bool = field(default=_UNSET) # 是否支持 SSE
sse_max_reasoning_chars: int = field(default=_UNSET) # SSE 最大推理字符数
polling_fallback: bool = field(default=_UNSET) # 是否启用轮询降级
# TTS文本转语音配置
tts: dict | None = None
# 类变量:已警告的冻结字段集合
_WARNED_FROZEN_FIELDS: ClassVar[frozenset[str]] = frozenset()
def __post_init__(self) -> None:
"""初始化后处理:记录原始值并对未设置的字段应用默认值。"""
self._raw_values = dict(self.__dict__)
# 布尔功能开关默认值均为 False
if self.reactions is _UNSET:
self.reactions = False
if self.typing_indicator is _UNSET:
self.typing_indicator = False
if self.threads is _UNSET:
self.threads = False
if self.edit is _UNSET:
self.edit = False
if self.unsend is _UNSET:
self.unsend = False
if self.reply is _UNSET:
self.reply = False
if self.media is _UNSET:
self.media = False
if self.effects is _UNSET:
self.effects = False
if self.native_commands is _UNSET:
self.native_commands = False
if self.polls is _UNSET:
self.polls = False
if self.pins is _UNSET:
self.pins = False
if self.group_management is _UNSET:
self.group_management = False
if self.adaptive_cards is _UNSET:
self.adaptive_cards = False
if self.tasks is _UNSET:
self.tasks = False
if self.events is _UNSET:
self.events = False
if self.notes is _UNSET:
self.notes = False
if self.task_modules is _UNSET:
self.task_modules = False
if self.messaging_extensions is _UNSET:
self.messaging_extensions = False
if self.incoming_webhooks is _UNSET:
self.incoming_webhooks = False
if self.bot_state is _UNSET:
self.bot_state = False
if self.streaming is _UNSET:
self.streaming = False
if self.block_streaming is _UNSET:
self.block_streaming = False
if self.sse_support is _UNSET:
self.sse_support = False
if self.polling_fallback is _UNSET:
self.polling_fallback = False
# 数值类型字段默认值来源config/defaults.py
if self.preview_stream_throttle_ms is _UNSET:
self.preview_stream_throttle_ms = STREAMING_PREVIEW_THROTTLE_MS
if self.preview_min_initial_chars is _UNSET:
self.preview_min_initial_chars = STREAMING_PREVIEW_MIN_INITIAL_CHARS
if self.block_streaming_break is _UNSET:
self.block_streaming_break = BLOCK_STREAMING_BREAK
if self.block_streaming_chunk_min_chars is _UNSET:
self.block_streaming_chunk_min_chars = BLOCK_STREAMING_CHUNK_MIN_CHARS
if self.block_streaming_chunk_max_chars is _UNSET:
self.block_streaming_chunk_max_chars = BLOCK_STREAMING_CHUNK_MAX_CHARS
if self.block_streaming_chunk_break_preference is _UNSET:
self.block_streaming_chunk_break_preference = BLOCK_STREAMING_CHUNK_BREAK_PREFERENCE
if self.block_streaming_coalesce_idle_ms is _UNSET:
self.block_streaming_coalesce_idle_ms = BLOCK_STREAMING_COALESCE_IDLE_MS
if self.sse_max_reasoning_chars is _UNSET:
self.sse_max_reasoning_chars = SSE_MAX_REASONING_CHARS
# 校验流式传输模式是否合法
if self.streaming_mode is not None and self.streaming_mode not in STREAMING_MODES:
warnings.warn(
f"Unknown streaming_mode: {self.streaming_mode!r}. Known modes: {STREAMING_MODES}",
stacklevel=3,
)
# 校验块级流式分割断点是否合法
if self.block_streaming_break not in BLOCK_STREAMING_BREAKS:
warnings.warn(
f"Unknown block_streaming_break: {self.block_streaming_break!r}. "
f"Known breaks: {BLOCK_STREAMING_BREAKS}",
stacklevel=3,
)
# 校验块字符数范围合理性
if self.block_streaming_chunk_min_chars > self.block_streaming_chunk_max_chars:
raise ValueError(
f"block_streaming_chunk_min_chars ({self.block_streaming_chunk_min_chars}) "
f"must be <= block_streaming_chunk_max_chars ({self.block_streaming_chunk_max_chars})"
)
# 块级流式传输仅在流式传输开启时生效
if self.block_streaming and not self.streaming:
warnings.warn(
"block_streaming=True but streaming=False, block_streaming has no effect",
stacklevel=3,
)
# 校验合并块字符数范围合理性
if self.block_streaming_coalesce_min_chars is not None and self.block_streaming_coalesce_max_chars is not None:
if self.block_streaming_coalesce_min_chars > self.block_streaming_coalesce_max_chars:
raise ValueError(
f"block_streaming_coalesce_min_chars ({self.block_streaming_coalesce_min_chars}) "
f"must be <= block_streaming_coalesce_max_chars ({self.block_streaming_coalesce_max_chars})"
)
def to_dict(self) -> dict:
"""将能力配置转换为字典格式。"""
return {
"chat_types": list(self.chat_types),
"message_types": list(self.message_types),
"interactions": list(self.interactions),
"reactions": self.reactions,
"typing_indicator": self.typing_indicator,
"threads": self.threads,
"edit": self.edit,
"unsend": self.unsend,
"reply": self.reply,
"media": self.media,
"effects": self.effects,
"native_commands": self.native_commands,
"polls": self.polls,
"pins": self.pins,
"group_management": self.group_management,
"adaptive_cards": self.adaptive_cards,
"tasks": self.tasks,
"events": self.events,
"notes": self.notes,
"task_modules": self.task_modules,
"messaging_extensions": self.messaging_extensions,
"incoming_webhooks": self.incoming_webhooks,
"bot_state": self.bot_state,
"streaming": self.streaming,
"streaming_mode": self.streaming_mode,
"preview_stream_throttle_ms": self.preview_stream_throttle_ms,
"preview_min_initial_chars": self.preview_min_initial_chars,
"block_streaming": self.block_streaming,
"block_streaming_break": self.block_streaming_break,
"block_streaming_chunk_min_chars": self.block_streaming_chunk_min_chars,
"block_streaming_chunk_max_chars": self.block_streaming_chunk_max_chars,
"block_streaming_chunk_break_preference": self.block_streaming_chunk_break_preference,
"block_streaming_coalesce_min_chars": self.block_streaming_coalesce_min_chars,
"block_streaming_coalesce_max_chars": self.block_streaming_coalesce_max_chars,
"block_streaming_coalesce_idle_ms": self.block_streaming_coalesce_idle_ms,
"sse_support": self.sse_support,
"sse_max_reasoning_chars": self.sse_max_reasoning_chars,
"polling_fallback": self.polling_fallback,
"tts": dict(self.tts) if self.tts is not None else None,
}
def override_with(self, overrides: ChannelCapabilities | dict) -> ChannelCapabilities:
"""使用另一组能力配置覆盖当前配置,返回新的 ChannelCapabilities 实例。"""
if isinstance(overrides, dict):
override_dict: dict | None = overrides
overrides = ChannelCapabilities(**overrides)
else:
override_dict = None
if not isinstance(overrides, ChannelCapabilities):
raise TypeError(f"Expected ChannelCapabilities or dict, got {type(overrides).__name__}")
def _merge_list(base: list[str], override: list[str]) -> list[str]:
"""合并两个列表并去重,保持原有顺序。"""
return list(dict.fromkeys(base + override))
def _should_override(name: str) -> bool:
"""判断指定字段是否需要被覆盖。"""
if override_dict is not None:
return name in override_dict
raw = getattr(overrides, "_raw_values", None)
if raw is None:
return True
raw_value = raw.get(name)
if raw_value is _UNSET:
return False
field_default = _FIELD_DEFAULTS.get(name)
if field_default is not _UNSET:
return raw_value != field_default
return True
return ChannelCapabilities(
chat_types=_merge_list(self.chat_types, overrides.chat_types),
message_types=_merge_list(self.message_types, overrides.message_types),
interactions=_merge_list(self.interactions, overrides.interactions),
reactions=overrides.reactions if _should_override("reactions") else self.reactions,
typing_indicator=overrides.typing_indicator if _should_override("typing_indicator") else self.typing_indicator,
threads=overrides.threads if _should_override("threads") else self.threads,
edit=overrides.edit if _should_override("edit") else self.edit,
unsend=overrides.unsend if _should_override("unsend") else self.unsend,
reply=overrides.reply if _should_override("reply") else self.reply,
media=overrides.media if _should_override("media") else self.media,
effects=overrides.effects if _should_override("effects") else self.effects,
native_commands=overrides.native_commands if _should_override("native_commands") else self.native_commands,
polls=overrides.polls if _should_override("polls") else self.polls,
pins=overrides.pins if _should_override("pins") else self.pins,
group_management=overrides.group_management if _should_override("group_management") else self.group_management,
adaptive_cards=overrides.adaptive_cards if _should_override("adaptive_cards") else self.adaptive_cards,
tasks=overrides.tasks if _should_override("tasks") else self.tasks,
events=overrides.events if _should_override("events") else self.events,
notes=overrides.notes if _should_override("notes") else self.notes,
task_modules=overrides.task_modules if _should_override("task_modules") else self.task_modules,
messaging_extensions=overrides.messaging_extensions if _should_override("messaging_extensions") else self.messaging_extensions,
incoming_webhooks=overrides.incoming_webhooks if _should_override("incoming_webhooks") else self.incoming_webhooks,
bot_state=overrides.bot_state if _should_override("bot_state") else self.bot_state,
streaming=overrides.streaming if _should_override("streaming") else self.streaming,
streaming_mode=overrides.streaming_mode if _should_override("streaming_mode") else self.streaming_mode,
preview_stream_throttle_ms=overrides.preview_stream_throttle_ms if _should_override("preview_stream_throttle_ms") else self.preview_stream_throttle_ms,
preview_min_initial_chars=overrides.preview_min_initial_chars if _should_override("preview_min_initial_chars") else self.preview_min_initial_chars,
block_streaming=overrides.block_streaming if _should_override("block_streaming") else self.block_streaming,
block_streaming_break=overrides.block_streaming_break if _should_override("block_streaming_break") else self.block_streaming_break,
block_streaming_chunk_min_chars=overrides.block_streaming_chunk_min_chars if _should_override("block_streaming_chunk_min_chars") else self.block_streaming_chunk_min_chars,
block_streaming_chunk_max_chars=overrides.block_streaming_chunk_max_chars if _should_override("block_streaming_chunk_max_chars") else self.block_streaming_chunk_max_chars,
block_streaming_chunk_break_preference=overrides.block_streaming_chunk_break_preference if _should_override("block_streaming_chunk_break_preference") else self.block_streaming_chunk_break_preference,
block_streaming_coalesce_min_chars=overrides.block_streaming_coalesce_min_chars if _should_override("block_streaming_coalesce_min_chars") else self.block_streaming_coalesce_min_chars,
block_streaming_coalesce_max_chars=overrides.block_streaming_coalesce_max_chars if _should_override("block_streaming_coalesce_max_chars") else self.block_streaming_coalesce_max_chars,
block_streaming_coalesce_idle_ms=overrides.block_streaming_coalesce_idle_ms if _should_override("block_streaming_coalesce_idle_ms") else self.block_streaming_coalesce_idle_ms,
sse_support=overrides.sse_support if _should_override("sse_support") else self.sse_support,
sse_max_reasoning_chars=overrides.sse_max_reasoning_chars if _should_override("sse_max_reasoning_chars") else self.sse_max_reasoning_chars,
polling_fallback=overrides.polling_fallback if _should_override("polling_fallback") else self.polling_fallback,
tts=overrides.tts if _should_override("tts") else self.tts,
)
def supports(self, feature: str) -> bool:
"""检查是否支持指定功能。"""
return bool(getattr(self, feature, False))
def get_action_registry(self, plugin: object) -> MessageActionRegistry:
"""根据当前能力配置生成消息动作注册表。"""
from yuxi.channel.sdk.actions import MessageAction, MessageActionRegistry
registry = MessageActionRegistry()
base_actions = {MessageAction.SEND, MessageAction.SEND_MEDIA}
if self.reply:
base_actions.add(MessageAction.REPLY)
if self.edit:
base_actions.add(MessageAction.EDIT)
if self.unsend:
base_actions.add(MessageAction.UNSEND)
if self.reactions:
base_actions.add(MessageAction.REACT)
base_actions.add(MessageAction.REACT_REMOVE)
base_actions.add(MessageAction.REACTIONS)
if self.pins:
base_actions.add(MessageAction.PIN)
base_actions.add(MessageAction.UNPIN)
if self.polls:
base_actions.add(MessageAction.POLL)
base_actions.add(MessageAction.POLL_VOTE)
if self.group_management:
base_actions |= {
MessageAction.RENAME_GROUP,
MessageAction.ADD_PARTICIPANT,
MessageAction.REMOVE_PARTICIPANT,
MessageAction.MEMBER_INFO,
MessageAction.PERMISSIONS,
}
if self.threads:
base_actions |= {
MessageAction.THREAD_CREATE,
MessageAction.THREAD_LIST,
MessageAction.THREAD_REPLY,
MessageAction.TOPIC_CREATE,
MessageAction.TOPIC_EDIT,
}
for action in base_actions:
registry.register(action, lambda **kwargs: {"success": True})
return registry
def derive_from_adapter(self, adapter) -> ChannelCapabilities:
"""从适配器推导能力配置。"""
actions = {a.value for a in adapter.list_actions()}
return ChannelCapabilities(
chat_types=self.chat_types,
message_types=self.message_types,
reactions="react" in actions or "react_remove" in actions,
edit="edit" in actions,
unsend="unsend" in actions,
polls="poll" in actions or "poll_vote" in actions,
pins="pin" in actions or "unpin" in actions,
group_management=(
"add_participant" in actions
or "remove_participant" in actions
or "rename_group" in actions
),
threads="thread_reply" in actions or "thread_create" in actions or "thread_list" in actions,
media="send_media" in actions,
reply="reply" in actions,
)
def get_effective_streaming_mode(self) -> str:
"""获取实际生效的流式传输模式。"""
if not self.streaming:
return "none"
if self.streaming_mode:
return self.streaming_mode
return "preview"
def get_chunk_limit(self) -> int:
"""获取分块大小限制。"""
return 4096
def get_effective_block_config(self) -> dict:
"""获取实际生效的块级流式传输配置。"""
text_limit = self.get_chunk_limit()
chunk_min = max(1, self.block_streaming_chunk_min_chars)
chunk_max = max(chunk_min, min(self.block_streaming_chunk_max_chars, text_limit))
coalesce_min = self.block_streaming_coalesce_min_chars or chunk_min
coalesce_max = self.block_streaming_coalesce_max_chars or chunk_max
coalesce_min = min(coalesce_min, coalesce_max)
coalesce_max = min(coalesce_max, chunk_max)
coalesce_idle_ms = max(0, min(self.block_streaming_coalesce_idle_ms, 5000))
return {
"chunk": {
"min_chars": chunk_min,
"max_chars": chunk_max,
"break_preference": self.block_streaming_chunk_break_preference,
},
"coalesce": {
"min_chars": coalesce_min,
"max_chars": coalesce_max,
"idle_ms": coalesce_idle_ms,
},
"text_limit": text_limit,
}
# 构建字段默认值映射表,用于 override_with 中的覆盖判断
_FIELD_DEFAULTS: dict[str, object] = {}
for _f in fields(ChannelCapabilities):
if _f.default is not MISSING:
_FIELD_DEFAULTS[_f.name] = _f.default
elif _f.default_factory is not MISSING:
_FIELD_DEFAULTS[_f.name] = _f.default_factory()