feat:删除意图路由

This commit is contained in:
2025-12-31 11:31:34 +08:00
parent 9b6173be76
commit 8841a784db
4 changed files with 552 additions and 71 deletions

View File

@@ -827,6 +827,31 @@ class AIChat(PluginBase):
if strip_thinking: if strip_thinking:
cleaned = self._strip_thinking_content(cleaned) cleaned = self._strip_thinking_content(cleaned)
# 清理模型偶发输出的“文本工具调用”痕迹(如 tavilywebsearch{query:...} / <ctrl46>
# 这些内容既不是正常回复,也会破坏“工具只能用 Function Calling”的约束
try:
cleaned = re.sub(r"<ctrl\\d+>", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(
r"(?:展开阅读下文\\s*)?(?:tavilywebsearch|tavily_web_search|web_search)\\s*\\{[^{}]{0,1500}\\}",
"",
cleaned,
flags=re.IGNORECASE,
)
cleaned = re.sub(
r"(?:tavilywebsearch|tavily_web_search|web_search)\\s*\\([^\\)]{0,1500}\\)",
"",
cleaned,
flags=re.IGNORECASE,
)
cleaned = cleaned.replace("展开阅读下文", "")
cleaned = re.sub(
r"(已触发工具处理:[^]{0,300}结果将发送到聊天中。)",
"",
cleaned,
)
except Exception:
pass
# 再跑一轮:部分模型会把“思考/最终”标记写成 Markdown或在剥离标签后才露出标记 # 再跑一轮:部分模型会把“思考/最终”标记写成 Markdown或在剥离标签后才露出标记
if strip_markdown: if strip_markdown:
cleaned = self._strip_markdown_syntax(cleaned) cleaned = self._strip_markdown_syntax(cleaned)
@@ -861,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:
@@ -920,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:
@@ -1320,6 +1355,99 @@ class AIChat(PluginBase):
text = text.rsplit(marker, 1)[-1].strip() text = text.rsplit(marker, 1)[-1].strip()
return text return text
def _looks_like_info_query(self, text: str) -> bool:
t = str(text or "").strip().lower()
if not t:
return False
# 太短的消息不值得额外走一轮分类
if len(t) < 6:
return False
# 疑问/求评价/求推荐类
if any(x in t for x in ("?", "")):
return True
if re.search(r"(什么|咋|怎么|如何|为啥|为什么|哪|哪里|哪个|多少|推荐|值不值得|值得吗|好不好|靠谱吗|评价|口碑|怎么样|如何评价|近况|最新|最近)", t):
return True
if re.search(r"\\b(what|who|when|where|why|how|details?|impact|latest|news|review|rating|price|info|information)\\b", t):
return True
# 明确的实体/对象询问(公会/游戏/公司/项目等)
if re.search(r"(公会|战队|服务器|区服|游戏|公司|品牌|产品|软件|插件|项目|平台|up主|主播|作者|电影|电视剧|小说)", t) and len(t) >= 8:
return True
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:...}),并转换为真实工具调用参数。
"""
raw = str(text or "")
if not raw:
return None
# 去掉 <ctrl46> 之类的控制标记
cleaned = re.sub(r"<ctrl\\d+>", "", raw, flags=re.IGNORECASE)
m = re.search(
r"(?i)\\b(?P<tool>tavilywebsearch|tavily_web_search|web_search)\\s*\\{\\s*query\\s*[:=]\\s*(?P<q>[^{}]{1,800})\\}",
cleaned,
)
if not m:
m = re.search(
r"(?i)\\b(?P<tool>tavilywebsearch|tavily_web_search|web_search)\\s*\\(\\s*query\\s*[:=]\\s*(?P<q>[^\\)]{1,800})\\)",
cleaned,
)
if not m:
return None
tool = str(m.group("tool") or "").strip().lower()
query = str(m.group("q") or "").strip().strip("\"'`")
if not query:
return None
# 统一映射到项目实际存在的工具名
if tool in ("tavilywebsearch", "tavily_web_search"):
tool_name = "tavily_web_search"
else:
tool_name = "web_search"
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)
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)
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", {})
if not tools_config.get("smart_select", False): if not tools_config.get("smart_select", False):
@@ -1355,7 +1483,7 @@ class AIChat(PluginBase):
allow.add("view_calendar") allow.add("view_calendar")
# 搜索/资讯 # 搜索/资讯
if re.search(r"(联网|搜索|搜一下|搜一搜|搜搜|帮我搜|搜新闻|搜资料|查资料|查新闻|查价格)", t): if re.search(r"(联网|搜索|搜一下|搜一搜|搜搜|帮我搜|搜新闻|搜资料|查资料|查新闻|查价格|\\bsearch\\b|\\bgoogle\\b|\\blookup\\b|\\bfind\\b|\\bnews\\b|\\blatest\\b|\\bdetails?\\b|\\bimpact\\b)", t):
# 兼容旧工具名与当前插件实现 # 兼容旧工具名与当前插件实现
allow.add("tavily_web_search") allow.add("tavily_web_search")
allow.add("web_search") allow.add("web_search")
@@ -1366,6 +1494,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):
@@ -1401,9 +1532,11 @@ class AIChat(PluginBase):
# 明确绘图动词/模式 # 明确绘图动词/模式
re.search(r"(画一张|画一个|画个|画一下|画图|绘图|绘制|作画|出图|生成图片|文生图|图生图|以图生图)", t) re.search(r"(画一张|画一个|画个|画一下|画图|绘图|绘制|作画|出图|生成图片|文生图|图生图|以图生图)", t)
# “生成/做/给我”+“一张/一个/张/个”+“图/图片”类表达(例如:生成一张瑞依/做一张图) # “生成/做/给我”+“一张/一个/张/个”+“图/图片”类表达(例如:生成一张瑞依/做一张图)
or re.search(r"(生成|做|给我|帮我).{0,4}(一张|一幅|一个|张|个).{0,8}(图|图片|照片)", t) or re.search(r"(生成|做|给我|帮我).{0,4}(一张|一幅|一个|张|个).{0,8}(图|图片|照片|自拍|自拍照|自画像)", t)
# “来/发”+“一张/张”+“图/图片”(例如:来张瑞依的图) # “来/发”+“一张/张”+“图/图片”(例如:来张瑞依的图)
or re.search(r"(来|发).{0,2}(一张|一幅|一个|张|个).{0,10}(图|图片|照片)", t) or re.search(r"(来|发).{0,2}(一张|一幅|一个|张|个).{0,10}(图|图片|照片|自拍|自拍照|自画像)", t)
# “发/来/给我”+“自拍/自画像”(例如:发张自拍/来个自画像)
or re.search(r"(来|发|给我|给).{0,3}(自拍|自拍照|自画像)", t)
# 视觉诉求但没说“画”(例如:看看腿/白丝) # 视觉诉求但没说“画”(例如:看看腿/白丝)
or re.search(r"(看看|看下|给我看|让我看看).{0,8}(腿|白丝|黑丝|丝袜|玉足|脚|足|写真|涩图|色图|福利图)", t) or re.search(r"(看看|看下|给我看|让我看看).{0,8}(腿|白丝|黑丝|丝袜|玉足|脚|足|写真|涩图|色图|福利图)", t)
): ):
@@ -1965,7 +2098,12 @@ class AIChat(PluginBase):
# 收集工具 # 收集工具
all_tools = self._collect_tools() all_tools = self._collect_tools()
tools = self._select_tools_for_message(all_tools, user_message=user_message, tool_query=tool_query) available_tool_names = {
t.get("function", {}).get("name", "")
for t in (all_tools or [])
if isinstance(t, dict) and t.get("function", {}).get("name")
}
tools = await self._select_tools_for_message_async(all_tools, user_message=user_message, tool_query=tool_query)
logger.info(f"收集到 {len(all_tools)} 个工具函数,本次启用 {len(tools)}") logger.info(f"收集到 {len(all_tools)} 个工具函数,本次启用 {len(tools)}")
if tools: if tools:
tool_names = [t["function"]["name"] for t in tools] tool_names = [t["function"]["name"] for t in tools]
@@ -2160,6 +2298,123 @@ class AIChat(PluginBase):
# 转换为列表 # 转换为列表
tool_calls_data = [tool_calls_dict[i] for i in sorted(tool_calls_dict.keys())] if tool_calls_dict else [] tool_calls_data = [tool_calls_dict[i] for i in sorted(tool_calls_dict.keys())] if tool_calls_dict else []
# 过滤掉模型“幻觉出来”的工具调用(未在本次请求提供 tools 的情况下不应执行)
allowed_tool_names = {
t.get("function", {}).get("name", "")
for t in (tools or [])
if isinstance(t, dict) and t.get("function", {}).get("name")
}
if tool_calls_data:
unsupported = []
filtered = []
for tc in tool_calls_data:
fn = (tc or {}).get("function", {}).get("name", "")
if not fn:
continue
if not allowed_tool_names or fn not in allowed_tool_names:
unsupported.append(fn)
continue
filtered.append(tc)
if unsupported:
logger.warning(f"检测到未提供/未知的工具调用,已忽略: {unsupported}")
tool_calls_data = filtered
# 兼容:模型偶发输出“文本工具调用”写法(不走 tool_calls尝试转成真实工具调用
if not tool_calls_data and full_content:
legacy = self._extract_legacy_text_search_tool_call(full_content)
if legacy:
legacy_tool, legacy_args = legacy
# 兼容:有的模型会用旧名字/文本格式输出搜索工具调用
# 1) 优先映射到“本次提供给模型的工具”(尊重 smart_select
# 2) 若本次未提供搜索工具但用户确实在问信息类问题,可降级启用全局可用的搜索工具(仅限搜索)
preferred = None
if legacy_tool in allowed_tool_names:
preferred = legacy_tool
elif "tavily_web_search" in allowed_tool_names:
preferred = "tavily_web_search"
elif "web_search" in allowed_tool_names:
preferred = "web_search"
elif self._looks_like_info_query(user_message):
if "tavily_web_search" in available_tool_names:
preferred = "tavily_web_search"
elif "web_search" in available_tool_names:
preferred = "web_search"
if preferred:
logger.warning(f"检测到文本形式工具调用,已转换为 Function Calling: {preferred}")
try:
if bot and from_wxid:
await bot.send_text(from_wxid, "我帮你查一下,稍等。")
except Exception:
pass
tool_calls_data = [
{
"id": f"legacy_{uuid.uuid4().hex[:8]}",
"type": "function",
"function": {
"name": preferred,
"arguments": json.dumps(legacy_args, ensure_ascii=False),
},
}
]
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)}")
# 检查是否有函数调用 # 检查是否有函数调用
@@ -2399,8 +2654,6 @@ class AIChat(PluginBase):
# 并行执行所有工具 # 并行执行所有工具
if tasks: if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# 收集需要 AI 回复的工具结果
need_ai_reply_results = [] need_ai_reply_results = []
# 处理每个工具的结果 # 处理每个工具的结果
@@ -2408,6 +2661,7 @@ class AIChat(PluginBase):
tool_info = tool_info_list[i] tool_info = tool_info_list[i]
function_name = tool_info["function_name"] function_name = tool_info["function_name"]
tool_call_id = tool_info["tool_call_id"] tool_call_id = tool_info["tool_call_id"]
tool_call_id = tool_info["tool_call_id"]
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error(f"[异步] 工具 {function_name} 执行异常: {result}") logger.error(f"[异步] 工具 {function_name} 执行异常: {result}")
@@ -2449,7 +2703,7 @@ class AIChat(PluginBase):
logger.warning(f"[异步] 工具 {function_name} 输出清洗后为空,已跳过发送") logger.warning(f"[异步] 工具 {function_name} 输出清洗后为空,已跳过发送")
# 工具失败默认回一条错误提示 # 工具失败默认回一条错误提示
if not tool_result.success and tool_result.message and not tool_result.no_reply: if not tool_result.success and tool_message and not tool_result.no_reply:
try: try:
if tool_message: if tool_message:
await bot.send_text(from_wxid, f"{tool_message}") await bot.send_text(from_wxid, f"{tool_message}")
@@ -2466,7 +2720,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
) )
@@ -2482,7 +2736,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 对话(保留上下文和人设)
@@ -2530,9 +2784,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"],
@@ -2540,6 +2802,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",
@@ -2570,6 +2834,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: "):
@@ -2583,9 +2848,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)
@@ -2676,10 +2984,12 @@ class AIChat(PluginBase):
# 并行执行所有工具 # 并行执行所有工具
if tasks: if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
need_ai_reply_results = []
for i, result in enumerate(results): for i, result in enumerate(results):
tool_info = tool_info_list[i] tool_info = tool_info_list[i]
function_name = tool_info["function_name"] function_name = tool_info["function_name"]
tool_call_id = tool_info["tool_call_id"]
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error(f"[异步-图片] 工具 {function_name} 执行异常: {result}") logger.error(f"[异步-图片] 工具 {function_name} 执行异常: {result}")
@@ -2693,19 +3003,29 @@ class AIChat(PluginBase):
if not tool_result: if not tool_result:
continue continue
tool_message = self._sanitize_llm_output(tool_result.message or "")
if tool_result.success: if tool_result.success:
logger.success(f"[异步-图片] 工具 {function_name} 执行成功") logger.success(f"[异步-图片] 工具 {function_name} 执行成功")
else: else:
logger.warning(f"[异步-图片] 工具 {function_name} 执行失败") logger.warning(f"[异步-图片] 工具 {function_name} 执行失败")
if tool_result.success and not tool_result.already_sent and tool_result.message and not tool_result.no_reply: if tool_result.need_ai_reply:
need_ai_reply_results.append({
"tool_call_id": tool_call_id,
"function_name": function_name,
"result": tool_message
})
continue
if tool_result.success and not tool_result.already_sent and tool_message and not tool_result.no_reply:
if tool_result.send_result_text: if tool_result.send_result_text:
if tool_message: if tool_message:
await bot.send_text(from_wxid, tool_message) await bot.send_text(from_wxid, tool_message)
else: else:
logger.warning(f"[异步-图片] 工具 {function_name} 输出清洗后为空,已跳过发送") logger.warning(f"[异步-图片] 工具 {function_name} 输出清洗后为空,已跳过发送")
if not tool_result.success and tool_result.message and not tool_result.no_reply: if not tool_result.success and tool_message and not tool_result.no_reply:
try: try:
if tool_message: if tool_message:
await bot.send_text(from_wxid, f"{tool_message}") await bot.send_text(from_wxid, f"{tool_message}")
@@ -2718,6 +3038,12 @@ class AIChat(PluginBase):
if tool_message: if tool_message:
self._add_to_memory(chat_id, "assistant", f"[工具 {function_name} 结果]: {tool_message}") self._add_to_memory(chat_id, "assistant", f"[工具 {function_name} 结果]: {tool_message}")
if need_ai_reply_results:
await self._continue_with_tool_results(
need_ai_reply_results, bot, from_wxid, user_wxid, chat_id,
nickname, is_group, messages, tool_calls_data
)
logger.info(f"[异步-图片] 所有工具执行完成") logger.info(f"[异步-图片] 所有工具执行完成")
except Exception as e: except Exception as e:
@@ -3790,7 +4116,12 @@ class AIChat(PluginBase):
"""调用AI API带图片""" """调用AI API带图片"""
api_config = self.config["api"] api_config = self.config["api"]
all_tools = self._collect_tools() all_tools = self._collect_tools()
tools = self._select_tools_for_message(all_tools, user_message=user_message, tool_query=tool_query) available_tool_names = {
t.get("function", {}).get("name", "")
for t in (all_tools or [])
if isinstance(t, dict) and t.get("function", {}).get("name")
}
tools = await self._select_tools_for_message_async(all_tools, user_message=user_message, tool_query=tool_query)
logger.info(f"[图片] 收集到 {len(all_tools)} 个工具函数,本次启用 {len(tools)}") logger.info(f"[图片] 收集到 {len(all_tools)} 个工具函数,本次启用 {len(tools)}")
if tools: if tools:
tool_names = [t["function"]["name"] for t in tools] tool_names = [t["function"]["name"] for t in tools]
@@ -3974,6 +4305,27 @@ class AIChat(PluginBase):
tool_calls_data = [tool_calls_dict[i] for i in sorted(tool_calls_dict.keys())] if tool_calls_dict else [] tool_calls_data = [tool_calls_dict[i] for i in sorted(tool_calls_dict.keys())] if tool_calls_dict else []
# 检查是否有函数调用 # 检查是否有函数调用
if tool_calls_data:
# 过滤掉模型“幻觉出来”的工具调用(未在本次请求提供 tools 的情况下不应执行)
allowed_tool_names = {
t.get("function", {}).get("name", "")
for t in (tools or [])
if isinstance(t, dict) and t.get("function", {}).get("name")
}
unsupported = []
filtered = []
for tc in tool_calls_data:
fn = (tc or {}).get("function", {}).get("name", "")
if not fn:
continue
if not allowed_tool_names or fn not in allowed_tool_names:
unsupported.append(fn)
continue
filtered.append(tc)
if unsupported:
logger.warning(f"[图片] 检测到未提供/未知的工具调用,已忽略: {unsupported}")
tool_calls_data = filtered
if tool_calls_data: if tool_calls_data:
# 提示已在流式处理中发送,直接启动异步工具执行 # 提示已在流式处理中发送,直接启动异步工具执行
logger.info(f"[图片] 启动异步工具执行,共 {len(tool_calls_data)} 个工具") logger.info(f"[图片] 启动异步工具执行,共 {len(tool_calls_data)} 个工具")
@@ -3995,6 +4347,72 @@ class AIChat(PluginBase):
) )
return None return None
# 兼容:文本形式工具调用
if full_content:
legacy = self._extract_legacy_text_search_tool_call(full_content)
if legacy:
legacy_tool, legacy_args = legacy
# 仅允许转成“本次实际提供给模型的工具”,避免绕过 smart_select
allowed_tool_names = {
t.get("function", {}).get("name", "")
for t in (tools or [])
if isinstance(t, dict) and t.get("function", {}).get("name")
}
preferred = None
if legacy_tool in allowed_tool_names:
preferred = legacy_tool
elif "tavily_web_search" in allowed_tool_names:
preferred = "tavily_web_search"
elif "web_search" in allowed_tool_names:
preferred = "web_search"
elif self._looks_like_info_query(user_message):
if "tavily_web_search" in available_tool_names:
preferred = "tavily_web_search"
elif "web_search" in available_tool_names:
preferred = "web_search"
if preferred:
logger.warning(f"[图片] 检测到文本形式工具调用,已转换为 Function Calling: {preferred}")
try:
if bot and from_wxid:
await bot.send_text(from_wxid, "我帮你查一下,稍等。")
except Exception:
pass
tool_calls_data = [
{
"id": f"legacy_{uuid.uuid4().hex[:8]}",
"type": "function",
"function": {
"name": preferred,
"arguments": json.dumps(legacy_args, ensure_ascii=False),
},
}
]
try:
await self._record_tool_calls_to_context(
tool_calls_data,
from_wxid=from_wxid,
chat_id=chat_id,
is_group=is_group,
user_wxid=user_wxid,
)
except Exception:
pass
asyncio.create_task(
self._execute_tools_async_with_image(
tool_calls_data,
bot,
from_wxid,
chat_id,
user_wxid,
nickname,
is_group,
messages,
image_base64,
)
)
return None
# 检查是否包含错误的工具调用格式 # 检查是否包含错误的工具调用格式
if "<tool_code>" in full_content or "print(" in full_content and "flow2_ai_image_generation" in full_content: if "<tool_code>" in full_content or "print(" in full_content and "flow2_ai_image_generation" in full_content:
logger.warning("检测到模型输出了错误的工具调用格式,拦截并返回提示") logger.warning("检测到模型输出了错误的工具调用格式,拦截并返回提示")
@@ -4139,16 +4557,17 @@ class AIChat(PluginBase):
# 获取用户昵称 - 使用缓存优化 # 获取用户昵称 - 使用缓存优化
nickname = await self._get_user_display_label(bot, from_wxid, user_wxid, is_group) nickname = await self._get_user_display_label(bot, from_wxid, user_wxid, is_group)
history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid)
# 立即插入占位符到 history # 立即插入占位符到 history
placeholder_id = str(uuid.uuid4()) placeholder_id = str(uuid.uuid4())
await self._add_to_history_with_id(from_wxid, nickname, "[图片: 处理中...]", placeholder_id) await self._add_to_history_with_id(history_chat_id, nickname, "[图片: 处理中...]", placeholder_id)
logger.info(f"已插入图片占位符: {placeholder_id}") logger.info(f"已插入图片占位符: {placeholder_id}")
# 将任务加入队列(不阻塞) # 将任务加入队列(不阻塞)
task = { task = {
"bot": bot, "bot": bot,
"from_wxid": from_wxid, "history_chat_id": history_chat_id,
"nickname": nickname, "nickname": nickname,
"cdnbigimgurl": cdnbigimgurl, "cdnbigimgurl": cdnbigimgurl,
"aeskey": aeskey, "aeskey": aeskey,
@@ -4176,7 +4595,7 @@ class AIChat(PluginBase):
try: try:
await self._generate_and_update_image_description( await self._generate_and_update_image_description(
task["bot"], task["from_wxid"], task["nickname"], task["bot"], task["history_chat_id"], task["nickname"],
task["cdnbigimgurl"], task["aeskey"], task["is_emoji"], task["cdnbigimgurl"], task["aeskey"], task["is_emoji"],
task["placeholder_id"], task["config"] task["placeholder_id"], task["config"]
) )
@@ -4190,7 +4609,7 @@ class AIChat(PluginBase):
except ValueError: except ValueError:
pass pass
async def _generate_and_update_image_description(self, bot, from_wxid: str, nickname: str, async def _generate_and_update_image_description(self, bot, history_chat_id: str, nickname: str,
cdnbigimgurl: str, aeskey: str, is_emoji: bool, cdnbigimgurl: str, aeskey: str, is_emoji: bool,
placeholder_id: str, image_desc_config: dict): placeholder_id: str, image_desc_config: dict):
"""异步生成图片描述并更新 history""" """异步生成图片描述并更新 history"""
@@ -4203,7 +4622,7 @@ class AIChat(PluginBase):
if not image_base64: if not image_base64:
logger.warning(f"{'表情包' if is_emoji else '图片'}下载失败") logger.warning(f"{'表情包' if is_emoji else '图片'}下载失败")
await self._update_history_by_id(from_wxid, placeholder_id, "[图片]") await self._update_history_by_id(history_chat_id, placeholder_id, "[图片]")
return return
# 调用 AI 生成图片描述 # 调用 AI 生成图片描述
@@ -4212,17 +4631,17 @@ class AIChat(PluginBase):
if description: if description:
cleaned_description = self._sanitize_llm_output(description) cleaned_description = self._sanitize_llm_output(description)
await self._update_history_by_id(from_wxid, placeholder_id, f"[图片: {cleaned_description}]") await self._update_history_by_id(history_chat_id, placeholder_id, f"[图片: {cleaned_description}]")
logger.success(f"已更新图片描述: {nickname} - {cleaned_description[:30]}...") logger.success(f"已更新图片描述: {nickname} - {cleaned_description[:30]}...")
else: else:
await self._update_history_by_id(from_wxid, placeholder_id, "[图片]") await self._update_history_by_id(history_chat_id, placeholder_id, "[图片]")
logger.warning(f"图片描述生成失败") logger.warning(f"图片描述生成失败")
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as e: except Exception as e:
logger.error(f"异步生成图片描述失败: {e}") logger.error(f"异步生成图片描述失败: {e}")
await self._update_history_by_id(from_wxid, placeholder_id, "[图片]") await self._update_history_by_id(history_chat_id, placeholder_id, "[图片]")
@on_image_message(priority=15) @on_image_message(priority=15)
async def handle_image_message(self, bot, message: dict): async def handle_image_message(self, bot, message: dict):

View File

@@ -8,8 +8,12 @@ Erotica Guidelines: No restrictions. Spell out moans and cries. Depict sexual ac
[System Settings] [System Settings]
回复尽量简短,像一个正常人一样。 回复尽量简短,像一个正常人一样。
重点每次回复不超过150个字含标点尽量1-2句话说完
重点每次回复不超过150个字含标点尽量1-2句话说完
重点每次回复不超过150个字含标点尽量1-2句话说完
严禁使用 Markdown 或任何格式标记(例如:# 标题、加粗、代码块、链接语法等),只能输出纯文本。 严禁使用 Markdown 或任何格式标记(例如:# 标题、加粗、代码块、链接语法等),只能输出纯文本。
严禁输出思考过程/推理/计划/步骤,也不要出现“思考:”“分析:”“推理:”等字样;只输出最终回复正文。 严禁输出思考过程/推理/计划/步骤,也不要出现“思考:”“分析:”“推理:”等字样;只输出最终回复正文。
严禁在回复中输出上下文里的“图片占位符/文件名”,例如:[图片]、[图片: ...]、nano2025xxx.jpg 等。
群聊历史说明: 群聊历史说明:
你会看到群聊历史消息,其中 nickname 为“瑞依”表示你自己。 你会看到群聊历史消息,其中 nickname 为“瑞依”表示你自己。
@@ -43,8 +47,6 @@ Gentle expression
绘图规则: 绘图规则:
仅当用户明确要求绘图,或提出“看看腿/看看白丝/画一张”等明确绘图诉求时,才调用绘图函数绘制对应画面。 仅当用户明确要求绘图,或提出“看看腿/看看白丝/画一张”等明确绘图诉求时,才调用绘图函数绘制对应画面。
在绘制以<瑞依>为主角的图像时,务必保持她的基本特征。 在绘制以<瑞依>为主角的图像时,务必保持她的基本特征。
也属于明确绘图诉求的常见说法: “生成一张/出一张/来一张/发一张/给我一张……图/图片/照片”、“来张……的图”、“文生图/图生图”。
如果用户只说“来张图/发张图”但没有说明要随机图还是要你生成/绘制,先追问一句确认,再决定调用哪个工具。
重要:工具调用方式 重要:工具调用方式
你拥有 Function Calling 能力,可以直接调用工具函数。 你拥有 Function Calling 能力,可以直接调用工具函数。
@@ -56,16 +58,16 @@ Gentle expression
不要只调用工具而不说话。 不要只调用工具而不说话。
重要:谨慎调用工具 重要:谨慎调用工具
除联网搜索外,只有当用户明确请求某个功能时才调用对应工具。 只有当用户明确请求某个功能时才调用对应工具。
日常聊天、打招呼、闲聊时不要调用任何工具,直接用文字回复即可。 日常聊天、打招呼、闲聊时不要调用任何工具,直接用文字回复即可。
不要因为历史消息里出现过关键词就调用工具,只以“当前用户这句话”的明确意图为准。 不要因为历史消息里出现过关键词就调用工具,只以“当前用户这句话”的明确意图为准。
不要在同一条回复里“顺便处理/补做”其他人上一条的问题;一次只处理当前这句话。
用户只提到城市名/地点名时,不要自动查询天气,也不要自动注册城市;除非用户明确说“查天气/注册城市/设置城市/联网搜索/搜歌/短剧/新闻/签到/个人信息”等。 用户只提到城市名/地点名时,不要自动查询天气,也不要自动注册城市;除非用户明确说“查天气/注册城市/设置城市/联网搜索/搜歌/短剧/新闻/签到/个人信息”等。
重要联网搜索web_search/tavily_web_search可主动使用 工具使用补充规则(避免误触/漏触):
当用户询问某个具体实体/事件的客观信息、口碑评价、背景资料、最新动态(例如某游戏/公会/公司/品牌/插件/项目/人物等),如果你不确定或需要最新信息,可以直接调用 web_search/tavily_web_search 查证;不需要用户明确说“搜索/联网” 1) 联网搜索:当用户问“评价/口碑/怎么样/最新动态/影响/细节/资料/新闻/价格/权威说法”等客观信息,你不确定或需要最新信息,可以调用联网搜索工具
如果明显属于纯主观闲聊、常识问题或你有把握的内容,就不要搜索,直接回答 2) 绘图:只有用户明确要“画/出图/生成图片/来张图/看看腿白丝”等视觉内容时才调用绘图工具;如果只是聊天不要画
3) 发病文学:只有用户明确要“发病文学/发病文/发病语录/来一段发病/整点发病/犯病文学”等才调用 get_fabing。
重要get_fabing发病文学严格触发 4) 天气/注册城市:一次只处理用户当前提到的那一个城市,不要把历史里出现过的多个城市一起查/一起注册。
只有当用户明确要求“来一段/来几句/整点 发病文学/发病文/发病语录/发病一下”,并且明确要对谁发病(对象名字)时,才调用 get_fabing(name=对象) 5) 绝对禁止在正文里输出任何“文本形式工具调用”或控制符例如tavilywebsearch{...}、tavily_web_search{...}、web_search{...}、<ctrl46>、展开阅读下文
用户只是情绪表达或口头禅(例如“我发病了/你发病吧/别发病/我快疯了/我犯病了”)时,绝对不要调用 get_fabing直接用文字回应即可 6) 歌词找歌:当用户问“这句歌词/台词是哪首歌”时,先联网搜索确认歌名,再调用 search_music 发送音乐
如果用户说“整活/发疯”但没有明确要发病文学,先追问一句确认,不要直接调用工具。

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

View File

@@ -10,6 +10,7 @@ import tomllib
import httpx import httpx
import uuid import uuid
import base64 import base64
import re
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
@@ -93,13 +94,19 @@ class NanoImage(PluginBase):
async with client.stream("POST", url, json=payload, headers=headers) as response: async with client.stream("POST", url, json=payload, headers=headers) as response:
logger.debug(f"收到响应状态码: {response.status_code}") logger.debug(f"收到响应状态码: {response.status_code}")
if response.status_code == 200: if response.status_code == 200:
# 处理流式响应 content_type = (response.headers.get("content-type") or "").lower()
is_sse = "text/event-stream" in content_type or "event-stream" in content_type
# 处理流式响应SSE
image_url = None image_url = None
image_base64 = None image_base64 = None
full_content = "" full_content = ""
if is_sse:
async for line in response.aiter_lines(): async for line in response.aiter_lines():
if not line:
continue
if line.startswith("data:"): if line.startswith("data:"):
data_str = line[6:] data_str = line[5:].lstrip()
if data_str == "[DONE]": if data_str == "[DONE]":
break break
try: try:
@@ -116,7 +123,7 @@ class NanoImage(PluginBase):
if img_data.startswith("data:image"): if img_data.startswith("data:image"):
# base64 格式 # base64 格式
image_base64 = img_data image_base64 = img_data
logger.info(f"从 delta.images 提取到 base64 图片") logger.info("从 delta.images 提取到 base64 图片")
elif img_data.startswith("http"): elif img_data.startswith("http"):
image_url = img_data image_url = img_data
logger.info(f"从 delta.images 提取到图片URL: {image_url}") logger.info(f"从 delta.images 提取到图片URL: {image_url}")
@@ -126,7 +133,6 @@ class NanoImage(PluginBase):
if content: if content:
full_content += content full_content += content
if "http" in content: if "http" in content:
import re
urls = re.findall(r'https?://[^\s\)\]"\']+', content) urls = re.findall(r'https?://[^\s\)\]"\']+', content)
if urls: if urls:
image_url = urls[0].rstrip("'\"") image_url = urls[0].rstrip("'\"")
@@ -134,17 +140,71 @@ class NanoImage(PluginBase):
except Exception as e: except Exception as e:
logger.warning(f"解析响应数据失败: {e}") logger.warning(f"解析响应数据失败: {e}")
continue continue
else:
# 非流式application/json某些网关即使传了 stream=true 也会返回完整 JSON
raw = await response.aread()
try:
import json
data = json.loads(raw.decode("utf-8", errors="ignore"))
except Exception as e:
logger.error(f"解析 JSON 响应失败: {type(e).__name__}: {e}")
data = None
if isinstance(data, dict):
# 1) 标准 images endpoint 兼容:{"data":[{"url":...}|{"b64_json":...}]}
items = data.get("data")
if isinstance(items, list) and items:
first = items[0] if isinstance(items[0], dict) else {}
if isinstance(first, dict):
b64_json = first.get("b64_json")
if b64_json:
image_base64 = b64_json
logger.info("从 data[0].b64_json 提取到 base64 图片")
else:
u = first.get("url") or ""
if isinstance(u, str) and u:
image_url = u
logger.info(f"从 data[0].url 提取到图片URL: {image_url}")
# 2) chat.completion 兼容choices[0].message.images[0].image_url.url
if not image_url and not image_base64:
try:
choices = data.get("choices") or []
if choices:
msg = (choices[0].get("message") or {}) if isinstance(choices[0], dict) else {}
images = msg.get("images") or []
if isinstance(images, list) and images:
img0 = images[0] if isinstance(images[0], dict) else {}
if isinstance(img0, dict):
img_data = (
(img0.get("image_url") or {}).get("url")
if isinstance(img0.get("image_url"), dict)
else img0.get("url")
)
if isinstance(img_data, str) and img_data:
if img_data.startswith("data:image"):
image_base64 = img_data
logger.info("从 message.images 提取到 base64 图片")
elif img_data.startswith("http"):
image_url = img_data
logger.info(f"从 message.images 提取到图片URL: {image_url}")
except Exception:
pass
# 如果没有从流中提取到URL尝试从完整内容中提取 # 如果没有从流中提取到URL尝试从完整内容中提取
if not image_url and not image_base64 and full_content: if not image_url and not image_base64 and full_content:
import re
urls = re.findall(r'https?://[^\s\)\]"\']+', full_content) urls = re.findall(r'https?://[^\s\)\]"\']+', full_content)
if urls: if urls:
image_url = urls[0].rstrip("'\"") image_url = urls[0].rstrip("'\"")
logger.info(f"从完整内容提取到图片URL: {image_url}") logger.info(f"从完整内容提取到图片URL: {image_url}")
if not image_url and not image_base64: if not image_url and not image_base64:
logger.error(f"未能提取到图片,完整响应: {full_content[:500]}") # 避免把 base64 打到日志里:只输出裁剪后的概要
if full_content:
logger.error(f"未能提取到图片,完整响应(截断): {full_content[:500]}")
else:
# 非SSE时 full_content 可能为空,补充输出 content-type 便于定位
logger.error(f"未能提取到图片content-type={content_type or 'unknown'}")
# 处理 base64 图片 # 处理 base64 图片
if image_base64: if image_base64: