mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
fix: 避免 Web Agent 语音转写阻塞事件循环 (#6389)
This commit is contained in:
+38
-16
@@ -1016,20 +1016,11 @@ def _get_web_agent_registered_file(ref: str) -> Optional[dict[str, Any]]:
|
|||||||
return _WEB_AGENT_FILE_REGISTRY.get(file_id)
|
return _WEB_AGENT_FILE_REGISTRY.get(file_id)
|
||||||
|
|
||||||
|
|
||||||
def _transcribe_web_agent_audio_refs(audio_refs: list[str]) -> Optional[str]:
|
def _resolve_web_agent_audio_refs(
|
||||||
"""
|
audio_refs: list[str],
|
||||||
转写 WebAgent 上传的本地录音附件。
|
) -> list[tuple[str, Path, str]]:
|
||||||
|
"""在调用协程中解析音频引用,返回不依赖登记表的文件快照。"""
|
||||||
Web 面板上传后的音频已经保存在短期文件登记表里,不能再像第三方渠道那样
|
audio_files = []
|
||||||
走模块下载逻辑;这里直接读取临时文件并调用当前音频输入 provider。
|
|
||||||
"""
|
|
||||||
if not audio_refs:
|
|
||||||
return None
|
|
||||||
if not AgentCapabilityManager.is_audio_input_available():
|
|
||||||
logger.warning("WebAgent 音频输入能力未配置或未启用,跳过语音识别")
|
|
||||||
return None
|
|
||||||
|
|
||||||
transcripts = []
|
|
||||||
for audio_ref in audio_refs:
|
for audio_ref in audio_refs:
|
||||||
file_info = _get_web_agent_registered_file(audio_ref)
|
file_info = _get_web_agent_registered_file(audio_ref)
|
||||||
if not file_info:
|
if not file_info:
|
||||||
@@ -1037,6 +1028,29 @@ def _transcribe_web_agent_audio_refs(audio_refs: list[str]) -> Optional[str]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
file_path = Path(file_info["path"])
|
file_path = Path(file_info["path"])
|
||||||
|
audio_files.append(
|
||||||
|
(audio_ref, file_path, file_info.get("name") or file_path.name)
|
||||||
|
)
|
||||||
|
return audio_files
|
||||||
|
|
||||||
|
|
||||||
|
def _transcribe_web_agent_audio_files(
|
||||||
|
audio_files: list[tuple[str, Path, str]],
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
转写 WebAgent 上传的本地录音附件。
|
||||||
|
|
||||||
|
文件信息已在调用协程中从短期登记表解析,阻塞文件读取和 provider 调用可在
|
||||||
|
worker 中执行,避免跨线程访问登记表。
|
||||||
|
"""
|
||||||
|
if not audio_files:
|
||||||
|
return None
|
||||||
|
if not AgentCapabilityManager.is_audio_input_available():
|
||||||
|
logger.warning("WebAgent 音频输入能力未配置或未启用,跳过语音识别")
|
||||||
|
return None
|
||||||
|
|
||||||
|
transcripts = []
|
||||||
|
for audio_ref, file_path, file_name in audio_files:
|
||||||
try:
|
try:
|
||||||
content = file_path.read_bytes()
|
content = file_path.read_bytes()
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
@@ -1045,7 +1059,7 @@ def _transcribe_web_agent_audio_refs(audio_refs: list[str]) -> Optional[str]:
|
|||||||
|
|
||||||
transcript = AgentCapabilityManager.transcribe_audio(
|
transcript = AgentCapabilityManager.transcribe_audio(
|
||||||
content=content,
|
content=content,
|
||||||
filename=file_info.get("name") or file_path.name,
|
filename=file_name,
|
||||||
)
|
)
|
||||||
if transcript:
|
if transcript:
|
||||||
transcripts.append(transcript)
|
transcripts.append(transcript)
|
||||||
@@ -1053,6 +1067,14 @@ def _transcribe_web_agent_audio_refs(audio_refs: list[str]) -> Optional[str]:
|
|||||||
return "\n".join(transcripts).strip() if transcripts else None
|
return "\n".join(transcripts).strip() if transcripts else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _transcribe_web_agent_audio_input(audio_refs: list[str]) -> Optional[str]:
|
||||||
|
"""解析并在线程池中转写 WebAgent 音频引用。"""
|
||||||
|
audio_files = _resolve_web_agent_audio_refs(audio_refs)
|
||||||
|
if not audio_files:
|
||||||
|
return None
|
||||||
|
return await asyncio.to_thread(_transcribe_web_agent_audio_files, audio_files)
|
||||||
|
|
||||||
|
|
||||||
def _merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[str]) -> str:
|
def _merge_web_agent_prompt_with_transcript(prompt: str, transcript: Optional[str]) -> str:
|
||||||
"""合并用户输入文本和语音转写文本,避免重复发送相同内容。"""
|
"""合并用户输入文本和语音转写文本,避免重复发送相同内容。"""
|
||||||
merged_parts = []
|
merged_parts = []
|
||||||
@@ -2104,7 +2126,7 @@ async def web_agent_stream(
|
|||||||
locale=locale,
|
locale=locale,
|
||||||
)
|
)
|
||||||
|
|
||||||
transcript = _transcribe_web_agent_audio_refs(payload.audio_refs or [])
|
transcript = await _transcribe_web_agent_audio_input(payload.audio_refs or [])
|
||||||
prompt = _merge_web_agent_prompt_with_transcript(prompt, transcript)
|
prompt = _merge_web_agent_prompt_with_transcript(prompt, transcript)
|
||||||
display_prompt = _merge_web_agent_prompt_with_transcript(display_prompt, transcript)
|
display_prompt = _merge_web_agent_prompt_with_transcript(display_prompt, transcript)
|
||||||
has_audio_input = bool(transcript)
|
has_audio_input = bool(transcript)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.agent import ReplyMode, agent_manager
|
from app.agent.contracts import ReplyMode
|
||||||
|
from app.agent.orchestrator import agent_manager
|
||||||
from app.api.endpoints.agent import (
|
from app.api.endpoints.agent import (
|
||||||
_WebAgentEventPublisher,
|
_WebAgentEventPublisher,
|
||||||
_WEB_AGENT_FILE_REGISTRY,
|
_WEB_AGENT_FILE_REGISTRY,
|
||||||
@@ -26,7 +27,8 @@ from app.api.endpoints.agent import (
|
|||||||
_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,
|
||||||
_transcribe_web_agent_audio_refs,
|
_resolve_web_agent_audio_refs,
|
||||||
|
_transcribe_web_agent_audio_files,
|
||||||
web_agent_stream,
|
web_agent_stream,
|
||||||
_resolve_web_agent_choice_payload,
|
_resolve_web_agent_choice_payload,
|
||||||
_split_web_agent_output,
|
_split_web_agent_output,
|
||||||
@@ -638,7 +640,7 @@ def test_prepare_web_agent_audio_attachment_converts_unsupported_audio(tmp_path)
|
|||||||
assert output_path.read_bytes() == b"wav-bytes"
|
assert output_path.read_bytes() == b"wav-bytes"
|
||||||
|
|
||||||
|
|
||||||
def test_transcribe_web_agent_audio_refs_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"
|
||||||
voice_path.write_bytes(b"webm-bytes")
|
voice_path.write_bytes(b"webm-bytes")
|
||||||
@@ -657,7 +659,11 @@ def test_transcribe_web_agent_audio_refs_reads_registered_upload(tmp_path):
|
|||||||
"app.api.endpoints.agent.AgentCapabilityManager.transcribe_audio",
|
"app.api.endpoints.agent.AgentCapabilityManager.transcribe_audio",
|
||||||
return_value="帮我推荐一部电影",
|
return_value="帮我推荐一部电影",
|
||||||
) as transcribe_audio:
|
) as transcribe_audio:
|
||||||
transcript = _transcribe_web_agent_audio_refs(["message/agent/file/audio-test"])
|
audio_files = _resolve_web_agent_audio_refs(
|
||||||
|
["message/agent/file/audio-test"]
|
||||||
|
)
|
||||||
|
_WEB_AGENT_FILE_REGISTRY.pop("audio-test", None)
|
||||||
|
transcript = _transcribe_web_agent_audio_files(audio_files)
|
||||||
finally:
|
finally:
|
||||||
_WEB_AGENT_FILE_REGISTRY.pop("audio-test", None)
|
_WEB_AGENT_FILE_REGISTRY.pop("audio-test", None)
|
||||||
|
|
||||||
@@ -679,16 +685,58 @@ def test_web_agent_stream_returns_error_when_voice_transcription_fails():
|
|||||||
user = SimpleNamespace(id=1, name="admin")
|
user = SimpleNamespace(id=1, name="admin")
|
||||||
|
|
||||||
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
|
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
|
||||||
"app.api.endpoints.agent._transcribe_web_agent_audio_refs",
|
"app.api.endpoints.agent._transcribe_web_agent_audio_files",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
):
|
) as transcribe_audio:
|
||||||
response = asyncio.run(web_agent_stream(payload, request, user))
|
response = asyncio.run(web_agent_stream(payload, request, user))
|
||||||
body = "".join(asyncio.run(_collect_streaming_response(response)))
|
body = "".join(asyncio.run(_collect_streaming_response(response)))
|
||||||
|
|
||||||
|
transcribe_audio.assert_not_called()
|
||||||
assert "error" in body
|
assert "error" in body
|
||||||
assert "语音识别失败" in body
|
assert "语音识别失败" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_agent_stream_does_not_block_event_loop_during_transcription():
|
||||||
|
"""同步音频 provider 等待时,事件循环仍应让其他任务获得执行机会。"""
|
||||||
|
payload = schemas.AgentWebChatRequest(
|
||||||
|
text="",
|
||||||
|
session_id="browser-session",
|
||||||
|
audio_refs=["message/agent/file/audio-test"],
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(headers={})
|
||||||
|
user = SimpleNamespace(id=1, name="admin")
|
||||||
|
|
||||||
|
transcription_started = ThreadEvent()
|
||||||
|
transcription_release = ThreadEvent()
|
||||||
|
|
||||||
|
def blocking_transcription(_audio_refs):
|
||||||
|
transcription_started.set()
|
||||||
|
assert transcription_release.wait(timeout=2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
stream_task = asyncio.create_task(web_agent_stream(payload, request, user))
|
||||||
|
assert await asyncio.to_thread(transcription_started.wait, 1)
|
||||||
|
assert stream_task.done() is False
|
||||||
|
transcription_release.set()
|
||||||
|
return await stream_task
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
|
||||||
|
"app.api.endpoints.agent._resolve_web_agent_audio_refs",
|
||||||
|
return_value=[Mock()],
|
||||||
|
), patch(
|
||||||
|
"app.api.endpoints.agent._transcribe_web_agent_audio_files",
|
||||||
|
side_effect=blocking_transcription,
|
||||||
|
):
|
||||||
|
response = asyncio.run(scenario())
|
||||||
|
finally:
|
||||||
|
transcription_release.set()
|
||||||
|
|
||||||
|
body = "".join(asyncio.run(_collect_streaming_response(response)))
|
||||||
|
assert "语音识别失败" in body
|
||||||
|
|
||||||
|
|
||||||
def test_web_agent_stream_binds_session_to_agent_manager():
|
def test_web_agent_stream_binds_session_to_agent_manager():
|
||||||
"""WebAgent 普通对话应统一进入 AgentManager 并绑定远程命令会话。"""
|
"""WebAgent 普通对话应统一进入 AgentManager 并绑定远程命令会话。"""
|
||||||
payload = schemas.AgentWebChatRequest(
|
payload = schemas.AgentWebChatRequest(
|
||||||
|
|||||||
Reference in New Issue
Block a user