feat(channel): 新增通道基础模块与UI schema定义
本次提交新增了通道模块的完整基础实现: 1. 搭建了channel包的顶层导出结构,整合所有核心子模块接口 2. 实现了错误处理工具类与重试退避逻辑 3. 新增UI表单/页面/字段的schema定义与自动生成工具 4. 完成上下文管理模块,支持会话上下文、可见性过滤与运行时状态注册 5. 实现通道能力配置类,支持流式传输、功能开关等多维度能力定义
This commit is contained in:
parent
1a7690aaa0
commit
a48f7ebf2a
69
backend/package/yuxi/channel/__init__.py
Normal file
69
backend/package/yuxi/channel/__init__.py
Normal file
@ -0,0 +1,69 @@
|
||||
from yuxi.channel.capabilities import ChannelCapabilities
|
||||
from yuxi.channel.common import AttachmentCache, CachedAttachment
|
||||
from yuxi.channel.context import (
|
||||
ChannelContext,
|
||||
ChannelRuntimeContexts,
|
||||
ContextVisibilityMode,
|
||||
ConversationBinding,
|
||||
SupplementalContext,
|
||||
task_scoped_contexts,
|
||||
)
|
||||
from yuxi.channel.events import ChannelEventBus, EventTopic
|
||||
from yuxi.channel.runtime.manager import (
|
||||
ChannelGateway,
|
||||
ChannelSnapshot,
|
||||
ChannelState,
|
||||
gateway,
|
||||
)
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
from yuxi.channel.runtime.trace import StartupTrace
|
||||
from yuxi.channel.streaming.draft_stream import (
|
||||
DraftStreamLoop,
|
||||
FinalizableDraftStreamControls,
|
||||
get_tool_status_label,
|
||||
)
|
||||
from yuxi.channel.runtime.trace_ctx import (
|
||||
TraceIdFilter,
|
||||
generate_trace_id,
|
||||
get_trace_id,
|
||||
install_trace_filter,
|
||||
set_trace_id,
|
||||
trace_context,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# common
|
||||
"AttachmentCache",
|
||||
"CachedAttachment",
|
||||
# core
|
||||
"ChannelCapabilities",
|
||||
"ChannelContext",
|
||||
"ChannelEventBus",
|
||||
"ChannelGateway",
|
||||
"ChannelPluginRegistry",
|
||||
"ChannelRuntimeContexts",
|
||||
"ChannelSnapshot",
|
||||
"ChannelState",
|
||||
"ContextVisibilityMode",
|
||||
"ConversationBinding",
|
||||
"EventTopic",
|
||||
# hooks
|
||||
"HookCallback",
|
||||
"HookEvent",
|
||||
"HookRegistration",
|
||||
"LifecycleHookRegistry",
|
||||
"StartupTrace",
|
||||
"DraftStreamLoop",
|
||||
"FinalizableDraftStreamControls",
|
||||
"get_tool_status_label",
|
||||
"SupplementalContext",
|
||||
"gateway",
|
||||
"task_scoped_contexts",
|
||||
# trace
|
||||
"TraceIdFilter",
|
||||
"generate_trace_id",
|
||||
"get_trace_id",
|
||||
"install_trace_filter",
|
||||
"set_trace_id",
|
||||
"trace_context",
|
||||
]
|
||||
437
backend/package/yuxi/channel/capabilities.py
Normal file
437
backend/package/yuxi/channel/capabilities.py
Normal file
@ -0,0 +1,437 @@
|
||||
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()
|
||||
215
backend/package/yuxi/channel/context.py
Normal file
215
backend/package/yuxi/channel/context.py
Normal file
@ -0,0 +1,215 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelContext:
|
||||
"""单个通道请求/会话的上下文对象,承载一次交互所需的全部运行时信息。"""
|
||||
|
||||
channel_type: str # 通道类型标识,例如 "feishu"、"wechat"
|
||||
account_id: str # 该通道下的账户唯一标识
|
||||
cancel_event: asyncio.Event = field(default_factory=asyncio.Event) # 用于取消当前任务的异步事件
|
||||
config: dict = field(default_factory=dict) # 通道级配置字典
|
||||
queue: asyncio.Queue[Any] | None = None # 可选的消息队列,用于异步流式回传
|
||||
logger: logging.Logger | None = None # 专用日志器,__post_init__ 中自动初始化
|
||||
request_id: str | None = None # 可选的链路追踪请求 ID
|
||||
|
||||
def __post_init__(self):
|
||||
# 若调用方未传入 logger,则按 "yuxi.channel.{channel_type}.{account_id}" 的命名规范自动创建
|
||||
if self.logger is None:
|
||||
self.logger = logging.getLogger(f"yuxi.channel.{self.channel_type}.{self.account_id}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConversationBinding:
|
||||
"""会话绑定关系,描述一条消息所属的对话层级结构。"""
|
||||
|
||||
channel: str # 通道标识
|
||||
account_id: str # 账户标识
|
||||
conversation_id: str # 当前会话/对话 ID
|
||||
parent_conversation_id: str | None = None # 父会话 ID,用于支持嵌套会话场景
|
||||
thread_id: str | None = None # 线程 ID,用于支持话题(thread)级别的细分
|
||||
|
||||
|
||||
class ContextVisibilityMode(StrEnum):
|
||||
"""补充上下文可见性控制模式,决定哪些附加信息可以进入大模型上下文。"""
|
||||
|
||||
ALL = "all" # 全部放行,不做过滤
|
||||
ALLOWLIST = "allowlist" # 仅允许发送者在白名单中的内容
|
||||
ALLOWLIST_QUOTE = "allowlist_quote" # 白名单逻辑,但引用(quote)类型始终放行
|
||||
NONE = "none" # 全部拒绝(通常仅保留系统级提示)
|
||||
|
||||
|
||||
def _evaluate_supplemental_visibility(
|
||||
mode: ContextVisibilityMode,
|
||||
kind: str,
|
||||
sender_allowed: bool,
|
||||
) -> bool:
|
||||
"""根据可见性模式判断某类补充上下文是否应被保留。
|
||||
|
||||
参数:
|
||||
mode: 当前采用的可见性模式。
|
||||
kind: 上下文类型,如 "quote"、"forwarded"、"thread"。
|
||||
sender_allowed: 发送者是否已在白名单中。
|
||||
"""
|
||||
if mode == ContextVisibilityMode.ALL:
|
||||
return True
|
||||
if sender_allowed:
|
||||
return True
|
||||
if mode == ContextVisibilityMode.ALLOWLIST_QUOTE and kind == "quote":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SupplementalContext:
|
||||
"""补充上下文,包含引用、转发、话题线程等可能来自外部的不受信信息。
|
||||
|
||||
在注入大模型前,应通过 filter_by_visibility() 按业务策略进行过滤,
|
||||
防止不可信内容污染系统提示或诱导模型产生错误回复。
|
||||
"""
|
||||
|
||||
quote: dict | None = None # 引用消息上下文
|
||||
forwarded: dict | None = None # 转发消息上下文
|
||||
thread: dict | None = None # 话题/线程上下文
|
||||
untrusted_context: list[dict] | None = None # 其他未受信上下文列表
|
||||
group_system_prompt: str | None = None # 群组级别的系统提示(通常不受可见性过滤影响)
|
||||
|
||||
def filter_by_visibility(self, mode: ContextVisibilityMode) -> "SupplementalContext":
|
||||
"""按指定可见性模式过滤当前实例,返回过滤后的新 SupplementalContext。
|
||||
|
||||
untrusted_context 与 group_system_prompt 不参与过滤,始终保留。
|
||||
"""
|
||||
if mode == ContextVisibilityMode.ALL:
|
||||
return self
|
||||
|
||||
quote = self.quote
|
||||
forwarded = self.forwarded
|
||||
thread = self.thread
|
||||
|
||||
if not _evaluate_supplemental_visibility(mode, "quote", bool(quote and quote.get("sender_allowed"))):
|
||||
quote = None
|
||||
if not _evaluate_supplemental_visibility(
|
||||
mode, "forwarded", bool(forwarded and forwarded.get("sender_allowed"))
|
||||
):
|
||||
forwarded = None
|
||||
if not _evaluate_supplemental_visibility(mode, "thread", bool(thread and thread.get("sender_allowed"))):
|
||||
thread = None
|
||||
|
||||
return SupplementalContext(
|
||||
quote=quote,
|
||||
forwarded=forwarded,
|
||||
thread=thread,
|
||||
untrusted_context=self.untrusted_context,
|
||||
group_system_prompt=self.group_system_prompt,
|
||||
)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""判断当前补充上下文是否不包含任何有效信息。"""
|
||||
return not any((self.quote, self.forwarded, self.thread, self.untrusted_context, self.group_system_prompt))
|
||||
|
||||
|
||||
class ChannelRuntimeContexts:
|
||||
"""跨生命周期状态注册表, keyed by (channel_id, account_id, capability).
|
||||
|
||||
借鉴 openclaw 的 ChannelRuntimeContextRegistry 模式,
|
||||
允许插件和模块在不产生紧耦合的情况下动态共享运行时状态。
|
||||
|
||||
Usage::
|
||||
|
||||
registry = ChannelRuntimeContexts()
|
||||
dispose = registry.register("feishu", "account1", "gateway_state", {"connected": True})
|
||||
state = registry.get("feishu", "account1", "gateway_state")
|
||||
dispose()
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 内部存储结构: key -> (token, context)
|
||||
# token 用于在并发 dispose 时做安全校验,防止误删后续重新注册的同名条目
|
||||
self._store: dict[str, tuple[int, Any]] = {}
|
||||
self._counter = 0
|
||||
|
||||
@staticmethod
|
||||
def _make_key(channel_id: str, account_id: str, capability: str) -> str:
|
||||
"""使用 \0 拼接三元组,避免不同字段组合产生 key 冲突。"""
|
||||
return f"{channel_id}\0{account_id}\0{capability}"
|
||||
|
||||
def register(
|
||||
self,
|
||||
channel_id: str,
|
||||
account_id: str,
|
||||
capability: str,
|
||||
context: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""注册一个运行时上下文,返回 dispose 闭包用于手动注销。
|
||||
|
||||
注意:
|
||||
- 同一 (channel_id, account_id, capability) 重复注册将覆盖旧值。
|
||||
- dispose 是幂等的,多次调用无副作用。
|
||||
"""
|
||||
key = self._make_key(channel_id, account_id, capability)
|
||||
self._counter += 1
|
||||
token = self._counter
|
||||
self._store[key] = (token, context)
|
||||
disposed = False
|
||||
|
||||
def dispose():
|
||||
nonlocal disposed
|
||||
if disposed:
|
||||
return
|
||||
disposed = True
|
||||
entry = self._store.get(key)
|
||||
# 仅当条目存在且 token 匹配时才删除,防止并发覆盖后误删新条目
|
||||
if entry is None or entry[0] != token:
|
||||
return
|
||||
del self._store[key]
|
||||
|
||||
return dispose
|
||||
|
||||
def get(self, channel_id: str, account_id: str, capability: str) -> Any | None:
|
||||
"""获取指定三元组对应的运行时上下文,若不存在则返回 None。"""
|
||||
entry = self._store.get(self._make_key(channel_id, account_id, capability))
|
||||
return entry[1] if entry is not None else None
|
||||
|
||||
def dispose_all(self):
|
||||
"""清空全部注册状态并重置计数器,通常用于服务关闭或测试清理。"""
|
||||
self._store.clear()
|
||||
self._counter = 0
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def task_scoped_contexts(registry: ChannelRuntimeContexts):
|
||||
"""异步上下文管理器, 在退出时自动 dispose 作用域内所有注册。
|
||||
|
||||
Usage::
|
||||
|
||||
registry = ChannelRuntimeContexts()
|
||||
async with task_scoped_contexts(registry) as scoped:
|
||||
scoped.register("feishu", "main", "temp", data)
|
||||
# 退出时自动清理
|
||||
"""
|
||||
leases: list[Callable[[], None]] = []
|
||||
|
||||
class _Scoped:
|
||||
"""作用域内暴露的注册代理,自动收集所有 dispose 句柄以便退出时统一清理。"""
|
||||
|
||||
def register(self, channel_id: str, account_id: str, capability: str, context: Any):
|
||||
dispose = registry.register(channel_id, account_id, capability, context)
|
||||
leases.append(dispose)
|
||||
return dispose
|
||||
|
||||
try:
|
||||
yield _Scoped()
|
||||
finally:
|
||||
# 按后进先出顺序清理,尽量保证依赖关系正确释放
|
||||
for dispose in reversed(leases):
|
||||
try:
|
||||
dispose()
|
||||
except Exception:
|
||||
# 清理异常不应阻断后续释放流程
|
||||
pass
|
||||
85
backend/package/yuxi/channel/errors.py
Normal file
85
backend/package/yuxi/channel/errors.py
Normal file
@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channel.protocols import ClassifiedError, ErrorSeverity, ErrorHandlingProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def capture_error(
|
||||
logger_instance: logging.Logger,
|
||||
error: BaseException,
|
||||
*,
|
||||
message: str = "",
|
||||
context: dict[str, Any] | None = None,
|
||||
level: str = "error",
|
||||
) -> ClassifiedError:
|
||||
if level == "warning":
|
||||
logger_instance.warning(
|
||||
"%s: %s | context=%s",
|
||||
message or "Operation failed",
|
||||
error,
|
||||
context or {},
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger_instance.exception(message or "Operation failed")
|
||||
|
||||
return ClassifiedError(
|
||||
severity=_infer_severity(error),
|
||||
original_error=error,
|
||||
error_message=str(error),
|
||||
)
|
||||
|
||||
|
||||
def classify_error(error: BaseException) -> ClassifiedError:
|
||||
return ClassifiedError(
|
||||
severity=_infer_severity(error),
|
||||
original_error=error,
|
||||
error_message=str(error),
|
||||
)
|
||||
|
||||
|
||||
def is_retryable(error: BaseException) -> bool:
|
||||
return _infer_severity(error) == ErrorSeverity.RETRYABLE
|
||||
|
||||
|
||||
def should_backoff(error: BaseException, attempt: int) -> int:
|
||||
if not is_retryable(error):
|
||||
return 0
|
||||
base_ms = 1000
|
||||
max_ms = 30000
|
||||
delay_ms = min(base_ms * (2 ** (attempt - 1)), max_ms)
|
||||
return delay_ms
|
||||
|
||||
|
||||
def _infer_severity(error: BaseException) -> ErrorSeverity:
|
||||
import asyncio
|
||||
import builtins
|
||||
|
||||
if isinstance(error, asyncio.TimeoutError):
|
||||
return ErrorSeverity.NETWORK
|
||||
if isinstance(error, builtins.TimeoutError):
|
||||
return ErrorSeverity.NETWORK
|
||||
if isinstance(error, ConnectionError):
|
||||
return ErrorSeverity.NETWORK
|
||||
if isinstance(error, PermissionError):
|
||||
return ErrorSeverity.FORBIDDEN
|
||||
if isinstance(error, (ValueError, TypeError, KeyError, IndexError, AttributeError)):
|
||||
return ErrorSeverity.FATAL
|
||||
if isinstance(error, asyncio.CancelledError):
|
||||
return ErrorSeverity.FATAL
|
||||
return ErrorSeverity.RETRYABLE
|
||||
|
||||
|
||||
class DefaultErrorHandler(ErrorHandlingProtocol):
|
||||
def classify_error(self, error: BaseException) -> ClassifiedError:
|
||||
return classify_error(error)
|
||||
|
||||
def is_retryable(self, error: BaseException) -> bool:
|
||||
return is_retryable(error)
|
||||
|
||||
def should_backoff(self, error: BaseException, attempt: int) -> int:
|
||||
return should_backoff(error, attempt)
|
||||
2332
backend/package/yuxi/channel/protocols.py
Normal file
2332
backend/package/yuxi/channel/protocols.py
Normal file
File diff suppressed because it is too large
Load Diff
1
backend/package/yuxi/channel/ui/__init__.py
Normal file
1
backend/package/yuxi/channel/ui/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
162
backend/package/yuxi/channel/ui/schema.py
Normal file
162
backend/package/yuxi/channel/ui/schema.py
Normal file
@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIFieldSchema:
|
||||
"""UI 字段定义 — 描述前端表单中单个字段的元数据。"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
placeholder: str = ""
|
||||
options: list[dict[str, Any]] = field(default_factory=list)
|
||||
validation: dict[str, Any] = field(default_factory=dict)
|
||||
sensitive: bool = False
|
||||
hidden: bool = False
|
||||
disabled: bool = False
|
||||
order: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"type": self.type,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"required": self.required,
|
||||
"default": self.default,
|
||||
"placeholder": self.placeholder,
|
||||
"options": self.options,
|
||||
"validation": self.validation,
|
||||
"sensitive": self.sensitive,
|
||||
"hidden": self.hidden,
|
||||
"disabled": self.disabled,
|
||||
"order": self.order,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIFormSchema:
|
||||
"""UI 表单定义 — 描述前端表单的整体结构。"""
|
||||
|
||||
id: str
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
fields: list[UIFieldSchema] = field(default_factory=list)
|
||||
submit_label: str = "Save"
|
||||
cancel_label: str = "Cancel"
|
||||
layout: str = "vertical"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"fields": [f.to_dict() for f in sorted(self.fields, key=lambda x: x.order)],
|
||||
"submit_label": self.submit_label,
|
||||
"cancel_label": self.cancel_label,
|
||||
"layout": self.layout,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UISectionSchema:
|
||||
"""UI 区块定义 — 描述前端页面中的一个区块。"""
|
||||
|
||||
id: str
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
forms: list[UIFormSchema] = field(default_factory=list)
|
||||
order: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"forms": [f.to_dict() for f in self.forms],
|
||||
"order": self.order,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIPageSchema:
|
||||
"""UI 页面定义 — 描述前端页面的完整结构。"""
|
||||
|
||||
id: str
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
sections: list[UISectionSchema] = field(default_factory=list)
|
||||
route: str = ""
|
||||
icon: str = ""
|
||||
order: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"sections": [s.to_dict() for s in sorted(self.sections, key=lambda x: x.order)],
|
||||
"route": self.route,
|
||||
"icon": self.icon,
|
||||
"order": self.order,
|
||||
}
|
||||
|
||||
|
||||
def build_ui_schema_from_config_schema(config_schema: dict[str, Any]) -> UIFormSchema:
|
||||
"""从配置 schema 自动生成 UI 表单 schema。"""
|
||||
fields: list[UIFieldSchema] = []
|
||||
properties = config_schema.get("properties", {})
|
||||
required = set(config_schema.get("required", []))
|
||||
|
||||
for name, prop in properties.items():
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
|
||||
field_type = _map_json_schema_type_to_ui(prop.get("type", "string"))
|
||||
field = UIFieldSchema(
|
||||
name=name,
|
||||
type=field_type,
|
||||
label=prop.get("title", name.replace("_", " ").title()),
|
||||
description=prop.get("description", ""),
|
||||
required=name in required,
|
||||
default=prop.get("default"),
|
||||
placeholder=prop.get("examples", [""])[0] if prop.get("examples") else "",
|
||||
sensitive=_is_sensitive_field(name),
|
||||
)
|
||||
|
||||
if "enum" in prop:
|
||||
field.options = [{"label": str(v), "value": v} for v in prop["enum"]]
|
||||
|
||||
fields.append(field)
|
||||
|
||||
return UIFormSchema(
|
||||
id=config_schema.get("title", "form"),
|
||||
title=config_schema.get("title", ""),
|
||||
description=config_schema.get("description", ""),
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
|
||||
def _map_json_schema_type_to_ui(json_type: str | list[str]) -> str:
|
||||
if isinstance(json_type, list):
|
||||
json_type = json_type[0] if json_type else "string"
|
||||
mapping = {
|
||||
"string": "text",
|
||||
"integer": "number",
|
||||
"number": "number",
|
||||
"boolean": "switch",
|
||||
"array": "list",
|
||||
"object": "group",
|
||||
}
|
||||
return mapping.get(json_type, "text")
|
||||
|
||||
|
||||
def _is_sensitive_field(name: str) -> bool:
|
||||
sensitive_keywords = {"password", "token", "secret", "key", "api_key", "private"}
|
||||
return any(keyword in name.lower() for keyword in sensitive_keywords)
|
||||
Loading…
Reference in New Issue
Block a user