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 = {
+60 -20
View File
@@ -9,16 +9,17 @@ from fastapi.responses import JSONResponse, StreamingResponse
from app import schemas
from app.api.endpoints.openai import (
MODEL_ID,
_CollectingMoviePilotAgent,
_is_manager_unavailable,
_run_managed_agent,
)
from app.api.openai_utils import (
build_anthropic_messages,
build_prompt,
build_session_id,
)
from app.agent.runtime_loader import get_running_agent_manager
from app.runtime.config import settings
from app.application.security.access import anthropic_api_key_header
from app.schemas.types import NotificationChannel
ANTHROPIC_ERROR_RESPONSES = {
400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"},
@@ -60,19 +61,31 @@ def _check_auth(api_key: Optional[str]) -> Optional[JSONResponse]:
async def _stream_anthropic_response(
agent: _CollectingMoviePilotAgent,
manager,
session_id: str,
user_id: str,
prompt: str,
images: List[str],
) -> AsyncIterator[str]:
event_queue: asyncio.Queue = asyncio.Queue()
if hasattr(agent.stream_handler, "bind_queue"):
agent.stream_handler.bind_queue(event_queue)
message_id = f"msg_{uuid.uuid4().hex}"
async def _run_agent():
try:
await agent.process(prompt, images=images, files=None)
await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=user_id,
username="anthropic-client",
source="anthropic",
prompt=prompt,
images=images,
stream_mode=True,
event_queue=event_queue,
)
except asyncio.CancelledError:
await event_queue.put({"error": "MoviePilot AI agent is unavailable."})
except Exception as exc:
await event_queue.put({"error": str(exc)})
finally:
@@ -87,7 +100,12 @@ async def _stream_anthropic_response(
if item is None:
break
if isinstance(item, dict) and item.get("error"):
raise RuntimeError(str(item["error"]))
yield (
"event: error\n"
f"data: {json.dumps({'type': 'error', 'error': {'type': 'api_error', 'message': str(item['error'])}}, ensure_ascii=False)}\n\n"
)
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'}, ensure_ascii=False)}\n\n"
return
text = str(item or "")
if not text:
continue
@@ -96,6 +114,7 @@ async def _stream_anthropic_response(
yield f"event: message_delta\ndata: {json.dumps({'type': 'message_delta', 'delta': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'usage': {'output_tokens': 0}}, ensure_ascii=False)}\n\n"
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'}, ensure_ascii=False)}\n\n"
finally:
await manager.clear_session(session_id=session_id, user_id=user_id)
if not task.done():
task.cancel()
try:
@@ -132,6 +151,13 @@ async def messages(
503,
error_type="api_error",
)
manager = get_running_agent_manager()
if manager is None:
return _anthropic_error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="api_error",
)
normalized_messages = build_anthropic_messages(payload.system, payload.messages)
try:
@@ -141,19 +167,15 @@ async def messages(
session_seed = anthropic_version or "anthropic"
session_id = build_session_id(f"{session_seed}:{uuid.uuid4().hex}", SESSION_PREFIX)
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_id,
channel=NotificationChannel.Web.value,
source="anthropic",
username="anthropic-client",
stream_mode=payload.stream,
)
if payload.stream:
return StreamingResponse(
_stream_anthropic_response(agent=agent, prompt=prompt, images=images),
_stream_anthropic_response(
manager=manager,
session_id=session_id,
user_id=session_id,
prompt=prompt,
images=images,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
@@ -162,14 +184,32 @@ async def messages(
},
)
collected_messages = []
try:
result = await agent.process(prompt, images=images, files=None)
result, collected_messages = await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=session_id,
username="anthropic-client",
source="anthropic",
prompt=prompt,
images=images,
stream_mode=False,
)
except Exception as exc:
if _is_manager_unavailable(exc):
return _anthropic_error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="api_error",
)
return _anthropic_error_response(str(exc), 500, error_type="api_error")
finally:
await manager.clear_session(session_id=session_id, user_id=session_id)
content = "\n\n".join(
message.strip()
for message in agent.collected_messages
for message in collected_messages
if message and message.strip()
).strip()
if not content and result:
+13 -3
View File
@@ -9,7 +9,8 @@ from sqlalchemy.orm import Session
from app import schemas
from app.api.response import ResponseAPIRouter
from app.agent.orchestrator import ReplyMode, agent_manager
from app.agent.contracts import ReplyMode
from app.agent.runtime_loader import get_running_agent_manager
from app.agent.prompt.transfer_redo import (
build_batch_manual_redo_prompt,
build_manual_redo_prompt,
@@ -31,6 +32,7 @@ from app.runtime.progress import ProgressHelper
from app.application.history import clear_transfer_failures
from app.schemas.types import EventType
from app.foundation.text import cut as jieba_cut
from app.runtime.log import logger
router = ResponseAPIRouter()
@@ -58,7 +60,11 @@ def _start_ai_redo_task(history_id: int, prompt: str, progress_key: str):
async def runner():
try:
await agent_manager.run_background_prompt(
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过单条整理历史 AI 重做")
raise RuntimeError("智能助手服务未运行")
await manager.run_background_prompt(
message=prompt,
session_prefix=f"__agent_manual_redo_{history_id}",
output_callback=update_output,
@@ -103,7 +109,11 @@ def _start_batch_ai_redo_task(
async def runner():
try:
await agent_manager.run_background_prompt(
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过批量整理历史 AI 重做")
raise RuntimeError("智能助手服务未运行")
await manager.run_background_prompt(
message=prompt,
session_prefix="__agent_manual_redo_batch",
output_callback=update_output,
+11 -3
View File
@@ -5,13 +5,19 @@ from fastapi.responses import HTMLResponse
from app import schemas
from app.api.response import ResponseAPIRouter
from app.agent.llm import LLMProviderManager, render_auth_result_html
from app.db.models import User
from app.api.deps import get_current_active_superuser_async
router = ResponseAPIRouter()
def _get_llm_provider_manager_type() -> type:
"""在真实管理请求边界解析 provider 运行时。"""
from app.agent.llm.provider import LLMProviderManager
return LLMProviderManager
@router.post(
"/manage",
summary="LLM提供商统一管理",
@@ -37,7 +43,7 @@ async def manage_provider(
"callback_url",
str(request.url_for("llm_provider_auth_callback", provider_id=payload.target)),
)
result = await LLMProviderManager().provider_manage(
result = await _get_llm_provider_manager_type()().provider_manage(
payload.target, payload.action, **params
)
return schemas.Response(
@@ -70,11 +76,13 @@ async def llm_provider_auth_callback(
"""
处理需要浏览器回跳的 OAuth provider。
"""
success, message = await LLMProviderManager().handle_chatgpt_callback(
success, message = await _get_llm_provider_manager_type()().handle_chatgpt_callback(
provider_id,
code,
state,
error,
error_description,
)
from app.agent.llm.provider import render_auth_result_html
return HTMLResponse(content=render_auth_result_html(success, message))
+12 -3
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import json
import time
from typing import Union, Any, List, Optional
from typing import Protocol, Union, Any, List, Optional
from fastapi import BackgroundTasks, Depends, Request
from pywebpush import WebPushException, webpush
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse
@@ -27,7 +28,13 @@ router = ResponseAPIRouter()
_WNS_DEFAULT_TTL = 86400
def is_webpush_subscription_gone(error: WebPushException) -> bool:
class WebPushError(Protocol):
"""Web Push 订阅状态判断所需的最小异常协议。"""
response: Any # 推送服务响应,状态码字段由具体 SDK 提供
def is_webpush_subscription_gone(error: WebPushError) -> bool:
"""判断 Web Push 订阅是否已在浏览器或推送服务侧失效。"""
response: Any = getattr(error, "response", None)
status_code = getattr(response, "status_code", None) or getattr(
@@ -359,6 +366,8 @@ def send_notification(
"""
发送webpush通知
"""
from pywebpush import WebPushException, webpush
for sub in global_vars.get_subscriptions():
try:
webpush(
+243 -36
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import time
import uuid
from threading import Lock
from typing import AsyncIterator, List, Optional, Tuple
from fastapi import APIRouter, Request, Security
@@ -15,8 +16,11 @@ from app.api.openai_utils import (
build_responses_input,
build_session_id,
)
from app.agent.callback import StreamingHandler
from app.agent.orchestrator import MoviePilotAgent
from app.agent.runtime_loader import (
get_moviepilot_agent_type,
get_running_agent_manager,
)
from app.agent.contracts import ReplyMode
from app.runtime.config import settings
from app.application.security.access import openai_bearer_scheme
from app.schemas.types import NotificationChannel
@@ -35,7 +39,7 @@ MODEL_ID = "moviepilot-agent"
SESSION_PREFIX = "openai:"
class _CollectingMoviePilotAgent(MoviePilotAgent):
class _CollectingMoviePilotAgentMixin:
"""
捕获 Agent 最终输出,避免再通过消息渠道二次发送。
"""
@@ -45,11 +49,38 @@ class _CollectingMoviePilotAgent(MoviePilotAgent):
self.collected_messages: List[str] = []
self.stream_mode = stream_mode
if stream_mode:
self.stream_handler = _OpenAIStreamingHandler()
self.stream_handler = _get_openai_streaming_handler_type()()
def _should_stream(self) -> bool:
return self.stream_mode
def configure_protocol_request(
self,
*,
stream_mode: bool,
event_queue: Optional[asyncio.Queue],
) -> None:
"""切换请求级输出目标,并保持已编译工具引用的 handler identity。"""
self.collected_messages = []
self.stream_mode = stream_mode
if isinstance(self.stream_handler, _OpenAIStreamingHandlerMixin):
self.stream_handler.bind_queue(event_queue if stream_mode else None)
return
if not stream_mode:
return
self.stream_handler = _get_openai_streaming_handler_type()()
self.stream_handler.bind_queue(event_queue)
# 已编译工具持有旧 handler;identity 变化时必须重建图和工具目录。
self._compiled_agent_bundle = None
def release_protocol_request(
self,
event_queue: Optional[asyncio.Queue],
) -> None:
"""释放已结束请求的输出队列,不影响同会话已重绑的新请求。"""
if isinstance(self.stream_handler, _OpenAIStreamingHandlerMixin):
self.stream_handler.unbind_queue(event_queue)
async def send_agent_message(self, message: str, title: str = ""):
text = (message or "").strip()
if title and text:
@@ -62,7 +93,7 @@ class _CollectingMoviePilotAgent(MoviePilotAgent):
self.stream_handler.emit(text)
class _OpenAIStreamingHandler(StreamingHandler):
class _OpenAIStreamingHandlerMixin:
"""
将 Agent 流式输出转发到 OpenAI SSE 队列,不向站内消息系统落消息。
"""
@@ -71,9 +102,15 @@ class _OpenAIStreamingHandler(StreamingHandler):
super().__init__()
self._event_queue: Optional[asyncio.Queue] = None
def bind_queue(self, queue: asyncio.Queue):
def bind_queue(self, queue: Optional[asyncio.Queue]):
"""绑定当前协议请求的输出队列。"""
self._event_queue = queue
def unbind_queue(self, queue: Optional[asyncio.Queue]) -> None:
"""仅当仍指向该请求时解除绑定,避免清掉已排队的新请求。"""
if self._event_queue is queue:
self._event_queue = None
def emit(self, token: str):
emitted = super().emit(token)
if emitted and self._event_queue is not None:
@@ -121,18 +158,67 @@ class _OpenAIStreamingHandler(StreamingHandler):
return True, final_text
def _get_openai_streaming_handler_type() -> type:
"""首次兼容协议调用时才解析完整流式处理器。"""
global _OPENAI_STREAMING_HANDLER_TYPE
if _OPENAI_STREAMING_HANDLER_TYPE is not None:
return _OPENAI_STREAMING_HANDLER_TYPE
with _OPENAI_STREAMING_HANDLER_TYPE_LOCK:
if _OPENAI_STREAMING_HANDLER_TYPE is None:
from app.agent.callback import StreamingHandler
_OPENAI_STREAMING_HANDLER_TYPE = type(
"_RuntimeOpenAIStreamingHandler",
(_OpenAIStreamingHandlerMixin, StreamingHandler),
{"__module__": __name__},
)
return _OPENAI_STREAMING_HANDLER_TYPE
_OPENAI_STREAMING_HANDLER_TYPE_LOCK = Lock()
_OPENAI_STREAMING_HANDLER_TYPE: Optional[type] = None
def _build_collecting_agent_type(agent_base_type: type) -> type:
"""为 OpenAI 与 Anthropic 兼容协议组合唯一的运行时类型。"""
return type(
"_RuntimeCollectingMoviePilotAgent",
(_CollectingMoviePilotAgentMixin, agent_base_type),
{"__module__": __name__},
)
_COLLECTING_AGENT_TYPE_LOCK = Lock()
_COLLECTING_AGENT_TYPE: Optional[type] = None
def _get_collecting_agent_type() -> type:
"""在首个真实兼容协议请求边界 single-flight 解析 Agent 类型。"""
global _COLLECTING_AGENT_TYPE
if _COLLECTING_AGENT_TYPE is not None:
return _COLLECTING_AGENT_TYPE
with _COLLECTING_AGENT_TYPE_LOCK:
if _COLLECTING_AGENT_TYPE is None:
_COLLECTING_AGENT_TYPE = _build_collecting_agent_type(
get_moviepilot_agent_type()
)
return _COLLECTING_AGENT_TYPE
def _sse_payload(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
async def _stream_response(
agent: _CollectingMoviePilotAgent,
manager,
session_id: str,
user_id: str,
username: str,
prompt: str,
images: List[str],
cleanup_session: bool,
) -> AsyncIterator[str]:
event_queue: asyncio.Queue = asyncio.Queue()
if isinstance(agent.stream_handler, _OpenAIStreamingHandler):
agent.stream_handler.bind_queue(event_queue)
created = int(time.time())
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
@@ -140,7 +226,19 @@ async def _stream_response(
async def _run_agent():
try:
await agent.process(prompt, images=images, files=None)
await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=user_id,
username=username,
source="openai",
prompt=prompt,
images=images,
stream_mode=True,
event_queue=event_queue,
)
except asyncio.CancelledError:
await event_queue.put({"error": "MoviePilot AI agent is unavailable."})
except Exception as exc:
await event_queue.put({"error": str(exc)})
finally:
@@ -170,7 +268,17 @@ async def _stream_response(
if item is None:
break
if isinstance(item, dict) and item.get("error"):
raise RuntimeError(str(item["error"]))
yield _sse_payload(
{
"error": {
"message": str(item["error"]),
"type": "server_error",
"code": "agent_execution_failed",
}
}
)
yield "data: [DONE]\n\n"
return
text = str(item or "")
if not text:
continue
@@ -208,6 +316,10 @@ async def _stream_response(
)
yield "data: [DONE]\n\n"
finally:
if cleanup_session:
await manager.clear_session(session_id=session_id, user_id=user_id)
elif not task.done():
await manager.stop_current_task(session_id)
if not task.done():
task.cancel()
try:
@@ -218,6 +330,57 @@ async def _stream_response(
await task
def _is_manager_unavailable(error: BaseException) -> bool:
"""识别 manager acceptance gate 的稳定错误,不导入完整编排模块。"""
return getattr(error, "code", None) == "agent_manager_unavailable"
async def _run_managed_agent(
*,
manager,
session_id: str,
user_id: str,
username: str,
source: str,
prompt: str,
images: List[str],
stream_mode: bool,
event_queue: Optional[asyncio.Queue] = None,
) -> tuple[str, List[str]]:
"""通过 AgentManager 执行协议请求,并在 worker 内配置请求级输出。"""
agent_holder = {}
def configure_agent(agent) -> None:
agent.configure_protocol_request(
stream_mode=stream_mode,
event_queue=event_queue,
)
agent_holder["agent"] = agent
try:
result = await manager.process_message(
session_id=session_id,
user_id=user_id,
message=prompt,
images=images,
files=None,
channel=NotificationChannel.Web.value,
source=source,
username=username,
reply_mode=ReplyMode.CAPTURE_ONLY,
allow_message_tools=True,
agent_factory=_get_collecting_agent_type(),
agent_setup=configure_agent,
wait_for_completion=True,
)
agent = agent_holder.get("agent")
return result, list(agent.collected_messages if agent else [])
finally:
agent = agent_holder.get("agent")
if agent is not None:
agent.release_protocol_request(event_queue)
def _error_response(
message: str,
status_code: int,
@@ -310,6 +473,14 @@ async def chat_completions(
error_type="server_error",
code="ai_agent_disabled",
)
manager = get_running_agent_manager()
if manager is None:
return _error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="server_error",
code="ai_agent_unavailable",
)
if not payload.messages:
return _error_response(
@@ -337,19 +508,17 @@ async def chat_completions(
session_id = build_session_id(session_key, SESSION_PREFIX)
username = str(payload.user or "openai-client")
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
channel=NotificationChannel.Web.value,
source="openai",
username=username,
stream_mode=payload.stream,
)
if payload.stream:
return StreamingResponse(
_stream_response(agent=agent, prompt=prompt, images=images),
_stream_response(
manager=manager,
session_id=session_id,
user_id=session_key,
username=username,
prompt=prompt,
images=images,
cleanup_session=not use_server_session,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
@@ -358,19 +527,39 @@ async def chat_completions(
},
)
collected_messages = []
try:
result = await agent.process(prompt, images=images, files=None)
result, collected_messages = await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=session_key,
username=username,
source="openai",
prompt=prompt,
images=images,
stream_mode=False,
)
except Exception as exc:
if _is_manager_unavailable(exc):
return _error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="server_error",
code="ai_agent_unavailable",
)
return _error_response(
str(exc),
500,
error_type="server_error",
code="agent_execution_failed",
)
finally:
if not use_server_session:
await manager.clear_session(session_id=session_id, user_id=session_key)
content = "\n\n".join(
message.strip()
for message in agent.collected_messages
for message in collected_messages
if message and message.strip()
).strip()
if not content and result:
@@ -403,6 +592,14 @@ async def responses(
error_type="server_error",
code="ai_agent_disabled",
)
manager = get_running_agent_manager()
if manager is None:
return _error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="server_error",
code="ai_agent_unavailable",
)
if payload.stream:
return _error_response(
@@ -430,29 +627,39 @@ async def responses(
session_key = str(payload.user or uuid.uuid4())
session_id = build_session_id(session_key, SESSION_PREFIX)
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
channel=NotificationChannel.Web.value,
source="openai.responses",
username=str(payload.user or "openai-client"),
stream_mode=False,
)
collected_messages = []
try:
result = await agent.process(prompt, images=images, files=None)
result, collected_messages = await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=session_key,
username=str(payload.user or "openai-client"),
source="openai.responses",
prompt=prompt,
images=images,
stream_mode=False,
)
except Exception as exc:
if _is_manager_unavailable(exc):
return _error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="server_error",
code="ai_agent_unavailable",
)
return _error_response(
str(exc),
500,
error_type="server_error",
code="agent_execution_failed",
)
finally:
if not payload.user:
await manager.clear_session(session_id=session_id, user_id=session_key)
content = "\n\n".join(
message.strip()
for message in agent.collected_messages
for message in collected_messages
if message and message.strip()
).strip()
if not content and result:
+11 -157
View File
@@ -12,7 +12,13 @@ from starlette.responses import StreamingResponse
from app import schemas
from app.api.response import ResponseAPIRouter
from app.command import Command
from app.application.plugins import (
register_plugin_api,
remove_plugin_api,
remove_plugin_from_folders,
)
from app.application.commands import init_commands
from app.application.scheduling import remove_plugin_job, update_plugin_job
from app.runtime.cache import async_fresh
from app.runtime.config import settings
from app.runtime.events import eventmanager
@@ -26,22 +32,12 @@ from app.application.security.access import (
from app.db.models import User
from app.db.oper.systemconfig import SystemConfigOper
from app.api.deps import get_current_active_superuser, get_current_active_superuser_async
from app.factory import app
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.external.market import PluginHelper
from app.runtime.log import logger
from app.scheduler import Scheduler
from app.schemas.event import PluginDataResetEventData
from app.schemas.types import ChainEventType, SystemConfigKey
PROTECTED_ROUTES = {
"/api/v1/openapi.json",
"/docs",
"/docs/oauth2-redirect",
"/redoc",
}
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
router = ResponseAPIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
@@ -106,117 +102,14 @@ def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str) -> None:
task.add_done_callback(_discard_task)
def register_plugin_api(plugin_id: Optional[str] = None):
"""
动态注册插件 API
:param plugin_id: 插件 ID,如果为 None,则注册所有插件
"""
_update_plugin_api_routes(plugin_id, action="add")
def remove_plugin_api(plugin_id: str):
"""
动态移除单个插件的 API
:param plugin_id: 插件 ID
"""
_update_plugin_api_routes(plugin_id, action="remove")
def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
"""
插件 API 路由注册和移除
:param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件
如果 action 为 "remove",plugin_id 必须是有效的插件 ID
:param action: "add""remove",决定是添加还是移除路由
"""
if action not in {"add", "remove"}:
raise ValueError("Action must be 'add' or 'remove'")
is_modified = False
existing_paths = {route.path: route for route in app.routes}
plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids()
for plugin_id in plugin_ids:
routes_removed = _remove_routes(plugin_id)
if routes_removed:
is_modified = True
if action != "add":
continue
# 获取插件的 API 路由信息
plugin_apis = PluginManager().get_plugin_apis(plugin_id)
for api in plugin_apis:
api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}"
try:
api["path"] = api_path
allow_anonymous = api.pop("allow_anonymous", False)
auth_mode = api.pop("auth", "apikey")
dependencies = api.setdefault("dependencies", [])
if not allow_anonymous:
if (
auth_mode == "bear"
and Depends(verify_token) not in dependencies
):
dependencies.append(Depends(verify_token))
elif Depends(verify_apikey) not in dependencies:
dependencies.append(Depends(verify_apikey))
app.add_api_route(**api, tags=["plugin"])
is_modified = True
logger.debug(f"Added plugin route: {api_path}")
except Exception as e:
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
if is_modified:
_clean_protected_routes(existing_paths)
app.openapi_schema = None
app.setup()
def _remove_routes(plugin_id: str) -> bool:
"""
移除与单个插件相关的路由
:param plugin_id: 插件 ID
:return: 是否有路由被移除
"""
if not plugin_id:
return False
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
routes_to_remove = [
route for route in app.routes if route.path.startswith(prefix)
]
removed = False
for route in routes_to_remove:
try:
app.routes.remove(route)
removed = True
logger.debug(f"Removed plugin route: {route.path}")
except Exception as e:
logger.error(f"Error removing plugin route {route.path}: {str(e)}")
return removed
def _clean_protected_routes(existing_paths: dict):
"""
清理受保护的路由,防止在插件操作中被删除或重复添加
:param existing_paths: 当前应用的路由路径映射
"""
for protected_route in PROTECTED_ROUTES:
try:
existing_route = existing_paths.get(protected_route)
if existing_route:
app.routes.remove(existing_route)
except Exception as e:
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
def register_plugin(plugin_id: str):
"""
注册一个插件相关的服务
"""
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
update_plugin_job(plugin_id)
# 注册菜单命令
Command().init_commands(plugin_id)
init_commands(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
@@ -1045,7 +938,7 @@ def uninstall_plugin(
# 移除插件API
remove_plugin_api(plugin_id)
# 移除插件服务
Scheduler().remove_plugin_job(plugin_id)
remove_plugin_job(plugin_id)
# 判断是否为分身
plugin_manager = PluginManager()
plugin_class = plugin_manager.plugins.get(plugin_id)
@@ -1062,7 +955,7 @@ def uninstall_plugin(
except Exception as e:
logger.error(f"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}")
# 从插件文件夹中移除该插件
_remove_plugin_from_folders(plugin_id)
remove_plugin_from_folders(plugin_id)
# 移除插件
plugin_manager.remove_plugin(plugin_id)
return schemas.Response(success=True)
@@ -1121,42 +1014,3 @@ def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):
except Exception as e:
logger.error(f"处理插件文件夹时出错:{str(e)}")
# 文件夹处理失败不影响插件分身创建的整体流程
def _remove_plugin_from_folders(plugin_id: str):
"""
从所有文件夹中移除指定的插件
:param plugin_id: 要移除的插件ID
"""
try:
config_oper = SystemConfigOper()
# 获取插件文件夹配置
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:
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
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):
# 旧格式:直接是插件列表
if plugin_id in folder_data:
folder_data.remove(plugin_id)
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
modified = True
# 如果有修改,保存更新后的文件夹配置
if modified:
config_oper.set(SystemConfigKey.PluginFolders, folders)
else:
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
except Exception as e:
logger.error(f"从文件夹中移除插件时出错:{str(e)}")
# 文件夹处理失败不影响插件卸载的整体流程
+4 -3
View File
@@ -41,7 +41,7 @@ from app.adapters.external.market import (
)
from app.application.messaging.message import MessageHelper
from app.runtime.progress import ProgressHelper
from app.application.filter import RuleHelper
from app.application.rules import RuleHelper
from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.state import SystemHelper
from app.runtime.log import logger
@@ -1405,8 +1405,9 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
查询已加载的模块ID列表
"""
modules = []
for module_id, module in ModuleManager().get_modules().items():
name = module.get_name()
for spec in ModuleManager().list_specs():
module_id = spec.id
name = str(spec.metadata["name"])
modules.append(
{
"id": module_id,