mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
450 lines
15 KiB
Python
450 lines
15 KiB
Python
"""
|
||
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
|