refactor(schemas): 统一 message/notification 命名边界,旧名收敛至兼容映射表

- notification 域:渠道能力(MessageChannel→NotificationChannel、ChannelCapability* 迁入 notification.py)
- message 域:消息收发(Notification→Message、NotificationType→MessageType、CommingMessage→IncomingMessage、NotificationHistoryItem→MessageHistoryItem、NotificationClear*→MessageClear*)
- Agent 工具契约:send_notification_message→send_message、notification_callback→message_callback
- 源码不保留旧名物理别名,旧导入经 app/runtime/compat/manifest.py SYMBOL_ALIASES 惰性解析
- API 路径与持久化键冻结不变,前端零改动
- 新增兼容守护测试与 docs/rules/07 命名边界规范
This commit is contained in:
jxxghp
2026-08-16 19:32:20 +08:00
parent 98276a68a8
commit 240a4dffe6
96 changed files with 1975 additions and 1751 deletions
+98 -98
View File
@@ -44,7 +44,7 @@ from app.application.messaging.agent import (
from app.application.messaging.router import has_pending_interaction
from app.runtime.localization import LocaleHelper
from app.runtime.log import logger
from app.schemas.types import EventType, MessageChannel
from app.schemas.types import EventType, NotificationChannel
router = ResponseAPIRouter()
@@ -62,9 +62,9 @@ WEB_AGENT_STREAM_COALESCE_MAX_CHARS = 256
WEB_AGENT_STREAM_HEARTBEAT_SECONDS = 15.0
WEB_AGENT_STREAM_QUEUE_MAX_SIZE = 64
_WEB_AGENT_FILE_REGISTRY: dict[str, dict[str, Any]] = {}
_WEB_AGENT_NOTICE_QUEUES: dict[str, list[Queue[schemas.Notification]]] = {}
_WEB_AGENT_NOTICE_LOCK = Lock()
_WEB_AGENT_NOTICE_LISTENER_REGISTERED = False
_WEB_AGENT_MESSAGE_QUEUES: dict[str, list[Queue[schemas.Message]]] = {}
_WEB_AGENT_MESSAGE_LOCK = Lock()
_WEB_AGENT_MESSAGE_LISTENER_REGISTERED = False
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
@@ -350,11 +350,11 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
def __init__(
self,
*args: Any,
notification_callback: Optional[Callable[[schemas.Notification], None]] = None,
message_callback: Optional[Callable[[schemas.Message], None]] = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self._notification_callback = notification_callback
self._message_callback = message_callback
self.stream_handler = _WebAgentStreamingHandler(self._emit_output)
def _should_stream(self) -> bool:
@@ -363,16 +363,16 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
return False
return True
def set_notification_callback(
def set_message_callback(
self,
notification_callback: Optional[Callable[[schemas.Notification], None]],
message_callback: Optional[Callable[[schemas.Message], None]],
) -> None:
"""
更新 Web SSE 通知回调,复用 Agent 实例时指向当前请求队列。
:param notification_callback: 当前请求的 Web 通知回调
:param message_callback: 当前请求的 Web 通知回调
"""
self._notification_callback = notification_callback
self._message_callback = message_callback
def set_output_callback(self, output_callback: Optional[Callable[[str], None]]) -> None:
"""
@@ -400,7 +400,7 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
async def _build_tool_context(self, should_dispatch_reply: bool) -> dict[str, object]:
"""向工具上下文注入 Web SSE 通知回调。"""
context = await super()._build_tool_context(should_dispatch_reply)
context["notification_callback"] = self._notification_callback
context["message_callback"] = self._message_callback
return context
def _handle_stream_text(self, text: str) -> None:
@@ -582,7 +582,7 @@ def _save_web_agent_display_snapshot(
channel=(
existing_chat.channel
if existing_chat and existing_chat.channel
else MessageChannel.WebAgent
else NotificationChannel.WebAgent
),
source=(
existing_chat.source
@@ -982,14 +982,14 @@ def _merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[st
return "\n".join(merged_parts).strip()
def _build_web_agent_choice_event(notification: schemas.Notification) -> Optional[dict]:
def _build_web_agent_choice_event(message: schemas.Message) -> Optional[dict]:
"""
将带按钮通知转换为 Web Agent 选择卡片事件。
:param notification: Agent 工具发出的按钮通知
:param message: Agent 工具发出的按钮通知
:return: 选择卡片事件,按钮为空时返回 None
"""
button_rows = normalize_web_agent_button_rows(notification.buttons)
button_rows = normalize_web_agent_button_rows(message.buttons)
buttons = [button for row in button_rows for button in row]
if not buttons:
return None
@@ -1003,8 +1003,8 @@ def _build_web_agent_choice_event(notification: schemas.Notification) -> Optiona
"type": "choice",
"choice": {
"id": choice_id or uuid.uuid4().hex,
"title": notification.title,
"prompt": notification.text or "",
"title": message.title,
"prompt": message.text or "",
"buttons": buttons,
"button_rows": button_rows,
},
@@ -1062,30 +1062,30 @@ def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optio
}
def _build_web_agent_notification_events(
notification: schemas.Notification,
def _build_web_agent_message_events(
message: schemas.Message,
) -> list[dict]:
"""
将 Agent 工具通知转换为 Web SSE 事件。
:param notification: 工具产生的通知消息
:param message: 工具产生的通知消息
:return: 前端可直接应用到当前助手消息的事件列表
"""
events = []
choice_event = _build_web_agent_choice_event(notification)
choice_event = _build_web_agent_choice_event(message)
if choice_event:
events.append(choice_event)
text_parts = [
str(item).strip()
for item in (notification.title, notification.text)
for item in (message.title, message.text)
if str(item or "").strip()
]
if text_parts and not choice_event:
events.append({"type": "delta", "content": "\n\n".join(text_parts)})
if notification.image:
image_ref = notification.image
if message.image:
image_ref = message.image
image_path = Path(image_ref).expanduser()
attachment = None
if not image_ref.startswith(("http://", "https://", "data:", "blob:")):
@@ -1096,12 +1096,12 @@ def _build_web_agent_notification_events(
attachment = _build_web_agent_url_attachment(
image_ref,
kind="image",
name=notification.title or image_path.name or "image",
name=message.title or image_path.name or "image",
)
events.append({"type": "attachment", "attachment": attachment})
if notification.voice_path:
audio_path = _prepare_web_agent_audio_attachment_path(notification.voice_path)
if message.voice_path:
audio_path = _prepare_web_agent_audio_attachment_path(message.voice_path)
attachment = _register_web_agent_file(
str(audio_path),
file_name=audio_path.name,
@@ -1111,10 +1111,10 @@ def _build_web_agent_notification_events(
if attachment:
events.append({"type": "attachment", "attachment": attachment})
if notification.file_path:
if message.file_path:
attachment = _register_web_agent_file(
notification.file_path,
file_name=notification.file_name or Path(notification.file_path).name,
message.file_path,
file_name=message.file_name or Path(message.file_path).name,
)
if attachment:
events.append({"type": "attachment", "attachment": attachment})
@@ -1162,9 +1162,9 @@ def _has_web_agent_traditional_interaction(user_id: str) -> bool:
return has_pending_interaction(user_id)
def _extract_web_agent_notification_from_event_data(
def _extract_web_agent_message_from_event_data(
data: dict,
) -> Optional[schemas.Notification]:
) -> Optional[schemas.Message]:
"""
从 NoticeMessage 事件数据中提取 WebAgent 通知。
@@ -1176,133 +1176,133 @@ def _extract_web_agent_notification_from_event_data(
try:
message = data.get("message")
if isinstance(message, schemas.Notification):
notification = message
if isinstance(message, schemas.Message):
message = message
elif isinstance(message, dict):
notification_data = copy.deepcopy(message)
notification_data.pop("type", None)
notification = schemas.Notification(**notification_data)
message_data = copy.deepcopy(message)
message_data.pop("type", None)
message = schemas.Message(**message_data)
else:
notification_data = copy.deepcopy(data)
notification_data.pop("type", None)
notification_data.pop("current_time", None)
notification = schemas.Notification(**notification_data)
message_data = copy.deepcopy(data)
message_data.pop("type", None)
message_data.pop("current_time", None)
message = schemas.Message(**message_data)
except Exception as err:
logger.debug(f"解析WebAgent通知事件失败: {err}")
return None
channel = notification.channel
channel_value = channel.value if isinstance(channel, MessageChannel) else channel
if channel_value != MessageChannel.WebAgent.value:
channel = message.channel
channel_value = channel.value if isinstance(channel, NotificationChannel) else channel
if channel_value != NotificationChannel.WebAgent.value:
return None
return notification
return message
def _is_web_agent_notice_for_user(
notification: schemas.Notification,
def _is_web_agent_message_for_user(
message: schemas.Message,
user_id: str,
) -> bool:
"""
判断 NoticeMessage 事件是否属于当前 WebAgent 用户。
:param notification: NoticeMessage 中的通知消息
:param message: NoticeMessage 中的通知消息
:param user_id: 当前登录用户 ID
:return: 可被本次 WebAgent 请求消费时返回 True
"""
try:
target_user = notification.userid
target_user = message.userid
return target_user is None or str(target_user) == str(user_id)
except Exception:
return False
def _get_web_agent_notice_user_id(notification: schemas.Notification) -> Optional[str]:
def _get_web_agent_message_user_id(message: schemas.Message) -> Optional[str]:
"""
从 NoticeMessage 事件中解析 WebAgent 目标用户。
:param notification: NoticeMessage 中的通知消息
:param message: NoticeMessage 中的通知消息
:return: 用户 ID 字符串,事件不属于 WebAgent 时返回 None
"""
try:
channel = notification.channel
channel_value = channel.value if isinstance(channel, MessageChannel) else channel
if channel_value != MessageChannel.WebAgent.value:
channel = message.channel
channel_value = channel.value if isinstance(channel, NotificationChannel) else channel
if channel_value != NotificationChannel.WebAgent.value:
return None
user_id = notification.userid
user_id = message.userid
return str(user_id) if user_id is not None else None
except Exception:
return None
def _dispatch_web_agent_notice_event(event: Event) -> None:
def _dispatch_web_agent_message_event(event: Event) -> None:
"""
将 WebAgent NoticeMessage 分发给正在等待的请求队列。
:param event: NoticeMessage 广播事件
"""
data = event.event_data if isinstance(event.event_data, dict) else {}
notification = _extract_web_agent_notification_from_event_data(data)
if not notification:
message = _extract_web_agent_message_from_event_data(data)
if not message:
return
with _WEB_AGENT_NOTICE_LOCK:
user_id = _get_web_agent_notice_user_id(notification)
with _WEB_AGENT_MESSAGE_LOCK:
user_id = _get_web_agent_message_user_id(message)
if user_id is None:
queues = [
notice_queue
for user_queues in _WEB_AGENT_NOTICE_QUEUES.values()
for notice_queue in user_queues
message_queue
for user_queues in _WEB_AGENT_MESSAGE_QUEUES.values()
for message_queue in user_queues
]
else:
queues = list(_WEB_AGENT_NOTICE_QUEUES.get(user_id) or [])
for notice_queue in queues:
notice_queue.put(notification)
queues = list(_WEB_AGENT_MESSAGE_QUEUES.get(user_id) or [])
for message_queue in queues:
message_queue.put(message)
def _ensure_web_agent_notice_listener() -> None:
def _ensure_web_agent_message_listener() -> None:
"""
确保 WebAgent NoticeMessage 全局监听器已注册。
"""
global _WEB_AGENT_NOTICE_LISTENER_REGISTERED
if _WEB_AGENT_NOTICE_LISTENER_REGISTERED:
global _WEB_AGENT_MESSAGE_LISTENER_REGISTERED
if _WEB_AGENT_MESSAGE_LISTENER_REGISTERED:
return
with _WEB_AGENT_NOTICE_LOCK:
if _WEB_AGENT_NOTICE_LISTENER_REGISTERED:
with _WEB_AGENT_MESSAGE_LOCK:
if _WEB_AGENT_MESSAGE_LISTENER_REGISTERED:
return
EventManager().add_event_listener(
EventType.NoticeMessage,
_dispatch_web_agent_notice_event,
_dispatch_web_agent_message_event,
)
_WEB_AGENT_NOTICE_LISTENER_REGISTERED = True
_WEB_AGENT_MESSAGE_LISTENER_REGISTERED = True
def _attach_web_agent_notice_queue(user_id: str, notice_queue: Queue[schemas.Notification]) -> None:
def _attach_web_agent_message_queue(user_id: str, message_queue: Queue[schemas.Message]) -> None:
"""
为当前 WebAgent 请求挂载通知收集队列。
:param user_id: 当前用户 ID
:param notice_queue: 用于接收通知事件的队列
:param message_queue: 用于接收通知事件的队列
"""
_ensure_web_agent_notice_listener()
with _WEB_AGENT_NOTICE_LOCK:
_WEB_AGENT_NOTICE_QUEUES.setdefault(str(user_id), []).append(notice_queue)
_ensure_web_agent_message_listener()
with _WEB_AGENT_MESSAGE_LOCK:
_WEB_AGENT_MESSAGE_QUEUES.setdefault(str(user_id), []).append(message_queue)
def _detach_web_agent_notice_queue(user_id: str, notice_queue: Queue[schemas.Notification]) -> None:
def _detach_web_agent_message_queue(user_id: str, message_queue: Queue[schemas.Message]) -> None:
"""
移除当前 WebAgent 请求的通知收集队列。
:param user_id: 当前用户 ID
:param notice_queue: 需要移除的队列
:param message_queue: 需要移除的队列
"""
with _WEB_AGENT_NOTICE_LOCK:
queues = _WEB_AGENT_NOTICE_QUEUES.get(str(user_id))
with _WEB_AGENT_MESSAGE_LOCK:
queues = _WEB_AGENT_MESSAGE_QUEUES.get(str(user_id))
if not queues:
return
_WEB_AGENT_NOTICE_QUEUES[str(user_id)] = [
item for item in queues if item is not notice_queue
_WEB_AGENT_MESSAGE_QUEUES[str(user_id)] = [
item for item in queues if item is not message_queue
]
if not _WEB_AGENT_NOTICE_QUEUES[str(user_id)]:
_WEB_AGENT_NOTICE_QUEUES.pop(str(user_id), None)
if not _WEB_AGENT_MESSAGE_QUEUES[str(user_id)]:
_WEB_AGENT_MESSAGE_QUEUES.pop(str(user_id), None)
def _build_web_agent_command_items() -> list[dict]:
@@ -1387,16 +1387,16 @@ async def _collect_web_agent_traditional_events(
:param original_chat_id: WebAgent 原聊天 ID
:return: 可直接发送给前端的 SSE 事件列表
"""
notice_queue: Queue[schemas.Notification] = Queue()
message_queue: Queue[schemas.Message] = Queue()
edit_queue: Queue[dict] = Queue()
user_id = str(current_user.id)
_attach_web_agent_notice_queue(user_id, notice_queue)
_attach_web_agent_message_queue(user_id, message_queue)
attach_web_agent_edit_queue(user_id, edit_queue)
try:
await run_in_threadpool(
MessageChain().handle_message,
channel=MessageChannel.WebAgent,
channel=NotificationChannel.WebAgent,
source=WEB_AGENT_SOURCE,
userid=user_id,
username=current_user.name or user_id,
@@ -1424,19 +1424,19 @@ async def _collect_web_agent_traditional_events(
wait_until = idle_deadline or deadline
timeout = max(0.05, min(0.25, wait_until - now, deadline - now))
try:
notification = await asyncio.to_thread(notice_queue.get, True, timeout)
message = await asyncio.to_thread(message_queue.get, True, timeout)
except Empty:
if idle_deadline and time.monotonic() >= idle_deadline:
break
continue
if not _is_web_agent_notice_for_user(notification, user_id):
if not _is_web_agent_message_for_user(message, user_id):
continue
events.extend(_build_web_agent_notification_events(notification))
events.extend(_build_web_agent_message_events(message))
idle_deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS
return events
finally:
_detach_web_agent_notice_queue(user_id, notice_queue)
_detach_web_agent_message_queue(user_id, message_queue)
detach_web_agent_edit_queue(user_id, edit_queue)
@@ -1884,7 +1884,7 @@ async def web_agent_stream(
and agent_manager.matches_secret_confirmation(
session_id,
str(current_user.id),
channel=MessageChannel.WebAgent.value,
channel=NotificationChannel.WebAgent.value,
source=WEB_AGENT_SOURCE,
)
)
@@ -2099,11 +2099,11 @@ async def web_agent_stream(
_apply_web_agent_display_event(item, assistant_display_message)
event_publisher.publish(item)
def notification_callback(notification: schemas.Notification) -> None:
def message_callback(message: schemas.Message) -> None:
"""
接收 Agent 工具主动发送的 Web 通知。
"""
for item in _build_web_agent_notification_events(notification):
for item in _build_web_agent_message_events(message):
_apply_web_agent_display_event(item, assistant_display_message)
event_publisher.publish(item)
@@ -2139,7 +2139,7 @@ async def web_agent_stream(
images=payload.images or [],
files=files or None,
has_audio_input=has_audio_input,
channel=MessageChannel.WebAgent.value,
channel=NotificationChannel.WebAgent.value,
source=WEB_AGENT_SOURCE,
username=current_user.name,
reply_mode=ReplyMode.CAPTURE_ONLY,
@@ -2150,7 +2150,7 @@ async def web_agent_stream(
if protected_transport_supported
else None
),
notification_callback=notification_callback,
message_callback=message_callback,
agent_factory=_WebAgentMoviePilotAgent,
wait_for_completion=True,
)
+2 -2
View File
@@ -18,7 +18,7 @@ from app.api.openai_utils import (
)
from app.runtime.config import settings
from app.application.security.access import anthropic_api_key_header
from app.schemas.types import MessageChannel
from app.schemas.types import NotificationChannel
ANTHROPIC_ERROR_RESPONSES = {
400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"},
@@ -145,7 +145,7 @@ async def messages(
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_id,
channel=MessageChannel.Web.value,
channel=NotificationChannel.Web.value,
source="anthropic",
username="anthropic-client",
stream_mode=payload.stream,
+9 -9
View File
@@ -20,7 +20,7 @@ from app.api.deps import get_current_active_superuser
from app.runtime.extensions.service_registry import ServiceConfigHelper
from app.runtime.log import logger
from app.adapters.external.wechat_crypt import WXBizMsgCrypt
from app.schemas.types import MessageChannel, SystemConfigKey
from app.schemas.types import NotificationChannel, SystemConfigKey
router = ResponseAPIRouter()
@@ -64,18 +64,18 @@ def _normalize_notification_clear_timestamp(value: Any) -> int:
return normalized_value if normalized_value > 0 else 0
def _get_notification_clear_before() -> schemas.NotificationClearBefore:
def _get_notification_clear_before() -> schemas.MessageClearBefore:
"""
读取通知中心清理时间配置。
"""
value = SystemConfigOper().get(SystemConfigKey.NotificationClearBefore)
if isinstance(value, dict):
return schemas.NotificationClearBefore(
return schemas.MessageClearBefore(
all=_normalize_notification_clear_timestamp(value.get("all")),
system=_normalize_notification_clear_timestamp(value.get("system")),
media=_normalize_notification_clear_timestamp(value.get("media")),
)
return schemas.NotificationClearBefore(
return schemas.MessageClearBefore(
all=_normalize_notification_clear_timestamp(value),
)
@@ -166,7 +166,7 @@ async def web_message(
images = [images]
MessageChain().handle_message(
channel=MessageChannel.Web,
channel=NotificationChannel.Web,
source=current_user.name,
userid=current_user.name,
username=current_user.name,
@@ -197,7 +197,7 @@ async def get_web_message(
return ret_messages
@router.get("/notification", summary="获取通知消息", response_model=List[schemas.NotificationHistoryItem])
@router.get("/notification", summary="获取通知消息", response_model=List[schemas.MessageHistoryItem])
async def get_notification_message(
_: schemas.TokenPayload = Depends(verify_token),
db: AsyncSession = Depends(get_async_db),
@@ -215,16 +215,16 @@ async def get_notification_message(
system_clear_before=_format_notification_clear_time(clear_before.system),
media_clear_before=_format_notification_clear_time(clear_before.media),
)
return [schemas.NotificationHistoryItem(**message.to_dict()) for message in messages]
return [schemas.MessageHistoryItem(**message.to_dict()) for message in messages]
@router.delete(
"/notification",
summary="清理通知消息",
response_model=schemas.Response[schemas.NotificationClearData],
response_model=schemas.Response[schemas.MessageClearData],
)
async def clear_notification_message(
scope: schemas.NotificationClearScope = schemas.NotificationClearScope.All,
scope: schemas.MessageClearScope = schemas.MessageClearScope.All,
_: schemas.TokenPayload = Depends(verify_token),
):
"""
+3 -3
View File
@@ -19,7 +19,7 @@ from app.agent.callback import StreamingHandler
from app.agent.orchestrator import MoviePilotAgent
from app.runtime.config import settings
from app.application.security.access import openai_bearer_scheme
from app.schemas.types import MessageChannel
from app.schemas.types import NotificationChannel
OPENAI_ERROR_RESPONSES = {
400: {"model": schemas.OpenAIErrorResponse, "description": "请求格式错误"},
@@ -341,7 +341,7 @@ async def chat_completions(
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
channel=MessageChannel.Web.value,
channel=NotificationChannel.Web.value,
source="openai",
username=username,
stream_mode=payload.stream,
@@ -434,7 +434,7 @@ async def responses(
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
channel=MessageChannel.Web.value,
channel=NotificationChannel.Web.value,
source="openai.responses",
username=str(payload.user or "openai-client"),
stream_mode=False,