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

216 lines
8.5 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.

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