mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
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:
+93
-16
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}")
|
||||
# 文件夹处理失败不影响插件卸载的整体流程
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user