feat:优化
This commit is contained in:
@@ -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):
|
||||
# 清洗后为空时,不要回退到包含思维链标记的原文(避免把 <think>... 直接发出去)
|
||||
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 "<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:
|
||||
"""从文本中抽取最后一个“最终/输出/答案”标记后的内容(不要求必须是编号大纲)。"""
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -70,3 +70,4 @@ Gentle expression
|
||||
3) 发病文学:只有用户明确要“发病文学/发病文/发病语录/来一段发病/整点发病/犯病文学”等才调用 get_fabing。
|
||||
4) 天气/注册城市:一次只处理用户当前提到的那一个城市,不要把历史里出现过的多个城市一起查/一起注册。
|
||||
5) 绝对禁止在正文里输出任何“文本形式工具调用”或控制符,例如:tavilywebsearch{...}、tavily_web_search{...}、web_search{...}、<ctrl46>、展开阅读下文。
|
||||
6) 歌词找歌:当用户问“这句歌词/台词是哪首歌”时,先联网搜索确认歌名,再调用 search_music 发送音乐。
|
||||
|
||||
Reference in New Issue
Block a user