From 9cc2bdbce1a3049c159eb4cd8edec9d51f8bec1e Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:16:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=20Web=20Agent=20?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E8=BD=AC=E5=86=99=E9=98=BB=E5=A1=9E=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=BE=AA=E7=8E=AF=20(#6389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/agent.py | 54 +++++++++++++++++++++--------- tests/test_web_agent_stream.py | 60 ++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index dfee17022..a9b63f8b0 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -1016,20 +1016,11 @@ def _get_web_agent_registered_file(ref: str) -> Optional[dict[str, Any]]: return _WEB_AGENT_FILE_REGISTRY.get(file_id) -def _transcribe_web_agent_audio_refs(audio_refs: list[str]) -> Optional[str]: - """ - 转写 WebAgent 上传的本地录音附件。 - - Web 面板上传后的音频已经保存在短期文件登记表里,不能再像第三方渠道那样 - 走模块下载逻辑;这里直接读取临时文件并调用当前音频输入 provider。 - """ - if not audio_refs: - return None - if not AgentCapabilityManager.is_audio_input_available(): - logger.warning("WebAgent 音频输入能力未配置或未启用,跳过语音识别") - return None - - transcripts = [] +def _resolve_web_agent_audio_refs( + audio_refs: list[str], +) -> list[tuple[str, Path, str]]: + """在调用协程中解析音频引用,返回不依赖登记表的文件快照。""" + audio_files = [] for audio_ref in audio_refs: file_info = _get_web_agent_registered_file(audio_ref) if not file_info: @@ -1037,6 +1028,29 @@ def _transcribe_web_agent_audio_refs(audio_refs: list[str]) -> Optional[str]: continue 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: content = file_path.read_bytes() except OSError as err: @@ -1045,7 +1059,7 @@ def _transcribe_web_agent_audio_refs(audio_refs: list[str]) -> Optional[str]: transcript = AgentCapabilityManager.transcribe_audio( content=content, - filename=file_info.get("name") or file_path.name, + filename=file_name, ) if 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 +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: """合并用户输入文本和语音转写文本,避免重复发送相同内容。""" merged_parts = [] @@ -2104,7 +2126,7 @@ async def web_agent_stream( 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) display_prompt = _merge_web_agent_prompt_with_transcript(display_prompt, transcript) has_audio_input = bool(transcript) diff --git a/tests/test_web_agent_stream.py b/tests/test_web_agent_stream.py index 4941e2baa..d1dc51dad 100644 --- a/tests/test_web_agent_stream.py +++ b/tests/test_web_agent_stream.py @@ -8,7 +8,8 @@ from unittest.mock import AsyncMock, Mock, patch import pytest 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 ( _WebAgentEventPublisher, _WEB_AGENT_FILE_REGISTRY, @@ -26,7 +27,8 @@ from app.api.endpoints.agent import ( _get_web_agent_type, _has_web_agent_traditional_interaction, _prepare_web_agent_audio_attachment_path, - _transcribe_web_agent_audio_refs, + _resolve_web_agent_audio_refs, + _transcribe_web_agent_audio_files, web_agent_stream, _resolve_web_agent_choice_payload, _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" -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 上传录音应从临时附件登记表读取并转写为文本。""" voice_path = tmp_path / "recording.webm" 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", return_value="帮我推荐一部电影", ) 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: _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") 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, - ): + ) as transcribe_audio: response = asyncio.run(web_agent_stream(payload, request, user)) body = "".join(asyncio.run(_collect_streaming_response(response))) + transcribe_audio.assert_not_called() assert "error" 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(): """WebAgent 普通对话应统一进入 AgentManager 并绑定远程命令会话。""" payload = schemas.AgentWebChatRequest(