Files
MoviePilot/app/agent/tools/impl/send_voice_message.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

132 lines
4.6 KiB
Python

"""
发送语音消息工具。
"""
from typing import Any, Optional, Type
from pydantic import BaseModel, Field
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.message import Message
from app.schemas.types import MessageType
class SendVoiceMessageInput(BaseModel):
"""
发送语音消息工具输入。
"""
message: str = Field(
...,
description="需要通过语音回复给当前用户的完整正文",
)
class SendVoiceMessageTool(MoviePilotTool):
"""
发送 Agent 语音回复的工具。
"""
name: str = Field(
default="send_voice_message",
description="供模型调用的稳定工具名称",
)
tags: list[str] = Field(
default=[ToolTag.Write, ToolTag.Message, ToolTag.TerminalResponse],
description="工具的能力和权限分类标签,初始化时会统一归一化",
)
sends_message: bool = Field(
default=True,
description="工具是否直接向当前用户发送消息",
)
return_direct: bool = Field(
default=True,
description="是否直接返回工具结果并结束本轮智能体响应",
)
description: str = Field(
default=(
"向当前用户发送语音回复。"
"仅在用户明确要求语音,或语音播放明显更合适时使用。"
"渠道不支持语音或 TTS 不可用时,自动回退为发送相同内容的纯文本。"
"本工具直接完成用户回复:在 message 中提供完整正文;"
"执行后不要再发送相同文本或调用 send_message 重复发送。"
),
description="供模型理解工具用途、输入约束和调用边界的说明",
)
args_schema: Type[BaseModel] = Field(
default=SendVoiceMessageInput,
description="工具输入参数的验证模型或 JSON Schema",
)
def get_tool_message(self, **kwargs: Any) -> Optional[str]:
"""
生成语音回复工具的执行提示。
"""
message = kwargs.get("message") or ""
if len(message) > 40:
message = message[:40] + "..."
return f"发送语音回复: {message}"
async def run(self, message: str, **kwargs: Any) -> str: # type: ignore[override]
"""
合成语音并发送到当前对话渠道,不支持时回退为文字。
"""
if not message:
return "语音回复内容不能为空"
voice_path = None
used_voice = False
channel = self._channel or ""
reply_mode = AgentCapabilityManager.resolve_reply_mode(
channel=channel,
source=self._source,
)
fallback_reason = "当前渠道不支持语音回复"
if not AgentCapabilityManager.supports_audio_output():
fallback_reason = "当前未启用音频输出"
if (
reply_mode == AgentCapabilityManager.REPLY_MODE_NATIVE
and AgentCapabilityManager.is_audio_output_available()
):
voice_file = await self.run_blocking(
"default",
AgentCapabilityManager.synthesize_speech, message
)
if voice_file:
voice_path = str(voice_file)
used_voice = True
elif reply_mode == AgentCapabilityManager.REPLY_MODE_NATIVE:
fallback_reason = "当前未配置可用的语音合成能力"
logger.info(
f"执行工具: {self.name}, channel={channel}, "
f"use_voice={used_voice}, text_len={len(message)}"
)
await self.send_message(
Message.model_validate({
"channel": self._channel,
"source": self._source,
"mtype": MessageType.Agent,
"userid": self._user_id,
"username": self._username,
"text": message,
"voice_path": voice_path,
"voice_caption": (
message
if voice_path and get_runtime_setting('AUDIO_OUTPUT_INCLUDE_TEXT')
else None
),
"save_history": False,
})
)
self._agent_context["user_reply_sent"] = True
self._agent_context["reply_mode"] = "voice" if used_voice else "text_fallback"
if used_voice:
return "语音回复已发送"
return f"{fallback_reason},已自动回退为文字回复"