diff --git a/app/agent/web.py b/app/agent/web.py new file mode 100644 index 000000000..c4ab63cc8 --- /dev/null +++ b/app/agent/web.py @@ -0,0 +1,235 @@ +"""WebAgent 运行时类型适配,不包含 HTTP 或 SSE 编码。""" + +from threading import Lock +from typing import Any, Awaitable, Callable, Optional + +from app.agent.runtime_loader import get_moviepilot_agent_type +from app.application.security.user import get_configured_user_id_lookup +from app.runtime.log import logger +from app.schemas.message import Message + + +class _WebAgentStreamingHandlerMixin: + """ + Web 前端专用流式处理器,将工具提示和文本统一回调给 SSE。 + """ + + _lock: Lock + _pending_tool_stats: dict[str, int] + + def __init__(self, on_emit: Callable[[str], None]) -> None: + super().__init__() + self._on_emit = on_emit + + def set_emit_callback(self, on_emit: Callable[[str], None]) -> None: + """ + 更新流式输出回调,复用 WebAgent 实例时指向当前 SSE 请求。 + + :param on_emit: 当前请求的输出回调 + """ + self._on_emit = on_emit + + def record_tool_call( + self, + tool_name: str, + tool_message: Optional[str] = None, + tool_kwargs: Optional[dict[str, Any]] = None, + ) -> None: + """记录并立即输出 Web 工具事件,避免汇总延迟到正文结束后。""" + # 该方法由运行时组合进 MRO 的 StreamingHandler 提供。 + super().record_tool_call( # type: ignore[misc] + tool_name=tool_name, + tool_message=tool_message, + tool_kwargs=tool_kwargs, + ) + self.flush_pending_tool_summary() + + def emit(self, token: str) -> str: + """追加 token 并同步通知 SSE 生产者。""" + # 该方法由运行时组合进 MRO 的 StreamingHandler 提供。 + emitted = super().emit(token) # type: ignore[misc] + if emitted: + self._on_emit(emitted) + return str(emitted) + + def flush_pending_tool_summary(self) -> str: + """输出延迟聚合的工具摘要。""" + # 该方法由运行时组合进 MRO 的 StreamingHandler 提供。 + emitted = super().flush_pending_tool_summary() # type: ignore[misc] + if emitted: + self._on_emit(emitted) + return "" + + async def start_streaming( + self, + channel: Optional[str] = None, + source: Optional[str] = None, + user_id: Optional[str] = None, + username: Optional[str] = None, + original_message_id: Optional[str] = None, + original_chat_id: Optional[str] = None, + title: str = "", + ) -> None: + """Web SSE 自身负责外发,不启动消息模块编辑循环。""" + self._channel = channel + self._source = source + self._user_id = user_id + self._username = username + self._original_message_id = original_message_id + self._original_chat_id = original_chat_id + self._title = title + self._streaming_enabled = True + self._sent_text = "" + self._message_response = None + self._msg_start_offset = 0 + self._pending_tool_stats = {} + + async def stop_streaming(self) -> tuple[bool, str]: + """停止 Web SSE 流式状态,保留缓冲区给 Agent 收口逻辑去重。""" + if not self._streaming_enabled: + return False, "" + self._streaming_enabled = False + self.flush_pending_tool_summary() + with self._lock: + self._sent_text = "" + self._message_response = None + self._msg_start_offset = 0 + self._pending_tool_stats = {} + return False, "" + + @property + def is_auto_flushing(self) -> bool: + """让工具执行提示进入缓冲区,由 SSE 回调负责外发。""" + return True + + +def _get_web_agent_streaming_handler_type() -> type: + """首次构造 Web Agent 时才解析完整流式处理器实现。""" + global _WEB_AGENT_STREAMING_HANDLER_TYPE + if _WEB_AGENT_STREAMING_HANDLER_TYPE is not None: + return _WEB_AGENT_STREAMING_HANDLER_TYPE + with _WEB_AGENT_STREAMING_HANDLER_TYPE_LOCK: + if _WEB_AGENT_STREAMING_HANDLER_TYPE is None: + from app.agent.callback import StreamingHandler + + _WEB_AGENT_STREAMING_HANDLER_TYPE = type( + "_RuntimeWebAgentStreamingHandler", + (_WebAgentStreamingHandlerMixin, StreamingHandler), + {"__module__": __name__}, + ) + return _WEB_AGENT_STREAMING_HANDLER_TYPE + + +_WEB_AGENT_STREAMING_HANDLER_TYPE_LOCK = Lock() +_WEB_AGENT_STREAMING_HANDLER_TYPE: Optional[type] = None + + +class _WebAgentMoviePilotAgentMixin: + """ + Web 前端专用 Agent,强制使用流式推理。 + """ + + is_background: bool + output_callback: Optional[Callable[[str], None]] + stream_handler: Any + user_id: Optional[str] + _streamed_output: str + + def __init__( + self, + *args: Any, + message_callback: Optional[Callable[[Message], Awaitable[None] | None]] = None, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self._message_callback = message_callback + self.stream_handler = _get_web_agent_streaming_handler_type()(self._emit_output) + + def _should_stream(self) -> bool: + """Web 对话实时输出,复用会话执行后台任务时改用非流式广播。""" + if self.is_background: + return False + return True + + def set_message_callback( + self, + message_callback: Optional[Callable[[Message], Awaitable[None] | None]], + ) -> None: + """ + 更新 Web SSE 通知回调,复用 Agent 实例时指向当前请求队列。 + + :param message_callback: 当前请求的 Web 通知回调 + """ + self._message_callback = message_callback + + def set_output_callback(self, output_callback: Optional[Callable[[str], None]]) -> None: + """ + 更新 Web SSE 输出回调,复用 Agent 实例时指向当前请求队列。 + + :param output_callback: 当前请求的输出回调 + """ + self.output_callback = output_callback + if output_callback and isinstance(self.stream_handler, _WebAgentStreamingHandlerMixin): + self.stream_handler.set_emit_callback(self._emit_output) + + async def _is_system_admin_context(self) -> bool: + """Web Agent 根据当前登录用户 ID 判断工具管理员上下文。""" + if not self.user_id: + return False + try: + user = get_configured_user_id_lookup()(int(self.user_id)) + except (TypeError, ValueError): + return False + except Exception as e: + logger.error(f"检查 Web Agent 用户管理员身份失败: {e}") + return False + return bool(user and user.is_superuser) + + async def _build_tool_context(self, should_dispatch_reply: bool) -> dict[str, object]: + """向工具上下文注入 Web SSE 通知回调。""" + # 该方法由运行时组合进 MRO 的 MoviePilotAgent 提供。 + context = await super()._build_tool_context( # type: ignore[misc] + should_dispatch_reply + ) + context["message_callback"] = self._message_callback + return dict(context) + + def _handle_stream_text(self, text: str) -> None: + """文本输出交由 Web 流式处理器统一回调,避免重复增量。""" + self.stream_handler.emit(text) + + def _emit_output(self, text: str) -> None: + """保留完整输出状态,同时只把本次增量交给 Web SSE 回调。""" + if not text: + return + self._streamed_output += text + if not callable(self.output_callback): + return + try: + self.output_callback(text) + except Exception as e: + logger.debug(f"Web智能体输出回调失败: {e}") + + +def _build_web_agent_type(agent_base_type: type) -> type: + """为 Web 通道组合唯一的运行时 Agent 类型。""" + return type( + "_RuntimeWebAgentMoviePilotAgent", + (_WebAgentMoviePilotAgentMixin, agent_base_type), + {"__module__": __name__}, + ) + + +_WEB_AGENT_TYPE_LOCK = Lock() +_WEB_AGENT_TYPE: Optional[type] = None + + +def _get_web_agent_type() -> type: + """在真实 Web Agent 调用边界 single-flight 解析运行时类型。""" + global _WEB_AGENT_TYPE + if _WEB_AGENT_TYPE is not None: + return _WEB_AGENT_TYPE + with _WEB_AGENT_TYPE_LOCK: + if _WEB_AGENT_TYPE is None: + _WEB_AGENT_TYPE = _build_web_agent_type(get_moviepilot_agent_type()) + return _WEB_AGENT_TYPE diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index a03519bfb..76a9a75ce 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -1,65 +1,33 @@ -import asyncio -import copy -import hashlib import json import mimetypes -import shutil -import time import uuid -from collections import deque -from pathlib import Path -from queue import Empty, Queue -from threading import Lock -from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Union +from typing import Any, AsyncIterator, Optional -import aiofiles from fastapi import Depends, File, Form, HTTPException, Request, UploadFile, status from fastapi.responses import FileResponse, StreamingResponse -from app.agent.contracts import ReplyMode, build_display_message from app.agent.mcp import agent_mcp_manager -from app.agent.runtime_loader import get_moviepilot_agent_type from app.api.dependencies.agent import ( get_agent_chat_persistence, get_agent_chat_service, ) from app.api.dependencies.auth import get_current_active_user -from app.api.presentation.sse import build_sse_error_response, build_sse_response +from app.api.presentation.sse import build_sse_response from app.api.principal import ApiPrincipal from app.api.response import ResponseAPIRouter -from app.application.agent import ( - get_running_agent_manager, - is_audio_input_available, - transcribe_audio, -) -from app.application.commands import get_command, get_commands -from app.application.configuration import get_api_runtime_config_snapshot +from app.application import agent as agent_application +from app.application.messaging import agent as web_agent_application from app.application.messaging.agent import ( - agent_interaction_manager, - attach_web_agent_edit_queue, - attach_web_agent_message_queue, - build_agent_choice_button_rows, - create_web_agent_background_task, - detach_web_agent_edit_queue, - detach_web_agent_message_queue, - is_web_agent_message_for_user, - normalize_web_agent_button_rows, parse_agent_choice_callback, ) from app.application.messaging.chat import ( AgentChatPersistenceService, - AgentChatRecord, AgentChatService, get_configured_agent_chat_persistence, get_configured_agent_chat_service, ) -from app.application.messaging.router import has_pending_interaction -from app.application.security.user import get_configured_user_id_lookup -from app.chain.message import MessageChain -from app.runtime.execution import run_in_threadpool from app.runtime.localization import LocaleHelper from app.runtime.log import logger -from app.runtime.stop import runtime_stop_state from app.schemas.agent import AgentChatDisplaySaveRequest as _SchemaAgentChatDisplaySaveRequest from app.schemas.agent import AgentChatSessionDetail as _SchemaAgentChatSessionDetail from app.schemas.agent import AgentChatSessionSummary as _SchemaAgentChatSessionSummary @@ -73,130 +41,10 @@ from app.schemas.agent import AgentWebCallbackData as _SchemaAgentWebCallbackDat from app.schemas.agent import AgentWebCommandInfo as _SchemaAgentWebCommandInfo from app.schemas.message import AgentWebChatRequest as _SchemaAgentWebChatRequest from app.schemas.message import AgentWebChoiceRequest as _SchemaAgentWebChoiceRequest -from app.schemas.message import Message as _SchemaMessage from app.schemas.response import Response as _SchemaResponse -from app.schemas.types import NotificationChannel router = ResponseAPIRouter() -WEB_AGENT_SESSION_PREFIX = "web-agent:" -WEB_AGENT_SOURCE = "web-agent" -WEB_AGENT_FILE_TTL_SECONDS = 6 * 60 * 60 -WEB_AGENT_FILE_MAX_ITEMS = 256 -WEB_AGENT_UPLOAD_MAX_BYTES = 32 * 1024 * 1024 -WEB_AGENT_UPLOAD_CHUNK_SIZE = 1024 * 1024 -WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS = 60.0 -WEB_AGENT_BROWSER_AUDIO_SUFFIXES = {".aac", ".m4a", ".mp3", ".mp4", ".wav", ".wave"} -WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS = 2.0 -WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS = 60.0 -WEB_AGENT_STREAM_COALESCE_SECONDS = 0.03 -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]] = {} - - -class _WebAgentEventPublisher: - """合并 WebAgent 文本增量,并通过有界队列向 SSE 消费者提供事件。""" - - def __init__(self) -> None: - self._queue: asyncio.Queue[dict] = asyncio.Queue( - maxsize=WEB_AGENT_STREAM_QUEUE_MAX_SIZE - ) - self._pending_events: deque[dict] = deque() - self._pending_signal = asyncio.Event() - self._pending_delta = "" - self._delta_timer: Optional[asyncio.TimerHandle] = None - self._disposed = False - self._max_depth = 0 - self._last_logged_depth = 0 - self._pump_task = asyncio.create_task(self._pump()) - - @property - def max_depth(self) -> int: - """返回本轮发布器观测到的最大积压深度。""" - return self._max_depth - - def publish(self, event: dict) -> bool: - """发布事件;返回关闭状态以便受保护投递能准确报告失败。""" - if self._disposed: - return False - if event.get("type") == "delta": - self._pending_delta += str(event.get("content") or "") - if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS: - self._flush_delta() - elif self._delta_timer is None: - loop = asyncio.get_running_loop() - self._delta_timer = loop.call_later( - WEB_AGENT_STREAM_COALESCE_SECONDS, - self._flush_delta, - ) - return True - - self._flush_delta() - self._append_event(event) - return True - - async def get(self) -> dict: - """等待并返回下一条已排序事件。""" - return await self._queue.get() - - async def aclose(self) -> None: - """停止发布器并释放等待中的泵任务。""" - if self._disposed: - return - self._disposed = True - self._cancel_delta_timer() - self._pending_delta = "" - self._pending_events.clear() - self._pump_task.cancel() - try: - await self._pump_task - except asyncio.CancelledError: - pass - - def _cancel_delta_timer(self) -> None: - """取消尚未触发的文本合并计时器。""" - if self._delta_timer is None: - return - self._delta_timer.cancel() - self._delta_timer = None - - def _flush_delta(self) -> None: - """把当前文本缓冲转换成一条增量事件。""" - self._cancel_delta_timer() - if not self._pending_delta or self._disposed: - return - content = self._pending_delta - self._pending_delta = "" - self._append_event({"type": "delta", "content": content}) - - def _append_event(self, event: dict) -> None: - """追加待发布事件,相邻文本在出口阻塞时继续合并。""" - if ( - event.get("type") == "delta" - and self._pending_events - and self._pending_events[-1].get("type") == "delta" - ): - self._pending_events[-1]["content"] += str(event.get("content") or "") - else: - self._pending_events.append(event) - self._pending_signal.set() - depth = self._queue.qsize() + len(self._pending_events) - self._max_depth = max(self._max_depth, depth) - if depth >= WEB_AGENT_STREAM_QUEUE_MAX_SIZE // 2 and depth > self._last_logged_depth: - self._last_logged_depth = depth - logger.debug(f"WebAgent SSE事件积压深度: {depth}") - - async def _pump(self) -> None: - """按发布顺序把本地合并结果写入有界出口队列。""" - while True: - await self._pending_signal.wait() - while self._pending_events: - event = self._pending_events.popleft() - await self._queue.put(event) - self._pending_signal.clear() - def _ensure_superuser(user: ApiPrincipal) -> None: """校验当前用户是否为超级管理员。""" @@ -282,435 +130,10 @@ async def test_agent_mcp_server( ) -class _WebAgentStreamingHandlerMixin: - """ - Web 前端专用流式处理器,将工具提示和文本统一回调给 SSE。 - """ - - def __init__(self, on_emit: Callable[[str], None]) -> None: - super().__init__() - self._on_emit = on_emit - - def set_emit_callback(self, on_emit: Callable[[str], None]) -> None: - """ - 更新流式输出回调,复用 WebAgent 实例时指向当前 SSE 请求。 - - :param on_emit: 当前请求的输出回调 - """ - self._on_emit = on_emit - - def record_tool_call( - self, - tool_name: str, - tool_message: Optional[str] = None, - tool_kwargs: Optional[dict[str, Any]] = None, - ) -> None: - """记录并立即输出 Web 工具事件,避免汇总延迟到正文结束后。""" - super().record_tool_call( - tool_name=tool_name, - tool_message=tool_message, - tool_kwargs=tool_kwargs, - ) - self.flush_pending_tool_summary() - - def emit(self, token: str) -> str: - """追加 token 并同步通知 SSE 生产者。""" - emitted = super().emit(token) - if emitted: - self._on_emit(emitted) - return emitted - - def flush_pending_tool_summary(self) -> str: - """输出延迟聚合的工具摘要。""" - emitted = super().flush_pending_tool_summary() - if emitted: - self._on_emit(emitted) - return "" - - async def start_streaming( - self, - channel: Optional[str] = None, - source: Optional[str] = None, - user_id: Optional[str] = None, - username: Optional[str] = None, - original_message_id: Optional[str] = None, - original_chat_id: Optional[str] = None, - title: str = "", - ) -> None: - """Web SSE 自身负责外发,不启动消息模块编辑循环。""" - self._channel = channel - self._source = source - self._user_id = user_id - self._username = username - self._original_message_id = original_message_id - self._original_chat_id = original_chat_id - self._title = title - self._streaming_enabled = True - self._sent_text = "" - self._message_response = None - self._msg_start_offset = 0 - self._pending_tool_stats = {} - - async def stop_streaming(self) -> tuple[bool, str]: - """停止 Web SSE 流式状态,保留缓冲区给 Agent 收口逻辑去重。""" - if not self._streaming_enabled: - return False, "" - self._streaming_enabled = False - self.flush_pending_tool_summary() - with self._lock: - self._sent_text = "" - self._message_response = None - self._msg_start_offset = 0 - self._pending_tool_stats = {} - return False, "" - - @property - def is_auto_flushing(self) -> bool: - """让工具执行提示进入缓冲区,由 SSE 回调负责外发。""" - return True - - -def _get_web_agent_streaming_handler_type() -> type: - """首次构造 Web Agent 时才解析完整流式处理器实现。""" - global _WEB_AGENT_STREAMING_HANDLER_TYPE - if _WEB_AGENT_STREAMING_HANDLER_TYPE is not None: - return _WEB_AGENT_STREAMING_HANDLER_TYPE - with _WEB_AGENT_STREAMING_HANDLER_TYPE_LOCK: - if _WEB_AGENT_STREAMING_HANDLER_TYPE is None: - from app.agent.callback import StreamingHandler - - _WEB_AGENT_STREAMING_HANDLER_TYPE = type( - "_RuntimeWebAgentStreamingHandler", - (_WebAgentStreamingHandlerMixin, StreamingHandler), - {"__module__": __name__}, - ) - return _WEB_AGENT_STREAMING_HANDLER_TYPE - - -_WEB_AGENT_STREAMING_HANDLER_TYPE_LOCK = Lock() -_WEB_AGENT_STREAMING_HANDLER_TYPE: Optional[type] = None - - -class _WebAgentMoviePilotAgentMixin: - """ - Web 前端专用 Agent,强制使用流式推理。 - """ - - def __init__( - self, - *args: Any, - message_callback: Optional[ - Callable[[_SchemaMessage], Awaitable[None] | None] - ] = None, - **kwargs: Any, - ) -> None: - super().__init__(*args, **kwargs) - self._message_callback = message_callback - self.stream_handler = _get_web_agent_streaming_handler_type()( - self._emit_output - ) - - def _should_stream(self) -> bool: - """Web 对话实时输出,复用会话执行后台任务时改用非流式广播。""" - if self.is_background: - return False - return True - - def set_message_callback( - self, - message_callback: Optional[ - Callable[[_SchemaMessage], Awaitable[None] | None] - ], - ) -> None: - """ - 更新 Web SSE 通知回调,复用 Agent 实例时指向当前请求队列。 - - :param message_callback: 当前请求的 Web 通知回调 - """ - self._message_callback = message_callback - - def set_output_callback(self, output_callback: Optional[Callable[[str], None]]) -> None: - """ - 更新 Web SSE 输出回调,复用 Agent 实例时指向当前请求队列。 - - :param output_callback: 当前请求的输出回调 - """ - self.output_callback = output_callback - if output_callback and isinstance( - self.stream_handler, _WebAgentStreamingHandlerMixin - ): - self.stream_handler.set_emit_callback(self._emit_output) - - async def _is_system_admin_context(self) -> bool: - """Web Agent 根据当前登录用户 ID 判断工具管理员上下文。""" - if not self.user_id: - return False - try: - user = get_configured_user_id_lookup()(int(self.user_id)) - except (TypeError, ValueError): - return False - except Exception as e: - logger.error(f"检查 Web Agent 用户管理员身份失败: {e}") - return False - return bool(user and user.is_superuser) - - 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["message_callback"] = self._message_callback - return context - - def _handle_stream_text(self, text: str) -> None: - """文本输出交由 Web 流式处理器统一回调,避免重复增量。""" - self.stream_handler.emit(text) - - def _emit_output(self, text: str) -> None: - """保留完整输出状态,同时只把本次增量交给 Web SSE 回调。""" - if not text: - return - self._streamed_output += text - if not callable(self.output_callback): - return - try: - self.output_callback(text) - except Exception as e: - logger.debug(f"Web智能体输出回调失败: {e}") - - -def _build_web_agent_type(agent_base_type: type) -> type: - """为 Web 通道组合唯一的运行时 Agent 类型。""" - return type( - "_RuntimeWebAgentMoviePilotAgent", - (_WebAgentMoviePilotAgentMixin, agent_base_type), - {"__module__": __name__}, - ) - - -_WEB_AGENT_TYPE_LOCK = Lock() -_WEB_AGENT_TYPE: Optional[type] = None - - -def _get_web_agent_type() -> type: - """在真实 Web Agent 调用边界 single-flight 解析运行时类型。""" - global _WEB_AGENT_TYPE - if _WEB_AGENT_TYPE is not None: - return _WEB_AGENT_TYPE - with _WEB_AGENT_TYPE_LOCK: - if _WEB_AGENT_TYPE is None: - _WEB_AGENT_TYPE = _build_web_agent_type(get_moviepilot_agent_type()) - return _WEB_AGENT_TYPE - - -def _build_web_agent_session_id(user: ApiPrincipal, session_id: Optional[str]) -> str: - """ - 构建前端 Agent 会话 ID。 - - :param user: 当前登录用户 - :param session_id: 前端传入的会话标识 - :return: 可用于 Agent 记忆隔离的服务端会话 ID - """ - seed = str(session_id or "").strip() or uuid.uuid4().hex - if seed.startswith(WEB_AGENT_SESSION_PREFIX): - return seed - try: - existing_chat = get_configured_agent_chat_service().get_sync(seed) - if existing_chat and AgentChatService.can_access(existing_chat, user): - return seed - except Exception as e: - logger.debug(f"读取WebAgent历史会话失败: {e}") - user_part = user.name or str(user.id) - digest = hashlib.sha256(f"{user_part}:{seed}".encode("utf-8")).hexdigest() - return f"{WEB_AGENT_SESSION_PREFIX}{digest[:32]}" - - -async def _build_web_agent_session_id_async( - user: ApiPrincipal, - session_id: Optional[str], - service: Optional[AgentChatService] = None, -) -> str: - """异步解析 Web Agent 会话 ID,并复用异步会话查询端口。""" - seed = str(session_id or "").strip() or uuid.uuid4().hex - if seed.startswith(WEB_AGENT_SESSION_PREFIX): - return seed - try: - if service is None: - service = get_configured_agent_chat_service() - existing_chat = await service.get(seed) - if existing_chat and AgentChatService.can_access(existing_chat, user): - return seed - except Exception as e: - logger.debug(f"读取WebAgent历史会话失败: {e}") - user_part = user.name or str(user.id) - digest = hashlib.sha256(f"{user_part}:{seed}".encode("utf-8")).hexdigest() - return f"{WEB_AGENT_SESSION_PREFIX}{digest[:32]}" - - -def _can_access_agent_chat(chat: Any, user: ApiPrincipal) -> bool: - """ - 判断当前登录用户是否可以访问指定 Agent 会话。 - - 超级用户可查看所有渠道历史;普通用户仅能查看 user_id 或 username 匹配自己的会话。 - """ - if not chat or not user: - return False - if getattr(user, "is_superuser", False): - return True - user_id = str(user.id) - username = str(user.name or "") - return chat.user_id == user_id or (bool(username) and chat.username == username) - - -async def _get_accessible_agent_chat( - service: AgentChatService, - session_id: str, - user: ApiPrincipal, -) -> Optional[AgentChatRecord]: - """ - 读取当前用户可访问的 Agent 会话。 - """ - return await service.get_accessible(session_id, user) - - -def _append_web_agent_text_segment(assistant_message: dict, content: str) -> None: - """ - 将文本增量追加到展示消息,并仅合并相邻文本片段。 - - :param assistant_message: 当前助手展示消息 - :param content: 新增文本 - """ - if not content: - return - assistant_message["content"] = str(assistant_message.get("content") or "") + content - segments = assistant_message.setdefault("segments", []) - if segments and segments[-1].get("type") == "text": - segments[-1]["content"] = str(segments[-1].get("content") or "") + content - else: - segments.append({"type": "text", "content": content}) - - -def _build_legacy_web_agent_segments(content: str, tools: list[dict]) -> list[dict]: - """ - 为未携带有序片段的旧展示消息生成兼容布局。 - - :param content: 聚合后的助手文本 - :param tools: 工具提示列表 - :return: 按旧版工具在前、文本在后的顺序生成的片段 - """ - segments = [ - {"type": "tool", "toolIndex": index} - for index in range(len(tools)) - ] - if content: - segments.append({"type": "text", "content": content}) - return segments - - -def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None: - """ - 将 WebAgent SSE 事件同步应用到服务端展示消息快照。 - """ - event_type = event.get("type") - if event_type == "delta": - _append_web_agent_text_segment( - assistant_message, event.get("content") or "" - ) - elif event_type == "tool": - for tool in assistant_message["tools"]: - tool["status"] = "done" - tool_index = len(assistant_message["tools"]) - assistant_message["tools"].append( - { - "id": f"tool-{uuid.uuid4().hex}", - "message": str(event.get("message") or "").strip(), - "status": "running", - } - ) - assistant_message.setdefault("segments", []).append( - {"type": "tool", "toolIndex": tool_index} - ) - elif event_type == "attachment" and event.get("attachment"): - assistant_message["attachments"].append(event["attachment"]) - elif event_type == "choice" and event.get("choice"): - assistant_message["choices"].append({**event["choice"], "status": "pending"}) - elif event_type == "message_update": - target_message = event.get("target_message") or {} - assistant_message["id"] = target_message.get("id") or assistant_message.get("id") - assistant_message["content"] = target_message.get("content") or "" - assistant_message["attachments"] = target_message.get("attachments") or [] - assistant_message["choices"] = target_message.get("choices") or [] - assistant_message["tools"] = target_message.get("tools") or [] - target_segments = target_message.get("segments") - assistant_message["segments"] = ( - target_segments - if isinstance(target_segments, list) - else _build_legacy_web_agent_segments( - assistant_message["content"], assistant_message["tools"] - ) - ) - assistant_message["status"] = target_message.get("status") or "done" - elif event_type == "error": - assistant_message["status"] = "error" - if not assistant_message["content"]: - _append_web_agent_text_segment( - assistant_message, - event.get("message") or "智能助手响应失败", - ) - for tool in assistant_message["tools"]: - tool["status"] = "done" - elif event_type == "done": - if assistant_message.get("status") != "error": - assistant_message["status"] = "done" - for tool in assistant_message["tools"]: - tool["status"] = "done" - - -async def _save_web_agent_display_snapshot( - *, - session_id: str, - current_user: ApiPrincipal, - messages: list[dict], - client_session_id: Optional[str] = None, - service: Optional[AgentChatService] = None, - persistence: Optional[AgentChatPersistenceService] = None, -) -> None: - """ - 保存 WebAgent 当前展示消息快照。 - """ - if service is None: - # 直接调用该内部 helper 时没有 FastAPI 依赖注入上下文。 - service = get_configured_agent_chat_service() - existing_chat = await service.get(session_id) - if persistence is None: - # 直接调用该内部 helper 时没有 FastAPI 依赖注入上下文。 - persistence = get_configured_agent_chat_persistence() - await persistence.async_save_display_messages( - session_id=session_id, - user_id=(existing_chat.user_id if existing_chat else str(current_user.id)), - username=(existing_chat.username if existing_chat else current_user.name), - channel=( - existing_chat.channel - if existing_chat and existing_chat.channel - else NotificationChannel.WebAgent - ), - source=( - existing_chat.source - if existing_chat and existing_chat.source - else WEB_AGENT_SOURCE - ), - original_chat_id=existing_chat.original_chat_id if existing_chat else None, - client_session_id=( - existing_chat.client_session_id - if existing_chat and existing_chat.client_session_id - else client_session_id - ), - messages=messages, - ) - - def _build_web_agent_sse( - event_type: str, - data: Optional[dict] = None, - locale: Optional[str] = None, + event_type: str, + data: Optional[dict[str, Any]] = None, + locale: Optional[str] = None, ) -> str: """ 构建 Web Agent SSE 消息。 @@ -721,900 +144,14 @@ def _build_web_agent_sse( :return: 符合 SSE 格式的字符串 """ if event_type == "interaction-protected": - return ( - "event: interaction-protected\n" - f"data: {json.dumps(data or {}, ensure_ascii=False)}\n\n" - ) + return f"event: interaction-protected\ndata: {json.dumps(data or {}, ensure_ascii=False)}\n\n" payload = {"type": event_type, **(data or {})} message = payload.get("message") if event_type == "error" and isinstance(message, str): - payload["message_i18n"] = LocaleHelper.translate_text( - message, locale=locale - ) + payload["message_i18n"] = LocaleHelper.translate_text(message, locale=locale) return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" -def _build_web_agent_error_response( - message: str, - *, - locale: Optional[str], -) -> StreamingResponse: - """Map a rejected WebAgent request to one terminal SSE error event.""" - return build_sse_error_response( - _build_web_agent_sse( - "error", - {"message": message}, - locale=locale, - ) - ) - - -def _sanitize_web_agent_upload_name( - filename: Optional[str], mime_type: Optional[str] = None -) -> str: - """ - 规范化 Web Agent 上传文件名,避免路径穿越和空文件名。 - - :param filename: 浏览器上传的原始文件名 - :param mime_type: 浏览器上报的 MIME 类型 - :return: 可安全落盘的文件名 - """ - name = Path(filename or "attachment").name.strip() - safe_name = "".join( - char for char in name if char.isalnum() or char in (" ", ".", "_", "-") - ).strip(" .") - if not safe_name: - safe_name = "attachment" - if "." not in safe_name: - suffix = mimetypes.guess_extension(mime_type or "") or "" - safe_name = f"{safe_name}{suffix}" - return safe_name - - -async def _get_web_agent_upload_dir( - user: ApiPrincipal, - session_id: Optional[str], - service: Optional[AgentChatService] = None, -) -> Path: - """ - 计算当前 Web Agent 会话的临时附件目录。 - - :param user: 当前登录用户 - :param session_id: 前端会话标识 - :return: 已创建的临时附件目录 - """ - server_session_id = await _build_web_agent_session_id_async( - user, - session_id, - service, - ) - safe_session_id = server_session_id.replace(":", "_") - upload_dir = get_api_runtime_config_snapshot().temp_path / "agent_uploads" / safe_session_id - upload_dir.mkdir(parents=True, exist_ok=True) - return upload_dir - - -async def _save_web_agent_upload(upload_file: UploadFile, target_path: Path) -> int: - """ - 分块保存 Web Agent 上传文件,并限制单文件体积。 - - :param upload_file: FastAPI 上传文件对象 - :param target_path: 目标落盘路径 - :return: 已写入的字节数 - """ - size = 0 - try: - async with aiofiles.open(target_path, "wb") as output: - while True: - chunk = await upload_file.read(WEB_AGENT_UPLOAD_CHUNK_SIZE) - if not chunk: - break - size += len(chunk) - if size > WEB_AGENT_UPLOAD_MAX_BYTES: - raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail="附件超过 32MB,无法发送给智能助手", - ) - await output.write(chunk) - except Exception: - await run_in_threadpool(target_path.unlink, missing_ok=True) - raise - finally: - await upload_file.close() - return size - - -def _cleanup_web_agent_file_registry() -> None: - """清理过期或过量的 Web Agent 临时附件引用。""" - now = time.time() - expired_ids = [ - file_id - for file_id, info in _WEB_AGENT_FILE_REGISTRY.items() - if now - info.get("created_at", now) > WEB_AGENT_FILE_TTL_SECONDS - ] - for file_id in expired_ids: - _WEB_AGENT_FILE_REGISTRY.pop(file_id, None) - - overflow = len(_WEB_AGENT_FILE_REGISTRY) - WEB_AGENT_FILE_MAX_ITEMS - if overflow <= 0: - return - sorted_items = sorted( - _WEB_AGENT_FILE_REGISTRY.items(), - key=lambda item: item[1].get("created_at", 0), - ) - for file_id, _ in sorted_items[:overflow]: - _WEB_AGENT_FILE_REGISTRY.pop(file_id, None) - - -def _guess_web_agent_attachment_kind( - mime_type: Optional[str], fallback: str = "file" -) -> str: - """ - 根据 MIME 类型推断前端附件展示方式。 - - :param mime_type: 文件 MIME 类型 - :param fallback: 无法推断时使用的类型 - :return: image、audio 或 file - """ - if mime_type and mime_type.startswith("image/"): - return "image" - if mime_type and mime_type.startswith("audio/"): - return "audio" - return fallback - - -def _build_web_agent_url_attachment( - url: str, - kind: str, - name: Optional[str] = None, - mime_type: Optional[str] = None, -) -> dict: - """ - 构建远程或 data URL 附件事件。 - - :param url: 前端可访问的附件地址 - :param kind: 附件展示类型 - :param name: 展示名称 - :param mime_type: MIME 类型 - :return: 前端附件描述 - """ - return { - "kind": kind, - "url": url, - "download_url": url, - "name": name, - "mime_type": mime_type, - } - - -def _build_web_agent_input_attachments( - images: list[str], - files: list[dict], - audio_refs: list[str], -) -> list[dict]: - """ - 构造 WebAgent 用户输入附件展示记录。 - """ - attachments = [] - for index, image in enumerate(images or [], start=1): - attachments.append( - { - "kind": "image", - "url": image, - "download_url": image, - "name": f"image-{index}", - "mime_type": "image/*", - } - ) - for index, file in enumerate(files or [], start=1): - ref = file.get("ref") or file.get("url") or file.get("local_path") or "" - mime_type = file.get("mime_type") - attachments.append( - { - "kind": _guess_web_agent_attachment_kind(mime_type), - "url": ref, - "download_url": ref, - "name": file.get("name") or f"attachment-{index}", - "mime_type": mime_type, - "size": file.get("size"), - "local_path": file.get("local_path"), - } - ) - for index, audio_ref in enumerate(audio_refs or [], start=1): - attachments.append( - { - "kind": "audio", - "url": audio_ref, - "download_url": audio_ref, - "name": f"voice-{index}", - "mime_type": "audio/*", - } - ) - return attachments - - -def _register_web_agent_file( - file_path: Optional[str], - file_name: Optional[str] = None, - kind: Optional[str] = None, - mime_type: Optional[str] = None, -) -> Optional[dict]: - """ - 注册 Web Agent 本地附件并返回前端可访问的短期下载地址。 - - :param file_path: 本地文件路径 - :param file_name: 前端展示文件名 - :param kind: 附件展示类型 - :param mime_type: 已知 MIME 类型 - :return: 前端附件描述,文件不可访问时返回 None - """ - if not file_path: - return None - try: - resolved_path = Path(file_path).expanduser().resolve(strict=True) - except OSError: - return None - if not resolved_path.is_file(): - return None - - _cleanup_web_agent_file_registry() - file_id = uuid.uuid4().hex - display_name = file_name or resolved_path.name - resolved_mime_type = mime_type or mimetypes.guess_type( - display_name or str(resolved_path) - )[0] - file_url = f"message/agent/file/{file_id}" - _WEB_AGENT_FILE_REGISTRY[file_id] = { - "path": resolved_path, - "name": display_name, - "mime_type": resolved_mime_type or "application/octet-stream", - "created_at": time.time(), - } - return { - "kind": kind or _guess_web_agent_attachment_kind(resolved_mime_type), - "url": file_url, - "download_url": file_url, - "name": display_name, - "mime_type": resolved_mime_type, - "size": resolved_path.stat().st_size, - } - - -def _get_web_agent_audio_mime_type(audio_path: Path) -> Optional[str]: - """ - 生成浏览器播放更友好的音频 MIME 类型。 - - :param audio_path: 音频文件路径 - :return: 可用于 FileResponse/audio 标签的 MIME 类型 - """ - suffix = audio_path.suffix.lower() - if suffix in {".wav", ".wave"}: - return "audio/wav" - if suffix == ".mp3": - return "audio/mpeg" - if suffix in {".m4a", ".mp4"}: - return "audio/mp4" - if suffix == ".aac": - return "audio/aac" - - return mimetypes.guess_type(audio_path.name)[0] - - -def _resolve_web_agent_audio_source_path(voice_path: str) -> Path: - """解析语音源文件;文件暂时不可用时保留原始路径以维持回退语义。""" - try: - return Path(voice_path).expanduser().resolve(strict=True) - except OSError: - return Path(voice_path) - - -def _remove_web_agent_audio_output(path: Path) -> None: - """清理未完成的转码产物。""" - try: - path.unlink() - except FileNotFoundError: - pass - except OSError as err: - logger.debug("WebAgent 清理未完成语音转码产物失败: path=%s, error=%s", path, err) - - -async def _terminate_web_agent_audio_process( - process: Optional[asyncio.subprocess.Process], -) -> None: - """终止并回收转码进程,避免取消或超时留下孤儿 ffmpeg。""" - if process is None or process.returncode is not None: - return - try: - process.kill() - except ProcessLookupError: - return - try: - await process.communicate() - except (OSError, ProcessLookupError): - pass - - -async def _prepare_web_agent_audio_attachment_path_async(voice_path: str) -> Path: - """异步准备 WebAgent 语音附件,转码等待可取消且有超时。""" - source_path = await run_in_threadpool( - _resolve_web_agent_audio_source_path, - voice_path, - ) - if source_path.suffix.lower() in WEB_AGENT_BROWSER_AUDIO_SUFFIXES: - return source_path - - ffmpeg_path = await run_in_threadpool(shutil.which, "ffmpeg") - if not ffmpeg_path: - logger.warning("WebAgent 语音转 WAV 跳过:ffmpeg 不可用,path=%s", source_path) - return source_path - - voice_dir = get_api_runtime_config_snapshot().temp_path / "voice" - try: - await run_in_threadpool(voice_dir.mkdir, parents=True, exist_ok=True) - except OSError as err: - logger.warning("WebAgent 语音转 WAV 目录不可用,将回退原文件: path=%s, error=%s", voice_dir, err) - return source_path - - output_path = voice_dir / f"{source_path.stem}_web_{uuid.uuid4().hex[:8]}.wav" - process: Optional[asyncio.subprocess.Process] = None - - async def cleanup_conversion_output() -> None: - """清理失败或取消的转码进程及临时产物。""" - await _terminate_web_agent_audio_process(process) - await run_in_threadpool(_remove_web_agent_audio_output, output_path) - - try: - process = await asyncio.create_subprocess_exec( - ffmpeg_path, - "-y", - "-i", - str(source_path), - "-ar", - "24000", - "-ac", - "1", - "-f", - "wav", - str(output_path), - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.PIPE, - ) - _, stderr = await asyncio.wait_for( - process.communicate(), - timeout=WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS, - ) - output_exists = await run_in_threadpool(output_path.exists) - if process.returncode != 0 or not output_exists: - await cleanup_conversion_output() - logger.warning( - "WebAgent 语音转 WAV 失败,将回退原文件: returncode=%s, stderr=%s", - process.returncode, - (stderr or b"").decode("utf-8", errors="replace").strip()[:500], - ) - return source_path - return output_path - except asyncio.TimeoutError: - await cleanup_conversion_output() - logger.warning( - "WebAgent 语音转 WAV 超时,将回退原文件: timeout=%ss, path=%s", - WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS, - source_path, - ) - return source_path - except asyncio.CancelledError: - await cleanup_conversion_output() - logger.warning("WebAgent 语音转 WAV 已取消,path=%s", source_path) - raise - except OSError as err: - await cleanup_conversion_output() - logger.warning("WebAgent 语音转 WAV 启动失败,将回退原文件: path=%s, error=%s", source_path, err) - return source_path - - -def _get_web_agent_registered_file(ref: str) -> Optional[dict[str, Any]]: - """ - 根据前端附件引用读取 WebAgent 临时文件登记信息。 - - :param ref: message/agent/file/{file_id} 形式的短期引用 - :return: 文件登记信息,引用无效或过期时返回 None - """ - normalized_ref = (ref or "").strip() - prefix = "message/agent/file/" - if not normalized_ref.startswith(prefix): - return None - - _cleanup_web_agent_file_registry() - file_id = normalized_ref[len(prefix):].split("/", 1)[0] - return _WEB_AGENT_FILE_REGISTRY.get(file_id) - - -def _resolve_web_agent_audio_refs( - audio_refs: list[str], -) -> list[tuple[str, Path, str]]: - """在调用协程中解析音频引用,返回不依赖登记表的文件快照。""" - audio_files = [] - for audio_ref in audio_refs: - file_info = _get_web_agent_registered_file(audio_ref) - if not file_info: - logger.warning("WebAgent 语音引用不存在或已过期: ref=%s", audio_ref) - continue - - file_path = Path(file_info["path"]) - audio_files.append( - (audio_ref, file_path, file_info.get("name") or file_path.name) - ) - return audio_files - - -def _transcribe_web_agent_audio_files( - audio_files: list[tuple[str, Path, str]], -) -> Optional[str]: - """ - 转写 WebAgent 上传的本地录音附件。 - - 文件信息已在调用协程中从短期登记表解析,阻塞文件读取和 provider 调用可在 - worker 中执行,避免跨线程访问登记表。 - """ - if not audio_files: - return None - if not is_audio_input_available(): - logger.warning("WebAgent 音频输入能力未配置或未启用,跳过语音识别") - return None - - transcripts = [] - for audio_ref, file_path, file_name in audio_files: - try: - content = file_path.read_bytes() - except OSError as err: - logger.warning("WebAgent 语音文件读取失败: ref=%s, error=%s", audio_ref, err) - continue - - transcript = transcribe_audio( - content=content, - filename=file_name, - ) - if transcript: - transcripts.append(transcript) - - return "\n".join(transcripts).strip() if transcripts else None - - -async def _transcribe_web_agent_audio_input(audio_refs: list[str]) -> Optional[str]: - """解析并在线程池中转写 WebAgent 音频引用。""" - audio_files = _resolve_web_agent_audio_refs(audio_refs) - if not audio_files: - return None - return await asyncio.to_thread(_transcribe_web_agent_audio_files, audio_files) - - -def _merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[str]) -> str: - """合并用户输入文本和语音转写文本,避免重复发送相同内容。""" - merged_parts = [] - seen_parts = set() - for item in (prompt, transcript or ""): - normalized = item.strip() - if not normalized or normalized in seen_parts: - continue - seen_parts.add(normalized) - merged_parts.append(normalized) - return "\n".join(merged_parts).strip() - - -def _build_web_agent_choice_event(message: _SchemaMessage) -> Optional[dict]: - """ - 将带按钮通知转换为 Web Agent 选择卡片事件。 - - :param message: Agent 工具发出的按钮通知 - :return: 选择卡片事件,按钮为空时返回 None - """ - 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 - - choice_id = None - parsed = parse_agent_choice_callback(buttons[0]["callback_data"]) - if parsed: - choice_id = parsed[0] - - return { - "type": "choice", - "choice": { - "id": choice_id or uuid.uuid4().hex, - "title": message.title, - "prompt": message.text or "", - "buttons": buttons, - "button_rows": button_rows, - }, - } - - -def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optional[dict]: - """ - 解析并消费 Web Agent 按钮选择,生成前端反馈与下一条用户消息。 - - :param callback_data: 前端点击的按钮回调数据 - :param user_id: 当前登录用户 ID - :return: 可返回给前端的数据,选择无效时返回 None - """ - parsed = parse_agent_choice_callback(callback_data) - if not parsed: - return None - - request_id, option_index = parsed - resolved = agent_interaction_manager.resolve( - request_id=request_id, - option_index=option_index, - user_id=str(user_id), - ) - if not resolved: - return None - - request, option = resolved - buttons, button_rows = build_agent_choice_button_rows(request) - selected_description = option.description or option.label - return { - "message": option.value, - "session_id": request.session_id, - "display_message": selected_description, - "choice_selection": { - "choice_id": request.request_id, - "title": request.title, - "prompt": request.prompt, - "buttons": buttons, - "button_rows": button_rows, - "selected_label": option.label, - "selected_value": option.value, - "selected_description": selected_description, - }, - "feedback": { - "request_id": request.request_id, - "title": request.title, - "prompt": request.prompt, - "selected_label": option.label, - "selected_value": option.value, - "selected_description": selected_description, - "buttons": buttons, - "button_rows": button_rows, - }, - } - - -def _build_web_agent_message_events( - message: _SchemaMessage, - *, - prepared_audio_path: Optional[Path] = None, -) -> list[dict]: - """ - 将 Agent 工具通知转换为 Web SSE 事件。 - - :param message: 工具产生的通知消息 - :return: 前端可直接应用到当前助手消息的事件列表 - """ - events = [] - choice_event = _build_web_agent_choice_event(message) - if choice_event: - events.append(choice_event) - - text_parts = [ - str(item).strip() - 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 message.image: - image_ref = message.image - image_path = Path(image_ref).expanduser() - attachment = None - if not image_ref.startswith(("http://", "https://", "data:", "blob:")): - attachment = _register_web_agent_file( - image_ref, file_name=Path(image_ref).name, kind="image" - ) - if not attachment: - attachment = _build_web_agent_url_attachment( - image_ref, - kind="image", - name=message.title or image_path.name or "image", - ) - events.append({"type": "attachment", "attachment": attachment}) - - if message.voice_path: - audio_path = prepared_audio_path or Path(message.voice_path) - attachment = _register_web_agent_file( - str(audio_path), - file_name=audio_path.name, - kind="audio", - mime_type=_get_web_agent_audio_mime_type(audio_path), - ) - if attachment: - events.append({"type": "attachment", "attachment": attachment}) - - if message.file_path: - attachment = _register_web_agent_file( - message.file_path, - file_name=message.file_name or Path(message.file_path).name, - ) - if attachment: - events.append({"type": "attachment", "attachment": attachment}) - - return events - - -async def _build_web_agent_message_events_async( - message: _SchemaMessage, -) -> list[dict]: - """异步构造 WebAgent 通知,确保语音转码不占用事件循环。""" - prepared_audio_path = None - if message.voice_path: - prepared_audio_path = await _prepare_web_agent_audio_attachment_path_async( - message.voice_path - ) - return _build_web_agent_message_events( - message, - prepared_audio_path=prepared_audio_path, - ) - - -def _build_web_agent_display_message_from_events( - events: list[dict], -) -> dict: - """ - 将传统消息事件聚合为前端展示消息快照。 - - :param events: 已转换的 WebAgent SSE 事件列表 - :return: 可持久化的助手展示消息 - """ - message = build_display_message( - role="assistant", - status="streaming", - ) - for event in events: - _apply_web_agent_display_event(copy.deepcopy(event), message) - _apply_web_agent_display_event({"type": "done"}, message) - return message - - -def _is_web_agent_traditional_message(text: str) -> bool: - """ - 判断用户输入是否应走传统消息命令/交互链路。 - - :param text: 前端输入文本 - :return: 需要交给 MessageChain 时返回 True - """ - normalized = str(text or "").strip() - return normalized.startswith("/") or normalized.startswith("CALLBACK:") - - -def _has_web_agent_traditional_interaction(user_id: str) -> bool: - """ - 判断当前用户是否存在待继续的传统交互会话。 - - :param user_id: 当前登录用户 ID - :return: 存在传统交互上下文时返回 True - """ - return has_pending_interaction(user_id) - - -def _build_web_agent_command_items() -> list[dict]: - """ - 读取当前可用斜杠命令并转换为前端建议列表。 - - :return: 按分类和命令名排序的命令列表 - """ - commands = get_commands() or {} - items = [] - for command, data in commands.items(): - if not command.startswith("/"): - continue - if data.get("show") is False: - continue - items.append( - { - "command": command, - "description": data.get("description") or "", - "category": data.get("category") or "其他", - "type": data.get("type") or "", - "pid": data.get("pid"), - } - ) - return sorted(items, key=lambda item: (item["category"], item["command"])) - - -def _extract_web_agent_slash_command(text: str) -> Optional[str]: - """ - 从 WebAgent 输入中提取斜杠命令名。 - - :param text: 前端输入文本 - :return: 斜杠命令名,非命令输入返回 None - """ - normalized = str(text or "").strip() - if not normalized.startswith("/") or normalized.startswith("//"): - return None - command = normalized.split(maxsplit=1)[0].strip() - return command or None - - -def _get_web_agent_unknown_command_message(text: str) -> Optional[str]: - """ - 判断 WebAgent 斜杠命令是否不存在。 - - :param text: 前端输入文本 - :return: 命令不存在时返回错误提示,命令存在或非命令时返回 None - """ - command = _extract_web_agent_slash_command(text) - if not command: - return None - if get_command(command): - return None - return f"命令不存在:{command}" - - -def _ensure_web_agent_command_allowed(current_user: ApiPrincipal) -> Optional[str]: - """ - 校验当前 Web 用户是否可以执行传统斜杠命令。 - - :param current_user: 当前登录用户 - :return: 无权限时返回错误提示,允许执行时返回 None - """ - if getattr(current_user, "is_superuser", False): - return None - return "只有管理员才有权限执行此命令" - - -async def _collect_web_agent_traditional_events( - *, - text: str, - current_user: ApiPrincipal, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[Union[str, int]] = None, -) -> list[dict]: - """ - 执行传统消息链路并收集本次 WebAgent 用户产生的通知事件。 - - :param text: 需要交给传统消息链路处理的文本 - :param current_user: 当前登录用户 - :param original_message_id: WebAgent 原助手消息 ID - :param original_chat_id: WebAgent 原聊天 ID - :return: 可直接发送给前端的 SSE 事件列表 - """ - message_queue: Queue[_SchemaMessage] = Queue() - edit_queue: Queue[dict] = Queue() - user_id = str(current_user.id) - - 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=NotificationChannel.WebAgent, - source=WEB_AGENT_SOURCE, - userid=user_id, - username=current_user.name or user_id, - text=text, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - - events = [] - deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS - idle_deadline: Optional[float] = None - while time.monotonic() < deadline: - now = time.monotonic() - drained_edit_event = False - while True: - try: - events.append(edit_queue.get_nowait()) - drained_edit_event = True - except Empty: - break - if drained_edit_event: - idle_deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS - continue - - wait_until = idle_deadline or deadline - timeout = max(0.05, min(0.25, wait_until - now, deadline - now)) - try: - 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_message_for_user(message, user_id): - continue - events.extend(await _build_web_agent_message_events_async(message)) - idle_deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS - return events - finally: - detach_web_agent_message_queue(user_id, message_queue) - detach_web_agent_edit_queue(user_id, edit_queue) - - -def _build_web_agent_traditional_callback_payload( - callback_data: str, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[Union[str, int]] = None, -) -> dict: - """ - 构造传统消息链按钮回调的前端执行载荷。 - - :param callback_data: 前端点击的传统按钮回调数据 - :param original_message_id: WebAgent 原助手消息 ID - :param original_chat_id: WebAgent 原聊天 ID - :return: 前端可继续发送到 /stream 的消息载荷 - """ - return { - "message": f"CALLBACK:{callback_data}", - "display_message": callback_data, - "traditional": True, - "original_message_id": original_message_id, - "original_chat_id": original_chat_id, - } - - -def _split_web_agent_output(text: str) -> list[dict]: - """ - 将 Agent 输出拆成普通文本与工具提示事件。 - - :param text: 本次新增的 Agent 文本 - :return: 前端可直接渲染的事件片段 - """ - if not text: - return [] - - events = [] - - def append_text(content: str) -> None: - """将工具汇总行从普通文本中拆出,保留与消息渠道一致的展示文案。""" - if not content: - return - lines = content.splitlines(keepends=True) - buffer = "" - for line in lines: - stripped_line = line.strip() - if stripped_line.startswith("(") and stripped_line.endswith(")"): - if buffer: - events.append({"type": "delta", "content": buffer}) - buffer = "" - events.append( - { - "type": "tool", - "message": stripped_line, - } - ) - else: - buffer += line - if buffer: - events.append({"type": "delta", "content": buffer}) - - marker = "⚙️ => " - remaining = text - while remaining: - marker_index = remaining.find(marker) - if marker_index < 0: - append_text(remaining) - break - - if marker_index > 0: - append_text(remaining[:marker_index]) - - after_marker = remaining[marker_index + len(marker):] - line_end = after_marker.find("\n") - if line_end < 0: - message = after_marker.strip() - remaining = "" - else: - message = after_marker[:line_end].strip() - remaining = after_marker[line_end:].lstrip("\n") - - if message: - events.append({"type": "tool", "message": f"{marker}{message}"}) - - return events - - @router.get( "/file/{file_id}", summary="下载 Web 智能助手附件", @@ -1623,11 +160,7 @@ def _split_web_agent_output(text: str) -> list[dict]: responses={ 200: { "description": "Agent 附件文件", - "content": { - "application/octet-stream": { - "schema": {"type": "string", "format": "binary"} - } - }, + "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}, } }, ) @@ -1638,14 +171,12 @@ async def download_web_agent_file(file_id: str) -> FileResponse: :param file_id: 附件随机标识 :return: 附件文件响应 """ - _cleanup_web_agent_file_registry() - file_info = _WEB_AGENT_FILE_REGISTRY.get(file_id) + file_info = web_agent_application.get_web_agent_registered_file(f"message/agent/file/{file_id}") if not file_info: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在或已过期") file_path = file_info["path"] if not file_path.exists() or not file_path.is_file(): - _WEB_AGENT_FILE_REGISTRY.pop(file_id, None) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在或已过期") return FileResponse( @@ -1675,14 +206,20 @@ async def upload_web_agent_file( :return: Agent 可消费的附件描述 """ mime_type = file.content_type or mimetypes.guess_type(file.filename or "")[0] - safe_name = _sanitize_web_agent_upload_name(file.filename, mime_type) - upload_dir = await _get_web_agent_upload_dir(current_user, session_id, service) + safe_name = web_agent_application.sanitize_web_agent_upload_name(file.filename, mime_type) + upload_dir = await web_agent_application.get_web_agent_upload_dir(current_user, session_id, service) target_path = upload_dir / f"{uuid.uuid4().hex[:8]}_{safe_name}" - size = await _save_web_agent_upload(file, target_path) - attachment = _register_web_agent_file( + try: + size = await web_agent_application.save_web_agent_upload(file, target_path) + except web_agent_application.WebAgentUploadTooLargeError as err: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=str(err), + ) from err + attachment = web_agent_application.register_web_agent_file( str(target_path), file_name=safe_name, - kind=_guess_web_agent_attachment_kind(mime_type), + kind=web_agent_application.guess_web_agent_attachment_kind(mime_type), mime_type=mime_type, ) if not attachment: @@ -1717,19 +254,19 @@ async def web_agent_callback( :return: 下一条需要发送给 Agent 的用户消息与卡片反馈 """ if not parse_agent_choice_callback(payload.callback_data): - denied_message = _ensure_web_agent_command_allowed(current_user) + denied_message = web_agent_application.ensure_web_agent_command_allowed(current_user) if denied_message: return _SchemaResponse(success=False, message=denied_message) return _SchemaResponse( success=True, - data=_build_web_agent_traditional_callback_payload( + data=web_agent_application.build_web_agent_traditional_callback_payload( payload.callback_data, original_message_id=payload.original_message_id, original_chat_id=payload.original_chat_id, ), ) - result = _resolve_web_agent_choice_payload( + result = web_agent_application.resolve_web_agent_choice_payload( callback_data=payload.callback_data, user_id=str(current_user.id), ) @@ -1752,10 +289,13 @@ async def list_web_agent_commands( :param current_user: 当前登录用户 :return: 可用命令列表 """ - denied_message = _ensure_web_agent_command_allowed(current_user) + denied_message = web_agent_application.ensure_web_agent_command_allowed(current_user) if denied_message: return _SchemaResponse(success=False, message=denied_message) - return _SchemaResponse(success=True, data=_build_web_agent_command_items()) + return _SchemaResponse( + success=True, + data=web_agent_application.build_web_agent_command_items(), + ) @router.get( @@ -1766,8 +306,8 @@ async def list_web_agent_commands( async def list_agent_chat_sessions( current_user: ApiPrincipal = Depends(get_current_active_user), service: AgentChatService = Depends(get_agent_chat_service), - page: Optional[int] = 1, - count: Optional[int] = 30, + page: int = 1, + count: int = 30, ) -> _SchemaResponse: """ 获取当前用户可访问的 Agent 历史会话列表。 @@ -1804,22 +344,22 @@ async def get_agent_chat_session( :param service: Agent 会话应用服务 :return: 会话详情 """ - chat = await _get_accessible_agent_chat(service, session_id, current_user) + chat = await web_agent_application.get_accessible_agent_chat(service, session_id, current_user) server_session_id = session_id if not chat: - server_session_id = await _build_web_agent_session_id_async( + server_session_id = await web_agent_application.build_web_agent_session_id_async( current_user, session_id, service, ) if server_session_id != session_id: - chat = await _get_accessible_agent_chat( + chat = await web_agent_application.get_accessible_agent_chat( service, server_session_id, current_user, ) if not chat: - manager = get_running_agent_manager() + manager = agent_application.get_running_agent_manager() if manager and manager.is_session_busy(server_session_id): return _SchemaResponse( success=True, @@ -1832,10 +372,8 @@ async def get_agent_chat_session( ) return _SchemaResponse(success=False, message="会话不存在或无权访问") data = service.to_detail(chat).model_dump() - manager = get_running_agent_manager() - data["is_processing"] = bool( - manager and manager.is_session_busy(chat.session_id) - ) + manager = agent_application.get_running_agent_manager() + data["is_processing"] = bool(manager and manager.is_session_busy(chat.session_id)) return _SchemaResponse(success=True, data=data) @@ -1868,11 +406,8 @@ async def save_agent_chat_display( if unrestricted_chat and existing_chat is None: return _SchemaResponse(success=False, message="会话不存在或无权访问") - messages = [ - message.model_dump(exclude_none=True) - for message in payload.messages - ] - await _save_web_agent_display_snapshot( + messages = [message.model_dump(exclude_none=True) for message in payload.messages] + await web_agent_application.save_web_agent_display_snapshot( session_id=session_id, current_user=current_user, messages=messages, @@ -1907,7 +442,7 @@ async def delete_agent_chat_session( :param service: Agent 会话应用服务 :return: 删除结果 """ - chat = await _get_accessible_agent_chat(service, session_id, current_user) + chat = await web_agent_application.get_accessible_agent_chat(service, session_id, current_user) if not chat: return _SchemaResponse(success=False, message="会话不存在或无权访问") deleted = await service.delete(session_id, current_user) @@ -1932,22 +467,22 @@ async def stop_web_agent_session_task( :param service: Agent 会话应用服务 :return: 停止结果 """ - server_session_id = await _build_web_agent_session_id_async( + server_session_id = await web_agent_application.build_web_agent_session_id_async( current_user, session_id, service, ) - chat = await _get_accessible_agent_chat( + chat = await web_agent_application.get_accessible_agent_chat( service, server_session_id, current_user, ) if not chat and server_session_id != session_id: - chat = await _get_accessible_agent_chat(service, session_id, current_user) - if chat and not _can_access_agent_chat(chat, current_user): + chat = await web_agent_application.get_accessible_agent_chat(service, session_id, current_user) + if chat and not web_agent_application.can_access_agent_chat(chat, current_user): return _SchemaResponse(success=False, message="会话不存在或无权访问") - manager = get_running_agent_manager() + manager = agent_application.get_running_agent_manager() stopped = await manager.stop_current_task(server_session_id) if manager else False return _SchemaResponse( success=True, @@ -1963,359 +498,55 @@ async def _web_agent_stream_impl( service: Optional[AgentChatService] = None, persistence: Optional[AgentChatPersistenceService] = None, ) -> StreamingResponse: - """ - Web 智能助手流式对话。 - - :param payload: 对话请求 - :param request: 当前 HTTP 请求 - :param current_user: 当前登录用户 - :return: SSE 流式响应 - """ - prompt = payload.text.strip() + """把 FastAPI 对话请求映射为 Application 事件流并完成 SSE framing。""" if not isinstance(service, AgentChatService): - # SSE 后台任务可能在请求依赖释放后继续运行,查询服务必须自行取得短会话。 service = get_configured_agent_chat_service() if not isinstance(persistence, AgentChatPersistenceService): - # 直接调用公开函数时不经过 FastAPI 依赖解析;生产路由总是传入运行时端口。 persistence = get_configured_agent_chat_persistence() + + async def is_disconnected() -> bool: + """读取当前 HTTP 连接状态;直接函数调用时按未断线处理。""" + callback = getattr(request, "is_disconnected", None) + return bool(await callback()) if callable(callback) else False + + result = await web_agent_application.build_web_agent_stream( + web_agent_application.WebAgentStreamCommand( + text=payload.text, + display_text=payload.display_text, + session_id=payload.session_id, + images=list(payload.images or []), + audio_refs=list(payload.audio_refs or []), + files=[file.model_dump(exclude_none=True) for file in (payload.files or [])], + choice_selection=payload.choice_selection, + original_message_id=payload.original_message_id, + original_chat_id=payload.original_chat_id, + echo_user=payload.echo_user, + ), + current_user=current_user, + is_disconnected=is_disconnected, + protected_transport_supported=(getattr(request, "headers", {}).get("X-MoviePilot-Agent-Interaction") == "1"), + service=service, + persistence=persistence, + ) locale = LocaleHelper.get_locale_from_request(request) - display_prompt = (payload.display_text or payload.text).strip() - session_id = await _build_web_agent_session_id_async( - current_user, - payload.session_id, - service, - ) - is_secret_confirmation_candidate = ( - prompt in {"确认", "取消"} - and not payload.images - and not payload.audio_refs - and not payload.files - ) - is_secret_confirmation_control = ( - is_secret_confirmation_candidate - and (manager := get_running_agent_manager()) is not None - and manager.matches_secret_confirmation( - session_id, - str(current_user.id), - channel=NotificationChannel.WebAgent.value, - source=WEB_AGENT_SOURCE, - ) - ) - protected_transport_supported = ( - getattr(request, "headers", {}).get("X-MoviePilot-Agent-Interaction") == "1" - ) - if is_secret_confirmation_control and not protected_transport_supported: - return _build_web_agent_error_response( - "当前客户端不支持安全交付敏感设置,未执行操作。", - locale=locale, - ) - is_traditional_message = ( - _is_web_agent_traditional_message(prompt) - or _has_web_agent_traditional_interaction(str(current_user.id)) - ) - if is_traditional_message: - denied_message = _ensure_web_agent_command_allowed(current_user) - if denied_message: - return _build_web_agent_error_response( - denied_message, - locale=locale, - ) - unknown_command_message = _get_web_agent_unknown_command_message(prompt) - if unknown_command_message: - return _build_web_agent_error_response( - unknown_command_message, - locale=locale, - ) - user_attachments = _build_web_agent_input_attachments( - images=payload.images or [], - files=[ - file.model_dump(exclude_none=True) - for file in (payload.files or []) - ], - audio_refs=payload.audio_refs or [], - ) - display_messages = [] - if payload.echo_user: - display_messages.append( - build_display_message( - role="user", - content=display_prompt or prompt, - attachments=user_attachments, - ) - ) - - async def traditional_event_generator() -> AsyncIterator[str]: - """ - 生成传统消息链路的 WebAgent SSE 事件。 - """ + async def sse_events() -> AsyncIterator[str]: + """把 Application 事件编码为浏览器可消费的 SSE 帧。""" + async for event in result.events: + event_payload = dict(event) + event_type = str(event_payload.pop("type", "")) + if event_type == "heartbeat": + yield ": heartbeat\n\n" + continue yield _build_web_agent_sse( - "start", - {"session_id": session_id}, + event_type, + event_payload, locale=locale, ) - collection_task = asyncio.create_task( - _collect_web_agent_traditional_events( - text=prompt, - current_user=current_user, - original_message_id=payload.original_message_id, - original_chat_id=payload.original_chat_id, - ) - ) - try: - while True: - try: - events = await asyncio.wait_for( - asyncio.shield(collection_task), - timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS, - ) - break - except asyncio.TimeoutError: - if await request.is_disconnected(): - collection_task.cancel() - return - yield ": heartbeat\n\n" - except asyncio.CancelledError: - return - finally: - if not collection_task.done(): - collection_task.cancel() - await asyncio.gather(collection_task, return_exceptions=True) - - assistant_message = _build_web_agent_display_message_from_events(events) - display_messages.append(assistant_message) - - async def save_display_snapshot() -> None: - """后台保存传统消息展示快照,不阻塞 SSE 终态。""" - try: - await _save_web_agent_display_snapshot( - session_id=session_id, - current_user=current_user, - messages=display_messages, - client_session_id=payload.session_id or session_id, - service=service, - persistence=persistence, - ) - except Exception as err: - logger.error(f"保存WebAgent传统消息快照失败: {str(err)}") - - snapshot_task = create_web_agent_background_task(save_display_snapshot()) - await asyncio.sleep(0) - for event in events: - event_payload = copy.deepcopy(event) - yield _build_web_agent_sse( - event_payload.pop("type"), - event_payload, - locale=locale, - ) - if await request.is_disconnected(): - return - yield _build_web_agent_sse("done", {}, locale=locale) - - return build_sse_response(traditional_event_generator()) - - if not get_api_runtime_config_snapshot().ai_agent_enable: - return _build_web_agent_error_response( - "智能助手未启用,请先在系统设置中开启。", - locale=locale, - ) - - manager = get_running_agent_manager() - if manager is None: - return _build_web_agent_error_response( - "智能助手服务尚未就绪,请稍后重试。", - locale=locale, - ) - - transcript = await _transcribe_web_agent_audio_input(payload.audio_refs or []) - prompt = _merge_web_agent_prompt_with_transcript(prompt, transcript) - display_prompt = _merge_web_agent_prompt_with_transcript(display_prompt, transcript) - has_audio_input = bool(transcript) - if not prompt and payload.audio_refs and not payload.images and not payload.files: - return _build_web_agent_error_response( - "语音识别失败,请稍后重试。", - locale=locale, - ) - if not prompt and not payload.images and not payload.files and not payload.audio_refs: - return _build_web_agent_error_response( - "请输入要发送给智能助手的内容或选择附件。", - locale=locale, - ) - - MessageChain().bind_user_session(str(current_user.id), session_id) - event_publisher = _WebAgentEventPublisher() - user_attachments = _build_web_agent_input_attachments( - images=payload.images or [], - files=[ - file.model_dump(exclude_none=True) - for file in (payload.files or []) - ], - audio_refs=payload.audio_refs or [], - ) - display_messages = [] - if payload.echo_user and not is_secret_confirmation_control: - user_display_message = build_display_message( - role="user", - content=display_prompt or prompt, - attachments=user_attachments, - ) - if payload.choice_selection: - user_display_message["choice_selection"] = payload.choice_selection - display_messages.append(user_display_message) - assistant_display_message = build_display_message( - role="assistant", - status="streaming", - ) - display_messages.append(assistant_display_message) - - def output_callback(delta: str) -> None: - """ - 接收 Agent 文本增量并转换成 SSE 事件。 - """ - for item in _split_web_agent_output(delta): - _apply_web_agent_display_event(item, assistant_display_message) - event_publisher.publish(item) - - async def message_callback(message: _SchemaMessage) -> None: - """ - 接收 Agent 工具主动发送的 Web 通知。 - """ - for item in await _build_web_agent_message_events_async(message): - _apply_web_agent_display_event(item, assistant_display_message) - event_publisher.publish(item) - - def protected_output_callback(content: str) -> bool: - """将敏感文本封装为不进入普通展示快照的命名 SSE 事件。""" - return event_publisher.publish( - { - "type": "interaction-protected", - "content": content, - } - ) - - async def event_generator() -> AsyncIterator[str]: - """ - 生成前端 Agent SSE 事件。 - """ - audio_ref_set = set(payload.audio_refs or []) - files = [ - file.model_dump(exclude_none=True) - for file in (payload.files or []) - if file.ref not in audio_ref_set - ] - for audio_ref in payload.audio_refs or []: - files.append({"ref": audio_ref, "mime_type": "audio/*"}) - - async def run_agent() -> None: - """后台执行 Agent,并将结果写入事件队列。""" - try: - runtime_manager = get_running_agent_manager() - if runtime_manager is None: - raise RuntimeError("智能助手服务尚未就绪,请稍后重试。") - await runtime_manager.process_message( - session_id=session_id, - user_id=str(current_user.id), - message=prompt, - images=payload.images or [], - files=files or None, - has_audio_input=has_audio_input, - channel=NotificationChannel.WebAgent.value, - source=WEB_AGENT_SOURCE, - username=current_user.name, - reply_mode=ReplyMode.CAPTURE_ONLY, - allow_message_tools=True, - output_callback=output_callback, - protected_output_callback=( - protected_output_callback - if protected_transport_supported - else None - ), - message_callback=message_callback, - agent_factory=_get_web_agent_type(), - wait_for_completion=True, - ) - except asyncio.CancelledError: - # 显式停止会话沿用正常终止语义;服务关闭会由 manager 的稳定异常分支处理。 - pass - except Exception as err: - logger.error(f"Web智能助手执行失败: {str(err)}") - error_event = { - "type": "error", - "message": f"智能助手执行失败: {str(err)}", - } - _apply_web_agent_display_event(error_event, assistant_display_message) - event_publisher.publish(error_event) - finally: - done_event = {"type": "done"} - _apply_web_agent_display_event(done_event, assistant_display_message) - # 终态先进入 SSE 队列,避免展示快照落库延迟前端结束动画。 - event_publisher.publish(done_event) - if not is_secret_confirmation_control: - try: - await _save_web_agent_display_snapshot( - session_id=session_id, - current_user=current_user, - messages=display_messages, - client_session_id=payload.session_id or session_id, - service=service, - persistence=persistence, - ) - except Exception as err: - logger.error(f"保存WebAgent展示历史失败:{err}") - - task = create_web_agent_background_task(run_agent()) - disconnected = False - terminal_sent = False - try: - yield _build_web_agent_sse( - "start", - {"session_id": session_id}, - locale=locale, - ) - while not runtime_stop_state.is_system_stopped: - if await request.is_disconnected(): - disconnected = True - break - try: - event = await asyncio.wait_for( - event_publisher.get(), - timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS, - ) - except asyncio.TimeoutError: - yield ": heartbeat\n\n" - continue - event_type = str(event.get("type") or "") - if event_type == "done": - terminal_sent = True - yield _build_web_agent_sse( - event_type, - {key: value for key, value in event.items() if key != "type"}, - locale=locale, - ) - if event_type == "done": - break - except asyncio.CancelledError: - disconnected = True - return - finally: - if not task.done() and not disconnected and not terminal_sent: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - await event_publisher.aclose() - # 客户端断线后保留 Agent 继续执行;发布器关闭后不再接受受保护结果。 return build_sse_response( - event_generator(), - headers={ - **( - {"X-MoviePilot-Agent-Control": "secret-confirmation"} - if is_secret_confirmation_control - else {} - ), - }, + sse_events(), + headers=({"X-MoviePilot-Agent-Control": result.control} if result.control else None), ) diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 2cf1d80df..b11ed4ed9 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -1,7 +1,7 @@ import asyncio import mimetypes import shutil -from typing import Annotated, Any, Dict, List, Optional, cast +from typing import Annotated, Any, Dict, List, Optional import aiofiles from anyio import Path as AsyncPath @@ -9,8 +9,6 @@ from fastapi import Depends, Header, HTTPException, Security from starlette import status from starlette.responses import StreamingResponse -from app.adapters.external.market import VERSION_BACKWARD_COMPATIBLE_FLAGS, PluginHelper -from app.adapters.external.server import MoviePilotServerHelper from app.adapters.web.security.access import ( resource_token_cookie, verify_resource_token, @@ -28,20 +26,23 @@ from app.api.principal import ApiPrincipal from app.api.response import ResponseAPIRouter from app.application.commands import init_commands from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config -from app.application.plugin.catalog import apply_declared_metadata_fallback +from app.application.plugin.catalog import get_plugin_catalog_query from app.application.plugin.config import PluginConfigCommand -from app.application.plugin.folders import remove_plugin_from_folders -from app.application.plugin.gateway import get_plugin_install_service -from app.application.plugin.identity import ( - OFFICIAL_PLUGIN_SOURCE_KEY, - TrustedPluginSourceType, +from app.application.plugin.folders import ( + add_clone_to_plugin_folder, + get_plugin_folder_service, + remove_plugin_from_folders, ) -from app.application.plugin.inventory import normalize_github_plugin_source +from app.application.plugin.gateway import get_plugin_install_service +from app.application.plugin.rating import ( + PluginNotInstalledError, + get_plugin_rating_service, +) +from app.application.plugin.release import get_plugin_release_service from app.application.plugin.routes import register_plugin_api, remove_plugin_api -from app.application.plugin.runtime import PluginRuntime, get_plugin_manager +from app.application.plugin.runtime import get_plugin_manager from app.application.plugin.transaction import get_plugin_persistence from app.application.scheduling import remove_plugin_job, update_plugin_job -from app.runtime.cache import async_fresh from app.runtime.extensions.plugin.contracts import ( PluginDashboardError, PluginNotFoundError, @@ -49,10 +50,7 @@ from app.runtime.extensions.plugin.contracts import ( from app.runtime.log import logger from app.runtime.tasks import TaskRegistry from app.schemas.common import JsonObject as _SchemaJsonObject -from app.schemas.exception import ( - PersistenceUnavailableError, - PluginMutationRejectedError, -) +from app.schemas.exception import PluginMutationRejectedError from app.schemas.plugin import Plugin as _SchemaPlugin from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard @@ -72,7 +70,6 @@ from app.schemas.plugin import PluginSourceChangeRequest as _SchemaPluginSourceC from app.schemas.plugin import PluginSourceIdentity as _SchemaPluginSourceIdentity from app.schemas.plugin import PluginSourceInstallRequest as _SchemaPluginSourceInstallRequest from app.schemas.plugin import PluginSourceOptions as _SchemaPluginSourceOptions -from app.schemas.plugin import PluginUpdateCandidate as _SchemaPluginUpdateCandidate from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.types import SystemConfigKey @@ -94,51 +91,12 @@ def _plugin_source_identity_schema(identity: Any) -> _SchemaPluginSourceIdentity ) -async def _get_market_plugin_from_repo( - plugin_manager: PluginRuntime, - plugin_id: str, - repo_url: str, - force: bool, -) -> Optional[_SchemaPlugin]: - """ - 按当前代际、向后兼容代际和基础索引顺序读取指定仓库的市场元数据。 - - 单插件详情只访问可信绑定仓库,避免触发全部市场刷新;代际顺序必须与 - 全量插件目录保持一致,否则仅存在于 package.v2.json 的插件会丢失历史说明。 - """ - version_flag = get_api_runtime_config_snapshot().version_flag - package_versions: list[Optional[str]] = [version_flag] if version_flag else [] - package_versions.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(version_flag, [])) - package_versions.append(None) - - for package_version in dict.fromkeys(package_versions): - market_plugins = cast( - Optional[List[_SchemaPlugin]], - await plugin_manager.async_get_plugins_from_market( - repo_url, package_version, force - ), - ) - market_plugin = next( - ( - plugin - for plugin in market_plugins or [] - if plugin.id == plugin_id - ), - None, - ) - if market_plugin: - return market_plugin - - return None - - async def _refresh_plugin_release_versions(plugin_id: str, repo_url: str) -> None: """ 后台强制刷新 Release 缓存,接口响应路径优先返回已有缓存。 """ try: - async with async_fresh(True): - await PluginHelper().async_get_plugin_release_versions(plugin_id, repo_url) + await get_plugin_release_service().refresh(plugin_id, repo_url) except Exception as e: logger.warning(f"后台刷新插件 {plugin_id} Release 列表失败:{e}") @@ -174,24 +132,6 @@ def register_plugin(plugin_id: str): register_plugin_api(plugin_id) -def _merge_plugin_market_metadata( - plugin: _SchemaPlugin, market_plugin: _SchemaPlugin -) -> _SchemaPlugin: - """ - 合并插件市场中的远端元数据,供已安装插件按需展示更新说明。 - """ - plugin.repo_url = market_plugin.repo_url or plugin.repo_url - plugin.history = market_plugin.history or {} - plugin.release = market_plugin.release - plugin.has_update = market_plugin.has_update - plugin.system_version = market_plugin.system_version or plugin.system_version - plugin.system_version_compatible = market_plugin.system_version_compatible - plugin.system_version_message = ( - market_plugin.system_version_message or plugin.system_version_message - ) - return plugin - - def _is_plugin_auth_remote_file(plugin_id: str, filepath: str) -> bool: """ 判断静态文件是否属于插件声明的匿名登录认证远程组件。 @@ -234,132 +174,6 @@ def _verify_plugin_static_file_access( verify_resource_token(resource_token) -async def _get_plugin_history_detail( - plugin_id: str, force: bool = True -) -> Optional[_SchemaPlugin]: - """ - 按需获取插件远端元数据,避免插件列表加载时批量访问网络。 - """ - plugin_manager = get_plugin_manager() - installed_plugin = next( - ( - plugin - for plugin in plugin_manager.get_installed_plugins() - if plugin.id == plugin_id - ), - None, - ) - if not installed_plugin: - return None - - identity = await get_plugin_persistence().get_identity(plugin_id) - if identity is not None: - installed_plugin = apply_declared_metadata_fallback( - [installed_plugin], - {identity.normalized_plugin_id: identity}, - )[0] - - local_repo_plugin = next( - (plugin for plugin in plugin_manager.get_local_repo_plugins() if plugin.id == plugin_id), - None, - ) - if local_repo_plugin: - return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin) - - trusted_repo_url = None - if identity is not None and identity.trusted_source_key: - owner_repo = identity.trusted_source_key.removeprefix("github:") - trusted_repo_url = f"https://github.com/{owner_repo}" - - if trusted_repo_url: - market_plugin = await _get_market_plugin_from_repo( - plugin_manager, plugin_id, trusted_repo_url, force - ) - if not market_plugin: - logger.debug(f"插件 {plugin_id} 未从来源仓库获取到更新说明,返回本地插件信息") - return installed_plugin - return _merge_plugin_market_metadata(installed_plugin, market_plugin) - - return installed_plugin - - -async def _installed_plugins_with_declared_metadata( - plugin_manager: PluginRuntime, -) -> list[_SchemaPlugin]: - """批量读取身份,并为未加载插件补齐已提交的展示声明。""" - plugins = plugin_manager.get_installed_plugins() - identities = await get_plugin_persistence().list_identities( - [plugin.id for plugin in plugins if plugin.id] - ) - return apply_declared_metadata_fallback( - plugins, - {identity.normalized_plugin_id: identity for identity in identities}, - ) - - -async def _prepare_update_candidates( - plugin_manager: PluginRuntime, - online_plugins: list[_SchemaPlugin], - plugins: list[_SchemaPlugin], - installed_ids: list[str], -) -> list[_SchemaPlugin]: - """优先选择绑定仓库的可用更新,再投影候选来源信息。""" - identities = await get_plugin_persistence().list_identities(installed_ids) - identity_by_id = {identity.normalized_plugin_id: identity for identity in identities} - installed_keys = {plugin_id.lower() for plugin_id in installed_ids} - result: list[_SchemaPlugin] = [] - for plugin in plugins: - if not plugin.id or plugin.id.lower() not in installed_keys or not plugin.has_update: - result.append(plugin) - continue - identity = identity_by_id.get(plugin.id.lower()) - if identity and identity.trusted_source_key: - bound_updates = [] - for online_plugin in online_plugins: - if ( - not online_plugin.id - or online_plugin.id.lower() != plugin.id.lower() - or not online_plugin.has_update - or not online_plugin.repo_url - ): - continue - try: - source_key, _ = normalize_github_plugin_source( - online_plugin.repo_url - ) - except ValueError: - continue - if source_key == identity.trusted_source_key: - bound_updates.append(online_plugin) - if bound_updates: - preferred = plugin_manager.process_plugins_list(bound_updates, []) - if preferred: - plugin = preferred[0] - - if not plugin.repo_url or not plugin.plugin_version: - result.append(plugin) - continue - try: - source_key, repo_url = normalize_github_plugin_source(plugin.repo_url) - except ValueError: - result.append(plugin) - continue - source_type = ( - TrustedPluginSourceType.OFFICIAL - if source_key == OFFICIAL_PLUGIN_SOURCE_KEY - else TrustedPluginSourceType.THIRD_PARTY - ) - plugin.update_candidate = _SchemaPluginUpdateCandidate( - source_type=source_type.value, - source_key=source_key, - repo_url=repo_url, - version=plugin.plugin_version, - is_bound=bool(identity and identity.trusted_source_key == source_key), - ) - result.append(plugin) - return result - - @router.get("/", summary="所有插件", response_model=List[_SchemaPlugin]) async def all_plugins( _: ApiPrincipal = Depends(get_current_active_superuser_async), @@ -369,64 +183,9 @@ async def all_plugins( """ 查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all """ - plugin_manager = get_plugin_manager() - installed_plugins = plugin_manager.get_installed_plugins() - if state == "installed": - return await _installed_plugins_with_declared_metadata(plugin_manager) - - # 本地插件 - local_plugins = plugin_manager.get_local_plugins() - # 未安装的本地插件 - not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed] - # 本地插件仓库目录中的插件 - local_repo_plugins = plugin_manager.get_local_repo_plugins() - # 在线插件 - online_plugins = await plugin_manager.async_get_online_plugin_candidates(force) - installed_ids = [plugin.id for plugin in installed_plugins if plugin.id] - candidate_plugins = ( - plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, []) - if online_plugins or local_repo_plugins - else [] - ) - candidate_plugins = await _prepare_update_candidates( - plugin_manager, - online_plugins, - candidate_plugins, - installed_ids, - ) - if not candidate_plugins: - # 没有获取在线插件 - if state == "market": - # 返回未安装的本地插件 - return not_installed_plugins - return ( - await _installed_plugins_with_declared_metadata(plugin_manager) - + not_installed_plugins - ) - - # 插件市场插件清单 - market_plugins = [] - # 已安装插件IDS - # 未安装的线上插件或者有更新的插件 - for plugin in candidate_plugins: - if plugin.id not in installed_ids: - market_plugins.append(plugin) - elif plugin.has_update: - market_plugins.append(plugin) - # 未安装的本地插件,且不在线上插件中 - _plugin_ids = [plugin.id for plugin in market_plugins] - for plugin in not_installed_plugins: - if plugin.id not in _plugin_ids: - market_plugins.append(plugin) - # 返回插件清单 - if state == "market": - # 返回未安装的插件 - return market_plugins - - # 返回所有插件 - return ( - await _installed_plugins_with_declared_metadata(plugin_manager) - + market_plugins + return await get_plugin_catalog_query().query( + state=state or "all", + force=force, ) @@ -492,7 +251,7 @@ async def plugin_history( """ 按需获取指定插件的更新说明。 """ - plugin = await _get_plugin_history_detail(plugin_id=plugin_id, force=force) + plugin = await get_plugin_release_service().history(plugin_id, force=force) if not plugin: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -518,54 +277,22 @@ async def plugin_releases( 市场元数据只读取请求仓库的当前 package,避免版本历史请求触发全部市场缓存读取。 """ - if not repo_url: - return { - "release_supported": False, - "latest_version": None, - "current_version": None, - "items": [], - } - - plugin_manager = get_plugin_manager() - market_plugin = await _get_market_plugin_from_repo( - plugin_manager, plugin_id, repo_url, force + snapshot = await get_plugin_release_service().versions( + plugin_id, + repo_url or "", + force=force, ) - latest_version = market_plugin.plugin_version if market_plugin else None - current_version = plugin_manager.get_local_plugin_version(plugin_id) - if not getattr(market_plugin, "release", False): - return { - "release_supported": False, - "latest_version": latest_version, - "current_version": current_version, - "items": [], - } - - plugin_helper = PluginHelper() - has_release_cache = ( - await plugin_helper.async_has_plugin_release_cache(repo_url) - if force - else False - ) - release_items = await plugin_helper.async_get_plugin_release_versions(plugin_id, repo_url) - if force and has_release_cache: + if snapshot.refresh_required: _schedule_plugin_release_refresh( plugin_id, - repo_url, + repo_url or "", resolve_background_task_registry(task_registry), ) - items = [] - for item in release_items: - version = item.get("version") - copied_item = item.copy() - copied_item["is_latest"] = bool(latest_version and version == latest_version) - copied_item["is_current"] = bool(current_version and version == current_version) - items.append(copied_item) - return { - "release_supported": bool(items), - "latest_version": latest_version, - "current_version": current_version, - "items": items, + "release_supported": snapshot.release_supported, + "latest_version": snapshot.latest_version, + "current_version": snapshot.current_version, + "items": list(snapshot.items), } @@ -578,7 +305,7 @@ async def statistic(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 插件安装统计 """ - return await MoviePilotServerHelper.async_get_plugin_statistic() + return await get_plugin_rating_service().statistic() @router.get( @@ -594,7 +321,7 @@ async def plugin_ratings( 批量查询插件平均分、评分人数和当前安装实例评分。 """ requested_ids = plugin_ids.split(",") if plugin_ids is not None else None - ratings = await MoviePilotServerHelper.async_get_plugin_ratings(requested_ids) + ratings = await get_plugin_rating_service().ratings(requested_ids) return { plugin_id: _SchemaPluginRating.model_validate(rating) for plugin_id, rating in ratings.items() @@ -613,7 +340,7 @@ async def plugin_rating( """ 查询单个插件平均分、评分人数和当前安装实例评分。 """ - rating = await MoviePilotServerHelper.async_get_plugin_rating(plugin_id) + rating = await get_plugin_rating_service().rating(plugin_id) return _SchemaPluginRating.model_validate(rating) @@ -630,17 +357,13 @@ async def rate_plugin( """ 为已安装插件新增或更新当前安装实例评分。 """ - installed_plugins = get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [] - if plugin_id not in installed_plugins: + try: + rating = await get_plugin_rating_service().submit(plugin_id, payload.rating) + except PluginNotInstalledError as error: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"插件 {plugin_id} 未安装,无法评分", - ) - - rating = await MoviePilotServerHelper.async_submit_plugin_rating( - plugin_id, - payload.rating, - ) + detail=str(error), + ) from error if rating is None: return _SchemaResponse(success=False, message="连接MoviePilot服务器失败") return _SchemaResponse(success=True, data=rating) @@ -1089,12 +812,7 @@ async def get_plugin_folders( """ 获取插件文件夹分组配置 """ - try: - result = get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} - return result - except Exception as e: - logger.error(f"[文件夹API] 获取文件夹配置失败: {str(e)}") - return {} + return get_plugin_folder_service().get_or_empty() @router.post("/folders", summary="保存插件文件夹配置", response_model=_SchemaResponse[None]) @@ -1104,18 +822,8 @@ async def save_plugin_folders( """ 保存插件文件夹分组配置 """ - try: - with get_plugin_manager().mutation("保存插件文件夹配置"): - await get_configured_system_config().async_set( - SystemConfigKey.PluginFolders, - folders, - ) - return _SchemaResponse(success=True) - except PersistenceUnavailableError: - raise - except Exception as e: - logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}") - return _SchemaResponse(success=False, message=str(e)) + result = await get_plugin_folder_service().save(folders) + return _SchemaResponse(success=result.success, message=result.message) @router.post( @@ -1127,26 +835,8 @@ async def create_plugin_folder( """ 创建新的插件文件夹 """ - try: - with get_plugin_manager().mutation(f"创建插件文件夹 {folder_name}"): - folders = ( - get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} - ) - if folder_name not in folders: - folders[folder_name] = [] - await get_configured_system_config().async_set( - SystemConfigKey.PluginFolders, - folders, - ) - return _SchemaResponse( - success=True, message=f"文件夹 '{folder_name}' 创建成功" - ) - return _SchemaResponse( - success=False, - message=f"文件夹 '{folder_name}' 已存在", - ) - except PluginMutationRejectedError as error: - return _SchemaResponse(success=False, message=str(error)) + result = await get_plugin_folder_service().create(folder_name) + return _SchemaResponse(success=result.success, message=result.message) @router.delete( @@ -1158,26 +848,8 @@ async def delete_plugin_folder( """ 删除插件文件夹 """ - try: - with get_plugin_manager().mutation(f"删除插件文件夹 {folder_name}"): - folders = ( - get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} - ) - if folder_name in folders: - del folders[folder_name] - await get_configured_system_config().async_set( - SystemConfigKey.PluginFolders, - folders, - ) - return _SchemaResponse( - success=True, message=f"文件夹 '{folder_name}' 删除成功" - ) - return _SchemaResponse( - success=False, - message=f"文件夹 '{folder_name}' 不存在", - ) - except PluginMutationRejectedError as error: - return _SchemaResponse(success=False, message=str(error)) + result = await get_plugin_folder_service().delete(folder_name) + return _SchemaResponse(success=result.success, message=result.message) @router.put( @@ -1193,22 +865,8 @@ async def update_folder_plugins( """ 更新指定文件夹中的插件列表 """ - try: - with get_plugin_manager().mutation(f"更新插件文件夹 {folder_name}"): - folders = ( - get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} - ) - folders[folder_name] = plugin_ids - await get_configured_system_config().async_set( - SystemConfigKey.PluginFolders, - folders, - ) - return _SchemaResponse( - success=True, - message=f"文件夹 '{folder_name}' 中的插件已更新", - ) - except PluginMutationRejectedError as error: - return _SchemaResponse(success=False, message=str(error)) + result = await get_plugin_folder_service().update_plugins(folder_name, plugin_ids) + return _SchemaResponse(success=result.success, message=result.message) @router.post( @@ -1238,7 +896,7 @@ def clone_plugin( # 分身服务已完成运行态加载,此处只补齐宿主注册。 register_plugin(message) # 将分身插件添加到原插件所在的文件夹中 - _add_clone_to_plugin_folder(plugin_id, message) + add_clone_to_plugin_folder(plugin_id, message) return _SchemaResponse(success=True, message="插件分身创建成功") return _SchemaResponse(success=False, message=message) except Exception as e: @@ -1336,58 +994,3 @@ def uninstall_plugin( return _SchemaResponse(success=True) except PluginMutationRejectedError as error: return _SchemaResponse(success=False, message=str(error)) - - -def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str): - """ - 将分身插件添加到原插件所在的文件夹中 - :param original_plugin_id: 原插件ID - :param clone_plugin_id: 分身插件ID - """ - try: - config_oper = get_configured_system_config() - # 获取插件文件夹配置 - folders = config_oper.get(SystemConfigKey.PluginFolders) or {} - - # 查找原插件所在的文件夹 - target_folder = None - for folder_name, folder_data in folders.items(): - if isinstance(folder_data, dict) and "plugins" in folder_data: - # 新格式:{"plugins": [...], "order": ..., "icon": ...} - if original_plugin_id in folder_data["plugins"]: - target_folder = folder_name - break - elif isinstance(folder_data, list): - # 旧格式:直接是插件列表 - if original_plugin_id in folder_data: - target_folder = folder_name - break - - # 如果找到了原插件所在的文件夹,则将分身插件也添加到该文件夹中 - if target_folder: - folder_data = folders[target_folder] - if isinstance(folder_data, dict) and "plugins" in folder_data: - # 新格式 - if clone_plugin_id not in folder_data["plugins"]: - folder_data["plugins"].append(clone_plugin_id) - logger.info( - f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中" - ) - elif isinstance(folder_data, list): - # 旧格式 - if clone_plugin_id not in folder_data: - folder_data.append(clone_plugin_id) - logger.info( - f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中" - ) - - # 保存更新后的文件夹配置 - config_oper.set(SystemConfigKey.PluginFolders, folders) - else: - logger.info( - f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立" - ) - - except Exception as e: - logger.error(f"处理插件文件夹时出错:{str(e)}") - # 文件夹处理失败不影响插件分身创建的整体流程 diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 823d43acc..7f17e0367 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -44,7 +44,7 @@ from app.application.database import get_database_governance from app.application.image import ImageHelper from app.application.messaging.message import MessageHelper from app.application.module import get_module_manager -from app.application.network import NetworkTestService +from app.application.network import get_configured_network_test_service from app.application.plugin.runtime import plugin_system_config_mutation from app.application.rules import RuleHelper from app.application.scheduling import get_scheduler @@ -52,11 +52,10 @@ from app.application.security.url import SecurityUtils from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.chain.media import MediaChain from app.chain.mediaserver import MediaServerChain -from app.chain.search import SearchChain +from app.chain.search import SearchChain # pylint: disable=no-name-in-module from app.domain.metainfo import MetaInfo from app.foundation.crypto import HashUtils from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled -from app.foundation.url import UrlUtils from app.runtime.events import eventmanager from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.localization import LocaleHelper @@ -90,7 +89,6 @@ from app.startup.composition.context import HostRuntime router = ResponseAPIRouter() -_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308} _PUBLIC_SYSTEM_CONFIG_KEYS = { item.value: item for item in ( @@ -223,229 +221,6 @@ def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool: ) -def _match_nettest_prefix(url: str, prefix: str) -> bool: - """ - 判断目标URL是否仍然落在允许的协议、主机、端口和路径前缀内。 - - nettest 会在服务端手动处理重定向,因此这里需要一个比简单 startswith - 更严格的匹配,避免不同端口或同名路径被误判为白名单内跳转。 - """ - parsed_url = urlparse(url) - parsed_prefix = urlparse(prefix) - if parsed_url.scheme.lower() != parsed_prefix.scheme.lower(): - return False - if (parsed_url.hostname or "").lower() != (parsed_prefix.hostname or "").lower(): - return False - url_port = parsed_url.port or (443 if parsed_url.scheme.lower() == "https" else 80) - prefix_port = parsed_prefix.port or ( - 443 if parsed_prefix.scheme.lower() == "https" else 80 - ) - if url_port != prefix_port: - return False - return parsed_url.path.startswith(parsed_prefix.path or "/") - - -def _build_nettest_rules() -> list[dict[str, Any]]: - """ - 构建系统内置的网络测试目标。 - - 这里集中维护“前端允许显示哪些测试项”和“后端允许访问哪些远端地址”。 - 前端只拿到展示所需的 id/name/icon;真正的 URL、代理策略、内容校验规则 - 和重定向白名单都保留在服务端,避免再出现用户可控 SSRF。 - """ - runtime_settings = get_runtime_settings() - github_proxy = UrlUtils.standardize_base_url( - runtime_settings.get("GITHUB_PROXY") or "" - ) - pip_proxy = UrlUtils.standardize_base_url( - runtime_settings.get("PIP_PROXY") or "https://pypi.org/simple/" - ) - tmdb_key = runtime_settings.get("TMDB_API_KEY") - tmdb_domain = runtime_settings.get("TMDB_API_DOMAIN") or "api.themoviedb.org" - github_headers = runtime_settings.get("GITHUB_HEADERS") - - github_readme_url = "https://github.com/jxxghp/MoviePilot/blob/v2/README.md" - raw_readme_url = "https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/README.md" - - rules = [ - { - "id": "tmdb_api", - "name": "api.themoviedb.org", - "icon": "tmdb", - "url": f"https://api.themoviedb.org/3/movie/550?api_key={tmdb_key}", - "proxy": True, - "allowed_redirect_prefixes": [ - "https://api.themoviedb.org/3/", - ], - }, - { - "id": "tmdb_api_alt", - "name": "api.tmdb.org", - "icon": "tmdb", - "url": f"https://api.tmdb.org/3/movie/550?api_key={tmdb_key}", - "proxy": True, - "allowed_redirect_prefixes": [ - "https://api.tmdb.org/3/", - ], - }, - { - "id": "tmdb_web", - "name": "www.themoviedb.org", - "icon": "tmdb", - "url": "https://www.themoviedb.org", - "proxy": True, - "allowed_redirect_prefixes": ["https://www.themoviedb.org/"], - }, - { - "id": "tvdb_api", - "name": "api.thetvdb.com", - "icon": "tvdb", - "url": "https://api.thetvdb.com/series/81189", - "proxy": True, - "allowed_redirect_prefixes": ["https://api.thetvdb.com/"], - }, - { - "id": "fanart_api", - "name": "webservice.fanart.tv", - "icon": "fanart", - "url": "https://webservice.fanart.tv", - "proxy": True, - "allowed_redirect_prefixes": ["https://webservice.fanart.tv/"], - }, - { - "id": "telegram_api", - "name": "api.telegram.org", - "icon": "telegram", - "url": "https://api.telegram.org", - "proxy": True, - "allowed_redirect_prefixes": [ - "https://api.telegram.org/", - "https://core.telegram.org/", - ], - }, - { - "id": "wechat_api", - "name": "qyapi.weixin.qq.com", - "icon": "wechat", - "url": "https://qyapi.weixin.qq.com/cgi-bin/gettoken", - "proxy": False, - "allowed_redirect_prefixes": ["https://qyapi.weixin.qq.com/"], - }, - { - "id": "douban_api", - "name": "frodo.douban.com", - "icon": "douban", - "url": "https://frodo.douban.com", - "proxy": False, - "allowed_redirect_prefixes": [ - "https://frodo.douban.com/", - "https://www.douban.com/doubanapp/frodo", - ], - }, - { - "id": "slack_api", - "name": "slack.com", - "icon": "slack", - "url": "https://slack.com", - "proxy": False, - "allowed_redirect_prefixes": [ - "https://slack.com/", - "https://www.slack.com/", - ], - }, - { - "id": "pip_proxy", - "name": "pypi.org", - "icon": "python", - "url": f"{pip_proxy}rsa/", - "proxy": True, - "allowed_redirect_prefixes": [ - pip_proxy, - "https://pypi.org/simple/", - ], - "expected_text": "pypi:repository-version", - "invalid_message": "PIP加速代理已失效,请检查配置", - "proxy_name": "PIP加速代理", - }, - { - "id": "github_proxy_web", - "name": "github.com", - "icon": "github", - "url": f"{github_proxy}{github_readme_url}" - if github_proxy - else github_readme_url, - "proxy": True, - "allowed_redirect_prefixes": [ - "https://github.com/", - *((f"{github_proxy}https://github.com/",) if github_proxy else ()), - ], - "expected_text": "MoviePilot", - "invalid_message": "Github加速代理已失效,请检查配置" - if github_proxy - else "无效响应", - "proxy_name": "Github加速代理" if github_proxy else "", - "headers": github_headers, - }, - { - "id": "github_api", - "name": "api.github.com", - "icon": "github", - "url": "https://api.github.com", - "proxy": True, - "allowed_redirect_prefixes": ["https://api.github.com/"], - "headers": github_headers, - }, - { - "id": "github_codeload", - "name": "codeload.github.com", - "icon": "github", - "url": "https://codeload.github.com", - "proxy": True, - "allowed_redirect_prefixes": [ - "https://codeload.github.com/", - "https://github.com/", - ], - "headers": github_headers, - }, - { - "id": "github_proxy_raw", - "name": "raw.githubusercontent.com", - "icon": "github", - "url": f"{github_proxy}{raw_readme_url}" - if github_proxy - else raw_readme_url, - "proxy": True, - "allowed_redirect_prefixes": [ - "https://raw.githubusercontent.com/", - *( - (f"{github_proxy}https://raw.githubusercontent.com/",) - if github_proxy - else () - ), - ], - "expected_text": "MoviePilot", - "invalid_message": "Github加速代理已失效,请检查配置" - if github_proxy - else "无效响应", - "proxy_name": "Github加速代理" if github_proxy else "", - "headers": github_headers, - }, - ] - if tmdb_domain not in {"api.themoviedb.org", "api.tmdb.org"}: - rules.insert( - 2, - { - "id": "tmdb_api_configured", - "name": tmdb_domain, - "icon": "tmdb", - "url": f"https://{tmdb_domain}/3/movie/550?api_key={tmdb_key}", - "proxy": True, - "allowed_redirect_prefixes": [ - f"https://{tmdb_domain}/3/", - ], - }, - ) - return rules def _collect_named_log_files(name: str) -> list[Path]: @@ -550,73 +325,6 @@ def _build_log_zip_data(name: str) -> tuple[bytes, str]: return zip_buffer.getvalue(), zip_stem -def _validate_nettest_url(url: str) -> Optional[str]: - """ - 对实际请求地址做基础安全校验。 - - 即使请求来自服务端内置规则,这里仍保留一层兜底校验,防止配置项被拼出 - 非 HTTPS、带凭据或不在内置目标集合中的地址。 - """ - parsed = urlparse(url) - if parsed.scheme.lower() != "https": - return "测试地址仅支持 HTTPS" - if not parsed.netloc: - return "测试地址无效" - if parsed.username or parsed.password: - return "测试地址不支持携带账号信息" - if not _get_nettest_rule(url): - return "测试地址不在允许的测试目标列表中" - return None - - -def _get_nettest_rule( - url: Optional[str] = None, target_id: Optional[str] = None -) -> Optional[dict[str, Any]]: - """ - 根据 target_id 或历史兼容参数匹配网络测试规则。 - - 现在的主路径是 target_id。保留 url 参数是为了兼容旧前端或未升级的调用方, - 但匹配结果仍然只能落到服务端预定义规则上。 - """ - for rule in _build_nettest_rules(): - if target_id and rule.get("id") == target_id: - return rule - if url and rule.get("url") == url: - return rule - return None - - -def _is_allowed_nettest_redirect(url: str, rule: dict[str, Any]) -> bool: - """ - 校验重定向目标是否仍属于当前测试项允许的跳转范围。 - - nettest 不再信任客户端跟随重定向,而是只允许在该测试项自己的白名单内跳转, - 这样既能兼容正常 30x,又不会把安全边界重新放开。 - """ - parsed = urlparse(url) - if parsed.scheme.lower() != "https" or not parsed.netloc: - return False - if parsed.username or parsed.password: - return False - return any( - _match_nettest_prefix(url, prefix) - for prefix in rule.get("allowed_redirect_prefixes", []) - ) - - -async def _close_nettest_response(response: Any) -> None: - """ - 安静地关闭 httpx 响应对象。 - - nettest 在手动处理重定向时会提前结束部分响应读取,这里统一做资源回收, - 避免连接泄漏干扰后续测试。 - """ - if response is None or not hasattr(response, "aclose"): - return - try: - await response.aclose() - except Exception as err: - logger.debug(f"关闭网络测试响应失败: {err}") async def fetch_image( @@ -1535,16 +1243,10 @@ async def nettest_targets(_: _SchemaTokenPayload = Depends(verify_token)): 这里只返回前端渲染所需的最小信息,避免把可请求 URL、内容校验规则和 跳转白名单暴露给客户端。 """ + targets = get_configured_network_test_service().list_targets() return _SchemaResponse( success=True, - data=[ - { - "id": item["id"], - "name": item["name"], - "icon": item["icon"], - } - for item in _build_nettest_rules() - ], + data=[{"id": item.id, "name": item.name, "icon": item.icon} for item in targets], ) @@ -1565,22 +1267,17 @@ async def nettest( `target_id` 是当前前端使用的正式入口。`url/proxy/include` 仅作兼容保留, 其中 `include` 不再参与客户端可控的内容匹配,具体校验由服务端规则决定。 """ - target = _get_nettest_rule(url=url, target_id=target_id) - if not target: - return _SchemaResponse(success=False, message="测试目标不存在") - url = target["url"] - invalid_message = _validate_nettest_url(url) - if invalid_message: - logger.warning(f"拦截不安全的网络测试地址: {url}") - return _SchemaResponse(success=False, message=invalid_message) - success, message, data = await NetworkTestService( - request_utils_cls=AsyncRequestUtils, - settings_getter=get_runtime_settings, - logger=logger, - redirect_checker=_is_allowed_nettest_redirect, - close_response=_close_nettest_response, - ).execute(target, include=include) - return _SchemaResponse(success=success, message=message, data=data) + result = await get_configured_network_test_service().execute( + target_id=target_id, + url=url, + include=include, + ) + data = {"time": result.elapsed_ms} if result.elapsed_ms is not None else None + return _SchemaResponse( + success=result.success, + message=result.message, + data=data, + ) @router.get( diff --git a/app/application/agent.py b/app/application/agent.py index f9cea81b7..f8f77bb49 100644 --- a/app/application/agent.py +++ b/app/application/agent.py @@ -48,15 +48,14 @@ class AgentDataContext: subscriptions: SubscriptionRepository subscription_mutation_scope: SubscriptionMutationScope subscription_delete_scope: DeleteSubscribeScope - async_rule_group_mutation_scope: Callable[ - [], AbstractAsyncContextManager[AsyncRuleGroupMutationService] - ] + async_rule_group_mutation_scope: Callable[[], AbstractAsyncContextManager[AsyncRuleGroupMutationService]] subscription_history: SubscriptionHistoryQueryPort transfer_history: TransferHistoryRepository transfer_execution: TransferExecutionRepository download_history: DownloadHistoryRepository plugin_data: PluginDataQueryRepository + Provider = Callable[[], Any] # provider 注册表由 startup/initializers/agent.py 在组合根装配。 @@ -66,27 +65,31 @@ _prompt_manager_provider: Optional[Provider] = None _agent_capability_manager_provider: Optional[Provider] = None _llm_helper_provider: Optional[Provider] = None _manual_redo_prompt_builder_provider: Optional[Provider] = None +_web_agent_type_provider: Optional[Provider] = None def register_agent_service_providers( - *, - agent_manager_provider: Provider, - running_agent_manager_provider: Provider, - prompt_manager_provider: Provider, - capability_manager_provider: Provider, - llm_helper_provider: Provider, - manual_redo_prompt_builder_provider: Provider, + *, + agent_manager_provider: Provider, + running_agent_manager_provider: Provider, + prompt_manager_provider: Provider, + capability_manager_provider: Provider, + llm_helper_provider: Provider, + manual_redo_prompt_builder_provider: Provider, + web_agent_type_provider: Provider, ) -> None: """注册 Agent 服务 provider,保持组合根装配阶段零重量实现导入。""" global _agent_manager_provider, _running_agent_manager_provider global _prompt_manager_provider, _agent_capability_manager_provider global _llm_helper_provider, _manual_redo_prompt_builder_provider + global _web_agent_type_provider _agent_manager_provider = agent_manager_provider _running_agent_manager_provider = running_agent_manager_provider _prompt_manager_provider = prompt_manager_provider _agent_capability_manager_provider = capability_manager_provider _llm_helper_provider = llm_helper_provider _manual_redo_prompt_builder_provider = manual_redo_prompt_builder_provider + _web_agent_type_provider = web_agent_type_provider def reset_agent_service_providers() -> None: @@ -94,20 +97,22 @@ def reset_agent_service_providers() -> None: global _agent_manager_provider, _running_agent_manager_provider global _prompt_manager_provider, _agent_capability_manager_provider global _llm_helper_provider, _manual_redo_prompt_builder_provider + global _web_agent_type_provider _agent_manager_provider = None _running_agent_manager_provider = None _prompt_manager_provider = None _agent_capability_manager_provider = None _llm_helper_provider = None _manual_redo_prompt_builder_provider = None + _web_agent_type_provider = None def register_agent_services( - agent_manager: Any, - prompt_manager: Any, - capability_manager: Any, - llm_helper: Any, - manual_redo_prompt_builder: Optional[Callable[[Any], str]] = None, + agent_manager: Any, + prompt_manager: Any, + capability_manager: Any, + llm_helper: Any, + manual_redo_prompt_builder: Optional[Callable[[Any], str]] = None, ) -> None: """兼容直接对象注入;生产组合根应注册惰性 provider。""" register_agent_service_providers( @@ -117,16 +122,14 @@ def register_agent_services( capability_manager_provider=lambda: capability_manager, llm_helper_provider=lambda: llm_helper, manual_redo_prompt_builder_provider=lambda: manual_redo_prompt_builder, + web_agent_type_provider=lambda: None, ) def _resolve(provider: Optional[Provider], service_name: str) -> Any: """解析已注册服务;缺少组合根装配时给出稳定错误。""" if provider is None: - raise RuntimeError( - f"Agent 服务 {service_name} 未注册:" - "请先导入 app.startup.initializers.agent 完成组合根装配" - ) + raise RuntimeError(f"Agent 服务 {service_name} 未注册:请先导入 app.startup.initializers.agent 完成组合根装配") return provider() @@ -148,10 +151,10 @@ def get_prompt_manager() -> Any: def supports_image_input( - provider: Optional[str] = None, - model: Optional[str] = None, - base_url: Optional[str] = None, - base_url_preset: Optional[str] = None, + provider: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + base_url_preset: Optional[str] = None, ) -> bool: """判断当前模型是否启用了图片输入能力。""" llm_helper = _resolve(_llm_helper_provider, "llm_helper") @@ -181,6 +184,14 @@ def transcribe_audio(content: bytes, filename: str = "input.ogg") -> Optional[st return capability_manager.transcribe_audio(content, filename=filename) +def get_web_agent_type() -> type: + """返回由 Agent 实现层提供的 WebAgent 运行时类型。""" + web_agent_type = _resolve(_web_agent_type_provider, "web_agent_type") + if not isinstance(web_agent_type, type): + raise RuntimeError("WebAgent 运行时类型尚未配置") + return web_agent_type + + def build_manual_redo_prompt(history: Any) -> str: """构造整理记录 AI 重新整理提示词(builder 由 agent 层注册)。""" builder = _resolve( diff --git a/app/application/messaging/agent.py b/app/application/messaging/agent.py index 5dc3a7097..fbbed5d19 100644 --- a/app/application/messaging/agent.py +++ b/app/application/messaging/agent.py @@ -1,16 +1,52 @@ import asyncio import copy +import hashlib +import mimetypes +import shutil +import time import uuid +from collections import deque from dataclasses import dataclass, field from datetime import datetime, timedelta -from queue import Queue +from pathlib import Path +from queue import Empty, Queue from threading import Lock -from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Tuple, Union +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Dict, + Iterable, + List, + Optional, + Protocol, + Tuple, + Union, + cast, +) +import aiofiles # type: ignore[import-untyped] + +from app.application import agent as agent_application +from app.application.agent import is_audio_input_available, transcribe_audio +from app.application.commands import get_command, get_commands +from app.application.configuration import get_api_runtime_config_snapshot +from app.application.messaging.chat import ( + AgentChatPersistenceService, + AgentChatPrincipal, + AgentChatRecord, + AgentChatService, + get_configured_agent_chat_persistence, + get_configured_agent_chat_service, +) +from app.application.messaging.router import has_pending_interaction +from app.runtime.execution import run_in_threadpool from app.runtime.log import logger -from app.schemas.message import Message -from app.schemas.types import NotificationChannel +from app.runtime.stop import runtime_stop_state from app.runtime.tasks import get_task_registry +from app.schemas.message import Message +from app.schemas.types import NotificationChannel, ReplyMode # Agent 选择按钮回调前缀(新旧两种格式都必须继续兼容) AGENT_CHOICE_PREFIX = "agent_interaction:choice:" @@ -23,7 +59,7 @@ def build_agent_choice_callback(request_id: str, option_index: int) -> str: def parse_agent_choice_callback( - callback_data: str, + callback_data: str, ) -> Optional[Tuple[str, int]]: """解析新旧两种 Agent 选择回调,格式无效时返回 None。""" if callback_data.startswith(AGENT_CHOICE_PREFIX): @@ -45,8 +81,8 @@ def parse_agent_choice_callback( def build_agent_choice_button_rows( - request: "PendingAgentInteraction", -) -> Tuple[List[dict], List[List[dict]]]: + request: "PendingAgentInteraction", +) -> Tuple[List[dict[str, Any]], List[List[dict[str, Any]]]]: """根据待选择请求构造 WebAgent 和消息渠道共用的按钮。""" buttons = [ { @@ -113,15 +149,15 @@ class AgentInteractionManager: self._pending_interactions.pop(request_id, None) def create_request( - self, - session_id: str, - user_id: str, - channel: Optional[str], - source: Optional[str], - username: Optional[str], - title: Optional[str], - prompt: str, - options: List[AgentInteractionOption], + self, + session_id: str, + user_id: str, + channel: Optional[str], + source: Optional[str], + username: Optional[str], + title: Optional[str], + prompt: str, + options: List[AgentInteractionOption], ) -> PendingAgentInteraction: """ 创建一条待用户确认的 Agent 交互请求。 @@ -146,10 +182,10 @@ class AgentInteractionManager: return request def resolve( - self, - request_id: str, - option_index: int, - user_id: Optional[str] = None, + self, + request_id: str, + option_index: int, + user_id: Optional[str] = None, ) -> Optional[tuple[PendingAgentInteraction, AgentInteractionOption]]: """ 消费一条 Agent 交互请求,并返回选中的选项。 @@ -178,11 +214,11 @@ class AgentInteractionManager: agent_interaction_manager = AgentInteractionManager() -_WEB_AGENT_EDIT_QUEUES: dict[str, list[Queue[dict]]] = {} +_WEB_AGENT_EDIT_QUEUES: dict[str, list[Queue[dict[str, Any]]]] = {} _WEB_AGENT_EDIT_LOCK = Lock() _WEB_AGENT_MESSAGE_QUEUES: dict[str, list[Queue[Message]]] = {} _WEB_AGENT_MESSAGE_LOCK = Lock() -_ChannelAdminResolver = Callable[[Optional[dict]], Iterable[Union[str, int]]] +_ChannelAdminResolver = Callable[[Optional[dict[str, Any]]], Iterable[Union[str, int]]] _CHANNEL_ADMIN_RESOLVERS: dict[str, _ChannelAdminResolver] = {} _WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task[object]] = set() @@ -191,9 +227,12 @@ def create_web_agent_background_task( coroutine: Awaitable[object], ) -> asyncio.Task[object]: """登记 Web Agent 后台任务,使应用关闭时可以统一收口。""" - task = get_task_registry().create( - coroutine, - owner="api.agent.web_execution", + task = cast( + asyncio.Task[object], + get_task_registry().create( + coroutine, + owner="api.agent.web_execution", + ), ) _WEB_AGENT_BACKGROUND_TASKS.add(task) task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard) @@ -219,8 +258,8 @@ async def wait_web_agent_background_tasks() -> None: def register_channel_admin_resolver( - channel: Union[NotificationChannel, str], - resolver: _ChannelAdminResolver, + channel: Union[NotificationChannel, str], + resolver: _ChannelAdminResolver, ) -> None: """ 注册消息渠道的管理员主体 ID 解析器。 @@ -233,8 +272,8 @@ def register_channel_admin_resolver( def resolve_config_principal_ids( - config: Optional[dict], - *config_keys: str, + config: Optional[dict[str, Any]], + *config_keys: str, ) -> set[str]: """ 从渠道自行声明的配置键中解析主体 ID。 @@ -243,20 +282,18 @@ def resolve_config_principal_ids( :param config_keys: 由渠道模块维护的主体 ID 配置键 :return: 去空白后的主体 ID 集合 """ - principal_ids = set() + principal_ids: set[str] = set() for config_key in config_keys: principal_ids.update( - item.strip() - for item in str((config or {}).get(config_key) or "").split(",") - if item.strip() + item.strip() for item in str((config or {}).get(config_key) or "").split(",") if item.strip() ) return principal_ids def matches_channel_admin( - channel: Union[NotificationChannel, str], - config: Optional[dict], - *principal_ids: Optional[Union[str, int]], + channel: Union[NotificationChannel, str], + config: Optional[dict[str, Any]], + *principal_ids: Optional[Union[str, int]], ) -> bool: """ 按渠道配置中的稳定主体 ID 判断管理员身份。 @@ -285,14 +322,14 @@ def matches_channel_admin( return bool(authorized_ids.intersection(candidates)) -def normalize_web_agent_button_rows(buttons: Optional[list[list[dict]]]) -> list[list[dict]]: +def normalize_web_agent_button_rows(buttons: Optional[list[list[dict[str, Any]]]]) -> list[list[dict[str, Any]]]: """ 将消息按钮转换为 WebAgent 前端可识别的按钮行。 :param buttons: 传统消息模块返回的按钮二维数组 :return: WebAgent 前端选项按钮二维数组 """ - button_rows: list[list[dict]] = [] + button_rows: list[list[dict[str, Any]]] = [] for row in buttons or []: normalized_row = [] for button in row or []: @@ -313,8 +350,8 @@ def normalize_web_agent_button_rows(buttons: Optional[list[list[dict]]]) -> list def _resolve_web_agent_choice_id( - message_id: Union[str, int], - button_rows: list[list[dict]], + message_id: Union[str, int], + button_rows: list[list[dict[str, Any]]], ) -> str: """ 从按钮回调中提取稳定的 WebAgent 选项 ID。 @@ -336,12 +373,12 @@ def _resolve_web_agent_choice_id( def build_web_agent_message_update_event( - *, - message_id: Union[str, int], - title: Optional[str], - text: str, - buttons: Optional[list[list[dict]]], -) -> dict: + *, + message_id: Union[str, int], + title: Optional[str], + text: str, + buttons: Optional[list[list[dict[str, Any]]]], +) -> dict[str, Any]: """ 构造 WebAgent 原消息更新事件。 @@ -362,21 +399,23 @@ def build_web_agent_message_update_event( "status": "done", } if button_rows: - target_message["choices"].append({ - "id": _resolve_web_agent_choice_id(message_id, button_rows), - "title": title, - "prompt": text or "", - "buttons": [button for row in button_rows for button in row], - "button_rows": button_rows, - "status": "pending", - }) + target_message["choices"].append( + { + "id": _resolve_web_agent_choice_id(message_id, button_rows), + "title": title, + "prompt": text or "", + "buttons": [button for row in button_rows for button in row], + "button_rows": button_rows, + "status": "pending", + } + ) return { "type": "message_update", "target_message": target_message, } -def extract_web_agent_message_from_event_data(data: dict) -> Optional[Message]: +def extract_web_agent_message_from_event_data(data: dict[str, Any]) -> Optional[Message]: """ 从 NoticeMessage 事件数据中提取 WebAgent 通知。 @@ -449,9 +488,7 @@ def dispatch_web_agent_message_event(event: object) -> None: user_id = _get_web_agent_message_user_id(message) if user_id is None: queues = [ - message_queue - for user_queues in _WEB_AGENT_MESSAGE_QUEUES.values() - for message_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_MESSAGE_QUEUES.get(user_id) or []) @@ -471,14 +508,12 @@ def detach_web_agent_message_queue(user_id: str, message_queue: Queue[Message]) queues = _WEB_AGENT_MESSAGE_QUEUES.get(str(user_id)) if not queues: return - _WEB_AGENT_MESSAGE_QUEUES[str(user_id)] = [ - item for item in queues if item is not message_queue - ] + _WEB_AGENT_MESSAGE_QUEUES[str(user_id)] = [item for item in queues if item is not message_queue] if not _WEB_AGENT_MESSAGE_QUEUES[str(user_id)]: _WEB_AGENT_MESSAGE_QUEUES.pop(str(user_id), None) -def attach_web_agent_edit_queue(user_id: str, edit_queue: Queue[dict]) -> None: +def attach_web_agent_edit_queue(user_id: str, edit_queue: Queue[dict[str, Any]]) -> None: """ 为当前 WebAgent 请求挂载原消息编辑事件队列。 @@ -489,7 +524,7 @@ def attach_web_agent_edit_queue(user_id: str, edit_queue: Queue[dict]) -> None: _WEB_AGENT_EDIT_QUEUES.setdefault(str(user_id), []).append(edit_queue) -def detach_web_agent_edit_queue(user_id: str, edit_queue: Queue[dict]) -> None: +def detach_web_agent_edit_queue(user_id: str, edit_queue: Queue[dict[str, Any]]) -> None: """ 移除当前 WebAgent 请求的原消息编辑事件队列。 @@ -500,17 +535,15 @@ def detach_web_agent_edit_queue(user_id: str, edit_queue: Queue[dict]) -> None: queues = _WEB_AGENT_EDIT_QUEUES.get(str(user_id)) if not queues: return - _WEB_AGENT_EDIT_QUEUES[str(user_id)] = [ - item for item in queues if item is not edit_queue - ] + _WEB_AGENT_EDIT_QUEUES[str(user_id)] = [item for item in queues if item is not edit_queue] if not _WEB_AGENT_EDIT_QUEUES[str(user_id)]: _WEB_AGENT_EDIT_QUEUES.pop(str(user_id), None) def dispatch_web_agent_edit_event( - *, - user_id: str, - event: dict, + *, + user_id: str, + event: dict[str, Any], ) -> bool: """ 将 WebAgent 原消息编辑事件分发给正在等待的请求队列。 @@ -527,12 +560,12 @@ def dispatch_web_agent_edit_event( def edit_web_agent_message( - *, - user_id: str, - message_id: Union[str, int], - title: Optional[str], - text: str, - buttons: Optional[list[list[dict]]] = None, + *, + user_id: str, + message_id: Union[str, int], + title: Optional[str], + text: str, + buttons: Optional[list[list[dict[str, Any]]]] = None, ) -> bool: """ 原地更新 WebAgent 前端消息卡片。 @@ -553,3 +586,1610 @@ def edit_web_agent_message( buttons=buttons, ) return dispatch_web_agent_edit_event(user_id=user_id, event=event) + + +WEB_AGENT_SESSION_PREFIX = "web-agent:" +WEB_AGENT_SOURCE = "web-agent" +WEB_AGENT_FILE_TTL_SECONDS = 6 * 60 * 60 +WEB_AGENT_FILE_MAX_ITEMS = 256 +WEB_AGENT_UPLOAD_MAX_BYTES = 32 * 1024 * 1024 +WEB_AGENT_UPLOAD_CHUNK_SIZE = 1024 * 1024 +WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS = 60.0 +WEB_AGENT_BROWSER_AUDIO_SUFFIXES = {".aac", ".m4a", ".mp3", ".mp4", ".wav", ".wave"} +WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS = 2.0 +WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS = 60.0 +WEB_AGENT_STREAM_HEARTBEAT_SECONDS = 15.0 +WEB_AGENT_STREAM_COALESCE_SECONDS = 0.03 +WEB_AGENT_STREAM_COALESCE_MAX_CHARS = 256 +WEB_AGENT_STREAM_QUEUE_MAX_SIZE = 64 +_WEB_AGENT_FILE_REGISTRY: dict[str, dict[str, Any]] = {} +_web_agent_message_handler: Optional[Callable[..., object]] = None +_web_agent_session_binder: Optional[Callable[[str, str], None]] = None + + +def configure_web_agent_message_runtime( + *, + message_handler: Callable[..., object], + session_binder: Callable[[str, str], None], +) -> None: + """由启动组合根装配传统消息执行与会话绑定端口。""" + global _web_agent_message_handler, _web_agent_session_binder + _web_agent_message_handler = message_handler + _web_agent_session_binder = session_binder + + +def reset_web_agent_message_runtime() -> None: + """清除 WebAgent 消息运行端口,避免 lifespan 重启复用旧对象。""" + global _web_agent_message_handler, _web_agent_session_binder + _web_agent_message_handler = None + _web_agent_session_binder = None + + +def _handle_web_agent_message(**kwargs: Any) -> object: + """调用已装配的传统消息入口。""" + if _web_agent_message_handler is None: + raise RuntimeError("WebAgent 传统消息入口尚未配置") + return _web_agent_message_handler(**kwargs) + + +def bind_web_agent_user_session(user_id: str, session_id: str) -> None: + """把 Web 用户绑定到服务端 Agent 会话。""" + if _web_agent_session_binder is None: + raise RuntimeError("WebAgent 会话绑定端口尚未配置") + _web_agent_session_binder(user_id, session_id) + + +class WebAgentUpload(Protocol): + """WebAgent 上传源所需的最小异步读取合同。""" + + async def read(self, size: int = -1) -> bytes: + """读取下一段上传内容。""" + ... + + async def close(self) -> None: + """关闭上传源。""" + ... + + +class WebAgentUploadTooLargeError(ValueError): + """上传附件超过 WebAgent 单文件容量限制。""" + + +@dataclass(frozen=True, slots=True) +class WebAgentStreamCommand: + """WebAgent 流式对话用例所需的传输无关输入。""" + + text: str = "" + display_text: Optional[str] = None + session_id: Optional[str] = None + images: list[str] = field(default_factory=list) + audio_refs: list[str] = field(default_factory=list) + files: list[dict[str, Any]] = field(default_factory=list) + choice_selection: Optional[dict[str, Any]] = None + original_message_id: Optional[Union[str, int]] = None + original_chat_id: Optional[Union[str, int]] = None + echo_user: bool = True + + +@dataclass(frozen=True, slots=True) +class WebAgentStreamResult: + """WebAgent 流式用例返回的事件迭代器与传输控制标记。""" + + events: AsyncIterator[dict[str, Any]] + control: Optional[str] = None + + +def build_web_agent_display_message( + role: str, + content: str = "", + attachments: Optional[list[dict[str, Any]]] = None, + status: str = "done", +) -> dict[str, Any]: + """构造 WebAgent 前后端共享的展示消息。""" + normalized_content = content or "" + return { + "id": f"{role}-{uuid.uuid4().hex}", + "role": role, + "content": normalized_content, + "createdAt": int(datetime.now().timestamp() * 1000), + "status": status, + "tools": [], + "segments": ([{"type": "text", "content": normalized_content}] if normalized_content else []), + "attachments": attachments or [], + "choices": [], + } + + +class WebAgentEventPublisher: + """合并 WebAgent 文本增量,并通过有界队列向 SSE 消费者提供事件。""" + + def __init__(self) -> None: + self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=WEB_AGENT_STREAM_QUEUE_MAX_SIZE) + self._pending_events: deque[dict[str, Any]] = deque() + self._pending_signal = asyncio.Event() + self._pending_delta = "" + self._delta_timer: Optional[asyncio.TimerHandle] = None + self._disposed = False + self._max_depth = 0 + self._last_logged_depth = 0 + self._pump_task = asyncio.create_task(self._pump()) + + @property + def max_depth(self) -> int: + """返回本轮发布器观测到的最大积压深度。""" + return self._max_depth + + def publish(self, event: dict[str, Any]) -> bool: + """发布事件;返回关闭状态以便受保护投递能准确报告失败。""" + if self._disposed: + return False + if event.get("type") == "delta": + self._pending_delta += str(event.get("content") or "") + if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS: + self._flush_delta() + elif self._delta_timer is None: + loop = asyncio.get_running_loop() + self._delta_timer = loop.call_later( + WEB_AGENT_STREAM_COALESCE_SECONDS, + self._flush_delta, + ) + return True + + self._flush_delta() + self._append_event(event) + return True + + async def get(self) -> dict[str, Any]: + """等待并返回下一条已排序事件。""" + return await self._queue.get() + + async def aclose(self) -> None: + """停止发布器并释放等待中的泵任务。""" + if self._disposed: + return + self._disposed = True + self._cancel_delta_timer() + self._pending_delta = "" + self._pending_events.clear() + self._pump_task.cancel() + try: + await self._pump_task + except asyncio.CancelledError: + pass + + def _cancel_delta_timer(self) -> None: + """取消尚未触发的文本合并计时器。""" + if self._delta_timer is None: + return + self._delta_timer.cancel() + self._delta_timer = None + + def _flush_delta(self) -> None: + """把当前文本缓冲转换成一条增量事件。""" + self._cancel_delta_timer() + if not self._pending_delta or self._disposed: + return + content = self._pending_delta + self._pending_delta = "" + self._append_event({"type": "delta", "content": content}) + + def _append_event(self, event: dict[str, Any]) -> None: + """追加待发布事件,相邻文本在出口阻塞时继续合并。""" + if event.get("type") == "delta" and self._pending_events and self._pending_events[-1].get("type") == "delta": + self._pending_events[-1]["content"] += str(event.get("content") or "") + else: + self._pending_events.append(event) + self._pending_signal.set() + depth = self._queue.qsize() + len(self._pending_events) + self._max_depth = max(self._max_depth, depth) + if depth >= WEB_AGENT_STREAM_QUEUE_MAX_SIZE // 2 and depth > self._last_logged_depth: + self._last_logged_depth = depth + logger.debug(f"WebAgent SSE事件积压深度: {depth}") + + async def _pump(self) -> None: + """按发布顺序把本地合并结果写入有界出口队列。""" + while True: + await self._pending_signal.wait() + while self._pending_events: + event = self._pending_events.popleft() + await self._queue.put(event) + self._pending_signal.clear() + + +def build_web_agent_session_id(user: AgentChatPrincipal, session_id: Optional[str]) -> str: + """ + 构建前端 Agent 会话 ID。 + + :param user: 当前登录用户 + :param session_id: 前端传入的会话标识 + :return: 可用于 Agent 记忆隔离的服务端会话 ID + """ + seed = str(session_id or "").strip() or uuid.uuid4().hex + if seed.startswith(WEB_AGENT_SESSION_PREFIX): + return seed + try: + existing_chat = get_configured_agent_chat_service().get_sync(seed) + if existing_chat and AgentChatService.can_access(existing_chat, user): + return seed + except Exception as e: + logger.debug(f"读取WebAgent历史会话失败: {e}") + user_part = user.name or str(user.id) + digest = hashlib.sha256(f"{user_part}:{seed}".encode("utf-8")).hexdigest() + return f"{WEB_AGENT_SESSION_PREFIX}{digest[:32]}" + + +async def build_web_agent_session_id_async( + user: AgentChatPrincipal, + session_id: Optional[str], + service: Optional[AgentChatService] = None, +) -> str: + """异步解析 Web Agent 会话 ID,并复用异步会话查询端口。""" + seed = str(session_id or "").strip() or uuid.uuid4().hex + if seed.startswith(WEB_AGENT_SESSION_PREFIX): + return seed + try: + if service is None: + service = get_configured_agent_chat_service() + existing_chat = await service.get(seed) + if existing_chat and AgentChatService.can_access(existing_chat, user): + return seed + except Exception as e: + logger.debug(f"读取WebAgent历史会话失败: {e}") + user_part = user.name or str(user.id) + digest = hashlib.sha256(f"{user_part}:{seed}".encode("utf-8")).hexdigest() + return f"{WEB_AGENT_SESSION_PREFIX}{digest[:32]}" + + +def can_access_agent_chat(chat: Any, user: AgentChatPrincipal) -> bool: + """ + 判断当前登录用户是否可以访问指定 Agent 会话。 + + 超级用户可查看所有渠道历史;普通用户仅能查看 user_id 或 username 匹配自己的会话。 + """ + if not chat or not user: + return False + if getattr(user, "is_superuser", False): + return True + user_id = str(user.id) + username = str(user.name or "") + return chat.user_id == user_id or (bool(username) and chat.username == username) + + +async def get_accessible_agent_chat( + service: AgentChatService, + session_id: str, + user: AgentChatPrincipal, +) -> Optional[AgentChatRecord]: + """ + 读取当前用户可访问的 Agent 会话。 + """ + return await service.get_accessible(session_id, user) + + +def append_web_agent_text_segment(assistant_message: dict[str, Any], content: str) -> None: + """ + 将文本增量追加到展示消息,并仅合并相邻文本片段。 + + :param assistant_message: 当前助手展示消息 + :param content: 新增文本 + """ + if not content: + return + assistant_message["content"] = str(assistant_message.get("content") or "") + content + segments = assistant_message.setdefault("segments", []) + if segments and segments[-1].get("type") == "text": + segments[-1]["content"] = str(segments[-1].get("content") or "") + content + else: + segments.append({"type": "text", "content": content}) + + +def build_legacy_web_agent_segments(content: str, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + 为未携带有序片段的旧展示消息生成兼容布局。 + + :param content: 聚合后的助手文本 + :param tools: 工具提示列表 + :return: 按旧版工具在前、文本在后的顺序生成的片段 + """ + segments = [{"type": "tool", "toolIndex": index} for index in range(len(tools))] + if content: + segments.append({"type": "text", "content": content}) + return segments + + +def apply_web_agent_display_event(event: dict[str, Any], assistant_message: dict[str, Any]) -> None: + """ + 将 WebAgent SSE 事件同步应用到服务端展示消息快照。 + """ + event_type = event.get("type") + if event_type == "delta": + append_web_agent_text_segment(assistant_message, event.get("content") or "") + elif event_type == "tool": + for tool in assistant_message["tools"]: + tool["status"] = "done" + tool_index = len(assistant_message["tools"]) + assistant_message["tools"].append( + { + "id": f"tool-{uuid.uuid4().hex}", + "message": str(event.get("message") or "").strip(), + "status": "running", + } + ) + assistant_message.setdefault("segments", []).append({"type": "tool", "toolIndex": tool_index}) + elif event_type == "attachment" and event.get("attachment"): + assistant_message["attachments"].append(event["attachment"]) + elif event_type == "choice" and event.get("choice"): + assistant_message["choices"].append({**event["choice"], "status": "pending"}) + elif event_type == "message_update": + target_message = event.get("target_message") or {} + assistant_message["id"] = target_message.get("id") or assistant_message.get("id") + assistant_message["content"] = target_message.get("content") or "" + assistant_message["attachments"] = target_message.get("attachments") or [] + assistant_message["choices"] = target_message.get("choices") or [] + assistant_message["tools"] = target_message.get("tools") or [] + target_segments = target_message.get("segments") + assistant_message["segments"] = ( + target_segments + if isinstance(target_segments, list) + else build_legacy_web_agent_segments(assistant_message["content"], assistant_message["tools"]) + ) + assistant_message["status"] = target_message.get("status") or "done" + elif event_type == "error": + assistant_message["status"] = "error" + if not assistant_message["content"]: + append_web_agent_text_segment( + assistant_message, + event.get("message") or "智能助手响应失败", + ) + for tool in assistant_message["tools"]: + tool["status"] = "done" + elif event_type == "done": + if assistant_message.get("status") != "error": + assistant_message["status"] = "done" + for tool in assistant_message["tools"]: + tool["status"] = "done" + + +async def save_web_agent_display_snapshot( + *, + session_id: str, + current_user: AgentChatPrincipal, + messages: list[dict[str, Any]], + client_session_id: Optional[str] = None, + service: Optional[AgentChatService] = None, + persistence: Optional[AgentChatPersistenceService] = None, +) -> None: + """ + 保存 WebAgent 当前展示消息快照。 + """ + if service is None: + # 直接调用该内部 helper 时没有 FastAPI 依赖注入上下文。 + service = get_configured_agent_chat_service() + existing_chat = await service.get(session_id) + if persistence is None: + # 直接调用该内部 helper 时没有 FastAPI 依赖注入上下文。 + persistence = get_configured_agent_chat_persistence() + await persistence.async_save_display_messages( + session_id=session_id, + user_id=(existing_chat.user_id if existing_chat else str(current_user.id)), + username=(existing_chat.username if existing_chat else current_user.name), + channel=(existing_chat.channel if existing_chat and existing_chat.channel else NotificationChannel.WebAgent), + source=(existing_chat.source if existing_chat and existing_chat.source else WEB_AGENT_SOURCE), + original_chat_id=existing_chat.original_chat_id if existing_chat else None, + client_session_id=( + existing_chat.client_session_id if existing_chat and existing_chat.client_session_id else client_session_id + ), + messages=messages, + ) + + +def sanitize_web_agent_upload_name(filename: Optional[str], mime_type: Optional[str] = None) -> str: + """ + 规范化 Web Agent 上传文件名,避免路径穿越和空文件名。 + + :param filename: 浏览器上传的原始文件名 + :param mime_type: 浏览器上报的 MIME 类型 + :return: 可安全落盘的文件名 + """ + name = Path(filename or "attachment").name.strip() + safe_name = "".join(char for char in name if char.isalnum() or char in (" ", ".", "_", "-")).strip(" .") + if not safe_name: + safe_name = "attachment" + if "." not in safe_name: + suffix = mimetypes.guess_extension(mime_type or "") or "" + safe_name = f"{safe_name}{suffix}" + return safe_name + + +async def get_web_agent_upload_dir( + user: AgentChatPrincipal, + session_id: Optional[str], + service: Optional[AgentChatService] = None, +) -> Path: + """ + 计算当前 Web Agent 会话的临时附件目录。 + + :param user: 当前登录用户 + :param session_id: 前端会话标识 + :return: 已创建的临时附件目录 + """ + server_session_id = await build_web_agent_session_id_async( + user, + session_id, + service, + ) + safe_session_id = server_session_id.replace(":", "_") + upload_dir = Path(get_api_runtime_config_snapshot().temp_path) / "agent_uploads" / safe_session_id + await run_in_threadpool(upload_dir.mkdir, parents=True, exist_ok=True) + return upload_dir + + +async def save_web_agent_upload(upload_file: WebAgentUpload, target_path: Path) -> int: + """ + 分块保存 Web Agent 上传文件,并限制单文件体积。 + + :param upload_file: FastAPI 上传文件对象 + :param target_path: 目标落盘路径 + :return: 已写入的字节数 + """ + size = 0 + try: + async with aiofiles.open(target_path, "wb") as output: + while True: + chunk = await upload_file.read(WEB_AGENT_UPLOAD_CHUNK_SIZE) + if not chunk: + break + size += len(chunk) + if size > WEB_AGENT_UPLOAD_MAX_BYTES: + raise WebAgentUploadTooLargeError("附件超过 32MB,无法发送给智能助手") + await output.write(chunk) + except Exception: + await run_in_threadpool(target_path.unlink, missing_ok=True) + raise + finally: + await upload_file.close() + return size + + +def cleanup_web_agent_file_registry() -> None: + """清理过期或过量的 Web Agent 临时附件引用。""" + now = time.time() + expired_ids = [ + file_id + for file_id, info in _WEB_AGENT_FILE_REGISTRY.items() + if now - info.get("created_at", now) > WEB_AGENT_FILE_TTL_SECONDS + ] + for file_id in expired_ids: + _WEB_AGENT_FILE_REGISTRY.pop(file_id, None) + + overflow = len(_WEB_AGENT_FILE_REGISTRY) - WEB_AGENT_FILE_MAX_ITEMS + if overflow <= 0: + return + sorted_items = sorted( + _WEB_AGENT_FILE_REGISTRY.items(), + key=lambda item: item[1].get("created_at", 0), + ) + for file_id, _ in sorted_items[:overflow]: + _WEB_AGENT_FILE_REGISTRY.pop(file_id, None) + + +def guess_web_agent_attachment_kind(mime_type: Optional[str], fallback: str = "file") -> str: + """ + 根据 MIME 类型推断前端附件展示方式。 + + :param mime_type: 文件 MIME 类型 + :param fallback: 无法推断时使用的类型 + :return: image、audio 或 file + """ + if mime_type and mime_type.startswith("image/"): + return "image" + if mime_type and mime_type.startswith("audio/"): + return "audio" + return fallback + + +def build_web_agent_url_attachment( + url: str, + kind: str, + name: Optional[str] = None, + mime_type: Optional[str] = None, +) -> dict[str, Any]: + """ + 构建远程或 data URL 附件事件。 + + :param url: 前端可访问的附件地址 + :param kind: 附件展示类型 + :param name: 展示名称 + :param mime_type: MIME 类型 + :return: 前端附件描述 + """ + return { + "kind": kind, + "url": url, + "download_url": url, + "name": name, + "mime_type": mime_type, + } + + +def build_web_agent_input_attachments( + images: list[str], + files: list[dict[str, Any]], + audio_refs: list[str], +) -> list[dict[str, Any]]: + """ + 构造 WebAgent 用户输入附件展示记录。 + """ + attachments: list[dict[str, Any]] = [] + for index, image in enumerate(images or [], start=1): + attachments.append( + { + "kind": "image", + "url": image, + "download_url": image, + "name": f"image-{index}", + "mime_type": "image/*", + } + ) + for index, file in enumerate(files or [], start=1): + ref = file.get("ref") or file.get("url") or file.get("local_path") or "" + mime_type = file.get("mime_type") + attachments.append( + { + "kind": guess_web_agent_attachment_kind(mime_type), + "url": ref, + "download_url": ref, + "name": file.get("name") or f"attachment-{index}", + "mime_type": mime_type, + "size": file.get("size"), + "local_path": file.get("local_path"), + } + ) + for index, audio_ref in enumerate(audio_refs or [], start=1): + attachments.append( + { + "kind": "audio", + "url": audio_ref, + "download_url": audio_ref, + "name": f"voice-{index}", + "mime_type": "audio/*", + } + ) + return attachments + + +def register_web_agent_file( + file_path: Optional[str], + file_name: Optional[str] = None, + kind: Optional[str] = None, + mime_type: Optional[str] = None, +) -> Optional[dict[str, Any]]: + """ + 注册 Web Agent 本地附件并返回前端可访问的短期下载地址。 + + :param file_path: 本地文件路径 + :param file_name: 前端展示文件名 + :param kind: 附件展示类型 + :param mime_type: 已知 MIME 类型 + :return: 前端附件描述,文件不可访问时返回 None + """ + if not file_path: + return None + try: + resolved_path = Path(file_path).expanduser().resolve(strict=True) + except OSError: + return None + if not resolved_path.is_file(): + return None + + cleanup_web_agent_file_registry() + file_id = uuid.uuid4().hex + display_name = file_name or resolved_path.name + resolved_mime_type = mime_type or mimetypes.guess_type(display_name or str(resolved_path))[0] + file_url = f"message/agent/file/{file_id}" + _WEB_AGENT_FILE_REGISTRY[file_id] = { + "path": resolved_path, + "name": display_name, + "mime_type": resolved_mime_type or "application/octet-stream", + "created_at": time.time(), + } + return { + "kind": kind or guess_web_agent_attachment_kind(resolved_mime_type), + "url": file_url, + "download_url": file_url, + "name": display_name, + "mime_type": resolved_mime_type, + "size": resolved_path.stat().st_size, + } + + +def get_web_agent_audio_mime_type(audio_path: Path) -> Optional[str]: + """ + 生成浏览器播放更友好的音频 MIME 类型。 + + :param audio_path: 音频文件路径 + :return: 可用于 FileResponse/audio 标签的 MIME 类型 + """ + suffix = audio_path.suffix.lower() + if suffix in {".wav", ".wave"}: + return "audio/wav" + if suffix == ".mp3": + return "audio/mpeg" + if suffix in {".m4a", ".mp4"}: + return "audio/mp4" + if suffix == ".aac": + return "audio/aac" + + return mimetypes.guess_type(audio_path.name)[0] + + +def resolve_web_agent_audio_source_path(voice_path: str) -> Path: + """解析语音源文件;文件暂时不可用时保留原始路径以维持回退语义。""" + try: + return Path(voice_path).expanduser().resolve(strict=True) + except OSError: + return Path(voice_path) + + +def remove_web_agent_audio_output(path: Path) -> None: + """清理未完成的转码产物。""" + try: + path.unlink() + except FileNotFoundError: + pass + except OSError as err: + logger.debug("WebAgent 清理未完成语音转码产物失败: path=%s, error=%s", path, err) + + +async def terminate_web_agent_audio_process( + process: Optional[asyncio.subprocess.Process], +) -> None: + """终止并回收转码进程,避免取消或超时留下孤儿 ffmpeg。""" + if process is None or process.returncode is not None: + return + try: + process.kill() + except ProcessLookupError: + return + try: + await process.communicate() + except (OSError, ProcessLookupError): + pass + + +async def prepare_web_agent_audio_attachment_path_async(voice_path: str) -> Path: + """异步准备 WebAgent 语音附件,转码等待可取消且有超时。""" + source_path = Path( + await run_in_threadpool( + resolve_web_agent_audio_source_path, + voice_path, + ) + ) + if source_path.suffix.lower() in WEB_AGENT_BROWSER_AUDIO_SUFFIXES: + return source_path + + ffmpeg_path = await run_in_threadpool(shutil.which, "ffmpeg") + if not ffmpeg_path: + logger.warning("WebAgent 语音转 WAV 跳过:ffmpeg 不可用,path=%s", source_path) + return source_path + + voice_dir = Path(get_api_runtime_config_snapshot().temp_path) / "voice" + try: + await run_in_threadpool(voice_dir.mkdir, parents=True, exist_ok=True) + except OSError as err: + logger.warning("WebAgent 语音转 WAV 目录不可用,将回退原文件: path=%s, error=%s", voice_dir, err) + return source_path + + output_path = voice_dir / f"{source_path.stem}_web_{uuid.uuid4().hex[:8]}.wav" + process: Optional[asyncio.subprocess.Process] = None + + async def cleanup_conversion_output() -> None: + """清理失败或取消的转码进程及临时产物。""" + await terminate_web_agent_audio_process(process) + await run_in_threadpool(remove_web_agent_audio_output, output_path) + + try: + process = await asyncio.create_subprocess_exec( + ffmpeg_path, + "-y", + "-i", + str(source_path), + "-ar", + "24000", + "-ac", + "1", + "-f", + "wav", + str(output_path), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await asyncio.wait_for( + process.communicate(), + timeout=WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS, + ) + output_exists = await run_in_threadpool(output_path.exists) + if process.returncode != 0 or not output_exists: + await cleanup_conversion_output() + logger.warning( + "WebAgent 语音转 WAV 失败,将回退原文件: returncode=%s, stderr=%s", + process.returncode, + (stderr or b"").decode("utf-8", errors="replace").strip()[:500], + ) + return source_path + return Path(output_path) + except asyncio.TimeoutError: + await cleanup_conversion_output() + logger.warning( + "WebAgent 语音转 WAV 超时,将回退原文件: timeout=%ss, path=%s", + WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS, + source_path, + ) + return source_path + except asyncio.CancelledError: + await cleanup_conversion_output() + logger.warning("WebAgent 语音转 WAV 已取消,path=%s", source_path) + raise + except OSError as err: + await cleanup_conversion_output() + logger.warning("WebAgent 语音转 WAV 启动失败,将回退原文件: path=%s, error=%s", source_path, err) + return source_path + + +def get_web_agent_registered_file(ref: str) -> Optional[dict[str, Any]]: + """ + 根据前端附件引用读取 WebAgent 临时文件登记信息。 + + :param ref: message/agent/file/{file_id} 形式的短期引用 + :return: 文件登记信息,引用无效或过期时返回 None + """ + normalized_ref = (ref or "").strip() + prefix = "message/agent/file/" + if not normalized_ref.startswith(prefix): + return None + + cleanup_web_agent_file_registry() + file_id = normalized_ref[len(prefix) :].split("/", 1)[0] + file_info = _WEB_AGENT_FILE_REGISTRY.get(file_id) + if not file_info: + return None + file_path = Path(file_info["path"]) + if not file_path.exists() or not file_path.is_file(): + _WEB_AGENT_FILE_REGISTRY.pop(file_id, None) + return None + return file_info + + +def resolve_web_agent_audio_refs( + audio_refs: list[str], +) -> list[tuple[str, Path, str]]: + """在调用协程中解析音频引用,返回不依赖登记表的文件快照。""" + audio_files = [] + for audio_ref in audio_refs: + file_info = get_web_agent_registered_file(audio_ref) + if not file_info: + logger.warning("WebAgent 语音引用不存在或已过期: ref=%s", audio_ref) + continue + + file_path = Path(file_info["path"]) + audio_files.append((audio_ref, file_path, file_info.get("name") or file_path.name)) + return audio_files + + +def transcribe_web_agent_audio_files( + audio_files: list[tuple[str, Path, str]], +) -> Optional[str]: + """ + 转写 WebAgent 上传的本地录音附件。 + + 文件信息已在调用协程中从短期登记表解析,阻塞文件读取和 provider 调用可在 + worker 中执行,避免跨线程访问登记表。 + """ + if not audio_files: + return None + if not is_audio_input_available(): + logger.warning("WebAgent 音频输入能力未配置或未启用,跳过语音识别") + return None + + transcripts = [] + for audio_ref, file_path, file_name in audio_files: + try: + content = file_path.read_bytes() + except OSError as err: + logger.warning("WebAgent 语音文件读取失败: ref=%s, error=%s", audio_ref, err) + continue + + transcript = transcribe_audio( + content=content, + filename=file_name, + ) + if transcript: + transcripts.append(transcript) + + return "\n".join(transcripts).strip() if transcripts else None + + +async def transcribe_web_agent_audio_input(audio_refs: list[str]) -> Optional[str]: + """解析并在线程池中转写 WebAgent 音频引用。""" + audio_files = resolve_web_agent_audio_refs(audio_refs) + if not audio_files: + return None + return await asyncio.to_thread(transcribe_web_agent_audio_files, audio_files) + + +def merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[str]) -> str: + """合并用户输入文本和语音转写文本,避免重复发送相同内容。""" + merged_parts = [] + seen_parts = set() + for item in (prompt, transcript or ""): + normalized = item.strip() + if not normalized or normalized in seen_parts: + continue + seen_parts.add(normalized) + merged_parts.append(normalized) + return "\n".join(merged_parts).strip() + + +def build_web_agent_choice_event(message: Message) -> Optional[dict[str, Any]]: + """ + 将带按钮通知转换为 Web Agent 选择卡片事件。 + + :param message: Agent 工具发出的按钮通知 + :return: 选择卡片事件,按钮为空时返回 None + """ + 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 + + choice_id = None + parsed = parse_agent_choice_callback(buttons[0]["callback_data"]) + if parsed: + choice_id = parsed[0] + + return { + "type": "choice", + "choice": { + "id": choice_id or uuid.uuid4().hex, + "title": message.title, + "prompt": message.text or "", + "buttons": buttons, + "button_rows": button_rows, + }, + } + + +def resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optional[dict[str, Any]]: + """ + 解析并消费 Web Agent 按钮选择,生成前端反馈与下一条用户消息。 + + :param callback_data: 前端点击的按钮回调数据 + :param user_id: 当前登录用户 ID + :return: 可返回给前端的数据,选择无效时返回 None + """ + parsed = parse_agent_choice_callback(callback_data) + if not parsed: + return None + + request_id, option_index = parsed + resolved = agent_interaction_manager.resolve( + request_id=request_id, + option_index=option_index, + user_id=str(user_id), + ) + if not resolved: + return None + + request, option = resolved + buttons, button_rows = build_agent_choice_button_rows(request) + selected_description = option.description or option.label + return { + "message": option.value, + "session_id": request.session_id, + "display_message": selected_description, + "choice_selection": { + "choice_id": request.request_id, + "title": request.title, + "prompt": request.prompt, + "buttons": buttons, + "button_rows": button_rows, + "selected_label": option.label, + "selected_value": option.value, + "selected_description": selected_description, + }, + "feedback": { + "request_id": request.request_id, + "title": request.title, + "prompt": request.prompt, + "selected_label": option.label, + "selected_value": option.value, + "selected_description": selected_description, + "buttons": buttons, + "button_rows": button_rows, + }, + } + + +def build_web_agent_message_events( + message: Message, + *, + prepared_audio_path: Optional[Path] = None, +) -> list[dict[str, Any]]: + """ + 将 Agent 工具通知转换为 Web SSE 事件。 + + :param message: 工具产生的通知消息 + :return: 前端可直接应用到当前助手消息的事件列表 + """ + events = [] + choice_event = build_web_agent_choice_event(message) + if choice_event: + events.append(choice_event) + + text_parts = [str(item).strip() 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 message.image: + image_ref = message.image + image_path = Path(image_ref).expanduser() + attachment = None + if not image_ref.startswith(("http://", "https://", "data:", "blob:")): + attachment = register_web_agent_file(image_ref, file_name=Path(image_ref).name, kind="image") + if not attachment: + attachment = build_web_agent_url_attachment( + image_ref, + kind="image", + name=message.title or image_path.name or "image", + ) + events.append({"type": "attachment", "attachment": attachment}) + + if message.voice_path: + audio_path = prepared_audio_path or Path(message.voice_path) + attachment = register_web_agent_file( + str(audio_path), + file_name=audio_path.name, + kind="audio", + mime_type=get_web_agent_audio_mime_type(audio_path), + ) + if attachment: + events.append({"type": "attachment", "attachment": attachment}) + + if message.file_path: + attachment = register_web_agent_file( + message.file_path, + file_name=message.file_name or Path(message.file_path).name, + ) + if attachment: + events.append({"type": "attachment", "attachment": attachment}) + + return events + + +async def build_web_agent_message_events_async( + message: Message, +) -> list[dict[str, Any]]: + """异步构造 WebAgent 通知,确保语音转码不占用事件循环。""" + prepared_audio_path = None + if message.voice_path: + prepared_audio_path = await prepare_web_agent_audio_attachment_path_async(message.voice_path) + return build_web_agent_message_events( + message, + prepared_audio_path=prepared_audio_path, + ) + + +def build_web_agent_display_message_from_events( + events: list[dict[str, Any]], +) -> dict[str, Any]: + """ + 将传统消息事件聚合为前端展示消息快照。 + + :param events: 已转换的 WebAgent SSE 事件列表 + :return: 可持久化的助手展示消息 + """ + message = build_web_agent_display_message( + role="assistant", + status="streaming", + ) + for event in events: + apply_web_agent_display_event(copy.deepcopy(event), message) + apply_web_agent_display_event({"type": "done"}, message) + return message + + +def split_web_agent_output(text: str) -> list[dict[str, Any]]: + """ + 将 Agent 输出拆成普通文本与工具提示事件。 + + :param text: 本次新增的 Agent 文本 + :return: 前端可直接渲染的事件片段 + """ + if not text: + return [] + + events = [] + + def append_text(content: str) -> None: + """将工具汇总行从普通文本中拆出,保留与消息渠道一致的展示文案。""" + if not content: + return + lines = content.splitlines(keepends=True) + buffer = "" + for line in lines: + stripped_line = line.strip() + if stripped_line.startswith("(") and stripped_line.endswith(")"): + if buffer: + events.append({"type": "delta", "content": buffer}) + buffer = "" + events.append( + { + "type": "tool", + "message": stripped_line, + } + ) + else: + buffer += line + if buffer: + events.append({"type": "delta", "content": buffer}) + + marker = "⚙️ => " + remaining = text + while remaining: + marker_index = remaining.find(marker) + if marker_index < 0: + append_text(remaining) + break + + if marker_index > 0: + append_text(remaining[:marker_index]) + + after_marker = remaining[marker_index + len(marker) :] + line_end = after_marker.find("\n") + if line_end < 0: + message = after_marker.strip() + remaining = "" + else: + message = after_marker[:line_end].strip() + remaining = after_marker[line_end:].lstrip("\n") + + if message: + events.append({"type": "tool", "message": f"{marker}{message}"}) + + return events + + +def is_web_agent_traditional_message(text: str) -> bool: + """ + 判断用户输入是否应走传统消息命令/交互链路。 + + :param text: 前端输入文本 + :return: 需要交给 MessageChain 时返回 True + """ + normalized = str(text or "").strip() + return normalized.startswith("/") or normalized.startswith("CALLBACK:") + + +def has_web_agent_traditional_interaction(user_id: str) -> bool: + """ + 判断当前用户是否存在待继续的传统交互会话。 + + :param user_id: 当前登录用户 ID + :return: 存在传统交互上下文时返回 True + """ + return bool(has_pending_interaction(user_id)) + + +def build_web_agent_command_items() -> list[dict[str, Any]]: + """ + 读取当前可用斜杠命令并转换为前端建议列表。 + + :return: 按分类和命令名排序的命令列表 + """ + commands = get_commands() or {} + items = [] + for command, data in commands.items(): + if not command.startswith("/"): + continue + if data.get("show") is False: + continue + items.append( + { + "command": command, + "description": data.get("description") or "", + "category": data.get("category") or "其他", + "type": data.get("type") or "", + "pid": data.get("pid"), + } + ) + return sorted(items, key=lambda item: (item["category"], item["command"])) + + +def extract_web_agent_slash_command(text: str) -> Optional[str]: + """ + 从 WebAgent 输入中提取斜杠命令名。 + + :param text: 前端输入文本 + :return: 斜杠命令名,非命令输入返回 None + """ + normalized = str(text or "").strip() + if not normalized.startswith("/") or normalized.startswith("//"): + return None + command = normalized.split(maxsplit=1)[0].strip() + return command or None + + +def get_web_agent_unknown_command_message(text: str) -> Optional[str]: + """ + 判断 WebAgent 斜杠命令是否不存在。 + + :param text: 前端输入文本 + :return: 命令不存在时返回错误提示,命令存在或非命令时返回 None + """ + command = extract_web_agent_slash_command(text) + if not command: + return None + if get_command(command): + return None + return f"命令不存在:{command}" + + +def ensure_web_agent_command_allowed(current_user: AgentChatPrincipal) -> Optional[str]: + """ + 校验当前 Web 用户是否可以执行传统斜杠命令。 + + :param current_user: 当前登录用户 + :return: 无权限时返回错误提示,允许执行时返回 None + """ + if getattr(current_user, "is_superuser", False): + return None + return "只有管理员才有权限执行此命令" + + +async def collect_web_agent_traditional_events( + *, + text: str, + current_user: AgentChatPrincipal, + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[Union[str, int]] = None, +) -> list[dict[str, Any]]: + """ + 执行传统消息链路并收集本次 WebAgent 用户产生的通知事件。 + + :param text: 需要交给传统消息链路处理的文本 + :param current_user: 当前登录用户 + :param original_message_id: WebAgent 原助手消息 ID + :param original_chat_id: WebAgent 原聊天 ID + :return: 可直接发送给前端的 SSE 事件列表 + """ + message_queue: Queue[Message] = Queue() + edit_queue: Queue[dict[str, Any]] = Queue() + user_id = str(current_user.id) + + attach_web_agent_message_queue(user_id, message_queue) + attach_web_agent_edit_queue(user_id, edit_queue) + try: + await run_in_threadpool( + _handle_web_agent_message, + channel=NotificationChannel.WebAgent, + source=WEB_AGENT_SOURCE, + userid=user_id, + username=current_user.name or user_id, + text=text, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + + events = [] + deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS + idle_deadline: Optional[float] = None + while time.monotonic() < deadline: + now = time.monotonic() + drained_edit_event = False + while True: + try: + events.append(edit_queue.get_nowait()) + drained_edit_event = True + except Empty: + break + if drained_edit_event: + idle_deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS + continue + + wait_until = idle_deadline or deadline + timeout = max(0.05, min(0.25, wait_until - now, deadline - now)) + try: + 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_message_for_user(message, user_id): + continue + events.extend(await build_web_agent_message_events_async(message)) + idle_deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS + return events + finally: + detach_web_agent_message_queue(user_id, message_queue) + detach_web_agent_edit_queue(user_id, edit_queue) + + +def build_web_agent_traditional_callback_payload( + callback_data: str, + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[Union[str, int]] = None, +) -> dict[str, Any]: + """ + 构造传统消息链按钮回调的前端执行载荷。 + + :param callback_data: 前端点击的传统按钮回调数据 + :param original_message_id: WebAgent 原助手消息 ID + :param original_chat_id: WebAgent 原聊天 ID + :return: 前端可继续发送到 /stream 的消息载荷 + """ + return { + "message": f"CALLBACK:{callback_data}", + "display_message": callback_data, + "traditional": True, + "original_message_id": original_message_id, + "original_chat_id": original_chat_id, + } + + +async def _single_web_agent_event(event: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + """把前置校验结果包装成单事件异步流。""" + yield event + + +def _build_traditional_web_agent_stream( + *, + command: WebAgentStreamCommand, + current_user: AgentChatPrincipal, + session_id: str, + prompt: str, + display_prompt: str, + service: AgentChatService, + persistence: AgentChatPersistenceService, + is_disconnected: Callable[[], Awaitable[bool]], +) -> AsyncIterator[dict[str, Any]]: + """构造传统消息链路的 WebAgent 事件流。""" + user_attachments = build_web_agent_input_attachments( + images=command.images, + files=command.files, + audio_refs=command.audio_refs, + ) + display_messages = [] + if command.echo_user: + display_messages.append( + build_web_agent_display_message( + role="user", + content=display_prompt or prompt, + attachments=user_attachments, + ) + ) + + async def event_generator() -> AsyncIterator[dict[str, Any]]: + """等待传统消息结果并投影为传输无关事件。""" + yield {"type": "start", "session_id": session_id} + collection_task = asyncio.create_task( + collect_web_agent_traditional_events( + text=prompt, + current_user=current_user, + original_message_id=command.original_message_id, + original_chat_id=command.original_chat_id, + ) + ) + try: + while True: + try: + events = await asyncio.wait_for( + asyncio.shield(collection_task), + timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS, + ) + break + except asyncio.TimeoutError: + if await is_disconnected(): + collection_task.cancel() + return + yield {"type": "heartbeat"} + except asyncio.CancelledError: + return + finally: + if not collection_task.done(): + collection_task.cancel() + await asyncio.gather(collection_task, return_exceptions=True) + + display_messages.append(build_web_agent_display_message_from_events(events)) + + async def save_display_snapshot() -> None: + """后台保存传统消息展示快照,不阻塞事件流终态。""" + try: + await save_web_agent_display_snapshot( + session_id=session_id, + current_user=current_user, + messages=display_messages, + client_session_id=command.session_id or session_id, + service=service, + persistence=persistence, + ) + except Exception as err: + logger.error(f"保存WebAgent传统消息快照失败: {str(err)}") + + create_web_agent_background_task(save_display_snapshot()) + await asyncio.sleep(0) + for event in events: + yield copy.deepcopy(event) + if await is_disconnected(): + return + yield {"type": "done"} + + return event_generator() + + +def _build_agent_web_agent_stream( + *, + command: WebAgentStreamCommand, + current_user: AgentChatPrincipal, + session_id: str, + prompt: str, + display_prompt: str, + has_audio_input: bool, + is_secret_confirmation_control: bool, + protected_transport_supported: bool, + service: AgentChatService, + persistence: AgentChatPersistenceService, + is_disconnected: Callable[[], Awaitable[bool]], +) -> AsyncIterator[dict[str, Any]]: + """构造标准 Agent 执行链路的 WebAgent 事件流。""" + bind_web_agent_user_session(str(current_user.id), session_id) + event_publisher = WebAgentEventPublisher() + user_attachments = build_web_agent_input_attachments( + images=command.images, + files=command.files, + audio_refs=command.audio_refs, + ) + display_messages = [] + if command.echo_user and not is_secret_confirmation_control: + user_display_message = build_web_agent_display_message( + role="user", + content=display_prompt or prompt, + attachments=user_attachments, + ) + if command.choice_selection: + user_display_message["choice_selection"] = command.choice_selection + display_messages.append(user_display_message) + assistant_display_message = build_web_agent_display_message( + role="assistant", + status="streaming", + ) + display_messages.append(assistant_display_message) + + def output_callback(delta: str) -> None: + """接收 Agent 文本增量并投影为展示事件。""" + for item in split_web_agent_output(delta): + apply_web_agent_display_event(item, assistant_display_message) + event_publisher.publish(item) + + async def message_callback(message: Message) -> None: + """接收 Agent 工具主动发送的 Web 通知。""" + for item in await build_web_agent_message_events_async(message): + apply_web_agent_display_event(item, assistant_display_message) + event_publisher.publish(item) + + def protected_output_callback(content: str) -> bool: + """发布不进入普通展示快照的敏感交互结果。""" + return event_publisher.publish({"type": "interaction-protected", "content": content}) + + async def event_generator() -> AsyncIterator[dict[str, Any]]: + """执行 Agent 并按断线与终态语义消费事件。""" + audio_ref_set = set(command.audio_refs) + files = [file for file in command.files if str(file.get("ref") or "") not in audio_ref_set] + files.extend({"ref": audio_ref, "mime_type": "audio/*"} for audio_ref in command.audio_refs) + + async def run_agent() -> None: + """后台执行 Agent,并在完成后持久化展示快照。""" + try: + runtime_manager = agent_application.get_running_agent_manager() + if runtime_manager is None: + raise RuntimeError("智能助手服务尚未就绪,请稍后重试。") + await runtime_manager.process_message( + session_id=session_id, + user_id=str(current_user.id), + message=prompt, + images=command.images, + files=files or None, + has_audio_input=has_audio_input, + channel=NotificationChannel.WebAgent.value, + source=WEB_AGENT_SOURCE, + username=current_user.name, + reply_mode=ReplyMode.CAPTURE_ONLY, + allow_message_tools=True, + output_callback=output_callback, + protected_output_callback=(protected_output_callback if protected_transport_supported else None), + message_callback=message_callback, + agent_factory=agent_application.get_web_agent_type(), + wait_for_completion=True, + ) + except asyncio.CancelledError: + # 显式停止会话沿用正常终止语义;服务关闭由 manager 的稳定异常分支处理。 + pass + except Exception as err: + logger.error(f"Web智能助手执行失败: {str(err)}") + error_event = { + "type": "error", + "message": f"智能助手执行失败: {str(err)}", + } + apply_web_agent_display_event(error_event, assistant_display_message) + event_publisher.publish(error_event) + finally: + done_event = {"type": "done"} + apply_web_agent_display_event(done_event, assistant_display_message) + # 终态先进入事件队列,避免展示快照落库延迟前端结束动画。 + event_publisher.publish(done_event) + if not is_secret_confirmation_control: + try: + await save_web_agent_display_snapshot( + session_id=session_id, + current_user=current_user, + messages=display_messages, + client_session_id=command.session_id or session_id, + service=service, + persistence=persistence, + ) + except Exception as err: + logger.error(f"保存WebAgent展示历史失败:{err}") + + task = create_web_agent_background_task(run_agent()) + disconnected = False + terminal_sent = False + try: + yield {"type": "start", "session_id": session_id} + while not runtime_stop_state.is_system_stopped: + if await is_disconnected(): + disconnected = True + break + try: + event = await asyncio.wait_for( + event_publisher.get(), + timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS, + ) + except asyncio.TimeoutError: + yield {"type": "heartbeat"} + continue + if event.get("type") == "done": + terminal_sent = True + yield event + if event.get("type") == "done": + break + except asyncio.CancelledError: + disconnected = True + return + finally: + if not task.done() and not disconnected and not terminal_sent: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + await event_publisher.aclose() + # 客户端断线后保留 Agent 继续执行;发布器关闭后拒绝受保护结果。 + + return event_generator() + + +async def build_web_agent_stream( + command: WebAgentStreamCommand, + *, + current_user: AgentChatPrincipal, + is_disconnected: Callable[[], Awaitable[bool]], + protected_transport_supported: bool, + service: Optional[AgentChatService] = None, + persistence: Optional[AgentChatPersistenceService] = None, +) -> WebAgentStreamResult: + """编排 WebAgent 会话、传统消息、音频输入、事件投影与展示持久化。""" + prompt = command.text.strip() + display_prompt = (command.display_text or command.text).strip() + service = service or get_configured_agent_chat_service() + persistence = persistence or get_configured_agent_chat_persistence() + session_id = await build_web_agent_session_id_async( + current_user, + command.session_id, + service, + ) + is_secret_confirmation_candidate = ( + prompt in {"确认", "取消"} and not command.images and not command.audio_refs and not command.files + ) + manager = agent_application.get_running_agent_manager() + is_secret_confirmation_control = ( + is_secret_confirmation_candidate + and manager is not None + and manager.matches_secret_confirmation( + session_id, + str(current_user.id), + channel=NotificationChannel.WebAgent.value, + source=WEB_AGENT_SOURCE, + ) + ) + if is_secret_confirmation_control and not protected_transport_supported: + return WebAgentStreamResult( + events=_single_web_agent_event( + { + "type": "error", + "message": "当前客户端不支持安全交付敏感设置,未执行操作。", + } + ) + ) + + is_traditional_message = is_web_agent_traditional_message(prompt) or has_web_agent_traditional_interaction( + str(current_user.id) + ) + if is_traditional_message: + denied_message = ensure_web_agent_command_allowed(current_user) + if denied_message: + return WebAgentStreamResult(events=_single_web_agent_event({"type": "error", "message": denied_message})) + unknown_command_message = get_web_agent_unknown_command_message(prompt) + if unknown_command_message: + return WebAgentStreamResult( + events=_single_web_agent_event({"type": "error", "message": unknown_command_message}) + ) + return WebAgentStreamResult( + events=_build_traditional_web_agent_stream( + command=command, + current_user=current_user, + session_id=session_id, + prompt=prompt, + display_prompt=display_prompt, + service=service, + persistence=persistence, + is_disconnected=is_disconnected, + ) + ) + + if not get_api_runtime_config_snapshot().ai_agent_enable: + return WebAgentStreamResult( + events=_single_web_agent_event({"type": "error", "message": "智能助手未启用,请先在系统设置中开启。"}) + ) + if manager is None: + return WebAgentStreamResult( + events=_single_web_agent_event( + { + "type": "error", + "message": "智能助手服务尚未就绪,请稍后重试。", + } + ) + ) + + transcript = await transcribe_web_agent_audio_input(command.audio_refs) + prompt = merge_web_agent_prompt_with_transcript(prompt, transcript) + display_prompt = merge_web_agent_prompt_with_transcript(display_prompt, transcript) + if not prompt and command.audio_refs and not command.images and not command.files: + return WebAgentStreamResult( + events=_single_web_agent_event({"type": "error", "message": "语音识别失败,请稍后重试。"}) + ) + if not prompt and not command.images and not command.files and not command.audio_refs: + return WebAgentStreamResult( + events=_single_web_agent_event( + { + "type": "error", + "message": "请输入要发送给智能助手的内容或选择附件。", + } + ) + ) + + return WebAgentStreamResult( + events=_build_agent_web_agent_stream( + command=command, + current_user=current_user, + session_id=session_id, + prompt=prompt, + display_prompt=display_prompt, + has_audio_input=bool(transcript), + is_secret_confirmation_control=is_secret_confirmation_control, + protected_transport_supported=protected_transport_supported, + service=service, + persistence=persistence, + is_disconnected=is_disconnected, + ), + control=("secret-confirmation" if is_secret_confirmation_control else None), + ) diff --git a/app/application/messaging/chat.py b/app/application/messaging/chat.py index 8ecbb928b..34ee559b5 100644 --- a/app/application/messaging/chat.py +++ b/app/application/messaging/chat.py @@ -27,9 +27,20 @@ def has_custom_agent_chat_title(value: Optional[str]) -> bool: class AgentChatPrincipal(Protocol): """会话访问控制所需的最小用户身份。""" - id: Any - name: Optional[str] - is_superuser: bool + @property + def id(self) -> Any: + """返回稳定用户标识。""" + ... + + @property + def name(self) -> Optional[str]: + """返回可选用户显示名。""" + ... + + @property + def is_superuser(self) -> bool: + """返回是否具备超级管理员权限。""" + ... class AsyncAgentChatRepository(Protocol): diff --git a/app/application/network.py b/app/application/network.py index b292f4a11..f173ab717 100644 --- a/app/application/network.py +++ b/app/application/network.py @@ -1,88 +1,528 @@ """受控外部网络探测应用服务。""" -from datetime import datetime -from typing import Any, Callable, Optional +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from time import monotonic +from typing import Any, Optional, Protocol from urllib.parse import urljoin, urlparse +from app.foundation.url import UrlUtils + +_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308} +_MAX_REDIRECTS = 3 + + +class NetworkTestResponse(Protocol): + """网络探测所需的最小异步响应合同。""" + + status_code: int + headers: Mapping[str, str] + text: str + + async def aclose(self) -> None: + """释放响应占用的传输资源。""" + + +class NetworkTestTransport(Protocol): + """网络探测使用的受限 HTTPS GET 端口。""" + + async def get( + self, + url: str, + *, + proxy: Any = None, + headers: Optional[Mapping[str, str]] = None, + user_agent: Optional[str] = None, + ) -> Optional[NetworkTestResponse]: + """关闭自动重定向并校验证书后请求指定 HTTPS 地址。""" + + +class NetworkTestLogger(Protocol): + """网络探测所需的最小日志端口。""" + + def debug(self, msg: str, *args: Any, **kwargs: Any) -> None: + """记录不影响探测结果的诊断信息。""" + + def warning(self, msg: str, *args: Any, **kwargs: Any) -> None: + """记录被安全策略阻断的网络行为。""" + + +@dataclass(frozen=True, slots=True) +class NetworkTestTarget: + """可安全返回给客户端的网络测试目标投影。""" + + id: str + name: str + icon: str + + +@dataclass(frozen=True, slots=True) +class NetworkTestRule: + """仅由宿主构造、不会向客户端暴露的完整网络测试规则。""" + + id: str + name: str + icon: str + url: str + proxy: bool + allowed_redirect_prefixes: tuple[str, ...] + expected_text: Optional[str] = None + invalid_message: Optional[str] = None + proxy_name: Optional[str] = None + headers: tuple[tuple[str, str], ...] = () + + def public_target(self) -> NetworkTestTarget: + """投影客户端展示字段,避免泄露 URL、凭据和准入规则。""" + return NetworkTestTarget(id=self.id, name=self.name, icon=self.icon) + + def request_headers(self) -> Optional[dict[str, str]]: + """为单次请求创建与冻结规则隔离的请求头字典。""" + return dict(self.headers) or None + + +@dataclass(frozen=True, slots=True) +class NetworkTestResult: + """网络测试应用结果,不包含 HTTP 响应层对象。""" + + success: bool + message: Optional[str] = None + elapsed_ms: Optional[int] = None + + +SettingsReader = Callable[[str, Any], Any] + class NetworkTestService: - """执行服务端预定义目标的 HTTPS 连通性测试。""" + """维护目标目录、安全准入并执行服务端受控连通性测试。""" def __init__( self, - request_utils_cls: Callable[..., Any], - settings_getter: Callable[[], Any], - logger: Any, - redirect_checker: Callable[[str, dict[str, Any]], bool], - close_response: Callable[[Any], Any], - ): - """注入网络客户端、配置和安全边界,方便隔离测试。""" - self.request_utils_cls = request_utils_cls - self.settings_getter = settings_getter - self.logger = logger - self.redirect_checker = redirect_checker - self.close_response = close_response + *, + transport: NetworkTestTransport, + settings: SettingsReader, + logger: NetworkTestLogger, + ) -> None: + """注入通用传输、部署设置读取和运行日志端口。""" + self._transport = transport + self._settings = settings + self._logger = logger + + def list_targets(self) -> tuple[NetworkTestTarget, ...]: + """返回不含请求细节的当前网络测试目标目录。""" + return tuple(rule.public_target() for rule in self._build_rules()) async def execute( self, - target: dict[str, Any], + *, + target_id: Optional[str] = None, + url: Optional[str] = None, include: Optional[str] = None, - ) -> tuple[bool, Optional[str], Optional[dict[str, int]]]: - """请求目标并处理受控重定向,返回旧端点使用的结果三元组。""" - start_time = datetime.now() - url = target["url"] - parsed = urlparse(url) - if parsed.scheme.lower() != "https" or not parsed.netloc or parsed.username or parsed.password: - return False, "测试地址无效", {"time": 0} + ) -> NetworkTestResult: + """解析内置目标、执行安全校验并返回连通性测试结果。""" + target = self._find_rule(target_id=target_id, url=url) + if target is None: + return NetworkTestResult(success=False, message="测试目标不存在") + invalid_message = self._validate_rule(target) + if invalid_message: + self._logger.warning(f"拦截不安全的网络测试地址: {target.url}") + return NetworkTestResult(success=False, message=invalid_message) if include: - self.logger.debug("nettest include 参数已忽略,改为服务端固定校验") + self._logger.debug("nettest include 参数已忽略,改为服务端固定校验") + return await self._request(target) - settings = self.settings_getter() - request_utils = self.request_utils_cls( - proxies=settings.get("PROXY") if target.get("proxy") else None, - headers=target.get("headers"), - timeout=10, - ua=settings.get("NORMAL_USER_AGENT"), - verify=True, - follow_redirects=False, - ) - result = None - current_url = url + async def _request(self, target: NetworkTestRule) -> NetworkTestResult: + """手动处理受控重定向并校验最终响应。""" + started_at = monotonic() + current_url = target.url redirect_count = 0 - while redirect_count <= 3: - result = await request_utils.get_res(current_url, allow_redirects=False) - if result is None or result.status_code not in {301, 302, 303, 307, 308}: + response: Optional[NetworkTestResponse] = None + while redirect_count <= _MAX_REDIRECTS: + response = await self._transport.get( + current_url, + proxy=self._settings("PROXY", None) if target.proxy else None, + headers=target.request_headers(), + user_agent=self._settings("NORMAL_USER_AGENT", None), + ) + if response is None or response.status_code not in _REDIRECT_STATUS_CODES: break - location = result.headers.get("location") + location = response.headers.get("location") if not location: break next_url = urljoin(current_url, location) - if not self.redirect_checker(next_url, target): - await self.close_response(result) - self.logger.warning(f"拦截网络测试重定向: {current_url} -> {next_url}") - return False, "测试目标发生了未授权跳转", None - await self.close_response(result) + if not self._is_allowed_redirect(next_url, target): + await self._close_response(response) + self._logger.warning( + f"拦截网络测试重定向: {current_url} -> {next_url}" + ) + return NetworkTestResult( + success=False, + message="测试目标发生了未授权跳转", + ) + await self._close_response(response) + response = None current_url = next_url redirect_count += 1 - elapsed = round((datetime.now() - start_time).total_seconds() * 1000) - timing = {"time": elapsed} - if redirect_count > 3: - return False, "测试目标重定向次数过多", None - if result is None: - return False, f"{target.get('proxy_name') or target.get('name')}无法连接", timing - if result.status_code == 200: - expected_text = target.get("expected_text") - if expected_text and expected_text.lower() not in (result.text or "").lower(): - return False, target.get("invalid_message") or "无效响应", timing - return True, None, timing - if target.get("proxy_name"): - message = f"{target['proxy_name']}已失效,错误码:{result.status_code}" + elapsed_ms = round((monotonic() - started_at) * 1000) + if redirect_count > _MAX_REDIRECTS: + await self._close_response(response) + return NetworkTestResult(success=False, message="测试目标重定向次数过多") + if response is None: + label = target.proxy_name or target.name + return NetworkTestResult( + success=False, + message=f"{label}无法连接", + elapsed_ms=elapsed_ms, + ) + + try: + return self._build_response_result(target, response, elapsed_ms) + finally: + await self._close_response(response) + + @staticmethod + def _build_response_result( + target: NetworkTestRule, + response: NetworkTestResponse, + elapsed_ms: int, + ) -> NetworkTestResult: + """把传输响应归一为稳定的应用结果。""" + if response.status_code == 200: + if target.expected_text and target.expected_text.lower() not in ( + response.text or "" + ).lower(): + return NetworkTestResult( + success=False, + message=target.invalid_message or "无效响应", + elapsed_ms=elapsed_ms, + ) + return NetworkTestResult(success=True, elapsed_ms=elapsed_ms) + if target.proxy_name: + message = f"{target.proxy_name}已失效,错误码:{response.status_code}" else: - message = f"错误码:{result.status_code}" - if "github" in url: - if result.status_code == 401: + message = f"错误码:{response.status_code}" + if "github" in target.url: + if response.status_code == 401: message = "Github Token已失效,请检查配置" - elif result.status_code in {403, 429}: + elif response.status_code in {403, 429}: message = "触发限流,请配置Github Token" - return False, message, timing + return NetworkTestResult( + success=False, + message=message, + elapsed_ms=elapsed_ms, + ) + + def _find_rule( + self, + *, + target_id: Optional[str], + url: Optional[str], + ) -> Optional[NetworkTestRule]: + """优先按正式 target_id、否则按旧 URL 精确匹配内置规则。""" + rules = self._build_rules() + if target_id: + return next((rule for rule in rules if rule.id == target_id), None) + if url: + return next((rule for rule in rules if rule.url == url), None) + return None + + def _validate_rule(self, target: NetworkTestRule) -> Optional[str]: + """兜底校验服务端规则未生成非 HTTPS、带凭据或目录外地址。""" + parsed = urlparse(target.url) + if parsed.scheme.lower() != "https": + return "测试地址仅支持 HTTPS" + if not parsed.netloc: + return "测试地址无效" + if parsed.username or parsed.password: + return "测试地址不支持携带账号信息" + if not any(rule == target for rule in self._build_rules()): + return "测试地址不在允许的测试目标列表中" + return None + + @classmethod + def _is_allowed_redirect(cls, url: str, target: NetworkTestRule) -> bool: + """只允许重定向到当前测试项声明的协议、主机、端口和路径。""" + parsed = urlparse(url) + if parsed.scheme.lower() != "https" or not parsed.netloc: + return False + if parsed.username or parsed.password: + return False + return any( + cls._matches_prefix(url, prefix) + for prefix in target.allowed_redirect_prefixes + ) + + @staticmethod + def _matches_prefix(url: str, prefix: str) -> bool: + """按协议、主机、有效端口和路径前缀匹配允许的跳转范围。""" + parsed_url = urlparse(url) + parsed_prefix = urlparse(prefix) + if parsed_url.scheme.lower() != parsed_prefix.scheme.lower(): + return False + if (parsed_url.hostname or "").lower() != ( + parsed_prefix.hostname or "" + ).lower(): + return False + try: + url_port = parsed_url.port or ( + 443 if parsed_url.scheme.lower() == "https" else 80 + ) + prefix_port = parsed_prefix.port or ( + 443 if parsed_prefix.scheme.lower() == "https" else 80 + ) + except ValueError: + return False + if url_port != prefix_port: + return False + prefix_path = parsed_prefix.path or "/" + if prefix_path.endswith("/"): + return parsed_url.path.startswith(prefix_path) + return parsed_url.path == prefix_path or parsed_url.path.startswith( + f"{prefix_path}/" + ) + + async def _close_response( + self, + response: Optional[NetworkTestResponse], + ) -> None: + """安静释放已读取响应,关闭失败不覆盖真实探测结果。""" + if response is None or not hasattr(response, "aclose"): + return + try: + await response.aclose() + except Exception as err: # noqa: BLE001 - 资源回收失败仅记录诊断 + self._logger.debug(f"关闭网络测试响应失败: {err}") + + def _build_rules(self) -> tuple[NetworkTestRule, ...]: + """根据当前部署设置构建唯一网络测试目录和安全准入规则。""" + github_proxy = UrlUtils.standardize_base_url( + self._settings("GITHUB_PROXY", None) or "" + ) + pip_proxy = UrlUtils.standardize_base_url( + self._settings("PIP_PROXY", None) or "https://pypi.org/simple/" + ) + tmdb_key = self._settings("TMDB_API_KEY", None) + tmdb_domain = ( + self._settings("TMDB_API_DOMAIN", None) or "api.themoviedb.org" + ) + github_headers = self._freeze_headers( + self._settings("GITHUB_HEADERS", None) + ) + github_readme_url = "https://github.com/jxxghp/MoviePilot/blob/v2/README.md" + raw_readme_url = ( + "https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/README.md" + ) + rules = [ + NetworkTestRule( + id="tmdb_api", + name="api.themoviedb.org", + icon="tmdb", + url=f"https://api.themoviedb.org/3/movie/550?api_key={tmdb_key}", + proxy=True, + allowed_redirect_prefixes=("https://api.themoviedb.org/3/",), + ), + NetworkTestRule( + id="tmdb_api_alt", + name="api.tmdb.org", + icon="tmdb", + url=f"https://api.tmdb.org/3/movie/550?api_key={tmdb_key}", + proxy=True, + allowed_redirect_prefixes=("https://api.tmdb.org/3/",), + ), + NetworkTestRule( + id="tmdb_web", + name="www.themoviedb.org", + icon="tmdb", + url="https://www.themoviedb.org", + proxy=True, + allowed_redirect_prefixes=("https://www.themoviedb.org/",), + ), + NetworkTestRule( + id="tvdb_api", + name="api.thetvdb.com", + icon="tvdb", + url="https://api.thetvdb.com/series/81189", + proxy=True, + allowed_redirect_prefixes=("https://api.thetvdb.com/",), + ), + NetworkTestRule( + id="fanart_api", + name="webservice.fanart.tv", + icon="fanart", + url="https://webservice.fanart.tv", + proxy=True, + allowed_redirect_prefixes=("https://webservice.fanart.tv/",), + ), + NetworkTestRule( + id="telegram_api", + name="api.telegram.org", + icon="telegram", + url="https://api.telegram.org", + proxy=True, + allowed_redirect_prefixes=( + "https://api.telegram.org/", + "https://core.telegram.org/", + ), + ), + NetworkTestRule( + id="wechat_api", + name="qyapi.weixin.qq.com", + icon="wechat", + url="https://qyapi.weixin.qq.com/cgi-bin/gettoken", + proxy=False, + allowed_redirect_prefixes=("https://qyapi.weixin.qq.com/",), + ), + NetworkTestRule( + id="douban_api", + name="frodo.douban.com", + icon="douban", + url="https://frodo.douban.com", + proxy=False, + allowed_redirect_prefixes=( + "https://frodo.douban.com/", + "https://www.douban.com/doubanapp/frodo", + ), + ), + NetworkTestRule( + id="slack_api", + name="slack.com", + icon="slack", + url="https://slack.com", + proxy=False, + allowed_redirect_prefixes=( + "https://slack.com/", + "https://www.slack.com/", + ), + ), + NetworkTestRule( + id="pip_proxy", + name="pypi.org", + icon="python", + url=f"{pip_proxy}rsa/", + proxy=True, + allowed_redirect_prefixes=( + pip_proxy, + "https://pypi.org/simple/", + ), + expected_text="pypi:repository-version", + invalid_message="PIP加速代理已失效,请检查配置", + proxy_name="PIP加速代理", + ), + NetworkTestRule( + id="github_proxy_web", + name="github.com", + icon="github", + url=( + f"{github_proxy}{github_readme_url}" + if github_proxy + else github_readme_url + ), + proxy=True, + allowed_redirect_prefixes=( + "https://github.com/", + *( + (f"{github_proxy}https://github.com/",) + if github_proxy + else () + ), + ), + expected_text="MoviePilot", + invalid_message=( + "Github加速代理已失效,请检查配置" + if github_proxy + else "无效响应" + ), + proxy_name="Github加速代理" if github_proxy else None, + headers=github_headers, + ), + NetworkTestRule( + id="github_api", + name="api.github.com", + icon="github", + url="https://api.github.com", + proxy=True, + allowed_redirect_prefixes=("https://api.github.com/",), + headers=github_headers, + ), + NetworkTestRule( + id="github_codeload", + name="codeload.github.com", + icon="github", + url="https://codeload.github.com", + proxy=True, + allowed_redirect_prefixes=( + "https://codeload.github.com/", + "https://github.com/", + ), + headers=github_headers, + ), + NetworkTestRule( + id="github_proxy_raw", + name="raw.githubusercontent.com", + icon="github", + url=( + f"{github_proxy}{raw_readme_url}" + if github_proxy + else raw_readme_url + ), + proxy=True, + allowed_redirect_prefixes=( + "https://raw.githubusercontent.com/", + *( + (f"{github_proxy}https://raw.githubusercontent.com/",) + if github_proxy + else () + ), + ), + expected_text="MoviePilot", + invalid_message=( + "Github加速代理已失效,请检查配置" + if github_proxy + else "无效响应" + ), + proxy_name="Github加速代理" if github_proxy else None, + headers=github_headers, + ), + ] + if tmdb_domain not in {"api.themoviedb.org", "api.tmdb.org"}: + rules.insert( + 2, + NetworkTestRule( + id="tmdb_api_configured", + name=str(tmdb_domain), + icon="tmdb", + url=f"https://{tmdb_domain}/3/movie/550?api_key={tmdb_key}", + proxy=True, + allowed_redirect_prefixes=( + f"https://{tmdb_domain}/3/", + ), + ), + ) + return tuple(rules) + + @staticmethod + def _freeze_headers(value: Any) -> tuple[tuple[str, str], ...]: + """把动态配置中的请求头复制为不可变、可比较的规则字段。""" + if not isinstance(value, Mapping): + return () + return tuple( + (str(key), str(item)) + for key, item in value.items() + if item is not None + ) + + +_configured_network_test_service: Optional[NetworkTestService] = None + + +def configure_network_test_service(service: NetworkTestService) -> None: + """由启动组合根登记唯一的网络测试应用服务。""" + global _configured_network_test_service + _configured_network_test_service = service + + +def get_configured_network_test_service() -> NetworkTestService: + """返回启动阶段登记的网络测试应用服务。""" + if _configured_network_test_service is None: + raise RuntimeError("网络测试服务尚未装配") + return _configured_network_test_service diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py index 4d0f6ad67..4fefcfe97 100644 --- a/app/application/plugin/catalog.py +++ b/app/application/plugin/catalog.py @@ -5,15 +5,21 @@ from __future__ import annotations import asyncio import concurrent.futures from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import Any, Optional +from typing import Any, Optional, cast from app.application.plugin.identity import ( + OFFICIAL_PLUGIN_SOURCE_KEY, PluginBindingBasis, PluginIdentity, TrustedPluginSourceType, normalize_physical_plugin_id, ) -from app.schemas.plugin import Plugin, PluginSourceBindingStatus +from app.application.plugin.inventory import normalize_github_plugin_source +from app.schemas.plugin import ( + Plugin, + PluginSourceBindingStatus, + PluginUpdateCandidate, +) MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]] AsyncMarketLoader = Callable[ @@ -396,3 +402,195 @@ class PluginCatalogService: result.append(plugin) add_time -= 1 return result + + +class PluginCatalogQuery: + """组合运行态、来源身份和市场候选,生成插件页目录快照。""" + + def __init__( + self, + *, + installed_plugins: Callable[[], list[Plugin]], + local_plugins: Callable[[], list[Plugin]], + local_repo_plugins: Callable[[], list[Plugin]], + online_candidates: Callable[[bool], Awaitable[list[Plugin]]], + process_plugins: Callable[[list[Plugin], list[Plugin]], list[Plugin]], + identities: Callable[[list[str]], Awaitable[list[PluginIdentity]]], + ) -> None: + """保存运行态目录和来源身份读取窄端口。""" + self._installed_plugins = installed_plugins + self._local_plugins = local_plugins + self._local_repo_plugins = local_repo_plugins + self._online_candidates = online_candidates + self._process_plugins = process_plugins + self._identities = identities + + async def query( + self, *, state: str = "all", force: bool = False + ) -> list[Plugin]: + """按 installed、market 或 all 返回插件页所需稳定目录。""" + installed_plugins = self._installed_plugins() + if state == "installed": + return await self._with_declared_metadata(installed_plugins) + + local_plugins = self._local_plugins() + not_installed = [plugin for plugin in local_plugins if not plugin.installed] + local_repo_plugins = self._local_repo_plugins() + online_plugins = await self._online_candidates(force) + installed_ids = [plugin.id for plugin in installed_plugins if plugin.id] + candidates = ( + self._process_plugins(online_plugins + local_repo_plugins, []) + if online_plugins or local_repo_plugins + else [] + ) + candidates = await self.project_update_candidates( + online_plugins, + candidates, + installed_ids, + ) + if not candidates: + if state == "market": + return not_installed + return await self._with_declared_metadata(installed_plugins) + not_installed + + installed_keys = set(installed_ids) + market_plugins = [ + plugin + for plugin in candidates + if plugin.id not in installed_keys or plugin.has_update + ] + market_ids = {plugin.id for plugin in market_plugins} + market_plugins.extend( + plugin for plugin in not_installed if plugin.id not in market_ids + ) + if state == "market": + return market_plugins + return await self._with_declared_metadata(installed_plugins) + market_plugins + + async def project_update_candidates( + self, + online_plugins: list[Plugin], + plugins: list[Plugin], + installed_ids: list[str], + ) -> list[Plugin]: + """优先选择绑定仓库更新并投影候选来源信息。""" + identities = await self._identities(installed_ids) + identity_by_id = {item.normalized_plugin_id: item for item in identities} + installed_keys = {plugin_id.lower() for plugin_id in installed_ids} + result: list[Plugin] = [] + for plugin in plugins: + if ( + not plugin.id + or plugin.id.lower() not in installed_keys + or not plugin.has_update + ): + result.append(plugin) + continue + identity = identity_by_id.get(plugin.id.lower()) + plugin = self._prefer_bound_update(plugin, online_plugins, identity) + result.append(_project_update_candidate(plugin, identity)) + return result + + async def _with_declared_metadata( + self, plugins: list[Plugin] + ) -> list[Plugin]: + """为未加载插件补齐已提交的展示声明。""" + identities = await self._identities( + [plugin.id for plugin in plugins if plugin.id] + ) + return apply_declared_metadata_fallback( + plugins, + {identity.normalized_plugin_id: identity for identity in identities}, + ) + + def _prefer_bound_update( + self, + plugin: Plugin, + online_plugins: list[Plugin], + identity: PluginIdentity | None, + ) -> Plugin: + """绑定仓库存在更新时优先返回其候选。""" + if identity is None or not identity.trusted_source_key: + return plugin + bound_updates = [ + candidate + for candidate in online_plugins + if _matches_trusted_source(candidate, plugin.id, identity.trusted_source_key) + ] + preferred = self._process_plugins(bound_updates, []) if bound_updates else [] + return preferred[0] if preferred else plugin + + +_catalog_query: PluginCatalogQuery | None = None + + +def configure_plugin_catalog_query(query: PluginCatalogQuery) -> None: + """由启动组合根发布当前 lifespan 的插件目录查询。""" + global _catalog_query + _catalog_query = query + + +def get_plugin_catalog_query() -> PluginCatalogQuery: + """返回已经由启动组合根装配的插件目录查询。""" + if _catalog_query is None: + raise RuntimeError("插件目录查询尚未完成初始化") + return _catalog_query + + +def reset_plugin_catalog_query() -> None: + """清除当前 lifespan 的插件目录查询,供停机和隔离测试使用。""" + global _catalog_query + _catalog_query = None + + +def _matches_trusted_source( + plugin: Plugin, + plugin_id: str, + trusted_source_key: str, +) -> bool: + """判断市场候选是否为指定插件的可信绑定仓库更新。""" + if ( + not plugin.id + or plugin.id.lower() != plugin_id.lower() + or not plugin.has_update + or not plugin.repo_url + ): + return False + try: + source_key, _ = normalize_github_plugin_source(plugin.repo_url) + except ValueError: + return False + return str(source_key) == trusted_source_key + + +def _project_update_candidate( + plugin: Plugin, identity: PluginIdentity | None +) -> Plugin: + """在隔离副本上附加来源类型和绑定关系。""" + if not plugin.repo_url or not plugin.plugin_version: + return plugin + try: + source_key, repo_url = normalize_github_plugin_source(plugin.repo_url) + except ValueError: + return plugin + source_type = ( + TrustedPluginSourceType.OFFICIAL + if source_key == OFFICIAL_PLUGIN_SOURCE_KEY + else TrustedPluginSourceType.THIRD_PARTY + ) + return cast( + Plugin, + plugin.model_copy( + update={ + "update_candidate": PluginUpdateCandidate( + source_type=source_type.value, + source_key=source_key, + repo_url=repo_url, + version=plugin.plugin_version, + is_bound=bool( + identity and identity.trusted_source_key == source_key + ), + ) + }, + ), + ) diff --git a/app/application/plugin/folders.py b/app/application/plugin/folders.py index 2ad6d4fd9..7b8b63258 100644 --- a/app/application/plugin/folders.py +++ b/app/application/plugin/folders.py @@ -1,38 +1,196 @@ -"""插件文件夹应用用例。""" +"""插件文件夹查询、变更和插件归属维护用例。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from contextlib import AbstractContextManager +from copy import deepcopy +from dataclasses import dataclass +from typing import Any from app.application.configuration import get_configured_system_config +from app.application.plugin.runtime import get_plugin_manager from app.runtime.log import logger +from app.schemas.exception import ( + PersistenceUnavailableError, + PluginMutationRejectedError, +) from app.schemas.types import SystemConfigKey +PluginFolders = dict[str, Any] +FolderReader = Callable[[], PluginFolders] +FolderWriter = Callable[[PluginFolders], Awaitable[object]] +FolderSyncWriter = Callable[[PluginFolders], object] +FolderMutation = Callable[[str], AbstractContextManager[None]] + + +@dataclass(frozen=True, slots=True) +class PluginFolderResult: + """描述一次插件文件夹变更结果和用户提示。""" + + success: bool + message: str = "" + + +class PluginFolderService: + """集中管理插件文件夹快照和带准入的持久化变更。""" + + def __init__( + self, + *, + read: FolderReader, + write: FolderWriter, + write_sync: FolderSyncWriter, + mutation: FolderMutation, + ) -> None: + """保存配置读写和插件运行态变更准入端口。""" + self._read = read + self._write = write + self._write_sync = write_sync + self._mutation = mutation + + def get(self) -> PluginFolders: + """返回与配置存储隔离的当前文件夹快照。""" + return deepcopy(self._read()) + + def get_or_empty(self) -> PluginFolders: + """读取失败时记录诊断并返回兼容的空文件夹快照。""" + try: + return self.get() + except Exception as error: # noqa: BLE001 - 查询入口保留旧空结果语义 + logger.error(f"[文件夹API] 获取文件夹配置失败: {error}") + return {} + + async def save(self, folders: PluginFolders) -> PluginFolderResult: + """完整替换文件夹配置并取得插件变更准入。""" + try: + with self._mutation("保存插件文件夹配置"): + await self._write(deepcopy(folders)) + return PluginFolderResult(True) + except PersistenceUnavailableError: + raise + except PluginMutationRejectedError as error: + return PluginFolderResult(False, str(error)) + except Exception as error: # noqa: BLE001 - 兼容入口以结果表达保存失败 + logger.error(f"[文件夹API] 保存文件夹配置失败: {error}") + return PluginFolderResult(False, str(error)) + + async def create(self, folder_name: str) -> PluginFolderResult: + """创建不存在的文件夹,保留旧列表格式的兼容形态。""" + try: + with self._mutation(f"创建插件文件夹 {folder_name}"): + folders = self.get() + if folder_name in folders: + return PluginFolderResult(False, f"文件夹 '{folder_name}' 已存在") + folders[folder_name] = [] + await self._write(folders) + return PluginFolderResult(True, f"文件夹 '{folder_name}' 创建成功") + except PluginMutationRejectedError as error: + return PluginFolderResult(False, str(error)) + + async def delete(self, folder_name: str) -> PluginFolderResult: + """删除存在的文件夹并返回稳定业务结果。""" + try: + with self._mutation(f"删除插件文件夹 {folder_name}"): + folders = self.get() + if folder_name not in folders: + return PluginFolderResult(False, f"文件夹 '{folder_name}' 不存在") + del folders[folder_name] + await self._write(folders) + return PluginFolderResult(True, f"文件夹 '{folder_name}' 删除成功") + except PluginMutationRejectedError as error: + return PluginFolderResult(False, str(error)) + + async def update_plugins( + self, folder_name: str, plugin_ids: list[str] + ) -> PluginFolderResult: + """更新指定文件夹的插件顺序和成员。""" + try: + with self._mutation(f"更新插件文件夹 {folder_name}"): + folders = self.get() + folders[folder_name] = list(plugin_ids) + await self._write(folders) + return PluginFolderResult( + True, + f"文件夹 '{folder_name}' 中的插件已更新", + ) + except PluginMutationRejectedError as error: + return PluginFolderResult(False, str(error)) + + def remove_plugin(self, plugin_id: str) -> None: + """从当前和旧版文件夹形态中移除插件且不阻断卸载。""" + try: + folders = self.get() + modified = False + for folder_name, folder_data in folders.items(): + plugins = _folder_plugins(folder_data) + if plugins is None or plugin_id not in plugins: + continue + plugins.remove(plugin_id) + logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") + modified = True + if modified: + self._write_sync(folders) + else: + logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除") + except Exception as error: # noqa: BLE001 + # 文件夹配置损坏不应阻断插件代码、数据和定时任务的卸载流程。 + logger.error(f"从文件夹中移除插件时出错:{error}") + + def add_clone(self, original_plugin_id: str, clone_plugin_id: str) -> None: + """让分身继承原插件所在的首个文件夹且不阻断创建。""" + try: + folders = self.get() + for folder_name, folder_data in folders.items(): + plugins = _folder_plugins(folder_data) + if plugins is None or original_plugin_id not in plugins: + continue + if clone_plugin_id not in plugins: + plugins.append(clone_plugin_id) + self._write_sync(folders) + logger.info( + f"已将分身插件 {clone_plugin_id} 添加到文件夹 " + f"'{folder_name}' 中" + ) + return + logger.info( + f"原插件 {original_plugin_id} 不在任何文件夹中," + f"分身插件 {clone_plugin_id} 将保持独立" + ) + except Exception as error: # noqa: BLE001 + # 文件夹处理失败不影响插件分身创建的整体流程。 + logger.error(f"处理插件文件夹时出错:{error}") + + +def get_plugin_folder_service() -> PluginFolderService: + """基于当前 lifespan 的 Application 配置和插件 Runtime 构造文件夹服务。""" + return PluginFolderService( + read=lambda: ( + get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} + ), + write=lambda folders: get_configured_system_config().async_set( + SystemConfigKey.PluginFolders, folders + ), + write_sync=lambda folders: get_configured_system_config().set( + SystemConfigKey.PluginFolders, folders + ), + mutation=lambda operation: get_plugin_manager().mutation(operation), + ) + def remove_plugin_from_folders(plugin_id: str) -> None: - """ - 从所有配置文件夹中移除指定插件。 + """保留卸载调用方使用的稳定 Application 入口。""" + get_plugin_folder_service().remove_plugin(plugin_id) - 同时兼容当前的字典格式和迁移前的插件列表格式,避免卸载旧版本插件时留下 - 不可见的文件夹引用。 - :param plugin_id: 要移除的插件 ID - """ - try: - config_oper = get_configured_system_config() - folders = config_oper.get(SystemConfigKey.PluginFolders) or {} - modified = False - for folder_name, folder_data in folders.items(): - if isinstance(folder_data, dict) and "plugins" in folder_data: - if plugin_id in folder_data["plugins"]: - folder_data["plugins"].remove(plugin_id) - logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") - modified = True - elif isinstance(folder_data, list) and plugin_id in folder_data: - folder_data.remove(plugin_id) - logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") - modified = True +def add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str) -> None: + """保留分身创建调用方使用的稳定 Application 入口。""" + get_plugin_folder_service().add_clone(original_plugin_id, clone_plugin_id) - if modified: - config_oper.set(SystemConfigKey.PluginFolders, folders) - else: - logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除") - except Exception as error: - # 文件夹配置损坏不应阻断插件代码、数据和定时任务的卸载流程。 - logger.error(f"从文件夹中移除插件时出错:{error}") + +def _folder_plugins(folder_data: Any) -> list[str] | None: + """返回当前字典形态或旧列表形态中的可变插件列表。""" + if isinstance(folder_data, dict): + plugins = folder_data.get("plugins") + return plugins if isinstance(plugins, list) else None + return folder_data if isinstance(folder_data, list) else None diff --git a/app/application/plugin/rating.py b/app/application/plugin/rating.py new file mode 100644 index 000000000..b91bade9f --- /dev/null +++ b/app/application/plugin/rating.py @@ -0,0 +1,77 @@ +"""插件安装统计和评分应用用例。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any + +StatisticLoader = Callable[[], Awaitable[Mapping[str, Any]]] +RatingsLoader = Callable[[list[str] | None], Awaitable[Mapping[str, Any]]] +RatingLoader = Callable[[str], Awaitable[Mapping[str, Any]]] +RatingSubmitter = Callable[[str, float], Awaitable[Mapping[str, Any] | None]] + + +class PluginNotInstalledError(ValueError): + """表示评分目标不在当前已安装插件集合中。""" + + +class PluginRatingService: + """通过注入的 MoviePilot Server 端口提供插件统计和评分用例。""" + + def __init__( + self, + *, + installed_plugins: Callable[[], Sequence[str]], + statistic: StatisticLoader, + ratings: RatingsLoader, + rating: RatingLoader, + submit: RatingSubmitter, + ) -> None: + """保存安装状态读取和远程评分窄端口。""" + self._installed_plugins = installed_plugins + self._statistic = statistic + self._ratings = ratings + self._rating = rating + self._submit = submit + + async def statistic(self) -> Mapping[str, Any]: + """返回插件安装统计。""" + return await self._statistic() + + async def ratings( + self, plugin_ids: list[str] | None + ) -> Mapping[str, Any]: + """批量返回插件评分快照。""" + return await self._ratings(plugin_ids) + + async def rating(self, plugin_id: str) -> Mapping[str, Any]: + """返回单个插件评分快照。""" + return await self._rating(plugin_id) + + async def submit(self, plugin_id: str, rating: float) -> Mapping[str, Any] | None: + """校验安装状态后提交当前实例评分。""" + if plugin_id not in self._installed_plugins(): + raise PluginNotInstalledError(f"插件 {plugin_id} 未安装,无法评分") + return await self._submit(plugin_id, rating) + + +_rating_service: PluginRatingService | None = None + + +def configure_plugin_rating_service(service: PluginRatingService) -> None: + """由启动组合根发布当前 lifespan 的插件评分服务。""" + global _rating_service + _rating_service = service + + +def get_plugin_rating_service() -> PluginRatingService: + """返回已经由启动组合根装配的插件评分服务。""" + if _rating_service is None: + raise RuntimeError("插件评分服务尚未完成初始化") + return _rating_service + + +def reset_plugin_rating_service() -> None: + """清除当前 lifespan 的评分服务,供停机和隔离测试使用。""" + global _rating_service + _rating_service = None diff --git a/app/application/plugin/release.py b/app/application/plugin/release.py new file mode 100644 index 000000000..5f4ec883a --- /dev/null +++ b/app/application/plugin/release.py @@ -0,0 +1,215 @@ +"""插件更新说明和 Release 版本查询用例。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, cast + +from app.application.plugin.catalog import apply_declared_metadata_fallback +from app.application.plugin.identity import PluginIdentity +from app.schemas.plugin import Plugin + +MarketPluginLoader = Callable[[str, str | None, bool], Awaitable[list[Plugin] | None]] +ReleaseCacheProbe = Callable[[str], Awaitable[bool]] +ReleaseLoader = Callable[[str, str], Awaitable[list[dict[str, Any]]]] +ReleaseRefresher = Callable[[str, str], Awaitable[object]] +IdentityReader = Callable[[str], Awaitable[PluginIdentity | None]] + + +@dataclass(frozen=True, slots=True) +class PluginReleaseSnapshot: + """描述指定插件仓库的 Release 展示快照。""" + + release_supported: bool + latest_version: str | None + current_version: str | None + items: tuple[dict[str, Any], ...] + refresh_required: bool = False + + +class PluginReleaseService: + """协调可信仓库元数据、Release 缓存和本地版本查询。""" + + def __init__( + self, + *, + installed_plugins: Callable[[], Sequence[Plugin]], + local_repo_plugins: Callable[[], Sequence[Plugin]], + market_plugins: MarketPluginLoader, + local_version: Callable[[str], str | None], + identity: IdentityReader, + version_flag: Callable[[], str | None], + compatible_flags: Callable[[str | None], Sequence[str]], + has_release_cache: ReleaseCacheProbe, + releases: ReleaseLoader, + refresh_releases: ReleaseRefresher, + ) -> None: + """保存运行态、来源身份和市场读取窄端口。""" + self._installed_plugins = installed_plugins + self._local_repo_plugins = local_repo_plugins + self._market_plugins = market_plugins + self._local_version = local_version + self._identity = identity + self._version_flag = version_flag + self._compatible_flags = compatible_flags + self._has_release_cache = has_release_cache + self._releases = releases + self._refresh_releases = refresh_releases + + async def history(self, plugin_id: str, *, force: bool = True) -> Plugin | None: + """按可信绑定仓库读取单个已安装插件的更新说明。""" + installed_plugin = next( + (plugin for plugin in self._installed_plugins() if plugin.id == plugin_id), + None, + ) + if installed_plugin is None: + return None + + identity = await self._identity(plugin_id) + if identity is not None: + installed_plugin = apply_declared_metadata_fallback( + [installed_plugin], + {identity.normalized_plugin_id: identity}, + )[0] + + local_plugin = next( + (plugin for plugin in self._local_repo_plugins() if plugin.id == plugin_id), + None, + ) + if local_plugin is not None: + return _merge_market_metadata(installed_plugin, local_plugin) + + repo_url = _trusted_repo_url(identity) + if repo_url is None: + return installed_plugin + market_plugin = await self._market_plugin(plugin_id, repo_url, force) + return ( + _merge_market_metadata(installed_plugin, market_plugin) + if market_plugin is not None + else installed_plugin + ) + + async def versions( + self, + plugin_id: str, + repo_url: str, + *, + force: bool = False, + ) -> PluginReleaseSnapshot: + """读取 Release 快照,并标记是否需要后台强制刷新已有缓存。""" + if not repo_url: + return PluginReleaseSnapshot(False, None, None, ()) + + market_plugin = await self._market_plugin(plugin_id, repo_url, force) + latest_version = market_plugin.plugin_version if market_plugin else None + current_version = self._local_version(plugin_id) + if not getattr(market_plugin, "release", False): + return PluginReleaseSnapshot( + False, + latest_version, + current_version, + (), + ) + + refresh_required = force and await self._has_release_cache(repo_url) + release_items = await self._releases(plugin_id, repo_url) + items = tuple( + _project_release_item(item, latest_version, current_version) + for item in release_items + ) + return PluginReleaseSnapshot( + bool(items), + latest_version, + current_version, + items, + refresh_required=refresh_required, + ) + + async def refresh(self, plugin_id: str, repo_url: str) -> None: + """强制刷新指定仓库的 Release 缓存。""" + await self._refresh_releases(plugin_id, repo_url) + + async def _market_plugin( + self, plugin_id: str, repo_url: str, force: bool + ) -> Plugin | None: + """按当前、兼容和基础索引顺序读取一个插件。""" + version_flag = self._version_flag() + package_versions: list[str | None] = [version_flag] if version_flag else [] + package_versions.extend(self._compatible_flags(version_flag)) + package_versions.append(None) + for package_version in dict.fromkeys(package_versions): + plugins = await self._market_plugins(repo_url, package_version, force) + matched = next( + (plugin for plugin in plugins or [] if plugin.id == plugin_id), + None, + ) + if matched is not None: + return matched + return None + + +_release_service: PluginReleaseService | None = None + + +def configure_plugin_release_service(service: PluginReleaseService) -> None: + """由启动组合根发布当前 lifespan 的 Release 查询服务。""" + global _release_service + _release_service = service + + +def get_plugin_release_service() -> PluginReleaseService: + """返回已经由启动组合根装配的 Release 查询服务。""" + if _release_service is None: + raise RuntimeError("插件 Release 查询服务尚未完成初始化") + return _release_service + + +def reset_plugin_release_service() -> None: + """清除当前 lifespan 的 Release 查询服务,供停机和隔离测试使用。""" + global _release_service + _release_service = None + + +def _trusted_repo_url(identity: PluginIdentity | None) -> str | None: + """把持久化的 GitHub 来源键投影为仓库 URL。""" + if identity is None or not identity.trusted_source_key: + return None + owner_repo = identity.trusted_source_key.removeprefix("github:") + return f"https://github.com/{owner_repo}" + + +def _merge_market_metadata(plugin: Plugin, market_plugin: Plugin) -> Plugin: + """在隔离副本上合并市场展示元数据。""" + return cast( + Plugin, + plugin.model_copy( + update={ + "repo_url": market_plugin.repo_url or plugin.repo_url, + "history": market_plugin.history or {}, + "release": market_plugin.release, + "has_update": market_plugin.has_update, + "system_version": market_plugin.system_version + or plugin.system_version, + "system_version_compatible": market_plugin.system_version_compatible, + "system_version_message": ( + market_plugin.system_version_message + or plugin.system_version_message + ), + } + ), + ) + + +def _project_release_item( + item: Mapping[str, Any], + latest_version: str | None, + current_version: str | None, +) -> dict[str, Any]: + """复制并标记一个 Release 条目的最新和当前版本状态。""" + version = item.get("version") + return { + **item, + "is_latest": bool(latest_version and version == latest_version), + "is_current": bool(current_version and version == current_version), + } diff --git a/app/startup/initializers/agent.py b/app/startup/initializers/agent.py index c196cc07e..31370ac68 100644 --- a/app/startup/initializers/agent.py +++ b/app/startup/initializers/agent.py @@ -19,6 +19,10 @@ from app.application.agent import ( register_agent_service_providers, reset_agent_service_providers, ) +from app.application.messaging.agent import ( + configure_web_agent_message_runtime, + reset_web_agent_message_runtime, +) from app.application.messaging.skill import ( register_skill_catalog_provider, reset_skill_catalog_provider, @@ -58,8 +62,7 @@ def configure_agent_data_context(context: AgentDataContext) -> None: return manager = _injected_agent_manager if manager is not None and ( - getattr(manager, "_accepting_tasks", False) - or bool(getattr(manager, "active_agents", {})) + getattr(manager, "_accepting_tasks", False) or bool(getattr(manager, "active_agents", {})) ): raise RuntimeError("AgentManager 已运行,不能替换数据上下文") _agent_data_context = context @@ -136,6 +139,28 @@ def _get_manual_redo_prompt_builder() -> Any: return build_manual_redo_prompt +def _get_web_agent_type() -> type: + """首个 Web 对话请求才解析 WebAgent 运行时类型。""" + from app.agent.web import _get_web_agent_type as get_web_agent_type + + return get_web_agent_type() + + +def _handle_web_agent_message(**kwargs: Any) -> object: + """按请求构造消息链并执行传统 WebAgent 输入。""" + from app.chain.message import MessageChain + + MessageChain().handle_message(**kwargs) + return None + + +def _bind_web_agent_user_session(user_id: str, session_id: str) -> None: + """把 Web 用户绑定到消息链的 Agent 会话。""" + from app.chain.message import MessageChain + + MessageChain().bind_user_session(user_id, session_id) + + async def _handle_agent_config_changed(event: Event) -> None: """把配置事件交给当前全局 initializer,避免监听器持有过期实例。""" await agent_initializer.handle_config_changed(event) @@ -165,14 +190,10 @@ class AgentInitializer: self._shutdown_started = False self._shutdown_complete = False if agent_manager is not None or _agent_data_context is not None: - if not get_runtime_setting('AI_AGENT_ENABLE'): + if not get_runtime_setting("AI_AGENT_ENABLE"): logger.info("AI智能体功能未启用") return True - self._manager = ( - agent_manager - if agent_manager is not None - else _get_injected_agent_manager() - ) + self._manager = agent_manager if agent_manager is not None else _get_injected_agent_manager() self._compat_injected = True await self._manager.initialize() else: @@ -192,12 +213,7 @@ class AgentInitializer: async def handle_config_changed(self, event: Event) -> None: """仅在 manifest watch 命中时协调 service,关闭态保持 fail closed。""" changed_keys = _event_changed_keys(event) - if ( - not changed_keys - or self._compat_injected - or self._shutdown_started - or self._shutdown_complete - ): + if not changed_keys or self._compat_injected or self._shutdown_started or self._shutdown_complete: return try: self._manager = await reconcile_agent_service( @@ -235,6 +251,7 @@ class AgentInitializer: # 全局AI智能体初始化器实例 agent_initializer = AgentInitializer() + def configure_agent_ports() -> None: """在 Agent 启动阶段原子登记全部 Application provider。""" reset_agent_ports() @@ -246,9 +263,14 @@ def configure_agent_ports() -> None: capability_manager_provider=_get_capability_manager, llm_helper_provider=_get_llm_helper, manual_redo_prompt_builder_provider=_get_manual_redo_prompt_builder, + web_agent_type_provider=_get_web_agent_type, ) register_skill_catalog_provider(_get_skill_catalog) register_llm_provider_runtime(_get_llm_provider_runtime) + configure_web_agent_message_runtime( + message_handler=_handle_web_agent_message, + session_binder=_bind_web_agent_user_session, + ) except Exception: reset_agent_ports() raise @@ -258,6 +280,7 @@ def reset_agent_ports() -> None: """清除 Agent、技能和 LLM provider,支持失败回滚与重复 lifespan。""" register_llm_provider_runtime(None) reset_skill_catalog_provider() + reset_web_agent_message_runtime() reset_agent_service_providers() diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 5f0e7b981..d842fc452 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -62,6 +62,7 @@ from app.application.configuration import ( configure_token_runtime_config, configure_transfer_retry_config, get_configured_system_config, + get_runtime_settings, ) from app.application.database import configure_database_governance from app.application.history import configure_transfer_history_repository @@ -94,6 +95,12 @@ from app.application.messaging.message import ( stop_message, ) from app.application.module import configure_module_runtime +from app.application.network import ( + NetworkTestResponse, + NetworkTestService, + NetworkTestTransport, + configure_network_test_service, +) from app.application.outbox import ( ClaimedOutboxMessage, OutboxDispatcher, @@ -546,6 +553,34 @@ def configure_wallpaper_services() -> None: ) +class _NetworkTestTransportAdapter: + """把通用异步 HTTP Adapter 收窄为网络探测 GET 端口。""" + + async def get( + self, + url: str, + *, + proxy: Any = None, + headers: Optional[Mapping[str, str]] = None, + user_agent: Optional[str] = None, + ) -> Optional[NetworkTestResponse]: + """使用固定超时、证书校验和手动重定向策略请求目标。""" + response = await AsyncRequestUtils( + proxies=proxy, + headers=dict(headers) if headers else None, + timeout=10, + ua=user_agent or "", + verify=True, + follow_redirects=False, + ).get_res(url, allow_redirects=False) + return cast(Optional[NetworkTestResponse], response) + + +def _read_network_test_setting(key: str, default: Any = None) -> Any: + """延迟读取已由组合根发布的部署设置,避免应用服务持有全局对象。""" + return get_runtime_settings().get(key, default) + + class _ImageTransportAdapter: """把通用 HTTP Adapter 收窄为图片应用服务的 GET 端口。""" @@ -631,10 +666,18 @@ class _MessageIngressAdapter: def configure_application_network_ports() -> None: - """装配图片读取、内部地址判断和消息回环传输端口。""" + """装配网络探测、图片读取、内部地址判断和消息回环传输端口。""" + network_test_transport: NetworkTestTransport = _NetworkTestTransportAdapter() image_transport: ImageTransport = _ImageTransportAdapter() internal_address: InternalAddressPort = _InternalAddressAdapter() message_ingress: MessageIngressPort = _MessageIngressAdapter() + configure_network_test_service( + NetworkTestService( + transport=network_test_transport, + settings=_read_network_test_setting, + logger=logger, + ) + ) configure_image_ports( transport=image_transport, internal_address=internal_address, @@ -655,7 +698,7 @@ def get_host_event_handler_factories() -> dict[type, Callable[[], object]]: """返回所有使用事件装饰器的宿主类及其明确实例工厂。""" from app.chain.download import DownloadChain from app.chain.scraping import ScrapingChain - from app.chain.search import SearchChain + from app.chain.search import SearchChain # pylint: disable=no-name-in-module from app.chain.site import SiteChain from app.chain.subscribe.facade import SubscribeChain from app.chain.workflow import WorkflowChain diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 660d63722..adf042c25 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -18,8 +18,16 @@ from app.adapters.system.plugin.dependency import PluginDependencyInstaller from app.adapters.system.plugin.manifest import dependency_manifest_status from app.adapters.system.plugin.package import PluginPackageManager from app.application.commands import init_commands -from app.application.configuration import get_configured_system_config -from app.application.plugin.catalog import PluginCatalogService +from app.application.configuration import ( + get_api_runtime_config_snapshot, + get_configured_system_config, +) +from app.application.plugin.catalog import ( + PluginCatalogQuery, + PluginCatalogService, + configure_plugin_catalog_query, + reset_plugin_catalog_query, +) from app.application.plugin.data import DeletePluginDataCommand from app.application.plugin.gateway import ( PluginInstallGateway, @@ -38,10 +46,20 @@ from app.application.plugin.migration import ( configure_plugin_identity_migration, get_plugin_identity_migration, ) +from app.application.plugin.rating import ( + PluginRatingService, + configure_plugin_rating_service, + reset_plugin_rating_service, +) from app.application.plugin.recovery import ( PluginInstallationRecoveryService, configure_plugin_installation_recovery, ) +from app.application.plugin.release import ( + PluginReleaseService, + configure_plugin_release_service, + reset_plugin_release_service, +) from app.application.plugin.routes import register_plugin_api from app.application.plugin.runtime import get_plugin_manager from app.application.plugin.transaction import ( @@ -54,6 +72,7 @@ from app.db.oper.plugindata import PluginDataOper from app.db.session import SessionFactory from app.db.uow import SqlAlchemyUnitOfWork from app.foundation.version import compare_version +from app.runtime.cache import async_fresh from app.runtime.compat.diagnostics import ( configure_legacy_import_diagnostics, scan_plugin_legacy_imports, @@ -125,6 +144,50 @@ def configure_plugin_services() -> None: ) persistence = get_plugin_persistence() + async def refresh_plugin_releases(plugin_id: str, repo_url: str) -> None: + """绕过已有 Release 缓存执行后台刷新。""" + async with async_fresh(True): + await plugin_helper.async_get_plugin_release_versions(plugin_id, repo_url) + + configure_plugin_release_service( + PluginReleaseService( + installed_plugins=plugin_manager.get_installed_plugins, + local_repo_plugins=plugin_manager.get_local_repo_plugins, + market_plugins=plugin_manager.async_get_plugins_from_market, + local_version=plugin_manager.get_local_plugin_version, + identity=persistence.get_identity, + version_flag=lambda: get_api_runtime_config_snapshot().version_flag, + compatible_flags=lambda flag: ( + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) if flag else [] + ), + has_release_cache=plugin_helper.async_has_plugin_release_cache, + releases=plugin_helper.async_get_plugin_release_versions, + refresh_releases=refresh_plugin_releases, + ) + ) + configure_plugin_catalog_query( + PluginCatalogQuery( + installed_plugins=plugin_manager.get_installed_plugins, + local_plugins=plugin_manager.get_local_plugins, + local_repo_plugins=plugin_manager.get_local_repo_plugins, + online_candidates=plugin_manager.async_get_online_plugin_candidates, + process_plugins=plugin_manager.process_plugins_list, + identities=persistence.list_identities, + ) + ) + configure_plugin_rating_service( + PluginRatingService( + installed_plugins=lambda: get_configured_system_config().get( + SystemConfigKey.UserInstalledPlugins + ) + or [], + statistic=MoviePilotServerHelper.async_get_plugin_statistic, + ratings=MoviePilotServerHelper.async_get_plugin_ratings, + rating=MoviePilotServerHelper.async_get_plugin_rating, + submit=MoviePilotServerHelper.async_submit_plugin_rating, + ) + ) + async def load_inventory(force: bool): """读取本轮配置市场和本地仓库的完整候选事实。""" return await inventory_reader.async_load( @@ -661,3 +724,7 @@ def stop_plugins() -> bool: except Exception as e: logger.error(f"停止插件时发生错误:{e}", exc_info=True) return False + finally: + reset_plugin_catalog_query() + reset_plugin_release_service() + reset_plugin_rating_service() diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index b3150642a..624cd3ef0 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 944 / 8,017 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 947 / 8,038 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -79,7 +79,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 | | 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 | | 全量 mypy 历史债务 | 10,686 / 597 文件 | strict frontier 当前覆盖 41 个文件,Media 同名包及受益调用方低水位已固化 | -| Ruff 历史诊断 | 661 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| Ruff 历史诊断 | 657 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 80.75%,Domain 79.32% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index c962ff0ee..4b86e91a9 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -728,8 +728,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 944 | -| 内部导入边 | 8,017 | +| Python 模块 | 947 | +| 内部导入边 | 8,038 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 55(债务已清零,55 条精确 containment) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 0edf2a666..455c92efb 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -68,12 +68,13 @@ to make the directory tree look symmetrical. | `app/application/music/` | Multi-source music catalog orchestration | | `app/application/chain/` | Injectable Chain runtime capabilities: `context.py` owns the typed runtime and persistence dependency aggregate, and `events.py` owns durable event write contracts plus replayable payload conversion | | `app/application/agent.py` | Agent orchestration facade and typed `AgentDataContext`; startup injects one explicit data context into the manager, memory, tool and scheduler owners without a process-wide persistence locator | +| `app/application/network.py` | System network-test target catalog, immutable public/private projections, URL and redirect admission, response validation and the injected transport Port; startup owns concrete HTTP Adapter assembly | | `app/application/outbox.py` | Durable intent, transaction-only stager, short-transaction dispatch store, claim fencing and structured post-commit result contracts | | `app/application/transfer/` | Durable transfer use cases: `workflow.py` owns admission/planning/queue behavior; `execution.py` owns stable operation identity, step/checkpoint state, retry/manual-review commands and terminal-settlement DTOs | | `app/application/plugin/` | Plugin market catalog, installation command, installed-plugin identity contract and startup migration, runtime port, folder operations and dynamic-route use cases; filenames remain single words (`catalog.py`, `identity.py`, `migration.py`, `install.py`, `runtime.py`, `folders.py`, `routes.py`) | | `app/application/server/` | MoviePilot Server reporting and sharing use cases; local data readers and transport callbacks are injected by startup | | `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here | -| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `ingress.py` owns the single channel-to-host loopback boundary; `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` agent choice state, callback protocol and WebAgent bridge; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use | +| `app/application/messaging/` | Message rendering/routing, interactions and the Agent-to-message bridge: `ingress.py` owns the single channel-to-host loopback boundary; `interaction.py` shared interaction contracts and view helpers; `router.py` unified interaction priority and callback dispatch; `site.py`/`subscribe.py`/`skill.py` per-command sessions, input parsing and views; `media.py` media interaction state while the business workflow stays in `MediaInteractionChain`; `plugin.py` plugin input capture and plugin button callbacks; `agent.py` owns Agent choice state, callback protocol, bounded WebAgent event publication, display projection, session identity/persistence coordination, temporary attachment registry and audio preparation/transcription; `message.py` notification rendering, templates and queue. Not a public SDK recommended for direct plugin use | | `app/application/security/` | Authentication, authorization, frozen user/auth projections, atomic user aggregate commands, per-user configuration publication, cookies, passkeys, OTP/two-factor, path/URL safety, SSRF and signing policy | Application services may use domain rules and runtime contracts. They own the @@ -84,6 +85,14 @@ workflows still belong in the existing `app/chain/` package. `Chain`, `Service` and `Manager` remain class patterns; they do not create additional top-level directory categories. +`app/api/endpoints/agent.py` must not recreate WebAgent file registries, audio +conversion/transcription policy, traditional-message dispatch, Agent execution +lifecycle, event-to-display projection or AgentChat snapshot writes. Those reusable +state transitions and the transport-neutral event-stream use case belong to +`app/application/messaging/agent.py`; the endpoint maps FastAPI principals and DTOs, +translates upload/domain failures to HTTP responses, and frames Application events +as SSE. + ### Runtime boundaries | Path | Ownership | @@ -693,6 +702,12 @@ expired claimed task remains exclusively owned by fenced recovery APIs. - Configured notification discovery lives in `app/application/notification.py`. Web Push subscription and manual-send HTTP behavior stays in `app/api/endpoints/message.py`. +- System network testing lives in `app/application/network.py`: it owns the + server-only target catalog, exact legacy URL matching, HTTPS and credential + checks, per-target redirect allowlists and content validation. The System API + only authenticates, maps request fields and projects the standard response; + startup injects the concrete HTTP transport with certificate verification and + automatic redirects disabled. - `app/runtime/compat` stores string mappings and resolves aliases lazily. It may not eagerly import canonical MoviePilot modules. - 已删除的 `app.db._oper` 路径继续由精确模块映射提供给旧插件;其中订阅写入、 @@ -811,6 +826,7 @@ driven workflow registration. | Path | Purpose | |---|---| | `app/application/agent.py` | Agent orchestration facade plus typed `AgentDataContext`; lightweight service providers register through `app/startup/initializers/agent.py`, with no static `application -> agent` edge or global persistence locator | +| `app/application/network.py` | Server-owned network-test catalog, public/private DTOs, URL and redirect admission, content validation and the injected HTTPS transport Port | | `app/db/adapters/agent.py` | Agent task and plugin-data persistence implementations; ORM values are projected to Application snapshots before Session close | | `app/agent/runtime_loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` | | `app/application/subscription/contract.py` | Deeply frozen Subscription/History DTOs, media identity, write patch and typed query/write/staging Repository contracts | @@ -866,10 +882,12 @@ driven workflow registration. | `app/runtime/extensions/plugin/monitor.py` | Plugin file-change aggregation and monitor-thread lifecycle | | `app/runtime/extensions/plugin/projection.py` | Plugin commands, APIs, services, modules and actions projected from a running-registry snapshot | | `app/runtime/extensions/plugin/storage.py` | Injected plugin configuration/data persistence port; runtime code does not import DB Oper classes | -| `app/application/plugin/catalog.py` | Plugin-market mapping, concurrent collection, generation merge and source/version deduplication | +| `app/application/plugin/catalog.py` | Plugin-market mapping, concurrent collection, generation merge, source/version deduplication and API catalog projection | | `app/application/plugin/install.py` | Compatibility, package installation, reporting, installed-list persistence and runtime reload command | | `app/application/plugin/routes.py` | Dynamic plugin-route registry protocol and registration/removal use cases; plugin response payloads remain raw unless the plugin chooses its own envelope | -| `app/application/plugin/folders.py` | Plugin-folder cleanup use case, compatible with current dictionary and legacy list storage shapes | +| `app/application/plugin/folders.py` | Plugin-folder query/mutation and clone/uninstall membership maintenance, compatible with current dictionary and legacy list storage shapes | +| `app/application/plugin/release.py` | Trusted-source history and Release projection over startup-injected market/cache ports | +| `app/application/plugin/rating.py` | Plugin statistics and rating use cases over a startup-injected MoviePilot Server port | | `app/application/plugin/runtime.py` | Plugin runtime port consumed by API, Agent and Workflow; the concrete `PluginManager` is registered only by startup | | `app/application/module.py` | Host module runtime port consumed by entrypoints; the concrete `ModuleManager` is registered only by startup | | `app/application/scheduling.py` | Scheduler runtime port consumed by API/Agent/application commands | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index dc3d96eeb..fd32014c7 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1119,8 +1119,8 @@ "runtime_only": true } }, - "edge_count": 8017, - "edge_sha256": "dbef1b17fb0a285ee113582bbedd2b193e612c650689abd1b3f079963ccd938c", + "edge_count": 8038, + "edge_sha256": "b54be324bc5ced15bb1b68c118912a1038de5dc099fb4b70fec5e4a78674caf0", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2648,6 +2648,16 @@ "app.agent.tools.manager -> app.application.plugin.runtime", "app.agent.tools.manager -> app.runtime", "app.agent.tools.manager -> app.runtime.log", + "app.agent.web -> app.agent", + "app.agent.web -> app.agent.callback", + "app.agent.web -> app.agent.runtime_loader", + "app.agent.web -> app.application", + "app.agent.web -> app.application.security", + "app.agent.web -> app.application.security.user", + "app.agent.web -> app.runtime", + "app.agent.web -> app.runtime.log", + "app.agent.web -> app.schemas", + "app.agent.web -> app.schemas.message", "app.api.apiv1 -> app.api", "app.api.apiv1 -> app.api.router_specs", "app.api.context -> app.application", @@ -2788,10 +2798,7 @@ "app.api.deps -> app.api.dependencies.subscription", "app.api.deps -> app.api.dependencies.workflow", "app.api.endpoints.agent -> app.agent", - "app.api.endpoints.agent -> app.agent.callback", - "app.api.endpoints.agent -> app.agent.contracts", "app.api.endpoints.agent -> app.agent.mcp", - "app.api.endpoints.agent -> app.agent.runtime_loader", "app.api.endpoints.agent -> app.api", "app.api.endpoints.agent -> app.api.dependencies", "app.api.endpoints.agent -> app.api.dependencies.agent", @@ -2802,26 +2809,16 @@ "app.api.endpoints.agent -> app.api.response", "app.api.endpoints.agent -> app.application", "app.api.endpoints.agent -> app.application.agent", - "app.api.endpoints.agent -> app.application.commands", - "app.api.endpoints.agent -> app.application.configuration", "app.api.endpoints.agent -> app.application.messaging", "app.api.endpoints.agent -> app.application.messaging.agent", "app.api.endpoints.agent -> app.application.messaging.chat", - "app.api.endpoints.agent -> app.application.messaging.router", - "app.api.endpoints.agent -> app.application.security", - "app.api.endpoints.agent -> app.application.security.user", - "app.api.endpoints.agent -> app.chain", - "app.api.endpoints.agent -> app.chain.message", "app.api.endpoints.agent -> app.runtime", - "app.api.endpoints.agent -> app.runtime.execution", "app.api.endpoints.agent -> app.runtime.localization", "app.api.endpoints.agent -> app.runtime.log", - "app.api.endpoints.agent -> app.runtime.stop", "app.api.endpoints.agent -> app.schemas", "app.api.endpoints.agent -> app.schemas.agent", "app.api.endpoints.agent -> app.schemas.message", "app.api.endpoints.agent -> app.schemas.response", - "app.api.endpoints.agent -> app.schemas.types", "app.api.endpoints.anilist -> app.adapters", "app.api.endpoints.anilist -> app.adapters.web", "app.api.endpoints.anilist -> app.adapters.web.security", @@ -3211,9 +3208,6 @@ "app.api.endpoints.openai -> app.schemas.openai", "app.api.endpoints.openai -> app.schemas.types", "app.api.endpoints.plugin -> app.adapters", - "app.api.endpoints.plugin -> app.adapters.external", - "app.api.endpoints.plugin -> app.adapters.external.market", - "app.api.endpoints.plugin -> app.adapters.external.server", "app.api.endpoints.plugin -> app.adapters.web", "app.api.endpoints.plugin -> app.adapters.web.security", "app.api.endpoints.plugin -> app.adapters.web.security.access", @@ -3232,14 +3226,13 @@ "app.api.endpoints.plugin -> app.application.plugin.config", "app.api.endpoints.plugin -> app.application.plugin.folders", "app.api.endpoints.plugin -> app.application.plugin.gateway", - "app.api.endpoints.plugin -> app.application.plugin.identity", - "app.api.endpoints.plugin -> app.application.plugin.inventory", + "app.api.endpoints.plugin -> app.application.plugin.rating", + "app.api.endpoints.plugin -> app.application.plugin.release", "app.api.endpoints.plugin -> app.application.plugin.routes", "app.api.endpoints.plugin -> app.application.plugin.runtime", "app.api.endpoints.plugin -> app.application.plugin.transaction", "app.api.endpoints.plugin -> app.application.scheduling", "app.api.endpoints.plugin -> app.runtime", - "app.api.endpoints.plugin -> app.runtime.cache", "app.api.endpoints.plugin -> app.runtime.extensions", "app.api.endpoints.plugin -> app.runtime.extensions.plugin", "app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts", @@ -3437,7 +3430,6 @@ "app.api.endpoints.system -> app.foundation", "app.api.endpoints.system -> app.foundation.crypto", "app.api.endpoints.system -> app.foundation.environment", - "app.api.endpoints.system -> app.foundation.url", "app.api.endpoints.system -> app.runtime", "app.api.endpoints.system -> app.runtime.events", "app.api.endpoints.system -> app.runtime.execution", @@ -3784,8 +3776,17 @@ "app.application.mediaserver -> app.schemas.mediaserver", "app.application.mediaserver -> app.schemas.system", "app.application.mediaserver -> app.schemas.types", + "app.application.messaging.agent -> app.application", + "app.application.messaging.agent -> app.application.agent", + "app.application.messaging.agent -> app.application.commands", + "app.application.messaging.agent -> app.application.configuration", + "app.application.messaging.agent -> app.application.messaging", + "app.application.messaging.agent -> app.application.messaging.chat", + "app.application.messaging.agent -> app.application.messaging.router", "app.application.messaging.agent -> app.runtime", + "app.application.messaging.agent -> app.runtime.execution", "app.application.messaging.agent -> app.runtime.log", + "app.application.messaging.agent -> app.runtime.stop", "app.application.messaging.agent -> app.runtime.tasks", "app.application.messaging.agent -> app.schemas", "app.application.messaging.agent -> app.schemas.message", @@ -3876,6 +3877,8 @@ "app.application.music.catalog -> app.schemas", "app.application.music.catalog -> app.schemas.media", "app.application.music.catalog -> app.schemas.types", + "app.application.network -> app.foundation", + "app.application.network -> app.foundation.url", "app.application.notification -> app.application", "app.application.notification -> app.application.service", "app.application.notification -> app.schemas", @@ -3892,6 +3895,7 @@ "app.application.plugin.catalog -> app.application", "app.application.plugin.catalog -> app.application.plugin", "app.application.plugin.catalog -> app.application.plugin.identity", + "app.application.plugin.catalog -> app.application.plugin.inventory", "app.application.plugin.catalog -> app.schemas", "app.application.plugin.catalog -> app.schemas.plugin", "app.application.plugin.config -> app.schemas", @@ -3900,9 +3904,12 @@ "app.application.plugin.data -> app.schemas.common", "app.application.plugin.folders -> app.application", "app.application.plugin.folders -> app.application.configuration", + "app.application.plugin.folders -> app.application.plugin", + "app.application.plugin.folders -> app.application.plugin.runtime", "app.application.plugin.folders -> app.runtime", "app.application.plugin.folders -> app.runtime.log", "app.application.plugin.folders -> app.schemas", + "app.application.plugin.folders -> app.schemas.exception", "app.application.plugin.folders -> app.schemas.types", "app.application.plugin.gateway -> app.application", "app.application.plugin.gateway -> app.application.plugin", @@ -3947,6 +3954,12 @@ "app.application.plugin.recovery -> app.application.plugin.transaction", "app.application.plugin.recovery -> app.runtime", "app.application.plugin.recovery -> app.runtime.log", + "app.application.plugin.release -> app.application", + "app.application.plugin.release -> app.application.plugin", + "app.application.plugin.release -> app.application.plugin.catalog", + "app.application.plugin.release -> app.application.plugin.identity", + "app.application.plugin.release -> app.schemas", + "app.application.plugin.release -> app.schemas.plugin", "app.application.plugin.runtime -> app.schemas", "app.application.plugin.runtime -> app.schemas.types", "app.application.plugin.source -> app.application", @@ -8578,10 +8591,14 @@ "app.startup.initializers.agent -> app.agent.skills.registry", "app.startup.initializers.agent -> app.agent.tools", "app.startup.initializers.agent -> app.agent.tools.base", + "app.startup.initializers.agent -> app.agent.web", "app.startup.initializers.agent -> app.application", "app.startup.initializers.agent -> app.application.agent", "app.startup.initializers.agent -> app.application.messaging", + "app.startup.initializers.agent -> app.application.messaging.agent", "app.startup.initializers.agent -> app.application.messaging.skill", + "app.startup.initializers.agent -> app.chain", + "app.startup.initializers.agent -> app.chain.message", "app.startup.initializers.agent -> app.runtime", "app.startup.initializers.agent -> app.runtime.events", "app.startup.initializers.agent -> app.runtime.log", @@ -8684,6 +8701,7 @@ "app.startup.initializers.modules -> app.application.messaging.ingress", "app.startup.initializers.modules -> app.application.messaging.message", "app.startup.initializers.modules -> app.application.module", + "app.startup.initializers.modules -> app.application.network", "app.startup.initializers.modules -> app.application.outbox", "app.startup.initializers.modules -> app.application.plugin", "app.startup.initializers.modules -> app.application.plugin.runtime", @@ -8821,7 +8839,9 @@ "app.startup.initializers.plugins -> app.application.plugin.inventory", "app.startup.initializers.plugins -> app.application.plugin.lifecycle", "app.startup.initializers.plugins -> app.application.plugin.migration", + "app.startup.initializers.plugins -> app.application.plugin.rating", "app.startup.initializers.plugins -> app.application.plugin.recovery", + "app.startup.initializers.plugins -> app.application.plugin.release", "app.startup.initializers.plugins -> app.application.plugin.routes", "app.startup.initializers.plugins -> app.application.plugin.runtime", "app.startup.initializers.plugins -> app.application.plugin.transaction", @@ -8835,6 +8855,7 @@ "app.startup.initializers.plugins -> app.foundation", "app.startup.initializers.plugins -> app.foundation.version", "app.startup.initializers.plugins -> app.runtime", + "app.startup.initializers.plugins -> app.runtime.cache", "app.startup.initializers.plugins -> app.runtime.compat", "app.startup.initializers.plugins -> app.runtime.compat.diagnostics", "app.startup.initializers.plugins -> app.runtime.compat.resource_imports", @@ -9140,7 +9161,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 944, + "module_count": 947, "modules": [ "app", "app.adapters", @@ -9332,6 +9353,7 @@ "app.agent.tools.impl.write_file", "app.agent.tools.manager", "app.agent.tools.tags", + "app.agent.web", "app.api", "app.api.apiv1", "app.api.context", @@ -9443,7 +9465,9 @@ "app.application.plugin.inventory", "app.application.plugin.lifecycle", "app.application.plugin.migration", + "app.application.plugin.rating", "app.application.plugin.recovery", + "app.application.plugin.release", "app.application.plugin.routes", "app.application.plugin.runtime", "app.application.plugin.source", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 1b3433bf6..fcce4b751 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -822,14 +822,8 @@ "redundant-cast": 2 }, "app/api/endpoints/agent.py": { - "arg-type": 9, - "attr-defined": 5, - "dict-item": 3, - "import-untyped": 1, - "misc": 17, - "no-any-return": 8, - "type-arg": 36, - "var-annotated": 1 + "misc": 13, + "type-arg": 11 }, "app/api/endpoints/anilist.py": { "attr-defined": 1, @@ -959,7 +953,7 @@ "misc": 33, "no-any-return": 5, "no-untyped-call": 1, - "no-untyped-def": 3, + "no-untyped-def": 2, "type-arg": 11 }, "app/api/endpoints/recommend.py": { @@ -1125,9 +1119,7 @@ "arg-type": 1, "attr-defined": 1, "no-untyped-call": 1, - "no-untyped-def": 1, - "type-arg": 15, - "var-annotated": 1 + "no-untyped-def": 1 }, "app/application/messaging/chat.py": { "type-arg": 8 diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index d32ec93ca..de3a2d993 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -198,9 +198,6 @@ "app/agent/tools/impl/update_system_settings.py": { "I001": 1 }, - "app/api/endpoints/agent.py": { - "F841": 1 - }, "app/application/downloader.py": { "I001": 1 }, @@ -210,9 +207,6 @@ "app/application/maintenance.py": { "I001": 1 }, - "app/application/messaging/agent.py": { - "I001": 1 - }, "app/application/messaging/session.py": { "I001": 1 }, @@ -866,9 +860,6 @@ "tests/test_agent_background_output.py": { "I001": 1 }, - "tests/test_agent_chat_persistence.py": { - "I001": 1 - }, "tests/test_agent_lazy_runtime_boundary.py": { "F401": 1 }, @@ -1194,9 +1185,6 @@ "tests/test_plugin_monitor_lifecycle.py": { "I001": 1 }, - "tests/test_plugin_rating.py": { - "I001": 1 - }, "tests/test_plugin_sdk.py": { "I001": 3 }, diff --git a/tests/test_agent_api_lazy_imports.py b/tests/test_agent_api_lazy_imports.py index 14c859548..e23135f71 100644 --- a/tests/test_agent_api_lazy_imports.py +++ b/tests/test_agent_api_lazy_imports.py @@ -34,7 +34,7 @@ def _run_isolated(script: str, config_dir: Path) -> dict: def test_full_api_openapi_keeps_agent_runtime_cold(tmp_path: Path) -> None: """完整路由与 OpenAPI 注册不得物化 Agent、工具或模型运行时。""" result = _run_isolated( - r''' + r""" import json import socket import sys @@ -91,7 +91,7 @@ print(json.dumps({ "missing_paths": sorted(required_paths - paths), "network_attempts": network_attempts, })) -''', +""", tmp_path / "router-import", ) @@ -107,7 +107,7 @@ def test_disabled_protocol_requests_preserve_503_without_runtime_load( ) -> None: """禁用态兼容协议保持 503,并且不会因构造响应加载 Agent。""" result = _run_isolated( - r''' + r""" import asyncio import json import socket @@ -193,7 +193,7 @@ print(json.dumps({ "status_codes": [response.status_code for response in protocol_responses], "bodies": [json.loads(response.body) for response in protocol_responses], }, ensure_ascii=False)) -''', +""", tmp_path / "disabled-requests", ) @@ -208,7 +208,7 @@ print(json.dumps({ def test_runtime_agent_type_factories_are_single_flight(tmp_path: Path) -> None: """并发首次解析必须返回同一 class,避免会话复用误判构造器已变化。""" result = _run_isolated( - r''' + r""" import json import sys import threading @@ -220,7 +220,8 @@ sites.SitesHelper = type("SitesHelper", (), {}) sites.__file__ = "" sys.modules["app.application.site.sites"] = sites -from app.api.endpoints import agent, openai +from app.agent import web +from app.api.endpoints import openai def exercise(module, factory_name, getter_name): calls = [] @@ -252,7 +253,7 @@ def exercise(module, factory_name, getter_name): return len(calls), all(result is results[0] for result in results) web_calls, web_identity = exercise( - agent, + web, "_get_web_agent_type", "get_moviepilot_agent_type", ) @@ -267,7 +268,7 @@ print(json.dumps({ "collecting_calls": collecting_calls, "collecting_identity": collecting_identity, })) -''', +""", tmp_path / "agent-type-single-flight", ) @@ -284,7 +285,7 @@ def test_persistent_protocol_agent_rebinds_stream_queue_without_stale_output( ) -> None: """稳定协议会话复用 Agent 时必须保留 handler identity 并切换请求队列。""" result = _run_isolated( - r''' + r""" import asyncio import json import sys @@ -332,7 +333,7 @@ print(json.dumps({ "second_empty": second_queue.empty(), "released": handler._event_queue is None, })) -''', +""", tmp_path / "protocol-stream-rebind", ) @@ -350,7 +351,7 @@ print(json.dumps({ def test_protocol_routes_follow_agent_service_lifecycle(tmp_path: Path) -> None: """服务未运行时返回 503,运行态仍执行原有兼容协议响应流程。""" result = _run_isolated( - r''' + r""" import asyncio import json import socket @@ -463,7 +464,7 @@ print(json.dumps({ "available_anthropic": available[1].content[0].text, "network_attempts": network_attempts, }, ensure_ascii=False)) -''', +""", tmp_path / "protocol-service-lifecycle", ) diff --git a/tests/test_agent_chat_persistence.py b/tests/test_agent_chat_persistence.py index 90cc782f4..2439c81f0 100644 --- a/tests/test_agent_chat_persistence.py +++ b/tests/test_agent_chat_persistence.py @@ -11,18 +11,18 @@ from uuid import uuid4 import pytest from sqlalchemy import delete, select -from app.schemas.exception import ( - AgentChatPersistenceUnavailableError, - DatabaseWorkerOverloadedError, -) -from app.application.messaging.chat import AgentChatPersistenceService, AgentChatService from app.api.endpoints.agent import save_agent_chat_display +from app.application.messaging.chat import AgentChatPersistenceService, AgentChatService from app.db.models.agentchat import AgentChat from app.db.oper.agentchat import AgentChatOper from app.db.session import SessionFactory, async_session_scope from app.db.uow import run_sync_transaction -from app.schemas.agent import AgentChatDisplaySaveRequest from app.db.worker import DatabaseWorker +from app.schemas.agent import AgentChatDisplaySaveRequest +from app.schemas.exception import ( + AgentChatPersistenceUnavailableError, + DatabaseWorkerOverloadedError, +) class _Executor: @@ -178,7 +178,7 @@ async def test_authoritative_display_save_propagates_worker_overload() -> None: @pytest.mark.asyncio async def test_authoritative_display_save_reads_fresh_projection_after_worker_write( - monkeypatch, + monkeypatch, ) -> None: """权威展示保存的响应必须读取 worker 提交后的最新投影。""" existing_chat = SimpleNamespace( @@ -218,9 +218,7 @@ async def test_authoritative_display_save_reads_fresh_projection_after_worker_wr assert response.success is True assert response.data == "fresh-summary" - canonical_service.get_accessible.assert_awaited_once_with( - "fresh-session", current_user - ) + canonical_service.get_accessible.assert_awaited_once_with("fresh-session", current_user) @pytest.mark.asyncio @@ -249,9 +247,7 @@ async def test_agent_chat_persistence_rolls_back_compound_write(monkeypatch) -> messages=[{"role": "user", "content": "not committed"}], ) async with async_session_scope() as session: - result = await session.execute( - select(AgentChat).where(AgentChat.session_id == session_id) - ) + result = await session.execute(select(AgentChat).where(AgentChat.session_id == session_id)) assert result.scalars().first() is None finally: await worker.shutdown() @@ -335,27 +331,13 @@ async def test_agent_chat_persistence_session_admission_is_fair() -> None: capacity=4, session_capacity=2, ) - first = asyncio.create_task( - service.async_save_agent_messages( - session_id="hot-session", user_id="1", messages=[] - ) - ) + first = asyncio.create_task(service.async_save_agent_messages(session_id="hot-session", user_id="1", messages=[])) await executor.started.wait() - second = asyncio.create_task( - service.async_save_agent_messages( - session_id="hot-session", user_id="1", messages=[] - ) - ) + second = asyncio.create_task(service.async_save_agent_messages(session_id="hot-session", user_id="1", messages=[])) await asyncio.sleep(0) with pytest.raises(AgentChatPersistenceUnavailableError): - await service.async_save_agent_messages( - session_id="hot-session", user_id="1", messages=[] - ) - other = asyncio.create_task( - service.async_save_agent_messages( - session_id="other-session", user_id="1", messages=[] - ) - ) + await service.async_save_agent_messages(session_id="hot-session", user_id="1", messages=[]) + other = asyncio.create_task(service.async_save_agent_messages(session_id="other-session", user_id="1", messages=[])) await asyncio.sleep(0) assert not other.done() executor.release.set() @@ -385,18 +367,14 @@ async def test_agent_chat_persistence_shutdown_drains_active_writes() -> None: sync_transaction=lambda operation: operation(object()), ) write = asyncio.create_task( - service.async_save_agent_messages( - session_id="shutdown-session", user_id="1", messages=[] - ) + service.async_save_agent_messages(session_id="shutdown-session", user_id="1", messages=[]) ) await executor.started.wait() shutdown = asyncio.create_task(service.shutdown()) await asyncio.sleep(0) assert not shutdown.done() with pytest.raises(AgentChatPersistenceUnavailableError): - await service.async_save_agent_messages( - session_id="new-session", user_id="1", messages=[] - ) + await service.async_save_agent_messages(session_id="new-session", user_id="1", messages=[]) executor.release.set() await write await shutdown @@ -515,32 +493,22 @@ async def test_agent_chat_persistence_serializes_same_session_writes() -> None: user_id="worker-race-user", messages=[{"role": "user", "content": "seed"}], ) - await asyncio.gather( - *(append_existing(f"existing-{index}") for index in range(4)) - ) + await asyncio.gather(*(append_existing(f"existing-{index}") for index in range(4))) async with async_session_scope() as session: result = await session.execute( - select(AgentChat).where( - AgentChat.session_id.in_((session_id, existing_session_id)) - ) + select(AgentChat).where(AgentChat.session_id.in_((session_id, existing_session_id))) ) rows = list(result.scalars().all()) assert len(rows) == 2 row_by_session = {row.session_id: row for row in rows} - assert { - message["content"] - for message in row_by_session[session_id].display_messages - } == {f"message-{index}" for index in range(4)} - assert { - message["content"] - for message in row_by_session[existing_session_id].display_messages - } == {"seed"} | {f"existing-{index}" for index in range(4)} + assert {message["content"] for message in row_by_session[session_id].display_messages} == { + f"message-{index}" for index in range(4) + } + assert {message["content"] for message in row_by_session[existing_session_id].display_messages} == {"seed"} | { + f"existing-{index}" for index in range(4) + } finally: with SessionFactory() as session: - session.execute( - delete(AgentChat).where( - AgentChat.session_id.in_((session_id, existing_session_id)) - ) - ) + session.execute(delete(AgentChat).where(AgentChat.session_id.in_((session_id, existing_session_id)))) session.commit() await worker.shutdown() diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 9eaf2b9f9..e0ce7f88a 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -19,9 +19,7 @@ from scripts.architecture.baseline import ( PROJECT_ROOT = Path(__file__).parents[1] APP_ROOT = PROJECT_ROOT / "app" -DEPENDENCY_POLICY_PATH = ( - PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "dependency-policy.json" -) +DEPENDENCY_POLICY_PATH = PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "dependency-policy.json" LEGACY_ROOTS = ("app.core", "app.helper", "app.utils") LEGACY_MODULES = {"app.log"} IMPLEMENTATION_ROOTS = ( @@ -197,9 +195,7 @@ def _legacy_imports(path: Path) -> set[str]: if isinstance(argument, ast.Constant) and isinstance(argument.value, str): candidates.append(argument.value) imports.update( - candidate - for candidate in candidates - if candidate in LEGACY_MODULES or candidate.startswith(LEGACY_ROOTS) + candidate for candidate in candidates if candidate in LEGACY_MODULES or candidate.startswith(LEGACY_ROOTS) ) return imports @@ -220,9 +216,7 @@ def _attribute_parts(node: ast.Attribute) -> list[str]: def _compat_symbol_references(tree: ast.AST) -> set[tuple[int, str]]: """收集显式导入或静态属性访问命中的兼容符号。""" compatibility_symbols = { - (module_name, symbol_name) - for module_name, symbols in SYMBOL_ALIASES.items() - for symbol_name in symbols + (module_name, symbol_name) for module_name, symbols in SYMBOL_ALIASES.items() for symbol_name in symbols } module_bindings: dict[str, str] = {} references: set[tuple[int, str]] = set() @@ -259,11 +253,7 @@ def _compat_symbol_references(tree: ast.AST) -> set[tuple[int, str]]: def _class_annotations(path: Path, class_name: str) -> dict[str, str]: """返回指定类的源码级字段注解。""" tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) - class_node = next( - node - for node in tree.body - if isinstance(node, ast.ClassDef) and node.name == class_name - ) + class_node = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == class_name) return { node.target.id: ast.unparse(node.annotation) for node in class_node.body @@ -283,21 +273,13 @@ def test_legacy_roots_contain_no_python_sources(): def test_legacy_source_directories_do_not_exist(): """core/helper/utils 物理目录应完全退役,旧导入只由虚拟兼容包解析。""" - leftovers = [ - root_name - for root_name in ("core", "helper", "utils") - if (APP_ROOT / root_name).exists() - ] + leftovers = [root_name for root_name in ("core", "helper", "utils") if (APP_ROOT / root_name).exists()] assert leftovers == [] def test_retired_canonical_filenames_do_not_return(): """能力包应使用包内语境明确的短文件名,避免再次出现冗余角色后缀。""" - leftovers = [ - relative_path - for relative_path in RETIRED_CANONICAL_FILES - if (PROJECT_ROOT / relative_path).exists() - ] + leftovers = [relative_path for relative_path in RETIRED_CANONICAL_FILES if (PROJECT_ROOT / relative_path).exists()] assert leftovers == [] @@ -324,10 +306,7 @@ def test_runtime_dependencies_use_same_named_single_word_package() -> None: continue tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) for node in ast.walk(tree): - if ( - isinstance(node, ast.ImportFrom) - and node.module == "app.runtime.dependencies" - ): + if isinstance(node, ast.ImportFrom) and node.module == "app.runtime.dependencies": violations.append(f"{relative}:{node.lineno}:from-package-root") elif isinstance(node, ast.Import): violations.extend( @@ -345,8 +324,7 @@ def test_workflow_query_contract_returns_only_typed_snapshots(): query_classes = { node.name: node for node in tree.body - if isinstance(node, ast.ClassDef) - and node.name in {"WorkflowQueryRepository", "WorkflowQueryService"} + if isinstance(node, ast.ClassDef) and node.name in {"WorkflowQueryRepository", "WorkflowQueryService"} } methods = [ node @@ -410,16 +388,12 @@ def test_workflow_execution_chain_uses_single_application_owned_port(): filename=str(contract_path), ) contract = next( - node - for node in contract_tree.body - if isinstance(node, ast.ClassDef) - and node.name == "WorkflowExecutionPort" + node for node in contract_tree.body if isinstance(node, ast.ClassDef) and node.name == "WorkflowExecutionPort" ) methods = { node.name: ast.unparse(node.returns) for node in contract.body - if isinstance(node, ast.FunctionDef) - and node.returns is not None + if isinstance(node, ast.FunctionDef) and node.returns is not None } assert methods == { "start": "bool", @@ -430,9 +404,7 @@ def test_workflow_execution_chain_uses_single_application_owned_port(): } chain_source = (APP_ROOT / "chain" / "workflow.py").read_text(encoding="utf-8") - startup_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") + startup_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") assert chain_source.count("get_configured_workflow_execution()") == 1 assert "get_chain_workflow_port" not in chain_source assert "configure_workflow_execution(workflow_execution)" in startup_source @@ -458,25 +430,16 @@ def test_chain_runtime_context_owns_typed_repository_instances(): } assert {name: annotations[name] for name in expected} == expected - chain_base_source = (APP_ROOT / "chain" / "base.py").read_text( - encoding="utf-8-sig" - ) + chain_base_source = (APP_ROOT / "chain" / "base.py").read_text(encoding="utf-8-sig") for field_name in expected: assert f"self.{field_name} = context.{field_name}" in chain_base_source download_source = "\n".join( - path.read_text(encoding="utf-8") - for path in sorted((APP_ROOT / "chain" / "download").glob("*.py")) + path.read_text(encoding="utf-8") for path in sorted((APP_ROOT / "chain" / "download").glob("*.py")) ) - mediaserver_source = (APP_ROOT / "chain" / "mediaserver.py").read_text( - encoding="utf-8" - ) - application_source = ( - APP_ROOT / "application" / "mediaserver.py" - ).read_text(encoding="utf-8") - startup_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") + mediaserver_source = (APP_ROOT / "chain" / "mediaserver.py").read_text(encoding="utf-8") + application_source = (APP_ROOT / "application" / "mediaserver.py").read_text(encoding="utf-8") + startup_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") assert "DownloadFailure = Any" not in download_source assert "dboper" not in mediaserver_source assert "async def async_get_item_id(" in application_source @@ -543,16 +506,10 @@ def test_startup_composes_typed_chain_and_agent_data_contexts(): constructors = [ node for node in ast.walk(startup_tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == class_name + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == class_name ] assert len(constructors) == 1 - values = { - keyword.arg: keyword.value - for keyword in constructors[0].keywords - if keyword.arg is not None - } + values = {keyword.arg: keyword.value for keyword in constructors[0].keywords if keyword.arg is not None} assert expected_fields <= values.keys() assert all(not isinstance(values[field_name], ast.Lambda) for field_name in expected_fields) @@ -564,11 +521,7 @@ def test_download_history_ports_are_typed_detached_and_canonically_injected(): history_path.read_text(encoding="utf-8"), filename=str(history_path), ) - classes = { - node.name: node - for node in history_tree.body - if isinstance(node, ast.ClassDef) - } + classes = {node.name: node for node in history_tree.body if isinstance(node, ast.ClassDef)} for class_name in ( "DownloadHistorySnapshot", "DownloadFileSnapshot", @@ -578,22 +531,16 @@ def test_download_history_ports_are_typed_detached_and_canonically_injected(): decorator = next( item for item in classes[class_name].decorator_list - if isinstance(item, ast.Call) - and isinstance(item.func, ast.Name) - and item.func.id == "dataclass" + if isinstance(item, ast.Call) and isinstance(item.func, ast.Name) and item.func.id == "dataclass" ) - keywords = { - item.arg: ast.literal_eval(item.value) - for item in decorator.keywords - } + keywords = {item.arg: ast.literal_eval(item.value) for item in decorator.keywords} assert keywords == {"frozen": True, "slots": True} for class_name in ("DownloadHistoryQueryPort", "DownloadHistoryWritePort"): annotations = [ ast.unparse(node.returns) for node in classes[class_name].body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - and node.returns is not None + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None ] assert annotations assert all("Any" not in annotation for annotation in annotations) @@ -622,24 +569,18 @@ def test_download_history_ports_are_typed_detached_and_canonically_injected(): assert "DownloadHistory = Any" not in source assert "DownloadFiles = Any" not in source - startup_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") + startup_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") assert "TransactionalDownloadHistoryRepository(" in startup_source assert "SessionDownloadHistoryRepository" in startup_source assert "DownloadHistoryOper" not in startup_source - adapter_source = ( - APP_ROOT / "db" / "adapters" / "history" / "download.py" - ).read_text(encoding="utf-8") + adapter_source = (APP_ROOT / "db" / "adapters" / "history" / "download.py").read_text(encoding="utf-8") assert "class TransactionalDownloadHistoryRepository" in adapter_source assert "class SessionDownloadHistoryRepository" in adapter_source assert "_project_history" in adapter_source assert "SqlAlchemyUnitOfWork" in adapter_source - legacy_source = ( - APP_ROOT / "sdk" / "_legacy" / "transfer.py" - ).read_text(encoding="utf-8") + legacy_source = (APP_ROOT / "sdk" / "_legacy" / "transfer.py").read_text(encoding="utf-8") assert "download_history: Optional[Any]" in legacy_source @@ -651,14 +592,12 @@ def test_user_configuration_uses_typed_transactional_adapter(): repository = next( node for node in application_tree.body - if isinstance(node, ast.ClassDef) - and node.name == "UserConfigurationRepository" + if isinstance(node, ast.ClassDef) and node.name == "UserConfigurationRepository" ) returns = { node.name: ast.unparse(node.returns) for node in repository.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - and node.returns is not None + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None } assert returns == { "get": "JsonData", @@ -668,9 +607,7 @@ def test_user_configuration_uses_typed_transactional_adapter(): } assert "Any" not in application_source - startup_source = ( - APP_ROOT / "startup" / "initializers" / "modules.py" - ).read_text(encoding="utf-8") + startup_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") assert "TransactionalUserConfigurationRepository(SessionFactory)" in startup_source assert "UserConfigOper" not in startup_source @@ -680,9 +617,7 @@ def test_user_configuration_uses_typed_transactional_adapter(): assert "SqlAlchemyUnitOfWork" in adapter_source assert "Any" not in adapter_source - oper_source = ( - APP_ROOT / "db" / "oper" / "userconfig.py" - ).read_text(encoding="utf-8") + oper_source = (APP_ROOT / "db" / "oper" / "userconfig.py").read_text(encoding="utf-8") assert "def stage_set(" in oper_source assert "def set(" in oper_source assert "Any" not in oper_source @@ -695,22 +630,12 @@ def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports(): oper_path.read_text(encoding="utf-8"), filename=str(oper_path), ) - oper_class = next( - node - for node in oper_tree.body - if isinstance(node, ast.ClassDef) and node.name == "WorkflowOper" - ) - method_names = { - node.name - for node in oper_class.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - } + oper_class = next(node for node in oper_tree.body if isinstance(node, ast.ClassDef) and node.name == "WorkflowOper") + method_names = {node.name for node in oper_class.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} assert {"start", "success", "fail", "step", "reset"}.isdisjoint(method_names) assert "legacy" not in oper_path.read_text(encoding="utf-8").lower() - package_source = (APP_ROOT / "db" / "oper" / "__init__.py").read_text( - encoding="utf-8" - ) + package_source = (APP_ROOT / "db" / "oper" / "__init__.py").read_text(encoding="utf-8") assert '"WorkflowOper"' not in package_source @@ -742,11 +667,7 @@ def test_startup_root_contains_only_composition_packages(): """组合根顶层只保留稳定分区,禁止再次堆叠扁平实现文件。""" startup_root = APP_ROOT / "startup" root_modules = sorted(path.name for path in startup_root.glob("*.py")) - python_packages = sorted( - path.name - for path in startup_root.iterdir() - if path.is_dir() and any(path.rglob("*.py")) - ) + python_packages = sorted(path.name for path in startup_root.iterdir() if path.is_dir() and any(path.rglob("*.py"))) assert root_modules == ["__init__.py"] assert python_packages == ["composition", "initializers", "lifecycle"] @@ -813,8 +734,7 @@ def test_host_code_does_not_use_compat_symbol_aliases() -> None: continue tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) violations.extend( - f"{relative.as_posix()}:{line}:{symbol}" - for line, symbol in sorted(_compat_symbol_references(tree)) + f"{relative.as_posix()}:{line}:{symbol}" for line, symbol in sorted(_compat_symbol_references(tree)) ) assert violations == [] @@ -837,16 +757,9 @@ def test_host_code_uses_explicit_runtime_facade_getters(): tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) for node in ast.walk(tree): if isinstance(node, ast.Assign) and isinstance(node.value, ast.Name): - target_names = { - target.id for target in node.targets if isinstance(target, ast.Name) - } - if ( - "SystemConfigOper" in target_names - and node.value.id == "get_configured_system_config" - ): - violations.append( - f"{relative.as_posix()}:{node.lineno}:SystemConfigOper" - ) + target_names = {target.id for target in node.targets if isinstance(target, ast.Name)} + if "SystemConfigOper" in target_names and node.value.id == "get_configured_system_config": + violations.append(f"{relative.as_posix()}:{node.lineno}:SystemConfigOper") continue if not isinstance(node, ast.ImportFrom) or not node.module: continue @@ -862,15 +775,9 @@ def test_host_code_uses_explicit_runtime_facade_getters(): and alias.name == "get_configured_system_config" and alias.asname is not None ) - if ( - alias.name in forbidden_names - or class_shaped_plugin_getter - or class_shaped_config_getter - ): + if alias.name in forbidden_names or class_shaped_plugin_getter or class_shaped_config_getter: imported_name = alias.asname or alias.name - violations.append( - f"{relative.as_posix()}:{node.lineno}:{imported_name}" - ) + violations.append(f"{relative.as_posix()}:{node.lineno}:{imported_name}") assert violations == [] @@ -935,19 +842,13 @@ def test_transfer_pending_oper_import_is_confined_to_database_boundary(): tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) for node in ast.walk(tree): if isinstance(node, ast.Import): - if any( - alias.name == "app.db.oper.transferpending" - for alias in node.names - ): + if any(alias.name == "app.db.oper.transferpending" for alias in node.names): violations.append(f"{relative}:{node.lineno}") elif isinstance(node, ast.ImportFrom) and ( node.module == "app.db.oper.transferpending" or ( node.module == "app.db.oper" - and any( - alias.name in {"transferpending", "TransferPendingOper"} - for alias in node.names - ) + and any(alias.name in {"transferpending", "TransferPendingOper"} for alias in node.names) ) ): violations.append(f"{relative}:{node.lineno}") @@ -962,27 +863,18 @@ def test_startup_injects_transactional_transfer_admission_repository(): imports_repository = any( isinstance(node, ast.ImportFrom) and node.module == "app.db.adapters.transfer.admission" - and any( - alias.name == "TransactionalTransferAdmissionRepository" - for alias in node.names - ) + and any(alias.name == "TransactionalTransferAdmissionRepository" for alias in node.names) for node in ast.walk(tree) ) chain_context_calls = [ node for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "ChainRuntimeContext" + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "ChainRuntimeContext" ] assert imports_repository is True assert len(chain_context_calls) == 1 - keywords = { - keyword.arg: keyword.value - for keyword in chain_context_calls[0].keywords - if keyword.arg is not None - } + keywords = {keyword.arg: keyword.value for keyword in chain_context_calls[0].keywords if keyword.arg is not None} admission = keywords["transfer_admission_repository"] assert isinstance(admission, ast.Call) assert isinstance(admission.func, ast.Name) @@ -1009,9 +901,7 @@ def test_scheduler_does_not_depend_on_database_implementation(): ) ) assert { - dependency - for dependency in dependencies - if dependency == "app.db" or dependency.startswith("app.db.") + dependency for dependency in dependencies if dependency == "app.db" or dependency.startswith("app.db.") } == set() @@ -1046,22 +936,15 @@ def test_canonical_service_config_consumers_use_application_directory(): for path in paths: tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) for node in ast.walk(tree): - if ( - isinstance(node, ast.ImportFrom) - and node.module == "app.runtime.extensions.service_config" - ): - violations.append( - f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}" - ) + if isinstance(node, ast.ImportFrom) and node.module == "app.runtime.extensions.service_config": + violations.append(f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}") if ( path.name == "mediaserver.py" and isinstance(node, ast.ImportFrom) and node.module == "app.application.mediaserver" and any(alias.name == "MediaServerHelper" for alias in node.names) ): - violations.append( - f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}:MediaServerHelper" - ) + violations.append(f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}:MediaServerHelper") assert violations == [] @@ -1081,33 +964,19 @@ def test_plugin_components_do_not_reexport_legacy_abi_names(): violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.name}") elif isinstance(node, ast.ImportFrom): for alias in node.names: - is_legacy_name = ( - alias.name in PLUGIN_LEGACY_ABI_NAMES - or alias.name.endswith("Oper") - ) + is_legacy_name = alias.name in PLUGIN_LEGACY_ABI_NAMES or alias.name.endswith("Oper") is_private = bool(alias.asname and alias.asname.startswith("_")) if is_legacy_name and not is_private: - violations.append( - f"{path.relative_to(PROJECT_ROOT)}:{alias.name}" - ) + violations.append(f"{path.relative_to(PROJECT_ROOT)}:{alias.name}") elif isinstance(node, (ast.Assign, ast.AnnAssign)): targets = node.targets if isinstance(node, ast.Assign) else [node.target] - names = { - target.id - for target in targets - if isinstance(target, ast.Name) - } + names = {target.id for target in targets if isinstance(target, ast.Name)} forbidden = { name for name in names - if name == "__all__" - or name in PLUGIN_LEGACY_ABI_NAMES - or name.endswith("Oper") + if name == "__all__" or name in PLUGIN_LEGACY_ABI_NAMES or name.endswith("Oper") } - violations.extend( - f"{path.relative_to(PROJECT_ROOT)}:{name}" - for name in sorted(forbidden) - ) + violations.extend(f"{path.relative_to(PROJECT_ROOT)}:{name}" for name in sorted(forbidden)) assert violations == [] @@ -1138,9 +1007,7 @@ def test_database_internals_do_not_import_db_facades(): continue tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) if any( - isinstance(node, ast.ImportFrom) - and node.module in {"app.db", "app.db.models"} - for node in ast.walk(tree) + isinstance(node, ast.ImportFrom) and node.module in {"app.db", "app.db.models"} for node in ast.walk(tree) ): violations.append(str(path.relative_to(PROJECT_ROOT))) assert violations == [] @@ -1186,11 +1053,7 @@ def test_models_and_base_require_explicit_database_sessions(): if isinstance(node, ast.ImportFrom) and node.module == "app.db.decorators": violations.append(f"{relative}:{node.lineno}:decorator-import") if path.name == "base.py": - base_class = next( - node - for node in tree.body - if isinstance(node, ast.ClassDef) and node.name == "Base" - ) + base_class = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Base") nodes = list(ast.walk(base_class)) for node in nodes: if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): @@ -1206,17 +1069,13 @@ def test_models_and_base_require_explicit_database_sessions(): if name in decorator_names: violations.append(f"{relative}:{node.lineno}:@{name}") arguments = [*node.args.posonlyargs, *node.args.args] - defaults = [None] * (len(arguments) - len(node.args.defaults)) + list( - node.args.defaults - ) + defaults = [None] * (len(arguments) - len(node.args.defaults)) + list(node.args.defaults) for argument, default in zip(arguments, defaults): if argument.arg != "db": continue annotation = ast.unparse(argument.annotation) if argument.annotation else "" if default is not None or "None" in annotation: - violations.append( - f"{relative}:{node.lineno}:{node.name}:optional-db" - ) + violations.append(f"{relative}:{node.lineno}:{node.name}:optional-db") assert violations == [] @@ -1230,13 +1089,13 @@ def test_plugin_sdk_does_not_import_or_export_host_models(): relative = path.relative_to(PROJECT_ROOT).as_posix() tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module and ( - node.module == "app.db.models" - or node.module.startswith("app.db.models.") - ) and (relative, node.module) not in internal_compat_imports: - violations.append( - f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:{node.module}" - ) + if ( + isinstance(node, ast.ImportFrom) + and node.module + and (node.module == "app.db.models" or node.module.startswith("app.db.models.")) + and (relative, node.module) not in internal_compat_imports + ): + violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:{node.module}") assert violations == [] @@ -1255,14 +1114,9 @@ def test_entry_layers_do_not_import_database_implementations(): "app.adapters", ) violations = { - source: sorted( - dependency - for dependency in dependencies - if dependency.startswith("app.db") - ) + source: sorted(dependency for dependency in dependencies if dependency.startswith("app.db")) for source, dependencies in graph.items() - if source.startswith(layer_roots) - and any(dependency.startswith("app.db") for dependency in dependencies) + if source.startswith(layer_roots) and any(dependency.startswith("app.db") for dependency in dependencies) } assert violations == {} @@ -1271,10 +1125,7 @@ def test_migrated_modules_are_not_in_import_cycles(): """任何 canonical 迁移模块都不得进入完整应用依赖图的环。""" modules = _discover_modules() known_modules = set(modules) - graph = { - name: _resolve_imports(name, path, known_modules) - for name, path in modules.items() - } + graph = {name: _resolve_imports(name, path, known_modules) for name, path in modules.items()} relevant_cycles = [ sorted(component) for component in _strongly_connected_components(graph) @@ -1295,9 +1146,7 @@ def test_canonical_layers_do_not_depend_on_sdk_or_compat(): continue dependencies = _resolve_imports(module_name, path, known_modules) forbidden = { - dependency - for dependency in dependencies - if dependency.startswith(("app.sdk", "app.runtime.compat")) + dependency for dependency in dependencies if dependency.startswith(("app.sdk", "app.runtime.compat")) } if forbidden: violations[module_name] = forbidden @@ -1311,20 +1160,14 @@ def test_capability_packages_do_not_import_forbidden_upper_layers(): violations: dict[str, set[str]] = {} for module_name, path in modules.items(): source_root = next( - ( - root - for root in FORBIDDEN_IMPORT_PREFIXES - if module_name == root or module_name.startswith(f"{root}.") - ), + (root for root in FORBIDDEN_IMPORT_PREFIXES if module_name == root or module_name.startswith(f"{root}.")), None, ) if not source_root: continue dependencies = _resolve_imports(module_name, path, known_modules) forbidden = { - dependency - for dependency in dependencies - if dependency.startswith(FORBIDDEN_IMPORT_PREFIXES[source_root]) + dependency for dependency in dependencies if dependency.startswith(FORBIDDEN_IMPORT_PREFIXES[source_root]) } if forbidden: violations[module_name] = forbidden @@ -1346,9 +1189,7 @@ def test_application_does_not_import_transport_frameworks(): forbidden.update( candidate for candidate in candidates - if candidate.startswith( - ("fastapi", "starlette", "app.api", "app.adapters.web") - ) + if candidate.startswith(("fastapi", "starlette", "app.api", "app.adapters.web")) ) if forbidden: violations[str(path.relative_to(PROJECT_ROOT))] = forbidden @@ -1410,11 +1251,7 @@ def test_runtime_log_is_a_dependency_leaf(): modules["app.runtime.log"], set(modules), ) - assert { - dependency - for dependency in dependencies - if dependency.startswith("app.") - } == set() + assert {dependency for dependency in dependencies if dependency.startswith("app.")} == set() def test_foundation_does_not_emit_runtime_logs(): @@ -1423,9 +1260,7 @@ def test_foundation_does_not_emit_runtime_logs(): for path in (APP_ROOT / "foundation").rglob("*.py"): tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) for node in ast.walk(tree): - if isinstance(node, ast.Import) and any( - alias.name == "logging" for alias in node.names - ): + if isinstance(node, ast.Import) and any(alias.name == "logging" for alias in node.names): violations.append(str(path.relative_to(PROJECT_ROOT))) break if isinstance(node, ast.ImportFrom) and node.module in { @@ -1434,11 +1269,7 @@ def test_foundation_does_not_emit_runtime_logs(): }: violations.append(str(path.relative_to(PROJECT_ROOT))) break - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "print" - ): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "print": violations.append(str(path.relative_to(PROJECT_ROOT))) break assert violations == [] @@ -1452,11 +1283,7 @@ def test_cache_contract_does_not_import_concrete_adapters(): modules["app.runtime.cache"], set(modules), ) - assert { - dependency - for dependency in dependencies - if dependency.startswith("app.adapters.cache") - } == set() + assert {dependency for dependency in dependencies if dependency.startswith("app.adapters.cache")} == set() def test_passkey_application_does_not_select_cache_backend(): @@ -1470,11 +1297,7 @@ def test_passkey_application_does_not_select_cache_backend(): ) source = path.read_text(encoding="utf-8-sig") - assert { - dependency - for dependency in dependencies - if dependency.startswith("app.adapters.cache") - } == set() + assert {dependency for dependency in dependencies if dependency.startswith("app.adapters.cache")} == set() assert "RedisHelper" not in source assert ".is_redis(" not in source @@ -1521,10 +1344,7 @@ def test_modules_do_not_import_other_modules_or_chain(): dependency for dependency in dependencies if dependency.startswith("app.chain") - or ( - dependency.startswith("app.modules.") - and dependency.split(".")[2] not in (own_package, "_base") - ) + or (dependency.startswith("app.modules.") and dependency.split(".")[2] not in (own_package, "_base")) } if forbidden: violations[module_name] = forbidden @@ -1541,11 +1361,7 @@ def test_entrypoints_do_not_import_module_internals(): if not module_name.startswith(entrypoint_roots): continue dependencies = _resolve_imports(module_name, path, known_modules) - forbidden = { - dependency - for dependency in dependencies - if dependency.startswith("app.modules.") - } + forbidden = {dependency for dependency in dependencies if dependency.startswith("app.modules.")} if forbidden: violations[module_name] = forbidden assert violations == {} @@ -1582,11 +1398,7 @@ def test_chain_does_not_import_module_internals(): if not module_name.startswith("app.chain"): continue dependencies = _resolve_imports(module_name, path, known_modules) - forbidden = { - dependency - for dependency in dependencies - if dependency.startswith("app.modules.") - } + forbidden = {dependency for dependency in dependencies if dependency.startswith("app.modules.")} if forbidden: violations[module_name] = forbidden assert violations == {} @@ -1600,10 +1412,7 @@ def _build_module_graph() -> dict[str, set[str]]: """ modules = _discover_modules() known_modules = set(modules) - return { - name: _resolve_imports(name, path, known_modules) - for name, path in modules.items() - } + return {name: _resolve_imports(name, path, known_modules) for name, path in modules.items()} def _load_dependency_policy() -> dict: @@ -1616,10 +1425,7 @@ def _scc_policy_violations( entries: list[dict], ) -> tuple[list[list[str]], list[list[str]]]: """返回未审查 SCC 与已经失效但未清理的 policy SCC。""" - actual = { - tuple(component) - for component in _strongly_connected_components(graph) - } + actual = {tuple(component) for component in _strongly_connected_components(graph)} reviewed = {tuple(entry["modules"]) for entry in entries} return ( [list(component) for component in sorted(actual - reviewed)], @@ -1646,16 +1452,11 @@ def test_complete_host_sccs_match_reviewed_policy() -> None: all_members = [member for entry in entries for member in entry["modules"]] assert len(all_members) == len(set(all_members)) - vendor = next( - entry for entry in entries if entry["classification"] == "contained_vendor" - ) + vendor = next(entry for entry in entries if entry["classification"] == "contained_vendor") assert vendor["id"] == "themoviedb-vendored-package" assert vendor["tracking"] == "replace-or-upgrade-vendored-package" assert len(vendor["modules"]) == 29 - assert all( - member.startswith("app.modules.themoviedb") - for member in vendor["modules"] - ) + assert all(member.startswith("app.modules.themoviedb") for member in vendor["modules"]) unreviewed, stale = _scc_policy_violations(_build_module_graph(), entries) assert unreviewed == [] @@ -1706,10 +1507,7 @@ def test_contained_vendor_scc_may_have_one_way_outbound_dependency() -> None: def test_host_dependency_graph_excludes_plugin_copies() -> None: """`app/plugins/**` 是运行时副本,不能进入宿主模块或 SCC 图。""" - assert all( - not module.startswith("app.plugins") - for module in _discover_modules() - ) + assert all(not module.startswith("app.plugins") for module in _discover_modules()) def test_chain_does_not_import_agent_implementation(): @@ -1718,11 +1516,7 @@ def test_chain_does_not_import_agent_implementation(): for module_name, dependencies in _build_module_graph().items(): if not module_name.startswith("app.chain"): continue - forbidden = { - dependency - for dependency in dependencies - if dependency.startswith("app.agent") - } + forbidden = {dependency for dependency in dependencies if dependency.startswith("app.agent")} if forbidden: violations[module_name] = forbidden assert violations == {} @@ -1731,11 +1525,7 @@ def test_chain_does_not_import_agent_implementation(): def test_agent_application_facade_does_not_import_agent_implementation(): """Agent application 门面只能接收组合根注入,不能反向解析具体实现。""" dependencies = _build_module_graph()["app.application.agent"] - assert { - dependency - for dependency in dependencies - if dependency.startswith("app.agent") - } == set() + assert {dependency for dependency in dependencies if dependency.startswith("app.agent")} == set() def test_host_consumers_get_agent_manager_through_application_facade(): @@ -1749,8 +1539,7 @@ def test_host_consumers_get_agent_manager_through_application_facade(): imported = { alias.name for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - and node.module == "app.agent.runtime_loader" + if isinstance(node, ast.ImportFrom) and node.module == "app.agent.runtime_loader" for alias in node.names if alias.name in forbidden } @@ -1770,8 +1559,7 @@ def test_host_consumers_resolve_llm_provider_runtime_through_gateway(): imported = { alias.name for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - and node.module in {"app.agent.llm", "app.agent.llm.provider"} + if isinstance(node, ast.ImportFrom) and node.module in {"app.agent.llm", "app.agent.llm.provider"} for alias in node.names if alias.name == "LLMProviderManager" } @@ -1793,8 +1581,7 @@ def test_host_consumers_use_agent_audio_capability_application_port(): imported = { alias.name for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - and node.module in {"app.agent.llm", "app.agent.llm.capability"} + if isinstance(node, ast.ImportFrom) and node.module in {"app.agent.llm", "app.agent.llm.capability"} for alias in node.names if alias.name == "AgentCapabilityManager" } @@ -1808,8 +1595,7 @@ def test_application_services_do_not_resolve_event_manager_singleton(): violations = { module_name: dependencies & {"app.runtime.events"} for module_name, dependencies in _build_module_graph().items() - if module_name.startswith("app.application") - and "app.runtime.events" in dependencies + if module_name.startswith("app.application") and "app.runtime.events" in dependencies } assert violations == {} @@ -1909,11 +1695,7 @@ def test_deprecated_settings_proxy_imports_are_zero(): counts[group] = counts.get(group, 0) + 1 unexpected = set(counts) - set(limits) - exceeded = { - group: count - for group, count in counts.items() - if group in limits and count > limits[group] - } + exceeded = {group: count for group, count in counts.items() if group in limits and count > limits[group]} assert unexpected == set() assert exceeded == {} @@ -1946,16 +1728,137 @@ def test_api_does_not_import_factory(): for module_name, dependencies in _build_module_graph().items(): if not module_name.startswith("app.api"): continue - forbidden = { - dependency - for dependency in dependencies - if dependency.startswith("app.factory") - } + forbidden = {dependency for dependency in dependencies if dependency.startswith("app.factory")} if forbidden: violations[module_name] = forbidden assert violations == {} +def test_web_agent_non_transport_state_stays_in_application(): + """Agent 端点不得重新拥有附件、音频、投影或会话写入实现。""" + endpoint_path = APP_ROOT / "api" / "endpoints" / "agent.py" + application_path = APP_ROOT / "application" / "messaging" / "agent.py" + endpoint_tree = ast.parse( + endpoint_path.read_text(encoding="utf-8-sig"), + filename=str(endpoint_path), + ) + application_tree = ast.parse( + application_path.read_text(encoding="utf-8-sig"), + filename=str(application_path), + ) + endpoint_owners = { + node.name + for node in endpoint_tree.body + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + } + application_owners = { + node.name + for node in application_tree.body + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + } + required = { + "WebAgentEventPublisher", + "apply_web_agent_display_event", + "build_web_agent_command_items", + "build_web_agent_input_attachments", + "build_web_agent_message_events_async", + "build_web_agent_session_id_async", + "build_web_agent_stream", + "collect_web_agent_traditional_events", + "get_web_agent_registered_file", + "prepare_web_agent_audio_attachment_path_async", + "save_web_agent_display_snapshot", + "transcribe_web_agent_audio_input", + } + forbidden_endpoint_imports = { + "app.application.commands", + "app.application.messaging.router", + "app.chain.message", + "app.runtime.execution", + "app.runtime.stop", + } + endpoint_imports = { + node.module + for node in ast.walk(endpoint_tree) + if isinstance(node, ast.ImportFrom) and node.module + } + + assert required <= application_owners + assert required.isdisjoint(endpoint_owners) + assert endpoint_imports.isdisjoint(forbidden_endpoint_imports) + + +def test_plugin_market_use_cases_stay_in_application(): + """Plugin 端点不得重新组合市场 Adapter 或拥有目录投影实现。""" + endpoint_path = APP_ROOT / "api" / "endpoints" / "plugin.py" + endpoint_tree = ast.parse( + endpoint_path.read_text(encoding="utf-8-sig"), + filename=str(endpoint_path), + ) + forbidden_imports = { + "app.adapters.external.market", + "app.adapters.external.server", + } + imported = {node.module for node in ast.walk(endpoint_tree) if isinstance(node, ast.ImportFrom) and node.module} + endpoint_owners = { + node.name + for node in endpoint_tree.body + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + } + + assert forbidden_imports.isdisjoint(imported) + assert { + "_get_market_plugin_from_repo", + "_get_plugin_history_detail", + "_installed_plugins_with_declared_metadata", + "_prepare_update_candidates", + }.isdisjoint(endpoint_owners) + + +def test_system_nettest_is_owned_by_application_service(): + """System API 不得重新拥有网络目标目录、安全准入或具体传输组装。""" + endpoint_path = APP_ROOT / "api" / "endpoints" / "system.py" + endpoint_tree = ast.parse( + endpoint_path.read_text(encoding="utf-8-sig"), + filename=str(endpoint_path), + ) + retired_endpoint_symbols = { + "_build_nettest_rules", + "_close_nettest_response", + "_get_nettest_rule", + "_is_allowed_nettest_redirect", + "_match_nettest_prefix", + "_validate_nettest_url", + } + endpoint_functions = { + node.name for node in endpoint_tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + assert endpoint_functions.isdisjoint(retired_endpoint_symbols) + assert not any( + isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "NetworkTestService" + for node in ast.walk(endpoint_tree) + ) + + application_path = APP_ROOT / "application" / "network.py" + application_tree = ast.parse( + application_path.read_text(encoding="utf-8-sig"), + filename=str(application_path), + ) + concrete_adapter_imports = { + node.module + for node in ast.walk(application_tree) + if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("app.adapters") + } + concrete_adapter_imports.update( + alias.name + for node in ast.walk(application_tree) + if isinstance(node, ast.Import) + for alias in node.names + if alias.name.startswith("app.adapters") + ) + assert concrete_adapter_imports == set() + + PROCESS_LEVEL_ROOTS = ( "app.api", "app.chain", @@ -1979,11 +1882,7 @@ def test_process_level_packages_are_not_mutually_cyclic(): components = _strongly_connected_components(graph) violations: list[list[str]] = [] for component in components: - roots = { - name.split(".")[1] - for name in component - if name.startswith("app.") and name.count(".") >= 1 - } + roots = {name.split(".")[1] for name in component if name.startswith("app.") and name.count(".") >= 1} involved = {root for root in roots if f"app.{root}" in PROCESS_LEVEL_ROOTS} if len(involved) > 1: violations.append(sorted(component)) diff --git a/tests/test_configuration_initializer.py b/tests/test_configuration_initializer.py index 5335c1b1e..36b289dba 100644 --- a/tests/test_configuration_initializer.py +++ b/tests/test_configuration_initializer.py @@ -1,5 +1,6 @@ """配置快照启动顺序测试。""" +from types import SimpleNamespace from unittest.mock import AsyncMock import pytest @@ -44,6 +45,49 @@ class _MutableSettings: return True, "" +@pytest.mark.asyncio +async def test_network_test_transport_enforces_safe_http_options(monkeypatch) -> None: + """组合根适配器必须固定证书校验、超时和手动重定向策略。""" + captured = {} + response = SimpleNamespace(status_code=200, headers={}, text="ok") + + class _RequestUtils: + """捕获组合根网络探测适配器使用的 HTTP 参数。""" + + def __init__(self, **kwargs) -> None: + """记录通用请求工具的构造参数。""" + captured["options"] = kwargs + + async def get_res(self, url, allow_redirects=True): + """记录请求地址和重定向开关并返回固定响应。""" + captured["url"] = url + captured["allow_redirects"] = allow_redirects + return response + + monkeypatch.setattr(modules_initializer, "AsyncRequestUtils", _RequestUtils) + + result = await modules_initializer._NetworkTestTransportAdapter().get( + "https://example.com/health", + proxy="http://proxy.example:7890", + headers={"Authorization": "Bearer test"}, + user_agent="MoviePilot-Test", + ) + + assert result is response + assert captured == { + "url": "https://example.com/health", + "allow_redirects": False, + "options": { + "proxies": "http://proxy.example:7890", + "headers": {"Authorization": "Bearer test"}, + "timeout": 10, + "ua": "MoviePilot-Test", + "verify": True, + "follow_redirects": False, + }, + } + + def test_runtime_settings_service_uses_legacy_settings_from_startup_root(monkeypatch) -> None: """组合根装配的设置服务应直接读写唯一部署配置对象。""" legacy_settings = _MutableSettings() @@ -65,11 +109,17 @@ async def test_configuration_services_publish_after_both_snapshots_load( events = [] class _SystemConfig: + """记录系统配置快照加载顺序。""" + def load_snapshot(self): + """登记系统快照已加载。""" events.append("load-system") class _UserConfig: + """记录用户配置快照加载顺序。""" + def load_snapshot(self): + """登记用户快照已加载。""" events.append("load-user") monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig) @@ -107,11 +157,17 @@ async def test_configuration_load_failure_does_not_publish_partial_service( published = [] class _SystemConfig: + """提供成功的系统配置快照加载桩。""" + def load_snapshot(self): + """模拟系统配置加载成功。""" return None class _UserConfig: + """提供失败的用户配置快照加载桩。""" + def load_snapshot(self): + """模拟用户配置加载失败。""" raise RuntimeError("load failed") monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig) @@ -184,7 +240,10 @@ async def test_database_worker_owner_is_retained_when_shutdown_fails(monkeypatch """数据库 worker 关闭失败时保留 owner,允许后续重试或诊断。""" class _FailingWorker: + """模拟无法完成关闭的数据库 worker。""" + async def shutdown(self): + """抛出关闭错误以验证 owner 保留。""" raise RuntimeError("shutdown failed") worker = _FailingWorker() diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 3d2aabfaa..a01934103 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -3,10 +3,11 @@ from contextlib import nullcontext from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from app import schemas from app.api.endpoints import plugin as plugin_endpoint from app.api.endpoints.plugin import ( - _prepare_update_candidates, plugin_history, plugin_releases, plugin_static_file, @@ -16,6 +17,8 @@ from app.api.endpoints.plugin import ( uninstall_plugin, ) from app.api.endpoints.system import sync_plugin_market_from_wiki +from app.application.plugin import release as release_module +from app.application.plugin.catalog import PluginCatalogQuery from app.application.plugin.config import PluginConfigCommand from app.application.plugin.declaration import PluginDeclaredMetadata from app.application.plugin.identity import ( @@ -24,6 +27,7 @@ from app.application.plugin.identity import ( PluginPayloadSourceType, TrustedPluginSourceType, ) +from app.application.plugin.release import PluginReleaseService from app.foundation.singleton import Singleton from app.runtime.config import settings from app.runtime.extensions.plugin.admission import PluginMutationAdmission @@ -75,6 +79,20 @@ def _plugin_identity( ) +def _catalog_query( + plugin_manager: MagicMock, persistence: MagicMock +) -> PluginCatalogQuery: + """用运行态和身份端口替身构造插件目录查询。""" + return PluginCatalogQuery( + installed_plugins=plugin_manager.get_installed_plugins, + local_plugins=plugin_manager.get_local_plugins, + local_repo_plugins=plugin_manager.get_local_repo_plugins, + online_candidates=plugin_manager.async_get_online_plugin_candidates, + process_plugins=plugin_manager.process_plugins_list, + identities=persistence.list_identities, + ) + + def test_update_candidates_report_source_and_binding_relationship(): """市场更新候选应标明仓库类型,并仅把当前绑定仓库视为可直接更新。""" plugins = [ @@ -124,18 +142,13 @@ def test_update_candidates_report_source_and_binding_relationship(): plugin_manager = MagicMock() plugin_manager.process_plugins_list.side_effect = lambda higher, _base: higher - with patch( - "app.api.endpoints.plugin.get_plugin_persistence", - return_value=persistence, - ): - result = asyncio.run( - _prepare_update_candidates( - plugin_manager, - plugins, - plugins, - [plugin.id for plugin in plugins], - ) + result = asyncio.run( + _catalog_query(plugin_manager, persistence).project_update_candidates( + plugins, + plugins, + [plugin.id for plugin in plugins], ) + ) assert result[0].update_candidate is not None assert result[0].update_candidate.source_type == "official" @@ -168,20 +181,15 @@ def test_bound_repository_update_precedes_a_higher_alternative(): plugin_manager = MagicMock() plugin_manager.process_plugins_list.return_value = [bound_update] - with patch( - "app.api.endpoints.plugin.get_plugin_persistence", - return_value=persistence, - ): - result = asyncio.run( - _prepare_update_candidates( - plugin_manager, - [bound_update, alternative_update], - [alternative_update], - ["DemoPlugin"], - ) + result = asyncio.run( + _catalog_query(plugin_manager, persistence).project_update_candidates( + [bound_update, alternative_update], + [alternative_update], + ["DemoPlugin"], ) + ) - assert result == [bound_update] + assert [plugin.id for plugin in result] == ["DemoPlugin"] assert result[0].update_candidate is not None assert result[0].update_candidate.version == "2.0.0" assert result[0].update_candidate.is_bound is True @@ -226,13 +234,14 @@ def test_market_endpoint_reads_source_preserving_candidates_for_bound_update(): persistence = MagicMock() persistence.list_identities = AsyncMock(return_value=[_plugin_identity()]) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + query = _catalog_query(plugin_manager, persistence) + with patch( + "app.api.endpoints.plugin.get_plugin_catalog_query", + return_value=query, ): result = asyncio.run(plugin_endpoint.all_plugins(None, "market", False)) - assert result == [bound_update] + assert [plugin.id for plugin in result] == ["DemoPlugin"] assert result[0].update_candidate is not None assert result[0].update_candidate.version == "2.0.0" assert result[0].update_candidate.is_bound is True @@ -246,6 +255,46 @@ def _persistence(identity: PluginIdentity) -> MagicMock: return persistence +def _release_service( + plugin_manager: MagicMock, + *, + persistence: MagicMock | None = None, + plugin_helper: MagicMock | None = None, +) -> PluginReleaseService: + """用端口替身构造与启动组合根等价的 Release 查询服务。""" + if persistence is None: + persistence = MagicMock() + persistence.get_identity = AsyncMock(return_value=None) + if plugin_helper is None: + plugin_helper = MagicMock() + plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) + plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[]) + return PluginReleaseService( + installed_plugins=plugin_manager.get_installed_plugins, + local_repo_plugins=plugin_manager.get_local_repo_plugins, + market_plugins=plugin_manager.async_get_plugins_from_market, + local_version=plugin_manager.get_local_plugin_version, + identity=persistence.get_identity, + version_flag=lambda: settings.VERSION_FLAG, + compatible_flags=lambda flag: ["v2"] if flag == "v3" else [], + has_release_cache=plugin_helper.async_has_plugin_release_cache, + releases=plugin_helper.async_get_plugin_release_versions, + refresh_releases=plugin_helper.async_get_plugin_release_versions, + ) + + +def test_plugin_release_service_reset_isolates_lifespans(monkeypatch): + """停机清理后不得继续复用上一 lifespan 的 Release 端口。""" + manager = MagicMock() + service = _release_service(manager) + monkeypatch.setattr(release_module, "_release_service", service) + + release_module.reset_plugin_release_service() + + with pytest.raises(RuntimeError, match="尚未完成初始化"): + release_module.get_plugin_release_service() + + def test_plugin_history_merges_remote_metadata(): """ 已安装插件点击更新说明时,接口会按需合并远端仓库中的更新记录。 @@ -270,10 +319,11 @@ def test_plugin_history_merges_remote_metadata(): plugin_manager.get_local_repo_plugins.return_value = [] plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) persistence = _persistence(_plugin_identity()) + release_service = _release_service(plugin_manager, persistence=persistence) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_history("DemoPlugin", None, True)) @@ -307,10 +357,11 @@ def test_plugin_history_falls_back_to_backward_compatible_package(): side_effect=[[], [market_plugin]] ) persistence = _persistence(_plugin_identity()) + release_service = _release_service(plugin_manager, persistence=persistence) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_history("DemoPlugin", None, True)) @@ -385,10 +436,11 @@ def test_plugin_history_returns_installed_plugin_when_remote_missing(): plugin_manager.get_local_repo_plugins.return_value = [] plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[]) persistence = _persistence(_plugin_identity()) + release_service = _release_service(plugin_manager, persistence=persistence) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_history("DemoPlugin", None, True)) @@ -418,10 +470,11 @@ def test_plugin_history_uses_bound_repo_without_refreshing_all_markets(): plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) plugin_manager.async_get_online_plugins = AsyncMock(return_value=[]) persistence = _persistence(_plugin_identity()) + release_service = _release_service(plugin_manager, persistence=persistence) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_history("DemoPlugin", None, True)) @@ -455,10 +508,11 @@ def test_plugin_history_uses_declared_metadata_when_bound_market_is_unavailable( plugin_manager.get_local_repo_plugins.return_value = [] plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[]) persistence = _persistence(_plugin_identity(metadata=metadata)) + release_service = _release_service(plugin_manager, persistence=persistence) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.get_plugin_persistence", return_value=persistence), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_history("DemoPlugin", None, True)) @@ -488,10 +542,13 @@ def test_plugin_releases_returns_supported_versions_with_latest_and_current(monk {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, {"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0", "asset_name": "demoplugin_v1.2.0.zip"}, ]) + release_service = _release_service( + plugin_manager, plugin_helper=plugin_helper + ) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False)) @@ -526,11 +583,15 @@ def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch): plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) plugin_manager.get_local_plugin_version.return_value = "1.2.0" plugin_helper = MagicMock() + plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=release_items) + release_service = _release_service( + plugin_manager, plugin_helper=plugin_helper + ) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False)) @@ -557,10 +618,13 @@ def test_plugin_releases_falls_back_to_compatible_base_package(monkeypatch): plugin_helper = MagicMock() plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[]) + release_service = _release_service( + plugin_manager, plugin_helper=plugin_helper + ) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run( plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False) @@ -590,10 +654,13 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch): plugin_helper = MagicMock() plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[]) + release_service = _release_service( + plugin_manager, plugin_helper=plugin_helper + ) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True)) @@ -637,14 +704,19 @@ def test_plugin_releases_force_uses_cached_release_response_and_schedules_refres ] plugin_helper.async_get_plugin_release_versions = fake_releases + release_service = _release_service( + plugin_manager, plugin_helper=plugin_helper + ) scheduled = [] def fake_schedule(plugin_id, repo_url, task_registry): scheduled.append((plugin_id, repo_url, task_registry)) with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper), + patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, + ), patch.object(plugin_endpoint, "_schedule_plugin_release_refresh", fake_schedule), ): result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True)) @@ -687,14 +759,19 @@ def test_plugin_releases_force_skips_background_refresh_without_release_cache(mo "asset_name": "demoplugin_v1.2.3.zip", } ]) + release_service = _release_service( + plugin_manager, plugin_helper=plugin_helper + ) scheduled = [] def fake_schedule(plugin_id, repo_url): scheduled.append((plugin_id, repo_url)) with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper), + patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, + ), patch.object(plugin_endpoint, "_schedule_plugin_release_refresh", fake_schedule), ): result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True)) @@ -720,13 +797,17 @@ def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release( plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) plugin_manager.get_local_plugin_version.return_value = None plugin_helper = MagicMock() + plugin_helper.async_has_plugin_release_cache = AsyncMock(return_value=False) plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[ {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, ]) + release_service = _release_service( + plugin_manager, plugin_helper=plugin_helper + ) - with ( - patch("app.api.endpoints.plugin.get_plugin_manager", return_value=plugin_manager), - patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper), + with patch( + "app.api.endpoints.plugin.get_plugin_release_service", + return_value=release_service, ): result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False)) @@ -957,7 +1038,7 @@ def test_sealed_http_clone_rejects_before_runtime_and_registration(monkeypatch): add_to_folder = MagicMock() monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager) monkeypatch.setattr(plugin_endpoint, "register_plugin", register) - monkeypatch.setattr(plugin_endpoint, "_add_clone_to_plugin_folder", add_to_folder) + monkeypatch.setattr(plugin_endpoint, "add_clone_to_plugin_folder", add_to_folder) result = plugin_endpoint.clone_plugin( "DemoPlugin", @@ -979,10 +1060,12 @@ def test_sealed_http_folder_update_rejects_before_config_access(monkeypatch): plugin_manager = MagicMock() plugin_manager.mutation.side_effect = admission.hold config_provider = MagicMock() - monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager) monkeypatch.setattr( - plugin_endpoint, - "get_configured_system_config", + "app.application.plugin.folders.get_plugin_manager", + lambda: plugin_manager, + ) + monkeypatch.setattr( + "app.application.plugin.folders.get_configured_system_config", config_provider, ) diff --git a/tests/test_plugin_folders.py b/tests/test_plugin_folders.py index cfe2017e3..41cef4c5f 100644 --- a/tests/test_plugin_folders.py +++ b/tests/test_plugin_folders.py @@ -1,6 +1,11 @@ -from unittest.mock import MagicMock +import asyncio +from contextlib import nullcontext +from unittest.mock import AsyncMock, MagicMock + +import pytest from app.application.plugin import folders +from app.schemas.exception import PersistenceUnavailableError from app.schemas.types import SystemConfigKey @@ -16,10 +21,12 @@ def test_remove_plugin_from_folders_updates_current_and_legacy_shapes(monkeypatc folders.remove_plugin_from_folders("DemoPlugin") - assert stored["常用"]["plugins"] == ["OtherPlugin"] - assert stored["旧目录"] == ["ThirdPlugin"] config.get.assert_called_once_with(SystemConfigKey.PluginFolders) - config.set.assert_called_once_with(SystemConfigKey.PluginFolders, stored) + written = config.set.call_args.args[1] + assert written["常用"]["plugins"] == ["OtherPlugin"] + assert written["旧目录"] == ["ThirdPlugin"] + assert stored["常用"]["plugins"] == ["DemoPlugin", "OtherPlugin"] + config.set.assert_called_once_with(SystemConfigKey.PluginFolders, written) def test_remove_plugin_from_folders_skips_write_when_plugin_is_absent(monkeypatch): @@ -46,3 +53,43 @@ def test_remove_plugin_from_folders_does_not_block_uninstall_on_config_error( folders.remove_plugin_from_folders("DemoPlugin") error.assert_called_once() + + +def test_save_folder_maps_ordinary_error_to_failure_result(): + """普通持久化失败应保持旧 HTTP 语义,由结果表达而不是抛出 500。""" + service = folders.PluginFolderService( + read=lambda: {}, + write=AsyncMock(side_effect=RuntimeError("write failed")), + write_sync=MagicMock(), + mutation=lambda _operation: nullcontext(), + ) + + result = asyncio.run(service.save({"常用": []})) + + assert result.success is False + assert result.message == "write failed" + + +def test_get_folder_maps_read_error_to_empty_snapshot(): + """文件夹读取失败应保持旧接口的空对象兜底语义。""" + service = folders.PluginFolderService( + read=MagicMock(side_effect=RuntimeError("read failed")), + write=AsyncMock(), + write_sync=MagicMock(), + mutation=lambda _operation: nullcontext(), + ) + + assert service.get_or_empty() == {} + + +def test_save_folder_propagates_persistence_unavailable(): + """持久化基础设施不可用必须继续交给统一异常处理器映射。""" + service = folders.PluginFolderService( + read=lambda: {}, + write=AsyncMock(side_effect=PersistenceUnavailableError()), + write_sync=MagicMock(), + mutation=lambda _operation: nullcontext(), + ) + + with pytest.raises(PersistenceUnavailableError): + asyncio.run(service.save({"常用": []})) diff --git a/tests/test_plugin_rating.py b/tests/test_plugin_rating.py index 1969580b9..881dde23e 100644 --- a/tests/test_plugin_rating.py +++ b/tests/test_plugin_rating.py @@ -1,13 +1,15 @@ import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from fastapi import HTTPException from app import schemas -from app.api.endpoints.plugin import plugin_rating, plugin_ratings, rate_plugin from app.adapters.external import server as server_module from app.adapters.external.server import MoviePilotServerHelper +from app.api.endpoints.plugin import plugin_rating, plugin_ratings, rate_plugin +from app.application.plugin import rating as rating_module +from app.application.plugin.rating import PluginRatingService def test_server_helper_uses_plugin_rating_endpoints() -> None: @@ -60,41 +62,31 @@ def test_plugin_rating_endpoints_return_center_results() -> None: "rating_count": 12, "user_rating": 4.5, } - with patch.object( - MoviePilotServerHelper, - "async_get_plugin_ratings", - new=AsyncMock(return_value={"DemoPlugin": rating_result}), - ) as batch_query: + batch_query = AsyncMock(return_value={"DemoPlugin": rating_result}) + single_query = AsyncMock(return_value=rating_result) + submit_rating = AsyncMock(return_value=rating_result) + service = PluginRatingService( + installed_plugins=lambda: ["DemoPlugin"], + statistic=AsyncMock(return_value={}), + ratings=batch_query, + rating=single_query, + submit=submit_rating, + ) + with patch( + "app.api.endpoints.plugin.get_plugin_rating_service", + return_value=service, + ): batch = await plugin_ratings("DemoPlugin", None) - - assert batch["DemoPlugin"].average_rating == 4.3 - batch_query.assert_awaited_once_with(["DemoPlugin"]) - - with patch.object( - MoviePilotServerHelper, - "async_get_plugin_rating", - new=AsyncMock(return_value=rating_result), - ): single = await plugin_rating("DemoPlugin", None) - - assert single.user_rating == 4.5 - - system_config = MagicMock() - system_config.get.return_value = ["DemoPlugin"] - with ( - patch("app.api.endpoints.plugin.get_configured_system_config", return_value=system_config), - patch.object( - MoviePilotServerHelper, - "async_submit_plugin_rating", - new=AsyncMock(return_value=rating_result), - ) as submit_rating, - ): response = await rate_plugin( "DemoPlugin", schemas.PluginRatingRequest(rating=4.5), None, ) + assert batch["DemoPlugin"].average_rating == 4.3 + batch_query.assert_awaited_once_with(["DemoPlugin"]) + assert single.user_rating == 4.5 assert response.success is True assert response.data == rating_result submit_rating.assert_awaited_once_with("DemoPlugin", 4.5) @@ -106,15 +98,17 @@ def test_plugin_rating_rejects_uninstalled_plugin() -> None: """未安装插件不能借助 MoviePilot 接口向中心端提交评分。""" async def run_scenario() -> None: - system_config = MagicMock() - system_config.get.return_value = [] - with ( - patch("app.api.endpoints.plugin.get_configured_system_config", return_value=system_config), - patch.object( - MoviePilotServerHelper, - "async_submit_plugin_rating", - new=AsyncMock(), - ) as submit_rating, + submit_rating = AsyncMock() + service = PluginRatingService( + installed_plugins=lambda: [], + statistic=AsyncMock(return_value={}), + ratings=AsyncMock(return_value={}), + rating=AsyncMock(return_value={}), + submit=submit_rating, + ) + with patch( + "app.api.endpoints.plugin.get_plugin_rating_service", + return_value=service, ): with pytest.raises(HTTPException) as error: await rate_plugin( @@ -127,3 +121,20 @@ def test_plugin_rating_rejects_uninstalled_plugin() -> None: submit_rating.assert_not_awaited() asyncio.run(run_scenario()) + + +def test_plugin_rating_service_reset_isolates_lifespans(monkeypatch) -> None: + """停机清理后不得继续复用上一 lifespan 的评分端口。""" + service = PluginRatingService( + installed_plugins=lambda: [], + statistic=AsyncMock(return_value={}), + ratings=AsyncMock(return_value={}), + rating=AsyncMock(return_value={}), + submit=AsyncMock(return_value=None), + ) + monkeypatch.setattr(rating_module, "_rating_service", service) + + rating_module.reset_plugin_rating_service() + + with pytest.raises(RuntimeError, match="尚未完成初始化"): + rating_module.get_plugin_rating_service() diff --git a/tests/test_system_nettest.py b/tests/test_system_nettest.py index 280c88a3e..1c95409cd 100644 --- a/tests/test_system_nettest.py +++ b/tests/test_system_nettest.py @@ -1,6 +1,5 @@ import asyncio import ipaddress -import unittest from types import ModuleType, SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -18,15 +17,22 @@ def _stub(name: str, **attrs) -> tuple: class _Dummy: + """为端点导入期依赖提供可调用的最小占位实现。""" + def __init__(self, *args, **kwargs): + """接受任意构造参数以模拟不同依赖类型。""" pass def __getattr__(self, _name): + """为未显式声明的属性返回无副作用调用桩。""" return lambda *args, **kwargs: None class _DummyError(Exception): + """模拟带耗时字段的 Agent 测试异常。""" + def __init__(self, message="", duration_ms=None): + """保存异常消息和可选耗时。""" super().__init__(message) self.duration_ms = duration_ms @@ -45,9 +51,11 @@ _STUB_MODULES = dict([ _stub("app.domain.metainfo", MetaInfo=_Dummy), _stub("app.runtime.extensions.module_manager", ModuleManager=_Dummy), _stub("app.adapters.web.security.access", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy), + _stub("app.api.context", get_host_runtime=_Dummy), + _stub("app.startup.composition.context", HostRuntime=_Dummy), _stub("app.db.models", User=_Dummy), _stub("app.db.oper.systemconfig", SystemConfigOper=_Dummy), - _stub("app.api.deps", get_current_active_superuser=_Dummy, + _stub("app.api.dependencies.auth", get_current_active_superuser=_Dummy, get_current_active_superuser_async=_Dummy, get_current_active_user_async=_Dummy), _stub("app.agent.llm", LLMHelper=_Dummy, LLMTestError=_DummyError, LLMTestTimeout=_DummyError), _stub("app.application.mediaserver", MediaServerHelper=_Dummy), @@ -69,11 +77,27 @@ _APP_MODULES = snapshot_modules("app") try: with stub_modules(_STUB_MODULES): from app.api.endpoints import system as system_endpoint + from app.application.network import NetworkTestService finally: restore_modules(_APP_MODULES, "app") -class NettestSecurityTest(unittest.TestCase): +def _network_test_service(transport, **settings) -> NetworkTestService: + """构造不访问真实网络且读取独立设置快照的网络测试服务。""" + values = { + "NORMAL_USER_AGENT": "MoviePilot-Test", + **settings, + } + return NetworkTestService( + transport=transport, + settings=lambda key, default=None: values.get(key, default), + logger=_Dummy(), + ) + + +class TestNettestSecurity: + """验证 System 安全入口和网络测试应用边界。""" + def test_get_env_setting_reports_rust_available_and_enabled_separately(self): """ 系统配置接口应分别返回 Rust 扩展可用性和当前实际启用状态。 @@ -89,12 +113,12 @@ class NettestSecurityTest(unittest.TestCase): ): resp = asyncio.run(system_endpoint.get_env_setting(_="token")) - self.assertTrue(resp.success) - self.assertTrue(resp.data["RUST_ACCEL_AVAILABLE"]) - self.assertFalse(resp.data["RUST_ACCEL_ENABLED"]) - self.assertTrue(resp.data["RUST_ACCEL_REQUIRED"]) - self.assertTrue(resp.data["PYTHON_FREE_THREADED"]) - self.assertFalse(resp.data["PYTHON_GIL_ENABLED"]) + assert resp.success + assert resp.data["RUST_ACCEL_AVAILABLE"] + assert not resp.data["RUST_ACCEL_ENABLED"] + assert resp.data["RUST_ACCEL_REQUIRED"] + assert resp.data["PYTHON_FREE_THREADED"] + assert not resp.data["PYTHON_GIL_ENABLED"] def test_get_user_global_setting_reports_runtime_variant(self): """登录后的全局设置应提供导航所需的解释器类型。""" @@ -121,9 +145,9 @@ class NettestSecurityTest(unittest.TestCase): ): resp = asyncio.run(system_endpoint.get_user_global_setting(_="token")) - self.assertTrue(resp.success) - self.assertTrue(resp.data["PYTHON_FREE_THREADED"]) - self.assertFalse(resp.data["PYTHON_GIL_ENABLED"]) + assert resp.success + assert resp.data["PYTHON_FREE_THREADED"] + assert not resp.data["PYTHON_GIL_ENABLED"] def test_fetch_image_allows_signed_private_url(self): """ @@ -148,7 +172,7 @@ class NettestSecurityTest(unittest.TestCase): ) ) - self.assertEqual(resp.status_code, 200) + assert resp.status_code == 200 image_helper.async_fetch_image_with_mime_type.assert_awaited_once_with( url=image_url, proxy=None, @@ -161,7 +185,10 @@ class NettestSecurityTest(unittest.TestCase): 图片代理即使拿到内网 allowlist 项,也必须在发起请求前拦截。 """ class FailIfCalled: + """确保危险图片地址不会物化请求实现。""" + def __init__(self, *args, **kwargs): + """任何构造都表示测试失败。""" raise AssertionError("fetch_image should block private URLs before fetching") with patch.object(system_endpoint, "ImageHelper", FailIfCalled): @@ -172,7 +199,293 @@ class NettestSecurityTest(unittest.TestCase): ) ) - self.assertIsNone(resp) + assert resp is None + + def test_nettest_targets_are_served_by_application(self): + """目标接口只投影应用服务提供的公开目录字段。""" + service = _network_test_service(_Dummy()) + with patch.object( + system_endpoint, + "get_configured_network_test_service", + return_value=service, + ): + resp = asyncio.run(system_endpoint.nettest_targets(_="token")) + + assert resp.success + assert any(item["id"] == "pip_proxy" for item in resp.data) + assert any(item["id"] == "github_proxy_web" for item in resp.data) + assert all(set(item) == {"id", "name", "icon"} for item in resp.data) + + def test_nettest_blocks_unknown_target_before_transport(self): + """未知目标在应用服务目录匹配阶段即返回,不得触发传输端口。""" + + class FailTransport: + """确保未知目标不会进入传输边界。""" + + async def get(self, *_args, **_kwargs): + """任何请求都表示目录准入失效。""" + raise AssertionError("未知网络测试目标不应触发外部请求") + + service = _network_test_service(FailTransport()) + with patch.object( + system_endpoint, + "get_configured_network_test_service", + return_value=service, + ): + resp = asyncio.run( + system_endpoint.nettest( + target_id="unknown-target", + _="token", + ) + ) + + assert not resp.success + assert "不存在" in resp.message + + def test_nettest_blocks_unapproved_redirect(self): + """应用服务必须在第二次请求前阻断目标规则之外的跳转。""" + captured = {"calls": 0, "closed": 0} + + class FakeResponse: + """返回未授权重定向并记录资源释放。""" + + status_code = 302 + headers = {"location": "https://169.254.169.254/latest/meta-data/"} + text = "" + + async def aclose(self): + """记录应用服务已关闭被拒绝的响应。""" + captured["closed"] += 1 + + class FakeTransport: + """记录未授权重定向场景中的请求次数。""" + + async def get(self, _url, **_kwargs): + """返回固定的元数据服务跳转响应。""" + captured["calls"] += 1 + return FakeResponse() + + service = _network_test_service( + FakeTransport(), + GITHUB_PROXY="https://ghproxy.example/", + ) + with patch.object( + system_endpoint, + "get_configured_network_test_service", + return_value=service, + ): + resp = asyncio.run( + system_endpoint.nettest( + target_id="github_proxy_web", + _="token", + ) + ) + + assert not resp.success + assert "跳转" in resp.message + assert captured == {"calls": 1, "closed": 1} + + def test_nettest_allows_known_external_redirects(self): + """内置目标声明的跨域跳转继续可用,并释放每个响应对象。""" + cases = { + "telegram_api": "https://core.telegram.org/bots", + "douban_api": "https://www.douban.com/doubanapp/frodo?wechat=0&os=Other", + "github_codeload": "https://github.com/", + } + + for target_id, redirect_url in cases.items(): + call_urls = [] + closed = [] + + class FakeResponse: + """模拟可关闭的重定向或成功响应。""" + + def __init__(self, status_code, headers=None, text=""): + """保存网络测试所需的最小响应字段。""" + self.status_code = status_code + self.headers = headers or {} + self.text = text + + async def aclose(self): + """记录当前响应已被应用服务释放。""" + closed.append(self.status_code) + + class FakeTransport: + """先返回受信跳转,再返回成功响应。""" + + async def get(self, url, **_kwargs): + """按调用顺序生成两段受控响应。""" + call_urls.append(url) + if len(call_urls) == 1: + return FakeResponse(302, headers={"location": redirect_url}) + return FakeResponse(200, text="ok") + + service = _network_test_service(FakeTransport()) + with patch.object( + system_endpoint, + "get_configured_network_test_service", + return_value=service, + ): + resp = asyncio.run( + system_endpoint.nettest( + target_id=target_id, + _="token", + ) + ) + + assert resp.success, target_id + assert len(call_urls) == 2 + assert closed == [302, 200] + + def test_nettest_rejects_malformed_port_and_path_prefix_confusion(self): + """畸形端口和相似路径不得绕过逐目标重定向白名单。""" + redirect_urls = ( + "https://www.douban.com:99999/doubanapp/frodo", + "https://www.douban.com/doubanapp/frodoevil", + ) + + for redirect_url in redirect_urls: + class FakeResponse: + """返回当前恶意重定向并允许应用服务释放资源。""" + + status_code = 302 + headers = {"location": redirect_url} + text = "" + + async def aclose(self): + """模拟关闭已读取的重定向响应。""" + + class FakeTransport: + """只允许应用服务发起第一跳请求。""" + + def __init__(self) -> None: + """初始化请求计数。""" + self.calls = 0 + + async def get(self, _url, **_kwargs): + """返回恶意跳转,第二次调用即表示准入失败。""" + self.calls += 1 + if self.calls > 1: + raise AssertionError("恶意重定向不应触发第二跳请求") + return FakeResponse() + + transport = FakeTransport() + service = _network_test_service(transport) + result = asyncio.run(service.execute(target_id="douban_api")) + + assert not result.success + assert "跳转" in result.message + assert transport.calls == 1 + + def test_nettest_uses_configured_transport_options_and_content_rule(self): + """应用服务固定代理、请求头和内容校验,不信任旧 include 参数。""" + captured = {} + + class FakeTransport: + """捕获应用服务传给传输端口的受控参数。""" + + async def get(self, url, **kwargs): + """记录请求并返回包含固定校验文本的响应。""" + captured["url"] = url + captured.update(kwargs) + return SimpleNamespace( + status_code=200, + headers={}, + text="MoviePilot README", + ) + + service = _network_test_service( + FakeTransport(), + GITHUB_PROXY="https://ghproxy.example/", + PROXY={"https": "http://proxy.example:7890"}, + GITHUB_HEADERS={"Authorization": "Bearer token"}, + ) + with patch.object( + system_endpoint, + "get_configured_network_test_service", + return_value=service, + ): + resp = asyncio.run( + system_endpoint.nettest( + target_id="github_proxy_web", + include="tag_name", + _="token", + ) + ) + + assert resp.success + assert captured["url"] == ( + "https://ghproxy.example/" + "https://github.com/jxxghp/MoviePilot/blob/v2/README.md" + ) + assert captured["proxy"] == {"https": "http://proxy.example:7890"} + assert captured["headers"] == {"Authorization": "Bearer token"} + assert captured["user_agent"] == "MoviePilot-Test" + + def test_nettest_fails_when_expected_content_is_missing(self): + """代理返回成功状态但缺少服务端固定内容时仍应判定失效。""" + + class FakeTransport: + """返回缺少预期内容的成功状态响应。""" + + async def get(self, _url, **_kwargs): + """模拟代理落地页冒充目标内容。""" + return SimpleNamespace( + status_code=200, + headers={}, + text="proxy landing page", + ) + + service = _network_test_service( + FakeTransport(), + PIP_PROXY="https://pypi.tuna.tsinghua.edu.cn/simple/", + ) + with patch.object( + system_endpoint, + "get_configured_network_test_service", + return_value=service, + ): + resp = asyncio.run( + system_endpoint.nettest( + target_id="pip_proxy", + _="token", + ) + ) + + assert not resp.success + assert "PIP加速代理" in resp.message + + def test_nettest_preserves_exact_legacy_url_matching(self): + """旧客户端传入内置 URL 时仍可解析,任意 URL 不会扩大准入面。""" + + class FakeTransport: + """为旧 URL 精确匹配提供无网络成功响应。""" + + async def get(self, _url, **_kwargs): + """返回满足无需内容校验目标的最小响应。""" + return SimpleNamespace(status_code=200, headers={}, text="ok") + + service = _network_test_service(FakeTransport()) + with patch.object( + system_endpoint, + "get_configured_network_test_service", + return_value=service, + ): + allowed = asyncio.run( + system_endpoint.nettest( + url="https://api.github.com", + _="token", + ) + ) + denied = asyncio.run( + system_endpoint.nettest( + url="https://example.com", + _="token", + ) + ) + + assert allowed.success + assert not denied.success def test_fetch_image_allows_configured_private_range_after_domain_match(self): """ @@ -208,7 +521,7 @@ class NettestSecurityTest(unittest.TestCase): ) ) - self.assertEqual(resp.status_code, 200) + assert resp.status_code == 200 image_helper.async_fetch_image_with_mime_type.assert_awaited_once_with( url="https://img1.doubanio.com/poster.webp", proxy=None, @@ -225,7 +538,10 @@ class NettestSecurityTest(unittest.TestCase): ).replace("/Items/abc/Images/Primary", "/System/Info/Public") class FailIfCalled: + """确保篡改签名的私网图片不会物化请求实现。""" + def __init__(self, *args, **kwargs): + """任何构造都表示签名校验被绕过。""" raise AssertionError("fetch_image should block tampered signed URLs") with patch.object(system_endpoint, "ImageHelper", FailIfCalled): @@ -236,167 +552,4 @@ class NettestSecurityTest(unittest.TestCase): ) ) - self.assertIsNone(resp) - - def test_nettest_targets_are_served_by_backend(self): - resp = asyncio.run(system_endpoint.nettest_targets(_="token")) - - self.assertTrue(resp.success) - self.assertTrue(any(item["id"] == "pip_proxy" for item in resp.data)) - self.assertTrue(any(item["id"] == "github_proxy_web" for item in resp.data)) - - def test_nettest_blocks_unknown_target(self): - class FailIfCalled: - def __init__(self, *args, **kwargs): - raise AssertionError("nettest should reject unknown targets before any outbound request") - - with patch.object(system_endpoint, "AsyncRequestUtils", FailIfCalled): - resp = asyncio.run( - system_endpoint.nettest( - target_id="unknown-target", - _="token", - ) - ) - - self.assertFalse(resp.success) - self.assertIn("不存在", resp.message) - - def test_nettest_blocks_unapproved_redirect(self): - captured = {"calls": 0} - - class FakeResponse: - def __init__(self, status_code, headers=None, text=""): - self.status_code = status_code - self.headers = headers or {} - self.text = text - - async def aclose(self): - return None - - class FakeAsyncRequestUtils: - def __init__(self, **kwargs): - captured["init_kwargs"] = kwargs - - async def get_res(self, url, allow_redirects=True): - captured["calls"] += 1 - return FakeResponse( - 302, - headers={"location": "https://169.254.169.254/latest/meta-data/"}, - ) - - with patch.object(system_endpoint, "AsyncRequestUtils", FakeAsyncRequestUtils), patch.object( - runtime_settings, - "GITHUB_PROXY", - "https://ghproxy.example/", - ): - resp = asyncio.run( - system_endpoint.nettest( - target_id="github_proxy_web", - _="token", - ) - ) - - self.assertFalse(resp.success) - self.assertIn("跳转", resp.message) - self.assertEqual(captured["calls"], 1) - - def test_nettest_allows_known_external_redirects(self): - cases = { - "telegram_api": "https://core.telegram.org/bots", - "douban_api": "https://www.douban.com/doubanapp/frodo?wechat=0&os=Other", - "github_codeload": "https://github.com/", - } - - for target_id, redirect_url in cases.items(): - call_urls = [] - - class FakeResponse: - def __init__(self, status_code, headers=None, text=""): - self.status_code = status_code - self.headers = headers or {} - self.text = text - - async def aclose(self): - return None - - class FakeAsyncRequestUtils: - def __init__(self, **kwargs): - pass - - async def get_res(self, url, allow_redirects=True): - call_urls.append(url) - if len(call_urls) == 1: - return FakeResponse(302, headers={"location": redirect_url}) - return FakeResponse(200, text="ok") - - with self.subTest(target_id=target_id), patch.object( - system_endpoint, - "AsyncRequestUtils", - FakeAsyncRequestUtils, - ): - resp = asyncio.run( - system_endpoint.nettest( - target_id=target_id, - _="token", - ) - ) - - self.assertTrue(resp.success) - self.assertEqual(len(call_urls), 2) - - def test_nettest_uses_safe_http_options_and_server_side_content_check(self): - captured = {} - - class FakeAsyncRequestUtils: - def __init__(self, **kwargs): - captured["init_kwargs"] = kwargs - - async def get_res(self, url, allow_redirects=True): - captured["url"] = url - captured["allow_redirects"] = allow_redirects - return SimpleNamespace(status_code=200, text="MoviePilot README") - - with patch.object(system_endpoint, "AsyncRequestUtils", FakeAsyncRequestUtils), patch.object( - runtime_settings, - "GITHUB_PROXY", - "https://ghproxy.example/", - ): - resp = asyncio.run( - system_endpoint.nettest( - target_id="github_proxy_web", - include="tag_name", - _="token", - ) - ) - - self.assertTrue(resp.success) - self.assertEqual( - captured["url"], - "https://ghproxy.example/https://github.com/jxxghp/MoviePilot/blob/v2/README.md", - ) - self.assertFalse(captured["allow_redirects"]) - self.assertTrue(captured["init_kwargs"]["verify"]) - self.assertFalse(captured["init_kwargs"]["follow_redirects"]) - - def test_nettest_fails_when_expected_content_is_missing(self): - class FakeAsyncRequestUtils: - def __init__(self, **kwargs): - pass - - async def get_res(self, url, allow_redirects=True): - return SimpleNamespace(status_code=200, text="proxy landing page") - - with patch.object(system_endpoint, "AsyncRequestUtils", FakeAsyncRequestUtils), patch.object( - runtime_settings, - "PIP_PROXY", - "https://pypi.tuna.tsinghua.edu.cn/simple/", - ): - resp = asyncio.run( - system_endpoint.nettest( - target_id="pip_proxy", - _="token", - ) - ) - - self.assertFalse(resp.success) - self.assertIn("PIP加速代理", resp.message) + assert resp is None diff --git a/tests/test_web_agent_stream.py b/tests/test_web_agent_stream.py index 8ee9a8936..ed301d2d5 100644 --- a/tests/test_web_agent_stream.py +++ b/tests/test_web_agent_stream.py @@ -10,37 +10,71 @@ import pytest from app import schemas from app.agent.contracts import ReplyMode from app.agent.orchestrator import agent_manager -from app.api.endpoints.agent import ( - _WEB_AGENT_FILE_REGISTRY, - _apply_web_agent_display_event, - _build_web_agent_command_items, - _build_web_agent_display_message_from_events, - _build_web_agent_input_attachments, - _build_web_agent_message_events, - _build_web_agent_session_id, - _build_web_agent_session_id_async, - _build_web_agent_traditional_callback_payload, - _collect_web_agent_traditional_events, - _get_web_agent_type, - _has_web_agent_traditional_interaction, - _prepare_web_agent_audio_attachment_path_async, - _resolve_web_agent_audio_refs, - _resolve_web_agent_choice_payload, - _split_web_agent_output, - _transcribe_web_agent_audio_files, - _WebAgentEventPublisher, - web_agent_stream, -) +from app.agent.web import _get_web_agent_type +from app.api.endpoints import agent as agent_endpoint +from app.api.endpoints.agent import web_agent_stream +from app.application.messaging import agent as agent_application from app.application.messaging.agent import ( + _WEB_AGENT_FILE_REGISTRY, AgentInteractionOption, agent_interaction_manager, attach_web_agent_message_queue, build_web_agent_message_update_event, + configure_web_agent_message_runtime, detach_web_agent_message_queue, dispatch_web_agent_message_event, extract_web_agent_message_from_event_data, + reset_web_agent_message_runtime, wait_web_agent_background_tasks, ) +from app.application.messaging.agent import ( + WebAgentEventPublisher as _WebAgentEventPublisher, +) +from app.application.messaging.agent import ( + apply_web_agent_display_event as _apply_web_agent_display_event, +) +from app.application.messaging.agent import ( + build_web_agent_command_items as _build_web_agent_command_items, +) +from app.application.messaging.agent import ( + build_web_agent_display_message_from_events as _build_web_agent_display_message_from_events, +) +from app.application.messaging.agent import ( + build_web_agent_input_attachments as _build_web_agent_input_attachments, +) +from app.application.messaging.agent import ( + build_web_agent_message_events as _build_web_agent_message_events, +) +from app.application.messaging.agent import ( + build_web_agent_session_id as _build_web_agent_session_id, +) +from app.application.messaging.agent import ( + build_web_agent_session_id_async as _build_web_agent_session_id_async, +) +from app.application.messaging.agent import ( + build_web_agent_traditional_callback_payload as _build_web_agent_traditional_callback_payload, +) +from app.application.messaging.agent import ( + collect_web_agent_traditional_events as _collect_web_agent_traditional_events, +) +from app.application.messaging.agent import ( + has_web_agent_traditional_interaction as _has_web_agent_traditional_interaction, +) +from app.application.messaging.agent import ( + prepare_web_agent_audio_attachment_path_async as _prepare_web_agent_audio_attachment_path_async, +) +from app.application.messaging.agent import ( + resolve_web_agent_audio_refs as _resolve_web_agent_audio_refs, +) +from app.application.messaging.agent import ( + resolve_web_agent_choice_payload as _resolve_web_agent_choice_payload, +) +from app.application.messaging.agent import ( + split_web_agent_output as _split_web_agent_output, +) +from app.application.messaging.agent import ( + transcribe_web_agent_audio_files as _transcribe_web_agent_audio_files, +) from app.application.messaging.chat import AgentChatService, configure_agent_chat_service from app.application.messaging.skill import skill_interaction_manager from app.chain.message import MessageChain @@ -56,20 +90,52 @@ def _running_agent_service(): was_accepting = agent_manager._accepting_tasks agent_manager._accepting_tasks = True MessageChain._user_sessions.clear() + configure_web_agent_message_runtime( + message_handler=lambda **kwargs: MessageChain().handle_message(**kwargs), + session_binder=lambda user_id, session_id: MessageChain().bind_user_session(user_id, session_id), + ) try: - with patch( - "app.api.endpoints.agent.get_running_agent_manager", - return_value=agent_manager, - ), patch( - "app.chain.message.get_running_agent_manager", - return_value=agent_manager, + with ( + patch( + "app.application.agent.get_running_agent_manager", + return_value=agent_manager, + ), + patch( + "app.application.agent.get_web_agent_type", + side_effect=_get_web_agent_type, + ), + patch( + "app.chain.message.get_running_agent_manager", + return_value=agent_manager, + ), ): yield finally: + reset_web_agent_message_runtime() MessageChain._user_sessions.clear() agent_manager._accepting_tasks = was_accepting +def test_web_agent_non_transport_helpers_are_application_owned(): + """附件、音频、事件投影和会话写入不得回流到 FastAPI 端点。""" + ownership = { + "WebAgentEventPublisher", + "apply_web_agent_display_event", + "build_web_agent_input_attachments", + "build_web_agent_message_events_async", + "build_web_agent_session_id_async", + "build_web_agent_stream", + "collect_web_agent_traditional_events", + "get_web_agent_registered_file", + "prepare_web_agent_audio_attachment_path_async", + "save_web_agent_display_snapshot", + "transcribe_web_agent_audio_input", + } + + assert all(hasattr(agent_application, name) for name in ownership) + assert all(not hasattr(agent_endpoint, name) for name in ownership) + + def test_split_web_agent_output_extracts_verbose_tool_message(): """应将啰嗦模式工具提示拆成独立工具事件,并保留渠道展示文案。""" events = _split_web_agent_output("准备查询。\n\n⚙️ => 查询站点\n\n已完成") @@ -142,9 +208,7 @@ def test_web_agent_event_publisher_rejects_events_after_close(): async def scenario(): publisher = _WebAgentEventPublisher() await publisher.aclose() - return publisher.publish( - {"type": "interaction-protected", "content": "secret"} - ) + return publisher.publish({"type": "interaction-protected", "content": "secret"}) assert asyncio.run(scenario()) is False @@ -193,12 +257,10 @@ def test_build_web_agent_session_id_async_uses_native_async_persistence(): ) with patch( - "app.api.endpoints.agent.get_configured_agent_chat_service", + "app.application.messaging.agent.get_configured_agent_chat_service", return_value=service, ): - session_id = asyncio.run( - _build_web_agent_session_id_async(user, "telegram-session") - ) + session_id = asyncio.run(_build_web_agent_session_id_async(user, "telegram-session")) assert session_id == "telegram-session" service.get.assert_awaited_once_with("telegram-session") @@ -253,9 +315,7 @@ def test_agent_chat_display_schema_preserves_ordered_segments(): "content": "先检查检查完成", "createdAt": 1, "status": "done", - "tools": [ - {"id": "tool-1", "message": "执行检查", "status": "done"} - ], + "tools": [{"id": "tool-1", "message": "执行检查", "status": "done"}], "segments": [ {"type": "text", "content": "先检查"}, {"type": "tool", "toolIndex": 0}, @@ -294,7 +354,7 @@ def test_build_web_agent_input_attachments_marks_kinds(): def test_build_web_agent_command_items_returns_slash_commands(): """WebAgent 命令建议应返回可展示的斜杠命令。""" with patch( - "app.api.endpoints.agent.get_commands", + "app.application.messaging.agent.get_commands", return_value={ "/sites": {"description": "管理站点", "category": "站点"}, "hidden": {"description": "忽略", "category": "其他"}, @@ -317,7 +377,7 @@ def test_build_web_agent_command_items_returns_slash_commands(): def test_build_web_agent_command_items_includes_sites_command(): """WebAgent 命令建议应包含内建站点管理命令。""" with patch( - "app.api.endpoints.agent.get_commands", + "app.application.messaging.agent.get_commands", return_value={ "/sites": {"description": "管理站点", "category": "站点"}, }, @@ -350,10 +410,13 @@ def test_web_agent_stream_returns_error_for_unknown_command(): request = SimpleNamespace() user = SimpleNamespace(id=1, name="admin", is_superuser=True) - with patch( - "app.api.endpoints.agent.get_command", - return_value=None, - ), patch("app.api.endpoints.agent.MessageChain.handle_message") as handle_message: + with ( + patch( + "app.application.messaging.agent.get_command", + return_value=None, + ), + patch("app.chain.message.MessageChain.handle_message") as handle_message, + ): response = asyncio.run(web_agent_stream(payload, request, user)) body = "".join(asyncio.run(_collect_streaming_response(response))) @@ -367,16 +430,9 @@ def test_web_agent_stream_does_not_bind_request_scoped_chat_service(): from app.api.dependencies.agent import get_agent_chat_service from app.api.endpoints import agent as agent_endpoint - route = next( - route - for route in agent_endpoint.router.routes - if getattr(route, "name", None) == "web_agent_stream" - ) + route = next(route for route in agent_endpoint.router.routes if getattr(route, "name", None) == "web_agent_stream") - assert all( - dependency.call is not get_agent_chat_service - for dependency in route.dependant.dependencies - ) + assert all(dependency.call is not get_agent_chat_service for dependency in route.dependant.dependencies) def test_build_web_agent_message_update_event_converts_buttons(): @@ -397,17 +453,19 @@ def test_build_web_agent_message_update_event_converts_buttons(): def test_build_web_agent_display_message_from_events_marks_done(): """传统消息事件应聚合为完成态助手展示消息。""" - message = _build_web_agent_display_message_from_events([ - {"type": "delta", "content": "菜单"}, - { - "type": "choice", - "choice": { - "id": "choice-1", - "prompt": "请选择", - "buttons": [{"label": "返回", "callback_data": "back"}], + message = _build_web_agent_display_message_from_events( + [ + {"type": "delta", "content": "菜单"}, + { + "type": "choice", + "choice": { + "id": "choice-1", + "prompt": "请选择", + "buttons": [{"label": "返回", "callback_data": "back"}], + }, }, - }, - ]) + ] + ) assert message["content"] == "菜单" assert message["status"] == "done" @@ -444,10 +502,9 @@ def test_web_agent_admin_context_uses_current_user_id(): lookup_fn = Mock(return_value=SimpleNamespace(is_superuser=True)) with patch( - "app.api.endpoints.agent.get_configured_user_id_lookup", + "app.agent.web.get_configured_user_id_lookup", return_value=lookup_fn, ) as lookup: - assert asyncio.run(agent._is_system_admin_context()) is True lookup.assert_called_once_with() lookup_fn.assert_called_once_with(7) @@ -509,24 +566,14 @@ def test_web_agent_tool_summary_is_emitted_before_following_text(): def test_web_agent_channel_supports_streaming_and_attachments(): """WebAgent 渠道应声明流式、多媒体和文件发送能力。""" - assert ChannelCapabilityManager.supports_capability( - NotificationChannel.WebAgent, ChannelCapability.INLINE_BUTTONS - ) + assert ChannelCapabilityManager.supports_capability(NotificationChannel.WebAgent, ChannelCapability.INLINE_BUTTONS) assert ChannelCapabilityManager.supports_capability( NotificationChannel.WebAgent, ChannelCapability.CALLBACK_QUERIES ) - assert ChannelCapabilityManager.supports_capability( - NotificationChannel.WebAgent, ChannelCapability.MESSAGE_EDITING - ) - assert ChannelCapabilityManager.supports_capability( - NotificationChannel.WebAgent, ChannelCapability.IMAGES - ) - assert ChannelCapabilityManager.supports_capability( - NotificationChannel.WebAgent, ChannelCapability.AUDIO_OUTPUT - ) - assert ChannelCapabilityManager.supports_capability( - NotificationChannel.WebAgent, ChannelCapability.FILE_SENDING - ) + assert ChannelCapabilityManager.supports_capability(NotificationChannel.WebAgent, ChannelCapability.MESSAGE_EDITING) + assert ChannelCapabilityManager.supports_capability(NotificationChannel.WebAgent, ChannelCapability.IMAGES) + assert ChannelCapabilityManager.supports_capability(NotificationChannel.WebAgent, ChannelCapability.AUDIO_OUTPUT) + assert ChannelCapabilityManager.supports_capability(NotificationChannel.WebAgent, ChannelCapability.FILE_SENDING) def test_build_web_agent_message_events_extracts_image(): @@ -565,9 +612,7 @@ def test_extract_web_agent_message_supports_wrapped_message_event(): userid="1", ) - extracted = extract_web_agent_message_from_event_data( - {"message": message, "current_time": "2026-06-26 09:18:38"} - ) + extracted = extract_web_agent_message_from_event_data({"message": message, "current_time": "2026-06-26 09:18:38"}) assert extracted == message @@ -600,14 +645,18 @@ def test_collect_web_agent_traditional_events_does_not_emit_submit_hint(): """传统命令未产生通知时不应返回“命令已提交”的兜底提示。""" user = SimpleNamespace(id=1, name="admin") - with patch( - "app.api.endpoints.agent.MessageChain.handle_message", - ), patch( - "app.api.endpoints.agent.WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS", - 0.01, - ), patch( - "app.api.endpoints.agent.WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS", - 0.05, + with ( + patch( + "app.chain.message.MessageChain.handle_message", + ), + patch( + "app.application.messaging.agent.WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS", + 0.01, + ), + patch( + "app.application.messaging.agent.WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS", + 0.05, + ), ): events = asyncio.run( _collect_web_agent_traditional_events( @@ -690,22 +739,25 @@ def test_prepare_web_agent_audio_attachment_async_keeps_loop_responsive(tmp_path return FakeProcess() async def scenario(): - with patch( - "app.api.endpoints.agent.shutil.which", - return_value="/usr/bin/ffmpeg", - ), patch( - "app.api.endpoints.agent.uuid.uuid4", - return_value=SimpleNamespace(hex="abcdef1234567890"), - ), patch( - "app.api.endpoints.agent.asyncio.create_subprocess_exec", - side_effect=fake_create_subprocess_exec, - ), patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(temp_path=tmp_path), + with ( + patch( + "app.application.messaging.agent.shutil.which", + return_value="/usr/bin/ffmpeg", + ), + patch( + "app.application.messaging.agent.uuid.uuid4", + return_value=SimpleNamespace(hex="abcdef1234567890"), + ), + patch( + "app.application.messaging.agent.asyncio.create_subprocess_exec", + side_effect=fake_create_subprocess_exec, + ), + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(temp_path=tmp_path), + ), ): - conversion_task = asyncio.create_task( - _prepare_web_agent_audio_attachment_path_async(str(source_path)) - ) + conversion_task = asyncio.create_task(_prepare_web_agent_audio_attachment_path_async(str(source_path))) await asyncio.wait_for(started.wait(), timeout=1) heartbeat = asyncio.create_task(asyncio.sleep(0)) await heartbeat @@ -747,22 +799,25 @@ def test_prepare_web_agent_audio_attachment_async_cancellation_reaps_process(tmp return FakeProcess() async def scenario(): - with patch( - "app.api.endpoints.agent.shutil.which", - return_value="/usr/bin/ffmpeg", - ), patch( - "app.api.endpoints.agent.uuid.uuid4", - return_value=SimpleNamespace(hex="abcdef1234567890"), - ), patch( - "app.api.endpoints.agent.asyncio.create_subprocess_exec", - side_effect=fake_create_subprocess_exec, - ), patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(temp_path=tmp_path), + with ( + patch( + "app.application.messaging.agent.shutil.which", + return_value="/usr/bin/ffmpeg", + ), + patch( + "app.application.messaging.agent.uuid.uuid4", + return_value=SimpleNamespace(hex="abcdef1234567890"), + ), + patch( + "app.application.messaging.agent.asyncio.create_subprocess_exec", + side_effect=fake_create_subprocess_exec, + ), + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(temp_path=tmp_path), + ), ): - conversion_task = asyncio.create_task( - _prepare_web_agent_audio_attachment_path_async(str(source_path)) - ) + conversion_task = asyncio.create_task(_prepare_web_agent_audio_attachment_path_async(str(source_path))) await asyncio.wait_for(started.wait(), timeout=5) conversion_task.cancel() with pytest.raises(asyncio.CancelledError): @@ -802,19 +857,21 @@ def test_prepare_web_agent_audio_attachment_async_communicate_error_reaps_proces return FakeProcess() async def scenario(): - with patch( - "app.api.endpoints.agent.shutil.which", - return_value="/usr/bin/ffmpeg", - ), patch( - "app.api.endpoints.agent.asyncio.create_subprocess_exec", - side_effect=fake_create_subprocess_exec, - ), patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(temp_path=tmp_path), + with ( + patch( + "app.application.messaging.agent.shutil.which", + return_value="/usr/bin/ffmpeg", + ), + patch( + "app.application.messaging.agent.asyncio.create_subprocess_exec", + side_effect=fake_create_subprocess_exec, + ), + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(temp_path=tmp_path), + ), ): - output_path = await _prepare_web_agent_audio_attachment_path_async( - str(source_path) - ) + output_path = await _prepare_web_agent_audio_attachment_path_async(str(source_path)) return output_path output_path = asyncio.run(scenario()) @@ -851,25 +908,29 @@ def test_prepare_web_agent_audio_attachment_async_cancellation_cleans_completed_ return await asyncio.to_thread(func, *args, **kwargs) async def scenario(): - with patch( - "app.api.endpoints.agent.shutil.which", - return_value="/usr/bin/ffmpeg", - ), patch( - "app.api.endpoints.agent.uuid.uuid4", - return_value=SimpleNamespace(hex="abcdef1234567890"), - ), patch( - "app.api.endpoints.agent.asyncio.create_subprocess_exec", - side_effect=fake_create_subprocess_exec, - ), patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(temp_path=tmp_path), - ), patch( - "app.api.endpoints.agent.run_in_threadpool", - side_effect=fake_run_in_threadpool, + with ( + patch( + "app.application.messaging.agent.shutil.which", + return_value="/usr/bin/ffmpeg", + ), + patch( + "app.application.messaging.agent.uuid.uuid4", + return_value=SimpleNamespace(hex="abcdef1234567890"), + ), + patch( + "app.application.messaging.agent.asyncio.create_subprocess_exec", + side_effect=fake_create_subprocess_exec, + ), + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(temp_path=tmp_path), + ), + patch( + "app.application.messaging.agent.run_in_threadpool", + side_effect=fake_run_in_threadpool, + ), ): - conversion_task = asyncio.create_task( - _prepare_web_agent_audio_attachment_path_async(str(source_path)) - ) + conversion_task = asyncio.create_task(_prepare_web_agent_audio_attachment_path_async(str(source_path))) await asyncio.wait_for(exists_started.wait(), timeout=1) assert output_path.exists() conversion_task.cancel() @@ -908,22 +969,25 @@ def test_prepare_web_agent_audio_attachment_async_timeout_falls_back(tmp_path): return FakeProcess() async def scenario(): - with patch( - "app.api.endpoints.agent.shutil.which", - return_value="/usr/bin/ffmpeg", - ), patch( - "app.api.endpoints.agent.asyncio.create_subprocess_exec", - side_effect=fake_create_subprocess_exec, - ), patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(temp_path=tmp_path), - ), patch( - "app.api.endpoints.agent.WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS", - 0.01, + with ( + patch( + "app.application.messaging.agent.shutil.which", + return_value="/usr/bin/ffmpeg", + ), + patch( + "app.application.messaging.agent.asyncio.create_subprocess_exec", + side_effect=fake_create_subprocess_exec, + ), + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(temp_path=tmp_path), + ), + patch( + "app.application.messaging.agent.WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS", + 0.01, + ), ): - conversion_task = asyncio.create_task( - _prepare_web_agent_audio_attachment_path_async(str(source_path)) - ) + conversion_task = asyncio.create_task(_prepare_web_agent_audio_attachment_path_async(str(source_path))) await asyncio.wait_for(started.wait(), timeout=1) return await conversion_task @@ -945,16 +1009,17 @@ def test_transcribe_web_agent_audio_files_reads_registered_upload(tmp_path): } try: - with patch( - "app.api.endpoints.agent.is_audio_input_available", - return_value=True, - ), patch( - "app.api.endpoints.agent.transcribe_audio", - return_value="帮我推荐一部电影", - ) as transcribe_audio: - audio_files = _resolve_web_agent_audio_refs( - ["message/agent/file/audio-test"] - ) + with ( + patch( + "app.application.messaging.agent.is_audio_input_available", + return_value=True, + ), + patch( + "app.application.messaging.agent.transcribe_audio", + return_value="帮我推荐一部电影", + ) as transcribe_audio, + ): + audio_files = _resolve_web_agent_audio_refs(["message/agent/file/audio-test"]) _WEB_AGENT_FILE_REGISTRY.pop("audio-test", None) transcript = _transcribe_web_agent_audio_files(audio_files) finally: @@ -977,13 +1042,16 @@ def test_web_agent_stream_returns_error_when_voice_transcription_fails(): request = SimpleNamespace() user = SimpleNamespace(id=1, name="admin") - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch( - "app.api.endpoints.agent._transcribe_web_agent_audio_files", - return_value=None, - ) as transcribe_audio: + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch( + "app.application.messaging.agent.transcribe_web_agent_audio_files", + return_value=None, + ) as transcribe_audio, + ): response = asyncio.run(web_agent_stream(payload, request, user)) body = "".join(asyncio.run(_collect_streaming_response(response))) @@ -1018,15 +1086,19 @@ def test_web_agent_stream_does_not_block_event_loop_during_transcription(): return await stream_task try: - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch( - "app.api.endpoints.agent._resolve_web_agent_audio_refs", - return_value=[Mock()], - ), patch( - "app.api.endpoints.agent._transcribe_web_agent_audio_files", - side_effect=blocking_transcription, + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch( + "app.application.messaging.agent.resolve_web_agent_audio_refs", + return_value=[Mock()], + ), + patch( + "app.application.messaging.agent.transcribe_web_agent_audio_files", + side_effect=blocking_transcription, + ), ): response = asyncio.run(scenario()) finally: @@ -1087,12 +1159,15 @@ def test_web_agent_stream_binds_session_to_agent_manager(): return "".join(await _collect_streaming_response(response)) try: - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch( - "app.api.endpoints.agent._get_web_agent_type", - return_value=FakeWebAgent, + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch( + "app.application.agent.get_web_agent_type", + return_value=FakeWebAgent, + ), ): body = asyncio.run(scenario()) @@ -1186,16 +1261,20 @@ def test_web_agent_stream_emits_secret_result_only_as_protected_event(): return "".join(await _collect_streaming_response(response)) try: - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch( - "app.api.endpoints.agent._get_web_agent_type", - return_value=FakeProtectedAgent, - ), patch( - "app.api.endpoints.agent._save_web_agent_display_snapshot", - new_callable=AsyncMock, - ) as save_snapshot: + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch( + "app.application.agent.get_web_agent_type", + return_value=FakeProtectedAgent, + ), + patch( + "app.application.messaging.agent.save_web_agent_display_snapshot", + new_callable=AsyncMock, + ) as save_snapshot, + ): body = asyncio.run(scenario()) assert "event: interaction-protected\n" in body @@ -1248,21 +1327,26 @@ def test_web_agent_cancel_keeps_existing_display_history(): return "".join(await _collect_streaming_response(response)) try: - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch.object( - agent_manager, - "matches_secret_confirmation", - return_value=True, - ), patch.object( - agent_manager, - "process_message", - new=AsyncMock(return_value="已取消敏感设置读取。"), - ), patch( - "app.api.endpoints.agent._save_web_agent_display_snapshot", - new_callable=AsyncMock, - ) as save_snapshot: + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch.object( + agent_manager, + "matches_secret_confirmation", + return_value=True, + ), + patch.object( + agent_manager, + "process_message", + new=AsyncMock(return_value="已取消敏感设置读取。"), + ), + patch( + "app.application.messaging.agent.save_web_agent_display_snapshot", + new_callable=AsyncMock, + ) as save_snapshot, + ): body = asyncio.run(scenario()) assert '"type": "done"' in body @@ -1292,14 +1376,18 @@ def test_web_agent_stream_rejects_confirmation_without_protected_capability(): response = await web_agent_stream(payload, request, user) return "".join(await _collect_streaming_response(response)) - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch.object( - agent_manager, - "matches_secret_confirmation", - return_value=True, - ), patch.object(agent_manager, "process_message", new=AsyncMock()) as process: + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch.object( + agent_manager, + "matches_secret_confirmation", + return_value=True, + ), + patch.object(agent_manager, "process_message", new=AsyncMock()) as process, + ): body = asyncio.run(scenario()) assert "不支持安全交付" in body @@ -1321,14 +1409,17 @@ def test_web_agent_stream_keeps_confirmation_without_pending_on_normal_path(): body = "".join(await _collect_streaming_response(response)) return response, body - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch.object( - agent_manager, - "process_message", - new=AsyncMock(return_value="普通回复"), - ) as process: + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch.object( + agent_manager, + "process_message", + new=AsyncMock(return_value="普通回复"), + ) as process, + ): response, body = asyncio.run(scenario()) assert "不支持安全交付" not in body @@ -1347,6 +1438,7 @@ def test_web_agent_stream_drops_secret_result_after_disconnect(): agent_started = asyncio.Event() release_agent = asyncio.Event() agent_completed = asyncio.Event() + async def disconnect_after_agent_starts(): """等待确认进入处理流程后再模拟浏览器断线。""" await agent_started.wait() @@ -1378,9 +1470,7 @@ def test_web_agent_stream_drops_secret_result_after_disconnect(): """断线后继续完成只读任务,并尝试向已关闭发布器投递。""" agent_started.set() await release_agent.wait() - delivery_results.append( - kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER") - ) + delivery_results.append(kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER")) agent_completed.set() async def scenario(): @@ -1397,21 +1487,26 @@ def test_web_agent_stream_drops_secret_result_after_disconnect(): return body try: - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch.object( - agent_manager, - "matches_secret_confirmation", - return_value=True, - ), patch.object( - agent_manager, - "process_message", - new=AsyncMock(side_effect=finish_after_disconnect), - ) as process, patch( - "app.api.endpoints.agent._save_web_agent_display_snapshot", - new_callable=AsyncMock, - ) as save_snapshot: + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch.object( + agent_manager, + "matches_secret_confirmation", + return_value=True, + ), + patch.object( + agent_manager, + "process_message", + new=AsyncMock(side_effect=finish_after_disconnect), + ) as process, + patch( + "app.application.messaging.agent.save_web_agent_display_snapshot", + new_callable=AsyncMock, + ) as save_snapshot, + ): body = asyncio.run(scenario()) assert '"type": "start"' in body @@ -1441,31 +1536,40 @@ def test_web_agent_stream_emits_heartbeat_during_idle_tool_wait(): response = await web_agent_stream(payload, request, user) return "".join(await _collect_streaming_response(response)) - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch( - "app.api.endpoints.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS", - 0.01, - ), patch( - "app.api.endpoints.agent._is_web_agent_traditional_message", - return_value=False, - ), patch( - "app.api.endpoints.agent._has_web_agent_traditional_interaction", - return_value=False, - ), patch( - "app.api.endpoints.agent._build_web_agent_session_id_async", - return_value="web-agent:heartbeat", - ), patch.object( - MessageChain, - "bind_user_session", - ), patch.object( - agent_manager, - "process_message", - side_effect=slow_process_message, - ), patch( - "app.api.endpoints.agent._save_web_agent_display_snapshot", - new_callable=AsyncMock, + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch( + "app.application.messaging.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS", + 0.01, + ), + patch( + "app.application.messaging.agent.is_web_agent_traditional_message", + return_value=False, + ), + patch( + "app.application.messaging.agent.has_web_agent_traditional_interaction", + return_value=False, + ), + patch( + "app.application.messaging.agent.build_web_agent_session_id_async", + return_value="web-agent:heartbeat", + ), + patch.object( + MessageChain, + "bind_user_session", + ), + patch.object( + agent_manager, + "process_message", + side_effect=slow_process_message, + ), + patch( + "app.application.messaging.agent.save_web_agent_display_snapshot", + new_callable=AsyncMock, + ), ): body = asyncio.run(scenario()) @@ -1508,9 +1612,7 @@ def test_web_agent_stop_finishes_stream_without_error(): received = [await asyncio.wait_for(anext(iterator), timeout=1)] await asyncio.wait_for(BlockingWebAgent.started.wait(), timeout=1) - assert await asyncio.wait_for( - agent_manager.stop_current_task(session_id), timeout=1 - ) is True + assert await asyncio.wait_for(agent_manager.stop_current_task(session_id), timeout=1) is True while '"type": "done"' not in "".join(received): received.append(await asyncio.wait_for(anext(iterator), timeout=1)) await iterator.aclose() @@ -1518,27 +1620,35 @@ def test_web_agent_stop_finishes_stream_without_error(): return "".join(received) try: - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch( - "app.api.endpoints.agent._is_web_agent_traditional_message", - return_value=False, - ), patch( - "app.api.endpoints.agent._has_web_agent_traditional_interaction", - return_value=False, - ), patch( - "app.api.endpoints.agent._build_web_agent_session_id_async", - return_value=session_id, - ), patch.object( - MessageChain, - "bind_user_session", - ), patch( - "app.api.endpoints.agent._get_web_agent_type", - return_value=BlockingWebAgent, - ), patch( - "app.api.endpoints.agent._save_web_agent_display_snapshot", - new_callable=AsyncMock, + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch( + "app.application.messaging.agent.is_web_agent_traditional_message", + return_value=False, + ), + patch( + "app.application.messaging.agent.has_web_agent_traditional_interaction", + return_value=False, + ), + patch( + "app.application.messaging.agent.build_web_agent_session_id_async", + return_value=session_id, + ), + patch.object( + MessageChain, + "bind_user_session", + ), + patch( + "app.application.agent.get_web_agent_type", + return_value=BlockingWebAgent, + ), + patch( + "app.application.messaging.agent.save_web_agent_display_snapshot", + new_callable=AsyncMock, + ), ): body = asyncio.run(scenario()) finally: @@ -1564,22 +1674,28 @@ def test_web_agent_stream_rechecks_running_service_before_enqueue(): response = await web_agent_stream(payload, request, user) return "".join(await _collect_streaming_response(response)) - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch( - "app.api.endpoints.agent._is_web_agent_traditional_message", - return_value=False, - ), patch( - "app.api.endpoints.agent._has_web_agent_traditional_interaction", - return_value=False, - ), patch( - "app.api.endpoints.agent.get_running_agent_manager", - side_effect=[stale_manager, None], - ), patch( - "app.api.endpoints.agent._save_web_agent_display_snapshot", + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch( + "app.application.messaging.agent.is_web_agent_traditional_message", + return_value=False, + ), + patch( + "app.application.messaging.agent.has_web_agent_traditional_interaction", + return_value=False, + ), + patch( + "app.application.agent.get_running_agent_manager", + side_effect=[stale_manager, None], + ), + patch( + "app.application.messaging.agent.save_web_agent_display_snapshot", new_callable=AsyncMock, - ): + ), + ): body = asyncio.run(scenario()) assert '"type": "error"' in body @@ -1633,28 +1749,36 @@ def test_web_agent_traditional_stream_keeps_alive_and_saves_after_done(): return "".join(received) try: - with patch( - "app.api.endpoints.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS", - 0.01, - ), patch( - "app.api.endpoints.agent._is_web_agent_traditional_message", - return_value=True, - ), patch( - "app.api.endpoints.agent._ensure_web_agent_command_allowed", - return_value=None, - ), patch( - "app.api.endpoints.agent._get_web_agent_unknown_command_message", - return_value=None, - ), patch( - "app.api.endpoints.agent._build_web_agent_session_id_async", - return_value="web-agent:traditional-heartbeat", - ), patch( - "app.api.endpoints.agent._collect_web_agent_traditional_events", - side_effect=slow_collect, - ), patch( - "app.api.endpoints.agent._save_web_agent_display_snapshot", - new_callable=AsyncMock, - side_effect=slow_snapshot, + with ( + patch( + "app.application.messaging.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS", + 0.01, + ), + patch( + "app.application.messaging.agent.is_web_agent_traditional_message", + return_value=True, + ), + patch( + "app.application.messaging.agent.ensure_web_agent_command_allowed", + return_value=None, + ), + patch( + "app.application.messaging.agent.get_web_agent_unknown_command_message", + return_value=None, + ), + patch( + "app.application.messaging.agent.build_web_agent_session_id_async", + return_value="web-agent:traditional-heartbeat", + ), + patch( + "app.application.messaging.agent.collect_web_agent_traditional_events", + side_effect=slow_collect, + ), + patch( + "app.application.messaging.agent.save_web_agent_display_snapshot", + new_callable=AsyncMock, + side_effect=slow_snapshot, + ), ): body = asyncio.run(scenario()) @@ -1691,21 +1815,27 @@ def test_web_agent_traditional_stream_drains_collection_on_cancellation(): cleanup_finished.set() raise - with patch( - "app.api.endpoints.agent._is_web_agent_traditional_message", - return_value=True, - ), patch( - "app.api.endpoints.agent._ensure_web_agent_command_allowed", - return_value=None, - ), patch( - "app.api.endpoints.agent._get_web_agent_unknown_command_message", - return_value=None, - ), patch( - "app.api.endpoints.agent._build_web_agent_session_id_async", - return_value="web-agent:traditional-cancel", - ), patch( - "app.api.endpoints.agent._collect_web_agent_traditional_events", - side_effect=blocked_collect, + with ( + patch( + "app.application.messaging.agent.is_web_agent_traditional_message", + return_value=True, + ), + patch( + "app.application.messaging.agent.ensure_web_agent_command_allowed", + return_value=None, + ), + patch( + "app.application.messaging.agent.get_web_agent_unknown_command_message", + return_value=None, + ), + patch( + "app.application.messaging.agent.build_web_agent_session_id_async", + return_value="web-agent:traditional-cancel", + ), + patch( + "app.application.messaging.agent.collect_web_agent_traditional_events", + side_effect=blocked_collect, + ), ): response = await web_agent_stream(payload, request, user) iterator = response.body_iterator.__aiter__() @@ -1770,29 +1900,37 @@ def test_web_agent_stream_sends_done_before_snapshot_persistence_finishes(): return "".join(received) try: - with patch( - "app.api.endpoints.agent.get_api_runtime_config_snapshot", - return_value=SimpleNamespace(ai_agent_enable=True), - ), patch( - "app.api.endpoints.agent._is_web_agent_traditional_message", - return_value=False, - ), patch( - "app.api.endpoints.agent._has_web_agent_traditional_interaction", - return_value=False, - ), patch( - "app.api.endpoints.agent._build_web_agent_session_id_async", - return_value="web-agent:snapshot", - ), patch.object( - MessageChain, - "bind_user_session", - ), patch.object( - agent_manager, - "process_message", - side_effect=immediate_process_message, - ), patch( - "app.api.endpoints.agent._save_web_agent_display_snapshot", - new_callable=AsyncMock, - side_effect=slow_snapshot, + with ( + patch( + "app.application.messaging.agent.get_api_runtime_config_snapshot", + return_value=SimpleNamespace(ai_agent_enable=True), + ), + patch( + "app.application.messaging.agent.is_web_agent_traditional_message", + return_value=False, + ), + patch( + "app.application.messaging.agent.has_web_agent_traditional_interaction", + return_value=False, + ), + patch( + "app.application.messaging.agent.build_web_agent_session_id_async", + return_value="web-agent:snapshot", + ), + patch.object( + MessageChain, + "bind_user_session", + ), + patch.object( + agent_manager, + "process_message", + side_effect=immediate_process_message, + ), + patch( + "app.application.messaging.agent.save_web_agent_display_snapshot", + new_callable=AsyncMock, + side_effect=slow_snapshot, + ), ): body = asyncio.run(scenario())