mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
Merge pull request #6411 from InfinityPacer/codex/replay/g3b
This commit is contained in:
@@ -9,7 +9,7 @@ import uuid
|
|||||||
import warnings
|
import warnings
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
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 fastapi.concurrency import run_in_threadpool
|
||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
@@ -2572,7 +2572,7 @@ class _MessageTask:
|
|||||||
allow_message_tools: bool = True
|
allow_message_tools: bool = True
|
||||||
output_callback: Optional[Callable[[str], None]] = None
|
output_callback: Optional[Callable[[str], None]] = None
|
||||||
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = 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_factory: Optional[Callable[..., MoviePilotAgent]] = None
|
||||||
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None
|
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None
|
||||||
completion_future: Optional[asyncio.Future] = None
|
completion_future: Optional[asyncio.Future] = None
|
||||||
@@ -2834,7 +2834,7 @@ class AgentManager:
|
|||||||
allow_message_tools: bool = True,
|
allow_message_tools: bool = True,
|
||||||
output_callback: Optional[Callable[[str], None]] = None,
|
output_callback: Optional[Callable[[str], None]] = None,
|
||||||
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = 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_factory: Optional[Callable[..., MoviePilotAgent]] = None,
|
||||||
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None,
|
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None,
|
||||||
wait_for_completion: bool = False,
|
wait_for_completion: bool = False,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
@@ -646,7 +647,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
|||||||
self._channel == NotificationChannel.WebAgent.value
|
self._channel == NotificationChannel.WebAgent.value
|
||||||
and callable(callback)
|
and callable(callback)
|
||||||
):
|
):
|
||||||
callback(message)
|
callback_result = callback(message)
|
||||||
|
if inspect.isawaitable(callback_result):
|
||||||
|
await callback_result
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._channel or not self._source:
|
if not self._channel or not self._source:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""删除整理历史记录工具"""
|
"""删除整理历史记录工具"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Optional, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
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):
|
class DeleteTransferHistoryTool(MoviePilotTool):
|
||||||
name: str = "delete_transfer_history"
|
name: str = "delete_transfer_history"
|
||||||
tags: list[str] = [
|
tags: list[str] = [
|
||||||
@@ -55,9 +64,21 @@ class DeleteTransferHistoryTool(MoviePilotTool):
|
|||||||
deleted_dest = False
|
deleted_dest = False
|
||||||
if history.dest_fileitem and not (history.status and history.mode == "move"):
|
if history.dest_fileitem and not (history.status and history.mode == "move"):
|
||||||
dest_fileitem = FileItem(**history.dest_fileitem)
|
dest_fileitem = FileItem(**history.dest_fileitem)
|
||||||
storage_chain = StorageChain()
|
try:
|
||||||
if storage_chain.exists(dest_fileitem):
|
destination_exists, destination_deleted = await self.run_blocking(
|
||||||
if not storage_chain.delete_media_file(dest_fileitem):
|
"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}"
|
return f"错误:旧媒体库文件删除失败,路径={dest_fileitem.path}"
|
||||||
deleted_dest = True
|
deleted_dest = True
|
||||||
await transferhis.async_delete(history_id)
|
await transferhis.async_delete(history_id)
|
||||||
|
|||||||
+125
-37
@@ -4,14 +4,13 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from queue import Empty, Queue
|
from queue import Empty, Queue
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Any, AsyncIterator, Callable, Optional, Union
|
from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Union
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from fastapi import Depends, File, Form, HTTPException, Request, UploadFile, status
|
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_FILE_MAX_ITEMS = 256
|
||||||
WEB_AGENT_UPLOAD_MAX_BYTES = 32 * 1024 * 1024
|
WEB_AGENT_UPLOAD_MAX_BYTES = 32 * 1024 * 1024
|
||||||
WEB_AGENT_UPLOAD_CHUNK_SIZE = 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_BROWSER_AUDIO_SUFFIXES = {".aac", ".m4a", ".mp3", ".mp4", ".wav", ".wave"}
|
||||||
WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS = 2.0
|
WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS = 2.0
|
||||||
WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS = 60.0
|
WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS = 60.0
|
||||||
@@ -401,7 +401,9 @@ class _WebAgentMoviePilotAgentMixin:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*args: Any,
|
*args: Any,
|
||||||
message_callback: Optional[Callable[[_SchemaMessage], None]] = None,
|
message_callback: Optional[
|
||||||
|
Callable[[_SchemaMessage], Awaitable[None] | None]
|
||||||
|
] = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -418,7 +420,9 @@ class _WebAgentMoviePilotAgentMixin:
|
|||||||
|
|
||||||
def set_message_callback(
|
def set_message_callback(
|
||||||
self,
|
self,
|
||||||
message_callback: Optional[Callable[[_SchemaMessage], None]],
|
message_callback: Optional[
|
||||||
|
Callable[[_SchemaMessage], Awaitable[None] | None]
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
更新 Web SSE 通知回调,复用 Agent 实例时指向当前请求队列。
|
更新 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]
|
return mimetypes.guess_type(audio_path.name)[0]
|
||||||
|
|
||||||
|
|
||||||
def _prepare_web_agent_audio_attachment_path(voice_path: str) -> Path:
|
def _resolve_web_agent_audio_source_path(voice_path: str) -> Path:
|
||||||
"""
|
"""解析语音源文件;文件暂时不可用时保留原始路径以维持回退语义。"""
|
||||||
将 Agent 语音回复准备成 Web 面板可稳定播放的音频文件。
|
|
||||||
|
|
||||||
部分 TTS provider 会生成 Opus/Ogg,桌面 Chromium 通常可播放,但 iOS/Safari
|
|
||||||
兼容性不稳定;WebAgent 只在浏览器内播放,因此这里单独转成 WAV。
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
source_path = Path(voice_path).expanduser().resolve(strict=True)
|
return Path(voice_path).expanduser().resolve(strict=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
return Path(voice_path)
|
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:
|
if source_path.suffix.lower() in WEB_AGENT_BROWSER_AUDIO_SUFFIXES:
|
||||||
return source_path
|
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)
|
logger.warning("WebAgent 语音转 WAV 跳过:ffmpeg 不可用,path=%s", source_path)
|
||||||
return source_path
|
return source_path
|
||||||
|
|
||||||
voice_dir = get_api_runtime_config_snapshot().temp_path / "voice"
|
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"
|
output_path = voice_dir / f"{source_path.stem}_web_{uuid.uuid4().hex[:8]}.wav"
|
||||||
cmd = [
|
process: Optional[asyncio.subprocess.Process] = None
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
async def cleanup_conversion_output() -> None:
|
||||||
"-i",
|
"""清理失败或取消的转码进程及临时产物。"""
|
||||||
str(source_path),
|
await _terminate_web_agent_audio_process(process)
|
||||||
"-ar",
|
await run_in_threadpool(_remove_web_agent_audio_output, output_path)
|
||||||
"24000",
|
|
||||||
"-ac",
|
try:
|
||||||
"1",
|
process = await asyncio.create_subprocess_exec(
|
||||||
"-f",
|
ffmpeg_path,
|
||||||
"wav",
|
"-y",
|
||||||
str(output_path),
|
"-i",
|
||||||
]
|
str(source_path),
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
"-ar",
|
||||||
if result.returncode != 0 or not output_path.exists():
|
"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(
|
logger.warning(
|
||||||
"WebAgent 语音转 WAV 失败,将回退原文件: returncode=%s, stderr=%s",
|
"WebAgent 语音转 WAV 超时,将回退原文件: timeout=%ss, path=%s",
|
||||||
result.returncode,
|
WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS,
|
||||||
(result.stderr or "").strip()[:500],
|
source_path,
|
||||||
)
|
)
|
||||||
return 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]]:
|
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(
|
def _build_web_agent_message_events(
|
||||||
message: _SchemaMessage,
|
message: _SchemaMessage,
|
||||||
|
*,
|
||||||
|
prepared_audio_path: Optional[Path] = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
将 Agent 工具通知转换为 Web SSE 事件。
|
将 Agent 工具通知转换为 Web SSE 事件。
|
||||||
@@ -1250,7 +1323,7 @@ def _build_web_agent_message_events(
|
|||||||
events.append({"type": "attachment", "attachment": attachment})
|
events.append({"type": "attachment", "attachment": attachment})
|
||||||
|
|
||||||
if message.voice_path:
|
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(
|
attachment = _register_web_agent_file(
|
||||||
str(audio_path),
|
str(audio_path),
|
||||||
file_name=audio_path.name,
|
file_name=audio_path.name,
|
||||||
@@ -1271,6 +1344,21 @@ def _build_web_agent_message_events(
|
|||||||
return 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(
|
def _build_web_agent_display_message_from_events(
|
||||||
events: list[dict],
|
events: list[dict],
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -1581,7 +1669,7 @@ async def _collect_web_agent_traditional_events(
|
|||||||
|
|
||||||
if not _is_web_agent_message_for_user(message, user_id):
|
if not _is_web_agent_message_for_user(message, user_id):
|
||||||
continue
|
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
|
idle_deadline = time.monotonic() + WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS
|
||||||
return events
|
return events
|
||||||
finally:
|
finally:
|
||||||
@@ -2231,11 +2319,11 @@ async def _web_agent_stream_impl(
|
|||||||
_apply_web_agent_display_event(item, assistant_display_message)
|
_apply_web_agent_display_event(item, assistant_display_message)
|
||||||
event_publisher.publish(item)
|
event_publisher.publish(item)
|
||||||
|
|
||||||
def message_callback(message: _SchemaMessage) -> None:
|
async def message_callback(message: _SchemaMessage) -> None:
|
||||||
"""
|
"""
|
||||||
接收 Agent 工具主动发送的 Web 通知。
|
接收 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)
|
_apply_web_agent_display_event(item, assistant_display_message)
|
||||||
event_publisher.publish(item)
|
event_publisher.publish(item)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from app.agent.tools.base import MoviePilotTool
|
|||||||
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
||||||
from app.api.endpoints.openai import _get_openai_streaming_handler_type
|
from app.api.endpoints.openai import _get_openai_streaming_handler_type
|
||||||
from app.runtime.config import settings
|
from app.runtime.config import settings
|
||||||
from app.schemas.message import MessageResponse
|
from app.schemas.message import Message, MessageResponse
|
||||||
from app.schemas.types import NotificationChannel, MessageType
|
from app.schemas.types import NotificationChannel, MessageType
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +77,31 @@ class AdminOnlyDummyTool(MoviePilotTool):
|
|||||||
class TestAgentToolStreaming:
|
class TestAgentToolStreaming:
|
||||||
"""Agent 工具流式输出测试。"""
|
"""Agent 工具流式输出测试。"""
|
||||||
|
|
||||||
|
def test_web_message_callback_can_await_async_delivery(self):
|
||||||
|
"""WebAgent 通知回调支持异步附件准备并保持发送顺序。"""
|
||||||
|
received = []
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
tool = DummyTool(session_id="session-1", user_id="10001")
|
||||||
|
tool.set_message_attr("WebAgent", "web-agent", "admin")
|
||||||
|
|
||||||
|
async def callback(message):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
received.append(message.text)
|
||||||
|
|
||||||
|
tool.set_agent_context({"message_callback": callback})
|
||||||
|
await tool.send_message(
|
||||||
|
Message(
|
||||||
|
text="异步通知",
|
||||||
|
channel=NotificationChannel.WebAgent,
|
||||||
|
mtype=MessageType.Agent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
assert received == ["异步通知"]
|
||||||
|
|
||||||
async def _run_tool(self, initial_buffer: str) -> tuple[str, str]:
|
async def _run_tool(self, initial_buffer: str) -> tuple[str, str]:
|
||||||
"""运行测试工具并返回工具结果与缓冲内容。"""
|
"""运行测试工具并返回工具结果与缓冲内容。"""
|
||||||
tool = DummyTool(session_id="session-1", user_id="10001")
|
tool = DummyTool(session_id="session-1", user_id="10001")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import threading
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
|
from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
|
||||||
@@ -277,6 +278,130 @@ def test_delete_transfer_history_tool_only_treats_exact_move_as_reorganize_sourc
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_transfer_history_storage_work_runs_outside_event_loop(monkeypatch):
|
||||||
|
"""整理历史的本地存储操作应在 storage worker 中执行。"""
|
||||||
|
caller_thread = threading.get_ident()
|
||||||
|
storage_threads = []
|
||||||
|
history = SimpleNamespace(
|
||||||
|
id=15,
|
||||||
|
title="奔跑吧",
|
||||||
|
src="/downloads/Keep.Running.mkv",
|
||||||
|
status=True,
|
||||||
|
mode="copy",
|
||||||
|
dest_fileitem={
|
||||||
|
"storage": "local",
|
||||||
|
"path": "/library/奔跑吧 (2014)/Keep.Running.mkv",
|
||||||
|
"name": "Keep.Running.mkv",
|
||||||
|
"type": "file",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeTransferHistoryOper:
|
||||||
|
async def async_get(self, history_id):
|
||||||
|
return history
|
||||||
|
|
||||||
|
async def async_delete(self, history_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeStorageChain:
|
||||||
|
def exists(self, fileitem):
|
||||||
|
storage_threads.append(threading.get_ident())
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_media_file(self, fileitem):
|
||||||
|
storage_threads.append(threading.get_ident())
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
|
||||||
|
FakeTransferHistoryOper,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.agent.tools.impl.delete_transfer_history.StorageChain",
|
||||||
|
FakeStorageChain,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
DeleteTransferHistoryTool(
|
||||||
|
session_id="redo-session",
|
||||||
|
user_id="10001",
|
||||||
|
).run(history_id=15)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "已删除整理历史记录" in result
|
||||||
|
assert storage_threads
|
||||||
|
assert all(thread_id != caller_thread for thread_id in storage_threads)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_transfer_history_cancellation_keeps_history_record(monkeypatch):
|
||||||
|
"""取消等待存储清理时不得继续提交整理历史删除。"""
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
finished = threading.Event()
|
||||||
|
history = SimpleNamespace(
|
||||||
|
id=16,
|
||||||
|
title="奔跑吧",
|
||||||
|
src="/downloads/Keep.Running.mkv",
|
||||||
|
status=True,
|
||||||
|
mode="copy",
|
||||||
|
dest_fileitem={
|
||||||
|
"storage": "local",
|
||||||
|
"path": "/library/奔跑吧 (2014)/Keep.Running.mkv",
|
||||||
|
"name": "Keep.Running.mkv",
|
||||||
|
"type": "file",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
delete_history_calls = []
|
||||||
|
|
||||||
|
class FakeTransferHistoryOper:
|
||||||
|
async def async_get(self, history_id):
|
||||||
|
return history
|
||||||
|
|
||||||
|
async def async_delete(self, history_id):
|
||||||
|
delete_history_calls.append(history_id)
|
||||||
|
|
||||||
|
class FakeStorageChain:
|
||||||
|
def exists(self, fileitem):
|
||||||
|
started.set()
|
||||||
|
release.wait(timeout=1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_media_file(self, fileitem):
|
||||||
|
finished.set()
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.agent.tools.impl.delete_transfer_history.TransferHistoryOper",
|
||||||
|
FakeTransferHistoryOper,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.agent.tools.impl.delete_transfer_history.StorageChain",
|
||||||
|
FakeStorageChain,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
task = asyncio.create_task(
|
||||||
|
DeleteTransferHistoryTool(
|
||||||
|
session_id="redo-session",
|
||||||
|
user_id="10001",
|
||||||
|
).run(history_id=16)
|
||||||
|
)
|
||||||
|
assert await asyncio.to_thread(started.wait, 1)
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(scenario())
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
assert delete_history_calls == []
|
||||||
|
assert finished.wait(timeout=1)
|
||||||
|
|
||||||
|
|
||||||
def test_manual_redo_context_uses_dest_path_for_successful_move_record():
|
def test_manual_redo_context_uses_dest_path_for_successful_move_record():
|
||||||
"""成功 move 记录重新整理时,旧目标文件才是可继续整理的输入路径。"""
|
"""成功 move 记录重新整理时,旧目标文件才是可继续整理的输入路径。"""
|
||||||
history = SimpleNamespace(
|
history = SimpleNamespace(
|
||||||
|
|||||||
+251
-11
@@ -27,7 +27,7 @@ from app.api.endpoints.agent import (
|
|||||||
_extract_web_agent_message_from_event_data,
|
_extract_web_agent_message_from_event_data,
|
||||||
_get_web_agent_type,
|
_get_web_agent_type,
|
||||||
_has_web_agent_traditional_interaction,
|
_has_web_agent_traditional_interaction,
|
||||||
_prepare_web_agent_audio_attachment_path,
|
_prepare_web_agent_audio_attachment_path_async,
|
||||||
_resolve_web_agent_audio_refs,
|
_resolve_web_agent_audio_refs,
|
||||||
_transcribe_web_agent_audio_files,
|
_transcribe_web_agent_audio_files,
|
||||||
web_agent_stream,
|
web_agent_stream,
|
||||||
@@ -661,31 +661,271 @@ def test_build_web_agent_message_events_registers_voice_attachment(tmp_path):
|
|||||||
assert attachment["url"].startswith("message/agent/file/")
|
assert attachment["url"].startswith("message/agent/file/")
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_web_agent_audio_attachment_converts_unsupported_audio(tmp_path):
|
def test_prepare_web_agent_audio_attachment_async_keeps_loop_responsive(tmp_path):
|
||||||
"""WebAgent 会把浏览器不稳定支持的语音格式转为 WAV 供面板播放。"""
|
"""异步转码等待期间事件循环仍应可调度其它任务。"""
|
||||||
source_path = tmp_path / "reply.opus"
|
source_path = tmp_path / "reply.opus"
|
||||||
source_path.write_bytes(b"opus-bytes")
|
source_path.write_bytes(b"opus-bytes")
|
||||||
converted_path = tmp_path / "voice" / "reply_web_abcdef12.wav"
|
converted_path = tmp_path / "voice" / "reply_web_abcdef12.wav"
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
with patch("app.api.endpoints.agent.shutil.which", return_value="/usr/bin/ffmpeg"), patch(
|
class FakeProcess:
|
||||||
"app.api.endpoints.agent.uuid.uuid4",
|
returncode = 0
|
||||||
return_value=SimpleNamespace(hex="abcdef1234567890"),
|
|
||||||
), patch("app.api.endpoints.agent.subprocess.run") as run:
|
async def communicate(self):
|
||||||
def write_converted_file(*args, **kwargs):
|
started.set()
|
||||||
|
await release.wait()
|
||||||
converted_path.write_bytes(b"wav-bytes")
|
converted_path.write_bytes(b"wav-bytes")
|
||||||
return SimpleNamespace(returncode=0, stderr="")
|
return b"", b""
|
||||||
|
|
||||||
run.side_effect = write_converted_file
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||||
|
assert args[0] == "/usr/bin/ffmpeg"
|
||||||
|
return FakeProcess()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
with patch(
|
with patch(
|
||||||
|
"app.api.endpoints.agent.shutil.which",
|
||||||
|
return_value="/usr/bin/ffmpeg",
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.uuid.uuid4",
|
||||||
|
return_value=SimpleNamespace(hex="abcdef1234567890"),
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
|
||||||
|
side_effect=fake_create_subprocess_exec,
|
||||||
|
), patch(
|
||||||
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
|
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
|
||||||
return_value=SimpleNamespace(temp_path=tmp_path),
|
return_value=SimpleNamespace(temp_path=tmp_path),
|
||||||
):
|
):
|
||||||
output_path = _prepare_web_agent_audio_attachment_path(str(source_path))
|
conversion_task = asyncio.create_task(
|
||||||
|
_prepare_web_agent_audio_attachment_path_async(str(source_path))
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
heartbeat = asyncio.create_task(asyncio.sleep(0))
|
||||||
|
await heartbeat
|
||||||
|
assert not conversion_task.done()
|
||||||
|
release.set()
|
||||||
|
return await conversion_task
|
||||||
|
|
||||||
|
output_path = asyncio.run(scenario())
|
||||||
|
|
||||||
assert output_path == converted_path
|
assert output_path == converted_path
|
||||||
assert output_path.read_bytes() == b"wav-bytes"
|
assert output_path.read_bytes() == b"wav-bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_web_agent_audio_attachment_async_cancellation_reaps_process(tmp_path):
|
||||||
|
"""取消 WebAgent 转码时应终止并回收 ffmpeg,不能留下半成品。"""
|
||||||
|
source_path = tmp_path / "reply.opus"
|
||||||
|
source_path.write_bytes(b"opus-bytes")
|
||||||
|
output_path = tmp_path / "voice" / "reply_web_abcdef12.wav"
|
||||||
|
started = asyncio.Event()
|
||||||
|
killed = False
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
returncode = None
|
||||||
|
_release = asyncio.Event()
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
nonlocal killed
|
||||||
|
killed = True
|
||||||
|
self.returncode = -9
|
||||||
|
self._release.set()
|
||||||
|
|
||||||
|
async def communicate(self):
|
||||||
|
started.set()
|
||||||
|
if self.returncode is None:
|
||||||
|
await self._release.wait()
|
||||||
|
return b"", b""
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||||
|
return FakeProcess()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
with patch(
|
||||||
|
"app.api.endpoints.agent.shutil.which",
|
||||||
|
return_value="/usr/bin/ffmpeg",
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.uuid.uuid4",
|
||||||
|
return_value=SimpleNamespace(hex="abcdef1234567890"),
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
|
||||||
|
side_effect=fake_create_subprocess_exec,
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
|
||||||
|
return_value=SimpleNamespace(temp_path=tmp_path),
|
||||||
|
):
|
||||||
|
conversion_task = asyncio.create_task(
|
||||||
|
_prepare_web_agent_audio_attachment_path_async(str(source_path))
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
conversion_task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await conversion_task
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
assert killed is True
|
||||||
|
assert not output_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_web_agent_audio_attachment_async_communicate_error_reaps_process(tmp_path):
|
||||||
|
"""ffmpeg 通信异常时应终止仍运行的进程并回退原文件。"""
|
||||||
|
source_path = tmp_path / "reply.opus"
|
||||||
|
source_path.write_bytes(b"opus-bytes")
|
||||||
|
started = asyncio.Event()
|
||||||
|
killed = False
|
||||||
|
communicate_calls = 0
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
returncode = None
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
nonlocal killed
|
||||||
|
killed = True
|
||||||
|
self.returncode = -9
|
||||||
|
|
||||||
|
async def communicate(self):
|
||||||
|
nonlocal communicate_calls
|
||||||
|
communicate_calls += 1
|
||||||
|
started.set()
|
||||||
|
if self.returncode is None:
|
||||||
|
raise OSError("pipe closed")
|
||||||
|
return b"", b""
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||||
|
return FakeProcess()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
with patch(
|
||||||
|
"app.api.endpoints.agent.shutil.which",
|
||||||
|
return_value="/usr/bin/ffmpeg",
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
|
||||||
|
side_effect=fake_create_subprocess_exec,
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
|
||||||
|
return_value=SimpleNamespace(temp_path=tmp_path),
|
||||||
|
):
|
||||||
|
output_path = await _prepare_web_agent_audio_attachment_path_async(
|
||||||
|
str(source_path)
|
||||||
|
)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
output_path = asyncio.run(scenario())
|
||||||
|
|
||||||
|
assert output_path == source_path
|
||||||
|
assert killed is True
|
||||||
|
assert communicate_calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_web_agent_audio_attachment_async_cancellation_cleans_completed_output(
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
"""转码完成后检查产物期间取消,也应清理未登记的 WAV。"""
|
||||||
|
source_path = tmp_path / "reply.opus"
|
||||||
|
source_path.write_bytes(b"opus-bytes")
|
||||||
|
output_path = tmp_path / "voice" / "reply_web_abcdef12.wav"
|
||||||
|
exists_started = asyncio.Event()
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
async def communicate(self):
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_bytes(b"wav-bytes")
|
||||||
|
return b"", b""
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||||
|
return FakeProcess()
|
||||||
|
|
||||||
|
async def fake_run_in_threadpool(func, *args, **kwargs):
|
||||||
|
if getattr(func, "__name__", "") == "exists":
|
||||||
|
exists_started.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
return await asyncio.to_thread(func, *args, **kwargs)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
with patch(
|
||||||
|
"app.api.endpoints.agent.shutil.which",
|
||||||
|
return_value="/usr/bin/ffmpeg",
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.uuid.uuid4",
|
||||||
|
return_value=SimpleNamespace(hex="abcdef1234567890"),
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
|
||||||
|
side_effect=fake_create_subprocess_exec,
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
|
||||||
|
return_value=SimpleNamespace(temp_path=tmp_path),
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.run_in_threadpool",
|
||||||
|
side_effect=fake_run_in_threadpool,
|
||||||
|
):
|
||||||
|
conversion_task = asyncio.create_task(
|
||||||
|
_prepare_web_agent_audio_attachment_path_async(str(source_path))
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(exists_started.wait(), timeout=1)
|
||||||
|
assert output_path.exists()
|
||||||
|
conversion_task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await conversion_task
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
assert not output_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_web_agent_audio_attachment_async_timeout_falls_back(tmp_path):
|
||||||
|
"""转码超时应回退原文件并回收 ffmpeg。"""
|
||||||
|
source_path = tmp_path / "reply.opus"
|
||||||
|
source_path.write_bytes(b"opus-bytes")
|
||||||
|
started = asyncio.Event()
|
||||||
|
killed = False
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
returncode = None
|
||||||
|
_release = asyncio.Event()
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
nonlocal killed
|
||||||
|
killed = True
|
||||||
|
self.returncode = -9
|
||||||
|
self._release.set()
|
||||||
|
|
||||||
|
async def communicate(self):
|
||||||
|
started.set()
|
||||||
|
if self.returncode is None:
|
||||||
|
await self._release.wait()
|
||||||
|
return b"", b""
|
||||||
|
|
||||||
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||||
|
return FakeProcess()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
with patch(
|
||||||
|
"app.api.endpoints.agent.shutil.which",
|
||||||
|
return_value="/usr/bin/ffmpeg",
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
|
||||||
|
side_effect=fake_create_subprocess_exec,
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
|
||||||
|
return_value=SimpleNamespace(temp_path=tmp_path),
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent.WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS",
|
||||||
|
0.01,
|
||||||
|
):
|
||||||
|
conversion_task = asyncio.create_task(
|
||||||
|
_prepare_web_agent_audio_attachment_path_async(str(source_path))
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
return await conversion_task
|
||||||
|
|
||||||
|
output_path = asyncio.run(scenario())
|
||||||
|
|
||||||
|
assert output_path == source_path
|
||||||
|
assert killed is True
|
||||||
|
|
||||||
|
|
||||||
def test_transcribe_web_agent_audio_files_reads_registered_upload(tmp_path):
|
def test_transcribe_web_agent_audio_files_reads_registered_upload(tmp_path):
|
||||||
"""WebAgent 上传录音应从临时附件登记表读取并转写为文本。"""
|
"""WebAgent 上传录音应从临时附件登记表读取并转写为文本。"""
|
||||||
voice_path = tmp_path / "recording.webm"
|
voice_path = tmp_path / "recording.webm"
|
||||||
|
|||||||
Reference in New Issue
Block a user