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 = {} # {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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 发送音乐。
|
||||||
|
|||||||
Reference in New Issue
Block a user