fix(agent): isolate web and storage blocking I/O

This commit is contained in:
InfinityPacer
2026-08-23 13:06:30 +08:00
parent 43c173a0e7
commit d11c2fb301
7 changed files with 558 additions and 56 deletions
+3 -3
View File
@@ -9,7 +9,7 @@ import uuid
import warnings
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Awaitable, Callable, Dict, List, Optional
from fastapi.concurrency import run_in_threadpool
from langchain.agents import create_agent
@@ -2572,7 +2572,7 @@ class _MessageTask:
allow_message_tools: bool = True
output_callback: Optional[Callable[[str], None]] = None
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None
message_callback: Optional[Callable[[Any], None]] = None
message_callback: Optional[Callable[[Any], Awaitable[None] | None]] = None
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None
completion_future: Optional[asyncio.Future] = None
@@ -2834,7 +2834,7 @@ class AgentManager:
allow_message_tools: bool = True,
output_callback: Optional[Callable[[str], None]] = None,
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
message_callback: Optional[Callable[[Any], None]] = None,
message_callback: Optional[Callable[[Any], Awaitable[None] | None]] = None,
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None,
wait_for_completion: bool = False,
+4 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import inspect
import json
import threading
from abc import ABCMeta, abstractmethod
@@ -646,7 +647,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
self._channel == NotificationChannel.WebAgent.value
and callable(callback)
):
callback(message)
callback_result = callback(message)
if inspect.isawaitable(callback_result):
await callback_result
return
if not self._channel or not self._source:
@@ -1,5 +1,6 @@
"""删除整理历史记录工具"""
import asyncio
from typing import Optional, Type
from pydantic import BaseModel, Field
@@ -20,6 +21,14 @@ class DeleteTransferHistoryInput(BaseModel):
)
def _delete_history_destination_file(fileitem: FileItem) -> tuple[bool, bool]:
"""在存储 worker 内完成旧目标检查和删除,保持历史删除前的顺序。"""
storage_chain = StorageChain()
if not storage_chain.exists(fileitem):
return False, False
return True, bool(storage_chain.delete_media_file(fileitem))
class DeleteTransferHistoryTool(MoviePilotTool):
name: str = "delete_transfer_history"
tags: list[str] = [
@@ -55,9 +64,21 @@ class DeleteTransferHistoryTool(MoviePilotTool):
deleted_dest = False
if history.dest_fileitem and not (history.status and history.mode == "move"):
dest_fileitem = FileItem(**history.dest_fileitem)
storage_chain = StorageChain()
if storage_chain.exists(dest_fileitem):
if not storage_chain.delete_media_file(dest_fileitem):
try:
destination_exists, destination_deleted = await self.run_blocking(
"storage",
_delete_history_destination_file,
dest_fileitem,
)
except asyncio.CancelledError:
logger.warning(
"删除整理历史的旧媒体文件等待已取消,底层文件操作可能仍在继续,"
"请确认实际状态后再重试,历史记录尚未删除,path=%s",
dest_fileitem.path,
)
raise
if destination_exists:
if not destination_deleted:
return f"错误:旧媒体库文件删除失败,路径={dest_fileitem.path}"
deleted_dest = True
await transferhis.async_delete(history_id)
+125 -37
View File
@@ -4,14 +4,13 @@ import hashlib
import json
import mimetypes
import shutil
import subprocess
import time
import uuid
from collections import deque
from queue import Empty, Queue
from pathlib import Path
from threading import Lock
from typing import Any, AsyncIterator, Callable, Optional, Union
from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Union
import aiofiles
from fastapi import Depends, File, Form, HTTPException, Request, UploadFile, status
@@ -85,6 +84,7 @@ WEB_AGENT_FILE_TTL_SECONDS = 6 * 60 * 60
WEB_AGENT_FILE_MAX_ITEMS = 256
WEB_AGENT_UPLOAD_MAX_BYTES = 32 * 1024 * 1024
WEB_AGENT_UPLOAD_CHUNK_SIZE = 1024 * 1024
WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS = 60.0
WEB_AGENT_BROWSER_AUDIO_SUFFIXES = {".aac", ".m4a", ".mp3", ".mp4", ".wav", ".wave"}
WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS = 2.0
WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS = 60.0
@@ -401,7 +401,9 @@ class _WebAgentMoviePilotAgentMixin:
def __init__(
self,
*args: Any,
message_callback: Optional[Callable[[_SchemaMessage], None]] = None,
message_callback: Optional[
Callable[[_SchemaMessage], Awaitable[None] | None]
] = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
@@ -418,7 +420,9 @@ class _WebAgentMoviePilotAgentMixin:
def set_message_callback(
self,
message_callback: Optional[Callable[[_SchemaMessage], None]],
message_callback: Optional[
Callable[[_SchemaMessage], Awaitable[None] | None]
],
) -> None:
"""
更新 Web SSE 通知回调,复用 Agent 实例时指向当前请求队列。
@@ -998,48 +1002,115 @@ def _get_web_agent_audio_mime_type(audio_path: Path) -> Optional[str]:
return mimetypes.guess_type(audio_path.name)[0]
def _prepare_web_agent_audio_attachment_path(voice_path: str) -> Path:
"""
将 Agent 语音回复准备成 Web 面板可稳定播放的音频文件。
部分 TTS provider 会生成 Opus/Ogg,桌面 Chromium 通常可播放,但 iOS/Safari
兼容性不稳定;WebAgent 只在浏览器内播放,因此这里单独转成 WAV。
"""
def _resolve_web_agent_audio_source_path(voice_path: str) -> Path:
"""解析语音源文件;文件暂时不可用时保留原始路径以维持回退语义。"""
try:
source_path = Path(voice_path).expanduser().resolve(strict=True)
return Path(voice_path).expanduser().resolve(strict=True)
except OSError:
return Path(voice_path)
def _remove_web_agent_audio_output(path: Path) -> None:
"""清理未完成的转码产物。"""
try:
path.unlink()
except FileNotFoundError:
pass
except OSError as err:
logger.debug("WebAgent 清理未完成语音转码产物失败: path=%s, error=%s", path, err)
async def _terminate_web_agent_audio_process(
process: Optional[asyncio.subprocess.Process],
) -> None:
"""终止并回收转码进程,避免取消或超时留下孤儿 ffmpeg。"""
if process is None or process.returncode is not None:
return
try:
process.kill()
except ProcessLookupError:
return
try:
await process.communicate()
except (OSError, ProcessLookupError):
pass
async def _prepare_web_agent_audio_attachment_path_async(voice_path: str) -> Path:
"""异步准备 WebAgent 语音附件,转码等待可取消且有超时。"""
source_path = await run_in_threadpool(
_resolve_web_agent_audio_source_path,
voice_path,
)
if source_path.suffix.lower() in WEB_AGENT_BROWSER_AUDIO_SUFFIXES:
return source_path
if not shutil.which("ffmpeg"):
ffmpeg_path = await run_in_threadpool(shutil.which, "ffmpeg")
if not ffmpeg_path:
logger.warning("WebAgent 语音转 WAV 跳过:ffmpeg 不可用,path=%s", source_path)
return source_path
voice_dir = get_api_runtime_config_snapshot().temp_path / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
try:
await run_in_threadpool(voice_dir.mkdir, parents=True, exist_ok=True)
except OSError as err:
logger.warning("WebAgent 语音转 WAV 目录不可用,将回退原文件: path=%s, error=%s", voice_dir, err)
return source_path
output_path = voice_dir / f"{source_path.stem}_web_{uuid.uuid4().hex[:8]}.wav"
cmd = [
"ffmpeg",
"-y",
"-i",
str(source_path),
"-ar",
"24000",
"-ac",
"1",
"-f",
"wav",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0 or not output_path.exists():
process: Optional[asyncio.subprocess.Process] = None
async def cleanup_conversion_output() -> None:
"""清理失败或取消的转码进程及临时产物。"""
await _terminate_web_agent_audio_process(process)
await run_in_threadpool(_remove_web_agent_audio_output, output_path)
try:
process = await asyncio.create_subprocess_exec(
ffmpeg_path,
"-y",
"-i",
str(source_path),
"-ar",
"24000",
"-ac",
"1",
"-f",
"wav",
str(output_path),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await asyncio.wait_for(
process.communicate(),
timeout=WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS,
)
output_exists = await run_in_threadpool(output_path.exists)
if process.returncode != 0 or not output_exists:
await cleanup_conversion_output()
logger.warning(
"WebAgent 语音转 WAV 失败,将回退原文件: returncode=%s, stderr=%s",
process.returncode,
(stderr or b"").decode("utf-8", errors="replace").strip()[:500],
)
return source_path
return output_path
except asyncio.TimeoutError:
await cleanup_conversion_output()
logger.warning(
"WebAgent 语音转 WAV 失败,将回退原文件: returncode=%s, stderr=%s",
result.returncode,
(result.stderr or "").strip()[:500],
"WebAgent 语音转 WAV 超时,将回退原文件: timeout=%ss, path=%s",
WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS,
source_path,
)
return source_path
return output_path
except asyncio.CancelledError:
await cleanup_conversion_output()
logger.warning("WebAgent 语音转 WAV 已取消,path=%s", source_path)
raise
except OSError as err:
await cleanup_conversion_output()
logger.warning("WebAgent 语音转 WAV 启动失败,将回退原文件: path=%s, error=%s", source_path, err)
return source_path
def _get_web_agent_registered_file(ref: str) -> Optional[dict[str, Any]]:
@@ -1213,6 +1284,8 @@ def _resolve_web_agent_choice_payload(callback_data: str, user_id: str) -> Optio
def _build_web_agent_message_events(
message: _SchemaMessage,
*,
prepared_audio_path: Optional[Path] = None,
) -> list[dict]:
"""
将 Agent 工具通知转换为 Web SSE 事件。
@@ -1250,7 +1323,7 @@ def _build_web_agent_message_events(
events.append({"type": "attachment", "attachment": attachment})
if message.voice_path:
audio_path = _prepare_web_agent_audio_attachment_path(message.voice_path)
audio_path = prepared_audio_path or Path(message.voice_path)
attachment = _register_web_agent_file(
str(audio_path),
file_name=audio_path.name,
@@ -1271,6 +1344,21 @@ def _build_web_agent_message_events(
return events
async def _build_web_agent_message_events_async(
message: _SchemaMessage,
) -> list[dict]:
"""异步构造 WebAgent 通知,确保语音转码不占用事件循环。"""
prepared_audio_path = None
if message.voice_path:
prepared_audio_path = await _prepare_web_agent_audio_attachment_path_async(
message.voice_path
)
return _build_web_agent_message_events(
message,
prepared_audio_path=prepared_audio_path,
)
def _build_web_agent_display_message_from_events(
events: list[dict],
) -> dict:
@@ -1581,7 +1669,7 @@ async def _collect_web_agent_traditional_events(
if not _is_web_agent_message_for_user(message, user_id):
continue
events.extend(_build_web_agent_message_events(message))
events.extend(await _build_web_agent_message_events_async(message))
idle_deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS
return events
finally:
@@ -2231,11 +2319,11 @@ async def _web_agent_stream_impl(
_apply_web_agent_display_event(item, assistant_display_message)
event_publisher.publish(item)
def message_callback(message: _SchemaMessage) -> None:
async def message_callback(message: _SchemaMessage) -> None:
"""
接收 Agent 工具主动发送的 Web 通知。
"""
for item in _build_web_agent_message_events(message):
for item in await _build_web_agent_message_events_async(message):
_apply_web_agent_display_event(item, assistant_display_message)
event_publisher.publish(item)