diff --git a/plugins/AIChat/main.py b/plugins/AIChat/main.py index 3d0bfa2..bd1d9af 100644 --- a/plugins/AIChat/main.py +++ b/plugins/AIChat/main.py @@ -53,7 +53,6 @@ class AIChat(PluginBase): self._chatroom_member_cache = {} # {chatroom_id: (ts, {wxid: display_name})} self._chatroom_member_cache_locks = {} # {chatroom_id: asyncio.Lock} self._chatroom_member_cache_ttl_seconds = 3600 # 群名片缓存1小时,减少协议 API 调用 - self._intent_cache = {} # {normalized_text: (ts, intent)} async def async_init(self): """插件异步初始化""" @@ -887,6 +886,8 @@ class AIChat(PluginBase): # 清洗后为空时,不要回退到包含思维链标记的原文(避免把 ... 直接发出去) if strip_thinking and self._contains_thinking_markers(raw_stripped): return "" + if self._contains_tool_call_markers(raw_stripped): + return "" return raw_stripped def _contains_thinking_markers(self, text: str) -> bool: @@ -946,6 +947,14 @@ class AIChat(PluginBase): ) return marker_re.search(text) is not None + def _contains_tool_call_markers(self, text: str) -> bool: + if not text: + return False + lowered = text.lower() + if " str | None: """从文本中抽取最后一个“最终/输出/答案”标记后的内容(不要求必须是编号大纲)。""" if not text: @@ -1369,6 +1378,15 @@ class AIChat(PluginBase): return False + def _looks_like_lyrics_query(self, text: str) -> bool: + t = str(text or "").strip().lower() + if not t: + return False + return bool(re.search( + r"(歌词|歌名|哪首歌|哪一首歌|哪首歌曲|哪一首歌曲|谁的歌|谁唱|谁唱的|这句.*(歌|歌词)|这段.*(歌|歌词)|是什么歌|什么歌|是哪首歌|出自.*(歌|歌曲)|台词.*歌|lyric|lyrics)", + t, + )) + def _extract_legacy_text_search_tool_call(self, text: str) -> tuple[str, dict] | None: """ 解析模型偶发输出的“文本工具调用”写法(例如 tavilywebsearch{query:...}),并转换为真实工具调用参数。 @@ -1405,6 +1423,27 @@ class AIChat(PluginBase): return tool_name, {"query": query[:400]} + def _should_allow_music_followup(self, messages: list, tool_calls_data: list) -> bool: + if not tool_calls_data: + return False + + has_search_tool = any( + (tc or {}).get("function", {}).get("name", "") in ("tavily_web_search", "web_search") + for tc in (tool_calls_data or []) + ) + if not has_search_tool: + return False + + user_text = "" + for msg in reversed(messages or []): + if msg.get("role") == "user": + user_text = self._extract_text_from_multimodal(msg.get("content")) + break + if not user_text: + return False + + return self._looks_like_lyrics_query(user_text) + def _intent_to_allowed_tool_names(self, intent: str) -> set[str]: intent = str(intent or "").strip().lower() mapping = { @@ -1603,51 +1642,8 @@ class AIChat(PluginBase): return intent async def _select_tools_for_message_async(self, tools: list, *, user_message: str, tool_query: str | None = None) -> list: - """ - 工具选择(支持意图路由): - 1) 先走现有 smart_select 规则(快) - 2) 规则未命中且像信息查询时,走一次轻量 LLM 意图分类(慢但更准) - """ - tools_config = (self.config or {}).get("tools", {}) - if not tools_config.get("smart_select", False): - return tools - - selected = self._select_tools_for_message(tools, user_message=user_message, tool_query=tool_query) - if selected: - return selected - - intent_text = self._extract_tool_intent_text(user_message, tool_query=tool_query) - if not intent_text: - return [] - - if not self._should_run_intent_router(intent_text): - return [] - - intent = await self._classify_intent_with_llm(intent_text) - allow = self._intent_to_allowed_tool_names(intent) - if not allow: - return [] - - # 对“容易误触”的工具再做一次本地硬约束,避免分类器误判导致执行敏感动作 - t = intent_text.lower() - if "register_city" in allow and not re.search(r"(注册|设置|更新|更换|修改|绑定|默认).{0,6}城市|城市.{0,6}(注册|设置|更新|更换|修改|绑定|默认)", t): - allow.discard("register_city") - if "user_signin" in allow and not re.search(r"(用户签到|签到|签个到)", t): - allow.discard("user_signin") - if "check_profile" in allow and not re.search(r"(个人信息|我的信息|我的积分|查积分|积分多少|连续签到|连签|我的资料)", t): - allow.discard("check_profile") - if "get_fabing" in allow and not re.search(r"(发病文学|犯病文学|发病文|犯病文|发病语录|犯病语录|发病一下|犯病一下)", t): - allow.discard("get_fabing") - - if not allow: - return [] - - result = [] - for tool in tools or []: - name = tool.get("function", {}).get("name", "") - if name and name in allow: - result.append(tool) - return result + """工具选择(与旧版一致,仅使用规则筛选)""" + return self._select_tools_for_message(tools, user_message=user_message, tool_query=tool_query) def _select_tools_for_message(self, tools: list, *, user_message: str, tool_query: str | None = None) -> list: tools_config = (self.config or {}).get("tools", {}) @@ -1695,6 +1691,9 @@ class AIChat(PluginBase): ): allow.add("tavily_web_search") allow.add("web_search") + if self._looks_like_lyrics_query(intent_text): + allow.add("tavily_web_search") + allow.add("web_search") if re.search(r"(60秒|每日新闻|早报|新闻图片|读懂世界)", t): allow.add("get_daily_news") if re.search(r"(epic|喜加一|免费游戏)", t): @@ -2556,6 +2555,63 @@ class AIChat(PluginBase): } ] + if not tool_calls_data and allowed_tool_names and full_content: + if self._contains_tool_call_markers(full_content): + fallback_tool = None + if "tavily_web_search" in allowed_tool_names: + fallback_tool = "tavily_web_search" + elif "web_search" in allowed_tool_names: + fallback_tool = "web_search" + + if fallback_tool: + fallback_query = self._extract_tool_intent_text(user_message, tool_query=tool_query) or user_message + fallback_query = str(fallback_query or "").strip() + if fallback_query: + logger.warning(f"检测到文本工具调用但未解析成功,已兜底调用: {fallback_tool}") + try: + if bot and from_wxid: + await bot.send_text(from_wxid, "我帮你查一下,稍等。") + except Exception: + pass + tool_calls_data = [ + { + "id": f"fallback_{uuid.uuid4().hex[:8]}", + "type": "function", + "function": { + "name": fallback_tool, + "arguments": json.dumps({"query": fallback_query[:400]}, ensure_ascii=False), + }, + } + ] + + if not tool_calls_data and allowed_tool_names and self._looks_like_lyrics_query(user_message): + fallback_tool = None + if "tavily_web_search" in allowed_tool_names: + fallback_tool = "tavily_web_search" + elif "web_search" in allowed_tool_names: + fallback_tool = "web_search" + + if fallback_tool: + fallback_query = self._extract_tool_intent_text(user_message, tool_query=tool_query) or user_message + fallback_query = str(fallback_query or "").strip() + if fallback_query: + logger.warning(f"歌词检索未触发工具,已兜底调用: {fallback_tool}") + try: + if bot and from_wxid: + await bot.send_text(from_wxid, "我帮你查一下这句歌词,稍等。") + except Exception: + pass + tool_calls_data = [ + { + "id": f"lyrics_{uuid.uuid4().hex[:8]}", + "type": "function", + "function": { + "name": fallback_tool, + "arguments": json.dumps({"query": fallback_query[:400]}, ensure_ascii=False), + }, + } + ] + logger.info(f"流式 API 响应完成, 内容长度: {len(full_content)}, 工具调用数: {len(tool_calls_data)}") # 检查是否有函数调用 @@ -2861,7 +2917,7 @@ class AIChat(PluginBase): # 如果有需要 AI 回复的工具结果,调用 AI 继续对话 if need_ai_reply_results: await self._continue_with_tool_results( - need_ai_reply_results, bot, from_wxid, chat_id, + need_ai_reply_results, bot, from_wxid, user_wxid, chat_id, nickname, is_group, messages, tool_calls_data ) @@ -2877,7 +2933,7 @@ class AIChat(PluginBase): pass async def _continue_with_tool_results(self, tool_results: list, bot, from_wxid: str, - chat_id: str, nickname: str, is_group: bool, + user_wxid: str, chat_id: str, nickname: str, is_group: bool, messages: list, tool_calls_data: list): """ 基于工具结果继续调用 AI 对话(保留上下文和人设) @@ -2925,9 +2981,17 @@ class AIChat(PluginBase): "content": tr["result"] }) - # 3. 调用 AI 继续对话(不带 tools 参数,避免再次调用工具) + # 3. 调用 AI 继续对话(默认不带 tools 参数,歌词搜歌场景允许放开 search_music) api_config = self.config["api"] proxy_config = self.config.get("proxy", {}) + user_wxid = user_wxid or from_wxid + + followup_tools = [] + if self._should_allow_music_followup(messages, tool_calls_data): + followup_tools = [ + t for t in (self._collect_tools() or []) + if (t.get("function", {}).get("name") == "search_music") + ] payload = { "model": api_config["model"], @@ -2935,6 +2999,8 @@ class AIChat(PluginBase): "max_tokens": api_config.get("max_tokens", 4096), "stream": True } + if followup_tools: + payload["tools"] = followup_tools headers = { "Content-Type": "application/json", @@ -2965,6 +3031,7 @@ class AIChat(PluginBase): # 流式读取响应 full_content = "" + tool_calls_dict = {} async for line in resp.content: line = line.decode("utf-8").strip() if not line or not line.startswith("data: "): @@ -2978,9 +3045,52 @@ class AIChat(PluginBase): content = delta.get("content", "") if content: full_content += content + if delta.get("tool_calls"): + for tool_call_delta in delta["tool_calls"]: + index = tool_call_delta.get("index", 0) + if index not in tool_calls_dict: + tool_calls_dict[index] = { + "id": "", + "type": "function", + "function": { + "name": "", + "arguments": "", + }, + } + if "id" in tool_call_delta: + tool_calls_dict[index]["id"] = tool_call_delta["id"] + if "type" in tool_call_delta: + tool_calls_dict[index]["type"] = tool_call_delta["type"] + if "function" in tool_call_delta: + func_delta = tool_call_delta["function"] + if "name" in func_delta: + tool_calls_dict[index]["function"]["name"] += func_delta["name"] + if "arguments" in func_delta: + tool_calls_dict[index]["function"]["arguments"] += func_delta["arguments"] except: continue + tool_calls_data = [tool_calls_dict[i] for i in sorted(tool_calls_dict.keys())] if tool_calls_dict else [] + if tool_calls_data and followup_tools: + allowed_tool_names = { + t.get("function", {}).get("name", "") + for t in followup_tools + if isinstance(t, dict) and t.get("function", {}).get("name") + } + filtered = [] + for tc in tool_calls_data: + fn = (tc or {}).get("function", {}).get("name", "") + if fn and fn in allowed_tool_names: + filtered.append(tc) + tool_calls_data = filtered + + if tool_calls_data: + await self._execute_tools_async( + tool_calls_data, bot, from_wxid, chat_id, + user_wxid, nickname, is_group, messages + ) + return + # 发送 AI 的回复 if full_content.strip(): cleaned_content = self._sanitize_llm_output(full_content) @@ -3127,7 +3237,7 @@ class AIChat(PluginBase): if need_ai_reply_results: await self._continue_with_tool_results( - need_ai_reply_results, bot, from_wxid, chat_id, + need_ai_reply_results, bot, from_wxid, user_wxid, chat_id, nickname, is_group, messages, tool_calls_data ) diff --git a/plugins/AIChat/prompts/瑞依.txt b/plugins/AIChat/prompts/瑞依.txt index 70fd9b1..563f2f1 100644 --- a/plugins/AIChat/prompts/瑞依.txt +++ b/plugins/AIChat/prompts/瑞依.txt @@ -70,3 +70,4 @@ Gentle expression 3) 发病文学:只有用户明确要“发病文学/发病文/发病语录/来一段发病/整点发病/犯病文学”等才调用 get_fabing。 4) 天气/注册城市:一次只处理用户当前提到的那一个城市,不要把历史里出现过的多个城市一起查/一起注册。 5) 绝对禁止在正文里输出任何“文本形式工具调用”或控制符,例如:tavilywebsearch{...}、tavily_web_search{...}、web_search{...}、、展开阅读下文。 +6) 歌词找歌:当用户问“这句歌词/台词是哪首歌”时,先联网搜索确认歌名,再调用 search_music 发送音乐。