Files
MoviePilot/app/agent/web.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

450 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
WebAgent 运行时类型适配,不包含 HTTP 或 SSE 编码。
"""
import time
import uuid
from threading import Lock
from typing import Any, Awaitable, Callable, Optional
from app.agent.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
from app.schemas.types import NotificationChannel
class _WebAgentStreamingHandlerMixin:
"""
Web 前端专用流式处理器,将工具提示和文本统一回调给 SSE。
"""
_lock: Lock
_pending_tool_stats: dict[str, int]
def _should_buffer_thinking_status(self) -> bool:
"""
WebAgent 通过结构化 SSE 展示思考状态,不把它写入正文。
"""
return False
def __init__(
self,
on_emit: Callable[[str], None],
on_tool_event: Optional[Callable[[dict[str, Any]], None]] = None,
) -> None:
"""
绑定当前 Web 请求的输出回调。
"""
super().__init__()
self._on_emit = on_emit
self._on_tool_event = on_tool_event
def set_emit_callback(self, on_emit: Callable[[str], None]) -> None:
"""
更新流式输出回调,复用 WebAgent 实例时指向当前 SSE 请求。
:param on_emit: 当前请求的输出回调
"""
self._on_emit = on_emit
def set_tool_event_callback(
self,
on_tool_event: Optional[Callable[[dict[str, Any]], None]],
) -> None:
"""
更新当前 Web 请求的结构化工具生命周期回调。
"""
self._on_tool_event = on_tool_event
def _uses_structured_tool_events(self) -> bool:
"""
仅在啰嗦模式下使用 WebAgent 的逐条工具生命周期事件。
"""
# ``StreamingHandler`` 是运行时组合进当前 MRO 的基础实现。
return bool(self._on_tool_event and super()._is_verbose_mode()) # type: ignore[misc]
def _publish_tool_event(self, event: dict[str, Any]) -> None:
"""
向 Web SSE 发布结构化工具生命周期事件,并隔离回调异常。
"""
if not self._on_tool_event:
return
try:
self._on_tool_event(event)
except Exception as error:
logger.debug(f"Web工具生命周期回调失败: {error}")
def thinking_started(self) -> None:
"""
发布 Web SSE 的模型思考开始事件,由前端负责持续计时。
"""
was_active = bool(getattr(self, "_thinking_active", False))
super().thinking_started() # type: ignore[misc]
if not was_active and self._thinking_active:
self._publish_tool_event({"type": "thinking", "status": "running", "started_at": int(time.time() * 1000)})
def thinking_finished(self) -> None:
"""
发布 Web SSE 的模型思考结束事件,并清理基础流式状态。
"""
was_active = bool(getattr(self, "_thinking_active", False))
super().thinking_finished() # type: ignore[misc]
if was_active:
self._publish_tool_event({"type": "thinking", "status": "done"})
def tool_call_started(
self,
tool_name: str,
tool_message: Optional[str] = None,
) -> str:
"""
发布真实工具开始事件,调用方随后用同一 ID 收口结果。
"""
super().tool_call_started(tool_name, tool_message) # type: ignore[misc]
if not self._uses_structured_tool_events():
return ""
tool_id = f"tool-{uuid.uuid4().hex}"
self._publish_tool_event(
{
"type": "tool",
"status": "running",
"tool_id": tool_id,
"tool_name": str(tool_name or ""),
"message": " ".join(str(tool_message or tool_name).splitlines()),
}
)
return tool_id
def tool_call_finished(self, tool_id: str, status: str = "done") -> None:
"""
发布真实工具完成或失败事件,保持与开始事件的 ID 对应。
"""
normalized_status = status if status in {"done", "error"} else "done"
if tool_id and self._uses_structured_tool_events():
self._publish_tool_event(
{
"type": "tool",
"status": normalized_status,
"tool_id": tool_id,
}
)
super().tool_call_finished(tool_id, status) # type: ignore[misc]
def report_tool_call(
self,
tool_name: str,
tool_message: Optional[str] = None,
tool_kwargs: Optional[dict[str, Any]] = None,
) -> str:
"""
为没有 MoviePilotTool 包装层的内部工具建立完整生命周期。
"""
if self._uses_structured_tool_events():
del tool_kwargs
return self.tool_call_started(tool_name, tool_message)
return str(super().report_tool_call( # type: ignore[misc]
tool_name=tool_name,
tool_message=tool_message,
tool_kwargs=tool_kwargs,
) or "")
def record_tool_call(
self,
tool_name: str,
tool_message: Optional[str] = None,
tool_kwargs: Optional[dict[str, Any]] = None,
) -> None:
"""
记录 Web 工具调用;详细模式使用生命周期事件,其他模式保留计数汇总。
"""
if self._uses_structured_tool_events():
return
# 该方法由运行时组合进 MRO 的 StreamingHandler 提供。
super().record_tool_call( # type: ignore[misc]
tool_name=tool_name,
tool_message=tool_message,
tool_kwargs=tool_kwargs,
)
def _on_tool_stats_recorded(self) -> None:
"""
WebAgent 非啰嗦模式在每次工具开始时立即发布最新摘要。
"""
if self._uses_structured_tool_events():
return
if self._streaming_enabled:
self.flush_pending_tool_summary()
return
# 兼容尚未进入流式生命周期的直接调用;正式 Web 请求会在上面的
# 分支中逐次回调 SSE,而不会等正文或流结束后才显示统计。
if not self._on_tool_event:
self.flush_pending_tool_summary()
def emit_tool_message(self, message: str) -> str:
"""
将工具摘要压成一行,防止多行参数被 SSE 文本解析器拆入正文。
仅处理展示文案;实际工具参数保持原样,由前端在工具提示条内自动折行。
"""
normalized_message = " ".join(str(message or "").splitlines())
if self._on_tool_event:
return ""
emitted = str(super().emit_tool_message(normalized_message)) # type: ignore[misc]
if emitted:
self._on_emit(emitted)
return emitted
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:
"""
输出延迟聚合的工具摘要。
"""
if self._uses_structured_tool_events():
return ""
# 该方法由运行时组合进 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 = {}
self._live_tool_summary = None
self._thinking_started_at = None
self._thinking_active = False
self._thinking_status_text = ""
async def stop_streaming(self) -> tuple[bool, str]:
"""
停止 Web SSE 流式状态,保留缓冲区给 Agent 收口逻辑去重。
"""
if not self._streaming_enabled:
return False, ""
self.thinking_finished()
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 = {}
self._live_tool_summary = None
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
channel: Optional[str]
source: Optional[str]
def __init__(
self,
*args: Any,
message_callback: Optional[Callable[[Message], Awaitable[None] | None]] = None,
**kwargs: Any,
) -> None:
"""
绑定当前 Web 消息回调,并装配支持结构化事件的流式处理器。
"""
tool_event_callback = kwargs.pop("tool_event_callback", None)
super().__init__(*args, **kwargs)
self._message_callback = message_callback
self.stream_handler = _get_web_agent_streaming_handler_type()(
self._emit_output,
tool_event_callback,
)
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)
def set_tool_event_callback(
self,
tool_event_callback: Optional[Callable[[dict[str, Any]], None]],
) -> None:
"""
更新当前 Web SSE 结构化工具生命周期回调。
"""
if isinstance(self.stream_handler, _WebAgentStreamingHandlerMixin):
self.stream_handler.set_tool_event_callback(tool_event_callback)
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)
async def _can_restore_cross_owner_history(self) -> bool:
"""
仅真实 Web 登录管理员可续聊已授权历史,不把后台复用扩展为全局读取。
"""
return (
self.channel == NotificationChannel.WebAgent.value
and self.source == "web-agent"
and await self._is_system_admin_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