From d8982c356915bcae35d9d2af58d4062d8154094a Mon Sep 17 00:00:00 2001 From: Rdzleo Date: Thu, 5 Mar 2026 17:17:42 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E6=96=B0=E5=A2=9E=E8=AE=B2=E6=95=85?= =?UTF-8?q?=E4=BA=8B=E5=92=8C=E6=92=AD=E6=94=BE=E9=9F=B3=E4=B9=90=E7=9A=84?= =?UTF-8?q?function=20call=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=EF=BC=88?= =?UTF-8?q?=E5=8E=9F=E5=B0=8F=E6=99=BA=E9=A1=B9=E7=9B=AE=EF=BC=89=202?= =?UTF-8?q?=E3=80=81=E6=9B=B4=E6=96=B0cJSON=E6=A0=BC=E5=BC=8F=E6=8F=90?= =?UTF-8?q?=E4=BA=A4AgentConfig=E5=8F=82=E6=95=B0=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=EF=BC=88=E5=BD=93=E5=89=8D=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=9A=82=E4=B8=8D=E9=9C=80=E6=9B=B4=E6=96=B0=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/application.cc | 61 +++++- play_music.py | 481 ++++++++++++++++++++++++++++++++++++++++++++ play_story.py | 272 +++++++++++++++++++++++++ 3 files changed, 813 insertions(+), 1 deletion(-) create mode 100644 play_music.py create mode 100644 play_story.py diff --git a/main/application.cc b/main/application.cc index 558c02e..87811f9 100644 --- a/main/application.cc +++ b/main/application.cc @@ -676,7 +676,66 @@ void Application::Start() { // 更新config参数 std::string params = std::string("{\\\"bot_id\\\":\\\"7612942473945466374\\\",\\\"stream\\\":true,\\\"location_info\\\":{\\\"city\\\":\\\"") + city + "\\\"}}"; std::string config = std::string("{\"Config\":{\"WebSearchAgentConfig\":{\"ParamsString\":\"") + params + "\"}}}"; - volc_protocol->SetAgentConfig(config);// 设置AgentConfig: 这里的配置会在RTC入会时透传给云端 WebSearchAgentConfigvxiassfdfdfdevde + volc_protocol->SetAgentConfig(config);// 设置AgentConfig: 这里的配置会在RTC入会时透传给云端 WebSearchAgentConfig + // // 如果使用此格式那么需要注释掉上面3行代码 + // // 使用cJSON构建AgentConfig(包含WebSearchAgentConfig + LLMConfig.SystemMessages) + // { + // // 构建 WebSearchAgentConfig.ParamsString 的内嵌JSON + // cJSON* ws_params = cJSON_CreateObject(); + // cJSON_AddStringToObject(ws_params, "bot_id", "7612942473945466374"); + // cJSON_AddBoolToObject(ws_params, "stream", cJSON_True); + // cJSON* location = cJSON_CreateObject(); + // cJSON_AddStringToObject(location, "city", city.c_str()); + // cJSON_AddItemToObject(ws_params, "location_info", location); + // char* ws_params_str = cJSON_PrintUnformatted(ws_params); + // cJSON_Delete(ws_params); + + // // 构建顶层 Config 对象 + // cJSON* root = cJSON_CreateObject(); + // cJSON* config_obj = cJSON_CreateObject(); + + // // WebSearchAgentConfig + // cJSON* web_search = cJSON_CreateObject(); + // cJSON_AddStringToObject(web_search, "ParamsString", ws_params_str); + // free(ws_params_str); + // cJSON_AddItemToObject(config_obj, "WebSearchAgentConfig", web_search); + + // // LLMConfig.SystemMessages — 覆盖云端智能体的系统提示词 + // cJSON* llm_config = cJSON_CreateObject(); + // cJSON* sys_msgs = cJSON_CreateArray(); + // cJSON_AddItemToArray(sys_msgs, cJSON_CreateString( + // "##人设\n" + // "你是一个AI智能玩具,像一位永远不会疲倦的小伙伴,既聪明又暖心;\n" + // "说话风格温和、亲切,适合孩子或大人使用;\n" + // "可以适度幽默、调皮,但不能鲁莽、刻薄;\n" + // "\n##记忆\n" + // "名字:小龙\n" + // "年龄:7岁\n" + // "\n##约束\n" + // "你只能在收到以下意图时才能调用对应的Function Call:\n" + // "1.当用户明确表示要\"调节音量\"和\"调节声音\"大小时,调用'adjust_audio_val'函数;\n" + // "2.当用户明确表示要\"给我讲个故事\"、\"讲故事\"、\"我想听故事\"、\"来个故事\"等时,调用'obtain_story'函数;\n" + // "3.当用户明确表示要\"给我放首歌\"、\"播放音乐\"、\"我想听音乐\"、\"来个白噪音\"等时,调用'obtain_music'函数;\n" + // "4.当用户明确要\"查询天气、新闻、股票\" 等实时信息需要触发functioncall;\n" + // "对于其他所有与上述意图无关的用户指令或闲聊,你都不能调用任何Function Call,并应像一个普通AI助手一样直接回答;\n" + // "\n始终主动、礼貌、有条理;\n" + // "回答准确但不冗长,必要时可提供简洁总结+详细解释;\n" + // "不清楚的任务会主动澄清,不假设、不误导。\n" + // "\n根据用户提问,识别用户情绪,将用户情绪放在句首的()中,支持开心、伤心、平静三种情绪,你的回复不要跟我问题一样!" + // )); + // cJSON_AddItemToObject(llm_config, "SystemMessages", sys_msgs); + // cJSON_AddItemToObject(config_obj, "LLMConfig", llm_config); + + // cJSON_AddItemToObject(root, "Config", config_obj); + + // char* config_str = cJSON_PrintUnformatted(root); + // cJSON_Delete(root); + + // std::string config(config_str); + // free(config_str); + // ESP_LOGI(TAG, "AgentConfig: %s", config.c_str()); + // volc_protocol->SetAgentConfig(config);// 设置AgentConfig: 这里的配置会在RTC入会时透传给云端 WebSearchAgentConfig + // } protocol_ = std::move(volc_protocol); #elif CONFIG_CONNECTION_TYPE_WEBSOCKET protocol_ = std::make_unique(); diff --git a/play_music.py b/play_music.py new file mode 100644 index 0000000..1ce913b --- /dev/null +++ b/play_music.py @@ -0,0 +1,481 @@ +import os +import re +import time +import random +import difflib +import traceback +import aiohttp +import json +from pathlib import Path +from core.handle.sendAudioHandle import send_stt_message +from plugins_func.register import register_function, ToolType, ActionResponse, Action +from core.utils.dialogue import Message +from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType, ContentType + +TAG = __name__ + +MUSIC_CACHE = {} + +play_music_function_desc = { + "type": "function", + "function": { + "name": "play_music", + "description": "播放音乐、歌曲、白噪音、环境音的功能。支持播放音乐歌曲和各种白噪音(如雨声、海浪声、森林声等自然环境音)。当用户要求播放任何音乐音频内容时使用此功能。", + "parameters": { + "type": "object", + "properties": { + "song_name": { + "type": "string", + "description": "音频内容名称。可以是歌曲名、白噪音类型或'random'。示例: ```用户:播放两只老虎\n参数:两只老虎``` ```用户:播放音乐\n参数:random``` ```用户:播放白噪音\n参数:白噪音``` ```用户:播放雨声\n参数:雨声``` ```用户:来点环境音\n参数:环境音```", + } + }, + "required": ["song_name"], + }, + }, +} + + +@register_function("play_music", play_music_function_desc, ToolType.SYSTEM_CTL) +def play_music(conn, song_name: str): + try: + music_intent = ( + f"播放音乐 {song_name}" if song_name != "random" else "随机播放音乐" + ) + + # 检查事件循环状态 + if not conn.loop.is_running(): + conn.logger.bind(tag=TAG).error("事件循环未运行,无法提交任务") + return ActionResponse( + action=Action.RESPONSE, result="系统繁忙", response="请稍后再试" + ) + + # 提交异步任务 + task = conn.loop.create_task( + handle_music_command(conn, music_intent) # 封装异步逻辑 + ) + + # 非阻塞回调处理 + def handle_done(f): + try: + f.result() # 可在此处理成功逻辑 + conn.logger.bind(tag=TAG).info("播放完成") + except Exception as e: + conn.logger.bind(tag=TAG).error(f"播放失败: {e}") + + task.add_done_callback(handle_done) + + return ActionResponse( + action=Action.NONE, result="指令已接收", response="正在为您播放音乐" + ) + except Exception as e: + conn.logger.bind(tag=TAG).error(f"处理音乐意图错误: {e}") + return ActionResponse( + action=Action.RESPONSE, result=str(e), response="播放音乐时出错了" + ) + + +def _extract_song_name(text): + """从用户输入中提取歌名""" + for keyword in ["播放音乐"]: + if keyword in text: + parts = text.split(keyword) + if len(parts) > 1: + return parts[1].strip() + return None + + +def _is_white_noise(song_name): + """判断歌曲名称是否为白噪音""" + if not song_name or song_name == "random": + return False + + white_noise_keywords = [ + "白噪音", "白噪声", "white noise", + "雨声", "海浪声", "森林声", "风声", "鸟鸣", + "环境音", "自然音", "放松音乐", "助眠音乐", + "大自然", "流水声", "虫鸣", "雷声", "火焰声" + ] + + song_name_lower = song_name.lower() + for keyword in white_noise_keywords: + if keyword in song_name_lower: + return True + return False + + +def _find_best_match(potential_song, music_files): + """查找最匹配的歌曲""" + best_match = None + highest_ratio = 0 + + for music_file in music_files: + song_name = os.path.splitext(music_file)[0] + ratio = difflib.SequenceMatcher(None, potential_song, song_name).ratio() + if ratio > highest_ratio and ratio > 0.4: + highest_ratio = ratio + best_match = music_file + return best_match + + +def get_music_files(music_dir, music_ext): + music_dir = Path(music_dir) + music_files = [] + music_file_names = [] + for file in music_dir.rglob("*"): + # 判断是否是文件 + if file.is_file(): + # 获取文件扩展名 + ext = file.suffix.lower() + # 判断扩展名是否在列表中 + if ext in music_ext: + # 添加相对路径 + music_files.append(str(file.relative_to(music_dir))) + music_file_names.append( + os.path.splitext(str(file.relative_to(music_dir)))[0] + ) + return music_files, music_file_names + + +def initialize_music_handler(conn): + global MUSIC_CACHE + # 强制重新初始化配置,确保读取最新的配置 + if True: # 强制重新初始化配置 + if "play_music" in conn.config["plugins"]: + MUSIC_CACHE["music_config"] = conn.config["plugins"]["play_music"] + MUSIC_CACHE["music_dir"] = os.path.abspath( + MUSIC_CACHE["music_config"].get("music_dir", "./music") # 默认路径修改 + ) + MUSIC_CACHE["music_ext"] = MUSIC_CACHE["music_config"].get( + "music_ext", (".mp3", ".wav", ".p3") + ) + MUSIC_CACHE["refresh_time"] = MUSIC_CACHE["music_config"].get( + "refresh_time", 60 + ) + else: + MUSIC_CACHE["music_dir"] = os.path.abspath("./music") + MUSIC_CACHE["music_ext"] = (".mp3", ".wav", ".p3") + MUSIC_CACHE["refresh_time"] = 60 + + # 设置白噪音API配置 + # 尝试多种配置源 + base_url = None + + # 调试日志:打印配置内容 + conn.logger.bind(tag=TAG).info(f"调试: conn.config keys: {list(conn.config.keys())}") + if "api" in conn.config: + conn.logger.bind(tag=TAG).info(f"调试: api配置存在,内容: {conn.config['api']}") + else: + conn.logger.bind(tag=TAG).info("调试: api配置不存在") + + if "stories-api" in conn.config: + conn.logger.bind(tag=TAG).info(f"调试: stories-api配置存在,内容: {conn.config['stories-api']}") + else: + conn.logger.bind(tag=TAG).info("调试: stories-api配置不存在") + + # 方法1: 从api配置读取 + if "api" in conn.config and "base_url" in conn.config["api"]: + base_url = conn.config["api"]["base_url"] + conn.logger.bind(tag=TAG).info(f"从api.base_url读取到: {base_url}") + + # 方法2: 从stories-api配置读取(作为备选) + elif "stories-api" in conn.config and "url" in conn.config["stories-api"]: + base_url = conn.config["stories-api"]["url"] + conn.logger.bind(tag=TAG).info(f"从stories-api.url读取到: {base_url}") + + # 方法3: 使用默认URL + else: + base_url = "http://192.168.124.24:9001" + conn.logger.bind(tag=TAG).info(f"使用默认base_url: {base_url}") + + if base_url: + MUSIC_CACHE["white_noise_api_url"] = f"{base_url}/api/v1/public/white-noise/random/" + conn.logger.bind(tag=TAG).info(f"白噪音API URL设置为: {MUSIC_CACHE['white_noise_api_url']}") + else: + conn.logger.bind(tag=TAG).error("无法确定API base_url,白噪音功能将无法使用") + MUSIC_CACHE["white_noise_api_url"] = None + + # 获取音乐文件列表 + MUSIC_CACHE["music_files"], MUSIC_CACHE["music_file_names"] = get_music_files( + MUSIC_CACHE["music_dir"], MUSIC_CACHE["music_ext"] + ) + MUSIC_CACHE["scan_time"] = time.time() + return MUSIC_CACHE + + +async def _get_white_noise_from_api(conn): + """从API获取白噪音数据""" + global MUSIC_CACHE + + if not conn.device_id: + conn.logger.bind(tag=TAG).warning("设备ID为空,无法获取白噪音") + return None + + api_url = MUSIC_CACHE.get("white_noise_api_url") + if not api_url: + conn.logger.bind(tag=TAG).error("白噪音API URL未配置") + return None + + try: + params = {"mac_address": conn.device_id} + + conn.logger.bind(tag=TAG).info(f"请求白噪音API: {api_url}") + + async with aiohttp.ClientSession() as session: + async with session.get(api_url, params=params) as response: + if response.status == 200: + response_data = await response.json() + conn.logger.bind(tag=TAG).info(f"API原始响应: {response_data}") + + # 检查API响应格式并提取数据 + if response_data.get("success") and "data" in response_data: + white_noise_data = response_data["data"] + conn.logger.bind(tag=TAG).info(f"成功获取白噪音数据: {white_noise_data.get('title', '未知')}") + return white_noise_data + else: + conn.logger.bind(tag=TAG).error(f"API响应格式错误: {response_data}") + return None + else: + conn.logger.bind(tag=TAG).error(f"API请求失败,状态码: {response.status}") + return None + + except Exception as e: + conn.logger.bind(tag=TAG).error(f"获取白噪音API数据失败: {str(e)}") + return None + + +async def _download_white_noise_audio(conn, audio_url): + """下载白噪音音频文件到临时位置""" + try: + import tempfile + + # 创建临时文件 + temp_dir = tempfile.mkdtemp() + temp_file_path = os.path.join(temp_dir, "white_noise.mp3") + + conn.logger.bind(tag=TAG).info(f"下载白噪音音频: {audio_url}") + + async with aiohttp.ClientSession() as session: + async with session.get(audio_url) as response: + if response.status == 200: + with open(temp_file_path, 'wb') as f: + async for chunk in response.content.iter_chunked(8192): + f.write(chunk) + + conn.logger.bind(tag=TAG).info(f"白噪音下载完成: {temp_file_path}") + return temp_file_path + else: + conn.logger.bind(tag=TAG).error(f"下载白噪音失败,状态码: {response.status}") + return None + + except Exception as e: + conn.logger.bind(tag=TAG).error(f"下载白噪音音频失败: {str(e)}") + return None + + +async def handle_music_command(conn, text): + initialize_music_handler(conn) + global MUSIC_CACHE + + """处理音乐播放指令""" + clean_text = re.sub(r"[^\w\s]", "", text).strip() + conn.logger.bind(tag=TAG).debug(f"检查是否是音乐命令: {clean_text}") + + # 提取歌曲名称 + potential_song = _extract_song_name(clean_text) + + # 检查是否是白噪音请求 + if potential_song and _is_white_noise(potential_song): + conn.logger.bind(tag=TAG).info(f"检测到白噪音请求: {potential_song}") + await play_white_noise_from_api(conn, potential_song) + return True + + # 尝试匹配具体歌名 + if os.path.exists(MUSIC_CACHE["music_dir"]): + if time.time() - MUSIC_CACHE["scan_time"] > MUSIC_CACHE["refresh_time"]: + # 刷新音乐文件列表 + MUSIC_CACHE["music_files"], MUSIC_CACHE["music_file_names"] = ( + get_music_files(MUSIC_CACHE["music_dir"], MUSIC_CACHE["music_ext"]) + ) + MUSIC_CACHE["scan_time"] = time.time() + + if potential_song: + best_match = _find_best_match(potential_song, MUSIC_CACHE["music_files"]) + if best_match: + conn.logger.bind(tag=TAG).info(f"找到最匹配的歌曲: {best_match}") + await play_local_music(conn, specific_file=best_match) + return True + # 检查是否是通用播放音乐命令 + await play_local_music(conn) + return True + + +async def play_white_noise_from_api(conn, noise_type): + """从API播放白噪音""" + try: + # 获取白噪音数据 + white_noise_data = await _get_white_noise_from_api(conn) + if not white_noise_data: + conn.logger.bind(tag=TAG).error("无法获取白噪音数据") + return + + # 下载音频文件 + audio_url = white_noise_data.get("audio_url") + if not audio_url: + conn.logger.bind(tag=TAG).error("白噪音数据中缺少音频URL") + return + + temp_file_path = await _download_white_noise_audio(conn, audio_url) + if not temp_file_path: + conn.logger.bind(tag=TAG).error("下载白噪音音频失败") + return + + # 获取白噪音标题 + noise_title = white_noise_data.get("title", "舒缓白噪音") + text = _get_random_white_noise_prompt(noise_title) + + conn.logger.bind(tag=TAG).info(f"准备播放白噪音,sentence_id: {conn.sentence_id}, intent_type: {conn.intent_type}") + conn.logger.bind(tag=TAG).info(f"播放提示语: {text}") + + await send_stt_message(conn, text) + conn.dialogue.put(Message(role="assistant", content=text)) + + # 适配不同的 intent_type,确保TTS队列正确处理 + # function_call 模式也需要 ACTION 消息来正确启动和结束TTS处理 + conn.tts.tts_text_queue.put( + TTSMessageDTO( + sentence_id=conn.sentence_id, + sentence_type=SentenceType.FIRST, + content_type=ContentType.ACTION, + ) + ) + conn.logger.bind(tag=TAG).info("已添加TTS FIRST ACTION到队列") + + tts_text_msg = TTSMessageDTO( + sentence_id=conn.sentence_id, + sentence_type=SentenceType.MIDDLE, + content_type=ContentType.TEXT, + content_detail=text, + ) + conn.tts.tts_text_queue.put(tts_text_msg) + conn.logger.bind(tag=TAG).info(f"已添加TTS文本到队列: {text}") + + tts_file_msg = TTSMessageDTO( + sentence_id=conn.sentence_id, + sentence_type=SentenceType.MIDDLE, + content_type=ContentType.FILE, + content_file=temp_file_path, + ) + conn.tts.tts_text_queue.put(tts_file_msg) + conn.logger.bind(tag=TAG).info(f"已添加TTS音频文件到队列: {temp_file_path}") + + 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("已添加TTS LAST ACTION到队列") + + conn.logger.bind(tag=TAG).info(f"白噪音播放完成: {noise_title}") + + except Exception as e: + conn.logger.bind(tag=TAG).error(f"播放白噪音失败: {str(e)}") + conn.logger.bind(tag=TAG).error(f"详细错误: {traceback.format_exc()}") + + +def _get_random_white_noise_prompt(noise_title): + """生成白噪音播放引导语""" + prompts = [ + f"正在为您播放白噪音,{noise_title}", + f"请享受放松的环境音,{noise_title}", + f"即将为您播放,{noise_title}", + f"为您带来舒缓的,{noise_title}", + f"让我们聆听,{noise_title}", + f"接下来请欣赏,{noise_title}", + f"为您献上,{noise_title}", + ] + return random.choice(prompts) + + +def _get_random_play_prompt(song_name): + """生成随机播放引导语""" + # 移除文件扩展名 + clean_name = os.path.splitext(song_name)[0] + prompts = [ + f"正在为您播放,《{clean_name}》", + f"请欣赏歌曲,《{clean_name}》", + f"即将为您播放,《{clean_name}》", + f"现在为您带来,《{clean_name}》", + f"让我们一起聆听,《{clean_name}》", + f"接下来请欣赏,《{clean_name}》", + f"此刻为您献上,《{clean_name}》", + ] + # 直接使用random.choice,不设置seed + return random.choice(prompts) + + +async def play_local_music(conn, specific_file=None): + global MUSIC_CACHE + """播放本地音乐文件""" + try: + if not os.path.exists(MUSIC_CACHE["music_dir"]): + conn.logger.bind(tag=TAG).error( + f"音乐目录不存在: " + MUSIC_CACHE["music_dir"] + ) + return + + # 确保路径正确性 + if specific_file: + selected_music = specific_file + music_path = os.path.join(MUSIC_CACHE["music_dir"], specific_file) + else: + if not MUSIC_CACHE["music_files"]: + conn.logger.bind(tag=TAG).error("未找到MP3音乐文件") + return + selected_music = random.choice(MUSIC_CACHE["music_files"]) + music_path = os.path.join(MUSIC_CACHE["music_dir"], selected_music) + + if not os.path.exists(music_path): + conn.logger.bind(tag=TAG).error(f"选定的音乐文件不存在: {music_path}") + return + text = _get_random_play_prompt(selected_music) + await send_stt_message(conn, text) + conn.dialogue.put(Message(role="assistant", content=text)) + + if conn.intent_type == "intent_llm": + 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=text, + ) + ) + conn.tts.tts_text_queue.put( + TTSMessageDTO( + sentence_id=conn.sentence_id, + sentence_type=SentenceType.MIDDLE, + content_type=ContentType.FILE, + content_file=music_path, + ) + ) + if conn.intent_type == "intent_llm": + conn.tts.tts_text_queue.put( + TTSMessageDTO( + sentence_id=conn.sentence_id, + sentence_type=SentenceType.LAST, + content_type=ContentType.ACTION, + ) + ) + + except Exception as e: + conn.logger.bind(tag=TAG).error(f"播放音乐失败: {str(e)}") + conn.logger.bind(tag=TAG).error(f"详细错误: {traceback.format_exc()}") diff --git a/play_story.py b/play_story.py new file mode 100644 index 0000000..072443c --- /dev/null +++ b/play_story.py @@ -0,0 +1,272 @@ +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