mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor(agent): 按需加载 Agent 运行时 (#6336)
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._notification_callback = notification_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=MessageChannel.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
|
||||
),
|
||||
notification_callback=notification_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 MessageChannel
|
||||
|
||||
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=MessageChannel.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))
|
||||
|
||||
+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 MessageChannel
|
||||
@@ -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=MessageChannel.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=MessageChannel.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=MessageChannel.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:
|
||||
|
||||
Reference in New Issue
Block a user