feat:优化

This commit is contained in:
2025-12-31 11:10:37 +08:00
parent 8dd1fac04d
commit d7a5358bd8
2 changed files with 161 additions and 50 deletions

View File

@@ -53,7 +53,6 @@ class AIChat(PluginBase):
self._chatroom_member_cache = {} # {chatroom_id: (ts, {wxid: display_name})} self._chatroom_member_cache = {} # {chatroom_id: (ts, {wxid: display_name})}
self._chatroom_member_cache_locks = {} # {chatroom_id: asyncio.Lock} self._chatroom_member_cache_locks = {} # {chatroom_id: asyncio.Lock}
self._chatroom_member_cache_ttl_seconds = 3600 # 群名片缓存1小时减少协议 API 调用 self._chatroom_member_cache_ttl_seconds = 3600 # 群名片缓存1小时减少协议 API 调用
self._intent_cache = {} # {normalized_text: (ts, intent)}
async def async_init(self): async def async_init(self):
"""插件异步初始化""" """插件异步初始化"""
@@ -887,6 +886,8 @@ class AIChat(PluginBase):
# 清洗后为空时,不要回退到包含思维链标记的原文(避免把 <think>... 直接发出去) # 清洗后为空时,不要回退到包含思维链标记的原文(避免把 <think>... 直接发出去)
if strip_thinking and self._contains_thinking_markers(raw_stripped): if strip_thinking and self._contains_thinking_markers(raw_stripped):
return "" return ""
if self._contains_tool_call_markers(raw_stripped):
return ""
return raw_stripped return raw_stripped
def _contains_thinking_markers(self, text: str) -> bool: def _contains_thinking_markers(self, text: str) -> bool:
@@ -946,6 +947,14 @@ class AIChat(PluginBase):
) )
return marker_re.search(text) is not None 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 "<ctrl" in lowered or "展开阅读下文" in text:
return True
return bool(re.search(r"(?i)(tavilywebsearch|tavily_web_search|web_search)\\s*[\\{\\(]", text))
def _extract_after_last_answer_marker(self, text: str) -> str | None: def _extract_after_last_answer_marker(self, text: str) -> str | None:
"""从文本中抽取最后一个“最终/输出/答案”标记后的内容(不要求必须是编号大纲)。""" """从文本中抽取最后一个“最终/输出/答案”标记后的内容(不要求必须是编号大纲)。"""
if not text: if not text:
@@ -1369,6 +1378,15 @@ class AIChat(PluginBase):
return False 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: def _extract_legacy_text_search_tool_call(self, text: str) -> tuple[str, dict] | None:
""" """
解析模型偶发输出的“文本工具调用”写法(例如 tavilywebsearch{query:...}),并转换为真实工具调用参数。 解析模型偶发输出的“文本工具调用”写法(例如 tavilywebsearch{query:...}),并转换为真实工具调用参数。
@@ -1405,6 +1423,27 @@ class AIChat(PluginBase):
return tool_name, {"query": query[:400]} 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]: def _intent_to_allowed_tool_names(self, intent: str) -> set[str]:
intent = str(intent or "").strip().lower() intent = str(intent or "").strip().lower()
mapping = { mapping = {
@@ -1603,51 +1642,8 @@ class AIChat(PluginBase):
return intent return intent
async def _select_tools_for_message_async(self, tools: list, *, user_message: str, tool_query: str | None = None) -> list: async def _select_tools_for_message_async(self, tools: list, *, user_message: str, tool_query: str | None = None) -> list:
""" """工具选择(与旧版一致,仅使用规则筛选)"""
工具选择(支持意图路由): return self._select_tools_for_message(tools, user_message=user_message, tool_query=tool_query)
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
def _select_tools_for_message(self, tools: list, *, user_message: str, tool_query: str | None = None) -> list: def _select_tools_for_message(self, tools: list, *, user_message: str, tool_query: str | None = None) -> list:
tools_config = (self.config or {}).get("tools", {}) tools_config = (self.config or {}).get("tools", {})
@@ -1695,6 +1691,9 @@ class AIChat(PluginBase):
): ):
allow.add("tavily_web_search") allow.add("tavily_web_search")
allow.add("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): if re.search(r"(60秒|每日新闻|早报|新闻图片|读懂世界)", t):
allow.add("get_daily_news") allow.add("get_daily_news")
if re.search(r"(epic|喜加一|免费游戏)", t): 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)}") logger.info(f"流式 API 响应完成, 内容长度: {len(full_content)}, 工具调用数: {len(tool_calls_data)}")
# 检查是否有函数调用 # 检查是否有函数调用
@@ -2861,7 +2917,7 @@ class AIChat(PluginBase):
# 如果有需要 AI 回复的工具结果,调用 AI 继续对话 # 如果有需要 AI 回复的工具结果,调用 AI 继续对话
if need_ai_reply_results: if need_ai_reply_results:
await self._continue_with_tool_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 nickname, is_group, messages, tool_calls_data
) )
@@ -2877,7 +2933,7 @@ class AIChat(PluginBase):
pass pass
async def _continue_with_tool_results(self, tool_results: list, bot, from_wxid: str, 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): messages: list, tool_calls_data: list):
""" """
基于工具结果继续调用 AI 对话(保留上下文和人设) 基于工具结果继续调用 AI 对话(保留上下文和人设)
@@ -2925,9 +2981,17 @@ class AIChat(PluginBase):
"content": tr["result"] "content": tr["result"]
}) })
# 3. 调用 AI 继续对话(不带 tools 参数,避免再次调用工具 # 3. 调用 AI 继续对话(默认不带 tools 参数,歌词搜歌场景允许放开 search_music
api_config = self.config["api"] api_config = self.config["api"]
proxy_config = self.config.get("proxy", {}) 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 = { payload = {
"model": api_config["model"], "model": api_config["model"],
@@ -2935,6 +2999,8 @@ class AIChat(PluginBase):
"max_tokens": api_config.get("max_tokens", 4096), "max_tokens": api_config.get("max_tokens", 4096),
"stream": True "stream": True
} }
if followup_tools:
payload["tools"] = followup_tools
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -2965,6 +3031,7 @@ class AIChat(PluginBase):
# 流式读取响应 # 流式读取响应
full_content = "" full_content = ""
tool_calls_dict = {}
async for line in resp.content: async for line in resp.content:
line = line.decode("utf-8").strip() line = line.decode("utf-8").strip()
if not line or not line.startswith("data: "): if not line or not line.startswith("data: "):
@@ -2978,9 +3045,52 @@ class AIChat(PluginBase):
content = delta.get("content", "") content = delta.get("content", "")
if content: if content:
full_content += 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: except:
continue 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 的回复 # 发送 AI 的回复
if full_content.strip(): if full_content.strip():
cleaned_content = self._sanitize_llm_output(full_content) cleaned_content = self._sanitize_llm_output(full_content)
@@ -3127,7 +3237,7 @@ class AIChat(PluginBase):
if need_ai_reply_results: if need_ai_reply_results:
await self._continue_with_tool_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 nickname, is_group, messages, tool_calls_data
) )

View File

@@ -70,3 +70,4 @@ Gentle expression
3) 发病文学:只有用户明确要“发病文学/发病文/发病语录/来一段发病/整点发病/犯病文学”等才调用 get_fabing。 3) 发病文学:只有用户明确要“发病文学/发病文/发病语录/来一段发病/整点发病/犯病文学”等才调用 get_fabing。
4) 天气/注册城市:一次只处理用户当前提到的那一个城市,不要把历史里出现过的多个城市一起查/一起注册。 4) 天气/注册城市:一次只处理用户当前提到的那一个城市,不要把历史里出现过的多个城市一起查/一起注册。
5) 绝对禁止在正文里输出任何“文本形式工具调用”或控制符例如tavilywebsearch{...}、tavily_web_search{...}、web_search{...}、<ctrl46>、展开阅读下文。 5) 绝对禁止在正文里输出任何“文本形式工具调用”或控制符例如tavilywebsearch{...}、tavily_web_search{...}、web_search{...}、<ctrl46>、展开阅读下文。
6) 歌词找歌:当用户问“这句歌词/台词是哪首歌”时,先联网搜索确认歌名,再调用 search_music 发送音乐。