from config.logger import setup_logging import random import uuid import tempfile import os import time import requests from plugins_func.register import register_function, ToolType, ActionResponse, Action from core.handle.sendAudioHandle import send_stt_message from core.utils.dialogue import Message from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType, ContentType from config.manage_api_client import get_device_stories TAG = __name__ play_story_function_desc = { "type": "function", "function": { "name": "play_story", "description": "播放故事、讲故事的功能。当用户说:讲故事、播放故事、听故事、来个故事、给我讲个故事、我想听故事等时调用此功能。", "parameters": { "type": "object", "properties": { "story_name": { "type": "string", "description": "故事名称,如果用户没有指定具体故事名则为'random', 明确指定的时返回故事名字 示例: ```用户:讲下王子与公主的故事\n参数:王子与公主``` ```用户:讲个故事 \n参数:random ```", } }, "required": ["story_name"], }, }, } def _get_random_play_prompt(story_title): """生成随机播放引导语""" prompts = [ f"正在为您播放,{story_title}", f"请欣赏故事,{story_title}", f"即将为您播放,{story_title}", f"为您带来,{story_title}", f"让我们聆听,{story_title}", f"接下来请欣赏,{story_title}", f"为您献上,{story_title}", ] return random.choice(prompts) def _download_audio_from_url(logger, audio_url: str): """从URL下载音频文件到临时目录""" try: # 创建临时文件 temp_dir = tempfile.gettempdir() temp_filename = f"function_story_{int(time.time())}.mp3" temp_path = os.path.join(temp_dir, temp_filename) logger.bind(tag=TAG).info(f"开始下载音频: {audio_url} -> {temp_path}") # 下载音频文件 response = requests.get(audio_url, stream=True, timeout=60) response.raise_for_status() with open(temp_path, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) # 验证文件大小 file_size = os.path.getsize(temp_path) if file_size == 0: logger.bind(tag=TAG).error("下载的音频文件大小为0") os.remove(temp_path) return None logger.bind(tag=TAG).info(f"音频下载完成: {temp_path}, 大小: {file_size} 字节") return temp_path except requests.exceptions.RequestException as e: logger.bind(tag=TAG).error(f"下载音频失败: {str(e)}") return None except Exception as e: logger.bind(tag=TAG).error(f"下载音频时发生未知错误: {str(e)}") return None @register_function("play_story", play_story_function_desc, ToolType.SYSTEM_CTL) def play_story(conn, story_name: str): """播放故事函数""" try: conn.logger.bind(tag=TAG).info(f"play_story 被调用: story_name={story_name}, device_id={conn.device_id}") # 获取设备ID device_id = conn.device_id if not device_id: conn.logger.bind(tag=TAG).error("设备ID不存在,无法播放故事") return ActionResponse( action=Action.RESPONSE, result="设备ID不存在", response="抱歉,无法播放故事" ) # 检查事件循环状态 if not hasattr(conn, 'loop') or not conn.loop.is_running(): conn.logger.bind(tag=TAG).error("事件循环未运行,无法提交任务") return ActionResponse( action=Action.RESPONSE, result="系统繁忙", response="请稍后再试" ) # 默认故事信息(作为兜底) default_story_name = "侦探与龙:宝石的秘密" default_audio_url = "https://toy-storage.airlabs.art/audio/volcano_tts/permanent/20250904/story_7369183431743246336_cfd71314.mp3" default_story_title = "侦探与龙:宝石的秘密" # 调用API获取设备故事信息 conn.logger.bind(tag=TAG).info(f"开始调用API获取设备故事,device_id: {device_id}") stories_data = get_device_stories(device_id) conn.logger.bind(tag=TAG).info(f"API返回的stories_data: {stories_data}") final_story_name = default_story_name final_audio_url = default_audio_url final_story_title = default_story_title if stories_data: # 从API响应中提取故事信息 story_info = None # 检查API返回格式:{"success": true, "data": {"title": "...", "audio_url": "..."}} if isinstance(stories_data, dict) and stories_data.get("success") and "data" in stories_data: data = stories_data["data"] # 新格式:data直接包含故事信息 if isinstance(data, dict) and "title" in data and "audio_url" in data: story_info = data conn.logger.bind(tag=TAG).info(f"从API获取到直接故事数据: {story_info.get('title')}") # 原格式:data包含stories数组 else: story_list = data.get("stories", []) if len(story_list) > 0: # 随机选择一个故事 story_info = random.choice(story_list) conn.logger.bind(tag=TAG).info(f"从API获取到{len(story_list)}个故事,随机选择其中一个") # 兼容其他格式 elif isinstance(stories_data, list) and len(stories_data) > 0: story_info = random.choice(stories_data) elif isinstance(stories_data, dict) and 'stories' in stories_data: story_list = stories_data['stories'] if len(story_list) > 0: story_info = random.choice(story_list) if story_info: final_story_name = story_info.get("title", default_story_name) final_audio_url = story_info.get("audio_url", default_audio_url) final_story_title = story_info.get("title", default_story_title) conn.logger.bind(tag=TAG).info(f"使用API获取的故事: {final_story_title}") else: conn.logger.bind(tag=TAG).warning("API返回数据格式不正确,使用默认故事信息") else: conn.logger.bind(tag=TAG).warning("无法获取设备故事信息,使用默认故事信息") # 下载音频文件 conn.logger.bind(tag=TAG).info(f"开始下载音频文件: {final_audio_url}") temp_file_path = _download_audio_from_url(conn.logger, final_audio_url) if not temp_file_path: conn.logger.bind(tag=TAG).error("下载音频文件失败") return ActionResponse( action=Action.RESPONSE, result="下载失败", response="抱歉,无法播放故事" ) # 生成播放提示语 play_prompt = _get_random_play_prompt(final_story_title) conn.logger.bind(tag=TAG).info(f"播放提示语: {play_prompt}") # 发送消息到对话 import asyncio asyncio.run_coroutine_threadsafe( send_stt_message(conn, play_prompt), conn.loop ) conn.dialogue.put(Message(role="assistant", content=play_prompt)) # 生成新的sentence_id并重置TTS状态 conn.sentence_id = str(uuid.uuid4()) conn.tts.tts_audio_first_sentence = True # 重置播放状态 conn.client_abort = False conn.llm_finish_task = False # 将音频加入TTS队列 conn.tts.tts_text_queue.put( TTSMessageDTO( sentence_id=conn.sentence_id, sentence_type=SentenceType.FIRST, content_type=ContentType.ACTION, ) ) conn.tts.tts_text_queue.put( TTSMessageDTO( sentence_id=conn.sentence_id, sentence_type=SentenceType.MIDDLE, content_type=ContentType.TEXT, content_detail=play_prompt, ) ) conn.tts.tts_text_queue.put( TTSMessageDTO( sentence_id=conn.sentence_id, sentence_type=SentenceType.MIDDLE, content_type=ContentType.FILE, content_file=temp_file_path, content_detail=final_story_title, ) ) conn.tts.tts_text_queue.put( TTSMessageDTO( sentence_id=conn.sentence_id, sentence_type=SentenceType.LAST, content_type=ContentType.ACTION, ) ) conn.logger.bind(tag=TAG).info(f"故事播放任务已加入队列: {final_story_title}") # 返回成功响应 return ActionResponse( action=Action.NONE, result="播放成功", response=play_prompt ) except Exception as e: conn.logger.bind(tag=TAG).error(f"播放故事失败: {str(e)}") import traceback conn.logger.bind(tag=TAG).error(f"详细错误: {traceback.format_exc()}") return ActionResponse( action=Action.RESPONSE, result=str(e), response="播放故事时出错了" ) async def handle_story_command(conn, text="播放随机故事"): """ 向后兼容函数,供 story_handler.py 调用 将异步调用转换为同步的 play_story 函数调用 """ try: conn.logger.bind(tag=TAG).info(f"handle_story_command 被调用: {text}") # 从 text 中提取故事名称 story_name = "random" if "讲故事" in text and text != "随机播放故事": # 提取故事名称,例如 "讲故事 小红帽" -> "小红帽" parts = text.split("讲故事", 1) if len(parts) > 1: story_name = parts[1].strip() # 调用同步的 play_story 函数 result = play_story(conn, story_name) conn.logger.bind(tag=TAG).info(f"handle_story_command 完成: {result}") return True except Exception as e: conn.logger.bind(tag=TAG).error(f"handle_story_command 失败: {str(e)}") import traceback conn.logger.bind(tag=TAG).error(f"详细错误: {traceback.format_exc()}") return False