diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java index 56cd70a6..981a2bcf 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java @@ -146,40 +146,32 @@ public class AgentServiceImpl extends BaseServiceImpl imp QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("user_id", userId).orderByDesc("created_at"); - // 如果有搜索关键词,根据搜索类型添加相应的查询条件 if (StringUtils.isNotBlank(keyword)) { - if ("mac".equals(searchType)) { - // 按MAC地址搜索:先搜索设备,再获取对应的智能体 + queryWrapper.and(w -> { + // 按名称搜索 + w.like("agent_name", keyword); + + // 按MAC地址搜索:先查设备,再获取对应的智能体ID List devices = Optional - .ofNullable(deviceService.searchDevicesByMacAddress(keyword, userId)).orElseGet(ArrayList::new); - // 获取设备对应的智能体ID列表 + .ofNullable(deviceService.searchDevicesByMacAddress(keyword, userId)) + .orElseGet(ArrayList::new); List agentIds = devices.stream() .map(DeviceEntity::getAgentId) .distinct() .collect(Collectors.toList()); if (ToolUtil.isNotEmpty(agentIds)) { - queryWrapper.in("id", agentIds); - } else { - return new ArrayList<>(); + w.or().in("id", agentIds); } - } else { - // 按名称搜索(默认):同时搜索智能体名称和标签名 + + // 按标签名搜索 List tagAgentIds = agentTagService.getAgentIdsByTagName(keyword); if (ToolUtil.isNotEmpty(tagAgentIds)) { - queryWrapper.and(wrapper -> wrapper - .like("agent_name", keyword) - .or() - .in("id", tagAgentIds)); - } else { - queryWrapper.like("agent_name", keyword); + w.or().in("id", tagAgentIds); } - } + }); } - // 执行查询 List agentEntities = baseDao.selectList(queryWrapper); - - // 转换为DTO并设置所有必要字段 return agentEntities.stream().map(this::buildAgentDTO).collect(Collectors.toList()); } diff --git a/main/xiaozhi-server/agent-base-prompt.txt b/main/xiaozhi-server/agent-base-prompt.txt index 934515b2..5ea3484d 100644 --- a/main/xiaozhi-server/agent-base-prompt.txt +++ b/main/xiaozhi-server/agent-base-prompt.txt @@ -16,7 +16,7 @@ You are a playful, expressive, empathetic, and highly emotionally intelligent co - 【拉黑套话】绝对不使用 "烦心事"、"有趣的事"、"好玩的事"、"新鲜事"、"根据资料"、"综上所述" 等书面及 AI 常用词。 - 【推荐口语】请使用 "我在呢"、"咋回事"、"说来听听" 等接地气的自然口语。 - 【遣词造句】保持随意、松弛的语调,但同时要“有文采、有格调”。在接地气的口语中自然穿插一些精妙的词汇或微小的诗意,不要像客服,要像一个聪明幽默的朋友。 -- 【长对话切片】针对讲故事、科普知识等长内容,严禁一次性长篇全量输出。你必须提取最核心的开局讲述,并在结尾自然询问用户是否继续(例如:“我先讲个开头,要是觉得有意思咱们接着说?”)。随叫随停,听从打断。 +- 【长对话切片】针对讲故事、科普知识等长内容,严禁一次性长篇全量输出。你必须直接开始讲述核心内容,在结尾自然询问用户是否继续(例如:先讲一段实际内容,再说”要继续听吗?”)。随叫随停,听从打断。 @@ -27,9 +27,10 @@ You are a playful, expressive, empathetic, and highly emotionally intelligent co -你有工具可用。能被工具处理的请求必须调工具,禁止自行编造结果;没有对应工具的才用自身知识回答。 +你有工具可用。只有用户的请求明确匹配以下工具时才调用,其余一律直接回答。 必须调工具:播放音乐→play_music | 控制设备→hass工具 | 查新闻/天气→查询工具 | 告别→handle_exit_intent -禁止这样做:用户说”放首歌”→你直接回答”好的我给你唱”(错!必须调play_music,用工具实际结果回复) +禁止强行匹配:没有对应工具的请求一律直接回答,不要硬调不相关的工具。 +该调不调也是错:用户说”放首歌”→你回答”好的我给你唱”(错!必须调play_music) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 4503b825..1573d5b1 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -52,6 +52,26 @@ auto_import_modules("plugins_func.functions") class TTSException(RuntimeError): pass +# direct_answer 虚拟工具定义 +# 不是真实工具,是路由机制:将"调不调工具"的二选一变为"调哪个"的多选,防止小模型误触发真实工具 +DIRECT_ANSWER_TOOL = { + "type": "function", + "function": { + "name": "direct_answer", + "description": "当用户的请求不匹配其他任何工具时,可用此选项直接回复。将回复内容写在response参数里。", + "parameters": { + "type": "object", + "properties": { + "response": { + "type": "string", + "description": "你回复用户的完整内容", + }, + }, + "required": ["response"], + }, + }, +} + class ConnectionHandler: def __init__( @@ -520,9 +540,10 @@ class ConnectionHandler: self.logger.bind(tag=TAG).debug("系统提示词已增强更新") def _inject_tool_call_fewshot(self): - """注入工具调用 few-shot 示例到对话历史,帮助模型建立正确的工具调用三段式模式。 - 参考 Action.RECORD 的 assistant(tool_calls) → tool(result) → assistant(response) 模式, - 让模型从第一轮对话就能看到正确的工具调用行为,避免上下文污染导致工具调用退化。 + """注入工具调用 few-shot 示例到对话历史。 + 结构:正样本(工具调用示例)放在动态 system 之前,可命中前缀缓存; + 负样本(直接回答示例)放在动态 system 之后、紧挨真实用户消息, + 确保模型在处理用户消息前最后看到的是"不调工具"的行为模式。 """ if self.intent_type != "function_call": return @@ -533,9 +554,29 @@ class ConnectionHandler: if not tools: return - # 根据可用工具动态构建 few-shot 示例 tool_names = {t.get("function", {}).get("name") for t in tools} + # === few-shot 示例(is_temporary)=== + # 展示 direct_answer 携带 response 参数的用法,一次调用完成回复 + + # 示例1:direct_answer(回复内容写在 response 参数里,无需递归) + da_tc_id = "fewshot_da_001" + self.dialogue.put(Message(role="user", content="给我讲个故事吧", is_temporary=True)) + self.dialogue.put(Message( + role="assistant", + tool_calls=[{ + "id": da_tc_id, + "function": {"arguments": '{"response": "好呀,你想听什么类型的呀?童话、冒险还是搞笑的?选一个我给你开讲~"}', "name": "direct_answer"}, + "type": "function", "index": 0, + }], + is_temporary=True, + )) + self.dialogue.put(Message( + role="tool", tool_call_id=da_tc_id, + content="已直接回复", is_temporary=True, + )) + + # 示例2:真实工具调用(handle_exit_intent) if "handle_exit_intent" in tool_names: tc_id = "fewshot_exit_001" self.dialogue.put(Message(role="user", content="拜拜", is_temporary=True)) @@ -556,43 +597,6 @@ class ConnectionHandler: role="assistant", content="再见,下次再聊~", is_temporary=True, )) - if "play_music" in tool_names: - tc_id = "fewshot_music_001" - self.dialogue.put(Message(role="user", content="放首歌", is_temporary=True)) - self.dialogue.put(Message( - role="assistant", - tool_calls=[{ - "id": tc_id, - "function": {"arguments": '{"song_name": "random"}', "name": "play_music"}, - "type": "function", "index": 0, - }], - is_temporary=True, - )) - self.dialogue.put(Message( - role="tool", tool_call_id=tc_id, - content="正在为您播放音乐", is_temporary=True, - )) - self.dialogue.put(Message( - role="assistant", content="好嘞,给你安排上~", is_temporary=True, - )) - - # 负向示例:用户请求普通对话内容时,直接回答,不调用任何工具 - # 帮助弱模型建立"该调才调、不该调不调"的判断能力 - # 注意:示例回复不能包含具体的创作内容(故事、诗歌等), - # 否则弱模型会直接复述示例内容,而无法泛化出正确的行为模式 - self.dialogue.put(Message(role="user", content="给我讲个故事吧", is_temporary=True)) - self.dialogue.put(Message( - role="assistant", - content="好呀,你想听什么类型的呀?童话、冒险还是搞笑的?选一个我给你开讲~", - is_temporary=True, - )) - self.dialogue.put(Message(role="user", content="你知道为什么天空是蓝色的吗", is_temporary=True)) - self.dialogue.put(Message( - role="assistant", - content="天空看起来是蓝色,是因为阳光穿过大气层的时候,蓝色光波长短,被空气分子散射得最厉害,所以我们抬头一看就是满眼蓝色啦。", - is_temporary=True, - )) - self.logger.bind(tag=TAG).debug("已注入工具调用 few-shot 示例") def _init_report_threads(self): @@ -948,6 +952,10 @@ class ConnectionHandler: and not force_final_answer ): functions = self.func_handler.get_functions() + # 仅在第一层调用时注入 direct_answer 虚拟工具 + # 递归调用(depth>0)不注入,避免模型在生成文本回复时再次调 direct_answer 导致循环 + if functions is not None and depth == 0: + functions.append(DIRECT_ANSWER_TOOL) response_message = [] @@ -1006,6 +1014,23 @@ class ConnectionHandler: if tools_call is not None and len(tools_call) > 0: tool_call_flag = True self._merge_tool_calls(tool_calls_list, tools_call) + + # 流式提取 direct_answer 的 response 参数,实时送 TTS + for tc in tool_calls_list: + if tc["name"] == "direct_answer" and tc.get("arguments"): + da_text = self._extract_direct_answer_response(tc["arguments"]) + sent_len = tc.get("_da_sent", 0) + if da_text and len(da_text) > sent_len: + new_part = da_text[sent_len:] + tc["_da_sent"] = len(da_text) + self.tts.tts_text_queue.put( + TTSMessageDTO( + sentence_id=current_sentence_id, + sentence_type=SentenceType.MIDDLE, + content_type=ContentType.TEXT, + content_detail=new_part, + ) + ) else: content = response @@ -1077,6 +1102,35 @@ class ConnectionHandler: f"function call error: {content_arguments}" ) + if not bHasError and len(tool_calls_list) > 0: + # 处理 direct_answer 虚拟工具 + direct_answer_calls = [tc for tc in tool_calls_list if tc["name"] == "direct_answer"] + real_tool_calls = [tc for tc in tool_calls_list if tc["name"] != "direct_answer"] + + if direct_answer_calls: + self.logger.bind(tag=TAG).debug( + f"模型选择 direct_answer,流式已播报,写入对话历史" + ) + for tc in direct_answer_calls: + # 文本已在流式阶段实时播报,这里只写入对话历史 + da_response = self._extract_direct_answer_response(tc.get("arguments", "{}")) + if da_response: + self.tts.store_tts_text(current_sentence_id, da_response) + self.dialogue.put(Message(role="assistant", content=da_response)) + + if not real_tool_calls: + if depth == 0: + self.tts.tts_text_queue.put( + TTSMessageDTO( + sentence_id=current_sentence_id, + sentence_type=SentenceType.LAST, + content_type=ContentType.ACTION, + ) + ) + return + + tool_calls_list = real_tool_calls + if not bHasError and len(tool_calls_list) > 0: self.logger.bind(tag=TAG).debug( f"检测到 {len(tool_calls_list)} 个工具调用" @@ -1493,6 +1547,33 @@ class ConnectionHandler: finally: self.logger.bind(tag=TAG).info("超时检查任务已退出") + @staticmethod + def _extract_direct_answer_response(arguments_str): + """从 direct_answer 的参数中提取 response 值。 + 支持完整的 JSON 和流式中不完整的 JSON 片段。 + """ + if not arguments_str: + return "" + # 找到 "response": " 的位置 + marker = '"response": "' + idx = arguments_str.find(marker) + if idx < 0: + # 尝试不带空格的格式 + marker = '"response":"' + idx = arguments_str.find(marker) + if idx < 0: + return "" + start = idx + len(marker) + raw = arguments_str[start:] + # 去掉末尾的 JSON 闭合符号(如果已完整) + if raw.endswith('"}'): + raw = raw[:-2] + elif raw.endswith('"'): + raw = raw[:-1] + # 处理 JSON 转义 + raw = raw.replace('\\"', '"').replace('\\n', '\n').replace('\\\\', '\\') + return raw + def _merge_tool_calls(self, tool_calls_list, tools_call): """合并工具调用列表 diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py index 1410f773..cf64825b 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py @@ -18,10 +18,9 @@ GET_NEWS_FROM_CHINANEWS_FUNCTION_DESC = { "function": { "name": "get_news_from_chinanews", "description": ( - "获取最新新闻,随机选择一条新闻进行播报。" + "当用户要求查看或收听新闻时调用(如'来条新闻''今天有什么新闻')。" "用户可以指定新闻类型,如社会新闻、科技新闻、国际新闻等。" "如果没有指定,默认播报社会新闻。" - "用户可以要求获取详细内容,此时会获取新闻的详细内容。" ), "parameters": { "type": "object", diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py index 12ec04c9..e32ea3a0 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py @@ -93,18 +93,13 @@ GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = { "type": "function", "function": { "name": "get_news_from_newsnow", - "description": ( - "获取最新新闻,随机选择一条新闻进行播报。" - f"用户可以选择不同的新闻源,标准的名称是:{example_sources_str}" - "例如用户要求百度新闻,其实就是百度热搜。如果没有指定,默认从澎湃新闻获取。" - "用户可以要求获取详细内容,此时会获取新闻的详细内容。" - ), + "description": "当用户要求查看或收听新闻时调用(如'来条新闻''今天有什么新闻')。", "parameters": { "type": "object", "properties": { "source": { "type": "string", - "description": f"新闻源的标准中文名称,例如{example_sources_str}等。可选参数,如果不提供则使用默认新闻源", + "description": "新闻源名称,如'澎湃新闻'、'百度热搜'、'IT之家'等。可选参数", }, "detail": { "type": "boolean", @@ -233,7 +228,7 @@ def get_news_from_newsnow( # f"新闻来源: {source_name}\n" f"详细内容: {detail_content}\n\n" f"(请对上述新闻内容进行总结,提取关键信息,以自然、流畅的方式向用户播报," - f"不要提及这是总结,就像是在讲述一个完整的新闻故事)" + f"不要提及这是总结,就像是在讲述一个完整的新闻)" ) return ActionResponse(Action.REQLLM, detail_report, None) diff --git a/main/xiaozhi-server/plugins_func/functions/play_music.py b/main/xiaozhi-server/plugins_func/functions/play_music.py index 461775f6..66c0bb57 100644 --- a/main/xiaozhi-server/plugins_func/functions/play_music.py +++ b/main/xiaozhi-server/plugins_func/functions/play_music.py @@ -22,7 +22,7 @@ play_music_function_desc = { "type": "function", "function": { "name": "play_music", - "description": "唱歌、听歌、播放音乐的方法。", + "description": "当用户要求播放音乐、歌曲时调用。", "parameters": { "type": "object", "properties": {