mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 04:14:38 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
132 lines
4.6 KiB
Python
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},已自动回退为文字回复"
|