Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	app/api/endpoints/agent.py
#	app/api/endpoints/anthropic.py
#	app/api/endpoints/openai.py
#	app/chain/__init__.py
#	app/chain/message.py
#	app/chain/site.py
#	app/chain/subscribe.py
#	app/chain/transfer.py
#	app/modules/discord/__init__.py
#	app/modules/qqbot/__init__.py
#	app/modules/slack/__init__.py
#	app/modules/telegram/__init__.py
#	app/modules/wechat/__init__.py
#	app/runtime/extensions/module_manager.py
#	app/runtime/extensions/service_registry.py
#	tests/test_agent_interaction.py
#	tests/test_slash_command_interactions.py
#	tests/test_web_agent_stream.py
This commit is contained in:
jxxghp
2026-08-16 19:44:43 +08:00
232 changed files with 21375 additions and 6683 deletions
+93 -16
View File
@@ -20,10 +20,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.api.response import ResponseAPIRouter
from app.agent.callback import StreamingHandler
from app.agent.orchestrator import MoviePilotAgent, ReplyMode, agent_manager
from app.agent.contracts import ReplyMode, build_display_message
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.mcp import agent_mcp_manager
from app.agent.runtime_loader import (
get_moviepilot_agent_type,
get_running_agent_manager,
)
from app.chain.message import MessageChain
from app.command import Command
from app.runtime.config import global_vars, settings
@@ -254,7 +257,7 @@ async def test_agent_mcp_server(
)
class _WebAgentStreamingHandler(StreamingHandler):
class _WebAgentStreamingHandlerMixin:
"""
Web 前端专用流式处理器,将工具提示和文本统一回调给 SSE。
"""
@@ -342,7 +345,28 @@ class _WebAgentStreamingHandler(StreamingHandler):
return True
class _WebAgentMoviePilotAgent(MoviePilotAgent):
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,强制使用流式推理。
"""
@@ -355,7 +379,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
) -> None:
super().__init__(*args, **kwargs)
self._message_callback = message_callback
self.stream_handler = _WebAgentStreamingHandler(self._emit_output)
self.stream_handler = _get_web_agent_streaming_handler_type()(
self._emit_output
)
def _should_stream(self) -> bool:
"""Web 对话实时输出,复用会话执行后台任务时改用非流式广播。"""
@@ -381,7 +407,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
:param output_callback: 当前请求的输出回调
"""
self.output_callback = output_callback
if output_callback and isinstance(self.stream_handler, _WebAgentStreamingHandler):
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:
@@ -420,6 +448,30 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
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: User, session_id: Optional[str]) -> str:
"""
构建前端 Agent 会话 ID。
@@ -1131,7 +1183,7 @@ def _build_web_agent_display_message_from_events(
:param events: 已转换的 WebAgent SSE 事件列表
:return: 可持久化的助手展示消息
"""
message = MoviePilotAgent.build_display_message(
message = build_display_message(
role="assistant",
status="streaming",
)
@@ -1725,7 +1777,8 @@ async def get_agent_chat_session(
if server_session_id != session_id:
chat = await _get_accessible_agent_chat(oper, server_session_id, current_user)
if not chat:
if agent_manager.is_session_busy(server_session_id):
manager = get_running_agent_manager()
if manager and manager.is_session_busy(server_session_id):
return schemas.Response(
success=True,
data={
@@ -1737,7 +1790,10 @@ async def get_agent_chat_session(
)
return schemas.Response(success=False, message="会话不存在或无权访问")
data = AgentChatOper.to_detail(chat)
data["is_processing"] = agent_manager.is_session_busy(chat.session_id)
manager = get_running_agent_manager()
data["is_processing"] = bool(
manager and manager.is_session_busy(chat.session_id)
)
return schemas.Response(success=True, data=data)
@@ -1836,7 +1892,8 @@ async def stop_web_agent_session_task(
if chat and not _can_access_agent_chat(chat, current_user):
return schemas.Response(success=False, message="会话不存在或无权访问")
stopped = await agent_manager.stop_current_task(server_session_id)
manager = get_running_agent_manager()
stopped = await manager.stop_current_task(server_session_id) if manager else False
return schemas.Response(
success=True,
data={"stopped": stopped},
@@ -1881,7 +1938,8 @@ async def web_agent_stream(
)
is_secret_confirmation_control = (
is_secret_confirmation_candidate
and agent_manager.matches_secret_confirmation(
and (manager := get_running_agent_manager()) is not None
and manager.matches_secret_confirmation(
session_id,
str(current_user.id),
channel=NotificationChannel.WebAgent.value,
@@ -1943,7 +2001,7 @@ async def web_agent_stream(
display_messages = []
if payload.echo_user:
display_messages.append(
MoviePilotAgent.build_display_message(
build_display_message(
role="user",
content=display_prompt or prompt,
attachments=user_attachments,
@@ -2038,6 +2096,19 @@ async def web_agent_stream(
media_type="text/event-stream",
)
manager = get_running_agent_manager()
if manager is None:
return StreamingResponse(
iter([
_build_web_agent_sse(
"error",
{"message": "智能助手服务尚未就绪,请稍后重试。"},
locale=locale,
)
]),
media_type="text/event-stream",
)
transcript = _transcribe_web_agent_audio_refs(payload.audio_refs or [])
prompt = _merge_web_agent_prompt_with_transcript(prompt, transcript)
display_prompt = _merge_web_agent_prompt_with_transcript(display_prompt, transcript)
@@ -2077,7 +2148,7 @@ async def web_agent_stream(
)
display_messages = []
if payload.echo_user and not is_secret_confirmation_control:
user_display_message = MoviePilotAgent.build_display_message(
user_display_message = build_display_message(
role="user",
content=display_prompt or prompt,
attachments=user_attachments,
@@ -2085,7 +2156,7 @@ async def web_agent_stream(
if payload.choice_selection:
user_display_message["choice_selection"] = payload.choice_selection
display_messages.append(user_display_message)
assistant_display_message = MoviePilotAgent.build_display_message(
assistant_display_message = build_display_message(
role="assistant",
status="streaming",
)
@@ -2132,7 +2203,10 @@ async def web_agent_stream(
async def run_agent() -> None:
"""后台执行 Agent,并将结果写入事件队列。"""
try:
await agent_manager.process_message(
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,
@@ -2151,9 +2225,12 @@ async def web_agent_stream(
else None
),
message_callback=message_callback,
agent_factory=_WebAgentMoviePilotAgent,
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 = {