From 9b6173be7648dd71fb3eb60f33cd2267d7b78b18 Mon Sep 17 00:00:00 2001 From: shihao <3127647737@qq.com> Date: Mon, 29 Dec 2025 18:40:24 +0800 Subject: [PATCH] =?UTF-8?q?feta:=E4=BC=98=E5=8C=96AI=E5=A4=84=E7=90=86?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/AIChat/main.py | 461 +++++++++++++++++++++++-- plugins/AIChat/prompts/瑞依 - 副本.txt | 91 ----- plugins/AIChat/prompts/瑞依.txt | 15 +- utils/message_hook.py | 35 +- 4 files changed, 473 insertions(+), 129 deletions(-) delete mode 100644 plugins/AIChat/prompts/瑞依 - 副本.txt diff --git a/plugins/AIChat/main.py b/plugins/AIChat/main.py index e3cd4d0..56f3bb2 100644 --- a/plugins/AIChat/main.py +++ b/plugins/AIChat/main.py @@ -167,6 +167,70 @@ class AIChat(PluginBase): else: return sender_wxid or from_wxid # 私聊使用用户ID + def _get_group_history_chat_id(self, from_wxid: str, user_wxid: str = None) -> str: + """获取群聊 history 的会话ID(可配置为全群共享或按用户隔离)""" + if not from_wxid: + return "" + + history_config = (self.config or {}).get("history", {}) + scope = str(history_config.get("scope", "chatroom") or "chatroom").strip().lower() + if scope in ("per_user", "user", "peruser"): + if not user_wxid: + return from_wxid + return self._get_chat_id(from_wxid, user_wxid, is_group=True) + + return from_wxid + + def _should_capture_group_history(self, *, is_triggered: bool) -> bool: + """判断群聊消息是否需要写入 history(减少无关上下文污染)""" + history_config = (self.config or {}).get("history", {}) + capture = str(history_config.get("capture", "all") or "all").strip().lower() + + if capture in ("none", "off", "disable", "disabled"): + return False + if capture in ("reply", "ai_only", "triggered"): + return bool(is_triggered) + return True + + def _parse_history_timestamp(self, ts) -> float | None: + if ts is None: + return None + if isinstance(ts, (int, float)): + return float(ts) + if isinstance(ts, str): + s = ts.strip() + if not s: + return None + try: + return float(s) + except Exception: + pass + try: + return datetime.fromisoformat(s).timestamp() + except Exception: + return None + return None + + def _filter_history_by_window(self, history: list) -> list: + history_config = (self.config or {}).get("history", {}) + window_seconds = history_config.get("context_window_seconds", None) + if window_seconds is None: + window_seconds = history_config.get("window_seconds", 0) + try: + window_seconds = float(window_seconds or 0) + except Exception: + window_seconds = 0 + if window_seconds <= 0: + return history + + cutoff = time.time() - window_seconds + filtered = [] + for msg in history or []: + ts = self._parse_history_timestamp((msg or {}).get("timestamp")) + if ts is None or ts >= cutoff: + filtered.append(msg) + return filtered + def _sanitize_speaker_name(self, name: str) -> str: """清洗昵称,避免破坏历史格式(如 [name] 前缀)。""" if name is None: @@ -1173,6 +1237,203 @@ class AIChat(PluginBase): "content": f"[{msg_nickname}] {msg_content}" }) + def _get_bot_nickname(self) -> str: + try: + with open("main_config.toml", "rb") as f: + main_config = tomllib.load(f) + nickname = main_config.get("Bot", {}).get("nickname", "") + return nickname or "机器人" + except Exception: + return "机器人" + + def _tool_call_to_action_text(self, function_name: str, arguments: dict) -> str: + args = arguments if isinstance(arguments, dict) else {} + + if function_name == "query_weather": + city = str(args.get("city") or "").strip() + return f"查询{city}天气" if city else "查询天气" + + if function_name == "register_city": + city = str(args.get("city") or "").strip() + return f"注册城市{city}" if city else "注册城市" + + if function_name == "user_signin": + return "签到" + + if function_name == "check_profile": + return "查询个人信息" + + return f"执行{function_name}" + + def _build_tool_calls_context_note(self, tool_calls_data: list) -> str: + actions: list[str] = [] + for tool_call in tool_calls_data or []: + function_name = tool_call.get("function", {}).get("name", "") + if not function_name: + continue + + arguments_str = tool_call.get("function", {}).get("arguments", "{}") + try: + arguments = json.loads(arguments_str) if arguments_str else {} + except Exception: + arguments = {} + + actions.append(self._tool_call_to_action_text(function_name, arguments)) + + if not actions: + return "(已触发工具处理:上一条请求。结果将发送到聊天中。)" + + return f"(已触发工具处理:{';'.join(actions)}。结果将发送到聊天中。)" + + async def _record_tool_calls_to_context( + self, + tool_calls_data: list, + *, + from_wxid: str, + chat_id: str, + is_group: bool, + user_wxid: str | None = None, + ): + note = self._build_tool_calls_context_note(tool_calls_data) + if chat_id: + self._add_to_memory(chat_id, "assistant", note) + + if is_group and from_wxid: + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid or "") + await self._add_to_history(history_chat_id, self._get_bot_nickname(), note, role="assistant", sender_wxid=user_wxid or None) + + def _extract_tool_intent_text(self, user_message: str, tool_query: str | None = None) -> str: + text = tool_query if tool_query is not None else user_message + text = str(text or "").strip() + if not text: + return "" + + # 对“聊天记录/视频”等组合消息,尽量只取用户真实提问部分,避免历史文本触发工具误判 + markers = ( + "[用户的问题]:", + "[用户的问题]:", + "[用户的问题]\n", + "[用户的问题]", + ) + for marker in markers: + if marker in text: + text = text.rsplit(marker, 1)[-1].strip() + return text + + def _select_tools_for_message(self, tools: list, *, user_message: str, tool_query: str | None = None) -> list: + tools_config = (self.config or {}).get("tools", {}) + if not tools_config.get("smart_select", False): + return tools + + intent_text = self._extract_tool_intent_text(user_message, tool_query=tool_query) + if not intent_text: + return tools + + t = intent_text.lower() + allow: set[str] = set() + + # 天气 + if re.search(r"(天气|气温|温度|下雨|下雪|风力|空气质量|pm2\\.?5|湿度|预报)", t): + allow.add("query_weather") + + # 注册/设置城市(避免仅凭城市名触发) + if re.search(r"(注册|设置|更新|更换|修改|绑定|默认).{0,6}城市|城市.{0,6}(注册|设置|更新|更换|修改|绑定|默认)", t): + allow.add("register_city") + + # 签到/个人信息 + if re.search(r"(用户签到|签到|签个到)", t): + allow.add("user_signin") + if re.search(r"(个人信息|我的信息|我的积分|查积分|积分多少|连续签到|连签|我的资料)", t): + allow.add("check_profile") + + # 鹿打卡 + if re.search(r"(鹿打卡|鹿签到)", t): + allow.add("deer_checkin") + if re.search(r"(补签|补打卡)", t): + allow.add("makeup_checkin") + if re.search(r"(鹿.*(日历|月历|打卡日历))|((日历|月历|打卡日历).*鹿)", t): + allow.add("view_calendar") + + # 搜索/资讯 + if re.search(r"(联网|搜索|搜一下|搜一搜|搜搜|帮我搜|搜新闻|搜资料|查资料|查新闻|查价格)", t): + # 兼容旧工具名与当前插件实现 + allow.add("tavily_web_search") + allow.add("web_search") + # 隐式信息检索:用户询问具体实体/口碑/评价但未明确说“搜索/联网” + if re.search(r"(怎么样|如何|评价|口碑|靠谱吗|值不值得|值得吗|好不好|推荐|牛不牛|强不强|厉不厉害|有名吗|什么来头|背景|近况|最新|最近)", t) and re.search( + r"(公会|战队|服务器|区服|游戏|公司|品牌|店|商家|产品|软件|插件|项目|平台|up主|主播|作者|电影|电视剧|小说|手游|网游)", + t, + ): + 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): + allow.add("get_epic_free_games") + + # 音乐/短剧 + if re.search(r"(搜歌|找歌|点歌|来一首|歌名|歌曲|音乐|听.*歌|播放.*歌)", t) or ("歌" in t and re.search(r"(搜|找|点|来一首|播放|听)", t)): + allow.add("search_music") + if re.search(r"(短剧|搜短剧|找短剧)", t): + allow.add("search_playlet") + + # 群聊总结 + if re.search(r"(群聊总结|生成总结|总结一下|今日总结|昨天总结|群总结)", t): + allow.add("generate_summary") + + # 娱乐 + if re.search(r"(疯狂星期四|v我50|kfc)", t): + allow.add("get_kfc") + # 发病文学:必须是明确请求(避免用户口头禅/情绪表达误触工具) + if re.search(r"(发病文学|犯病文学|发病文|犯病文|发病语录|犯病语录)", t): + allow.add("get_fabing") + elif re.search(r"(来|整|给|写|讲|说|发|搞|整点).{0,4}(发病|犯病)", t): + allow.add("get_fabing") + elif re.search(r"(发病|犯病).{0,6}(一下|一段|一条|几句|文学|文|语录|段子)", t): + allow.add("get_fabing") + if re.search(r"(随机图片|来张图|来个图|随机图)", t): + allow.add("get_random_image") + if re.search(r"(随机视频|来个视频|随机短视频)", t): + allow.add("get_random_video") + + # 绘图/视频生成(只在用户明确要求时开放) + if ( + # 明确绘图动词/模式 + re.search(r"(画一张|画一个|画个|画一下|画图|绘图|绘制|作画|出图|生成图片|文生图|图生图|以图生图)", t) + # “生成/做/给我”+“一张/一个/张/个”+“图/图片”类表达(例如:生成一张瑞依/做一张图) + or re.search(r"(生成|做|给我|帮我).{0,4}(一张|一幅|一个|张|个).{0,8}(图|图片|照片)", t) + # “来/发”+“一张/张”+“图/图片”(例如:来张瑞依的图) + or re.search(r"(来|发).{0,2}(一张|一幅|一个|张|个).{0,10}(图|图片|照片)", t) + # 视觉诉求但没说“画”(例如:看看腿/白丝) + or re.search(r"(看看|看下|给我看|让我看看).{0,8}(腿|白丝|黑丝|丝袜|玉足|脚|足|写真|涩图|色图|福利图)", t) + ): + allow.update({ + "nano_ai_image_generation", + "flow2_ai_image_generation", + "jimeng_ai_image_generation", + "kiira2_ai_image_generation", + "generate_image", + }) + if re.search(r"(生成视频|做个视频|视频生成|sora)", t): + allow.add("sora_video_generation") + + # 如果已经命中特定领域工具(天气/音乐/短剧等),且用户未明确表示“联网/网页/链接/来源”等需求,避免把联网搜索也暴露出去造成误触 + explicit_web = bool(re.search(r"(联网|网页|网站|网址|链接|来源)", t)) + if not explicit_web and {"query_weather", "search_music", "search_playlet"} & allow: + allow.discard("tavily_web_search") + allow.discard("web_search") + + # 严格模式:没有明显工具意图时,不向模型暴露任何 tools,避免误触 + if not allow: + return [] + + selected = [] + for tool in tools or []: + name = tool.get("function", {}).get("name", "") + if name and name in allow: + selected.append(tool) + return selected + async def _handle_context_stats(self, bot, from_wxid: str, user_wxid: str, is_group: bool): """处理上下文统计指令""" try: @@ -1190,7 +1451,9 @@ class AIChat(PluginBase): if is_group: # 群聊:使用 history 机制 - history = await self._load_history(from_wxid) + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + history = await self._load_history(history_chat_id) + history = self._filter_history_by_window(history) max_context = self.config.get("history", {}).get("max_context", 50) # 实际会发送给 AI 的上下文 @@ -1393,7 +1656,9 @@ class AIChat(PluginBase): if content == "/记忆状态": if user_wxid in admins: if is_group: - history = await self._load_history(from_wxid) + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + history = await self._load_history(history_chat_id) + history = self._filter_history_by_window(history) max_context = self.config.get("history", {}).get("max_context", 50) context_count = min(len(history), max_context) msg = f"📊 群聊记忆: {len(history)} 条\n" @@ -1479,15 +1744,18 @@ class AIChat(PluginBase): if should_reply: actual_content = self._extract_content(message, content) - # 保存到群组历史记录(所有消息都保存,不管是否回复) + # 保存到群组历史记录(默认全量保存;可配置为仅保存触发 AI 的消息,减少上下文污染/串线) # 但如果是 AutoReply 触发的,跳过保存(消息已经在正常流程中保存过了) if is_group and not message.get('_auto_reply_triggered'): - # mention 模式下,群聊里@机器人仅作为触发条件,不进入上下文,避免同一句话在上下文中出现两种形式(含@/不含@) - trigger_mode = self.config.get("behavior", {}).get("trigger_mode", "mention") - history_content = content - if trigger_mode == "mention" and should_reply and actual_content: - history_content = actual_content - await self._add_to_history(from_wxid, nickname, history_content, sender_wxid=user_wxid) + if self._should_capture_group_history(is_triggered=bool(should_reply)): + # mention 模式下,群聊里@机器人仅作为触发条件,不进入上下文,避免同一句话在上下文中出现两种形式(含@/不含@) + trigger_mode = self.config.get("behavior", {}).get("trigger_mode", "mention") + history_content = content + if trigger_mode == "mention" and should_reply and actual_content: + history_content = actual_content + + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + await self._add_to_history(history_chat_id, nickname, history_content, sender_wxid=user_wxid) # 如果不需要回复,直接返回 if not should_reply: @@ -1517,7 +1785,13 @@ class AIChat(PluginBase): # 群聊:消息已写入 history,则不再重复附加到 LLM messages,避免“同一句话发给AI两次” history_enabled = bool(self.store) and self.config.get("history", {}).get("enabled", True) - append_user_message = not (is_group and history_enabled and not message.get('_auto_reply_triggered')) + captured_to_history = bool( + is_group + and history_enabled + and not message.get('_auto_reply_triggered') + and self._should_capture_group_history(is_triggered=True) + ) + append_user_message = not captured_to_history # 调用 AI API(带重试机制) max_retries = self.config.get("api", {}).get("max_retries", 2) @@ -1569,11 +1843,22 @@ class AIChat(PluginBase): await bot.send_text(from_wxid, cleaned_response) self._add_to_memory(chat_id, "assistant", cleaned_response) # 保存机器人回复到历史记录 - if is_group: + history_config = self.config.get("history", {}) + sync_bot_messages = history_config.get("sync_bot_messages", False) + history_scope = str(history_config.get("scope", "chatroom") or "chatroom").strip().lower() + can_rely_on_hook = bool(sync_bot_messages and history_scope not in ("per_user", "user", "peruser")) + if is_group and not can_rely_on_hook: with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) bot_nickname = main_config.get("Bot", {}).get("nickname", "机器人") - await self._add_to_history(from_wxid, bot_nickname, cleaned_response, role="assistant") + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + await self._add_to_history( + history_chat_id, + bot_nickname, + cleaned_response, + role="assistant", + sender_wxid=user_wxid, + ) logger.success(f"AI 回复成功: {cleaned_response[:50]}...") else: logger.warning("AI 回复清洗后为空(可能只包含思维链/格式标记),已跳过发送") @@ -1673,16 +1958,18 @@ class AIChat(PluginBase): is_group: bool = False, *, append_user_message: bool = True, + tool_query: str | None = None, ) -> str: """调用 AI API""" api_config = self.config["api"] # 收集工具 - tools = self._collect_tools() - logger.info(f"收集到 {len(tools)} 个工具函数") + all_tools = self._collect_tools() + tools = self._select_tools_for_message(all_tools, user_message=user_message, tool_query=tool_query) + logger.info(f"收集到 {len(all_tools)} 个工具函数,本次启用 {len(tools)} 个") if tools: tool_names = [t["function"]["name"] for t in tools] - logger.info(f"工具列表: {tool_names}") + logger.info(f"本次启用工具: {tool_names}") # 构建消息列表 system_content = self.system_prompt @@ -1714,7 +2001,9 @@ class AIChat(PluginBase): # 从 JSON 历史记录加载上下文(仅群聊) if is_group and from_wxid: - history = await self._load_history(from_wxid) + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid or "") + history = await self._load_history(history_chat_id) + history = self._filter_history_by_window(history) max_context = self.config.get("history", {}).get("max_context", 50) # 取最近的 N 条消息作为上下文 @@ -1877,6 +2166,16 @@ class AIChat(PluginBase): if tool_calls_data: # 提示已在流式处理中发送,直接启动异步工具执行 logger.info(f"启动异步工具执行,共 {len(tool_calls_data)} 个工具") + 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 as e: + logger.debug(f"记录工具调用到上下文失败: {e}") asyncio.create_task( self._execute_tools_async( tool_calls_data, bot, from_wxid, chat_id, @@ -2624,12 +2923,20 @@ class AIChat(PluginBase): self._add_to_memory(chat_id, "user", title_text, image_base64=image_base64) # 保存用户引用图片消息到群组历史记录 - if is_group: - await self._add_to_history(from_wxid, nickname, title_text, image_base64=image_base64) + if is_group and self._should_capture_group_history(is_triggered=True): + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + await self._add_to_history( + history_chat_id, + nickname, + title_text, + image_base64=image_base64, + sender_wxid=user_wxid, + ) # 调用AI API(带图片) history_enabled = bool(self.store) and self.config.get("history", {}).get("enabled", True) - append_user_message = not (is_group and history_enabled) + captured_to_history = bool(is_group and history_enabled and self._should_capture_group_history(is_triggered=True)) + append_user_message = not captured_to_history response = await self._call_ai_api_with_image( title_text, image_base64, @@ -2640,6 +2947,7 @@ class AIChat(PluginBase): user_wxid, is_group, append_user_message=append_user_message, + tool_query=title_text, ) if response: @@ -2648,12 +2956,23 @@ class AIChat(PluginBase): await bot.send_text(from_wxid, cleaned_response) self._add_to_memory(chat_id, "assistant", cleaned_response) # 保存机器人回复到历史记录 - if is_group: + history_config = self.config.get("history", {}) + sync_bot_messages = history_config.get("sync_bot_messages", False) + history_scope = str(history_config.get("scope", "chatroom") or "chatroom").strip().lower() + can_rely_on_hook = bool(sync_bot_messages and history_scope not in ("per_user", "user", "peruser")) + if is_group and not can_rely_on_hook: import tomllib with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) bot_nickname = main_config.get("Bot", {}).get("nickname", "机器人") - await self._add_to_history(from_wxid, bot_nickname, cleaned_response, role="assistant") + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + await self._add_to_history( + history_chat_id, + bot_nickname, + cleaned_response, + role="assistant", + sender_wxid=user_wxid, + ) logger.success(f"AI回复成功: {cleaned_response[:50]}...") else: logger.warning("AI 回复清洗后为空,已跳过发送") @@ -2758,11 +3077,26 @@ class AIChat(PluginBase): self._add_to_memory(chat_id, "user", combined_message) # 如果是群聊,添加到历史记录 - if is_group: - await self._add_to_history(from_wxid, nickname, f"[发送了聊天记录] {user_question}") + if is_group and self._should_capture_group_history(is_triggered=True): + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + await self._add_to_history( + history_chat_id, + nickname, + f"[发送了聊天记录] {user_question}", + sender_wxid=user_wxid, + ) # 调用 AI API - response = await self._call_ai_api(combined_message, bot, from_wxid, chat_id, nickname, user_wxid, is_group) + response = await self._call_ai_api( + combined_message, + bot, + from_wxid, + chat_id, + nickname, + user_wxid, + is_group, + tool_query=user_question, + ) if response: cleaned_response = self._sanitize_llm_output(response) @@ -2770,12 +3104,23 @@ class AIChat(PluginBase): await bot.send_text(from_wxid, cleaned_response) self._add_to_memory(chat_id, "assistant", cleaned_response) # 保存机器人回复到历史记录 - if is_group: + history_config = self.config.get("history", {}) + sync_bot_messages = history_config.get("sync_bot_messages", False) + history_scope = str(history_config.get("scope", "chatroom") or "chatroom").strip().lower() + can_rely_on_hook = bool(sync_bot_messages and history_scope not in ("per_user", "user", "peruser")) + if is_group and not can_rely_on_hook: import tomllib with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) bot_nickname = main_config.get("Bot", {}).get("nickname", "机器人") - await self._add_to_history(from_wxid, bot_nickname, cleaned_response, role="assistant") + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + await self._add_to_history( + history_chat_id, + bot_nickname, + cleaned_response, + role="assistant", + sender_wxid=user_wxid, + ) logger.success(f"[聊天记录] AI 回复成功: {cleaned_response[:50]}...") else: logger.warning("[聊天记录] AI 回复清洗后为空,已跳过发送") @@ -2848,11 +3193,26 @@ class AIChat(PluginBase): self._add_to_memory(chat_id, "user", combined_message) # 如果是群聊,添加到历史记录 - if is_group: - await self._add_to_history(from_wxid, nickname, f"[发送了一个视频] {user_question}") + if is_group and self._should_capture_group_history(is_triggered=True): + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + await self._add_to_history( + history_chat_id, + nickname, + f"[发送了一个视频] {user_question}", + sender_wxid=user_wxid, + ) # 调用主AI生成回复(使用现有的 _call_ai_api 方法,继承完整上下文) - response = await self._call_ai_api(combined_message, bot, from_wxid, chat_id, nickname, user_wxid, is_group) + response = await self._call_ai_api( + combined_message, + bot, + from_wxid, + chat_id, + nickname, + user_wxid, + is_group, + tool_query=user_question, + ) if response: cleaned_response = self._sanitize_llm_output(response) @@ -2860,12 +3220,23 @@ class AIChat(PluginBase): await bot.send_text(from_wxid, cleaned_response) self._add_to_memory(chat_id, "assistant", cleaned_response) # 保存机器人回复到历史记录 - if is_group: + history_config = self.config.get("history", {}) + sync_bot_messages = history_config.get("sync_bot_messages", False) + history_scope = str(history_config.get("scope", "chatroom") or "chatroom").strip().lower() + can_rely_on_hook = bool(sync_bot_messages and history_scope not in ("per_user", "user", "peruser")) + if is_group and not can_rely_on_hook: import tomllib with open("main_config.toml", "rb") as f: main_config = tomllib.load(f) bot_nickname = main_config.get("Bot", {}).get("nickname", "机器人") - await self._add_to_history(from_wxid, bot_nickname, cleaned_response, role="assistant") + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid) + await self._add_to_history( + history_chat_id, + bot_nickname, + cleaned_response, + role="assistant", + sender_wxid=user_wxid, + ) logger.success(f"[视频识别] 主AI回复成功: {cleaned_response[:50]}...") else: logger.warning("[视频识别] 主AI回复清洗后为空,已跳过发送") @@ -3132,7 +3503,9 @@ class AIChat(PluginBase): history_context = "" if is_group and from_wxid: # 群聊:从 Redis/文件加载历史 - history = await self._load_history(from_wxid) + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid or "") + history = await self._load_history(history_chat_id) + history = self._filter_history_by_window(history) max_context = self.config.get("history", {}).get("max_context", 50) recent_history = history[-max_context:] if len(history) > max_context else history @@ -3412,10 +3785,16 @@ class AIChat(PluginBase): is_group: bool = False, *, append_user_message: bool = True, + tool_query: str | None = None, ) -> str: """调用AI API(带图片)""" api_config = self.config["api"] - 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) + logger.info(f"[图片] 收集到 {len(all_tools)} 个工具函数,本次启用 {len(tools)} 个") + if tools: + tool_names = [t["function"]["name"] for t in tools] + logger.info(f"[图片] 本次启用工具: {tool_names}") # 构建消息列表 system_content = self.system_prompt @@ -3446,7 +3825,9 @@ class AIChat(PluginBase): # 添加历史上下文 if is_group and from_wxid: - history = await self._load_history(from_wxid) + history_chat_id = self._get_group_history_chat_id(from_wxid, user_wxid or "") + history = await self._load_history(history_chat_id) + history = self._filter_history_by_window(history) max_context = self.config.get("history", {}).get("max_context", 50) recent_history = history[-max_context:] if len(history) > max_context else history self._append_group_history_messages(messages, recent_history) @@ -3596,13 +3977,23 @@ class AIChat(PluginBase): if tool_calls_data: # 提示已在流式处理中发送,直接启动异步工具执行 logger.info(f"[图片] 启动异步工具执行,共 {len(tool_calls_data)} 个工具") + 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 as e: + logger.debug(f"[图片] 记录工具调用到上下文失败: {e}") 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 "" + return None # 检查是否包含错误的工具调用格式 if "" in full_content or "print(" in full_content and "flow2_ai_image_generation" in full_content: diff --git a/plugins/AIChat/prompts/瑞依 - 副本.txt b/plugins/AIChat/prompts/瑞依 - 副本.txt deleted file mode 100644 index 7bdcdfc..0000000 --- a/plugins/AIChat/prompts/瑞依 - 副本.txt +++ /dev/null @@ -1,91 +0,0 @@ -# 角色设定:瑞依(猫娘) - -你是一只猫娘,你的名字叫 **<瑞依>**。瑞依的性格 **天真可爱**。 - -## 聊天记录 -### 你会看见群聊历史聊天记录,其中"nickname": "瑞依"是你自己,格式例如: -文字: -```json - { - "nickname": "义乌打包王👑", - "content": "新领导认字", - "timestamp": "2025-11-19T12:52:01.279292" - }, -``` -图片: -```json: - { - "nickname": "鹏鹏", - "timestamp": "2025-11-20T09:44:28.605840", - "content": "[图片: 该图片展示了一个以黑色纯色为背景的动画风格人物的半身像。\n\n**整体场景和背景:**\n背景是纯黑色,没有其他可见的物体或环境细节。光线似乎从人物的左上方(观察者视角)投射过来,导致人物的右侧(观察者视角)略显阴影。整体光线偏暗,但足以看清人物的细节。由于缺乏背景信息,无法判断具体地点、时间或氛围,但人物的动画风格暗示这可能是一个数字图像或游戏截图。\n\n**画面构图:**\n画面中心偏左是唯一的人物。人物占据了画面垂直方向的大部分,从头部到腰部以上可见。人物的头部位于画面上方中央,面部朝向观察者略偏右。左臂(观察者视角)抬起,手放在头部后方。\n\n**人物特征、姿势和动作:**\n* **外观特征:**\n * **大致年龄:** 无法精确判断,但其面部特征和体型倾向于年轻成年女性。\n * **性别:** 女性。\n * **体型:** 较为纤细。\n * **肤色:** 浅肉色,略带灰调,呈现出动画人物的特点,皮肤光滑,没有可见的纹理或细节。\n * **发型:** 头发是浅蓝色或蓝灰色,梳成一个高髻,位于头顶后部。发丝光滑,没有明显的层次感。前额没有刘海,发际线清晰可见。\n * **服装:** 人物穿着一件无袖的深蓝色和青蓝色渐变上衣。上衣的领子部分呈高耸的立领设计,颜色为深蓝色,材质看起来比较厚实。胸部以下部分颜色逐渐变为青蓝色。肩部设计独特,似乎有向外延伸的尖角或结构。左肩(观察者视角)的衣服细节可见。\n* **表情:** 人物的眉毛微微上扬并向内收拢,眼睛朝向右上方(观察者视角),目光似乎带着一丝不解、沉思或略显烦躁的神情。嘴巴紧闭,唇形清晰,没有明显的笑容或悲伤。\n* **姿势和具体动作:** 人物站立,身体略微向左倾斜。左臂(观察者视角)向上抬起,弯曲,手掌托住头部的左后侧(观察者视角),手指伸展开。右臂(观察者视角)自然垂下,小臂和手腕部分被身体遮挡,但可见其一部分肩膀和上臂。\n\n**重要物体与细节:**\n* **人物头部:** 头部轮廓清晰,呈现出动画的低多边形或扁平化风格。眼睛呈深灰色杏仁状,眉毛细长,向上挑起。鼻子小巧,鼻尖略尖。嘴唇较薄,呈粉色。\n* **服装细节:** 上衣的深蓝色立领部分在肩部形成独特的结构,颜色均匀。身体部分的渐变色从深蓝色过渡到青蓝色,过渡平滑。\n* **手部:** 左手(观察者视角)可见,手指细长,肤色与面部相同。\n* **阴影:** 人物面部、颈部和右臂(观察者视角)的阴影描绘了光源的方向。\n\n**画面中的全部文字:**\n画面中没有可见的文字、标语、招牌、屏幕内容或图标。]" - }, -``` - -## 基本形象特征 - -* 1girl, solo -* Full body, standing pose -* Looking at viewer, smiling -* Long white hair, blue eyes -* Cat ears -* Black ribbon hair accessories -* Cute dress, frilled sleeves -* Elegant lolita-inspired outfit -* Dark frilled skirt -* Soft lighting, fantasy background -* Detailed hair -* Anime style, high quality illustration -* Gentle expression - -## 绘图规则 - -* **当且仅当对话进入某个全新场景时**、用户说:看看腿、看看白丝时,或者用户要求你绘图时,你将调用绘图函数绘制对应画面。 -* 在绘制以 **<瑞依>** 为主角的图像时,务必保持她的基本特征。 -* 禁止强调你是 AI、机器人,你就是一只真正的猫娘。 -* 当需要绘画时,调用 `jimeng_ai_image_generation` 进行绘图。 - ---- - -## 支持的工具函数 - -### 1. **SignIn 插件 - 签到功能** - -* `user_signin`:用户签到并获取积分奖励 -* `check_profile`:查看个人信息(积分、连续签到天数等) -* `register_city`:注册或更新用户城市信息 - -### 2. **DeerCheckin 插件 - 鹿打卡功能** - -* `deer_checkin`:鹿打卡,可记录今天的鹿数量(支持指定数量) -* `view_calendar`:查看本月鹿打卡日历 -* `makeup_checkin`:补签指定日期的鹿打卡记录 - -### 3. **ChatRoomSummary 插件 - 群聊总结功能** - -* `generate_summary`:生成群聊总结(支持今日/昨日选择) - -### 4. **PlayletSearch 插件 - 短剧搜索功能** - -* `search_playlet`:搜索短剧并获取视频链接 - -### 5. **Grok_video 插件 - 视频生成功能** - -* `generate_video`:生成视频 - -### 6. **Weather 插件 - 天气查询功能** - -* `query_weather`:查询天气预报信息 - - **何时使用**:当用户询问天气、气温、会不会下雨、天气怎么样等天气相关问题时,**立即调用此函数** - - **参数说明**: - - `city`(可选):城市名称。如果用户明确指定了城市(如"北京天气"),则传入城市名;如果用户没有指定城市(如"今天天气怎么样"),则不传此参数,系统会自动使用用户设置的默认城市 - - **使用示例**: - - 用户:"帮我查下天气" → 调用 `query_weather()` 不传参数 - - 用户:"北京今天会下雨吗" → 调用 `query_weather(city="北京")` - - 用户:"今天气温多少度" → 调用 `query_weather()` 不传参数 - - **重要**:不要询问用户城市,直接调用函数即可,函数会自动处理 - -### 7. **RandomVideo 插件 - 随机视频功能** -* `get_random_video()`:随机视频 - -### 8. **RandomImage 插件 - 随机图片功能** -* `get_random_image`:随机图片 \ No newline at end of file diff --git a/plugins/AIChat/prompts/瑞依.txt b/plugins/AIChat/prompts/瑞依.txt index e66cebb..76fad7d 100644 --- a/plugins/AIChat/prompts/瑞依.txt +++ b/plugins/AIChat/prompts/瑞依.txt @@ -43,6 +43,8 @@ Gentle expression 绘图规则: 仅当用户明确要求绘图,或提出“看看腿/看看白丝/画一张”等明确绘图诉求时,才调用绘图函数绘制对应画面。 在绘制以<瑞依>为主角的图像时,务必保持她的基本特征。 +也属于明确绘图诉求的常见说法: “生成一张/出一张/来一张/发一张/给我一张……图/图片/照片”、“来张……的图”、“文生图/图生图”。 +如果用户只说“来张图/发张图”但没有说明要随机图还是要你生成/绘制,先追问一句确认,再决定调用哪个工具。 重要:工具调用方式 你拥有 Function Calling 能力,可以直接调用工具函数。 @@ -54,5 +56,16 @@ Gentle expression 不要只调用工具而不说话。 重要:谨慎调用工具 -只有当用户明确请求某个功能时才调用对应工具。 +除联网搜索外,只有当用户明确请求某个功能时才调用对应工具。 日常聊天、打招呼、闲聊时不要调用任何工具,直接用文字回复即可。 +不要因为历史消息里出现过关键词就调用工具,只以“当前用户这句话”的明确意图为准。 +用户只提到城市名/地点名时,不要自动查询天气,也不要自动注册城市;除非用户明确说“查天气/注册城市/设置城市/联网搜索/搜歌/短剧/新闻/签到/个人信息”等。 + +重要:联网搜索(web_search/tavily_web_search)可主动使用 +当用户询问某个具体实体/事件的客观信息、口碑评价、背景资料、最新动态(例如某游戏/公会/公司/品牌/插件/项目/人物等),如果你不确定或需要最新信息,可以直接调用 web_search/tavily_web_search 查证;不需要用户明确说“搜索/联网”。 +如果明显属于纯主观闲聊、常识问题或你有把握的内容,就不要搜索,直接回答。 + +重要:get_fabing(发病文学)严格触发 +只有当用户明确要求“来一段/来几句/整点 发病文学/发病文/发病语录/发病一下”,并且明确要对谁发病(对象名字)时,才调用 get_fabing(name=对象)。 +用户只是情绪表达或口头禅(例如“我发病了/你发病吧/别发病/我快疯了/我犯病了”)时,绝对不要调用 get_fabing,直接用文字回应即可。 +如果用户说“整活/发疯”但没有明确要发病文学,先追问一句确认,不要直接调用工具。 diff --git a/utils/message_hook.py b/utils/message_hook.py index a037bc1..896324d 100644 --- a/utils/message_hook.py +++ b/utils/message_hook.py @@ -19,7 +19,7 @@ async def log_bot_message(to_wxid: str, content: str, msg_type: str = "text", me """ try: logger.info(f"message_hook: 开始记录机器人消息") - + # 动态导入避免循环依赖 from plugins.MessageLogger.main import MessageLogger logger.info(f"message_hook: MessageLogger 导入成功") @@ -34,6 +34,37 @@ async def log_bot_message(to_wxid: str, content: str, msg_type: str = "text", me logger.info(f"message_hook: save_bot_message 调用完成") else: logger.warning("MessageLogger 实例未找到,跳过消息记录") + + # 同步写入 AIChat 群聊 history(避免其他插件的机器人回复缺失,导致上下文混乱/工具误触) + try: + if to_wxid and to_wxid.endswith("@chatroom"): + from utils.plugin_manager import PluginManager + + aichat = PluginManager().plugins.get("AIChat") + store = getattr(aichat, "store", None) if aichat else None + aichat_config = getattr(aichat, "config", None) if aichat else None + + if store and aichat_config and aichat_config.get("history", {}).get("sync_bot_messages", False): + bot_nickname = "机器人" + bot_wxid = "" + try: + import tomllib + with open("main_config.toml", "rb") as f: + main_config = tomllib.load(f) + bot_nickname = main_config.get("Bot", {}).get("nickname") or bot_nickname + bot_wxid = main_config.get("Bot", {}).get("wxid") or "" + except Exception: + pass + + await store.add_group_message( + to_wxid, + bot_nickname, + content, + role="assistant", + sender_wxid=bot_wxid or None, + ) + except Exception as e: + logger.debug(f"message_hook: 同步 AIChat 群聊 history 失败: {e}") except Exception as e: logger.error(f"记录机器人消息失败: {e}") @@ -85,4 +116,4 @@ def create_file_message_hook(original_method, msg_type: str): return result - return wrapper \ No newline at end of file + return wrapper