From fe3684736a026fc83eb281bb836ec895157b598e Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Sun, 9 Mar 2025 10:27:54 +0800 Subject: [PATCH 001/162] =?UTF-8?q?feat:=20=E8=B6=85=E5=A4=A7=E5=9E=8B?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 6 +- src/plugins/chat/cq_code.py | 278 +++++++-------- src/plugins/chat/emoji_manager.py | 104 ++++-- src/plugins/chat/llm_generator.py | 2 +- src/plugins/chat/message.py | 469 ++++++++++++++++--------- src/plugins/chat/message_base.py | 158 +++++++++ src/plugins/chat/message_cq.py | 188 ++++++++++ src/plugins/chat/message_sender.py | 2 +- src/plugins/chat/storage.py | 2 +- src/plugins/chat/utils.py | 23 +- src/plugins/chat/utils_image.py | 543 ++++++++++++++++++----------- 11 files changed, 1209 insertions(+), 566 deletions(-) create mode 100644 src/plugins/chat/message_base.py create mode 100644 src/plugins/chat/message_cq.py diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index a02c4a059..c9eea3ed9 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -7,10 +7,10 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent from ..memory_system.memory import hippocampus from ..moods.moods import MoodManager # 导入情绪管理器 from .config import global_config -from .cq_code import CQCode # 导入CQCode模块 +from .cq_code import CQCode,cq_code_tool # 导入CQCode模块 from .emoji_manager import emoji_manager # 导入表情包管理器 from .llm_generator import ResponseGenerator -from .message import ( +from .message_cq import ( Message, Message_Sending, Message_Thinking, # 导入 Message_Thinking 类 @@ -180,7 +180,7 @@ class ChatBot: if emoji_raw != None: emoji_path,discription = emoji_raw - emoji_cq = CQCode.create_emoji_cq(emoji_path) + emoji_cq = cq_code_tool.create_emoji_cq(emoji_path) if random() < 0.5: bot_response_time = tinking_time_point - 1 diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index 4a295e3d5..e908219b5 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -3,7 +3,7 @@ import html import os import time from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict, Optional, List, Union import requests @@ -12,12 +12,14 @@ import requests import urllib3 from nonebot import get_driver from urllib3.util import create_urllib3_context +from loguru import logger from ..models.utils_model import LLM_request from .config import global_config from .mapper import emojimapper -from .utils_image import storage_emoji, storage_image +from .utils_image import image_manager from .utils_user import get_user_nickname +from .message_base import Seg driver = get_driver() config = driver.config @@ -48,16 +50,15 @@ class CQCode: type: CQ码类型(如'image', 'at', 'face'等) params: CQ码的参数字典 raw_code: 原始CQ码字符串 - translated_plain_text: 经过处理(如AI翻译)后的文本表示 + translated_segments: 经过处理后的Seg对象列表 """ type: str params: Dict[str, str] - # raw_code: str group_id: int user_id: int group_name: str = "" user_nickname: str = "" - translated_plain_text: Optional[str] = None + translated_segments: Optional[Union[Seg, List[Seg]]] = None reply_message: Dict = None # 存储回复消息 image_base64: Optional[str] = None _llm: Optional[LLM_request] = None @@ -66,31 +67,72 @@ class CQCode: """初始化LLM实例""" self._llm = LLM_request(model=global_config.vlm, temperature=0.4, max_tokens=300) - async def translate(self): - """根据CQ码类型进行相应的翻译处理""" + def translate(self): + """根据CQ码类型进行相应的翻译处理,转换为Seg对象""" if self.type == 'text': - self.translated_plain_text = self.params.get('text', '') + self.translated_segments = Seg( + type='text', + data=self.params.get('text', '') + ) elif self.type == 'image': - if self.params.get('sub_type') == '0': - self.translated_plain_text = await self.translate_image() + base64_data = self.translate_image() + if base64_data: + if self.params.get('sub_type') == '0': + self.translated_segments = Seg( + type='image', + data=base64_data + ) + else: + self.translated_segments = Seg( + type='emoji', + data=base64_data + ) else: - self.translated_plain_text = await self.translate_emoji() + self.translated_segments = Seg( + type='text', + data='[图片]' + ) elif self.type == 'at': user_nickname = get_user_nickname(self.params.get('qq', '')) - if user_nickname: - self.translated_plain_text = f"[@{user_nickname}]" - else: - self.translated_plain_text = "@某人" + self.translated_segments = Seg( + type='text', + data=f"[@{user_nickname or '某人'}]" + ) elif self.type == 'reply': - self.translated_plain_text = await self.translate_reply() + reply_segments = self.translate_reply() + if reply_segments: + self.translated_segments = Seg( + type='seglist', + data=reply_segments + ) + else: + self.translated_segments = Seg( + type='text', + data='[回复某人消息]' + ) elif self.type == 'face': face_id = self.params.get('id', '') - # self.translated_plain_text = f"[表情{face_id}]" - self.translated_plain_text = f"[{emojimapper.get(int(face_id), '表情')}]" + self.translated_segments = Seg( + type='text', + data=f"[{emojimapper.get(int(face_id), '表情')}]" + ) elif self.type == 'forward': - self.translated_plain_text = await self.translate_forward() + forward_segments = self.translate_forward() + if forward_segments: + self.translated_segments = Seg( + type='seglist', + data=forward_segments + ) + else: + self.translated_segments = Seg( + type='text', + data='[转发消息]' + ) else: - self.translated_plain_text = f"[{self.type}]" + self.translated_segments = Seg( + type='text', + data=f"[{self.type}]" + ) def get_img(self): ''' @@ -160,155 +202,101 @@ class CQCode: return None - async def translate_emoji(self) -> str: - """处理表情包类型的CQ码""" + + def translate_image(self) -> Optional[str]: + """处理图片类型的CQ码,返回base64字符串""" if 'url' not in self.params: - return '[表情包]' - base64_str = self.get_img() - if base64_str: - # 将 base64 字符串转换为字节类型 - image_bytes = base64.b64decode(base64_str) - storage_emoji(image_bytes) - return await self.get_emoji_description(base64_str) - else: - return '[表情包]' + return None + return self.get_img() - async def translate_image(self) -> str: - """处理图片类型的CQ码,区分普通图片和表情包""" - # 没有url,直接返回默认文本 - if 'url' not in self.params: - return '[图片]' - base64_str = self.get_img() - if base64_str: - image_bytes = base64.b64decode(base64_str) - storage_image(image_bytes) - return await self.get_image_description(base64_str) - else: - return '[图片]' - - async def get_emoji_description(self, image_base64: str) -> str: - """调用AI接口获取表情包描述""" - try: - prompt = "这是一个表情包,请用简短的中文描述这个表情包传达的情感和含义。最多20个字。" - # description, _ = self._llm.generate_response_for_image_sync(prompt, image_base64) - description, _ = await self._llm.generate_response_for_image(prompt, image_base64) - return f"[表情包:{description}]" - except Exception as e: - print(f"\033[1;31m[错误]\033[0m AI接口调用失败: {str(e)}") - return "[表情包]" - - async def get_image_description(self, image_base64: str) -> str: - """调用AI接口获取普通图片描述""" - try: - prompt = "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" - # description, _ = self._llm.generate_response_for_image_sync(prompt, image_base64) - description, _ = await self._llm.generate_response_for_image(prompt, image_base64) - return f"[图片:{description}]" - except Exception as e: - print(f"\033[1;31m[错误]\033[0m AI接口调用失败: {str(e)}") - return "[图片]" - - async def translate_forward(self) -> str: - """处理转发消息""" + def translate_forward(self) -> Optional[List[Seg]]: + """处理转发消息,返回Seg列表""" try: if 'content' not in self.params: - return '[转发消息]' + return None - # 解析content内容(需要先反转义) content = self.unescape(self.params['content']) - # print(f"\033[1;34m[调试信息]\033[0m 转发消息内容: {content}") - # 将字符串形式的列表转换为Python对象 import ast try: messages = ast.literal_eval(content) except ValueError as e: - print(f"\033[1;31m[错误]\033[0m 解析转发消息内容失败: {str(e)}") - return '[转发消息]' + logger.error(f"解析转发消息内容失败: {str(e)}") + return None - # 处理每条消息 - formatted_messages = [] + formatted_segments = [] for msg in messages: sender = msg.get('sender', {}) nickname = sender.get('card') or sender.get('nickname', '未知用户') - - # 获取消息内容并使用Message类处理 raw_message = msg.get('raw_message', '') message_array = msg.get('message', []) if message_array and isinstance(message_array, list): - # 检查是否包含嵌套的转发消息 for message_part in message_array: if message_part.get('type') == 'forward': - content = '[转发消息]' + content_seg = Seg(type='text', data='[转发消息]') break - else: - # 处理普通消息 - if raw_message: - from .message import Message - message_obj = Message( - user_id=msg.get('user_id', 0), - message_id=msg.get('message_id', 0), - raw_message=raw_message, - plain_text=raw_message, - group_id=msg.get('group_id', 0) - ) - await message_obj.initialize() - content = message_obj.processed_plain_text else: - content = '[空消息]' + if raw_message: + from .message_cq import MessageRecvCQ + message_obj = MessageRecvCQ( + user_id=msg.get('user_id', 0), + message_id=msg.get('message_id', 0), + raw_message=raw_message, + plain_text=raw_message, + group_id=msg.get('group_id', 0) + ) + content_seg = Seg(type='seglist', data=message_obj.message_segments) + else: + content_seg = Seg(type='text', data='[空消息]') else: - # 处理普通消息 if raw_message: - from .message import Message - message_obj = Message( + from .message_cq import MessageRecvCQ + message_obj = MessageRecvCQ( user_id=msg.get('user_id', 0), message_id=msg.get('message_id', 0), raw_message=raw_message, plain_text=raw_message, group_id=msg.get('group_id', 0) ) - await message_obj.initialize() - content = message_obj.processed_plain_text + content_seg = Seg(type='seglist', data=message_obj.message_segments) else: - content = '[空消息]' + content_seg = Seg(type='text', data='[空消息]') - formatted_msg = f"{nickname}: {content}" - formatted_messages.append(formatted_msg) + formatted_segments.append(Seg(type='text', data=f"{nickname}: ")) + formatted_segments.append(content_seg) + formatted_segments.append(Seg(type='text', data='\n')) - # 合并所有消息 - combined_messages = '\n'.join(formatted_messages) - print(f"\033[1;34m[调试信息]\033[0m 合并后的转发消息: {combined_messages}") - return f"[转发消息:\n{combined_messages}]" + return formatted_segments except Exception as e: - print(f"\033[1;31m[错误]\033[0m 处理转发消息失败: {str(e)}") - return '[转发消息]' + logger.error(f"处理转发消息失败: {str(e)}") + return None - async def translate_reply(self) -> str: - """处理回复类型的CQ码""" - - # 创建Message对象 - from .message import Message - if self.reply_message == None: - # print(f"\033[1;31m[错误]\033[0m 回复消息为空") - return '[回复某人消息]' + def translate_reply(self) -> Optional[List[Seg]]: + """处理回复类型的CQ码,返回Seg列表""" + from .message_cq import MessageRecvCQ + if self.reply_message is None: + return None if self.reply_message.sender.user_id: - message_obj = Message( + message_obj = MessageRecvCQ( user_id=self.reply_message.sender.user_id, message_id=self.reply_message.message_id, raw_message=str(self.reply_message.message), group_id=self.group_id ) - await message_obj.initialize() + + segments = [] if message_obj.user_id == global_config.BOT_QQ: - return f"[回复 {global_config.BOT_NICKNAME} 的消息: {message_obj.processed_plain_text}]" + segments.append(Seg(type='text', data=f"[回复 {global_config.BOT_NICKNAME} 的消息: ")) else: - return f"[回复 {self.reply_message.sender.nickname} 的消息: {message_obj.processed_plain_text}]" - + segments.append(Seg(type='text', data=f"[回复 {self.reply_message.sender.nickname} 的消息: ")) + + segments.append(Seg(type='seglist', data=message_obj.message_segments)) + segments.append(Seg(type='text', data="]")) + return segments else: - print("\033[1;31m[错误]\033[0m 回复消息的sender.user_id为空") - return '[回复某人消息]' + return None @staticmethod def unescape(text: str) -> str: @@ -318,29 +306,12 @@ class CQCode: .replace(']', ']') \ .replace('&', '&') - @staticmethod - def create_emoji_cq(file_path: str) -> str: - """ - 创建表情包CQ码 - Args: - file_path: 本地表情包文件路径 - Returns: - 表情包CQ码字符串 - """ - # 确保使用绝对路径 - abs_path = os.path.abspath(file_path) - # 转义特殊字符 - escaped_path = abs_path.replace('&', '&') \ - .replace('[', '[') \ - .replace(']', ']') \ - .replace(',', ',') - # 生成CQ码,设置sub_type=1表示这是表情包 - return f"[CQ:image,file=file:///{escaped_path},sub_type=1]" + class CQCode_tool: @staticmethod - async def cq_from_dict_to_class(cq_code: Dict, reply: Optional[Dict] = None) -> CQCode: + def cq_from_dict_to_class(cq_code: Dict, reply: Optional[Dict] = None) -> CQCode: """ 将CQ码字典转换为CQCode对象 @@ -369,7 +340,7 @@ class CQCode_tool: ) # 进行翻译处理 - await instance.translate() + instance.translate() return instance @staticmethod @@ -382,6 +353,27 @@ class CQCode_tool: 回复CQ码字符串 """ return f"[CQ:reply,id={message_id}]" + + @staticmethod + def create_emoji_cq(file_path: str) -> str: + """ + 创建表情包CQ码 + Args: + file_path: 本地表情包文件路径 + Returns: + 表情包CQ码字符串 + """ + # 确保使用绝对路径 + abs_path = os.path.abspath(file_path) + # 转义特殊字符 + escaped_path = abs_path.replace('&', '&') \ + .replace('[', '[') \ + .replace(']', ']') \ + .replace(',', ',') + # 生成CQ码,设置sub_type=1表示这是表情包 + return f"[CQ:image,file=file:///{escaped_path},sub_type=1]" + + cq_code_tool = CQCode_tool() diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 4f2637738..ff59220dc 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -4,6 +4,8 @@ import random import time import traceback from typing import Optional +import base64 +import hashlib from loguru import logger from nonebot import get_driver @@ -13,9 +15,11 @@ from ..chat.config import global_config from ..chat.utils import get_embedding from ..chat.utils_image import image_path_to_base64 from ..models.utils_model import LLM_request +from ..chat.utils_image import ImageManager driver = get_driver() config = driver.config +image_manager = ImageManager() class EmojiManager: @@ -142,14 +146,14 @@ class EmojiManager: emoji_similarities.sort(key=lambda x: x[1], reverse=True) # 获取前3个最相似的表情包 - top_3_emojis = emoji_similarities[:3] + top_10_emojis = emoji_similarities[:10 if len(emoji_similarities) > 10 else len(emoji_similarities)] - if not top_3_emojis: + if not top_10_emojis: logger.warning("未找到匹配的表情包") return None # 从前3个中随机选择一个 - selected_emoji, similarity = random.choice(top_3_emojis) + selected_emoji, similarity = random.choice(top_10_emojis) if selected_emoji and 'path' in selected_emoji: # 更新使用次数 @@ -172,13 +176,13 @@ class EmojiManager: return None async def _get_emoji_discription(self, image_base64: str) -> str: - """获取表情包的标签""" + """获取表情包的标签,使用image_manager的描述生成功能""" try: - prompt = '这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感' - - content, _ = await self.vlm.generate_response_for_image(prompt, image_base64) - logger.debug(f"输出描述: {content}") - return content + # 使用image_manager获取描述,去掉前后的方括号和"表情包:"前缀 + description = await image_manager.get_emoji_description(image_base64) + # 去掉[表情包:xxx]的格式,只保留描述内容 + description = description.strip('[]').replace('表情包:', '') + return description except Exception as e: logger.error(f"获取标签失败: {str(e)}") @@ -220,42 +224,94 @@ class EmojiManager: for filename in files_to_process: image_path = os.path.join(emoji_dir, filename) - # 检查是否已经注册过 - existing_emoji = self.db.db['emoji'].find_one({'filename': filename}) - if existing_emoji: - continue - - # 压缩图片并获取base64编码 + # 获取图片的base64编码和哈希值 image_base64 = image_path_to_base64(image_path) if image_base64 is None: os.remove(image_path) continue - # 获取表情包的描述 - discription = await self._get_emoji_discription(image_base64) + image_bytes = base64.b64decode(image_base64) + image_hash = hashlib.md5(image_bytes).hexdigest() + + # 检查是否已经注册过 + existing_emoji = self.db.db['emoji'].find_one({'filename': filename}) + description = None + + if existing_emoji: + # 即使表情包已存在,也检查是否需要同步到images集合 + description = existing_emoji.get('discription') + # 检查是否在images集合中存在 + existing_image = await image_manager.db.db.images.find_one({'hash': image_hash}) + if not existing_image: + # 同步到images集合 + image_doc = { + 'hash': image_hash, + 'path': image_path, + 'type': 'emoji', + 'description': description, + 'timestamp': int(time.time()) + } + await image_manager.db.db.images.update_one( + {'hash': image_hash}, + {'$set': image_doc}, + upsert=True + ) + # 保存描述到image_descriptions集合 + await image_manager._save_description_to_db(image_hash, description, 'emoji') + logger.success(f"同步已存在的表情包到images集合: {filename}") + continue + + # 检查是否在images集合中已有描述 + existing_description = await image_manager._get_description_from_db(image_hash, 'emoji') + + if existing_description: + description = existing_description + else: + # 获取表情包的描述 + description = await self._get_emoji_discription(image_base64) + if global_config.EMOJI_CHECK: check = await self._check_emoji(image_base64) if '是' not in check: os.remove(image_path) - logger.info(f"描述: {discription}") + logger.info(f"描述: {description}") logger.info(f"其不满足过滤规则,被剔除 {check}") continue logger.info(f"check通过 {check}") - embedding = await get_embedding(discription) - if discription is not None: + + if description is not None: + embedding = await get_embedding(description) # 准备数据库记录 emoji_record = { 'filename': filename, 'path': image_path, - 'embedding':embedding, - 'discription': discription, + 'embedding': embedding, + 'discription': description, + 'hash': image_hash, 'timestamp': int(time.time()) } - # 保存到数据库 + # 保存到emoji数据库 self.db.db['emoji'].insert_one(emoji_record) logger.success(f"注册新表情包: {filename}") - logger.info(f"描述: {discription}") + logger.info(f"描述: {description}") + + # 保存到images数据库 + image_doc = { + 'hash': image_hash, + 'path': image_path, + 'type': 'emoji', + 'description': description, + 'timestamp': int(time.time()) + } + await image_manager.db.db.images.update_one( + {'hash': image_hash}, + {'$set': image_doc}, + upsert=True + ) + # 保存描述到image_descriptions集合 + await image_manager._save_description_to_db(image_hash, description, 'emoji') + logger.success(f"同步保存到images集合: {filename}") else: logger.warning(f"跳过表情包: {filename}") diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index 1ac421e6b..2803e5a14 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -7,7 +7,7 @@ from nonebot import get_driver from ...common.database import Database from ..models.utils_model import LLM_request from .config import global_config -from .message import Message +from .message_cq import Message from .prompt_builder import prompt_builder from .relationship_manager import relationship_manager from .utils import process_llm_response diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index f1fc5569d..a561e6490 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -1,231 +1,360 @@ import time from dataclasses import dataclass -from typing import Dict, ForwardRef, List, Optional +from typing import Dict, ForwardRef, List, Optional, Union import urllib3 +from loguru import logger from .cq_code import CQCode, cq_code_tool from .utils_cq import parse_cq_code from .utils_user import get_groupname, get_user_cardname, get_user_nickname - -Message = ForwardRef('Message') # 添加这行 +from .utils_image import image_manager +from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase # 禁用SSL警告 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) #这个类是消息数据类,用于存储和管理消息数据。 #它定义了消息的属性,包括群组ID、用户ID、消息ID、原始消息内容、纯文本内容和时间戳。 #它还定义了两个辅助属性:keywords用于提取消息的关键词,is_plain_text用于判断消息是否为纯文本。 + +@dataclass +class MessageRecv(MessageBase): + """接收消息类,用于处理从MessageCQ序列化的消息""" + + def __init__(self, message_dict: Dict): + """从MessageCQ的字典初始化 + + Args: + message_dict: MessageCQ序列化后的字典 + """ + message_info = BaseMessageInfo(**message_dict.get('message_info', {})) + message_segment = Seg(**message_dict.get('message_segment', {})) + raw_message = message_dict.get('raw_message') + + super().__init__( + message_info=message_info, + message_segment=message_segment, + raw_message=raw_message + ) + + # 处理消息内容 + self.processed_plain_text = "" # 初始化为空字符串 + self.detailed_plain_text = "" # 初始化为空字符串 + async def process(self) -> None: + """处理消息内容,生成纯文本和详细文本 + + 这个方法必须在创建实例后显式调用,因为它包含异步操作。 + """ + self.processed_plain_text = await self._process_message_segments(self.message_segment) + self.detailed_plain_text = self._generate_detailed_text() + async def _process_message_segments(self, segment: Seg) -> str: + """递归处理消息段,转换为文字描述 + + Args: + segment: 要处理的消息段 + + Returns: + str: 处理后的文本 + """ + if segment.type == 'seglist': + # 处理消息段列表 + segments_text = [] + for seg in segment.data: + processed = await self._process_message_segments(seg) + if processed: + segments_text.append(processed) + return ' '.join(segments_text) + else: + # 处理单个消息段 + return await self._process_single_segment(segment) + + async def _process_single_segment(self, seg: Seg) -> str: + """处理单个消息段 + + Args: + seg: 要处理的消息段 + + Returns: + str: 处理后的文本 + """ + try: + if seg.type == 'text': + return seg.data + elif seg.type == 'image': + # 如果是base64图片数据 + if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): + return await image_manager.get_image_description(seg.data) + return '[图片]' + elif seg.type == 'emoji': + if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): + return await image_manager.get_emoji_description(seg.data) + return '[表情]' + else: + return f"[{seg.type}:{str(seg.data)}]" + except Exception as e: + logger.error(f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}") + return f"[处理失败的{seg.type}消息]" + + def _generate_detailed_text(self) -> str: + """生成详细文本,包含时间和用户信息""" + time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(self.message_info.time)) + user_info = self.message_info.user_info + name = ( + f"{user_info.user_nickname}(ta的昵称:{user_info.user_cardname},ta的id:{user_info.user_id})" + if user_info.user_cardname!='' + else f"{user_info.user_nickname}(ta的id:{user_info.user_id})" + ) + return f"[{time_str}] {name}: {self.processed_plain_text}\n" @dataclass -class Message: - """消息数据类""" - message_id: int = None - time: float = None - - group_id: int = None - group_name: str = None # 群名称 - - user_id: int = None - user_nickname: str = None # 用户昵称 - user_cardname: str = None # 用户群昵称 - - raw_message: str = None # 原始消息,包含未解析的cq码 - plain_text: str = None # 纯文本 - - reply_message: Dict = None # 存储 回复的 源消息 - - # 延迟初始化字段 - _initialized: bool = False - message_segments: List[Dict] = None # 存储解析后的消息片段 - processed_plain_text: str = None # 用于存储处理后的plain_text - detailed_plain_text: str = None # 用于存储详细可读文本 - - # 状态标志 - is_emoji: bool = False - has_emoji: bool = False - translate_cq: bool = True - - async def initialize(self): - """显式异步初始化方法(必须调用)""" - if self._initialized: - return - - # 异步获取补充信息 - self.group_name = self.group_name or get_groupname(self.group_id) - self.user_nickname = self.user_nickname or get_user_nickname(self.user_id) - self.user_cardname = self.user_cardname or get_user_cardname(self.user_id) - - # 消息解析 - if self.raw_message: - if not isinstance(self,Message_Sending): - self.message_segments = await self.parse_message_segments(self.raw_message) - self.processed_plain_text = ' '.join( - seg.translated_plain_text - for seg in self.message_segments - ) - - # 构建详细文本 - if self.time is None: - self.time = int(time.time()) - time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(self.time)) - name = ( - f"{self.user_nickname}(ta的昵称:{self.user_cardname},ta的id:{self.user_id})" - if self.user_cardname - else f"{self.user_nickname or f'用户{self.user_id}'}" - ) - if isinstance(self,Message_Sending) and self.is_emoji: - self.detailed_plain_text = f"[{time_str}] {name}: {self.detailed_plain_text}\n" - else: - self.detailed_plain_text = f"[{time_str}] {name}: {self.processed_plain_text}\n" - - self._initialized = True +class MessageProcessBase(MessageBase): + """消息处理基类,用于处理中和发送中的消息""" - async def parse_message_segments(self, message: str) -> List[CQCode]: - """ - 将消息解析为片段列表,包括纯文本和CQ码 - 返回的列表中每个元素都是字典,包含: - - cq_code_list:分割出的聊天对象,包括文本和CQ码 - - trans_list:翻译后的对象列表 - """ - # print(f"\033[1;34m[调试信息]\033[0m 正在处理消息: {message}") - cq_code_dict_list = [] - trans_list = [] - - start = 0 - while True: - # 查找下一个CQ码的开始位置 - cq_start = message.find('[CQ:', start) - #如果没有cq码,直接返回文本内容 - if cq_start == -1: - # 如果没有找到更多CQ码,添加剩余文本 - if start < len(message): - text = message[start:].strip() - if text: # 只添加非空文本 - cq_code_dict_list.append(parse_cq_code(text)) - break - # 添加CQ码前的文本 - if cq_start > start: - text = message[start:cq_start].strip() - if text: # 只添加非空文本 - cq_code_dict_list.append(parse_cq_code(text)) - # 查找CQ码的结束位置 - cq_end = message.find(']', cq_start) - if cq_end == -1: - # CQ码未闭合,作为普通文本处理 - text = message[cq_start:].strip() - if text: - cq_code_dict_list.append(parse_cq_code(text)) - break - cq_code = message[cq_start:cq_end + 1] - - #将cq_code解析成字典 - cq_code_dict_list.append(parse_cq_code(cq_code)) - # 更新start位置到当前CQ码之后 - start = cq_end + 1 - - # print(f"\033[1;34m[调试信息]\033[0m 提取的消息对象:列表: {cq_code_dict_list}") - - #判定是否是表情包消息,以及是否含有表情包 - if len(cq_code_dict_list) == 1 and cq_code_dict_list[0]['type'] == 'image': - self.is_emoji = True - self.has_emoji_emoji = True - else: - for segment in cq_code_dict_list: - if segment['type'] == 'image' and segment['data'].get('sub_type') == '1': - self.has_emoji_emoji = True - break - - - #翻译作为字典的CQ码 - for _code_item in cq_code_dict_list: - message_obj = await cq_code_tool.cq_from_dict_to_class(_code_item,reply = self.reply_message) - trans_list.append(message_obj) - return trans_list + def __init__( + self, + message_id: str, + user_id: int, + group_id: Optional[int] = None, + platform: str = "qq", + message_segment: Optional[Seg] = None, + reply: Optional['MessageRecv'] = None + ): + # 构造用户信息 + user_info = UserInfo( + platform=platform, + user_id=user_id, + user_nickname=get_user_nickname(user_id), + user_cardname=get_user_cardname(user_id) if group_id else None + ) -class Message_Thinking: - """消息思考类""" - def __init__(self, message: Message,message_id: str): - # 复制原始消息的基本属性 - self.group_id = message.group_id - self.user_id = message.user_id - self.user_nickname = message.user_nickname - self.user_cardname = message.user_cardname - self.group_name = message.group_name - - self.message_id = message_id - - # 思考状态相关属性 + # 构造群组信息(如果有) + group_info = None + if group_id: + group_info = GroupInfo( + platform=platform, + group_id=group_id, + group_name=get_groupname(group_id) + ) + + # 构造基础消息信息 + message_info = BaseMessageInfo( + platform=platform, + message_id=message_id, + time=int(time.time()), + group_info=group_info, + user_info=user_info + ) + + # 调用父类初始化 + super().__init__( + message_info=message_info, + message_segment=message_segment, + raw_message=None + ) + + # 处理状态相关属性 self.thinking_start_time = int(time.time()) self.thinking_time = 0 - self.interupt=False - - def update_thinking_time(self): - self.thinking_time = round(time.time(), 2) - self.thinking_start_time - + + # 文本处理相关属性 + self.processed_plain_text = "" + self.detailed_plain_text = "" + + # 回复消息 + self.reply = reply -@dataclass -class Message_Sending(Message): - """发送中的消息类""" - thinking_start_time: float = None # 思考开始时间 - thinking_time: float = None # 思考时间 - - reply_message_id: int = None # 存储 回复的 源消息ID - - is_head: bool = False # 是否是头部消息 - - def update_thinking_time(self): - self.thinking_time = round(time.time(), 2) - self.thinking_start_time + def update_thinking_time(self) -> float: + """更新思考时间""" + self.thinking_time = round(time.time() - self.thinking_start_time, 2) return self.thinking_time + async def _process_message_segments(self, segment: Seg) -> str: + """递归处理消息段,转换为文字描述 + + Args: + segment: 要处理的消息段 + + Returns: + str: 处理后的文本 + """ + if segment.type == 'seglist': + # 处理消息段列表 + segments_text = [] + for seg in segment.data: + processed = await self._process_message_segments(seg) + if processed: + segments_text.append(processed) + return ' '.join(segments_text) + else: + # 处理单个消息段 + return await self._process_single_segment(segment) - + async def _process_single_segment(self, seg: Seg) -> str: + """处理单个消息段 + + Args: + seg: 要处理的消息段 + + Returns: + str: 处理后的文本 + """ + try: + if seg.type == 'text': + return seg.data + elif seg.type == 'image': + # 如果是base64图片数据 + if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): + return await image_manager.get_image_description(seg.data) + return '[图片]' + elif seg.type == 'emoji': + if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): + return await image_manager.get_emoji_description(seg.data) + return '[表情]' + elif seg.type == 'at': + return f"[@{seg.data}]" + elif seg.type == 'reply': + if self.reply and hasattr(self.reply, 'processed_plain_text'): + return f"[回复:{self.reply.processed_plain_text}]" + else: + return f"[{seg.type}:{str(seg.data)}]" + except Exception as e: + logger.error(f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}") + return f"[处理失败的{seg.type}消息]" + + def _generate_detailed_text(self) -> str: + """生成详细文本,包含时间和用户信息""" + time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(self.message_info.time)) + user_info = self.message_info.user_info + name = ( + f"{user_info.user_nickname}(ta的昵称:{user_info.user_cardname},ta的id:{user_info.user_id})" + if user_info.user_cardname != '' + else f"{user_info.user_nickname}(ta的id:{user_info.user_id})" + ) + return f"[{time_str}] {name}: {self.processed_plain_text}\n" + +@dataclass +class MessageThinking(MessageProcessBase): + """思考状态的消息类""" + + def __init__( + self, + message_id: str, + user_id: int, + group_id: Optional[int] = None, + platform: str = "qq", + reply: Optional['MessageRecv'] = None + ): + # 调用父类初始化 + super().__init__( + message_id=message_id, + user_id=user_id, + group_id=group_id, + platform=platform, + message_segment=None, # 思考状态不需要消息段 + reply=reply + ) + + # 思考状态特有属性 + self.interrupt = False + +@dataclass +class MessageSending(MessageProcessBase): + """发送状态的消息类""" + + def __init__( + self, + message_id: str, + user_id: int, + message_segment: Seg, + group_id: Optional[int] = None, + reply: Optional['MessageRecv'] = None, + platform: str = "qq", + is_head: bool = False + ): + # 调用父类初始化 + super().__init__( + message_id=message_id, + user_id=user_id, + group_id=group_id, + platform=platform, + message_segment=message_segment, + reply=reply + ) + + # 发送状态特有属性 + self.reply_to_message_id = reply.message_info.message_id if reply else None + self.is_head = is_head + + async def process(self) -> None: + """处理消息内容,生成纯文本和详细文本""" + if self.message_segment: + self.processed_plain_text = await self._process_message_segments(self.message_segment) + self.detailed_plain_text = self._generate_detailed_text() + + @classmethod + def from_thinking( + cls, + thinking: MessageThinking, + message_segment: Seg, + reply: Optional['MessageRecv'] = None, + is_head: bool = False + ) -> 'MessageSending': + """从思考状态消息创建发送状态消息""" + return cls( + message_id=thinking.message_info.message_id, + user_id=thinking.message_info.user_info.user_id, + message_segment=message_segment, + group_id=thinking.message_info.group_info.group_id if thinking.message_info.group_info else None, + reply=reply or thinking.reply, + platform=thinking.message_info.platform, + is_head=is_head + ) + +@dataclass class MessageSet: """消息集合类,可以存储多个发送消息""" def __init__(self, group_id: int, user_id: int, message_id: str): self.group_id = group_id self.user_id = user_id self.message_id = message_id - self.messages: List[Message_Sending] = [] # 修改类型标注 + self.messages: List[MessageSending] = [] self.time = round(time.time(), 2) - def add_message(self, message: Message_Sending) -> None: - """添加消息到集合,只接受Message_Sending类型""" - if not isinstance(message, Message_Sending): - raise TypeError("MessageSet只能添加Message_Sending类型的消息") + def add_message(self, message: MessageSending) -> None: + """添加消息到集合""" + if not isinstance(message, MessageSending): + raise TypeError("MessageSet只能添加MessageSending类型的消息") self.messages.append(message) - # 按时间排序 - self.messages.sort(key=lambda x: x.time) + self.messages.sort(key=lambda x: x.message_info.time) - def get_message_by_index(self, index: int) -> Optional[Message_Sending]: + def get_message_by_index(self, index: int) -> Optional[MessageSending]: """通过索引获取消息""" if 0 <= index < len(self.messages): return self.messages[index] return None - def get_message_by_time(self, target_time: float) -> Optional[Message_Sending]: + def get_message_by_time(self, target_time: float) -> Optional[MessageSending]: """获取最接近指定时间的消息""" if not self.messages: return None - # 使用二分查找找到最接近的消息 left, right = 0, len(self.messages) - 1 while left < right: mid = (left + right) // 2 - if self.messages[mid].time < target_time: + if self.messages[mid].message_info.time < target_time: left = mid + 1 else: right = mid return self.messages[left] - def clear_messages(self) -> None: """清空所有消息""" self.messages.clear() - def remove_message(self, message: Message_Sending) -> bool: + def remove_message(self, message: MessageSending) -> bool: """移除指定消息""" if message in self.messages: self.messages.remove(message) diff --git a/src/plugins/chat/message_base.py b/src/plugins/chat/message_base.py new file mode 100644 index 000000000..77694ad8c --- /dev/null +++ b/src/plugins/chat/message_base.py @@ -0,0 +1,158 @@ +from dataclasses import dataclass, asdict +from typing import List, Optional, Union, Any, Dict + +@dataclass +class Seg(dict): + """消息片段类,用于表示消息的不同部分 + + Attributes: + type: 片段类型,可以是 'text'、'image'、'seglist' 等 + data: 片段的具体内容 + - 对于 text 类型,data 是字符串 + - 对于 image 类型,data 是 base64 字符串 + - 对于 seglist 类型,data 是 Seg 列表 + translated_data: 经过翻译处理的数据(可选) + """ + type: str + data: Union[str, List['Seg']] + translated_data: Optional[str] = None + + def __init__(self, type: str, data: Union[str, List['Seg']], translated_data: Optional[str] = None): + """初始化实例,确保字典和属性同步""" + # 先初始化字典 + super().__init__(type=type, data=data) + if translated_data is not None: + self['translated_data'] = translated_data + + # 再初始化属性 + object.__setattr__(self, 'type', type) + object.__setattr__(self, 'data', data) + object.__setattr__(self, 'translated_data', translated_data) + + # 验证数据类型 + self._validate_data() + + def _validate_data(self) -> None: + """验证数据类型的正确性""" + if self.type == 'seglist' and not isinstance(self.data, list): + raise ValueError("seglist类型的data必须是列表") + elif self.type == 'text' and not isinstance(self.data, str): + raise ValueError("text类型的data必须是字符串") + elif self.type == 'image' and not isinstance(self.data, str): + raise ValueError("image类型的data必须是字符串") + + def __setattr__(self, name: str, value: Any) -> None: + """重写属性设置,同时更新字典值""" + # 更新属性 + object.__setattr__(self, name, value) + # 同步更新字典 + if name in ['type', 'data', 'translated_data']: + self[name] = value + + def __setitem__(self, key: str, value: Any) -> None: + """重写字典值设置,同时更新属性""" + # 更新字典 + super().__setitem__(key, value) + # 同步更新属性 + if key in ['type', 'data', 'translated_data']: + object.__setattr__(self, key, value) + + def to_dict(self) -> Dict: + """转换为字典格式""" + result = {'type': self.type} + if self.type == 'seglist': + result['data'] = [seg.to_dict() for seg in self.data] + else: + result['data'] = self.data + if self.translated_data is not None: + result['translated_data'] = self.translated_data + return result + +@dataclass +class GroupInfo: + """群组信息类""" + platform: Optional[str] = None + group_id: Optional[int] = None + group_name: Optional[str] = None # 群名称 + + def to_dict(self) -> Dict: + """转换为字典格式""" + return {k: v for k, v in asdict(self).items() if v is not None} + +@dataclass +class UserInfo: + """用户信息类""" + platform: Optional[str] = None + user_id: Optional[int] = None + user_nickname: Optional[str] = None # 用户昵称 + user_cardname: Optional[str] = None # 用户群昵称 + + def to_dict(self) -> Dict: + """转换为字典格式""" + return {k: v for k, v in asdict(self).items() if v is not None} + +@dataclass +class BaseMessageInfo: + """消息信息类""" + platform: Optional[str] = None + message_id: Optional[int,str] = None + time: Optional[int] = None + group_info: Optional[GroupInfo] = None + user_info: Optional[UserInfo] = None + + def to_dict(self) -> Dict: + """转换为字典格式""" + result = {} + for field, value in asdict(self).items(): + if value is not None: + if isinstance(value, (GroupInfo, UserInfo)): + result[field] = value.to_dict() + else: + result[field] = value + return result + +@dataclass +class MessageBase: + """消息类""" + message_info: BaseMessageInfo + message_segment: Seg + raw_message: Optional[str] = None # 原始消息,包含未解析的cq码 + + def to_dict(self) -> Dict: + """转换为字典格式 + + Returns: + Dict: 包含所有非None字段的字典,其中: + - message_info: 转换为字典格式 + - message_segment: 转换为字典格式 + - raw_message: 如果存在则包含 + """ + result = { + 'message_info': self.message_info.to_dict(), + 'message_segment': self.message_segment.to_dict() + } + if self.raw_message is not None: + result['raw_message'] = self.raw_message + return result + + @classmethod + def from_dict(cls, data: Dict) -> 'MessageBase': + """从字典创建MessageBase实例 + + Args: + data: 包含必要字段的字典 + + Returns: + MessageBase: 新的实例 + """ + message_info = BaseMessageInfo(**data.get('message_info', {})) + message_segment = Seg(**data.get('message_segment', {})) + raw_message = data.get('raw_message') + return cls( + message_info=message_info, + message_segment=message_segment, + raw_message=raw_message + ) + + + diff --git a/src/plugins/chat/message_cq.py b/src/plugins/chat/message_cq.py new file mode 100644 index 000000000..80df4e340 --- /dev/null +++ b/src/plugins/chat/message_cq.py @@ -0,0 +1,188 @@ +import time +from dataclasses import dataclass +from typing import Dict, ForwardRef, List, Optional, Union + +import urllib3 + +from .cq_code import CQCode, cq_code_tool +from .utils_cq import parse_cq_code +from .utils_user import get_groupname, get_user_cardname, get_user_nickname +from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase +# 禁用SSL警告 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +#这个类是消息数据类,用于存储和管理消息数据。 +#它定义了消息的属性,包括群组ID、用户ID、消息ID、原始消息内容、纯文本内容和时间戳。 +#它还定义了两个辅助属性:keywords用于提取消息的关键词,is_plain_text用于判断消息是否为纯文本。 + +@dataclass +class MessageCQ(MessageBase): + """QQ消息基类,继承自MessageBase + + 最小必要参数: + - message_id: 消息ID + - user_id: 发送者/接收者ID + - platform: 平台标识(默认为"qq") + """ + def __init__( + self, + message_id: int, + user_id: int, + group_id: Optional[int] = None, + platform: str = "qq" + ): + # 构造用户信息 + user_info = UserInfo( + platform=platform, + user_id=user_id, + user_nickname=get_user_nickname(user_id), + user_cardname=get_user_cardname(user_id) if group_id else None + ) + + # 构造群组信息(如果有) + group_info = None + if group_id: + group_info = GroupInfo( + platform=platform, + group_id=group_id, + group_name=get_groupname(group_id) + ) + + # 构造基础消息信息 + message_info = BaseMessageInfo( + platform=platform, + message_id=message_id, + time=int(time.time()), + group_info=group_info, + user_info=user_info + ) + + # 调用父类初始化,message_segment 由子类设置 + super().__init__( + message_info=message_info, + message_segment=None, + raw_message=None + ) + +@dataclass +class MessageRecvCQ(MessageCQ): + """QQ接收消息类,用于解析raw_message到Seg对象""" + + def __init__( + self, + message_id: int, + user_id: int, + raw_message: str, + group_id: Optional[int] = None, + reply_message: Optional[Dict] = None, + platform: str = "qq" + ): + # 调用父类初始化 + super().__init__(message_id, user_id, group_id, platform) + + # 解析消息段 + self.message_segment = self._parse_message(raw_message, reply_message) + self.raw_message = raw_message + + def _parse_message(self, message: str, reply_message: Optional[Dict] = None) -> Seg: + """解析消息内容为Seg对象""" + cq_code_dict_list = [] + segments = [] + + start = 0 + while True: + cq_start = message.find('[CQ:', start) + if cq_start == -1: + if start < len(message): + text = message[start:].strip() + if text: + cq_code_dict_list.append(parse_cq_code(text)) + break + + if cq_start > start: + text = message[start:cq_start].strip() + if text: + cq_code_dict_list.append(parse_cq_code(text)) + + cq_end = message.find(']', cq_start) + if cq_end == -1: + text = message[cq_start:].strip() + if text: + cq_code_dict_list.append(parse_cq_code(text)) + break + + cq_code = message[cq_start:cq_end + 1] + cq_code_dict_list.append(parse_cq_code(cq_code)) + start = cq_end + 1 + + # 转换CQ码为Seg对象 + for code_item in cq_code_dict_list: + message_obj = cq_code_tool.cq_from_dict_to_class(code_item, reply=reply_message) + if message_obj.translated_segments: + segments.append(message_obj.translated_segments) + + # 如果只有一个segment,直接返回 + if len(segments) == 1: + return segments[0] + + # 否则返回seglist类型的Seg + return Seg(type='seglist', data=segments) + + def to_dict(self) -> Dict: + """转换为字典格式,包含所有必要信息""" + base_dict = super().to_dict() + return base_dict + +@dataclass +class MessageSendCQ(MessageCQ): + """QQ发送消息类,用于将Seg对象转换为raw_message""" + + def __init__( + self, + message_id: int, + user_id: int, + message_segment: Seg, + group_id: Optional[int] = None, + reply_to_message_id: Optional[int] = None, + platform: str = "qq" + ): + # 调用父类初始化 + super().__init__(message_id, user_id, group_id, platform) + + self.message_segment = message_segment + self.raw_message = self._generate_raw_message(reply_to_message_id) + + def _generate_raw_message(self, reply_to_message_id: Optional[int] = None) -> str: + """将Seg对象转换为raw_message""" + segments = [] + + # 添加回复消息 + if reply_to_message_id: + segments.append(cq_code_tool.create_reply_cq(reply_to_message_id)) + + # 处理消息段 + if self.message_segment.type == 'seglist': + for seg in self.message_segment.data: + segments.append(self._seg_to_cq_code(seg)) + else: + segments.append(self._seg_to_cq_code(self.message_segment)) + + return ''.join(segments) + + def _seg_to_cq_code(self, seg: Seg) -> str: + """将单个Seg对象转换为CQ码字符串""" + if seg.type == 'text': + return str(seg.data) + elif seg.type == 'image': + # 如果是base64图片数据 + if seg.data.startswith(('data:', 'base64:')): + return f"[CQ:image,file=base64://{seg.data}]" + # 如果是表情包(本地文件) + return cq_code_tool.create_emoji_cq(seg.data) + elif seg.type == 'at': + return f"[CQ:at,qq={seg.data}]" + elif seg.type == 'reply': + return cq_code_tool.create_reply_cq(int(seg.data)) + else: + return f"[{seg.data}]" + diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 050c59d74..8ed30d69c 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -5,7 +5,7 @@ from typing import Dict, List, Optional, Union from nonebot.adapters.onebot.v11 import Bot from .cq_code import cq_code_tool -from .message import Message, Message_Sending, Message_Thinking, MessageSet +from .message_cq import Message, Message_Sending, Message_Thinking, MessageSet from .storage import MessageStorage from .utils import calculate_typing_time from .config import global_config diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index 6a87480b7..4b1bf0ca4 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -1,7 +1,7 @@ from typing import Optional from ...common.database import Database -from .message import Message +from .message_cq import Message class MessageStorage: diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index b2583e86f..07c3e2a6f 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -11,31 +11,12 @@ from nonebot import get_driver from ..models.utils_model import LLM_request from ..utils.typo_generator import ChineseTypoGenerator from .config import global_config -from .message import Message +from .message_cq import Message driver = get_driver() config = driver.config -def combine_messages(messages: List[Message]) -> str: - """将消息列表组合成格式化的字符串 - - Args: - messages: Message对象列表 - - Returns: - str: 格式化后的消息字符串 - """ - result = "" - for message in messages: - time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(message.time)) - name = message.user_nickname or f"用户{message.user_id}" - content = message.processed_plain_text or message.plain_text - - result += f"[{time_str}] {name}: {content}\n" - - return result - def db_message_to_str(message_dict: Dict) -> str: print(f"message_dict: {message_dict}") @@ -159,7 +140,7 @@ async def get_recent_group_messages(db, group_id: int, limit: int = 12) -> list: return [] # 转换为 Message对象列表 - from .message import Message + from .message_cq import Message message_objects = [] for msg_data in recent_messages: try: diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 8a8b3ce5a..aba09714c 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -2,7 +2,11 @@ import base64 import io import os import time -import zlib # 用于 CRC32 +import zlib +import aiohttp +import hashlib +from typing import Optional, Tuple, Union +from urllib.parse import urlparse from loguru import logger from nonebot import get_driver @@ -10,213 +14,348 @@ from PIL import Image from ...common.database import Database from ..chat.config import global_config - +from ..models.utils_model import LLM_request driver = get_driver() config = driver.config - - -def storage_compress_image(base64_data: str, max_size: int = 200) -> str: - """ - 压缩base64格式的图片到指定大小(单位:KB)并在数据库中记录图片信息 - Args: - base64_data: base64编码的图片数据 - max_size: 最大文件大小(KB) - Returns: - str: 压缩后的base64图片数据 - """ - try: - # 将base64转换为字节数据 - image_data = base64.b64decode(base64_data) - - # 使用 CRC32 计算哈希值 - hash_value = format(zlib.crc32(image_data) & 0xFFFFFFFF, 'x') - - # 确保图片目录存在 - images_dir = "data/images" - os.makedirs(images_dir, exist_ok=True) - - # 连接数据库 - db = Database( - host=config.mongodb_host, - port=int(config.mongodb_port), - db_name=config.database_name, - username=config.mongodb_username, - password=config.mongodb_password, - auth_source=config.mongodb_auth_source - ) - - # 检查是否已存在相同哈希值的图片 - collection = db.db['images'] - existing_image = collection.find_one({'hash': hash_value}) - - if existing_image: - print(f"\033[1;33m[提示]\033[0m 发现重复图片,使用已存在的文件: {existing_image['path']}") - return base64_data - - # 将字节数据转换为图片对象 - img = Image.open(io.BytesIO(image_data)) - - # 如果是动图,直接返回原图 - if getattr(img, 'is_animated', False): - return base64_data - - # 计算当前大小(KB) - current_size = len(image_data) / 1024 - - # 如果已经小于目标大小,直接使用原图 - if current_size <= max_size: - compressed_data = image_data - else: - # 压缩逻辑 - # 先缩放到50% - new_width = int(img.width * 0.5) - new_height = int(img.height * 0.5) - img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - # 如果缩放后的最大边长仍然大于400,继续缩放 - max_dimension = 400 - max_current = max(new_width, new_height) - if max_current > max_dimension: - ratio = max_dimension / max_current - new_width = int(new_width * ratio) - new_height = int(new_height * ratio) - img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - # 转换为RGB模式(去除透明通道) - if img.mode in ('RGBA', 'P'): - img = img.convert('RGB') - - # 使用固定质量参数压缩 - output = io.BytesIO() - img.save(output, format='JPEG', quality=85, optimize=True) - compressed_data = output.getvalue() - - # 生成文件名(使用时间戳和哈希值确保唯一性) - timestamp = int(time.time()) - filename = f"{timestamp}_{hash_value}.jpg" - image_path = os.path.join(images_dir, filename) - - # 保存文件 - with open(image_path, "wb") as f: - f.write(compressed_data) - - print(f"\033[1;32m[成功]\033[0m 保存图片到: {image_path}") - - try: - # 准备数据库记录 - image_record = { - 'filename': filename, - 'path': image_path, - 'size': len(compressed_data) / 1024, - 'timestamp': timestamp, - 'width': img.width, - 'height': img.height, - 'description': '', - 'tags': [], - 'type': 'image', - 'hash': hash_value - } - - # 保存记录 - collection.insert_one(image_record) - print("\033[1;32m[成功]\033[0m 保存图片记录到数据库") - - except Exception as db_error: - print(f"\033[1;31m[错误]\033[0m 数据库操作失败: {str(db_error)}") - - # 将压缩后的数据转换为base64 - compressed_base64 = base64.b64encode(compressed_data).decode('utf-8') - return compressed_base64 - - except Exception as e: - print(f"\033[1;31m[错误]\033[0m 压缩图片失败: {str(e)}") - import traceback - print(traceback.format_exc()) - return base64_data - -def storage_emoji(image_data: bytes) -> bytes: - """ - 存储表情包到本地文件夹 - Args: - image_data: 图片字节数据 - group_id: 群组ID(仅用于日志) - user_id: 用户ID(仅用于日志) - Returns: - bytes: 原始图片数据 - """ - if not global_config.EMOJI_SAVE: - return image_data - try: - # 使用 CRC32 计算哈希值 - hash_value = format(zlib.crc32(image_data) & 0xFFFFFFFF, 'x') - - # 确保表情包目录存在 - emoji_dir = "data/emoji" - os.makedirs(emoji_dir, exist_ok=True) - - # 检查是否已存在相同哈希值的文件 - for filename in os.listdir(emoji_dir): - if hash_value in filename: - # print(f"\033[1;33m[提示]\033[0m 发现重复表情包: {filename}") - return image_data - - # 生成文件名 - timestamp = int(time.time()) - filename = f"{timestamp}_{hash_value}.jpg" - emoji_path = os.path.join(emoji_dir, filename) - - # 直接保存原始文件 - with open(emoji_path, "wb") as f: - f.write(image_data) - - print(f"\033[1;32m[成功]\033[0m 保存表情包到: {emoji_path}") - return image_data - - except Exception as e: - print(f"\033[1;31m[错误]\033[0m 保存表情包失败: {str(e)}") - return image_data +class ImageManager: + _instance = None + IMAGE_DIR = "data" # 图像存储根目录 - -def storage_image(image_data: bytes) -> bytes: - """ - 存储图片到本地文件夹 - Args: - image_data: 图片字节数据 - group_id: 群组ID(仅用于日志) - user_id: 用户ID(仅用于日志) - Returns: - bytes: 原始图片数据 - """ - try: - # 使用 CRC32 计算哈希值 - hash_value = format(zlib.crc32(image_data) & 0xFFFFFFFF, 'x') - - # 确保表情包目录存在 - image_dir = "data/image" - os.makedirs(image_dir, exist_ok=True) - - # 检查是否已存在相同哈希值的文件 - for filename in os.listdir(image_dir): - if hash_value in filename: - # print(f"\033[1;33m[提示]\033[0m 发现重复表情包: {filename}") - return image_data - - # 生成文件名 - timestamp = int(time.time()) - filename = f"{timestamp}_{hash_value}.jpg" - image_path = os.path.join(image_dir, filename) - - # 直接保存原始文件 - with open(image_path, "wb") as f: - f.write(image_data) + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance.db = None + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if not self._initialized: + self.db = Database.get_instance() + self._ensure_image_collection() + self._ensure_description_collection() + self._ensure_image_dir() + self._initialized = True + self._llm = LLM_request(model=global_config.vlm, temperature=0.4, max_tokens=300) - print(f"\033[1;32m[成功]\033[0m 保存图片到: {image_path}") - return image_data + def _ensure_image_dir(self): + """确保图像存储目录存在""" + os.makedirs(self.IMAGE_DIR, exist_ok=True) - except Exception as e: - print(f"\033[1;31m[错误]\033[0m 保存图片失败: {str(e)}") - return image_data + def _ensure_image_collection(self): + """确保images集合存在并创建索引""" + if 'images' not in self.db.db.list_collection_names(): + self.db.db.create_collection('images') + # 创建索引 + self.db.db.images.create_index([('hash', 1)], unique=True) + self.db.db.images.create_index([('url', 1)]) + self.db.db.images.create_index([('path', 1)]) + + def _ensure_description_collection(self): + """确保image_descriptions集合存在并创建索引""" + if 'image_descriptions' not in self.db.db.list_collection_names(): + self.db.db.create_collection('image_descriptions') + # 创建索引 + self.db.db.image_descriptions.create_index([('hash', 1)], unique=True) + self.db.db.image_descriptions.create_index([('type', 1)]) + + async def _get_description_from_db(self, image_hash: str, description_type: str) -> Optional[str]: + """从数据库获取图片描述 + + Args: + image_hash: 图片哈希值 + description_type: 描述类型 ('emoji' 或 'image') + + Returns: + Optional[str]: 描述文本,如果不存在则返回None + """ + result = await self.db.db.image_descriptions.find_one({ + 'hash': image_hash, + 'type': description_type + }) + return result['description'] if result else None + + async def _save_description_to_db(self, image_hash: str, description: str, description_type: str) -> None: + """保存图片描述到数据库 + + Args: + image_hash: 图片哈希值 + description: 描述文本 + description_type: 描述类型 ('emoji' 或 'image') + """ + await self.db.db.image_descriptions.update_one( + {'hash': image_hash, 'type': description_type}, + { + '$set': { + 'description': description, + 'timestamp': int(time.time()) + } + }, + upsert=True + ) + + async def save_image(self, + image_data: Union[str, bytes], + url: str = None, + description: str = None, + is_base64: bool = False) -> Optional[str]: + """保存图像 + Args: + image_data: 图像数据(base64字符串或字节) + url: 图像URL + description: 图像描述 + is_base64: image_data是否为base64格式 + Returns: + str: 保存后的文件路径,失败返回None + """ + try: + # 转换为字节格式 + if is_base64: + if isinstance(image_data, str): + image_bytes = base64.b64decode(image_data) + else: + return None + else: + if isinstance(image_data, bytes): + image_bytes = image_data + else: + return None + + # 计算哈希值 + image_hash = hashlib.md5(image_bytes).hexdigest() + + # 查重 + existing = self.db.db.images.find_one({'hash': image_hash}) + if existing: + return existing['path'] + + # 生成文件名和路径 + timestamp = int(time.time()) + filename = f"{timestamp}_{image_hash[:8]}.jpg" + file_path = os.path.join(self.IMAGE_DIR, filename) + + # 保存文件 + with open(file_path, "wb") as f: + f.write(image_bytes) + + # 保存到数据库 + image_doc = { + 'hash': image_hash, + 'path': file_path, + 'url': url, + 'description': description, + 'timestamp': timestamp + } + self.db.db.images.insert_one(image_doc) + + return file_path + + except Exception as e: + logger.error(f"保存图像失败: {str(e)}") + return None + + async def get_image_by_url(self, url: str) -> Optional[str]: + """根据URL获取图像路径(带查重) + Args: + url: 图像URL + Returns: + str: 本地文件路径,不存在返回None + """ + try: + # 先查找是否已存在 + existing = self.db.db.images.find_one({'url': url}) + if existing: + return existing['path'] + + # 下载图像 + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status == 200: + image_bytes = await resp.read() + return await self.save_image(image_bytes, url=url) + return None + + except Exception as e: + logger.error(f"获取图像失败: {str(e)}") + return None + + async def get_base64_by_url(self, url: str) -> Optional[str]: + """根据URL获取base64(带查重) + Args: + url: 图像URL + Returns: + str: base64字符串,失败返回None + """ + try: + image_path = await self.get_image_by_url(url) + if not image_path: + return None + + with open(image_path, 'rb') as f: + image_bytes = f.read() + return base64.b64encode(image_bytes).decode('utf-8') + + except Exception as e: + logger.error(f"获取base64失败: {str(e)}") + return None + + async def save_base64_image(self, base64_str: str, description: str = None) -> Optional[str]: + """保存base64图像(带查重) + Args: + base64_str: base64字符串 + description: 图像描述 + Returns: + str: 保存路径,失败返回None + """ + return await self.save_image(base64_str, description=description, is_base64=True) + + def check_url_exists(self, url: str) -> bool: + """检查URL是否已存在 + Args: + url: 图像URL + Returns: + bool: 是否存在 + """ + return self.db.db.images.find_one({'url': url}) is not None + + def check_hash_exists(self, image_data: Union[str, bytes], is_base64: bool = False) -> bool: + """检查图像是否已存在 + Args: + image_data: 图像数据(base64或字节) + is_base64: 是否为base64格式 + Returns: + bool: 是否存在 + """ + try: + if is_base64: + if isinstance(image_data, str): + image_bytes = base64.b64decode(image_data) + else: + return False + else: + if isinstance(image_data, bytes): + image_bytes = image_data + else: + return False + + image_hash = hashlib.md5(image_bytes).hexdigest() + return self.db.db.images.find_one({'hash': image_hash}) is not None + + except Exception as e: + logger.error(f"检查哈希失败: {str(e)}") + return False + + async def get_emoji_description(self, image_base64: str) -> str: + """获取表情包描述,带查重和保存功能""" + try: + # 计算图片哈希 + image_bytes = base64.b64decode(image_base64) + image_hash = hashlib.md5(image_bytes).hexdigest() + + # 查询缓存的描述 + cached_description = await self._get_description_from_db(image_hash, 'emoji') + if cached_description: + return f"[表情包:{cached_description}]" + + # 调用AI获取描述 + prompt = "这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感" + description, _ = await self._llm.generate_response_for_image(prompt, image_base64) + + # 根据配置决定是否保存图片 + if global_config.EMOJI_SAVE: + # 生成文件名和路径 + timestamp = int(time.time()) + filename = f"emoji_{timestamp}_{image_hash[:8]}.jpg" + file_path = os.path.join(self.IMAGE_DIR, filename) + + try: + # 保存文件 + with open(file_path, "wb") as f: + f.write(image_bytes) + + # 保存到数据库 + image_doc = { + 'hash': image_hash, + 'path': file_path, + 'type': 'emoji', + 'description': description, + 'timestamp': timestamp + } + await self.db.db.images.update_one( + {'hash': image_hash}, + {'$set': image_doc}, + upsert=True + ) + logger.success(f"保存表情包: {file_path}") + except Exception as e: + logger.error(f"保存表情包文件失败: {str(e)}") + + # 保存描述到数据库 + await self._save_description_to_db(image_hash, description, 'emoji') + + return f"[表情包:{description}]" + except Exception as e: + logger.error(f"获取表情包描述失败: {str(e)}") + return "[表情包]" + + async def get_image_description(self, image_base64: str) -> str: + """获取普通图片描述,带查重和保存功能""" + try: + # 计算图片哈希 + image_bytes = base64.b64decode(image_base64) + image_hash = hashlib.md5(image_bytes).hexdigest() + + # 查询缓存的描述 + cached_description = await self._get_description_from_db(image_hash, 'image') + if cached_description: + return f"[图片:{cached_description}]" + + # 调用AI获取描述 + prompt = "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" + description, _ = await self._llm.generate_response_for_image(prompt, image_base64) + + # 根据配置决定是否保存图片 + if global_config.EMOJI_SAVE: + # 生成文件名和路径 + timestamp = int(time.time()) + filename = f"image_{timestamp}_{image_hash[:8]}.jpg" + file_path = os.path.join(self.IMAGE_DIR, filename) + + try: + # 保存文件 + with open(file_path, "wb") as f: + f.write(image_bytes) + + # 保存到数据库 + image_doc = { + 'hash': image_hash, + 'path': file_path, + 'type': 'image', + 'description': description, + 'timestamp': timestamp + } + await self.db.db.images.update_one( + {'hash': image_hash}, + {'$set': image_doc}, + upsert=True + ) + logger.success(f"保存图片: {file_path}") + except Exception as e: + logger.error(f"保存图片文件失败: {str(e)}") + + # 保存描述到数据库 + await self._save_description_to_db(image_hash, description, 'image') + + return f"[图片:{description}]" + except Exception as e: + logger.error(f"获取图片描述失败: {str(e)}") + return "[图片]" + + + +# 创建全局单例 +image_manager = ImageManager() + def compress_base64_image_by_scale(base64_data: str, target_size: int = 0.8 * 1024 * 1024) -> str: """压缩base64格式的图片到指定大小 From 5566f178d0f090158c18b7888e9bf7fff5ef00e1 Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Sun, 9 Mar 2025 22:12:10 +0800 Subject: [PATCH 002/162] =?UTF-8?q?refractor:=20=E5=87=A0=E4=B9=8E?= =?UTF-8?q?=E5=86=99=E5=AE=8C=E4=BA=86=EF=BC=8C=E8=BF=9B=E5=85=A5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 162 +++++++++-------- src/plugins/chat/chat_stream.py | 209 ++++++++++++++++++++++ src/plugins/chat/cq_code.py | 18 ++ src/plugins/chat/emoji_manager.py | 4 +- src/plugins/chat/message.py | 82 ++++----- src/plugins/chat/message_cq.py | 2 +- src/plugins/chat/message_sender.py | 167 +++++++++--------- src/plugins/chat/relationship_manager.py | 210 ++++++++++++++++++----- src/plugins/chat/storage.py | 43 ++--- src/plugins/chat/willing_manager.py | 84 +++++---- 10 files changed, 662 insertions(+), 319 deletions(-) create mode 100644 src/plugins/chat/chat_stream.py diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index c9eea3ed9..b5096b7a9 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -10,18 +10,19 @@ from .config import global_config from .cq_code import CQCode,cq_code_tool # 导入CQCode模块 from .emoji_manager import emoji_manager # 导入表情包管理器 from .llm_generator import ResponseGenerator +from .message import MessageSending, MessageRecv, MessageThinking, MessageSet from .message_cq import ( - Message, - Message_Sending, - Message_Thinking, # 导入 Message_Thinking 类 - MessageSet, + MessageRecvCQ, + MessageSendCQ, ) +from .chat_stream import chat_manager from .message_sender import message_manager # 导入新的消息管理器 from .relationship_manager import relationship_manager from .storage import MessageStorage from .utils import calculate_typing_time, is_mentioned_bot_in_txt +from .utils_image import image_path_to_base64 from .willing_manager import willing_manager # 导入意愿管理器 - +from .message_base import UserInfo, GroupInfo, Seg class ChatBot: def __init__(self): @@ -43,12 +44,9 @@ class ChatBot: async def handle_message(self, event: GroupMessageEvent, bot: Bot) -> None: """处理收到的群消息""" - if event.group_id not in global_config.talk_allowed_groups: - return self.bot = bot # 更新 bot 实例 - if event.user_id in global_config.ban_user_id: - return + group_info = await bot.get_group_info(group_id=event.group_id) sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) @@ -56,25 +54,42 @@ class ChatBot: await relationship_manager.update_relationship(user_id = event.user_id, data = sender_info) await relationship_manager.update_relationship_value(user_id = event.user_id, relationship_value = 0.5) - message = Message( - group_id=event.group_id, - user_id=event.user_id, + message_cq=MessageRecvCQ( message_id=event.message_id, - user_cardname=sender_info['card'], - raw_message=str(event.original_message), - plain_text=event.get_plaintext(), + user_id=event.user_id, + raw_message=str(event.original_message), + group_id=event.group_id, reply_message=event.reply, + platform='qq' ) - await message.initialize() - + message_json=message_cq.to_dict() + message=MessageRecv(**message_json) + await message.process() + groupinfo=message.message_info.group_info + userinfo=message.message_info.user_info + messageinfo=message.message_info + chat = await chat_manager.get_or_create_stream(platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo) + if groupinfo: + if groupinfo.group_id not in global_config.talk_allowed_groups: + return + else: + if userinfo: + if userinfo.user_id in []: + pass + else: + return + else: + return + if userinfo.user_id in global_config.ban_user_id: + return # 过滤词 for word in global_config.ban_words: - if word in message.detailed_plain_text: - logger.info(f"\033[1;32m[{message.group_name}]{message.user_nickname}:\033[0m {message.processed_plain_text}") + if word in message.processed_plain_text: + logger.info(f"\033[1;32m[{groupinfo.group_name}]{userinfo.user_nickname}:\033[0m {message.processed_plain_text}") logger.info(f"\033[1;32m[过滤词识别]\033[0m 消息中含有{word},filtered") return - current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.time)) + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) @@ -88,44 +103,58 @@ class ChatBot: await self.storage.store_message(message, topic[0] if topic else None) is_mentioned = is_mentioned_bot_in_txt(message.processed_plain_text) - reply_probability = willing_manager.change_reply_willing_received( - event.group_id, - topic[0] if topic else None, - is_mentioned, - global_config, - event.user_id, - message.is_emoji, - interested_rate + reply_probability = await willing_manager.change_reply_willing_received( + platform=messageinfo.platform, + user_info=userinfo, + group_info=groupinfo, + topic=topic[0] if topic else None, + is_mentioned_bot=is_mentioned, + config=global_config, + is_emoji=message.is_emoji, + interested_rate=interested_rate + ) + current_willing = willing_manager.get_willing( + platform=messageinfo.platform, + user_info=userinfo, + group_info=groupinfo ) - current_willing = willing_manager.get_willing(event.group_id) - - print(f"\033[1;32m[{current_time}][{message.group_name}]{message.user_nickname}:\033[0m {message.processed_plain_text}\033[1;36m[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]\033[0m") + print(f"\033[1;32m[{current_time}][{groupinfo.group_name}]{userinfo.user_nickname}:\033[0m {message.processed_plain_text}\033[1;36m[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]\033[0m") response = "" if random() < reply_probability: - - + bot_user_info=UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=messageinfo.platform + ) tinking_time_point = round(time.time(), 2) think_id = 'mt' + str(tinking_time_point) - thinking_message = Message_Thinking(message=message,message_id=think_id) + thinking_message = MessageThinking.from_chat_stream( + chat_stream=chat, + message_id=think_id, + reply=message + ) message_manager.add_message(thinking_message) - willing_manager.change_reply_willing_sent(thinking_message.group_id) + willing_manager.change_reply_willing_sent( + platform=messageinfo.platform, + user_info=userinfo, + group_info=groupinfo + ) response,raw_content = await self.gpt.generate_response(message) if response: - container = message_manager.get_container(event.group_id) + container = message_manager.get_container(chat.stream_id) thinking_message = None # 找到message,删除 for msg in container.messages: - if isinstance(msg, Message_Thinking) and msg.message_id == think_id: + if isinstance(msg, MessageThinking) and msg.message_id == think_id: thinking_message = msg container.messages.remove(msg) - # print(f"\033[1;32m[思考消息删除]\033[0m 已找到思考消息对象,开始删除") break # 如果找不到思考消息,直接返回 @@ -135,11 +164,10 @@ class ChatBot: #记录开始思考的时间,避免从思考到回复的时间太久 thinking_start_time = thinking_message.thinking_start_time - message_set = MessageSet(event.group_id, global_config.BOT_QQ, think_id) # 发送消息的id和产生发送消息的message_thinking是一致的 + message_set = MessageSet(chat, think_id) #计算打字时间,1是为了模拟打字,2是避免多条回复乱序 accu_typing_time = 0 - # print(f"\033[1;32m[开始回复]\033[0m 开始将回复1载入发送容器") mark_head = False for msg in response: # print(f"\033[1;32m[回复内容]\033[0m {msg}") @@ -148,22 +176,16 @@ class ChatBot: accu_typing_time += typing_time timepoint = tinking_time_point + accu_typing_time - bot_message = Message_Sending( - group_id=event.group_id, - user_id=global_config.BOT_QQ, + message_segment = Seg(type='text', data=msg) + bot_message = MessageSending( message_id=think_id, - raw_message=msg, - plain_text=msg, - processed_plain_text=msg, - user_nickname=global_config.BOT_NICKNAME, - group_name=message.group_name, - time=timepoint, #记录了回复生成的时间 - thinking_start_time=thinking_start_time, #记录了思考开始的时间 - reply_message_id=message.message_id + chat_stream=chat, + message_segment=message_segment, + reply=message, + is_head=not mark_head, + is_emoji=False ) - await bot_message.initialize() if not mark_head: - bot_message.is_head = True mark_head = True message_set.add_message(bot_message) @@ -180,30 +202,22 @@ class ChatBot: if emoji_raw != None: emoji_path,discription = emoji_raw - emoji_cq = cq_code_tool.create_emoji_cq(emoji_path) + emoji_cq = image_path_to_base64(emoji_path) if random() < 0.5: bot_response_time = tinking_time_point - 1 else: bot_response_time = bot_response_time + 1 - bot_message = Message_Sending( - group_id=event.group_id, - user_id=global_config.BOT_QQ, - message_id=0, - raw_message=emoji_cq, - plain_text=emoji_cq, - processed_plain_text=emoji_cq, - detailed_plain_text=discription, - user_nickname=global_config.BOT_NICKNAME, - group_name=message.group_name, - time=bot_response_time, - is_emoji=True, - translate_cq=False, - thinking_start_time=thinking_start_time, - # reply_message_id=message.message_id + message_segment = Seg(type='emoji', data=emoji_cq) + bot_message = MessageSending( + message_id=think_id, + chat_stream=chat, + message_segment=message_segment, + reply=message, + is_head=False, + is_emoji=True ) - await bot_message.initialize() message_manager.add_message(bot_message) emotion = await self.gpt._get_emotion_tags(raw_content) print(f"为 '{response}' 获取到的情感标签为:{emotion}") @@ -219,8 +233,12 @@ class ChatBot: await relationship_manager.update_relationship_value(message.user_id, relationship_value=valuedict[emotion[0]]) # 使用情绪管理器更新情绪 self.mood_manager.update_mood_from_emotion(emotion[0], global_config.mood_intensity_factor) - - # willing_manager.change_reply_willing_after_sent(event.group_id) + + willing_manager.change_reply_willing_after_sent( + platform=messageinfo.platform, + user_info=userinfo, + group_info=groupinfo + ) # 创建全局ChatBot实例 chat_bot = ChatBot() \ No newline at end of file diff --git a/src/plugins/chat/chat_stream.py b/src/plugins/chat/chat_stream.py new file mode 100644 index 000000000..e617054ec --- /dev/null +++ b/src/plugins/chat/chat_stream.py @@ -0,0 +1,209 @@ +import time +import asyncio +from typing import Optional, Dict, Tuple +import hashlib + +from loguru import logger +from ...common.database import Database +from .message_base import UserInfo, GroupInfo + + +class ChatStream: + """聊天流对象,存储一个完整的聊天上下文""" + def __init__(self, + stream_id: str, + platform: str, + user_info: UserInfo, + group_info: Optional[GroupInfo] = None, + data: dict = None): + self.stream_id = stream_id + self.platform = platform + self.user_info = user_info + self.group_info = group_info + self.create_time = data.get('create_time', int(time.time())) if data else int(time.time()) + self.last_active_time = data.get('last_active_time', self.create_time) if data else self.create_time + self.saved = False + + def to_dict(self) -> dict: + """转换为字典格式""" + result = { + 'stream_id': self.stream_id, + 'platform': self.platform, + 'user_info': self.user_info.to_dict() if self.user_info else None, + 'group_info': self.group_info.to_dict() if self.group_info else None, + 'create_time': self.create_time, + 'last_active_time': self.last_active_time + } + return result + + @classmethod + def from_dict(cls, data: dict) -> 'ChatStream': + """从字典创建实例""" + user_info = UserInfo(**data.get('user_info', {})) if data.get('user_info') else None + group_info = GroupInfo(**data.get('group_info', {})) if data.get('group_info') else None + + return cls( + stream_id=data['stream_id'], + platform=data['platform'], + user_info=user_info, + group_info=group_info, + data=data + ) + + def update_active_time(self): + """更新最后活跃时间""" + self.last_active_time = int(time.time()) + self.saved = False + + +class ChatManager: + """聊天管理器,管理所有聊天流""" + _instance = None + _initialized = False + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not self._initialized: + self.streams: Dict[str, ChatStream] = {} # stream_id -> ChatStream + self.db = Database.get_instance() + self._ensure_collection() + self._initialized = True + # 在事件循环中启动初始化 + asyncio.create_task(self._initialize()) + # 启动自动保存任务 + asyncio.create_task(self._auto_save_task()) + + async def _initialize(self): + """异步初始化""" + try: + await self.load_all_streams() + logger.success(f"聊天管理器已启动,已加载 {len(self.streams)} 个聊天流") + except Exception as e: + logger.error(f"聊天管理器启动失败: {str(e)}") + + async def _auto_save_task(self): + """定期自动保存所有聊天流""" + while True: + await asyncio.sleep(300) # 每5分钟保存一次 + try: + await self._save_all_streams() + logger.info("聊天流自动保存完成") + except Exception as e: + logger.error(f"聊天流自动保存失败: {str(e)}") + + def _ensure_collection(self): + """确保数据库集合存在并创建索引""" + if 'chat_streams' not in self.db.db.list_collection_names(): + self.db.db.create_collection('chat_streams') + # 创建索引 + self.db.db.chat_streams.create_index([('stream_id', 1)], unique=True) + self.db.db.chat_streams.create_index([ + ('platform', 1), + ('user_info.user_id', 1), + ('group_info.group_id', 1) + ]) + + def _generate_stream_id(self, platform: str, user_info: UserInfo, group_info: Optional[GroupInfo] = None) -> str: + """生成聊天流唯一ID""" + # 组合关键信息 + components = [ + platform, + str(user_info.user_id), + str(group_info.group_id) if group_info else 'private' + ] + + # 使用MD5生成唯一ID + key = '_'.join(components) + return hashlib.md5(key.encode()).hexdigest() + + async def get_or_create_stream(self, + platform: str, + user_info: UserInfo, + group_info: Optional[GroupInfo] = None) -> ChatStream: + """获取或创建聊天流 + + Args: + platform: 平台标识 + user_info: 用户信息 + group_info: 群组信息(可选) + + Returns: + ChatStream: 聊天流对象 + """ + # 生成stream_id + stream_id = self._generate_stream_id(platform, user_info, group_info) + + # 检查内存中是否存在 + if stream_id in self.streams: + stream = self.streams[stream_id] + # 更新用户信息和群组信息 + stream.user_info = user_info + if group_info: + stream.group_info = group_info + stream.update_active_time() + return stream + + # 检查数据库中是否存在 + data = self.db.db.chat_streams.find_one({'stream_id': stream_id}) + if data: + stream = ChatStream.from_dict(data) + # 更新用户信息和群组信息 + stream.user_info = user_info + if group_info: + stream.group_info = group_info + stream.update_active_time() + else: + # 创建新的聊天流 + stream = ChatStream( + stream_id=stream_id, + platform=platform, + user_info=user_info, + group_info=group_info + ) + + # 保存到内存和数据库 + self.streams[stream_id] = stream + await self._save_stream(stream) + return stream + + def get_stream(self, stream_id: str) -> Optional[ChatStream]: + """通过stream_id获取聊天流""" + return self.streams.get(stream_id) + + def get_stream_by_info(self, + platform: str, + user_info: UserInfo, + group_info: Optional[GroupInfo] = None) -> Optional[ChatStream]: + """通过信息获取聊天流""" + stream_id = self._generate_stream_id(platform, user_info, group_info) + return self.streams.get(stream_id) + + async def _save_stream(self, stream: ChatStream): + """保存聊天流到数据库""" + if not stream.saved: + self.db.db.chat_streams.update_one( + {'stream_id': stream.stream_id}, + {'$set': stream.to_dict()}, + upsert=True + ) + stream.saved = True + + async def _save_all_streams(self): + """保存所有聊天流""" + for stream in self.streams.values(): + await self._save_stream(stream) + + async def load_all_streams(self): + """从数据库加载所有聊天流""" + all_streams = self.db.db.chat_streams.find({}) + for data in all_streams: + stream = ChatStream.from_dict(data) + self.streams[stream.stream_id] = stream + + +# 创建全局单例 +chat_manager = ChatManager() diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index e908219b5..b29e25b4c 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -373,6 +373,24 @@ class CQCode_tool: # 生成CQ码,设置sub_type=1表示这是表情包 return f"[CQ:image,file=file:///{escaped_path},sub_type=1]" + @staticmethod + def create_emoji_cq_base64(base64_data: str) -> str: + """ + 创建表情包CQ码 + Args: + base64_data: base64编码的表情包数据 + Returns: + 表情包CQ码字符串 + """ + # 转义base64数据 + escaped_base64 = base64_data.replace('&', '&') \ + .replace('[', '[') \ + .replace(']', ']') \ + .replace(',', ',') + # 生成CQ码,设置sub_type=1表示这是表情包 + return f"[CQ:image,file=base64://{escaped_base64},sub_type=1]" + + diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index ff59220dc..3432f011c 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -3,7 +3,7 @@ import os import random import time import traceback -from typing import Optional +from typing import Optional, Tuple import base64 import hashlib @@ -92,7 +92,7 @@ class EmojiManager: except Exception as e: logger.error(f"记录表情使用失败: {str(e)}") - async def get_emoji_for_text(self, text: str) -> Optional[str]: + async def get_emoji_for_text(self, text: str) -> Optional[Tuple[str,str]]: """根据文本内容获取相关表情包 Args: text: 输入文本 diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index a561e6490..949f45596 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -5,11 +5,10 @@ from typing import Dict, ForwardRef, List, Optional, Union import urllib3 from loguru import logger -from .cq_code import CQCode, cq_code_tool -from .utils_cq import parse_cq_code from .utils_user import get_groupname, get_user_cardname, get_user_nickname from .utils_image import image_manager from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase +from .chat_stream import ChatStream # 禁用SSL警告 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -115,36 +114,17 @@ class MessageProcessBase(MessageBase): def __init__( self, message_id: str, - user_id: int, - group_id: Optional[int] = None, - platform: str = "qq", + chat_stream: ChatStream, message_segment: Optional[Seg] = None, reply: Optional['MessageRecv'] = None ): - # 构造用户信息 - user_info = UserInfo( - platform=platform, - user_id=user_id, - user_nickname=get_user_nickname(user_id), - user_cardname=get_user_cardname(user_id) if group_id else None - ) - - # 构造群组信息(如果有) - group_info = None - if group_id: - group_info = GroupInfo( - platform=platform, - group_id=group_id, - group_name=get_groupname(group_id) - ) - # 构造基础消息信息 message_info = BaseMessageInfo( - platform=platform, + platform=chat_stream.platform, message_id=message_id, time=int(time.time()), - group_info=group_info, - user_info=user_info + group_info=chat_stream.group_info, + user_info=chat_stream.user_info ) # 调用父类初始化 @@ -241,17 +221,13 @@ class MessageThinking(MessageProcessBase): def __init__( self, message_id: str, - user_id: int, - group_id: Optional[int] = None, - platform: str = "qq", + chat_stream: ChatStream, reply: Optional['MessageRecv'] = None ): # 调用父类初始化 super().__init__( message_id=message_id, - user_id=user_id, - group_id=group_id, - platform=platform, + chat_stream=chat_stream, message_segment=None, # 思考状态不需要消息段 reply=reply ) @@ -259,6 +235,15 @@ class MessageThinking(MessageProcessBase): # 思考状态特有属性 self.interrupt = False + @classmethod + def from_chat_stream(cls, chat_stream: ChatStream, message_id: str, reply: Optional['MessageRecv'] = None) -> 'MessageThinking': + """从聊天流创建思考状态消息""" + return cls( + message_id=message_id, + chat_stream=chat_stream, + reply=reply + ) + @dataclass class MessageSending(MessageProcessBase): """发送状态的消息类""" @@ -266,19 +251,16 @@ class MessageSending(MessageProcessBase): def __init__( self, message_id: str, - user_id: int, + chat_stream: ChatStream, message_segment: Seg, - group_id: Optional[int] = None, reply: Optional['MessageRecv'] = None, - platform: str = "qq", - is_head: bool = False + is_head: bool = False, + is_emoji: bool = False ): # 调用父类初始化 super().__init__( message_id=message_id, - user_id=user_id, - group_id=group_id, - platform=platform, + chat_stream=chat_stream, message_segment=message_segment, reply=reply ) @@ -286,6 +268,12 @@ class MessageSending(MessageProcessBase): # 发送状态特有属性 self.reply_to_message_id = reply.message_info.message_id if reply else None self.is_head = is_head + self.is_emoji = is_emoji + if is_head: + self.message_segment = Seg(type='seglist', data=[ + Seg(type='reply', data=reply.message_info.message_id), + self.message_segment + ]) async def process(self) -> None: """处理消息内容,生成纯文本和详细文本""" @@ -298,26 +286,24 @@ class MessageSending(MessageProcessBase): cls, thinking: MessageThinking, message_segment: Seg, - reply: Optional['MessageRecv'] = None, - is_head: bool = False + is_head: bool = False, + is_emoji: bool = False ) -> 'MessageSending': """从思考状态消息创建发送状态消息""" return cls( message_id=thinking.message_info.message_id, - user_id=thinking.message_info.user_info.user_id, + chat_stream=thinking.chat_stream, message_segment=message_segment, - group_id=thinking.message_info.group_info.group_id if thinking.message_info.group_info else None, - reply=reply or thinking.reply, - platform=thinking.message_info.platform, - is_head=is_head + reply=thinking.reply, + is_head=is_head, + is_emoji=is_emoji ) @dataclass class MessageSet: """消息集合类,可以存储多个发送消息""" - def __init__(self, group_id: int, user_id: int, message_id: str): - self.group_id = group_id - self.user_id = user_id + def __init__(self, chat_stream: ChatStream, message_id: str): + self.chat_stream = chat_stream self.message_id = message_id self.messages: List[MessageSending] = [] self.time = round(time.time(), 2) diff --git a/src/plugins/chat/message_cq.py b/src/plugins/chat/message_cq.py index 80df4e340..7d9c6216d 100644 --- a/src/plugins/chat/message_cq.py +++ b/src/plugins/chat/message_cq.py @@ -176,7 +176,7 @@ class MessageSendCQ(MessageCQ): elif seg.type == 'image': # 如果是base64图片数据 if seg.data.startswith(('data:', 'base64:')): - return f"[CQ:image,file=base64://{seg.data}]" + return cq_code_tool.create_emoji_cq_base64(seg.data) # 如果是表情包(本地文件) return cq_code_tool.create_emoji_cq(seg.data) elif seg.type == 'at': diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 8ed30d69c..9b1ab66be 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -5,10 +5,11 @@ from typing import Dict, List, Optional, Union from nonebot.adapters.onebot.v11 import Bot from .cq_code import cq_code_tool -from .message_cq import Message, Message_Sending, Message_Thinking, MessageSet +from .message_cq import MessageSendCQ +from .message import MessageSending, MessageThinking, MessageRecv,MessageSet from .storage import MessageStorage -from .utils import calculate_typing_time from .config import global_config +from .chat_stream import chat_manager class Message_Sender: @@ -21,66 +22,59 @@ class Message_Sender: def set_bot(self, bot: Bot): """设置当前bot实例""" self._current_bot = bot - - async def send_group_message( - self, - group_id: int, - send_text: str, - auto_escape: bool = False, - reply_message_id: int = None, - at_user_id: int = None - ) -> None: - if not self._current_bot: - raise RuntimeError("Bot未设置,请先调用set_bot方法设置bot实例") - - message = send_text - - # 如果需要回复 - if reply_message_id: - reply_cq = cq_code_tool.create_reply_cq(reply_message_id) - message = reply_cq + message - - # 如果需要at - # if at_user_id: - # at_cq = cq_code_tool.create_at_cq(at_user_id) - # message = at_cq + " " + message - - - typing_time = calculate_typing_time(message) - if typing_time > 10: - typing_time = 10 - await asyncio.sleep(typing_time) - - # 发送消息 - try: - await self._current_bot.send_group_msg( - group_id=group_id, - message=message, - auto_escape=auto_escape + async def send_message( + self, + message: MessageSending, + ) -> None: + """发送消息""" + if isinstance(message, MessageSending): + message_send=MessageSendCQ( + message_id=message.message_id, + user_id=message.message_info.user_info.user_id, + message_segment=message.message_segment, + reply=message.reply ) - print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") - except Exception as e: - print(f"发生错误 {e}") - print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") + if message.message_info.group_info: + try: + await self._current_bot.send_group_msg( + group_id=message.message_info.group_info.group_id, + message=message_send.raw_message, + auto_escape=False + ) + print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") + except Exception as e: + print(f"发生错误 {e}") + print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") + else: + try: + await self._current_bot.send_private_msg( + user_id=message.message_info.user_info.user_id, + message=message_send.raw_message, + auto_escape=False + ) + print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") + except Exception as e: + print(f"发生错误 {e}") + print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") class MessageContainer: - """单个群的发送/思考消息容器""" - def __init__(self, group_id: int, max_size: int = 100): - self.group_id = group_id + """单个聊天流的发送/思考消息容器""" + def __init__(self, chat_id: str, max_size: int = 100): + self.chat_id = chat_id self.max_size = max_size self.messages = [] self.last_send_time = 0 self.thinking_timeout = 20 # 思考超时时间(秒) - def get_timeout_messages(self) -> List[Message_Sending]: + def get_timeout_messages(self) -> List[MessageSending]: """获取所有超时的Message_Sending对象(思考时间超过30秒),按thinking_start_time排序""" current_time = time.time() timeout_messages = [] for msg in self.messages: - if isinstance(msg, Message_Sending): + if isinstance(msg, MessageSending): if current_time - msg.thinking_start_time > self.thinking_timeout: timeout_messages.append(msg) @@ -89,7 +83,7 @@ class MessageContainer: return timeout_messages - def get_earliest_message(self) -> Optional[Union[Message_Thinking, Message_Sending]]: + def get_earliest_message(self) -> Optional[Union[MessageThinking, MessageSending]]: """获取thinking_start_time最早的消息对象""" if not self.messages: return None @@ -102,16 +96,15 @@ class MessageContainer: earliest_message = msg return earliest_message - def add_message(self, message: Union[Message_Thinking, Message_Sending]) -> None: + def add_message(self, message: Union[MessageThinking, MessageSending]) -> None: """添加消息到队列""" - # print(f"\033[1;32m[添加消息]\033[0m 添加消息到对应群") if isinstance(message, MessageSet): for single_message in message.messages: self.messages.append(single_message) else: self.messages.append(message) - def remove_message(self, message: Union[Message_Thinking, Message_Sending]) -> bool: + def remove_message(self, message: Union[MessageThinking, MessageSending]) -> bool: """移除消息,如果消息存在则返回True,否则返回False""" try: if message in self.messages: @@ -126,40 +119,42 @@ class MessageContainer: """检查是否有待发送的消息""" return bool(self.messages) - def get_all_messages(self) -> List[Union[Message, Message_Thinking]]: + def get_all_messages(self) -> List[Union[MessageSending, MessageThinking]]: """获取所有消息""" return list(self.messages) class MessageManager: - """管理所有群的消息容器""" + """管理所有聊天流的消息容器""" def __init__(self): - self.containers: Dict[int, MessageContainer] = {} + self.containers: Dict[str, MessageContainer] = {} # chat_id -> MessageContainer self.storage = MessageStorage() self._running = True - def get_container(self, group_id: int) -> MessageContainer: - """获取或创建群的消息容器""" - if group_id not in self.containers: - self.containers[group_id] = MessageContainer(group_id) - return self.containers[group_id] + def get_container(self, chat_id: str) -> MessageContainer: + """获取或创建聊天流的消息容器""" + if chat_id not in self.containers: + self.containers[chat_id] = MessageContainer(chat_id) + return self.containers[chat_id] - def add_message(self, message: Union[Message_Thinking, Message_Sending, MessageSet]) -> None: - container = self.get_container(message.group_id) + def add_message(self, message: Union[MessageThinking, MessageSending, MessageSet]) -> None: + chat_stream = chat_manager.get_stream_by_info( + platform=message.message_info.platform, + user_info=message.message_info.user_info, + group_info=message.message_info.group_info + ) + if not chat_stream: + raise ValueError("无法找到对应的聊天流") + container = self.get_container(chat_stream.stream_id) container.add_message(message) - async def process_group_messages(self, group_id: int): - """处理群消息""" - # if int(time.time() / 3) == time.time() / 3: - # print(f"\033[1;34m[调试]\033[0m 开始处理群{group_id}的消息") - container = self.get_container(group_id) + async def process_chat_messages(self, chat_id: str): + """处理聊天流消息""" + container = self.get_container(chat_id) if container.has_messages(): - #最早的对象,可能是思考消息,也可能是发送消息 - message_earliest = container.get_earliest_message() #一个message_thinking or message_sending + message_earliest = container.get_earliest_message() - #如果是思考消息 - if isinstance(message_earliest, Message_Thinking): - #优先等待这条消息 + if isinstance(message_earliest, MessageThinking): message_earliest.update_thinking_time() thinking_time = message_earliest.thinking_time print(f"\033[1;34m[调试]\033[0m 消息正在思考中,已思考{int(thinking_time)}秒\033[K\r", end='', flush=True) @@ -168,42 +163,36 @@ class MessageManager: if thinking_time > global_config.thinking_timeout: print(f"\033[1;33m[警告]\033[0m 消息思考超时({thinking_time}秒),移除该消息") container.remove_message(message_earliest) - else:# 如果不是message_thinking就只能是message_sending + else: print(f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中") - #直接发,等什么呢 - if message_earliest.is_head and message_earliest.update_thinking_time() >30: - await message_sender.send_group_message(group_id, message_earliest.processed_plain_text, auto_escape=False, reply_message_id=message_earliest.reply_message_id) + if message_earliest.is_head and message_earliest.update_thinking_time() > 30: + await message_sender.send_message(message_earliest) else: - await message_sender.send_group_message(group_id, message_earliest.processed_plain_text, auto_escape=False) - #移除消息 + await message_sender.send_message(message_earliest) + if message_earliest.is_emoji: message_earliest.processed_plain_text = "[表情包]" await self.storage.store_message(message_earliest, None) container.remove_message(message_earliest) - #获取并处理超时消息 - message_timeout = container.get_timeout_messages() #也许是一堆message_sending + message_timeout = container.get_timeout_messages() if message_timeout: print(f"\033[1;34m[调试]\033[0m 发现{len(message_timeout)}条超时消息") for msg in message_timeout: if msg == message_earliest: - continue # 跳过已经处理过的消息 + continue try: - #发送 - if msg.is_head and msg.update_thinking_time() >30: - await message_sender.send_group_message(group_id, msg.processed_plain_text, auto_escape=False, reply_message_id=msg.reply_message_id) + if msg.is_head and msg.update_thinking_time() > 30: + await message_sender.send_group_message(chat_id, msg.processed_plain_text, auto_escape=False, reply_message_id=msg.reply_message_id) else: - await message_sender.send_group_message(group_id, msg.processed_plain_text, auto_escape=False) - + await message_sender.send_group_message(chat_id, msg.processed_plain_text, auto_escape=False) - #如果是表情包,则替换为"[表情包]" if msg.is_emoji: msg.processed_plain_text = "[表情包]" await self.storage.store_message(msg, None) - # 安全地移除消息 if not container.remove_message(msg): print("\033[1;33m[警告]\033[0m 尝试删除不存在的消息") except Exception as e: @@ -215,8 +204,8 @@ class MessageManager: while self._running: await asyncio.sleep(1) tasks = [] - for group_id in self.containers.keys(): - tasks.append(self.process_group_messages(group_id)) + for chat_id in self.containers.keys(): + tasks.append(self.process_chat_messages(chat_id)) await asyncio.gather(*tasks) diff --git a/src/plugins/chat/relationship_manager.py b/src/plugins/chat/relationship_manager.py index 4ed7a2f11..b69f2a638 100644 --- a/src/plugins/chat/relationship_manager.py +++ b/src/plugins/chat/relationship_manager.py @@ -1,7 +1,8 @@ import asyncio -from typing import Optional +from typing import Optional, Union from ...common.database import Database +from .message_base import UserInfo class Impression: @@ -13,27 +14,36 @@ class Impression: class Relationship: user_id: int = None - # impression: Impression = None - # group_id: int = None - # group_name: str = None + platform: str = None gender: str = None age: int = None nickname: str = None relationship_value: float = None saved = False - def __init__(self, user_id: int, data=None, **kwargs): + def __init__(self, user_id: int = None, data: dict = None, user_info: UserInfo = None, **kwargs): if isinstance(data, dict): # 如果输入是字典,使用字典解析 self.user_id = data.get('user_id') + self.platform = data.get('platform', 'qq') self.gender = data.get('gender') self.age = data.get('age') self.nickname = data.get('nickname') self.relationship_value = data.get('relationship_value', 0.0) self.saved = data.get('saved', False) + elif user_info is not None: + # 如果输入是UserInfo对象 + self.user_id = user_info.user_id + self.platform = user_info.platform or 'qq' + self.nickname = user_info.user_nickname or user_info.user_cardname or "某人" + self.relationship_value = kwargs.get('relationship_value', 0.0) + self.gender = kwargs.get('gender') + self.age = kwargs.get('age') + self.saved = kwargs.get('saved', False) else: # 如果是直接传入属性值 self.user_id = kwargs.get('user_id') + self.platform = kwargs.get('platform', 'qq') self.gender = kwargs.get('gender') self.age = kwargs.get('age') self.nickname = kwargs.get('nickname') @@ -41,32 +51,63 @@ class Relationship: self.saved = kwargs.get('saved', False) - - class RelationshipManager: def __init__(self): - self.relationships: dict[int, Relationship] = {} + self.relationships: dict[tuple[int, str], Relationship] = {} # 修改为使用(user_id, platform)作为键 - async def update_relationship(self, user_id: int, data=None, **kwargs): + async def update_relationship(self, + user_id: int = None, + platform: str = None, + user_info: UserInfo = None, + data: dict = None, + **kwargs) -> Optional[Relationship]: + """更新或创建关系 + Args: + user_id: 用户ID(可选,如果提供user_info则不需要) + platform: 平台(可选,如果提供user_info则不需要) + user_info: 用户信息对象(可选) + data: 字典格式的数据(可选) + **kwargs: 其他参数 + Returns: + Relationship: 关系对象 + """ + # 确定user_id和platform + if user_info is not None: + user_id = user_info.user_id + platform = user_info.platform or 'qq' + else: + platform = platform or 'qq' + + if user_id is None: + raise ValueError("必须提供user_id或user_info") + + # 使用(user_id, platform)作为键 + key = (user_id, platform) + # 检查是否在内存中已存在 - relationship = self.relationships.get(user_id) + relationship = self.relationships.get(key) if relationship: # 如果存在,更新现有对象 if isinstance(data, dict): - for key, value in data.items(): - if hasattr(relationship, key) and value is not None: - setattr(relationship, key, value) + for k, value in data.items(): + if hasattr(relationship, k) and value is not None: + setattr(relationship, k, value) else: - for key, value in kwargs.items(): - if hasattr(relationship, key) and value is not None: - setattr(relationship, key, value) + for k, value in kwargs.items(): + if hasattr(relationship, k) and value is not None: + setattr(relationship, k, value) else: # 如果不存在,创建新对象 - relationship = Relationship(user_id, data=data) if isinstance(data, dict) else Relationship(user_id, **kwargs) - self.relationships[user_id] = relationship - - # 更新 id_name_nickname_table - # self.id_name_nickname_table[user_id] = [relationship.nickname] # 别称设置为空列表 + if user_info is not None: + relationship = Relationship(user_info=user_info, **kwargs) + elif isinstance(data, dict): + data['platform'] = platform + relationship = Relationship(user_id=user_id, data=data) + else: + kwargs['platform'] = platform + kwargs['user_id'] = user_id + relationship = Relationship(**kwargs) + self.relationships[key] = relationship # 保存到数据库 await self.storage_relationship(relationship) @@ -74,33 +115,87 @@ class RelationshipManager: return relationship - async def update_relationship_value(self, user_id: int, **kwargs): + async def update_relationship_value(self, + user_id: int = None, + platform: str = None, + user_info: UserInfo = None, + **kwargs) -> Optional[Relationship]: + """更新关系值 + Args: + user_id: 用户ID(可选,如果提供user_info则不需要) + platform: 平台(可选,如果提供user_info则不需要) + user_info: 用户信息对象(可选) + **kwargs: 其他参数 + Returns: + Relationship: 关系对象 + """ + # 确定user_id和platform + if user_info is not None: + user_id = user_info.user_id + platform = user_info.platform or 'qq' + else: + platform = platform or 'qq' + + if user_id is None: + raise ValueError("必须提供user_id或user_info") + + # 使用(user_id, platform)作为键 + key = (user_id, platform) + # 检查是否在内存中已存在 - relationship = self.relationships.get(user_id) + relationship = self.relationships.get(key) if relationship: - for key, value in kwargs.items(): - if key == 'relationship_value': + for k, value in kwargs.items(): + if k == 'relationship_value': relationship.relationship_value += value await self.storage_relationship(relationship) relationship.saved = True return relationship else: - print(f"\033[1;31m[关系管理]\033[0m 用户 {user_id} 不存在,无法更新") + # 如果不存在且提供了user_info,则创建新的关系 + if user_info is not None: + return await self.update_relationship(user_info=user_info, **kwargs) + print(f"\033[1;31m[关系管理]\033[0m 用户 {user_id}({platform}) 不存在,无法更新") return None - - def get_relationship(self, user_id: int) -> Optional[Relationship]: - """获取用户关系对象""" - if user_id in self.relationships: - return self.relationships[user_id] + def get_relationship(self, + user_id: int = None, + platform: str = None, + user_info: UserInfo = None) -> Optional[Relationship]: + """获取用户关系对象 + Args: + user_id: 用户ID(可选,如果提供user_info则不需要) + platform: 平台(可选,如果提供user_info则不需要) + user_info: 用户信息对象(可选) + Returns: + Relationship: 关系对象 + """ + # 确定user_id和platform + if user_info is not None: + user_id = user_info.user_id + platform = user_info.platform or 'qq' + else: + platform = platform or 'qq' + + if user_id is None: + raise ValueError("必须提供user_id或user_info") + + key = (user_id, platform) + if key in self.relationships: + return self.relationships[key] else: return 0 async def load_relationship(self, data: dict) -> Relationship: - """从数据库加载或创建新的关系对象""" - rela = Relationship(user_id=data['user_id'], data=data) + """从数据库加载或创建新的关系对象""" + # 确保data中有platform字段,如果没有则默认为'qq' + if 'platform' not in data: + data['platform'] = 'qq' + + rela = Relationship(data=data) rela.saved = True - self.relationships[rela.user_id] = rela + key = (rela.user_id, rela.platform) + self.relationships[key] = rela return rela async def load_all_relationships(self): @@ -117,9 +212,7 @@ class RelationshipManager: all_relationships = db.db.relationships.find({}) # 依次加载每条记录 for data in all_relationships: - user_id = data['user_id'] - relationship = await self.load_relationship(data) - self.relationships[user_id] = relationship + await self.load_relationship(data) print(f"\033[1;32m[关系管理]\033[0m 已加载 {len(self.relationships)} 条关系记录") while True: @@ -130,16 +223,15 @@ class RelationshipManager: async def _save_all_relationships(self): """将所有关系数据保存到数据库""" # 保存所有关系数据 - for userid, relationship in self.relationships.items(): + for (userid, platform), relationship in self.relationships.items(): if not relationship.saved: relationship.saved = True await self.storage_relationship(relationship) - async def storage_relationship(self,relationship: Relationship): - """ - 将关系记录存储到数据库中 - """ + async def storage_relationship(self, relationship: Relationship): + """将关系记录存储到数据库中""" user_id = relationship.user_id + platform = relationship.platform nickname = relationship.nickname relationship_value = relationship.relationship_value gender = relationship.gender @@ -148,8 +240,9 @@ class RelationshipManager: db = Database.get_instance() db.db.relationships.update_one( - {'user_id': user_id}, + {'user_id': user_id, 'platform': platform}, {'$set': { + 'platform': platform, 'nickname': nickname, 'relationship_value': relationship_value, 'gender': gender, @@ -159,12 +252,35 @@ class RelationshipManager: upsert=True ) - def get_name(self, user_id: int) -> str: + def get_name(self, + user_id: int = None, + platform: str = None, + user_info: UserInfo = None) -> str: + """获取用户昵称 + Args: + user_id: 用户ID(可选,如果提供user_info则不需要) + platform: 平台(可选,如果提供user_info则不需要) + user_info: 用户信息对象(可选) + Returns: + str: 用户昵称 + """ + # 确定user_id和platform + if user_info is not None: + user_id = user_info.user_id + platform = user_info.platform or 'qq' + else: + platform = platform or 'qq' + + if user_id is None: + raise ValueError("必须提供user_id或user_info") + # 确保user_id是整数类型 user_id = int(user_id) - if user_id in self.relationships: - - return self.relationships[user_id].nickname + key = (user_id, platform) + if key in self.relationships: + return self.relationships[key].nickname + elif user_info is not None: + return user_info.user_nickname or user_info.user_cardname or "某人" else: return "某人" diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index 4b1bf0ca4..2c1f4071d 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -1,47 +1,28 @@ -from typing import Optional +from typing import Optional, Union from ...common.database import Database -from .message_cq import Message +from .message_base import MessageBase +from .message import MessageSending, MessageRecv class MessageStorage: def __init__(self): self.db = Database.get_instance() - async def store_message(self, message: Message, topic: Optional[str] = None) -> None: + async def store_message(self, message: Union[MessageSending, MessageRecv], topic: Optional[str] = None) -> None: """存储消息到数据库""" try: - if not message.is_emoji: - message_data = { - "group_id": message.group_id, - "user_id": message.user_id, - "message_id": message.message_id, - "raw_message": message.raw_message, - "plain_text": message.plain_text, + message_data = { + "message_id": message.message_info.message_id, + "time": message.message_info.time, + "group_id": message.message_info.group_info.group_id, + "group_name": message.message_info.group_info.group_name, + "user_id": message.message_info.user_info.user_id, + "user_nickname": message.message_info.user_info.user_nickname, + "detailed_plain_text": message.detailed_plain_text, "processed_plain_text": message.processed_plain_text, - "time": message.time, - "user_nickname": message.user_nickname, - "user_cardname": message.user_cardname, - "group_name": message.group_name, "topic": topic, - "detailed_plain_text": message.detailed_plain_text, } - else: - message_data = { - "group_id": message.group_id, - "user_id": message.user_id, - "message_id": message.message_id, - "raw_message": message.raw_message, - "plain_text": message.plain_text, - "processed_plain_text": '[表情包]', - "time": message.time, - "user_nickname": message.user_nickname, - "user_cardname": message.user_cardname, - "group_name": message.group_name, - "topic": topic, - "detailed_plain_text": message.detailed_plain_text, - } - self.db.db.messages.insert_one(message_data) except Exception as e: print(f"\033[1;31m[错误]\033[0m 存储消息失败: {e}") diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 001b66207..d73071ba4 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -1,10 +1,15 @@ import asyncio +from typing import Dict +from loguru import logger + from .config import global_config +from .message_base import UserInfo, GroupInfo +from .chat_stream import chat_manager class WillingManager: def __init__(self): - self.group_reply_willing = {} # 存储每个群的回复意愿 + self.chat_reply_willing: Dict[str, float] = {} # 存储每个聊天流的回复意愿 self._decay_task = None self._started = False @@ -12,20 +17,35 @@ class WillingManager: """定期衰减回复意愿""" while True: await asyncio.sleep(5) - for group_id in self.group_reply_willing: - self.group_reply_willing[group_id] = max(0, self.group_reply_willing[group_id] * 0.6) + for chat_id in self.chat_reply_willing: + self.chat_reply_willing[chat_id] = max(0, self.chat_reply_willing[chat_id] * 0.6) - def get_willing(self, group_id: int) -> float: - """获取指定群组的回复意愿""" - return self.group_reply_willing.get(group_id, 0) + def get_willing(self, platform: str, user_info: UserInfo, group_info: GroupInfo = None) -> float: + """获取指定聊天流的回复意愿""" + stream = chat_manager.get_stream_by_info(platform, user_info, group_info) + if stream: + return self.chat_reply_willing.get(stream.stream_id, 0) + return 0 - def set_willing(self, group_id: int, willing: float): - """设置指定群组的回复意愿""" - self.group_reply_willing[group_id] = willing + def set_willing(self, chat_id: str, willing: float): + """设置指定聊天流的回复意愿""" + self.chat_reply_willing[chat_id] = willing - def change_reply_willing_received(self, group_id: int, topic: str, is_mentioned_bot: bool, config, user_id: int = None, is_emoji: bool = False, interested_rate: float = 0) -> float: - """改变指定群组的回复意愿并返回回复概率""" - current_willing = self.group_reply_willing.get(group_id, 0) + async def change_reply_willing_received(self, + platform: str, + user_info: UserInfo, + group_info: GroupInfo = None, + topic: str = None, + is_mentioned_bot: bool = False, + config = None, + is_emoji: bool = False, + interested_rate: float = 0) -> float: + """改变指定聊天流的回复意愿并返回回复概率""" + # 获取或创建聊天流 + stream = await chat_manager.get_or_create_stream(platform, user_info, group_info) + chat_id = stream.stream_id + + current_willing = self.chat_reply_willing.get(chat_id, 0) # print(f"初始意愿: {current_willing}") if is_mentioned_bot and current_willing < 1.0: @@ -49,31 +69,37 @@ class WillingManager: # print(f"放大系数_willing: {global_config.response_willing_amplifier}, 当前意愿: {current_willing}") reply_probability = max((current_willing - 0.45) * 2, 0) - if group_id not in config.talk_allowed_groups: - current_willing = 0 - reply_probability = 0 - - if group_id in config.talk_frequency_down_groups: - reply_probability = reply_probability / global_config.down_frequency_rate + + # 检查群组权限(如果是群聊) + if group_info: + if group_info.group_id not in config.talk_allowed_groups: + current_willing = 0 + reply_probability = 0 + + if group_info.group_id in config.talk_frequency_down_groups: + reply_probability = reply_probability / global_config.down_frequency_rate reply_probability = min(reply_probability, 1) if reply_probability < 0: reply_probability = 0 - - self.group_reply_willing[group_id] = min(current_willing, 3.0) + self.chat_reply_willing[chat_id] = min(current_willing, 3.0) return reply_probability - def change_reply_willing_sent(self, group_id: int): - """开始思考后降低群组的回复意愿""" - current_willing = self.group_reply_willing.get(group_id, 0) - self.group_reply_willing[group_id] = max(0, current_willing - 2) + def change_reply_willing_sent(self, platform: str, user_info: UserInfo, group_info: GroupInfo = None): + """开始思考后降低聊天流的回复意愿""" + stream = chat_manager.get_stream_by_info(platform, user_info, group_info) + if stream: + current_willing = self.chat_reply_willing.get(stream.stream_id, 0) + self.chat_reply_willing[stream.stream_id] = max(0, current_willing - 2) - def change_reply_willing_after_sent(self, group_id: int): - """发送消息后提高群组的回复意愿""" - current_willing = self.group_reply_willing.get(group_id, 0) - if current_willing < 1: - self.group_reply_willing[group_id] = min(1, current_willing + 0.2) + def change_reply_willing_after_sent(self, platform: str, user_info: UserInfo, group_info: GroupInfo = None): + """发送消息后提高聊天流的回复意愿""" + stream = chat_manager.get_stream_by_info(platform, user_info, group_info) + if stream: + current_willing = self.chat_reply_willing.get(stream.stream_id, 0) + if current_willing < 1: + self.chat_reply_willing[stream.stream_id] = min(1, current_willing + 0.2) async def ensure_started(self): """确保衰减任务已启动""" From 288dbb68b5d8820c8507151d565fa803aebbbaba Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Mon, 10 Mar 2025 00:27:42 +0800 Subject: [PATCH 003/162] refactor: logger in src\plugins\chat\__init__.py --- src/plugins/chat/__init__.py | 39 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index 485d9d759..795658800 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -2,9 +2,8 @@ import asyncio import time from loguru import logger -from nonebot import get_driver, on_command, on_message, require +from nonebot import get_driver, on_message, require from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment -from nonebot.rule import to_me from nonebot.typing import T_State from ...common.database import Database @@ -16,6 +15,10 @@ from .config import global_config from .emoji_manager import emoji_manager from .relationship_manager import relationship_manager from .willing_manager import willing_manager +from ..memory_system.memory import hippocampus, memory_graph +from .bot import ChatBot +from .message_sender import message_manager, message_sender + # 创建LLM统计实例 llm_stats = LLMStatistics("llm_statistics.txt") @@ -35,19 +38,13 @@ Database.initialize( password=config.MONGODB_PASSWORD, auth_source=config.MONGODB_AUTH_SOURCE ) -print("\033[1;32m[初始化数据库完成]\033[0m") +logger.success("初始化数据库成功") -# 导入其他模块 -from ..memory_system.memory import hippocampus, memory_graph -from .bot import ChatBot - -# from .message_send_control import message_sender -from .message_sender import message_manager, message_sender # 初始化表情管理器 emoji_manager.initialize() -print(f"\033[1;32m正在唤醒{global_config.BOT_NICKNAME}......\033[0m") +logger.debug(f"正在唤醒{global_config.BOT_NICKNAME}......") # 创建机器人实例 chat_bot = ChatBot() # 注册群消息处理器 @@ -61,7 +58,7 @@ async def start_background_tasks(): """启动后台任务""" # 启动LLM统计 llm_stats.start() - logger.success("[初始化]LLM统计功能已启动") + logger.success("LLM统计功能启动成功") # 初始化并启动情绪管理器 mood_manager = MoodManager.get_instance() @@ -77,7 +74,7 @@ async def start_background_tasks(): @driver.on_startup async def init_relationships(): """在 NoneBot2 启动时初始化关系管理器""" - print("\033[1;32m[初始化]\033[0m 正在加载用户关系数据...") + logger.debug("正在加载用户关系数据...") await relationship_manager.load_all_relationships() asyncio.create_task(relationship_manager._start_relationship_manager()) @@ -86,19 +83,19 @@ async def init_relationships(): async def _(bot: Bot): """Bot连接成功时的处理""" global _message_manager_started - print(f"\033[1;38;5;208m-----------{global_config.BOT_NICKNAME}成功连接!-----------\033[0m") + logger.debug(f"-----------{global_config.BOT_NICKNAME}成功连接!-----------") await willing_manager.ensure_started() message_sender.set_bot(bot) - print("\033[1;38;5;208m-----------消息发送器已启动!-----------\033[0m") + logger.success("-----------消息发送器已启动!-----------") if not _message_manager_started: asyncio.create_task(message_manager.start_processor()) _message_manager_started = True - print("\033[1;38;5;208m-----------消息处理器已启动!-----------\033[0m") + logger.success("-----------消息处理器已启动!-----------") asyncio.create_task(emoji_manager._periodic_scan(interval_MINS=global_config.EMOJI_REGISTER_INTERVAL)) - print("\033[1;38;5;208m-----------开始偷表情包!-----------\033[0m") + logger.success("-----------开始偷表情包!-----------") @group_msg.handle() @@ -110,13 +107,15 @@ async def _(bot: Bot, event: GroupMessageEvent, state: T_State): @scheduler.scheduled_job("interval", seconds=global_config.build_memory_interval, id="build_memory") async def build_memory_task(): """每build_memory_interval秒执行一次记忆构建""" - print( - "\033[1;32m[记忆构建]\033[0m -------------------------------------------开始构建记忆-------------------------------------------") + logger.debug( + "[记忆构建]" + "------------------------------------开始构建记忆--------------------------------------") start_time = time.time() await hippocampus.operation_build_memory(chat_size=20) end_time = time.time() - print( - f"\033[1;32m[记忆构建]\033[0m -------------------------------------------记忆构建完成:耗时: {end_time - start_time:.2f} 秒-------------------------------------------") + logger.success( + f"[记忆构建]--------------------------记忆构建完成:耗时: {end_time - start_time:.2f} " + "秒-------------------------------------------") @scheduler.scheduled_job("interval", seconds=global_config.forget_memory_interval, id="forget_memory") From 5746afaa2aeeb77b4c3bc3ca2f65e02b821acf89 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Mon, 10 Mar 2025 00:33:13 +0800 Subject: [PATCH 004/162] refactor: logger in src\plugins\chat\bot.py --- src/plugins/chat/bot.py | 103 +++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index a02c4a059..a1d710122 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -31,10 +31,10 @@ class ChatBot: self._started = False self.mood_manager = MoodManager.get_instance() # 获取情绪管理器单例 self.mood_manager.start_mood_update() # 启动情绪更新 - + self.emoji_chance = 0.2 # 发送表情包的基础概率 # self.message_streams = MessageStreamContainer() - + async def _ensure_started(self): """确保所有任务已启动""" if not self._started: @@ -42,26 +42,26 @@ class ChatBot: async def handle_message(self, event: GroupMessageEvent, bot: Bot) -> None: """处理收到的群消息""" - + if event.group_id not in global_config.talk_allowed_groups: return self.bot = bot # 更新 bot 实例 - + if event.user_id in global_config.ban_user_id: return group_info = await bot.get_group_info(group_id=event.group_id) sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) - - await relationship_manager.update_relationship(user_id = event.user_id, data = sender_info) - await relationship_manager.update_relationship_value(user_id = event.user_id, relationship_value = 0.5) - + + await relationship_manager.update_relationship(user_id=event.user_id, data=sender_info) + await relationship_manager.update_relationship_value(user_id=event.user_id, relationship_value=0.5) + message = Message( group_id=event.group_id, user_id=event.user_id, message_id=event.message_id, user_cardname=sender_info['card'], - raw_message=str(event.original_message), + raw_message=str(event.original_message), plain_text=event.get_plaintext(), reply_message=event.reply, ) @@ -70,26 +70,26 @@ class ChatBot: # 过滤词 for word in global_config.ban_words: if word in message.detailed_plain_text: - logger.info(f"\033[1;32m[{message.group_name}]{message.user_nickname}:\033[0m {message.processed_plain_text}") + logger.info( + f"\033[1;32m[{message.group_name}]{message.user_nickname}:\033[0m {message.processed_plain_text}") logger.info(f"\033[1;32m[过滤词识别]\033[0m 消息中含有{word},filtered") return - + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.time)) - - # topic=await topic_identifier.identify_topic_llm(message.processed_plain_text) topic = '' interested_rate = 0 - interested_rate = await hippocampus.memory_activate_value(message.processed_plain_text)/100 - print(f"\033[1;32m[记忆激活]\033[0m 对{message.processed_plain_text}的激活度:---------------------------------------{interested_rate}\n") + interested_rate = await hippocampus.memory_activate_value(message.processed_plain_text) / 100 + logger.debug(f"\033[1;32m[记忆激活]\033[0m 对{message.processed_plain_text}" + "的激活度:---------------------------------------{interested_rate}\n") # logger.info(f"\033[1;32m[主题识别]\033[0m 使用{global_config.topic_extract}主题: {topic}") - + await self.storage.store_message(message, topic[0] if topic else None) is_mentioned = is_mentioned_bot_in_txt(message.processed_plain_text) reply_probability = willing_manager.change_reply_willing_received( - event.group_id, + event.group_id, topic[0] if topic else None, is_mentioned, global_config, @@ -98,25 +98,25 @@ class ChatBot: interested_rate ) current_willing = willing_manager.get_willing(event.group_id) - - - print(f"\033[1;32m[{current_time}][{message.group_name}]{message.user_nickname}:\033[0m {message.processed_plain_text}\033[1;36m[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]\033[0m") + + logger.debug( + f"\033[1;32m[{current_time}][{message.group_name}]{message.user_nickname}:\033[0m " + "{message.processed_plain_text}\033[1;36m[回复意愿:{current_willing:.2f}][概率:{reply_probability * " + "100:.1f}%]\033[0m") response = "" - + if random() < reply_probability: - - tinking_time_point = round(time.time(), 2) think_id = 'mt' + str(tinking_time_point) - thinking_message = Message_Thinking(message=message,message_id=think_id) - + thinking_message = Message_Thinking(message=message, message_id=think_id) + message_manager.add_message(thinking_message) willing_manager.change_reply_willing_sent(thinking_message.group_id) - - response,raw_content = await self.gpt.generate_response(message) - + + response, raw_content = await self.gpt.generate_response(message) + if response: container = message_manager.get_container(event.group_id) thinking_message = None @@ -127,27 +127,28 @@ class ChatBot: container.messages.remove(msg) # print(f"\033[1;32m[思考消息删除]\033[0m 已找到思考消息对象,开始删除") break - + # 如果找不到思考消息,直接返回 if not thinking_message: print(f"\033[1;33m[警告]\033[0m 未找到对应的思考消息,可能已超时被移除") return - - #记录开始思考的时间,避免从思考到回复的时间太久 + + # 记录开始思考的时间,避免从思考到回复的时间太久 thinking_start_time = thinking_message.thinking_start_time - message_set = MessageSet(event.group_id, global_config.BOT_QQ, think_id) # 发送消息的id和产生发送消息的message_thinking是一致的 - #计算打字时间,1是为了模拟打字,2是避免多条回复乱序 + message_set = MessageSet(event.group_id, global_config.BOT_QQ, + think_id) # 发送消息的id和产生发送消息的message_thinking是一致的 + # 计算打字时间,1是为了模拟打字,2是避免多条回复乱序 accu_typing_time = 0 - + # print(f"\033[1;32m[开始回复]\033[0m 开始将回复1载入发送容器") mark_head = False for msg in response: # print(f"\033[1;32m[回复内容]\033[0m {msg}") - #通过时间改变时间戳 + # 通过时间改变时间戳 typing_time = calculate_typing_time(msg) accu_typing_time += typing_time timepoint = tinking_time_point + accu_typing_time - + bot_message = Message_Sending( group_id=event.group_id, user_id=global_config.BOT_QQ, @@ -157,8 +158,8 @@ class ChatBot: processed_plain_text=msg, user_nickname=global_config.BOT_NICKNAME, group_name=message.group_name, - time=timepoint, #记录了回复生成的时间 - thinking_start_time=thinking_start_time, #记录了思考开始的时间 + time=timepoint, # 记录了回复生成的时间 + thinking_start_time=thinking_start_time, # 记录了思考开始的时间 reply_message_id=message.message_id ) await bot_message.initialize() @@ -166,27 +167,27 @@ class ChatBot: bot_message.is_head = True mark_head = True message_set.add_message(bot_message) - - #message_set 可以直接加入 message_manager + + # message_set 可以直接加入 message_manager # print(f"\033[1;32m[回复]\033[0m 将回复载入发送容器") message_manager.add_message(message_set) - + bot_response_time = tinking_time_point if random() < global_config.emoji_chance: emoji_raw = await emoji_manager.get_emoji_for_text(response) - + # 检查是否 <没有找到> emoji if emoji_raw != None: - emoji_path,discription = emoji_raw + emoji_path, discription = emoji_raw emoji_cq = CQCode.create_emoji_cq(emoji_path) - + if random() < 0.5: bot_response_time = tinking_time_point - 1 else: bot_response_time = bot_response_time + 1 - + bot_message = Message_Sending( group_id=event.group_id, user_id=global_config.BOT_QQ, @@ -206,8 +207,8 @@ class ChatBot: await bot_message.initialize() message_manager.add_message(bot_message) emotion = await self.gpt._get_emotion_tags(raw_content) - print(f"为 '{response}' 获取到的情感标签为:{emotion}") - valuedict={ + logger.debug(f"为 '{response}' 获取到的情感标签为:{emotion}") + valuedict = { 'happy': 0.5, 'angry': -1, 'sad': -0.5, @@ -216,11 +217,13 @@ class ChatBot: 'fearful': -0.7, 'neutral': 0.1 } - await relationship_manager.update_relationship_value(message.user_id, relationship_value=valuedict[emotion[0]]) + await relationship_manager.update_relationship_value(message.user_id, + relationship_value=valuedict[emotion[0]]) # 使用情绪管理器更新情绪 self.mood_manager.update_mood_from_emotion(emotion[0], global_config.mood_intensity_factor) - + # willing_manager.change_reply_willing_after_sent(event.group_id) + # 创建全局ChatBot实例 -chat_bot = ChatBot() \ No newline at end of file +chat_bot = ChatBot() From 052802c85161ee7bf149b1126c16dcae1bdc9169 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Mon, 10 Mar 2025 00:52:04 +0800 Subject: [PATCH 005/162] refactor: logger promotion --- src/plugins/chat/__init__.py | 2 +- src/plugins/chat/emoji_manager.py | 2 +- src/plugins/chat/relationship_manager.py | 54 +++++++++++----------- src/plugins/schedule/schedule_generator.py | 12 ++--- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index 795658800..bd71be019 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -63,7 +63,7 @@ async def start_background_tasks(): # 初始化并启动情绪管理器 mood_manager = MoodManager.get_instance() mood_manager.start_mood_update(update_interval=global_config.mood_update_interval) - logger.success("[初始化]情绪管理器已启动") + logger.success("情绪管理器启动成功") # 只启动表情包管理任务 asyncio.create_task(emoji_manager.start_periodic_check(interval_MINS=global_config.EMOJI_CHECK_INTERVAL)) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 4f2637738..05c5aeada 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -302,7 +302,7 @@ class EmojiManager: # 从数据库中删除记录 result = self.db.db.emoji.delete_one({'_id': emoji['_id']}) if result.deleted_count > 0: - logger.success(f"成功删除数据库记录: {emoji['_id']}") + logger.debug(f"成功删除数据库记录: {emoji['_id']}") removed_count += 1 else: logger.error(f"删除数据库记录失败: {emoji['_id']}") diff --git a/src/plugins/chat/relationship_manager.py b/src/plugins/chat/relationship_manager.py index 4ed7a2f11..4d82184c8 100644 --- a/src/plugins/chat/relationship_manager.py +++ b/src/plugins/chat/relationship_manager.py @@ -1,4 +1,5 @@ import asyncio +from loguru import logger from typing import Optional from ...common.database import Database @@ -8,9 +9,10 @@ class Impression: traits: str = None called: str = None know_time: float = None - + relationship_value: float = None + class Relationship: user_id: int = None # impression: Impression = None @@ -21,7 +23,7 @@ class Relationship: nickname: str = None relationship_value: float = None saved = False - + def __init__(self, user_id: int, data=None, **kwargs): if isinstance(data, dict): # 如果输入是字典,使用字典解析 @@ -39,14 +41,12 @@ class Relationship: self.nickname = kwargs.get('nickname') self.relationship_value = kwargs.get('relationship_value', 0.0) self.saved = kwargs.get('saved', False) - - class RelationshipManager: def __init__(self): - self.relationships: dict[int, Relationship] = {} - + self.relationships: dict[int, Relationship] = {} + async def update_relationship(self, user_id: int, data=None, **kwargs): # 检查是否在内存中已存在 relationship = self.relationships.get(user_id) @@ -62,7 +62,8 @@ class RelationshipManager: setattr(relationship, key, value) else: # 如果不存在,创建新对象 - relationship = Relationship(user_id, data=data) if isinstance(data, dict) else Relationship(user_id, **kwargs) + relationship = Relationship(user_id, data=data) if isinstance(data, dict) else Relationship(user_id, + **kwargs) self.relationships[user_id] = relationship # 更新 id_name_nickname_table @@ -71,9 +72,9 @@ class RelationshipManager: # 保存到数据库 await self.storage_relationship(relationship) relationship.saved = True - + return relationship - + async def update_relationship_value(self, user_id: int, **kwargs): # 检查是否在内存中已存在 relationship = self.relationships.get(user_id) @@ -85,31 +86,30 @@ class RelationshipManager: relationship.saved = True return relationship else: - print(f"\033[1;31m[关系管理]\033[0m 用户 {user_id} 不存在,无法更新") + logger.warning(f"用户 {user_id} 不存在,无法更新") return None - - + def get_relationship(self, user_id: int) -> Optional[Relationship]: """获取用户关系对象""" if user_id in self.relationships: return self.relationships[user_id] else: return 0 - + async def load_relationship(self, data: dict) -> Relationship: - """从数据库加载或创建新的关系对象""" + """从数据库加载或创建新的关系对象""" rela = Relationship(user_id=data['user_id'], data=data) rela.saved = True self.relationships[rela.user_id] = rela return rela - + async def load_all_relationships(self): """加载所有关系对象""" db = Database.get_instance() all_relationships = db.db.relationships.find({}) for data in all_relationships: await self.load_relationship(data) - + async def _start_relationship_manager(self): """每5分钟自动保存一次关系数据""" db = Database.get_instance() @@ -119,23 +119,23 @@ class RelationshipManager: for data in all_relationships: user_id = data['user_id'] relationship = await self.load_relationship(data) - self.relationships[user_id] = relationship - print(f"\033[1;32m[关系管理]\033[0m 已加载 {len(self.relationships)} 条关系记录") - + self.relationships[user_id] = relationship + logger.debug(f"已加载 {len(self.relationships)} 条关系记录") + while True: - print("\033[1;32m[关系管理]\033[0m 正在自动保存关系") + logger.debug("正在自动保存关系") await asyncio.sleep(300) # 等待300秒(5分钟) await self._save_all_relationships() - + async def _save_all_relationships(self): - """将所有关系数据保存到数据库""" + """将所有关系数据保存到数据库""" # 保存所有关系数据 for userid, relationship in self.relationships.items(): if not relationship.saved: relationship.saved = True await self.storage_relationship(relationship) - - async def storage_relationship(self,relationship: Relationship): + + async def storage_relationship(self, relationship: Relationship): """ 将关系记录存储到数据库中 """ @@ -145,7 +145,7 @@ class RelationshipManager: gender = relationship.gender age = relationship.age saved = relationship.saved - + db = Database.get_instance() db.db.relationships.update_one( {'user_id': user_id}, @@ -158,7 +158,7 @@ class RelationshipManager: }}, upsert=True ) - + def get_name(self, user_id: int) -> str: # 确保user_id是整数类型 user_id = int(user_id) @@ -169,4 +169,4 @@ class RelationshipManager: return "某人" -relationship_manager = RelationshipManager() \ No newline at end of file +relationship_manager = RelationshipManager() diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index ffe99a2da..fc07a152d 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -57,12 +57,12 @@ class ScheduleGenerator: existing_schedule = self.db.db.schedule.find_one({"date": date_str}) if existing_schedule: - logger.info(f"{date_str}的日程已存在:") + logger.debug(f"{date_str}的日程已存在:") schedule_text = existing_schedule["schedule"] # print(self.schedule_text) - elif read_only == False: - logger.info(f"{date_str}的日程不存在,准备生成新的日程。") + elif not read_only: + logger.debug(f"{date_str}的日程不存在,准备生成新的日程。") prompt = f"""我是{global_config.BOT_NICKNAME},{global_config.PROMPT_SCHEDULE_GEN},请为我生成{date_str}({weekday})的日程安排,包括:""" + \ """ 1. 早上的学习和工作安排 @@ -78,7 +78,7 @@ class ScheduleGenerator: schedule_text = "生成日程时出错了" # print(self.schedule_text) else: - logger.info(f"{date_str}的日程不存在。") + logger.debug(f"{date_str}的日程不存在。") schedule_text = "忘了" return schedule_text, None @@ -154,10 +154,10 @@ class ScheduleGenerator: logger.warning("今日日程有误,将在下次运行时重新生成") self.db.db.schedule.delete_one({"date": datetime.datetime.now().strftime("%Y-%m-%d")}) else: - logger.info("\n=== 今日日程安排 ===") + logger.info("=== 今日日程安排 ===") for time_str, activity in self.today_schedule.items(): logger.info(f"时间[{time_str}]: 活动[{activity}]") - logger.info("==================\n") + logger.info("==================") # def main(): From 8d99592b32f834afd109d62b88fe1f0809d113c9 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Mon, 10 Mar 2025 01:15:31 +0800 Subject: [PATCH 006/162] =?UTF-8?q?fix:=20logger=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 1 + src/plugins/chat/config.py | 2 +- src/plugins/memory_system/memory.py | 272 ++++++++++++++-------------- 3 files changed, 142 insertions(+), 133 deletions(-) diff --git a/bot.py b/bot.py index 84ce5067b..c9e397724 100644 --- a/bot.py +++ b/bot.py @@ -149,6 +149,7 @@ if __name__ == "__main__": init_config() init_env() load_env() + load_logger() env_config = {key: os.getenv(key) for key in os.environ} scan_provider(env_config) diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 49963ad3b..02fccc863 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -162,7 +162,7 @@ class BotConfig: personality_config = parent['personality'] personality = personality_config.get('prompt_personality') if len(personality) >= 2: - logger.info(f"载入自定义人格:{personality}") + logger.debug(f"载入自定义人格:{personality}") config.PROMPT_PERSONALITY = personality_config.get('prompt_personality', config.PROMPT_PERSONALITY) logger.info(f"载入自定义日程prompt:{personality_config.get('prompt_schedule', config.PROMPT_SCHEDULE_GEN)}") config.PROMPT_SCHEDULE_GEN = personality_config.get('prompt_schedule', config.PROMPT_SCHEDULE_GEN) diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 08fc6d30c..8e917caf4 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -7,6 +7,7 @@ import time import jieba import networkx as nx +from loguru import logger from ...common.database import Database # 使用正确的导入语法 from ..chat.config import global_config from ..chat.utils import ( @@ -22,7 +23,7 @@ class Memory_graph: def __init__(self): self.G = nx.Graph() # 使用 networkx 的图结构 self.db = Database.get_instance() - + def connect_dot(self, concept1, concept2): # 如果边已存在,增加 strength if self.G.has_edge(concept1, concept2): @@ -30,7 +31,7 @@ class Memory_graph: else: # 如果是新边,初始化 strength 为 1 self.G.add_edge(concept1, concept2, strength=1) - + def add_dot(self, concept, memory): if concept in self.G: # 如果节点已存在,将新记忆添加到现有列表中 @@ -44,7 +45,7 @@ class Memory_graph: else: # 如果是新节点,创建新的记忆列表 self.G.add_node(concept, memory_items=[memory]) - + def get_dot(self, concept): # 检查节点是否存在于图中 if concept in self.G: @@ -56,13 +57,13 @@ class Memory_graph: def get_related_item(self, topic, depth=1): if topic not in self.G: return [], [] - + first_layer_items = [] second_layer_items = [] - + # 获取相邻节点 neighbors = list(self.G.neighbors(topic)) - + # 获取当前节点的记忆项 node_data = self.get_dot(topic) if node_data: @@ -73,7 +74,7 @@ class Memory_graph: first_layer_items.extend(memory_items) else: first_layer_items.append(memory_items) - + # 只在depth=2时获取第二层记忆 if depth >= 2: # 获取相邻节点的记忆项 @@ -87,9 +88,9 @@ class Memory_graph: second_layer_items.extend(memory_items) else: second_layer_items.append(memory_items) - + return first_layer_items, second_layer_items - + @property def dots(self): # 返回所有节点对应的 Memory_dot 对象 @@ -99,43 +100,43 @@ class Memory_graph: """随机删除指定话题中的一条记忆,如果话题没有记忆则移除该话题节点""" if topic not in self.G: return None - + # 获取话题节点数据 node_data = self.G.nodes[topic] - + # 如果节点存在memory_items if 'memory_items' in node_data: memory_items = node_data['memory_items'] - + # 确保memory_items是列表 if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] - + # 如果有记忆项可以删除 if memory_items: # 随机选择一个记忆项删除 removed_item = random.choice(memory_items) memory_items.remove(removed_item) - + # 更新节点的记忆项 if memory_items: self.G.nodes[topic]['memory_items'] = memory_items else: # 如果没有记忆项了,删除整个节点 self.G.remove_node(topic) - + return removed_item - + return None # 海马体 class Hippocampus: - def __init__(self,memory_graph:Memory_graph): + def __init__(self, memory_graph: Memory_graph): self.memory_graph = memory_graph - self.llm_topic_judge = LLM_request(model = global_config.llm_topic_judge,temperature=0.5) - self.llm_summary_by_topic = LLM_request(model = global_config.llm_summary_by_topic,temperature=0.5) - + self.llm_topic_judge = LLM_request(model=global_config.llm_topic_judge, temperature=0.5) + self.llm_summary_by_topic = LLM_request(model=global_config.llm_summary_by_topic, temperature=0.5) + def get_all_node_names(self) -> list: """获取记忆图中所有节点的名字列表 @@ -156,8 +157,8 @@ class Hippocampus: """计算边的特征值""" nodes = sorted([source, target]) return hash(f"{nodes[0]}:{nodes[1]}") - - def get_memory_sample(self, chat_size=20, time_frequency:dict={'near':2,'mid':4,'far':3}): + + def get_memory_sample(self, chat_size=20, time_frequency: dict = {'near': 2, 'mid': 4, 'far': 3}): """获取记忆样本 Returns: @@ -165,26 +166,26 @@ class Hippocampus: """ current_timestamp = datetime.datetime.now().timestamp() chat_samples = [] - + # 短期:1h 中期:4h 长期:24h for _ in range(time_frequency.get('near')): random_time = current_timestamp - random.randint(1, 3600) messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) - + for _ in range(time_frequency.get('mid')): - random_time = current_timestamp - random.randint(3600, 3600*4) + random_time = current_timestamp - random.randint(3600, 3600 * 4) messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) - + for _ in range(time_frequency.get('far')): - random_time = current_timestamp - random.randint(3600*4, 3600*24) + random_time = current_timestamp - random.randint(3600 * 4, 3600 * 24) messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) - + return chat_samples async def memory_compress(self, messages: list, compress_rate=0.1): @@ -199,17 +200,17 @@ class Hippocampus: """ if not messages: return set() - + # 合并消息文本,同时保留时间信息 input_text = "" time_info = "" # 计算最早和最晚时间 earliest_time = min(msg['time'] for msg in messages) latest_time = max(msg['time'] for msg in messages) - + earliest_dt = datetime.datetime.fromtimestamp(earliest_time) latest_dt = datetime.datetime.fromtimestamp(latest_time) - + # 如果是同一年 if earliest_dt.year == latest_dt.year: earliest_str = earliest_dt.strftime("%m-%d %H:%M:%S") @@ -217,54 +218,56 @@ class Hippocampus: time_info += f"是在{earliest_dt.year}年,{earliest_str} 到 {latest_str} 的对话:\n" else: earliest_str = earliest_dt.strftime("%Y-%m-%d %H:%M:%S") - latest_str = latest_dt.strftime("%Y-%m-%d %H:%M:%S") + latest_str = latest_dt.strftime("%Y-%m-%d %H:%M:%S") time_info += f"是从 {earliest_str} 到 {latest_str} 的对话:\n" - + for msg in messages: input_text += f"{msg['text']}\n" - + print(input_text) - + topic_num = self.calculate_topic_num(input_text, compress_rate) topics_response = await self.llm_topic_judge.generate_response(self.find_topic_llm(input_text, topic_num)) - + # 过滤topics filter_keywords = global_config.memory_ban_words - topics = [topic.strip() for topic in topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] + topics = [topic.strip() for topic in + topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] filtered_topics = [topic for topic in topics if not any(keyword in topic for keyword in filter_keywords)] - + print(f"过滤后话题: {filtered_topics}") - + # 创建所有话题的请求任务 tasks = [] for topic in filtered_topics: topic_what_prompt = self.topic_what(input_text, topic, time_info) task = self.llm_summary_by_topic.generate_response_async(topic_what_prompt) tasks.append((topic.strip(), task)) - + # 等待所有任务完成 compressed_memory = set() for topic, task in tasks: response = await task if response: compressed_memory.add((topic, response[0])) - + return compressed_memory - def calculate_topic_num(self,text, compress_rate): + def calculate_topic_num(self, text, compress_rate): """计算文本的话题数量""" information_content = calculate_information_content(text) - topic_by_length = text.count('\n')*compress_rate - topic_by_information_content = max(1, min(5, int((information_content-3) * 2))) - topic_num = int((topic_by_length + topic_by_information_content)/2) - print(f"topic_by_length: {topic_by_length}, topic_by_information_content: {topic_by_information_content}, topic_num: {topic_num}") + topic_by_length = text.count('\n') * compress_rate + topic_by_information_content = max(1, min(5, int((information_content - 3) * 2))) + topic_num = int((topic_by_length + topic_by_information_content) / 2) + print( + f"topic_by_length: {topic_by_length}, topic_by_information_content: {topic_by_information_content}, topic_num: {topic_num}") return topic_num - async def operation_build_memory(self,chat_size=20): + async def operation_build_memory(self, chat_size=20): # 最近消息获取频率 - time_frequency = {'near':2,'mid':4,'far':2} - memory_sample = self.get_memory_sample(chat_size,time_frequency) - + time_frequency = {'near': 2, 'mid': 4, 'far': 2} + memory_sample = self.get_memory_sample(chat_size, time_frequency) + for i, input_text in enumerate(memory_sample, 1): # 加载进度可视化 all_topics = [] @@ -279,7 +282,7 @@ class Hippocampus: compress_rate = 0.1 compressed_memory = await self.memory_compress(input_text, compress_rate) print(f"\033[1;33m压缩后记忆数量\033[0m: {len(compressed_memory)}") - + # 将记忆加入到图谱中 for topic, memory in compressed_memory: print(f"\033[1;32m添加节点\033[0m: {topic}") @@ -289,7 +292,7 @@ class Hippocampus: for j in range(i + 1, len(all_topics)): print(f"\033[1;32m连接节点\033[0m: {all_topics[i]} 和 {all_topics[j]}") self.memory_graph.connect_dot(all_topics[i], all_topics[j]) - + self.sync_memory_to_db() def sync_memory_to_db(self): @@ -297,19 +300,19 @@ class Hippocampus: # 获取数据库中所有节点和内存中所有节点 db_nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) - + # 转换数据库节点为字典格式,方便查找 db_nodes_dict = {node['concept']: node for node in db_nodes} - + # 检查并更新节点 for concept, data in memory_nodes: memory_items = data.get('memory_items', []) if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] - + # 计算内存中节点的特征值 memory_hash = self.calculate_node_hash(concept, memory_items) - + if concept not in db_nodes_dict: # 数据库中缺少的节点,添加 node_data = { @@ -322,7 +325,7 @@ class Hippocampus: # 获取数据库中节点的特征值 db_node = db_nodes_dict[concept] db_hash = db_node.get('hash', None) - + # 如果特征值不同,则更新节点 if db_hash != memory_hash: self.memory_graph.db.db.graph_data.nodes.update_one( @@ -332,17 +335,17 @@ class Hippocampus: 'hash': memory_hash }} ) - + # 检查并删除数据库中多余的节点 memory_concepts = set(node[0] for node in memory_nodes) for db_node in db_nodes: if db_node['concept'] not in memory_concepts: self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) - + # 处理边的信息 db_edges = list(self.memory_graph.db.db.graph_data.edges.find()) memory_edges = list(self.memory_graph.G.edges()) - + # 创建边的哈希值字典 db_edge_dict = {} for edge in db_edges: @@ -351,13 +354,13 @@ class Hippocampus: 'hash': edge_hash, 'strength': edge.get('strength', 1) } - + # 检查并更新边 for source, target in memory_edges: edge_hash = self.calculate_edge_hash(source, target) edge_key = (source, target) strength = self.memory_graph.G[source][target].get('strength', 1) - + if edge_key not in db_edge_dict: # 添加新边 edge_data = { @@ -377,7 +380,7 @@ class Hippocampus: 'strength': strength }} ) - + # 删除多余的边 memory_edge_set = set(memory_edges) for edge_key in db_edge_dict: @@ -392,7 +395,7 @@ class Hippocampus: """从数据库同步数据到内存中的图结构""" # 清空当前图 self.memory_graph.G.clear() - + # 从数据库加载所有节点 nodes = self.memory_graph.db.db.graph_data.nodes.find() for node in nodes: @@ -403,7 +406,7 @@ class Hippocampus: memory_items = [memory_items] if memory_items else [] # 添加节点到图中 self.memory_graph.G.add_node(concept, memory_items=memory_items) - + # 从数据库加载所有边 edges = self.memory_graph.db.db.graph_data.edges.find() for edge in edges: @@ -413,7 +416,7 @@ class Hippocampus: # 只有当源节点和目标节点都存在时才添加边 if source in self.memory_graph.G and target in self.memory_graph.G: self.memory_graph.G.add_edge(source, target, strength=strength) - + async def operation_forget_topic(self, percentage=0.1): """随机选择图中一定比例的节点进行检查,根据条件决定是否遗忘""" # 获取所有节点 @@ -422,18 +425,18 @@ class Hippocampus: check_count = max(1, int(len(all_nodes) * percentage)) # 随机选择节点 nodes_to_check = random.sample(all_nodes, check_count) - + forgotten_nodes = [] for node in nodes_to_check: # 获取节点的连接数 connections = self.memory_graph.G.degree(node) - + # 获取节点的内容条数 memory_items = self.memory_graph.G.nodes[node].get('memory_items', []) if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] content_count = len(memory_items) - + # 检查连接强度 weak_connections = True if connections > 1: # 只有当连接数大于1时才检查强度 @@ -442,14 +445,14 @@ class Hippocampus: if strength > 2: weak_connections = False break - + # 如果满足遗忘条件 if (connections <= 1 and weak_connections) or content_count <= 2: removed_item = self.memory_graph.forget_topic(node) if removed_item: forgotten_nodes.append((node, removed_item)) print(f"遗忘节点 {node} 的记忆: {removed_item}") - + # 同步到数据库 if forgotten_nodes: self.sync_memory_to_db() @@ -468,35 +471,35 @@ class Hippocampus: memory_items = self.memory_graph.G.nodes[topic].get('memory_items', []) if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] - + # 如果记忆项不足,直接返回 if len(memory_items) < 10: return - + # 随机选择10条记忆 selected_memories = random.sample(memory_items, 10) - + # 拼接成文本 merged_text = "\n".join(selected_memories) print(f"\n[合并记忆] 话题: {topic}") print(f"选择的记忆:\n{merged_text}") - + # 使用memory_compress生成新的压缩记忆 compressed_memories = await self.memory_compress(selected_memories, 0.1) - + # 从原记忆列表中移除被选中的记忆 for memory in selected_memories: memory_items.remove(memory) - + # 添加新的压缩记忆 for _, compressed_memory in compressed_memories: memory_items.append(compressed_memory) print(f"添加压缩记忆: {compressed_memory}") - + # 更新节点的记忆项 self.memory_graph.G.nodes[topic]['memory_items'] = memory_items print(f"完成记忆合并,当前记忆数量: {len(memory_items)}") - + async def operation_merge_memory(self, percentage=0.1): """ 随机检查一定比例的节点,对内容数量超过100的节点进行记忆合并 @@ -510,7 +513,7 @@ class Hippocampus: check_count = max(1, int(len(all_nodes) * percentage)) # 随机选择节点 nodes_to_check = random.sample(all_nodes, check_count) - + merged_nodes = [] for node in nodes_to_check: # 获取节点的内容条数 @@ -518,13 +521,13 @@ class Hippocampus: if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] content_count = len(memory_items) - + # 如果内容数量超过100,进行合并 if content_count > 100: print(f"\n检查节点: {node}, 当前记忆数量: {content_count}") await self.merge_memory(node) merged_nodes.append(node) - + # 同步到数据库 if merged_nodes: self.sync_memory_to_db() @@ -532,11 +535,11 @@ class Hippocampus: else: print("\n本次检查没有需要合并的节点") - def find_topic_llm(self,text, topic_num): + def find_topic_llm(self, text, topic_num): prompt = f'这是一段文字:{text}。请你从这段话中总结出{topic_num}个关键的概念,可以是名词,动词,或者特定人物,帮我列出来,用逗号,隔开,尽可能精简。只需要列举{topic_num}个话题就好,不要有序号,不要告诉我其他内容。' return prompt - def topic_what(self,text, topic, time_info): + def topic_what(self, text, topic, time_info): prompt = f'这是一段文字,{time_info}:{text}。我想让你基于这段文字来概括"{topic}"这个概念,帮我总结成一句自然的话,可以包含时间和人物,以及具体的观点。只输出这句话就好' return prompt @@ -551,11 +554,12 @@ class Hippocampus: """ topics_response = await self.llm_topic_judge.generate_response(self.find_topic_llm(text, 5)) # print(f"话题: {topics_response[0]}") - topics = [topic.strip() for topic in topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] + topics = [topic.strip() for topic in + topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] # print(f"话题: {topics}") - + return topics - + def _find_similar_topics(self, topics: list, similarity_threshold: float = 0.4, debug_info: str = "") -> list: """查找与给定主题相似的记忆主题 @@ -569,16 +573,16 @@ class Hippocampus: """ all_memory_topics = self.get_all_node_names() all_similar_topics = [] - + # 计算每个识别出的主题与记忆主题的相似度 for topic in topics: if debug_info: # print(f"\033[1;32m[{debug_info}]\033[0m 正在思考有没有见过: {topic}") pass - + topic_vector = text_to_vector(topic) has_similar_topic = False - + for memory_topic in all_memory_topics: memory_vector = text_to_vector(memory_topic) # 获取所有唯一词 @@ -588,20 +592,20 @@ class Hippocampus: v2 = [memory_vector.get(word, 0) for word in all_words] # 计算相似度 similarity = cosine_similarity(v1, v2) - + if similarity >= similarity_threshold: has_similar_topic = True if debug_info: # print(f"\033[1;32m[{debug_info}]\033[0m 找到相似主题: {topic} -> {memory_topic} (相似度: {similarity:.2f})") pass all_similar_topics.append((memory_topic, similarity)) - + if not has_similar_topic and debug_info: # print(f"\033[1;31m[{debug_info}]\033[0m 没有见过: {topic} ,呃呃") pass - + return all_similar_topics - + def _get_top_topics(self, similar_topics: list, max_topics: int = 5) -> list: """获取相似度最高的主题 @@ -614,36 +618,36 @@ class Hippocampus: """ seen_topics = set() top_topics = [] - + for topic, score in sorted(similar_topics, key=lambda x: x[1], reverse=True): if topic not in seen_topics and len(top_topics) < max_topics: seen_topics.add(topic) top_topics.append((topic, score)) - + return top_topics async def memory_activate_value(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.3) -> int: """计算输入文本对记忆的激活程度""" print(f"\033[1;32m[记忆激活]\033[0m 识别主题: {await self._identify_topics(text)}") - + # 识别主题 identified_topics = await self._identify_topics(text) if not identified_topics: return 0 - + # 查找相似主题 all_similar_topics = self._find_similar_topics( - identified_topics, + identified_topics, similarity_threshold=similarity_threshold, debug_info="记忆激活" ) - + if not all_similar_topics: return 0 - + # 获取最相关的主题 top_topics = self._get_top_topics(all_similar_topics, max_topics) - + # 如果只找到一个主题,进行惩罚 if len(top_topics) == 1: topic, score = top_topics[0] @@ -653,15 +657,16 @@ class Hippocampus: memory_items = [memory_items] if memory_items else [] content_count = len(memory_items) penalty = 1.0 / (1 + math.log(content_count + 1)) - + activation = int(score * 50 * penalty) - print(f"\033[1;32m[记忆激活]\033[0m 单主题「{topic}」- 相似度: {score:.3f}, 内容数: {content_count}, 激活值: {activation}") + print( + f"\033[1;32m[记忆激活]\033[0m 单主题「{topic}」- 相似度: {score:.3f}, 内容数: {content_count}, 激活值: {activation}") return activation - + # 计算关键词匹配率,同时考虑内容数量 matched_topics = set() topic_similarities = {} - + for memory_topic, similarity in top_topics: # 计算内容数量惩罚 memory_items = self.memory_graph.G.nodes[memory_topic].get('memory_items', []) @@ -669,7 +674,7 @@ class Hippocampus: memory_items = [memory_items] if memory_items else [] content_count = len(memory_items) penalty = 1.0 / (1 + math.log(content_count + 1)) - + # 对每个记忆主题,检查它与哪些输入主题相似 for input_topic in identified_topics: topic_vector = text_to_vector(input_topic) @@ -682,33 +687,36 @@ class Hippocampus: matched_topics.add(input_topic) adjusted_sim = sim * penalty topic_similarities[input_topic] = max(topic_similarities.get(input_topic, 0), adjusted_sim) - print(f"\033[1;32m[记忆激活]\033[0m 主题「{input_topic}」-> 「{memory_topic}」(内容数: {content_count}, 相似度: {adjusted_sim:.3f})") - + print( + f"\033[1;32m[记忆激活]\033[0m 主题「{input_topic}」-> 「{memory_topic}」(内容数: {content_count}, 相似度: {adjusted_sim:.3f})") + # 计算主题匹配率和平均相似度 topic_match = len(matched_topics) / len(identified_topics) average_similarities = sum(topic_similarities.values()) / len(topic_similarities) if topic_similarities else 0 - + # 计算最终激活值 activation = int((topic_match + average_similarities) / 2 * 100) - print(f"\033[1;32m[记忆激活]\033[0m 匹配率: {topic_match:.3f}, 平均相似度: {average_similarities:.3f}, 激活值: {activation}") - + print( + f"\033[1;32m[记忆激活]\033[0m 匹配率: {topic_match:.3f}, 平均相似度: {average_similarities:.3f}, 激活值: {activation}") + return activation - async def get_relevant_memories(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.4, max_memory_num: int = 5) -> list: + async def get_relevant_memories(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.4, + max_memory_num: int = 5) -> list: """根据输入文本获取相关的记忆内容""" # 识别主题 identified_topics = await self._identify_topics(text) - + # 查找相似主题 all_similar_topics = self._find_similar_topics( - identified_topics, + identified_topics, similarity_threshold=similarity_threshold, debug_info="记忆检索" ) - + # 获取最相关的主题 relevant_topics = self._get_top_topics(all_similar_topics, max_topics) - + # 获取相关记忆内容 relevant_memories = [] for topic, score in relevant_topics: @@ -716,8 +724,8 @@ class Hippocampus: first_layer, _ = self.memory_graph.get_related_item(topic, depth=1) if first_layer: # 如果记忆条数超过限制,随机选择指定数量的记忆 - if len(first_layer) > max_memory_num/2: - first_layer = random.sample(first_layer, max_memory_num//2) + if len(first_layer) > max_memory_num / 2: + first_layer = random.sample(first_layer, max_memory_num // 2) # 为每条记忆添加来源主题和相似度信息 for memory in first_layer: relevant_memories.append({ @@ -725,20 +733,20 @@ class Hippocampus: 'similarity': score, 'content': memory }) - + # 如果记忆数量超过5个,随机选择5个 # 按相似度排序 relevant_memories.sort(key=lambda x: x['similarity'], reverse=True) - + if len(relevant_memories) > max_memory_num: relevant_memories = random.sample(relevant_memories, max_memory_num) - + return relevant_memories def segment_text(text): seg_text = list(jieba.cut(text)) - return seg_text + return seg_text from nonebot import get_driver @@ -749,19 +757,19 @@ config = driver.config start_time = time.time() Database.initialize( - host= config.MONGODB_HOST, - port= config.MONGODB_PORT, - db_name= config.DATABASE_NAME, - username= config.MONGODB_USERNAME, - password= config.MONGODB_PASSWORD, + host=config.MONGODB_HOST, + port=config.MONGODB_PORT, + db_name=config.DATABASE_NAME, + username=config.MONGODB_USERNAME, + password=config.MONGODB_PASSWORD, auth_source=config.MONGODB_AUTH_SOURCE ) -#创建记忆图 +# 创建记忆图 memory_graph = Memory_graph() -#创建海马体 +# 创建海马体 hippocampus = Hippocampus(memory_graph) -#从数据库加载记忆图 +# 从数据库加载记忆图 hippocampus.sync_memory_from_db() end_time = time.time() -print(f"\033[32m[加载海马体耗时: {end_time - start_time:.2f} 秒]\033[0m") \ No newline at end of file +logger.success(f"加载海马体耗时: {end_time - start_time:.2f} 秒") From 052e67b5762e6b6db6ab99dec98caf12379ff5e6 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Mon, 10 Mar 2025 02:25:03 +0800 Subject: [PATCH 007/162] =?UTF-8?q?refactor:=20=E6=97=A5=E5=BF=97=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E4=BC=98=E5=8C=96=EF=BC=88=E7=BB=88=E4=BA=8E=E6=94=B9?= =?UTF-8?q?=E5=AE=8C=E4=BA=86=EF=BC=8C=E7=88=BD=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 2 +- src/plugins/chat/bot.py | 17 +- src/plugins/chat/cq_code.py | 17 +- src/plugins/chat/emoji_manager.py | 114 +++++----- src/plugins/chat/llm_generator.py | 15 +- src/plugins/chat/message_sender.py | 142 ++++++------ src/plugins/chat/prompt_builder.py | 207 +++++++++--------- src/plugins/chat/topic_identifier.py | 19 +- src/plugins/chat/utils.py | 27 ++- src/plugins/chat/willing_manager.py | 9 +- src/plugins/memory_system/memory.py | 51 ++--- .../memory_system/memory_manual_build.py | 2 +- src/plugins/models/utils_model.py | 68 +++--- src/plugins/moods/moods.py | 4 +- src/test/typo.py | 149 +++++++------ 15 files changed, 431 insertions(+), 412 deletions(-) diff --git a/bot.py b/bot.py index c9e397724..c2ed3dfdf 100644 --- a/bot.py +++ b/bot.py @@ -100,7 +100,7 @@ def load_logger(): "#777777>| {name:.<8}:{function:.<8}:{line: >4} - {message}", colorize=True, - level=os.getenv("LOG_LEVEL", "INFO") # 根据环境设置日志级别,默认为INFO + level=os.getenv("LOG_LEVEL", "DEBUG") # 根据环境设置日志级别,默认为INFO ) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index a1d710122..c510fe4bf 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -71,8 +71,8 @@ class ChatBot: for word in global_config.ban_words: if word in message.detailed_plain_text: logger.info( - f"\033[1;32m[{message.group_name}]{message.user_nickname}:\033[0m {message.processed_plain_text}") - logger.info(f"\033[1;32m[过滤词识别]\033[0m 消息中含有{word},filtered") + f"[{message.group_name}]{message.user_nickname}:{message.processed_plain_text}") + logger.info(f"[过滤词识别]消息中含有{word},filtered") return current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.time)) @@ -81,8 +81,8 @@ class ChatBot: topic = '' interested_rate = 0 interested_rate = await hippocampus.memory_activate_value(message.processed_plain_text) / 100 - logger.debug(f"\033[1;32m[记忆激活]\033[0m 对{message.processed_plain_text}" - "的激活度:---------------------------------------{interested_rate}\n") + logger.debug(f"对{message.processed_plain_text}" + f"的激活度:{interested_rate}") # logger.info(f"\033[1;32m[主题识别]\033[0m 使用{global_config.topic_extract}主题: {topic}") await self.storage.store_message(message, topic[0] if topic else None) @@ -99,10 +99,9 @@ class ChatBot: ) current_willing = willing_manager.get_willing(event.group_id) - logger.debug( - f"\033[1;32m[{current_time}][{message.group_name}]{message.user_nickname}:\033[0m " - "{message.processed_plain_text}\033[1;36m[回复意愿:{current_willing:.2f}][概率:{reply_probability * " - "100:.1f}%]\033[0m") + logger.info( + f"[{current_time}][{message.group_name}]{message.user_nickname}:" + f"{message.processed_plain_text}[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]") response = "" @@ -130,7 +129,7 @@ class ChatBot: # 如果找不到思考消息,直接返回 if not thinking_message: - print(f"\033[1;33m[警告]\033[0m 未找到对应的思考消息,可能已超时被移除") + logger.warning(f"未找到对应的思考消息,可能已超时被移除") return # 记录开始思考的时间,避免从思考到回复的时间太久 diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index 4a295e3d5..b13e33e48 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -4,6 +4,7 @@ import os import time from dataclasses import dataclass from typing import Dict, Optional +from loguru import logger import requests @@ -151,11 +152,11 @@ class CQCode: except (requests.exceptions.SSLError, requests.exceptions.HTTPError) as e: if retry == max_retries - 1: - print(f"\033[1;31m[致命错误]\033[0m 最终请求失败: {str(e)}") + logger.error(f"最终请求失败: {str(e)}") time.sleep(1.5 ** retry) # 指数退避 except Exception as e: - print(f"\033[1;33m[未知错误]\033[0m {str(e)}") + logger.exception(f"[未知错误]") return None return None @@ -194,7 +195,7 @@ class CQCode: description, _ = await self._llm.generate_response_for_image(prompt, image_base64) return f"[表情包:{description}]" except Exception as e: - print(f"\033[1;31m[错误]\033[0m AI接口调用失败: {str(e)}") + logger.exception(f"AI接口调用失败: {str(e)}") return "[表情包]" async def get_image_description(self, image_base64: str) -> str: @@ -205,7 +206,7 @@ class CQCode: description, _ = await self._llm.generate_response_for_image(prompt, image_base64) return f"[图片:{description}]" except Exception as e: - print(f"\033[1;31m[错误]\033[0m AI接口调用失败: {str(e)}") + logger.exception(f"AI接口调用失败: {str(e)}") return "[图片]" async def translate_forward(self) -> str: @@ -222,7 +223,7 @@ class CQCode: try: messages = ast.literal_eval(content) except ValueError as e: - print(f"\033[1;31m[错误]\033[0m 解析转发消息内容失败: {str(e)}") + logger.error(f"解析转发消息内容失败: {str(e)}") return '[转发消息]' # 处理每条消息 @@ -277,11 +278,11 @@ class CQCode: # 合并所有消息 combined_messages = '\n'.join(formatted_messages) - print(f"\033[1;34m[调试信息]\033[0m 合并后的转发消息: {combined_messages}") + logger.debug(f"合并后的转发消息: {combined_messages}") return f"[转发消息:\n{combined_messages}]" except Exception as e: - print(f"\033[1;31m[错误]\033[0m 处理转发消息失败: {str(e)}") + logger.exception("处理转发消息失败") return '[转发消息]' async def translate_reply(self) -> str: @@ -307,7 +308,7 @@ class CQCode: return f"[回复 {self.reply_message.sender.nickname} 的消息: {message_obj.processed_plain_text}]" else: - print("\033[1;31m[错误]\033[0m 回复消息的sender.user_id为空") + logger.error("回复消息的sender.user_id为空") return '[回复某人消息]' @staticmethod diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 05c5aeada..eb1ff281a 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -21,24 +21,25 @@ config = driver.config class EmojiManager: _instance = None EMOJI_DIR = "data/emoji" # 表情包存储目录 - + def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.db = None cls._instance._initialized = False return cls._instance - + def __init__(self): self.db = Database.get_instance() self._scan_task = None self.vlm = LLM_request(model=global_config.vlm, temperature=0.3, max_tokens=1000) - self.llm_emotion_judge = LLM_request(model=global_config.llm_normal_minor, max_tokens=60,temperature=0.8) #更高的温度,更少的token(后续可以根据情绪来调整温度) - + self.llm_emotion_judge = LLM_request(model=global_config.llm_normal_minor, max_tokens=60, + temperature=0.8) # 更高的温度,更少的token(后续可以根据情绪来调整温度) + def _ensure_emoji_dir(self): """确保表情存储目录存在""" os.makedirs(self.EMOJI_DIR, exist_ok=True) - + def initialize(self): """初始化数据库连接和表情目录""" if not self._initialized: @@ -50,15 +51,15 @@ class EmojiManager: # 启动时执行一次完整性检查 self.check_emoji_file_integrity() except Exception as e: - logger.error(f"初始化表情管理器失败: {str(e)}") - + logger.exception(f"初始化表情管理器失败") + def _ensure_db(self): """确保数据库已初始化""" if not self._initialized: self.initialize() if not self._initialized: raise RuntimeError("EmojiManager not initialized") - + def _ensure_emoji_collection(self): """确保emoji集合存在并创建索引 @@ -76,7 +77,7 @@ class EmojiManager: self.db.db.emoji.create_index([('embedding', '2dsphere')]) self.db.db.emoji.create_index([('tags', 1)]) self.db.db.emoji.create_index([('filename', 1)], unique=True) - + def record_usage(self, emoji_id: str): """记录表情使用次数""" try: @@ -86,8 +87,8 @@ class EmojiManager: {'$inc': {'usage_count': 1}} ) except Exception as e: - logger.error(f"记录表情使用失败: {str(e)}") - + logger.exception(f"记录表情使用失败") + async def get_emoji_for_text(self, text: str) -> Optional[str]: """根据文本内容获取相关表情包 Args: @@ -102,9 +103,9 @@ class EmojiManager: """ try: self._ensure_db() - + # 获取文本的embedding - text_for_search= await self._get_kimoji_for_text(text) + text_for_search = await self._get_kimoji_for_text(text) if not text_for_search: logger.error("无法获取文本的情绪") return None @@ -112,15 +113,15 @@ class EmojiManager: if not text_embedding: logger.error("无法获取文本的embedding") return None - + try: # 获取所有表情包 all_emojis = list(self.db.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'discription': 1})) - + if not all_emojis: logger.warning("数据库中没有任何表情包") return None - + # 计算余弦相似度并排序 def cosine_similarity(v1, v2): if not v1 or not v2: @@ -131,42 +132,43 @@ class EmojiManager: if norm_v1 == 0 or norm_v2 == 0: return 0 return dot_product / (norm_v1 * norm_v2) - + # 计算所有表情包与输入文本的相似度 emoji_similarities = [ (emoji, cosine_similarity(text_embedding, emoji.get('embedding', []))) for emoji in all_emojis ] - + # 按相似度降序排序 emoji_similarities.sort(key=lambda x: x[1], reverse=True) - + # 获取前3个最相似的表情包 top_3_emojis = emoji_similarities[:3] - + if not top_3_emojis: logger.warning("未找到匹配的表情包") return None - + # 从前3个中随机选择一个 selected_emoji, similarity = random.choice(top_3_emojis) - + if selected_emoji and 'path' in selected_emoji: # 更新使用次数 self.db.db.emoji.update_one( {'_id': selected_emoji['_id']}, {'$inc': {'usage_count': 1}} ) - logger.success(f"找到匹配的表情包: {selected_emoji.get('discription', '无描述')} (相似度: {similarity:.4f})") + logger.success( + f"找到匹配的表情包: {selected_emoji.get('discription', '无描述')} (相似度: {similarity:.4f})") # 稍微改一下文本描述,不然容易产生幻觉,描述已经包含 表情包 了 - return selected_emoji['path'],"[ %s ]" % selected_emoji.get('discription', '无描述') - + return selected_emoji['path'], "[ %s ]" % selected_emoji.get('discription', '无描述') + except Exception as search_error: logger.error(f"搜索表情包失败: {str(search_error)}") return None - + return None - + except Exception as e: logger.error(f"获取表情包失败: {str(e)}") return None @@ -175,39 +177,39 @@ class EmojiManager: """获取表情包的标签""" try: prompt = '这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感' - + content, _ = await self.vlm.generate_response_for_image(prompt, image_base64) logger.debug(f"输出描述: {content}") return content - + except Exception as e: logger.error(f"获取标签失败: {str(e)}") return None - + async def _check_emoji(self, image_base64: str) -> str: try: prompt = f'这是一个表情包,请回答这个表情包是否满足\"{global_config.EMOJI_CHECK_PROMPT}\"的要求,是则回答是,否则回答否,不要出现任何其他内容' - + content, _ = await self.vlm.generate_response_for_image(prompt, image_base64) logger.debug(f"输出描述: {content}") return content - + except Exception as e: logger.error(f"获取标签失败: {str(e)}") return None - - async def _get_kimoji_for_text(self, text:str): + + async def _get_kimoji_for_text(self, text: str): try: prompt = f'这是{global_config.BOT_NICKNAME}将要发送的消息内容:\n{text}\n若要为其配上表情包,请你输出这个表情包应该表达怎样的情感,应该给人什么样的感觉,不要太简洁也不要太长,注意不要输出任何对消息内容的分析内容,只输出\"一种什么样的感觉\"中间的形容词部分。' - + content, _ = await self.llm_emotion_judge.generate_response_async(prompt) logger.info(f"输出描述: {content}") return content - + except Exception as e: logger.error(f"获取标签失败: {str(e)}") return None - + async def scan_new_emojis(self): """扫描新的表情包""" try: @@ -215,22 +217,23 @@ class EmojiManager: os.makedirs(emoji_dir, exist_ok=True) # 获取所有支持的图片文件 - files_to_process = [f for f in os.listdir(emoji_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif'))] - + files_to_process = [f for f in os.listdir(emoji_dir) if + f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif'))] + for filename in files_to_process: image_path = os.path.join(emoji_dir, filename) - + # 检查是否已经注册过 existing_emoji = self.db.db['emoji'].find_one({'filename': filename}) if existing_emoji: continue - + # 压缩图片并获取base64编码 image_base64 = image_path_to_base64(image_path) if image_base64 is None: os.remove(image_path) continue - + # 获取表情包的描述 discription = await self._get_emoji_discription(image_base64) if global_config.EMOJI_CHECK: @@ -247,30 +250,28 @@ class EmojiManager: emoji_record = { 'filename': filename, 'path': image_path, - 'embedding':embedding, + 'embedding': embedding, 'discription': discription, 'timestamp': int(time.time()) } - + # 保存到数据库 self.db.db['emoji'].insert_one(emoji_record) logger.success(f"注册新表情包: {filename}") logger.info(f"描述: {discription}") else: logger.warning(f"跳过表情包: {filename}") - + except Exception as e: - logger.error(f"扫描表情包失败: {str(e)}") - logger.error(traceback.format_exc()) - + logger.exception(f"扫描表情包失败") + async def _periodic_scan(self, interval_MINS: int = 10): """定期扫描新表情包""" while True: - print("\033[1;36m[表情包]\033[0m 开始扫描新表情包...") + logger.info("开始扫描新表情包...") await self.scan_new_emojis() await asyncio.sleep(interval_MINS * 60) # 每600秒扫描一次 - def check_emoji_file_integrity(self): """检查表情包文件完整性 如果文件已被删除,则从数据库中移除对应记录 @@ -281,7 +282,7 @@ class EmojiManager: all_emojis = list(self.db.db.emoji.find()) removed_count = 0 total_count = len(all_emojis) - + for emoji in all_emojis: try: if 'path' not in emoji: @@ -289,13 +290,13 @@ class EmojiManager: self.db.db.emoji.delete_one({'_id': emoji['_id']}) removed_count += 1 continue - + if 'embedding' not in emoji: logger.warning(f"发现过时记录(缺少embedding字段),ID: {emoji.get('_id', 'unknown')}") self.db.db.emoji.delete_one({'_id': emoji['_id']}) removed_count += 1 continue - + # 检查文件是否存在 if not os.path.exists(emoji['path']): logger.warning(f"表情包文件已被删除: {emoji['path']}") @@ -309,7 +310,7 @@ class EmojiManager: except Exception as item_error: logger.error(f"处理表情包记录时出错: {str(item_error)}") continue - + # 验证清理结果 remaining_count = self.db.db.emoji.count_documents({}) if removed_count > 0: @@ -317,7 +318,7 @@ class EmojiManager: logger.info(f"清理前总数: {total_count} | 清理后总数: {remaining_count}") else: logger.info(f"已检查 {total_count} 个表情包记录") - + except Exception as e: logger.error(f"检查表情包完整性失败: {str(e)}") logger.error(traceback.format_exc()) @@ -328,6 +329,5 @@ class EmojiManager: await asyncio.sleep(interval_MINS * 60) - # 创建全局单例 -emoji_manager = EmojiManager() \ No newline at end of file +emoji_manager = EmojiManager() diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index 1ac421e6b..1236bb2e0 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -3,6 +3,7 @@ import time from typing import List, Optional, Tuple, Union from nonebot import get_driver +from loguru import logger from ...common.database import Database from ..models.utils_model import LLM_request @@ -39,13 +40,13 @@ class ResponseGenerator: self.current_model_type = 'r1_distill' current_model = self.model_r1_distill - print(f"+++++++++++++++++{global_config.BOT_NICKNAME}{self.current_model_type}思考中+++++++++++++++++") + logger.info(f"{global_config.BOT_NICKNAME}{self.current_model_type}思考中") model_response = await self._generate_response_with_model(message, current_model) raw_content=model_response if model_response: - print(f'{global_config.BOT_NICKNAME}的回复是:{model_response}') + logger.info(f'{global_config.BOT_NICKNAME}的回复是:{model_response}') model_response = await self._process_response(model_response) if model_response: @@ -93,7 +94,7 @@ class ResponseGenerator: try: content, reasoning_content = await model.generate_response(prompt) except Exception as e: - print(f"生成回复时出错: {e}") + logger.exception(f"生成回复时出错: {e}") return None # 保存到数据库 @@ -145,7 +146,7 @@ class ResponseGenerator: return ["neutral"] except Exception as e: - print(f"获取情感标签时出错: {e}") + logger.exception(f"获取情感标签时出错: {e}") return ["neutral"] async def _process_response(self, content: str) -> Tuple[List[str], List[str]]: @@ -172,7 +173,7 @@ class InitiativeMessageGenerate: prompt_builder._build_initiative_prompt_select(message.group_id) ) content_select, reasoning = self.model_v3.generate_response(topic_select_prompt) - print(f"[DEBUG] {content_select} {reasoning}") + logger.debug(f"{content_select} {reasoning}") topics_list = [dot[0] for dot in dots_for_select] if content_select: if content_select in topics_list: @@ -185,12 +186,12 @@ class InitiativeMessageGenerate: select_dot[1], prompt_template ) content_check, reasoning_check = self.model_v3.generate_response(prompt_check) - print(f"[DEBUG] {content_check} {reasoning_check}") + logger.info(f"{content_check} {reasoning_check}") if "yes" not in content_check.lower(): return None prompt = prompt_builder._build_initiative_prompt( select_dot, prompt_template, memory ) content, reasoning = self.model_r1.generate_response_async(prompt) - print(f"[DEBUG] {content} {reasoning}") + logger.debug(f"[DEBUG] {content} {reasoning}") return content diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 050c59d74..b9506b455 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -2,6 +2,7 @@ import asyncio import time from typing import Dict, List, Optional, Union +from loguru import logger from nonebot.adapters.onebot.v11 import Bot from .cq_code import cq_code_tool @@ -13,45 +14,45 @@ from .config import global_config class Message_Sender: """发送器""" + def __init__(self): self.message_interval = (0.5, 1) # 消息间隔时间范围(秒) self.last_send_time = 0 self._current_bot = None - + def set_bot(self, bot: Bot): """设置当前bot实例""" self._current_bot = bot - + async def send_group_message( - self, - group_id: int, - send_text: str, - auto_escape: bool = False, - reply_message_id: int = None, - at_user_id: int = None + self, + group_id: int, + send_text: str, + auto_escape: bool = False, + reply_message_id: int = None, + at_user_id: int = None ) -> None: if not self._current_bot: raise RuntimeError("Bot未设置,请先调用set_bot方法设置bot实例") - + message = send_text - + # 如果需要回复 if reply_message_id: reply_cq = cq_code_tool.create_reply_cq(reply_message_id) message = reply_cq + message - + # 如果需要at # if at_user_id: # at_cq = cq_code_tool.create_at_cq(at_user_id) # message = at_cq + " " + message - - + typing_time = calculate_typing_time(message) if typing_time > 10: typing_time = 10 await asyncio.sleep(typing_time) - + # 发送消息 try: await self._current_bot.send_group_msg( @@ -59,49 +60,49 @@ class Message_Sender: message=message, auto_escape=auto_escape ) - print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") + logger.debug(f"发送消息{message}成功") except Exception as e: - print(f"发生错误 {e}") - print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") + logger.exception(f"发送消息{message}失败") class MessageContainer: """单个群的发送/思考消息容器""" + def __init__(self, group_id: int, max_size: int = 100): self.group_id = group_id self.max_size = max_size self.messages = [] self.last_send_time = 0 self.thinking_timeout = 20 # 思考超时时间(秒) - + def get_timeout_messages(self) -> List[Message_Sending]: """获取所有超时的Message_Sending对象(思考时间超过30秒),按thinking_start_time排序""" current_time = time.time() timeout_messages = [] - + for msg in self.messages: if isinstance(msg, Message_Sending): if current_time - msg.thinking_start_time > self.thinking_timeout: timeout_messages.append(msg) - + # 按thinking_start_time排序,时间早的在前面 timeout_messages.sort(key=lambda x: x.thinking_start_time) - + return timeout_messages - + def get_earliest_message(self) -> Optional[Union[Message_Thinking, Message_Sending]]: """获取thinking_start_time最早的消息对象""" if not self.messages: return None earliest_time = float('inf') earliest_message = None - for msg in self.messages: + for msg in self.messages: msg_time = msg.thinking_start_time if msg_time < earliest_time: earliest_time = msg_time - earliest_message = msg + earliest_message = msg return earliest_message - + def add_message(self, message: Union[Message_Thinking, Message_Sending]) -> None: """添加消息到队列""" # print(f"\033[1;32m[添加消息]\033[0m 添加消息到对应群") @@ -110,7 +111,7 @@ class MessageContainer: self.messages.append(single_message) else: self.messages.append(message) - + def remove_message(self, message: Union[Message_Thinking, Message_Sending]) -> bool: """移除消息,如果消息存在则返回True,否则返回False""" try: @@ -119,97 +120,103 @@ class MessageContainer: return True return False except Exception as e: - print(f"\033[1;31m[错误]\033[0m 移除消息时发生错误: {e}") + logger.exception(f"移除消息时发生错误: {e}") return False - + def has_messages(self) -> bool: """检查是否有待发送的消息""" return bool(self.messages) - + def get_all_messages(self) -> List[Union[Message, Message_Thinking]]: """获取所有消息""" return list(self.messages) - + class MessageManager: """管理所有群的消息容器""" + def __init__(self): self.containers: Dict[int, MessageContainer] = {} self.storage = MessageStorage() self._running = True - + def get_container(self, group_id: int) -> MessageContainer: """获取或创建群的消息容器""" if group_id not in self.containers: self.containers[group_id] = MessageContainer(group_id) return self.containers[group_id] - + def add_message(self, message: Union[Message_Thinking, Message_Sending, MessageSet]) -> None: container = self.get_container(message.group_id) container.add_message(message) - + async def process_group_messages(self, group_id: int): """处理群消息""" # if int(time.time() / 3) == time.time() / 3: - # print(f"\033[1;34m[调试]\033[0m 开始处理群{group_id}的消息") + # print(f"\033[1;34m[调试]\033[0m 开始处理群{group_id}的消息") container = self.get_container(group_id) if container.has_messages(): - #最早的对象,可能是思考消息,也可能是发送消息 - message_earliest = container.get_earliest_message() #一个message_thinking or message_sending - - #如果是思考消息 + # 最早的对象,可能是思考消息,也可能是发送消息 + message_earliest = container.get_earliest_message() # 一个message_thinking or message_sending + + # 如果是思考消息 if isinstance(message_earliest, Message_Thinking): - #优先等待这条消息 + # 优先等待这条消息 message_earliest.update_thinking_time() thinking_time = message_earliest.thinking_time - print(f"\033[1;34m[调试]\033[0m 消息正在思考中,已思考{int(thinking_time)}秒\033[K\r", end='', flush=True) - + print(f"消息正在思考中,已思考{int(thinking_time)}秒\r", end='', flush=True) + # 检查是否超时 if thinking_time > global_config.thinking_timeout: - print(f"\033[1;33m[警告]\033[0m 消息思考超时({thinking_time}秒),移除该消息") + logger.warning(f"消息思考超时({thinking_time}秒),移除该消息") container.remove_message(message_earliest) - else:# 如果不是message_thinking就只能是message_sending - print(f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中") - #直接发,等什么呢 - if message_earliest.is_head and message_earliest.update_thinking_time() >30: - await message_sender.send_group_message(group_id, message_earliest.processed_plain_text, auto_escape=False, reply_message_id=message_earliest.reply_message_id) + else: # 如果不是message_thinking就只能是message_sending + logger.debug(f"消息'{message_earliest.processed_plain_text}'正在发送中") + # 直接发,等什么呢 + if message_earliest.is_head and message_earliest.update_thinking_time() > 30: + await message_sender.send_group_message(group_id, message_earliest.processed_plain_text, + auto_escape=False, + reply_message_id=message_earliest.reply_message_id) else: - await message_sender.send_group_message(group_id, message_earliest.processed_plain_text, auto_escape=False) - #移除消息 + await message_sender.send_group_message(group_id, message_earliest.processed_plain_text, + auto_escape=False) + # 移除消息 if message_earliest.is_emoji: message_earliest.processed_plain_text = "[表情包]" await self.storage.store_message(message_earliest, None) - + container.remove_message(message_earliest) - - #获取并处理超时消息 - message_timeout = container.get_timeout_messages() #也许是一堆message_sending + + # 获取并处理超时消息 + message_timeout = container.get_timeout_messages() # 也许是一堆message_sending if message_timeout: - print(f"\033[1;34m[调试]\033[0m 发现{len(message_timeout)}条超时消息") + logger.warning(f"发现{len(message_timeout)}条超时消息") for msg in message_timeout: if msg == message_earliest: continue # 跳过已经处理过的消息 - + try: - #发送 - if msg.is_head and msg.update_thinking_time() >30: - await message_sender.send_group_message(group_id, msg.processed_plain_text, auto_escape=False, reply_message_id=msg.reply_message_id) + # 发送 + if msg.is_head and msg.update_thinking_time() > 30: + await message_sender.send_group_message(group_id, msg.processed_plain_text, + auto_escape=False, + reply_message_id=msg.reply_message_id) else: - await message_sender.send_group_message(group_id, msg.processed_plain_text, auto_escape=False) - - - #如果是表情包,则替换为"[表情包]" + await message_sender.send_group_message(group_id, msg.processed_plain_text, + auto_escape=False) + + # 如果是表情包,则替换为"[表情包]" if msg.is_emoji: msg.processed_plain_text = "[表情包]" await self.storage.store_message(msg, None) - + # 安全地移除消息 if not container.remove_message(msg): - print("\033[1;33m[警告]\033[0m 尝试删除不存在的消息") + logger.warning("尝试删除不存在的消息") except Exception as e: - print(f"\033[1;31m[错误]\033[0m 处理超时消息时发生错误: {e}") + logger.exception(f"处理超时消息时发生错误: {e}") continue - + async def start_processor(self): """启动消息处理器""" while self._running: @@ -217,9 +224,10 @@ class MessageManager: tasks = [] for group_id in self.containers.keys(): tasks.append(self.process_group_messages(group_id)) - + await asyncio.gather(*tasks) + # 创建全局消息管理器实例 message_manager = MessageManager() # 创建全局发送器实例 diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index fdb887af5..4cf21af19 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -1,6 +1,7 @@ import random import time from typing import Optional +from loguru import logger from ...common.database import Database from ..memory_system.memory import hippocampus, memory_graph @@ -16,13 +17,11 @@ class PromptBuilder: self.activate_messages = '' self.db = Database.get_instance() - - - async def _build_prompt(self, - message_txt: str, - sender_name: str = "某人", - relationship_value: float = 0.0, - group_id: Optional[int] = None) -> tuple[str, str]: + async def _build_prompt(self, + message_txt: str, + sender_name: str = "某人", + relationship_value: float = 0.0, + group_id: Optional[int] = None) -> tuple[str, str]: """构建prompt Args: @@ -33,57 +32,56 @@ class PromptBuilder: Returns: str: 构建好的prompt - """ - #先禁用关系 + """ + # 先禁用关系 if 0 > 30: relation_prompt = "关系特别特别好,你很喜欢喜欢他" relation_prompt_2 = "热情发言或者回复" - elif 0 <-20: + elif 0 < -20: relation_prompt = "关系很差,你很讨厌他" relation_prompt_2 = "骂他" else: relation_prompt = "关系一般" relation_prompt_2 = "发言或者回复" - - #开始构建prompt - - - #心情 + + # 开始构建prompt + + # 心情 mood_manager = MoodManager.get_instance() mood_prompt = mood_manager.get_prompt() - - - #日程构建 + + # 日程构建 current_date = time.strftime("%Y-%m-%d", time.localtime()) current_time = time.strftime("%H:%M:%S", time.localtime()) - bot_schedule_now_time,bot_schedule_now_activity = bot_schedule.get_current_task() + bot_schedule_now_time, bot_schedule_now_activity = bot_schedule.get_current_task() prompt_date = f'''今天是{current_date},现在是{current_time},你今天的日程是:\n{bot_schedule.today_schedule}\n你现在正在{bot_schedule_now_activity}\n''' - #知识构建 + # 知识构建 start_time = time.time() - + prompt_info = '' promt_info_prompt = '' - prompt_info = await self.get_prompt_info(message_txt,threshold=0.5) + prompt_info = await self.get_prompt_info(message_txt, threshold=0.5) if prompt_info: - prompt_info = f'''\n----------------------------------------------------\n你有以下这些[知识]:\n{prompt_info}\n请你记住上面的[知识],之后可能会用到\n----------------------------------------------------\n''' - + prompt_info = f'''你有以下这些[知识]:{prompt_info}请你记住上面的[ + 知识],之后可能会用到-''' + end_time = time.time() - print(f"\033[1;32m[知识检索]\033[0m 耗时: {(end_time - start_time):.3f}秒") - + logger.debug(f"知识检索耗时: {(end_time - start_time):.3f}秒") + # 获取聊天上下文 chat_talking_prompt = '' if group_id: - chat_talking_prompt = get_recent_group_detailed_plain_text(self.db, group_id, limit=global_config.MAX_CONTEXT_SIZE,combine = True) - + chat_talking_prompt = get_recent_group_detailed_plain_text(self.db, group_id, + limit=global_config.MAX_CONTEXT_SIZE, + combine=True) + chat_talking_prompt = f"以下是群里正在聊天的内容:\n{chat_talking_prompt}" - - - + # 使用新的记忆获取方法 memory_prompt = '' start_time = time.time() - + # 调用 hippocampus 的 get_relevant_memories 方法 relevant_memories = await hippocampus.get_relevant_memories( text=message_txt, @@ -91,30 +89,28 @@ class PromptBuilder: similarity_threshold=0.4, max_memory_num=5 ) - + if relevant_memories: # 格式化记忆内容 memory_items = [] for memory in relevant_memories: memory_items.append(f"关于「{memory['topic']}」的记忆:{memory['content']}") - + memory_prompt = "看到这些聊天,你想起来:\n" + "\n".join(memory_items) + "\n" - + # 打印调试信息 - print("\n\033[1;32m[记忆检索]\033[0m 找到以下相关记忆:") + logger.debug("[记忆检索]找到以下相关记忆:") for memory in relevant_memories: - print(f"- 主题「{memory['topic']}」[相似度: {memory['similarity']:.2f}]: {memory['content']}") - + logger.debug(f"- 主题「{memory['topic']}」[相似度: {memory['similarity']:.2f}]: {memory['content']}") + end_time = time.time() - print(f"\033[1;32m[回忆耗时]\033[0m 耗时: {(end_time - start_time):.3f}秒") - - - - #激活prompt构建 + logger.info(f"回忆耗时: {(end_time - start_time):.3f}秒") + + # 激活prompt构建 activate_prompt = '' - activate_prompt = f"以上是群里正在进行的聊天,{memory_prompt} 现在昵称为 '{sender_name}' 的用户说的:{message_txt}。引起了你的注意,你和他{relation_prompt},{mood_prompt},你想要{relation_prompt_2}。" - - #检测机器人相关词汇,改为关键词检测与反应功能了,提取到全局配置中 + activate_prompt = f"以上是群里正在进行的聊天,{memory_prompt} 现在昵称为 '{sender_name}' 的用户说的:{message_txt}。引起了你的注意,你和他{relation_prompt},{mood_prompt},你想要{relation_prompt_2}。" + + # 检测机器人相关词汇,改为关键词检测与反应功能了,提取到全局配置中 # bot_keywords = ['人机', 'bot', '机器', '入机', 'robot', '机器人'] # is_bot = any(keyword in message_txt.lower() for keyword in bot_keywords) # if is_bot: @@ -127,12 +123,11 @@ class PromptBuilder: for rule in global_config.keywords_reaction_rules: if rule.get("enable", False): if any(keyword in message_txt.lower() for keyword in rule.get("keywords", [])): - print(f"检测到以下关键词之一:{rule.get('keywords', [])},触发反应:{rule.get('reaction', '')}") + logger.info(f"检测到以下关键词之一:{rule.get('keywords', [])},触发反应:{rule.get('reaction', '')}") keywords_reaction_prompt += rule.get("reaction", "") + ',' - - #人格选择 - personality=global_config.PROMPT_PERSONALITY + # 人格选择 + personality = global_config.PROMPT_PERSONALITY probability_1 = global_config.PERSONALITY_1 probability_2 = global_config.PERSONALITY_2 probability_3 = global_config.PERSONALITY_3 @@ -150,8 +145,8 @@ class PromptBuilder: prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[2]}, 你正在浏览qq群,{promt_info_prompt}, 现在请你给出日常且口语化的回复,请表现你自己的见解,不要一昧迎合,尽量简短一些。{keywords_reaction_prompt} 请你表达自己的见解和观点。可以有个性。''' - - #中文高手(新加的好玩功能) + + # 中文高手(新加的好玩功能) prompt_ger = '' if random.random() < 0.04: prompt_ger += '你喜欢用倒装句' @@ -159,23 +154,23 @@ class PromptBuilder: prompt_ger += '你喜欢用反问句' if random.random() < 0.01: prompt_ger += '你喜欢用文言文' - - #额外信息要求 - extra_info = '''但是记得回复平淡一些,简短一些,尤其注意在没明确提到时不要过多提及自身的背景, 不要直接回复别人发的表情包,记住不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只需要输出回复内容就好,不要输出其他任何内容''' - - #合并prompt + + # 额外信息要求 + extra_info = '''但是记得回复平淡一些,简短一些,尤其注意在没明确提到时不要过多提及自身的背景, 不要直接回复别人发的表情包,记住不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只需要输出回复内容就好,不要输出其他任何内容''' + + # 合并prompt prompt = "" prompt += f"{prompt_info}\n" prompt += f"{prompt_date}\n" - prompt += f"{chat_talking_prompt}\n" + prompt += f"{chat_talking_prompt}\n" prompt += f"{prompt_personality}\n" prompt += f"{prompt_ger}\n" - prompt += f"{extra_info}\n" - - '''读空气prompt处理''' - activate_prompt_check=f"以上是群里正在进行的聊天,昵称为 '{sender_name}' 的用户说的:{message_txt}。引起了你的注意,你和他{relation_prompt},你想要{relation_prompt_2},但是这不一定是合适的时机,请你决定是否要回应这条消息。" + prompt += f"{extra_info}\n" + + '''读空气prompt处理''' + activate_prompt_check = f"以上是群里正在进行的聊天,昵称为 '{sender_name}' 的用户说的:{message_txt}。引起了你的注意,你和他{relation_prompt},你想要{relation_prompt_2},但是这不一定是合适的时机,请你决定是否要回应这条消息。" prompt_personality_check = '' - extra_check_info=f"请注意把握群里的聊天内容的基础上,综合群内的氛围,例如,和{global_config.BOT_NICKNAME}相关的话题要积极回复,如果是at自己的消息一定要回复,如果自己正在和别人聊天一定要回复,其他话题如果合适搭话也可以回复,如果认为应该回复请输出yes,否则输出no,请注意是决定是否需要回复,而不是编写回复内容,除了yes和no不要输出任何回复内容。" + extra_check_info = f"请注意把握群里的聊天内容的基础上,综合群内的氛围,例如,和{global_config.BOT_NICKNAME}相关的话题要积极回复,如果是at自己的消息一定要回复,如果自己正在和别人聊天一定要回复,其他话题如果合适搭话也可以回复,如果认为应该回复请输出yes,否则输出no,请注意是决定是否需要回复,而不是编写回复内容,除了yes和no不要输出任何回复内容。" if personality_choice < probability_1: # 第一种人格 prompt_personality_check = f'''你的网名叫{global_config.BOT_NICKNAME},{personality[0]}, 你正在浏览qq群,{promt_info_prompt} {activate_prompt_check} {extra_check_info}''' elif personality_choice < probability_1 + probability_2: # 第二种人格 @@ -183,34 +178,36 @@ class PromptBuilder: else: # 第三种人格 prompt_personality_check = f'''你的网名叫{global_config.BOT_NICKNAME},{personality[2]}, 你正在浏览qq群,{promt_info_prompt} {activate_prompt_check} {extra_check_info}''' - prompt_check_if_response=f"{prompt_info}\n{prompt_date}\n{chat_talking_prompt}\n{prompt_personality_check}" - - return prompt,prompt_check_if_response - - def _build_initiative_prompt_select(self,group_id): + prompt_check_if_response = f"{prompt_info}\n{prompt_date}\n{chat_talking_prompt}\n{prompt_personality_check}" + + return prompt, prompt_check_if_response + + def _build_initiative_prompt_select(self, group_id, probability_1=0.8, probability_2=0.1): current_date = time.strftime("%Y-%m-%d", time.localtime()) current_time = time.strftime("%H:%M:%S", time.localtime()) - bot_schedule_now_time,bot_schedule_now_activity = bot_schedule.get_current_task() + bot_schedule_now_time, bot_schedule_now_activity = bot_schedule.get_current_task() prompt_date = f'''今天是{current_date},现在是{current_time},你今天的日程是:\n{bot_schedule.today_schedule}\n你现在正在{bot_schedule_now_activity}\n''' chat_talking_prompt = '' if group_id: - chat_talking_prompt = get_recent_group_detailed_plain_text(self.db, group_id, limit=global_config.MAX_CONTEXT_SIZE,combine = True) - + chat_talking_prompt = get_recent_group_detailed_plain_text(self.db, group_id, + limit=global_config.MAX_CONTEXT_SIZE, + combine=True) + chat_talking_prompt = f"以下是群里正在聊天的内容:\n{chat_talking_prompt}" - # print(f"\033[1;34m[调试]\033[0m 已从数据库获取群 {group_id} 的消息记录:{chat_talking_prompt}") + # print(f"\033[1;34m[调试]\033[0m 已从数据库获取群 {group_id} 的消息记录:{chat_talking_prompt}") # 获取主动发言的话题 - all_nodes=memory_graph.dots - all_nodes=filter(lambda dot:len(dot[1]['memory_items'])>3,all_nodes) - nodes_for_select=random.sample(all_nodes,5) - topics=[info[0] for info in nodes_for_select] - infos=[info[1] for info in nodes_for_select] + all_nodes = memory_graph.dots + all_nodes = filter(lambda dot: len(dot[1]['memory_items']) > 3, all_nodes) + nodes_for_select = random.sample(all_nodes, 5) + topics = [info[0] for info in nodes_for_select] + infos = [info[1] for info in nodes_for_select] - #激活prompt构建 + # 激活prompt构建 activate_prompt = '' activate_prompt = "以上是群里正在进行的聊天。" - personality=global_config.PROMPT_PERSONALITY + personality = global_config.PROMPT_PERSONALITY prompt_personality = '' personality_choice = random.random() if personality_choice < probability_1: # 第一种人格 @@ -219,32 +216,31 @@ class PromptBuilder: prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[1]}''' else: # 第三种人格 prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[2]}''' - - topics_str=','.join(f"\"{topics}\"") - prompt_for_select=f"你现在想在群里发言,回忆了一下,想到几个话题,分别是{topics_str},综合当前状态以及群内气氛,请你在其中选择一个合适的话题,注意只需要输出话题,除了话题什么也不要输出(双引号也不要输出)" - - prompt_initiative_select=f"{prompt_date}\n{prompt_personality}\n{prompt_for_select}" - prompt_regular=f"{prompt_date}\n{prompt_personality}" - return prompt_initiative_select,nodes_for_select,prompt_regular - - def _build_initiative_prompt_check(self,selected_node,prompt_regular): - memory=random.sample(selected_node['memory_items'],3) - memory='\n'.join(memory) - prompt_for_check=f"{prompt_regular}你现在想在群里发言,回忆了一下,想到一个话题,是{selected_node['concept']},关于这个话题的记忆有\n{memory}\n,以这个作为主题发言合适吗?请在把握群里的聊天内容的基础上,综合群内的氛围,如果认为应该发言请输出yes,否则输出no,请注意是决定是否需要发言,而不是编写回复内容,除了yes和no不要输出任何回复内容。" - return prompt_for_check,memory - - def _build_initiative_prompt(self,selected_node,prompt_regular,memory): - prompt_for_initiative=f"{prompt_regular}你现在想在群里发言,回忆了一下,想到一个话题,是{selected_node['concept']},关于这个话题的记忆有\n{memory}\n,请在把握群里的聊天内容的基础上,综合群内的氛围,以日常且口语化的口吻,简短且随意一点进行发言,不要说的太有条理,可以有个性。记住不要输出多余内容(包括前后缀,冒号和引号,括号,表情等)" + topics_str = ','.join(f"\"{topics}\"") + prompt_for_select = f"你现在想在群里发言,回忆了一下,想到几个话题,分别是{topics_str},综合当前状态以及群内气氛,请你在其中选择一个合适的话题,注意只需要输出话题,除了话题什么也不要输出(双引号也不要输出)" + + prompt_initiative_select = f"{prompt_date}\n{prompt_personality}\n{prompt_for_select}" + prompt_regular = f"{prompt_date}\n{prompt_personality}" + + return prompt_initiative_select, nodes_for_select, prompt_regular + + def _build_initiative_prompt_check(self, selected_node, prompt_regular): + memory = random.sample(selected_node['memory_items'], 3) + memory = '\n'.join(memory) + prompt_for_check = f"{prompt_regular}你现在想在群里发言,回忆了一下,想到一个话题,是{selected_node['concept']},关于这个话题的记忆有\n{memory}\n,以这个作为主题发言合适吗?请在把握群里的聊天内容的基础上,综合群内的氛围,如果认为应该发言请输出yes,否则输出no,请注意是决定是否需要发言,而不是编写回复内容,除了yes和no不要输出任何回复内容。" + return prompt_for_check, memory + + def _build_initiative_prompt(self, selected_node, prompt_regular, memory): + prompt_for_initiative = f"{prompt_regular}你现在想在群里发言,回忆了一下,想到一个话题,是{selected_node['concept']},关于这个话题的记忆有\n{memory}\n,请在把握群里的聊天内容的基础上,综合群内的氛围,以日常且口语化的口吻,简短且随意一点进行发言,不要说的太有条理,可以有个性。记住不要输出多余内容(包括前后缀,冒号和引号,括号,表情等)" return prompt_for_initiative - - async def get_prompt_info(self,message:str,threshold:float): + async def get_prompt_info(self, message: str, threshold: float): related_info = '' - print(f"\033[1;34m[调试]\033[0m 获取知识库内容,元消息:{message[:30]}...,消息长度: {len(message)}") + logger.debug(f"获取知识库内容,元消息:{message[:30]}...,消息长度: {len(message)}") embedding = await get_embedding(message) - related_info += self.get_info_from_db(embedding,threshold=threshold) - + related_info += self.get_info_from_db(embedding, threshold=threshold) + return related_info def get_info_from_db(self, query_embedding: list, limit: int = 1, threshold: float = 0.5) -> str: @@ -305,14 +301,15 @@ class PromptBuilder: {"$limit": limit}, {"$project": {"content": 1, "similarity": 1}} ] - + results = list(self.db.db.knowledges.aggregate(pipeline)) # print(f"\033[1;34m[调试]\033[0m获取知识库内容结果: {results}") - + if not results: return '' - + # 返回所有找到的内容,用换行分隔 return '\n'.join(str(result['content']) for result in results) - -prompt_builder = PromptBuilder() \ No newline at end of file + + +prompt_builder = PromptBuilder() diff --git a/src/plugins/chat/topic_identifier.py b/src/plugins/chat/topic_identifier.py index 3296d0895..a0c5bae30 100644 --- a/src/plugins/chat/topic_identifier.py +++ b/src/plugins/chat/topic_identifier.py @@ -4,9 +4,11 @@ from nonebot import get_driver from ..models.utils_model import LLM_request from .config import global_config +from loguru import logger driver = get_driver() -config = driver.config +config = driver.config + class TopicIdentifier: def __init__(self): @@ -23,19 +25,20 @@ class TopicIdentifier: # 使用 LLM_request 类进行请求 topic, _ = await self.llm_topic_judge.generate_response(prompt) - + if not topic: - print("\033[1;31m[错误]\033[0m LLM API 返回为空") + logger.error("LLM API 返回为空") return None - + # 直接在这里处理主题解析 if not topic or topic == "无主题": return None - + # 解析主题字符串为列表 topic_list = [t.strip() for t in topic.split(",") if t.strip()] - - print(f"\033[1;32m[主题识别]\033[0m 主题: {topic_list}") + + logger.info(f"主题: {topic_list}") return topic_list if topic_list else None -topic_identifier = TopicIdentifier() \ No newline at end of file + +topic_identifier = TopicIdentifier() diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index d166bcd27..e5ebad59d 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -7,6 +7,7 @@ from typing import Dict, List import jieba import numpy as np from nonebot import get_driver +from loguru import logger from ..models.utils_model import LLM_request from ..utils.typo_generator import ChineseTypoGenerator @@ -39,16 +40,16 @@ def combine_messages(messages: List[Message]) -> str: def db_message_to_str(message_dict: Dict) -> str: - print(f"message_dict: {message_dict}") + logger.debug(f"message_dict: {message_dict}") time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(message_dict["time"])) try: name = "[(%s)%s]%s" % ( - message_dict['user_id'], message_dict.get("user_nickname", ""), message_dict.get("user_cardname", "")) + message_dict['user_id'], message_dict.get("user_nickname", ""), message_dict.get("user_cardname", "")) except: name = message_dict.get("user_nickname", "") or f"用户{message_dict['user_id']}" content = message_dict.get("processed_plain_text", "") result = f"[{time_str}] {name}: {content}\n" - print(f"result: {result}") + logger.debug(f"result: {result}") return result @@ -176,7 +177,7 @@ async def get_recent_group_messages(db, group_id: int, limit: int = 12) -> list: await msg.initialize() message_objects.append(msg) except KeyError: - print("[WARNING] 数据库中存在无效的消息") + logger.warning("数据库中存在无效的消息") continue # 按时间正序排列 @@ -292,11 +293,10 @@ def split_into_sentences_w_remove_punctuation(text: str) -> List[str]: sentence = sentence.replace(',', ' ').replace(',', ' ') sentences_done.append(sentence) - print(f"处理后的句子: {sentences_done}") + logger.info(f"处理后的句子: {sentences_done}") return sentences_done - def random_remove_punctuation(text: str) -> str: """随机处理标点符号,模拟人类打字习惯 @@ -324,11 +324,10 @@ def random_remove_punctuation(text: str) -> str: return result - def process_llm_response(text: str) -> List[str]: # processed_response = process_text_with_typos(content) if len(text) > 200: - print(f"回复过长 ({len(text)} 字符),返回默认回复") + logger.warning(f"回复过长 ({len(text)} 字符),返回默认回复") return ['懒得说'] # 处理长消息 typo_generator = ChineseTypoGenerator( @@ -348,9 +347,9 @@ def process_llm_response(text: str) -> List[str]: else: sentences.append(sentence) # 检查分割后的消息数量是否过多(超过3条) - + if len(sentences) > 5: - print(f"分割后消息数量过多 ({len(sentences)} 条),返回默认回复") + logger.warning(f"分割后消息数量过多 ({len(sentences)} 条),返回默认回复") return [f'{global_config.BOT_NICKNAME}不知道哦'] return sentences @@ -372,15 +371,15 @@ def calculate_typing_time(input_string: str, chinese_time: float = 0.4, english_ mood_arousal = mood_manager.current_mood.arousal # 映射到0.5到2倍的速度系数 typing_speed_multiplier = 1.5 ** mood_arousal # 唤醒度为1时速度翻倍,为-1时速度减半 - chinese_time *= 1/typing_speed_multiplier - english_time *= 1/typing_speed_multiplier + chinese_time *= 1 / typing_speed_multiplier + english_time *= 1 / typing_speed_multiplier # 计算中文字符数 chinese_chars = sum(1 for char in input_string if '\u4e00' <= char <= '\u9fff') - + # 如果只有一个中文字符,使用3倍时间 if chinese_chars == 1 and len(input_string.strip()) == 1: return chinese_time * 3 + 0.3 # 加上回车时间 - + # 正常计算所有字符的输入时间 total_time = 0.0 for char in input_string: diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 001b66207..4f8bec7dc 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -1,5 +1,6 @@ import asyncio from .config import global_config +from loguru import logger class WillingManager: @@ -30,16 +31,16 @@ class WillingManager: # print(f"初始意愿: {current_willing}") if is_mentioned_bot and current_willing < 1.0: current_willing += 0.9 - print(f"被提及, 当前意愿: {current_willing}") + logger.info(f"被提及, 当前意愿: {current_willing}") elif is_mentioned_bot: current_willing += 0.05 - print(f"被重复提及, 当前意愿: {current_willing}") + logger.info(f"被重复提及, 当前意愿: {current_willing}") if is_emoji: current_willing *= 0.1 - print(f"表情包, 当前意愿: {current_willing}") + logger.info(f"表情包, 当前意愿: {current_willing}") - print(f"放大系数_interested_rate: {global_config.response_interested_rate_amplifier}") + logger.debug(f"放大系数_interested_rate: {global_config.response_interested_rate_amplifier}") interested_rate *= global_config.response_interested_rate_amplifier #放大回复兴趣度 if interested_rate > 0.4: # print(f"兴趣度: {interested_rate}, 当前意愿: {current_willing}") diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 8e917caf4..9b325b36d 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -224,7 +224,7 @@ class Hippocampus: for msg in messages: input_text += f"{msg['text']}\n" - print(input_text) + logger.debug(input_text) topic_num = self.calculate_topic_num(input_text, compress_rate) topics_response = await self.llm_topic_judge.generate_response(self.find_topic_llm(input_text, topic_num)) @@ -235,7 +235,7 @@ class Hippocampus: topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] filtered_topics = [topic for topic in topics if not any(keyword in topic for keyword in filter_keywords)] - print(f"过滤后话题: {filtered_topics}") + logger.info(f"过滤后话题: {filtered_topics}") # 创建所有话题的请求任务 tasks = [] @@ -259,8 +259,9 @@ class Hippocampus: topic_by_length = text.count('\n') * compress_rate topic_by_information_content = max(1, min(5, int((information_content - 3) * 2))) topic_num = int((topic_by_length + topic_by_information_content) / 2) - print( - f"topic_by_length: {topic_by_length}, topic_by_information_content: {topic_by_information_content}, topic_num: {topic_num}") + logger.debug( + f"topic_by_length: {topic_by_length}, topic_by_information_content: {topic_by_information_content}, " + f"topic_num: {topic_num}") return topic_num async def operation_build_memory(self, chat_size=20): @@ -275,22 +276,22 @@ class Hippocampus: bar_length = 30 filled_length = int(bar_length * i // len(memory_sample)) bar = '█' * filled_length + '-' * (bar_length - filled_length) - print(f"\n进度: [{bar}] {progress:.1f}% ({i}/{len(memory_sample)})") + logger.debug(f"进度: [{bar}] {progress:.1f}% ({i}/{len(memory_sample)})") # 生成压缩后记忆 ,表现为 (话题,记忆) 的元组 compressed_memory = set() compress_rate = 0.1 compressed_memory = await self.memory_compress(input_text, compress_rate) - print(f"\033[1;33m压缩后记忆数量\033[0m: {len(compressed_memory)}") + logger.info(f"压缩后记忆数量: {len(compressed_memory)}") # 将记忆加入到图谱中 for topic, memory in compressed_memory: - print(f"\033[1;32m添加节点\033[0m: {topic}") + logger.info(f"添加节点: {topic}") self.memory_graph.add_dot(topic, memory) all_topics.append(topic) # 收集所有话题 for i in range(len(all_topics)): for j in range(i + 1, len(all_topics)): - print(f"\033[1;32m连接节点\033[0m: {all_topics[i]} 和 {all_topics[j]}") + logger.info(f"连接节点: {all_topics[i]} 和 {all_topics[j]}") self.memory_graph.connect_dot(all_topics[i], all_topics[j]) self.sync_memory_to_db() @@ -451,14 +452,14 @@ class Hippocampus: removed_item = self.memory_graph.forget_topic(node) if removed_item: forgotten_nodes.append((node, removed_item)) - print(f"遗忘节点 {node} 的记忆: {removed_item}") + logger.debug(f"遗忘节点 {node} 的记忆: {removed_item}") # 同步到数据库 if forgotten_nodes: self.sync_memory_to_db() - print(f"完成遗忘操作,共遗忘 {len(forgotten_nodes)} 个节点的记忆") + logger.debug(f"完成遗忘操作,共遗忘 {len(forgotten_nodes)} 个节点的记忆") else: - print("本次检查没有节点满足遗忘条件") + logger.debug("本次检查没有节点满足遗忘条件") async def merge_memory(self, topic): """ @@ -481,8 +482,8 @@ class Hippocampus: # 拼接成文本 merged_text = "\n".join(selected_memories) - print(f"\n[合并记忆] 话题: {topic}") - print(f"选择的记忆:\n{merged_text}") + logger.debug(f"\n[合并记忆] 话题: {topic}") + logger.debug(f"选择的记忆:\n{merged_text}") # 使用memory_compress生成新的压缩记忆 compressed_memories = await self.memory_compress(selected_memories, 0.1) @@ -494,11 +495,11 @@ class Hippocampus: # 添加新的压缩记忆 for _, compressed_memory in compressed_memories: memory_items.append(compressed_memory) - print(f"添加压缩记忆: {compressed_memory}") + logger.info(f"添加压缩记忆: {compressed_memory}") # 更新节点的记忆项 self.memory_graph.G.nodes[topic]['memory_items'] = memory_items - print(f"完成记忆合并,当前记忆数量: {len(memory_items)}") + logger.debug(f"完成记忆合并,当前记忆数量: {len(memory_items)}") async def operation_merge_memory(self, percentage=0.1): """ @@ -524,16 +525,16 @@ class Hippocampus: # 如果内容数量超过100,进行合并 if content_count > 100: - print(f"\n检查节点: {node}, 当前记忆数量: {content_count}") + logger.debug(f"检查节点: {node}, 当前记忆数量: {content_count}") await self.merge_memory(node) merged_nodes.append(node) # 同步到数据库 if merged_nodes: self.sync_memory_to_db() - print(f"\n完成记忆合并操作,共处理 {len(merged_nodes)} 个节点") + logger.debug(f"完成记忆合并操作,共处理 {len(merged_nodes)} 个节点") else: - print("\n本次检查没有需要合并的节点") + logger.debug("本次检查没有需要合并的节点") def find_topic_llm(self, text, topic_num): prompt = f'这是一段文字:{text}。请你从这段话中总结出{topic_num}个关键的概念,可以是名词,动词,或者特定人物,帮我列出来,用逗号,隔开,尽可能精简。只需要列举{topic_num}个话题就好,不要有序号,不要告诉我其他内容。' @@ -628,7 +629,7 @@ class Hippocampus: async def memory_activate_value(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.3) -> int: """计算输入文本对记忆的激活程度""" - print(f"\033[1;32m[记忆激活]\033[0m 识别主题: {await self._identify_topics(text)}") + logger.info(f"识别主题: {await self._identify_topics(text)}") # 识别主题 identified_topics = await self._identify_topics(text) @@ -659,8 +660,8 @@ class Hippocampus: penalty = 1.0 / (1 + math.log(content_count + 1)) activation = int(score * 50 * penalty) - print( - f"\033[1;32m[记忆激活]\033[0m 单主题「{topic}」- 相似度: {score:.3f}, 内容数: {content_count}, 激活值: {activation}") + logger.info( + f"[记忆激活]单主题「{topic}」- 相似度: {score:.3f}, 内容数: {content_count}, 激活值: {activation}") return activation # 计算关键词匹配率,同时考虑内容数量 @@ -687,8 +688,8 @@ class Hippocampus: matched_topics.add(input_topic) adjusted_sim = sim * penalty topic_similarities[input_topic] = max(topic_similarities.get(input_topic, 0), adjusted_sim) - print( - f"\033[1;32m[记忆激活]\033[0m 主题「{input_topic}」-> 「{memory_topic}」(内容数: {content_count}, 相似度: {adjusted_sim:.3f})") + logger.info( + f"[记忆激活]主题「{input_topic}」-> 「{memory_topic}」(内容数: {content_count}, 相似度: {adjusted_sim:.3f})") # 计算主题匹配率和平均相似度 topic_match = len(matched_topics) / len(identified_topics) @@ -696,8 +697,8 @@ class Hippocampus: # 计算最终激活值 activation = int((topic_match + average_similarities) / 2 * 100) - print( - f"\033[1;32m[记忆激活]\033[0m 匹配率: {topic_match:.3f}, 平均相似度: {average_similarities:.3f}, 激活值: {activation}") + logger.info( + f"[记忆激活]匹配率: {topic_match:.3f}, 平均相似度: {average_similarities:.3f}, 激活值: {activation}") return activation diff --git a/src/plugins/memory_system/memory_manual_build.py b/src/plugins/memory_system/memory_manual_build.py index 2e1151f93..012e5ecbb 100644 --- a/src/plugins/memory_system/memory_manual_build.py +++ b/src/plugins/memory_system/memory_manual_build.py @@ -743,7 +743,7 @@ class Hippocampus: async def memory_activate_value(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.3) -> int: """计算输入文本对记忆的激活程度""" - print(f"\033[1;32m[记忆激活]\033[0m 识别主题: {await self._identify_topics(text)}") + logger.info(f"[记忆激活]识别主题: {await self._identify_topics(text)}") identified_topics = await self._identify_topics(text) if not identified_topics: diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index bd06fd6dd..f4100e0bb 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -28,10 +28,10 @@ class LLM_request: raise ValueError(f"配置错误:找不到对应的配置项 - {str(e)}") from e self.model_name = model["name"] self.params = kwargs - + self.pri_in = model.get("pri_in", 0) self.pri_out = model.get("pri_out", 0) - + # 获取数据库实例 self.db = Database.get_instance() self._init_database() @@ -47,9 +47,9 @@ class LLM_request: except Exception as e: logger.error(f"创建数据库索引失败: {e}") - def _record_usage(self, prompt_tokens: int, completion_tokens: int, total_tokens: int, - user_id: str = "system", request_type: str = "chat", - endpoint: str = "/chat/completions"): + def _record_usage(self, prompt_tokens: int, completion_tokens: int, total_tokens: int, + user_id: str = "system", request_type: str = "chat", + endpoint: str = "/chat/completions"): """记录模型使用情况到数据库 Args: prompt_tokens: 输入token数 @@ -140,12 +140,12 @@ class LLM_request: } api_url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" - #判断是否为流式 + # 判断是否为流式 stream_mode = self.params.get("stream", False) if self.params.get("stream", False) is True: - logger.info(f"进入流式输出模式,发送请求到URL: {api_url}") + logger.debug(f"进入流式输出模式,发送请求到URL: {api_url}") else: - logger.info(f"发送请求到URL: {api_url}") + logger.debug(f"发送请求到URL: {api_url}") logger.info(f"使用模型: {self.model_name}") # 构建请求体 @@ -158,7 +158,7 @@ class LLM_request: try: # 使用上下文管理器处理会话 headers = await self._build_headers() - #似乎是openai流式必须要的东西,不过阿里云的qwq-plus加了这个没有影响 + # 似乎是openai流式必须要的东西,不过阿里云的qwq-plus加了这个没有影响 if stream_mode: headers["Accept"] = "text/event-stream" @@ -184,29 +184,31 @@ class LLM_request: logger.error(f"错误码: {response.status} - {error_code_mapping.get(response.status)}") if response.status == 403: # 尝试降级Pro模型 - if self.model_name.startswith("Pro/") and self.base_url == "https://api.siliconflow.cn/v1/": + if self.model_name.startswith( + "Pro/") and self.base_url == "https://api.siliconflow.cn/v1/": old_model_name = self.model_name self.model_name = self.model_name[4:] # 移除"Pro/"前缀 logger.warning(f"检测到403错误,模型从 {old_model_name} 降级为 {self.model_name}") - + # 对全局配置进行更新 - if hasattr(global_config, 'llm_normal') and global_config.llm_normal.get('name') == old_model_name: + if hasattr(global_config, 'llm_normal') and global_config.llm_normal.get( + 'name') == old_model_name: global_config.llm_normal['name'] = self.model_name logger.warning(f"已将全局配置中的 llm_normal 模型降级") - + # 更新payload中的模型名 if payload and 'model' in payload: payload['model'] = self.model_name - + # 重新尝试请求 retry -= 1 # 不计入重试次数 continue - + raise RuntimeError(f"请求被拒绝: {error_code_mapping.get(response.status)}") - + response.raise_for_status() - - #将流式输出转化为非流式输出 + + # 将流式输出转化为非流式输出 if stream_mode: accumulated_content = "" async for line_bytes in response.content: @@ -233,12 +235,15 @@ class LLM_request: reasoning_content = think_match.group(1).strip() content = re.sub(r'.*?', '', content, flags=re.DOTALL).strip() # 构造一个伪result以便调用自定义响应处理器或默认处理器 - result = {"choices": [{"message": {"content": content, "reasoning_content": reasoning_content}}]} - return response_handler(result) if response_handler else self._default_response_handler(result, user_id, request_type, endpoint) + result = { + "choices": [{"message": {"content": content, "reasoning_content": reasoning_content}}]} + return response_handler(result) if response_handler else self._default_response_handler( + result, user_id, request_type, endpoint) else: result = await response.json() # 使用自定义处理器或默认处理 - return response_handler(result) if response_handler else self._default_response_handler(result, user_id, request_type, endpoint) + return response_handler(result) if response_handler else self._default_response_handler( + result, user_id, request_type, endpoint) except Exception as e: if retry < policy["max_retries"] - 1: @@ -252,8 +257,8 @@ class LLM_request: logger.error("达到最大重试次数,请求仍然失败") raise RuntimeError("达到最大重试次数,API请求仍然失败") - - async def _transform_parameters(self, params: dict) ->dict: + + async def _transform_parameters(self, params: dict) -> dict: """ 根据模型名称转换参数: - 对于需要转换的OpenAI CoT系列模型(例如 "o3-mini"),删除 'temprature' 参数, @@ -262,7 +267,8 @@ class LLM_request: # 复制一份参数,避免直接修改原始数据 new_params = dict(params) # 定义需要转换的模型列表 - models_needing_transformation = ["o3-mini", "o1-mini", "o1-preview", "o1-2024-12-17", "o1-preview-2024-09-12", "o3-mini-2025-01-31", "o1-mini-2024-09-12"] + models_needing_transformation = ["o3-mini", "o1-mini", "o1-preview", "o1-2024-12-17", "o1-preview-2024-09-12", + "o3-mini-2025-01-31", "o1-mini-2024-09-12"] if self.model_name.lower() in models_needing_transformation: # 删除 'temprature' 参数(如果存在) new_params.pop("temperature", None) @@ -298,13 +304,13 @@ class LLM_request: **params_copy } # 如果 payload 中依然存在 max_tokens 且需要转换,在这里进行再次检查 - if self.model_name.lower() in ["o3-mini", "o1-mini", "o1-preview", "o1-2024-12-17", "o1-preview-2024-09-12", "o3-mini-2025-01-31", "o1-mini-2024-09-12"] and "max_tokens" in payload: + if self.model_name.lower() in ["o3-mini", "o1-mini", "o1-preview", "o1-2024-12-17", "o1-preview-2024-09-12", + "o3-mini-2025-01-31", "o1-mini-2024-09-12"] and "max_tokens" in payload: payload["max_completion_tokens"] = payload.pop("max_tokens") return payload - - def _default_response_handler(self, result: dict, user_id: str = "system", - request_type: str = "chat", endpoint: str = "/chat/completions") -> Tuple: + def _default_response_handler(self, result: dict, user_id: str = "system", + request_type: str = "chat", endpoint: str = "/chat/completions") -> Tuple: """默认响应解析""" if "choices" in result and result["choices"]: message = result["choices"][0]["message"] @@ -356,8 +362,8 @@ class LLM_request: return { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" - } - # 防止小朋友们截图自己的key + } + # 防止小朋友们截图自己的key async def generate_response(self, prompt: str) -> Tuple[str, str]: """根据输入的提示生成模型的异步响应""" @@ -404,6 +410,7 @@ class LLM_request: Returns: list: embedding向量,如果失败则返回None """ + def embedding_handler(result): """处理响应""" if "data" in result and len(result["data"]) > 0: @@ -425,4 +432,3 @@ class LLM_request: response_handler=embedding_handler ) return embedding - diff --git a/src/plugins/moods/moods.py b/src/plugins/moods/moods.py index c35779f84..c37bfc81d 100644 --- a/src/plugins/moods/moods.py +++ b/src/plugins/moods/moods.py @@ -4,7 +4,7 @@ import time from dataclasses import dataclass from ..chat.config import global_config - +from loguru import logger @dataclass class MoodState: @@ -210,7 +210,7 @@ class MoodManager: def print_mood_status(self) -> None: """打印当前情绪状态""" - print(f"\033[1;35m[情绪状态]\033[0m 愉悦度: {self.current_mood.valence:.2f}, " + logger.info(f"[情绪状态]愉悦度: {self.current_mood.valence:.2f}, " f"唤醒度: {self.current_mood.arousal:.2f}, " f"心情: {self.current_mood.text}") diff --git a/src/test/typo.py b/src/test/typo.py index 16834200f..1378eae7d 100644 --- a/src/test/typo.py +++ b/src/test/typo.py @@ -11,12 +11,14 @@ from pathlib import Path import random import math import time +from loguru import logger + class ChineseTypoGenerator: - def __init__(self, - error_rate=0.3, - min_freq=5, - tone_error_rate=0.2, + def __init__(self, + error_rate=0.3, + min_freq=5, + tone_error_rate=0.2, word_replace_rate=0.3, max_freq_diff=200): """ @@ -34,27 +36,27 @@ class ChineseTypoGenerator: self.tone_error_rate = tone_error_rate self.word_replace_rate = word_replace_rate self.max_freq_diff = max_freq_diff - + # 加载数据 - print("正在加载汉字数据库,请稍候...") + logger.debug("正在加载汉字数据库,请稍候...") self.pinyin_dict = self._create_pinyin_dict() self.char_frequency = self._load_or_create_char_frequency() - + def _load_or_create_char_frequency(self): """ 加载或创建汉字频率字典 """ cache_file = Path("char_frequency.json") - + # 如果缓存文件存在,直接加载 if cache_file.exists(): with open(cache_file, 'r', encoding='utf-8') as f: return json.load(f) - + # 使用内置的词频文件 char_freq = defaultdict(int) dict_path = os.path.join(os.path.dirname(jieba.__file__), 'dict.txt') - + # 读取jieba的词典文件 with open(dict_path, 'r', encoding='utf-8') as f: for line in f: @@ -63,15 +65,15 @@ class ChineseTypoGenerator: for char in word: if self._is_chinese_char(char): char_freq[char] += int(freq) - + # 归一化频率值 max_freq = max(char_freq.values()) - normalized_freq = {char: freq/max_freq * 1000 for char, freq in char_freq.items()} - + normalized_freq = {char: freq / max_freq * 1000 for char, freq in char_freq.items()} + # 保存到缓存文件 with open(cache_file, 'w', encoding='utf-8') as f: json.dump(normalized_freq, f, ensure_ascii=False, indent=2) - + return normalized_freq def _create_pinyin_dict(self): @@ -81,7 +83,7 @@ class ChineseTypoGenerator: # 常用汉字范围 chars = [chr(i) for i in range(0x4e00, 0x9fff)] pinyin_dict = defaultdict(list) - + # 为每个汉字建立拼音映射 for char in chars: try: @@ -89,7 +91,7 @@ class ChineseTypoGenerator: pinyin_dict[py].append(char) except Exception: continue - + return pinyin_dict def _is_chinese_char(self, char): @@ -107,7 +109,7 @@ class ChineseTypoGenerator: """ # 将句子拆分成单个字符 characters = list(sentence) - + # 获取每个字符的拼音 result = [] for char in characters: @@ -117,7 +119,7 @@ class ChineseTypoGenerator: # 获取拼音(数字声调) py = pinyin(char, style=Style.TONE3)[0][0] result.append((char, py)) - + return result def _get_similar_tone_pinyin(self, py): @@ -127,19 +129,19 @@ class ChineseTypoGenerator: # 检查拼音是否为空或无效 if not py or len(py) < 1: return py - + # 如果最后一个字符不是数字,说明可能是轻声或其他特殊情况 if not py[-1].isdigit(): # 为非数字结尾的拼音添加数字声调1 return py + '1' - + base = py[:-1] # 去掉声调 tone = int(py[-1]) # 获取声调 - + # 处理轻声(通常用5表示)或无效声调 if tone not in [1, 2, 3, 4]: return base + str(random.choice([1, 2, 3, 4])) - + # 正常处理声调 possible_tones = [1, 2, 3, 4] possible_tones.remove(tone) # 移除原声调 @@ -152,11 +154,11 @@ class ChineseTypoGenerator: """ if target_freq > orig_freq: return 1.0 # 如果替换字频率更高,保持原有概率 - + freq_diff = orig_freq - target_freq if freq_diff > self.max_freq_diff: return 0.0 # 频率差太大,不替换 - + # 使用指数衰减函数计算概率 # 频率差为0时概率为1,频率差为max_freq_diff时概率接近0 return math.exp(-3 * freq_diff / self.max_freq_diff) @@ -166,42 +168,42 @@ class ChineseTypoGenerator: 获取与给定字频率相近的同音字,可能包含声调错误 """ homophones = [] - + # 有一定概率使用错误声调 if random.random() < self.tone_error_rate: wrong_tone_py = self._get_similar_tone_pinyin(py) homophones.extend(self.pinyin_dict[wrong_tone_py]) - + # 添加正确声调的同音字 homophones.extend(self.pinyin_dict[py]) - + if not homophones: return None - + # 获取原字的频率 orig_freq = self.char_frequency.get(char, 0) - + # 计算所有同音字与原字的频率差,并过滤掉低频字 - freq_diff = [(h, self.char_frequency.get(h, 0)) - for h in homophones - if h != char and self.char_frequency.get(h, 0) >= self.min_freq] - + freq_diff = [(h, self.char_frequency.get(h, 0)) + for h in homophones + if h != char and self.char_frequency.get(h, 0) >= self.min_freq] + if not freq_diff: return None - + # 计算每个候选字的替换概率 candidates_with_prob = [] for h, freq in freq_diff: prob = self._calculate_replacement_probability(orig_freq, freq) if prob > 0: # 只保留有效概率的候选字 candidates_with_prob.append((h, prob)) - + if not candidates_with_prob: return None - + # 根据概率排序 candidates_with_prob.sort(key=lambda x: x[1], reverse=True) - + # 返回概率最高的几个字 return [char for char, _ in candidates_with_prob[:num_candidates]] @@ -223,10 +225,10 @@ class ChineseTypoGenerator: """ if len(word) == 1: return [] - + # 获取词的拼音 word_pinyin = self._get_word_pinyin(word) - + # 遍历所有可能的同音字组合 candidates = [] for py in word_pinyin: @@ -234,11 +236,11 @@ class ChineseTypoGenerator: if not chars: return [] candidates.append(chars) - + # 生成所有可能的组合 import itertools all_combinations = itertools.product(*candidates) - + # 获取jieba词典和词频信息 dict_path = os.path.join(os.path.dirname(jieba.__file__), 'dict.txt') valid_words = {} # 改用字典存储词语及其频率 @@ -249,11 +251,11 @@ class ChineseTypoGenerator: word_text = parts[0] word_freq = float(parts[1]) # 获取词频 valid_words[word_text] = word_freq - + # 获取原词的词频作为参考 original_word_freq = valid_words.get(word, 0) min_word_freq = original_word_freq * 0.1 # 设置最小词频为原词频的10% - + # 过滤和计算频率 homophones = [] for combo in all_combinations: @@ -268,7 +270,7 @@ class ChineseTypoGenerator: combined_score = (new_word_freq * 0.7 + char_avg_freq * 0.3) if combined_score >= self.min_freq: homophones.append((new_word, combined_score)) - + # 按综合分数排序并限制返回数量 sorted_homophones = sorted(homophones, key=lambda x: x[1], reverse=True) return [word for word, _ in sorted_homophones[:5]] # 限制返回前5个结果 @@ -286,19 +288,19 @@ class ChineseTypoGenerator: """ result = [] typo_info = [] - + # 分词 words = self._segment_sentence(sentence) - + for word in words: # 如果是标点符号或空格,直接添加 if all(not self._is_chinese_char(c) for c in word): result.append(word) continue - + # 获取词语的拼音 word_pinyin = self._get_word_pinyin(word) - + # 尝试整词替换 if len(word) > 1 and random.random() < self.word_replace_rate: word_homophones = self._get_word_homophones(word) @@ -307,15 +309,15 @@ class ChineseTypoGenerator: # 计算词的平均频率 orig_freq = sum(self.char_frequency.get(c, 0) for c in word) / len(word) typo_freq = sum(self.char_frequency.get(c, 0) for c in typo_word) / len(typo_word) - + # 添加到结果中 result.append(typo_word) - typo_info.append((word, typo_word, - ' '.join(word_pinyin), - ' '.join(self._get_word_pinyin(typo_word)), - orig_freq, typo_freq)) + typo_info.append((word, typo_word, + ' '.join(word_pinyin), + ' '.join(self._get_word_pinyin(typo_word)), + orig_freq, typo_freq)) continue - + # 如果不进行整词替换,则进行单字替换 if len(word) == 1: char = word @@ -339,7 +341,7 @@ class ChineseTypoGenerator: for i, (char, py) in enumerate(zip(word, word_pinyin)): # 词中的字替换概率降低 word_error_rate = self.error_rate * (0.7 ** (len(word) - 1)) - + if random.random() < word_error_rate: similar_chars = self._get_similar_frequency_chars(char, py) if similar_chars: @@ -354,7 +356,7 @@ class ChineseTypoGenerator: continue word_result.append(char) result.append(''.join(word_result)) - + return ''.join(result), typo_info def format_typo_info(self, typo_info): @@ -369,7 +371,7 @@ class ChineseTypoGenerator: """ if not typo_info: return "未生成错别字" - + result = [] for orig, typo, orig_py, typo_py, orig_freq, typo_freq in typo_info: # 判断是否为词语替换 @@ -379,12 +381,12 @@ class ChineseTypoGenerator: else: tone_error = orig_py[:-1] == typo_py[:-1] and orig_py[-1] != typo_py[-1] error_type = "声调错误" if tone_error else "同音字替换" - + result.append(f"原文:{orig}({orig_py}) [频率:{orig_freq:.2f}] -> " - f"替换:{typo}({typo_py}) [频率:{typo_freq:.2f}] [{error_type}]") - + f"替换:{typo}({typo_py}) [频率:{typo_freq:.2f}] [{error_type}]") + return "\n".join(result) - + def set_params(self, **kwargs): """ 设置参数 @@ -399,9 +401,10 @@ class ChineseTypoGenerator: for key, value in kwargs.items(): if hasattr(self, key): setattr(self, key, value) - print(f"参数 {key} 已设置为 {value}") + logger.debug(f"参数 {key} 已设置为 {value}") else: - print(f"警告: 参数 {key} 不存在") + logger.warning(f"警告: 参数 {key} 不存在") + def main(): # 创建错别字生成器实例 @@ -411,27 +414,27 @@ def main(): tone_error_rate=0.02, word_replace_rate=0.3 ) - + # 获取用户输入 sentence = input("请输入中文句子:") - + # 创建包含错别字的句子 start_time = time.time() typo_sentence, typo_info = typo_generator.create_typo_sentence(sentence) - + # 打印结果 - print("\n原句:", sentence) - print("错字版:", typo_sentence) - + logger.debug("原句:", sentence) + logger.debug("错字版:", typo_sentence) + # 打印错别字信息 if typo_info: - print("\n错别字信息:") - print(typo_generator.format_typo_info(typo_info)) - + logger.debug(f"错别字信息:{typo_generator.format_typo_info(typo_info)})") + # 计算并打印总耗时 end_time = time.time() total_time = end_time - start_time - print(f"\n总耗时:{total_time:.2f}秒") + logger.debug(f"总耗时:{total_time:.2f}秒") + if __name__ == "__main__": main() From a43f9495eaf2ac82bfc19db8babe50ec9d745252 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Mon, 10 Mar 2025 11:46:59 +0800 Subject: [PATCH 008/162] fix: remove duplicate message(CR comments) --- src/gui/reasoning_gui.py | 169 +++++++++++------------ src/plugins/chat/config.py | 6 +- src/plugins/chat/llm_generator.py | 8 +- src/plugins/chat/message_sender.py | 8 +- src/plugins/chat/storage.py | 13 +- src/plugins/memory_system/draw_memory.py | 119 ++++++++-------- src/plugins/models/utils_model.py | 10 +- src/plugins/utils/statistic.py | 5 +- 8 files changed, 171 insertions(+), 167 deletions(-) diff --git a/src/gui/reasoning_gui.py b/src/gui/reasoning_gui.py index 572e4ece9..514a95dfb 100644 --- a/src/gui/reasoning_gui.py +++ b/src/gui/reasoning_gui.py @@ -5,6 +5,9 @@ import threading import time from datetime import datetime from typing import Dict, List +from loguru import logger +from typing import Optional +from pymongo import MongoClient import customtkinter as ctk from dotenv import load_dotenv @@ -17,23 +20,20 @@ root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) # 加载环境变量 if os.path.exists(os.path.join(root_dir, '.env.dev')): load_dotenv(os.path.join(root_dir, '.env.dev')) - print("成功加载开发环境配置") + logger.info("成功加载开发环境配置") elif os.path.exists(os.path.join(root_dir, '.env.prod')): load_dotenv(os.path.join(root_dir, '.env.prod')) - print("成功加载生产环境配置") + logger.info("成功加载生产环境配置") else: - print("未找到环境配置文件") + logger.error("未找到环境配置文件") sys.exit(1) -from typing import Optional - -from pymongo import MongoClient - class Database: _instance: Optional["Database"] = None - - def __init__(self, host: str, port: int, db_name: str, username: str = None, password: str = None, auth_source: str = None): + + def __init__(self, host: str, port: int, db_name: str, username: str = None, password: str = None, + auth_source: str = None): if username and password: self.client = MongoClient( host=host, @@ -45,96 +45,96 @@ class Database: else: self.client = MongoClient(host, port) self.db = self.client[db_name] - + @classmethod - def initialize(cls, host: str, port: int, db_name: str, username: str = None, password: str = None, auth_source: str = None) -> "Database": + def initialize(cls, host: str, port: int, db_name: str, username: str = None, password: str = None, + auth_source: str = None) -> "Database": if cls._instance is None: cls._instance = cls(host, port, db_name, username, password, auth_source) return cls._instance - + @classmethod def get_instance(cls) -> "Database": if cls._instance is None: raise RuntimeError("Database not initialized") - return cls._instance - + return cls._instance class ReasoningGUI: def __init__(self): # 记录启动时间戳,转换为Unix时间戳 self.start_timestamp = datetime.now().timestamp() - print(f"程序启动时间戳: {self.start_timestamp}") - + logger.info(f"程序启动时间戳: {self.start_timestamp}") + # 设置主题 ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") - + # 创建主窗口 self.root = ctk.CTk() self.root.title('麦麦推理') self.root.geometry('800x600') self.root.protocol("WM_DELETE_WINDOW", self._on_closing) - + # 初始化数据库连接 try: self.db = Database.get_instance().db - print("数据库连接成功") + logger.success("数据库连接成功") except RuntimeError: - print("数据库未初始化,正在尝试初始化...") + logger.warning("数据库未初始化,正在尝试初始化...") try: Database.initialize("127.0.0.1", 27017, "maimai_bot") self.db = Database.get_instance().db - print("数据库初始化成功") - except Exception as e: - print(f"数据库初始化失败: {e}") + logger.success("数据库初始化成功") + except Exception: + logger.exception(f"数据库初始化失败") sys.exit(1) - + # 存储群组数据 self.group_data: Dict[str, List[dict]] = {} - + # 创建更新队列 self.update_queue = queue.Queue() - + # 创建主框架 self.frame = ctk.CTkFrame(self.root) self.frame.pack(pady=20, padx=20, fill="both", expand=True) - + # 添加标题 self.title = ctk.CTkLabel(self.frame, text="麦麦的脑内所想", font=("Arial", 24)) self.title.pack(pady=10, padx=10) - + # 创建左右分栏 self.paned = ctk.CTkFrame(self.frame) self.paned.pack(fill="both", expand=True, padx=10, pady=10) - + # 左侧群组列表 self.left_frame = ctk.CTkFrame(self.paned, width=200) self.left_frame.pack(side="left", fill="y", padx=5, pady=5) - + self.group_label = ctk.CTkLabel(self.left_frame, text="群组列表", font=("Arial", 16)) self.group_label.pack(pady=5) - + # 创建可滚动框架来容纳群组按钮 self.group_scroll_frame = ctk.CTkScrollableFrame(self.left_frame, width=180, height=400) self.group_scroll_frame.pack(pady=5, padx=5, fill="both", expand=True) - + # 存储群组按钮的字典 self.group_buttons: Dict[str, ctk.CTkButton] = {} # 当前选中的群组ID self.selected_group_id: Optional[str] = None - + # 右侧内容显示 self.right_frame = ctk.CTkFrame(self.paned) self.right_frame.pack(side="right", fill="both", expand=True, padx=5, pady=5) - + self.content_label = ctk.CTkLabel(self.right_frame, text="推理内容", font=("Arial", 16)) self.content_label.pack(pady=5) - + # 创建富文本显示框 self.content_text = ctk.CTkTextbox(self.right_frame, width=500, height=400) self.content_text.pack(pady=5, padx=5, fill="both", expand=True) - + # 配置文本标签 - 只使用颜色 self.content_text.tag_config("timestamp", foreground="#888888") # 时间戳使用灰色 self.content_text.tag_config("user", foreground="#4CAF50") # 用户名使用绿色 @@ -144,11 +144,11 @@ class ReasoningGUI: self.content_text.tag_config("reasoning", foreground="#FF9800") # 推理过程使用橙色 self.content_text.tag_config("response", foreground="#E91E63") # 回复使用粉色 self.content_text.tag_config("separator", foreground="#666666") # 分隔符使用深灰色 - + # 底部控制栏 self.control_frame = ctk.CTkFrame(self.frame) self.control_frame.pack(fill="x", padx=10, pady=5) - + self.clear_button = ctk.CTkButton( self.control_frame, text="清除显示", @@ -156,19 +156,19 @@ class ReasoningGUI: width=120 ) self.clear_button.pack(side="left", padx=5) - + # 启动自动更新线程 self.update_thread = threading.Thread(target=self._auto_update, daemon=True) self.update_thread.start() - + # 启动GUI更新检查 self.root.after(100, self._process_queue) - + def _on_closing(self): """处理窗口关闭事件""" self.root.quit() sys.exit(0) - + def _process_queue(self): """处理更新队列中的任务""" try: @@ -183,14 +183,14 @@ class ReasoningGUI: finally: # 继续检查队列 self.root.after(100, self._process_queue) - + def _update_group_list_gui(self): """在主线程中更新群组列表""" # 清除现有按钮 for button in self.group_buttons.values(): button.destroy() self.group_buttons.clear() - + # 创建新的群组按钮 for group_id in self.group_data.keys(): button = ctk.CTkButton( @@ -203,16 +203,16 @@ class ReasoningGUI: ) button.pack(pady=2, padx=5) self.group_buttons[group_id] = button - + # 如果有选中的群组,保持其高亮状态 if self.selected_group_id and self.selected_group_id in self.group_buttons: self._highlight_selected_group(self.selected_group_id) - + def _on_group_select(self, group_id: str): """处理群组选择事件""" self._highlight_selected_group(group_id) self._update_display_gui(group_id) - + def _highlight_selected_group(self, group_id: str): """高亮显示选中的群组按钮""" # 重置所有按钮的颜色 @@ -223,9 +223,9 @@ class ReasoningGUI: else: # 恢复其他按钮的默认颜色 button.configure(fg_color="#2B2B2B", hover_color="#404040") - + self.selected_group_id = group_id - + def _update_display_gui(self, group_id: str): """在主线程中更新显示内容""" if group_id in self.group_data: @@ -234,19 +234,19 @@ class ReasoningGUI: # 时间戳 time_str = item['time'].strftime("%Y-%m-%d %H:%M:%S") self.content_text.insert("end", f"[{time_str}]\n", "timestamp") - + # 用户信息 self.content_text.insert("end", "用户: ", "timestamp") self.content_text.insert("end", f"{item.get('user', '未知')}\n", "user") - + # 消息内容 self.content_text.insert("end", "消息: ", "timestamp") self.content_text.insert("end", f"{item.get('message', '')}\n", "message") - + # 模型信息 self.content_text.insert("end", "模型: ", "timestamp") self.content_text.insert("end", f"{item.get('model', '')}\n", "model") - + # Prompt内容 self.content_text.insert("end", "Prompt内容:\n", "timestamp") prompt_text = item.get('prompt', '') @@ -257,7 +257,7 @@ class ReasoningGUI: self.content_text.insert("end", " " + line + "\n", "prompt") else: self.content_text.insert("end", " 无Prompt内容\n", "prompt") - + # 推理过程 self.content_text.insert("end", "推理过程:\n", "timestamp") reasoning_text = item.get('reasoning', '') @@ -268,53 +268,53 @@ class ReasoningGUI: self.content_text.insert("end", " " + line + "\n", "reasoning") else: self.content_text.insert("end", " 无推理过程\n", "reasoning") - + # 回复内容 self.content_text.insert("end", "回复: ", "timestamp") self.content_text.insert("end", f"{item.get('response', '')}\n", "response") - + # 分隔符 - self.content_text.insert("end", f"\n{'='*50}\n\n", "separator") - + self.content_text.insert("end", f"\n{'=' * 50}\n\n", "separator") + # 滚动到顶部 self.content_text.see("1.0") - + def _auto_update(self): """自动更新函数""" while True: try: # 从数据库获取最新数据,只获取启动时间之后的记录 query = {"time": {"$gt": self.start_timestamp}} - print(f"查询条件: {query}") - + logger.debug(f"查询条件: {query}") + # 先获取一条记录检查时间格式 sample = self.db.reasoning_logs.find_one() if sample: - print(f"样本记录时间格式: {type(sample['time'])} 值: {sample['time']}") - + logger.debug(f"样本记录时间格式: {type(sample['time'])} 值: {sample['time']}") + cursor = self.db.reasoning_logs.find(query).sort("time", -1) new_data = {} total_count = 0 - + for item in cursor: # 调试输出 if total_count == 0: - print(f"记录时间: {item['time']}, 类型: {type(item['time'])}") - + logger.debug(f"记录时间: {item['time']}, 类型: {type(item['time'])}") + total_count += 1 group_id = str(item.get('group_id', 'unknown')) if group_id not in new_data: new_data[group_id] = [] - + # 转换时间戳为datetime对象 if isinstance(item['time'], (int, float)): time_obj = datetime.fromtimestamp(item['time']) elif isinstance(item['time'], datetime): time_obj = item['time'] else: - print(f"未知的时间格式: {type(item['time'])}") + logger.warning(f"未知的时间格式: {type(item['time'])}") time_obj = datetime.now() # 使用当前时间作为后备 - + new_data[group_id].append({ 'time': time_obj, 'user': item.get('user', '未知'), @@ -324,13 +324,13 @@ class ReasoningGUI: 'response': item.get('response', ''), 'prompt': item.get('prompt', '') # 添加prompt字段 }) - - print(f"从数据库加载了 {total_count} 条记录,分布在 {len(new_data)} 个群组中") - + + logger.info(f"从数据库加载了 {total_count} 条记录,分布在 {len(new_data)} 个群组中") + # 更新数据 if new_data != self.group_data: self.group_data = new_data - print("数据已更新,正在刷新显示...") + logger.info("数据已更新,正在刷新显示...") # 将更新任务添加到队列 self.update_queue.put({'type': 'update_group_list'}) if self.group_data: @@ -341,16 +341,16 @@ class ReasoningGUI: 'type': 'update_display', 'group_id': self.selected_group_id }) - except Exception as e: - print(f"自动更新出错: {e}") - + except Exception: + logger.exception(f"自动更新出错") + # 每5秒更新一次 time.sleep(5) - + def clear_display(self): """清除显示内容""" self.content_text.delete("1.0", "end") - + def run(self): """运行GUI""" self.root.mainloop() @@ -359,18 +359,17 @@ class ReasoningGUI: def main(): """主函数""" Database.initialize( - host= os.getenv("MONGODB_HOST"), - port= int(os.getenv("MONGODB_PORT")), - db_name= os.getenv("DATABASE_NAME"), - username= os.getenv("MONGODB_USERNAME"), - password= os.getenv("MONGODB_PASSWORD"), + host=os.getenv("MONGODB_HOST"), + port=int(os.getenv("MONGODB_PORT")), + db_name=os.getenv("DATABASE_NAME"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), auth_source=os.getenv("MONGODB_AUTH_SOURCE") ) - + app = ReasoningGUI() app.run() - if __name__ == "__main__": main() diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 02fccc863..c37d23a46 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -135,7 +135,7 @@ class BotConfig: try: config_version: str = toml["inner"]["version"] except KeyError as e: - logger.error(f"配置文件中 inner 段 不存在 {e}, 这是错误的配置文件") + logger.error(f"配置文件中 inner 段 不存在, 这是错误的配置文件") raise KeyError(f"配置文件中 inner 段 不存在 {e}, 这是错误的配置文件") else: toml["inner"] = {"version": "0.0.0"} @@ -246,11 +246,11 @@ class BotConfig: try: cfg_target[i] = cfg_item[i] except KeyError as e: - logger.error(f"{item} 中的必要字段 {e} 不存在,请检查") + logger.error(f"{item} 中的必要字段不存在,请检查") raise KeyError(f"{item} 中的必要字段 {e} 不存在,请检查") provider = cfg_item.get("provider") - if provider == None: + if provider is None: logger.error(f"provider 字段在模型配置 {item} 中不存在,请检查") raise KeyError(f"provider 字段在模型配置 {item} 中不存在,请检查") diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index 1236bb2e0..f40c9a441 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -93,8 +93,8 @@ class ResponseGenerator: # 生成回复 try: content, reasoning_content = await model.generate_response(prompt) - except Exception as e: - logger.exception(f"生成回复时出错: {e}") + except Exception: + logger.exception(f"生成回复时出错") return None # 保存到数据库 @@ -145,8 +145,8 @@ class ResponseGenerator: else: return ["neutral"] - except Exception as e: - logger.exception(f"获取情感标签时出错: {e}") + except Exception: + logger.exception(f"获取情感标签时出错") return ["neutral"] async def _process_response(self, content: str) -> Tuple[List[str], List[str]]: diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index b9506b455..4abbd3b3f 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -119,8 +119,8 @@ class MessageContainer: self.messages.remove(message) return True return False - except Exception as e: - logger.exception(f"移除消息时发生错误: {e}") + except Exception: + logger.exception(f"移除消息时发生错误") return False def has_messages(self) -> bool: @@ -213,8 +213,8 @@ class MessageManager: # 安全地移除消息 if not container.remove_message(msg): logger.warning("尝试删除不存在的消息") - except Exception as e: - logger.exception(f"处理超时消息时发生错误: {e}") + except Exception: + logger.exception(f"处理超时消息时发生错误") continue async def start_processor(self): diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index 6a87480b7..1c2d05071 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -2,12 +2,13 @@ from typing import Optional from ...common.database import Database from .message import Message +from loguru import logger class MessageStorage: def __init__(self): self.db = Database.get_instance() - + async def store_message(self, message: Message, topic: Optional[str] = None) -> None: """存储消息到数据库""" try: @@ -41,9 +42,9 @@ class MessageStorage: "topic": topic, "detailed_plain_text": message.detailed_plain_text, } - - self.db.db.messages.insert_one(message_data) - except Exception as e: - print(f"\033[1;31m[错误]\033[0m 存储消息失败: {e}") -# 如果需要其他存储相关的函数,可以在这里添加 \ No newline at end of file + self.db.db.messages.insert_one(message_data) + except Exception: + logger.exception(f"存储消息失败") + +# 如果需要其他存储相关的函数,可以在这里添加 diff --git a/src/plugins/memory_system/draw_memory.py b/src/plugins/memory_system/draw_memory.py index c2d04064d..6da330d95 100644 --- a/src/plugins/memory_system/draw_memory.py +++ b/src/plugins/memory_system/draw_memory.py @@ -7,6 +7,7 @@ import jieba import matplotlib.pyplot as plt import networkx as nx from dotenv import load_dotenv +from loguru import logger sys.path.append("C:/GitHub/MaiMBot") # 添加项目根目录到 Python 路径 from src.common.database import Database # 使用正确的导入语法 @@ -15,15 +16,15 @@ from src.common.database import Database # 使用正确的导入语法 env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), '.env.dev') load_dotenv(env_path) - + class Memory_graph: def __init__(self): self.G = nx.Graph() # 使用 networkx 的图结构 self.db = Database.get_instance() - + def connect_dot(self, concept1, concept2): self.G.add_edge(concept1, concept2) - + def add_dot(self, concept, memory): if concept in self.G: # 如果节点已存在,将新记忆添加到现有列表中 @@ -37,7 +38,7 @@ class Memory_graph: else: # 如果是新节点,创建新的记忆列表 self.G.add_node(concept, memory_items=[memory]) - + def get_dot(self, concept): # 检查节点是否存在于图中 if concept in self.G: @@ -45,20 +46,20 @@ class Memory_graph: node_data = self.G.nodes[concept] # print(node_data) # 创建新的Memory_dot对象 - return concept,node_data + return concept, node_data return None def get_related_item(self, topic, depth=1): if topic not in self.G: return [], [] - + first_layer_items = [] second_layer_items = [] - + # 获取相邻节点 neighbors = list(self.G.neighbors(topic)) # print(f"第一层: {topic}") - + # 获取当前节点的记忆项 node_data = self.get_dot(topic) if node_data: @@ -69,7 +70,7 @@ class Memory_graph: first_layer_items.extend(memory_items) else: first_layer_items.append(memory_items) - + # 只在depth=2时获取第二层记忆 if depth >= 2: # 获取相邻节点的记忆项 @@ -84,42 +85,44 @@ class Memory_graph: second_layer_items.extend(memory_items) else: second_layer_items.append(memory_items) - + return first_layer_items, second_layer_items - + def store_memory(self): for node in self.G.nodes(): dot_data = { "concept": node } self.db.db.store_memory_dots.insert_one(dot_data) - + @property def dots(self): # 返回所有节点对应的 Memory_dot 对象 return [self.get_dot(node) for node in self.G.nodes()] - - + def get_random_chat_from_db(self, length: int, timestamp: str): # 从数据库中根据时间戳获取离其最近的聊天记录 chat_text = '' closest_record = self.db.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) # 调试输出 - print(f"距离time最近的消息时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(closest_record['time'])))}") - + logger.info( + f"距离time最近的消息时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(closest_record['time'])))}") + if closest_record: closest_time = closest_record['time'] group_id = closest_record['group_id'] # 获取groupid # 获取该时间戳之后的length条消息,且groupid相同 - chat_record = list(self.db.db.messages.find({"time": {"$gt": closest_time}, "group_id": group_id}).sort('time', 1).limit(length)) + chat_record = list( + self.db.db.messages.find({"time": {"$gt": closest_time}, "group_id": group_id}).sort('time', 1).limit( + length)) for record in chat_record: time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(record['time']))) try: - displayname="[(%s)%s]%s" % (record["user_id"],record["user_nickname"],record["user_cardname"]) + displayname = "[(%s)%s]%s" % (record["user_id"], record["user_nickname"], record["user_cardname"]) except: - displayname=record["user_nickname"] or "用户" + str(record["user_id"]) + displayname = record["user_nickname"] or "用户" + str(record["user_id"]) chat_text += f'[{time_str}] {displayname}: {record["processed_plain_text"]}\n' # 添加发送者和时间信息 return chat_text - + return [] # 如果没有找到记录,返回空列表 def save_graph_to_db(self): @@ -166,53 +169,54 @@ def main(): password=os.getenv("MONGODB_PASSWORD", ""), auth_source=os.getenv("MONGODB_AUTH_SOURCE", "") ) - + memory_graph = Memory_graph() memory_graph.load_graph_from_db() - + # 只显示一次优化后的图形 visualize_graph_lite(memory_graph) - + while True: query = input("请输入新的查询概念(输入'退出'以结束):") if query.lower() == '退出': break first_layer_items, second_layer_items = memory_graph.get_related_item(query) if first_layer_items or second_layer_items: - print("\n第一层记忆:") + logger.debug("第一层记忆:") for item in first_layer_items: - print(item) - print("\n第二层记忆:") + logger.debug(item) + logger.debug("第二层记忆:") for item in second_layer_items: - print(item) + logger.debug(item) else: - print("未找到相关记忆。") - + logger.debug("未找到相关记忆。") + def segment_text(text): seg_text = list(jieba.cut(text)) - return seg_text + return seg_text + def find_topic(text, topic_num): prompt = f'这是一段文字:{text}。请你从这段话中总结出{topic_num}个话题,帮我列出来,用逗号隔开,尽可能精简。只需要列举{topic_num}个话题就好,不要告诉我其他内容。' return prompt + def topic_what(text, topic): prompt = f'这是一段文字:{text}。我想知道这记忆里有什么关于{topic}的话题,帮我总结成一句自然的话,可以包含时间和人物。只输出这句话就好' return prompt - def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = False): # 设置中文字体 plt.rcParams['font.sans-serif'] = ['SimHei'] # 用来正常显示中文标签 plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号 - + G = memory_graph.G - + # 创建一个新图用于可视化 H = G.copy() - + # 移除只有一条记忆的节点和连接数少于3的节点 nodes_to_remove = [] for node in H.nodes(): @@ -221,14 +225,14 @@ def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = Fal degree = H.degree(node) if memory_count < 3 or degree < 2: # 改为小于2而不是小于等于2 nodes_to_remove.append(node) - + H.remove_nodes_from(nodes_to_remove) - + # 如果过滤后没有节点,则返回 if len(H.nodes()) == 0: - print("过滤后没有符合条件的节点可显示") + logger.debug("过滤后没有符合条件的节点可显示") return - + # 保存图到本地 # nx.write_gml(H, "memory_graph.gml") # 保存为 GML 格式 @@ -236,7 +240,7 @@ def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = Fal node_colors = [] node_sizes = [] nodes = list(H.nodes()) - + # 获取最大记忆数和最大度数用于归一化 max_memories = 1 max_degree = 1 @@ -246,7 +250,7 @@ def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = Fal degree = H.degree(node) max_memories = max(max_memories, memory_count) max_degree = max(max_degree, degree) - + # 计算每个节点的大小和颜色 for node in nodes: # 计算节点大小(基于记忆数量) @@ -254,9 +258,9 @@ def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = Fal memory_count = len(memory_items) if isinstance(memory_items, list) else (1 if memory_items else 0) # 使用指数函数使变化更明显 ratio = memory_count / max_memories - size = 500 + 5000 * (ratio ) # 使用1.5次方函数使差异不那么明显 + size = 500 + 5000 * (ratio) # 使用1.5次方函数使差异不那么明显 node_sizes.append(size) - + # 计算节点颜色(基于连接数) degree = H.degree(node) # 红色分量随着度数增加而增加 @@ -267,26 +271,25 @@ def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = Fal # blue = 1 color = (red, 0.1, blue) node_colors.append(color) - + # 绘制图形 plt.figure(figsize=(12, 8)) pos = nx.spring_layout(H, k=1, iterations=50) # 增加k值使节点分布更开 - nx.draw(H, pos, - with_labels=True, - node_color=node_colors, - node_size=node_sizes, - font_size=10, - font_family='SimHei', - font_weight='bold', - edge_color='gray', - width=0.5, - alpha=0.9) - + nx.draw(H, pos, + with_labels=True, + node_color=node_colors, + node_size=node_sizes, + font_size=10, + font_family='SimHei', + font_weight='bold', + edge_color='gray', + width=0.5, + alpha=0.9) + title = '记忆图谱可视化 - 节点大小表示记忆数量,颜色表示连接数' plt.title(title, fontsize=16, fontfamily='SimHei') plt.show() - - - + + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index f4100e0bb..fe8e1a100 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -45,7 +45,7 @@ class LLM_request: self.db.db.llm_usage.create_index([("user_id", 1)]) self.db.db.llm_usage.create_index([("request_type", 1)]) except Exception as e: - logger.error(f"创建数据库索引失败: {e}") + logger.error(f"创建数据库索引失败") def _record_usage(self, prompt_tokens: int, completion_tokens: int, total_tokens: int, user_id: str = "system", request_type: str = "chat", @@ -79,8 +79,8 @@ class LLM_request: f"提示词: {prompt_tokens}, 完成: {completion_tokens}, " f"总计: {total_tokens}" ) - except Exception as e: - logger.error(f"记录token使用情况失败: {e}") + except Exception: + logger.error(f"记录token使用情况失败") def _calculate_cost(self, prompt_tokens: int, completion_tokens: int) -> float: """计算API调用成本 @@ -226,8 +226,8 @@ class LLM_request: if delta_content is None: delta_content = "" accumulated_content += delta_content - except Exception as e: - logger.error(f"解析流式输出错误: {e}") + except Exception: + logger.exception(f"解析流式输出错") content = accumulated_content reasoning_content = "" think_match = re.search(r'(.*?)', content, re.DOTALL) diff --git a/src/plugins/utils/statistic.py b/src/plugins/utils/statistic.py index d7248e869..a071355a3 100644 --- a/src/plugins/utils/statistic.py +++ b/src/plugins/utils/statistic.py @@ -3,6 +3,7 @@ import time from collections import defaultdict from datetime import datetime, timedelta from typing import Any, Dict +from loguru import logger from ...common.database import Database @@ -153,8 +154,8 @@ class LLMStatistics: try: all_stats = self._collect_all_statistics() self._save_statistics(all_stats) - except Exception as e: - print(f"\033[1;31m[错误]\033[0m 统计数据处理失败: {e}") + except Exception: + logger.exception(f"统计数据处理失败") # 等待1分钟 for _ in range(60): From e1b484ae61249febf66692cc5c709f5bba9c689c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Mon, 10 Mar 2025 13:53:17 +0900 Subject: [PATCH 009/162] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0CLAUDE.md?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E6=8C=87=E5=8D=97=E6=96=87=E4=BB=B6=EF=BC=88?= =?UTF-8?q?=E7=94=A8=E4=BA=8EClaude=20Code=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d30b0e651 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# MaiMBot 开发指南 + +## 🛠️ 常用命令 + +- **运行机器人**: `python run.py` 或 `python bot.py` +- **安装依赖**: `pip install --upgrade -r requirements.txt` +- **Docker 部署**: `docker-compose up` +- **代码检查**: `ruff check .` +- **代码格式化**: `ruff format .` +- **内存可视化**: `run_memory_vis.bat` 或 `python -m src.plugins.memory_system.draw_memory` +- **推理过程可视化**: `script/run_thingking.bat` + +## 🔧 脚本工具 + +- **运行MongoDB**: `script/run_db.bat` - 在端口27017启动MongoDB +- **Windows完整启动**: `script/run_windows.bat` - 检查Python版本、设置虚拟环境、安装依赖并运行机器人 +- **快速启动**: `script/run_maimai.bat` - 设置UTF-8编码并执行"nb run"命令 + +## 📝 代码风格 + +- **Python版本**: 3.9+ +- **行长度限制**: 88字符 +- **命名规范**: + - `snake_case` 用于函数和变量 + - `PascalCase` 用于类 + - `_prefix` 用于私有成员 +- **导入顺序**: 标准库 → 第三方库 → 本地模块 +- **异步编程**: 对I/O操作使用async/await +- **日志记录**: 使用loguru进行一致的日志记录 +- **错误处理**: 使用带有具体异常的try/except +- **文档**: 为类和公共函数编写docstrings + +## 🧩 系统架构 + +- **框架**: NoneBot2框架与插件架构 +- **数据库**: MongoDB持久化存储 +- **设计模式**: 工厂模式和单例管理器 +- **配置管理**: 使用环境变量和TOML文件 +- **内存系统**: 基于图的记忆结构,支持记忆构建、压缩、检索和遗忘 +- **情绪系统**: 情绪模拟与概率权重 +- **LLM集成**: 支持多个LLM服务提供商(ChatAnywhere, SiliconFlow, DeepSeek) + +## ⚙️ 环境配置 + +- 使用`template.env`作为环境变量模板 +- 使用`template/bot_config_template.toml`作为机器人配置模板 +- MongoDB配置: 主机、端口、数据库名 +- API密钥配置: 各LLM提供商的API密钥 From c9f12446c0a24a8bc3eaea9f88157389c1360cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Mon, 10 Mar 2025 13:54:15 +0900 Subject: [PATCH 010/162] =?UTF-8?q?docs:=20=E6=94=B9=E8=BF=9BREADME.md?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=A0=BC=E5=BC=8F=E5=92=8C=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一空行格式 - 修复标题标记一致性 - 优化列表格式和标题间距 - 添加缺失的章节分隔符 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0c02d1cba..55f8e9f82 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -# 麦麦!MaiMBot (编辑中) - +# 麦麦!MaiMBot (编辑中)
@@ -30,6 +29,7 @@
> ⚠️ **注意事项** +> > - 项目处于活跃开发阶段,代码可能随时更改 > - 文档未完善,有问题可以提交 Issue 或者 Discussion > - QQ机器人存在被限制风险,请自行了解,谨慎使用 @@ -44,7 +44,6 @@ - (由 [CabLate](https://github.com/cablate) 贡献) [Telegram 与其他平台(未来可能会有)的版本](https://github.com/cablate/MaiMBot/tree/telegram) - [集中讨论串](https://github.com/SengokuCola/MaiMBot/discussions/149) -##

📚 文档 ⬇️ 快速开始使用麦麦 ⬇️

@@ -55,15 +54,14 @@ - [🐳 Docker部署指南](docs/docker_deploy.md) - - [📦 手动部署指南 Windows](docs/manual_deploy_windows.md) - - [📦 手动部署指南 Linux](docs/manual_deploy_linux.md) -- 📦 Windows 一键傻瓜式部署,请运行项目根目录中的 ```run.bat```,部署完成后请参照后续配置指南进行配置 +- 📦 Windows 一键傻瓜式部署,请运行项目根目录中的 ```run.bat```,部署完成后请参照后续配置指南进行配置 ### 配置说明 + - [🎀 新手配置指南](docs/installation_cute.md) - 通俗易懂的配置教程,适合初次使用的猫娘 - [⚙️ 标准配置指南](docs/installation_standard.md) - 简明专业的配置说明,适合有经验的用户 @@ -76,6 +74,7 @@ ## 🎯 功能介绍 ### 💬 聊天功能 + - 支持关键词检索主动发言:对消息的话题topic进行识别,如果检测到麦麦存储过的话题就会主动进行发言 - 支持bot名字呼唤发言:检测到"麦麦"会主动发言,可配置 - 支持多模型,多厂商自定义配置 @@ -84,31 +83,33 @@ - 错别字和多条回复功能:麦麦可以随机生成错别字,会多条发送回复以及对消息进行reply ### 😊 表情包功能 + - 支持根据发言内容发送对应情绪的表情包 - 会自动偷群友的表情包 ### 📅 日程功能 + - 麦麦会自动生成一天的日程,实现更拟人的回复 ### 🧠 记忆功能 + - 对聊天记录进行概括存储,在需要时调用,待完善 ### 📚 知识库功能 + - 基于embedding模型的知识库,手动放入txt会自动识别,写完了,暂时禁用 ### 👥 关系功能 + - 针对每个用户创建"关系",可以对不同用户进行个性化回复,目前只有极其简单的好感度(WIP) - 针对每个群创建"群印象",可以对不同群进行个性化回复(WIP) - - ## 开发计划TODO:LIST 规划主线 0.6.0:记忆系统更新 0.7.0: 麦麦RunTime - - 人格功能:WIP - 群氛围功能:WIP - 图片发送,转发功能:WIP @@ -128,7 +129,6 @@ - 采用截断生成加快麦麦的反应速度 - 改进发送消息的触发 - ## 设计理念 - **千石可乐说:** @@ -138,13 +138,14 @@ - 如果人类真的需要一个AI来陪伴自己,并不是所有人都需要一个完美的,能解决所有问题的helpful assistant,而是一个会犯错的,拥有自己感知和想法的"生命形式"。 - 代码会保持开源和开放,但个人希望MaiMbot的运行时数据保持封闭,尽量避免以显式命令来对其进行控制和调试.我认为一个你无法完全掌控的个体才更能让你感觉到它的自主性,而视其成为一个对话机器. - ## 📌 注意事项 + SengokuCola纯编程外行,面向cursor编程,很多代码史一样多多包涵 > ⚠️ **警告**:本应用生成内容来自人工智能模型,由 AI 生成,请仔细甄别,请勿用于违反法律的用途,AI生成内容不代表本人观点和立场。 ## 致谢 + [nonebot2](https://github.com/nonebot/nonebot2): 跨平台 Python 异步聊天机器人框架 [NapCat](https://github.com/NapNeko/NapCatQQ): 现代化的基于 NTQQ 的 Bot 协议端实现 @@ -156,6 +157,6 @@ SengokuCola纯编程外行,面向cursor编程,很多代码史一样多多包 - ## Stargazers over time + [![Stargazers over time](https://starchart.cc/SengokuCola/MaiMBot.svg?variant=adaptive)](https://starchart.cc/SengokuCola/MaiMBot) From 8a32d184605ec256541fc8d9b3bcee2dae2967ff Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Mon, 10 Mar 2025 13:09:01 +0800 Subject: [PATCH 011/162] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96willing=5Fman?= =?UTF-8?q?ager=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E4=BF=9D=E5=BA=95=E6=A6=82=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/willing_manager.py | 111 +++++++++++++++++----------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 4f8bec7dc..116ee3f87 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -1,6 +1,6 @@ import asyncio -from .config import global_config from loguru import logger +from .config import global_config class WillingManager: @@ -8,74 +8,100 @@ class WillingManager: self.group_reply_willing = {} # 存储每个群的回复意愿 self._decay_task = None self._started = False - + self.min_reply_willing = 0.01 + self.attenuation_coefficient = 0.75 + async def _decay_reply_willing(self): """定期衰减回复意愿""" while True: await asyncio.sleep(5) for group_id in self.group_reply_willing: - self.group_reply_willing[group_id] = max(0, self.group_reply_willing[group_id] * 0.6) - + self.group_reply_willing[group_id] = max( + self.min_reply_willing, + self.group_reply_willing[group_id] * self.attenuation_coefficient + ) + def get_willing(self, group_id: int) -> float: """获取指定群组的回复意愿""" return self.group_reply_willing.get(group_id, 0) - + def set_willing(self, group_id: int, willing: float): """设置指定群组的回复意愿""" self.group_reply_willing[group_id] = willing - - def change_reply_willing_received(self, group_id: int, topic: str, is_mentioned_bot: bool, config, user_id: int = None, is_emoji: bool = False, interested_rate: float = 0) -> float: - """改变指定群组的回复意愿并返回回复概率""" + + def change_reply_willing_received(self, group_id: int, topic: str, is_mentioned_bot: bool, config, + user_id: int = None, is_emoji: bool = False, interested_rate: float = 0) -> float: + + # 若非目标回复群组,则直接return + if group_id not in config.talk_allowed_groups: + reply_probability = 0 + return reply_probability + current_willing = self.group_reply_willing.get(group_id, 0) - - # print(f"初始意愿: {current_willing}") - if is_mentioned_bot and current_willing < 1.0: - current_willing += 0.9 - logger.info(f"被提及, 当前意愿: {current_willing}") - elif is_mentioned_bot: - current_willing += 0.05 - logger.info(f"被重复提及, 当前意愿: {current_willing}") - + + logger.debug(f"[{group_id}]的初始回复意愿: {current_willing}") + + # 根据消息类型(被cue/表情包)调控 + if is_mentioned_bot: + current_willing = min( + 3.0, + current_willing + 0.9 + ) + logger.debug(f"被提及, 当前意愿: {current_willing}") + if is_emoji: current_willing *= 0.1 - logger.info(f"表情包, 当前意愿: {current_willing}") - - logger.debug(f"放大系数_interested_rate: {global_config.response_interested_rate_amplifier}") - interested_rate *= global_config.response_interested_rate_amplifier #放大回复兴趣度 - if interested_rate > 0.4: - # print(f"兴趣度: {interested_rate}, 当前意愿: {current_willing}") - current_willing += interested_rate-0.4 - - current_willing *= global_config.response_willing_amplifier #放大回复意愿 - # print(f"放大系数_willing: {global_config.response_willing_amplifier}, 当前意愿: {current_willing}") - - reply_probability = max((current_willing - 0.45) * 2, 0) - if group_id not in config.talk_allowed_groups: - current_willing = 0 - reply_probability = 0 - + logger.debug(f"表情包, 当前意愿: {current_willing}") + + # 兴趣放大系数,若兴趣 > 0.4则增加回复概率 + interested_rate_amplifier = global_config.response_interested_rate_amplifier + logger.debug(f"放大系数_interested_rate: {interested_rate_amplifier}") + interested_rate *= interested_rate_amplifier + + current_willing += max( + 0.0, + interested_rate - 0.4 + ) + + # 回复意愿系数调控,独立乘区 + willing_amplifier = max( + global_config.response_willing_amplifier, + self.min_reply_willing + ) + current_willing *= willing_amplifier + logger.debug(f"放大系数_willing: {global_config.response_willing_amplifier}, 当前意愿: {current_willing}") + + # 回复概率迭代,保底0.01回复概率 + reply_probability = max( + (current_willing - 0.45) * 2, + self.min_reply_willing + ) + + # 降低目标低频群组回复概率 + down_frequency_rate = max( + 1.0, + global_config.down_frequency_rate + ) if group_id in config.talk_frequency_down_groups: - reply_probability = reply_probability / global_config.down_frequency_rate + reply_probability = reply_probability / down_frequency_rate reply_probability = min(reply_probability, 1) - if reply_probability < 0: - reply_probability = 0 - - + self.group_reply_willing[group_id] = min(current_willing, 3.0) + logger.debug(f"当前群组{group_id}回复概率:{reply_probability}") return reply_probability - + def change_reply_willing_sent(self, group_id: int): """开始思考后降低群组的回复意愿""" current_willing = self.group_reply_willing.get(group_id, 0) self.group_reply_willing[group_id] = max(0, current_willing - 2) - + def change_reply_willing_after_sent(self, group_id: int): """发送消息后提高群组的回复意愿""" current_willing = self.group_reply_willing.get(group_id, 0) if current_willing < 1: self.group_reply_willing[group_id] = min(1, current_willing + 0.2) - + async def ensure_started(self): """确保衰减任务已启动""" if not self._started: @@ -83,5 +109,6 @@ class WillingManager: self._decay_task = asyncio.create_task(self._decay_reply_willing()) self._started = True + # 创建全局实例 -willing_manager = WillingManager() +willing_manager = WillingManager() From 4baa6c6f0aa2986a8fa6a7f7425a2dcb315d6478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Mon, 10 Mar 2025 14:48:43 +0900 Subject: [PATCH 012/162] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0MongoDB=20URI?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E8=BF=9E=E6=8E=A5=EF=BC=8C=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=95=B0=E6=8D=AE=E5=BA=93=E8=BF=9E=E6=8E=A5=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/database.py | 36 ++++++-- src/gui/reasoning_gui.py | 53 ++++-------- src/plugins/chat/__init__.py | 1 + src/plugins/chat/utils_image.py | 9 +- src/plugins/knowledege/knowledge_library.py | 5 +- src/plugins/memory_system/draw_memory.py | 7 +- src/plugins/memory_system/memory.py | 8 +- .../memory_system/memory_manual_build.py | 82 ++++++------------- src/plugins/schedule/schedule_generator.py | 2 +- 9 files changed, 82 insertions(+), 121 deletions(-) diff --git a/src/common/database.py b/src/common/database.py index 45ac05dac..f0954b07c 100644 --- a/src/common/database.py +++ b/src/common/database.py @@ -6,20 +6,44 @@ from pymongo import MongoClient class Database: _instance: Optional["Database"] = None - def __init__(self, host: str, port: int, db_name: str, username: Optional[str] = None, password: Optional[str] = None, auth_source: Optional[str] = None): - if username and password: + def __init__( + self, + host: str, + port: int, + db_name: str, + username: Optional[str] = None, + password: Optional[str] = None, + auth_source: Optional[str] = None, + uri: Optional[str] = None, + ): + if uri and uri.startswith("mongodb://"): + # 优先使用URI连接 + self.client = MongoClient(uri) + elif username and password: # 如果有用户名和密码,使用认证连接 - # TODO: 复杂情况直接支持URI吧 - self.client = MongoClient(host, port, username=username, password=password, authSource=auth_source) + self.client = MongoClient( + host, port, username=username, password=password, authSource=auth_source + ) else: # 否则使用无认证连接 self.client = MongoClient(host, port) self.db = self.client[db_name] @classmethod - def initialize(cls, host: str, port: int, db_name: str, username: Optional[str] = None, password: Optional[str] = None, auth_source: Optional[str] = None) -> "Database": + def initialize( + cls, + host: str, + port: int, + db_name: str, + username: Optional[str] = None, + password: Optional[str] = None, + auth_source: Optional[str] = None, + uri: Optional[str] = None, + ) -> "Database": if cls._instance is None: - cls._instance = cls(host, port, db_name, username, password, auth_source) + cls._instance = cls( + host, port, db_name, username, password, auth_source, uri + ) return cls._instance @classmethod diff --git a/src/gui/reasoning_gui.py b/src/gui/reasoning_gui.py index 514a95dfb..dd62e0634 100644 --- a/src/gui/reasoning_gui.py +++ b/src/gui/reasoning_gui.py @@ -7,7 +7,7 @@ from datetime import datetime from typing import Dict, List from loguru import logger from typing import Optional -from pymongo import MongoClient +from ..common.database import Database import customtkinter as ctk from dotenv import load_dotenv @@ -28,38 +28,6 @@ else: logger.error("未找到环境配置文件") sys.exit(1) - -class Database: - _instance: Optional["Database"] = None - - def __init__(self, host: str, port: int, db_name: str, username: str = None, password: str = None, - auth_source: str = None): - if username and password: - self.client = MongoClient( - host=host, - port=port, - username=username, - password=password, - authSource=auth_source or 'admin' - ) - else: - self.client = MongoClient(host, port) - self.db = self.client[db_name] - - @classmethod - def initialize(cls, host: str, port: int, db_name: str, username: str = None, password: str = None, - auth_source: str = None) -> "Database": - if cls._instance is None: - cls._instance = cls(host, port, db_name, username, password, auth_source) - return cls._instance - - @classmethod - def get_instance(cls) -> "Database": - if cls._instance is None: - raise RuntimeError("Database not initialized") - return cls._instance - - class ReasoningGUI: def __init__(self): # 记录启动时间戳,转换为Unix时间戳 @@ -83,7 +51,15 @@ class ReasoningGUI: except RuntimeError: logger.warning("数据库未初始化,正在尝试初始化...") try: - Database.initialize("127.0.0.1", 27017, "maimai_bot") + Database.initialize( + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), + ) self.db = Database.get_instance().db logger.success("数据库初始化成功") except Exception: @@ -359,12 +335,13 @@ class ReasoningGUI: def main(): """主函数""" Database.initialize( - host=os.getenv("MONGODB_HOST"), - port=int(os.getenv("MONGODB_PORT")), - db_name=os.getenv("DATABASE_NAME"), + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), username=os.getenv("MONGODB_USERNAME"), password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE") + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), ) app = ReasoningGUI() diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index bd71be019..36d558d10 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -31,6 +31,7 @@ driver = get_driver() config = driver.config Database.initialize( + uri=config.MONGODB_URI, host=config.MONGODB_HOST, port=int(config.MONGODB_PORT), db_name=config.DATABASE_NAME, diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 8a8b3ce5a..7e57560c9 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -37,14 +37,7 @@ def storage_compress_image(base64_data: str, max_size: int = 200) -> str: os.makedirs(images_dir, exist_ok=True) # 连接数据库 - db = Database( - host=config.mongodb_host, - port=int(config.mongodb_port), - db_name=config.database_name, - username=config.mongodb_username, - password=config.mongodb_password, - auth_source=config.mongodb_auth_source - ) + db = Database.get_instance() # 检查是否已存在相同哈希值的图片 collection = db.db['images'] diff --git a/src/plugins/knowledege/knowledge_library.py b/src/plugins/knowledege/knowledge_library.py index 481076961..99e2f842a 100644 --- a/src/plugins/knowledege/knowledge_library.py +++ b/src/plugins/knowledege/knowledge_library.py @@ -19,12 +19,13 @@ from src.common.database import Database # 从环境变量获取配置 Database.initialize( + uri=os.getenv("MONGODB_URI"), host=os.getenv("MONGODB_HOST", "127.0.0.1"), port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "maimai"), + db_name=os.getenv("DATABASE_NAME", "MegBot"), username=os.getenv("MONGODB_USERNAME"), password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE", "admin") + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), ) class KnowledgeLibrary: diff --git a/src/plugins/memory_system/draw_memory.py b/src/plugins/memory_system/draw_memory.py index 6da330d95..ffe2ba42c 100644 --- a/src/plugins/memory_system/draw_memory.py +++ b/src/plugins/memory_system/draw_memory.py @@ -162,12 +162,13 @@ class Memory_graph: def main(): # 初始化数据库 Database.initialize( + uri=os.getenv("MONGODB_URI"), host=os.getenv("MONGODB_HOST", "127.0.0.1"), port=int(os.getenv("MONGODB_PORT", "27017")), db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME", ""), - password=os.getenv("MONGODB_PASSWORD", ""), - auth_source=os.getenv("MONGODB_AUTH_SOURCE", "") + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), ) memory_graph = Memory_graph() diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 9b325b36d..b894aa6ff 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -8,6 +8,7 @@ import jieba import networkx as nx from loguru import logger +from nonebot import get_driver from ...common.database import Database # 使用正确的导入语法 from ..chat.config import global_config from ..chat.utils import ( @@ -18,7 +19,6 @@ from ..chat.utils import ( ) from ..models.utils_model import LLM_request - class Memory_graph: def __init__(self): self.G = nx.Graph() # 使用 networkx 的图结构 @@ -130,7 +130,7 @@ class Memory_graph: return None -# 海马体 +# 海马体 class Hippocampus: def __init__(self, memory_graph: Memory_graph): self.memory_graph = memory_graph @@ -749,15 +749,13 @@ def segment_text(text): seg_text = list(jieba.cut(text)) return seg_text - -from nonebot import get_driver - driver = get_driver() config = driver.config start_time = time.time() Database.initialize( + uri=config.MONGODB_URI, host=config.MONGODB_HOST, port=config.MONGODB_PORT, db_name=config.DATABASE_NAME, diff --git a/src/plugins/memory_system/memory_manual_build.py b/src/plugins/memory_system/memory_manual_build.py index 3c120f21b..3a2961b69 100644 --- a/src/plugins/memory_system/memory_manual_build.py +++ b/src/plugins/memory_system/memory_manual_build.py @@ -35,45 +35,6 @@ else: logger.warning(f"未找到环境变量文件: {env_path}") logger.info("将使用默认配置") -class Database: - _instance = None - db = None - - @classmethod - def get_instance(cls): - if cls._instance is None: - cls._instance = cls() - return cls._instance - - def __init__(self): - if not Database.db: - Database.initialize( - host=os.getenv("MONGODB_HOST"), - port=int(os.getenv("MONGODB_PORT")), - db_name=os.getenv("DATABASE_NAME"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE") - ) - - @classmethod - def initialize(cls, host, port, db_name, username=None, password=None, auth_source="admin"): - try: - if username and password: - uri = f"mongodb://{username}:{password}@{host}:{port}/{db_name}?authSource={auth_source}" - else: - uri = f"mongodb://{host}:{port}" - - client = pymongo.MongoClient(uri) - cls.db = client[db_name] - # 测试连接 - client.server_info() - logger.success("MongoDB连接成功!") - - except Exception as e: - logger.error(f"初始化MongoDB失败: {str(e)}") - raise - def calculate_information_content(text): """计算文本的信息量(熵)""" char_count = Counter(text) @@ -202,7 +163,7 @@ class Memory_graph: # 返回所有节点对应的 Memory_dot 对象 return [self.get_dot(node) for node in self.G.nodes()] -# 海马体 +# 海马体 class Hippocampus: def __init__(self, memory_graph: Memory_graph): self.memory_graph = memory_graph @@ -941,59 +902,67 @@ def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = Fal async def main(): # 初始化数据库 logger.info("正在初始化数据库连接...") - db = Database.get_instance() + Database.initialize( + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), + ) start_time = time.time() - + test_pare = {'do_build_memory':False,'do_forget_topic':False,'do_visualize_graph':True,'do_query':False,'do_merge_memory':False} - + # 创建记忆图 memory_graph = Memory_graph() - + # 创建海马体 hippocampus = Hippocampus(memory_graph) - + # 从数据库同步数据 hippocampus.sync_memory_from_db() - + end_time = time.time() logger.info(f"\033[32m[加载海马体耗时: {end_time - start_time:.2f} 秒]\033[0m") - + # 构建记忆 if test_pare['do_build_memory']: logger.info("开始构建记忆...") chat_size = 20 await hippocampus.operation_build_memory(chat_size=chat_size) - + end_time = time.time() logger.info(f"\033[32m[构建记忆耗时: {end_time - start_time:.2f} 秒,chat_size={chat_size},chat_count = 16]\033[0m") - + if test_pare['do_forget_topic']: logger.info("开始遗忘记忆...") await hippocampus.operation_forget_topic(percentage=0.1) - + end_time = time.time() logger.info(f"\033[32m[遗忘记忆耗时: {end_time - start_time:.2f} 秒]\033[0m") - + if test_pare['do_merge_memory']: logger.info("开始合并记忆...") await hippocampus.operation_merge_memory(percentage=0.1) - + end_time = time.time() logger.info(f"\033[32m[合并记忆耗时: {end_time - start_time:.2f} 秒]\033[0m") - + if test_pare['do_visualize_graph']: # 展示优化后的图形 logger.info("生成记忆图谱可视化...") print("\n生成优化后的记忆图谱:") visualize_graph_lite(memory_graph) - + if test_pare['do_query']: # 交互式查询 while True: query = input("\n请输入新的查询概念(输入'退出'以结束):") if query.lower() == '退出': break - + items_list = memory_graph.get_related_item(query) if items_list: first_layer, second_layer = items_list @@ -1008,9 +977,6 @@ async def main(): else: print("未找到相关记忆。") - if __name__ == "__main__": import asyncio asyncio.run(main()) - - diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index fc07a152d..b968d43ca 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -14,6 +14,7 @@ driver = get_driver() config = driver.config Database.initialize( + uri=config.MONGODB_URI, host=config.MONGODB_HOST, port=int(config.MONGODB_PORT), db_name=config.DATABASE_NAME, @@ -22,7 +23,6 @@ Database.initialize( auth_source=config.MONGODB_AUTH_SOURCE ) - class ScheduleGenerator: def __init__(self): # 根据global_config.llm_normal这一字典配置指定模型 From 6e36a56ceecaf5ab8e7a175a6855e4b13fbc0b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Mon, 10 Mar 2025 14:50:05 +0900 Subject: [PATCH 013/162] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20MONGODB=5F?= =?UTF-8?q?URI=20=E7=9A=84=E9=85=8D=E7=BD=AE=E9=A1=B9=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=B0=86=E6=89=80=E6=9C=89env=E6=96=87=E4=BB=B6=E7=9A=84?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E5=8D=95=E7=8B=AC=E6=94=BE=E5=9C=A8=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=EF=BC=88python=E7=9A=84dotenv=E6=9C=89=E6=97=B6?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=AD=A3=E7=A1=AE=E5=A4=84=E7=90=86=E8=A1=8C?= =?UTF-8?q?=E5=86=85=E6=B3=A8=E9=87=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/installation_cute.md | 38 +++++++++++++++++++++++++---------- docs/installation_standard.md | 28 ++++++++++++++++++-------- template.env | 15 +++++++++----- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/docs/installation_cute.md b/docs/installation_cute.md index e7541f7d3..d79b28839 100644 --- a/docs/installation_cute.md +++ b/docs/installation_cute.md @@ -1,8 +1,9 @@ # 🔧 配置指南 喵~ -## 👋 你好呀! +## 👋 你好呀 让咱来告诉你我们要做什么喵: + 1. 我们要一起设置一个可爱的AI机器人 2. 这个机器人可以在QQ上陪你聊天玩耍哦 3. 需要设置两个文件才能让机器人工作呢 @@ -10,16 +11,19 @@ ## 📝 需要设置的文件喵 要设置这两个文件才能让机器人跑起来哦: + 1. `.env.prod` - 这个文件告诉机器人要用哪些AI服务呢 2. `bot_config.toml` - 这个文件教机器人怎么和你聊天喵 ## 🔑 密钥和域名的对应关系 想象一下,你要进入一个游乐园,需要: + 1. 知道游乐园的地址(这就是域名 base_url) 2. 有入场的门票(这就是密钥 key) 在 `.env.prod` 文件里,我们定义了三个游乐园的地址和门票喵: + ```ini # 硅基流动游乐园 SILICONFLOW_KEY=your_key # 硅基流动的门票 @@ -35,6 +39,7 @@ CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 # ChatAnyWhere的地 ``` 然后在 `bot_config.toml` 里,机器人会用这些门票和地址去游乐园玩耍: + ```toml [model.llm_reasoning] name = "Pro/deepseek-ai/DeepSeek-R1" @@ -47,9 +52,10 @@ base_url = "SILICONFLOW_BASE_URL" # 还是去硅基流动游乐园 key = "SILICONFLOW_KEY" # 用同一张门票就可以啦 ``` -### 🎪 举个例子喵: +### 🎪 举个例子喵 如果你想用DeepSeek官方的服务,就要这样改: + ```toml [model.llm_reasoning] name = "Pro/deepseek-ai/DeepSeek-R1" @@ -62,7 +68,8 @@ base_url = "DEEP_SEEK_BASE_URL" # 也去DeepSeek游乐园 key = "DEEP_SEEK_KEY" # 用同一张DeepSeek门票 ``` -### 🎯 简单来说: +### 🎯 简单来说 + - `.env.prod` 文件就像是你的票夹,存放着各个游乐园的门票和地址 - `bot_config.toml` 就是告诉机器人:用哪张票去哪个游乐园玩 - 所有模型都可以用同一个游乐园的票,也可以去不同的游乐园玩耍 @@ -88,19 +95,25 @@ CHAT_ANY_WHERE_KEY=your_key CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 # 如果你不知道这是什么,那么下面这些不用改,保持原样就好啦 -HOST=127.0.0.1 # 如果使用Docker部署,需要改成0.0.0.0喵,不然听不见群友讲话了喵 +# 如果使用Docker部署,需要改成0.0.0.0喵,不然听不见群友讲话了喵 +HOST=127.0.0.1 PORT=8080 # 这些是数据库设置,一般也不用改呢 -MONGODB_HOST=127.0.0.1 # 如果使用Docker部署,需要改成数据库容器的名字喵,默认是mongodb喵 +# 如果使用Docker部署,需要把MONGODB_HOST改成数据库容器的名字喵,默认是mongodb喵 +MONGODB_HOST=127.0.0.1 MONGODB_PORT=27017 DATABASE_NAME=MegBot -MONGODB_USERNAME = "" # 如果数据库需要用户名,就在这里填写喵 -MONGODB_PASSWORD = "" # 如果数据库需要密码,就在这里填写呢 -MONGODB_AUTH_SOURCE = "" # 数据库认证源,一般不用改哦 +# 数据库认证信息,如果需要认证就取消注释并填写下面三行喵 +# MONGODB_USERNAME = "" +# MONGODB_PASSWORD = "" +# MONGODB_AUTH_SOURCE = "" -# 插件设置喵 -PLUGINS=["src2.plugins.chat"] # 这里是机器人的插件列表呢 +# 也可以使用URI连接数据库,取消注释填写在下面这行喵(URI的优先级比上面的高) +# MONGODB_URI=mongodb://127.0.0.1:27017/MegBot + +# 这里是机器人的插件列表呢 +PLUGINS=["src2.plugins.chat"] ``` ### 第二个文件:机器人配置 (bot_config.toml) @@ -198,10 +211,12 @@ key = "SILICONFLOW_KEY" - `topic`: 负责理解对话主题的能力呢 ## 🌟 小提示 + - 如果你刚开始使用,建议保持默认配置呢 - 不同的模型有不同的特长,可以根据需要调整它们的使用比例哦 ## 🌟 小贴士喵 + - 记得要好好保管密钥(key)哦,不要告诉别人呢 - 配置文件要小心修改,改错了机器人可能就不能和你玩了喵 - 如果想让机器人更聪明,可以调整 personality 里的设置呢 @@ -209,7 +224,8 @@ key = "SILICONFLOW_KEY" - QQ群号和QQ号都要用数字填写,不要加引号哦(除了机器人自己的QQ号) ## ⚠️ 注意事项 + - 这个机器人还在测试中呢,可能会有一些小问题喵 - 如果不知道怎么改某个设置,就保持原样不要动它哦~ - 记得要先有AI服务的密钥,不然机器人就不能和你说话了呢 -- 修改完配置后要重启机器人才能生效喵~ \ No newline at end of file +- 修改完配置后要重启机器人才能生效喵~ diff --git a/docs/installation_standard.md b/docs/installation_standard.md index 5f52676d1..cb6c5dff9 100644 --- a/docs/installation_standard.md +++ b/docs/installation_standard.md @@ -3,6 +3,7 @@ ## 简介 本项目需要配置两个主要文件: + 1. `.env.prod` - 配置API服务和系统环境 2. `bot_config.toml` - 配置机器人行为和模型 @@ -10,7 +11,8 @@ `.env.prod`和`bot_config.toml`中的API配置关系如下: -### 在.env.prod中定义API凭证: +### 在.env.prod中定义API凭证 + ```ini # API凭证配置 SILICONFLOW_KEY=your_key # 硅基流动API密钥 @@ -23,7 +25,8 @@ CHAT_ANY_WHERE_KEY=your_key # ChatAnyWhere API密钥 CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 # ChatAnyWhere API地址 ``` -### 在bot_config.toml中引用API凭证: +### 在bot_config.toml中引用API凭证 + ```toml [model.llm_reasoning] name = "Pro/deepseek-ai/DeepSeek-R1" @@ -32,6 +35,7 @@ key = "SILICONFLOW_KEY" # 引用.env.prod中定义的密钥 ``` 如需切换到其他API服务,只需修改引用: + ```toml [model.llm_reasoning] name = "Pro/deepseek-ai/DeepSeek-R1" @@ -42,6 +46,7 @@ key = "DEEP_SEEK_KEY" # 使用DeepSeek密钥 ## 配置文件详解 ### 环境配置文件 (.env.prod) + ```ini # API配置 SILICONFLOW_KEY=your_key @@ -52,22 +57,29 @@ CHAT_ANY_WHERE_KEY=your_key CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 # 服务配置 -HOST=127.0.0.1 # 如果使用Docker部署,需要改成0.0.0.0,否则QQ消息无法传入 + # 如果使用Docker部署,需要改成0.0.0.0,否则QQ消息无法传入 +HOST=127.0.0.1 PORT=8080 # 数据库配置 -MONGODB_HOST=127.0.0.1 # 如果使用Docker部署,需要改成数据库容器的名字,默认是mongodb +# 如果使用Docker部署,需要改成数据库容器的名字,默认是mongodb +MONGODB_HOST=127.0.0.1 MONGODB_PORT=27017 DATABASE_NAME=MegBot -MONGODB_USERNAME = "" # 数据库用户名 -MONGODB_PASSWORD = "" # 数据库密码 -MONGODB_AUTH_SOURCE = "" # 认证数据库 +# 数据库认证信息,如果需要认证就取消注释并填写下面三行 +# MONGODB_USERNAME = "" +# MONGODB_PASSWORD = "" +# MONGODB_AUTH_SOURCE = "" + +# 也可以使用URI连接数据库,取消注释填写在下面这行(URI的优先级比上面的高) +# MONGODB_URI=mongodb://127.0.0.1:27017/MegBot # 插件配置 PLUGINS=["src2.plugins.chat"] ``` ### 机器人配置文件 (bot_config.toml) + ```toml [bot] qq = "机器人QQ号" # 必填 @@ -151,4 +163,4 @@ key = "SILICONFLOW_KEY" 3. 其他说明: - 项目处于测试阶段,可能存在未知问题 - - 建议初次使用保持默认配置 \ No newline at end of file + - 建议初次使用保持默认配置 diff --git a/template.env b/template.env index 09fe63597..d2a763112 100644 --- a/template.env +++ b/template.env @@ -5,13 +5,18 @@ PORT=8080 PLUGINS=["src2.plugins.chat"] # 默认配置 -MONGODB_HOST=127.0.0.1 # 如果工作在Docker下,请改成 MONGODB_HOST=mongodb +# 如果工作在Docker下,请改成 MONGODB_HOST=mongodb +MONGODB_HOST=127.0.0.1 MONGODB_PORT=27017 DATABASE_NAME=MegBot -MONGODB_USERNAME = "" # 默认空值 -MONGODB_PASSWORD = "" # 默认空值 -MONGODB_AUTH_SOURCE = "" # 默认空值 +# 也可以使用 URI 连接数据库(优先级比上面的高) +# MONGODB_URI=mongodb://127.0.0.1:27017/MegBot + +# MongoDB 认证信息,若需要认证,请取消注释以下三行并填写正确的信息 +# MONGODB_USERNAME=user +# MONGODB_PASSWORD=password +# MONGODB_AUTH_SOURCE=admin #key and url CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 @@ -21,4 +26,4 @@ DEEP_SEEK_BASE_URL=https://api.deepseek.com/v1 #定义你要用的api的base_url DEEP_SEEK_KEY= CHAT_ANY_WHERE_KEY= -SILICONFLOW_KEY= \ No newline at end of file +SILICONFLOW_KEY= From 8ff7bb6f88b400f972641b3c5c6499b48d020eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Mon, 10 Mar 2025 14:50:15 +0900 Subject: [PATCH 014/162] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=8C=E4=BF=AE=E6=AD=A3=E6=A0=BC=E5=BC=8F=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=BF=85=E8=A6=81=E7=9A=84=E6=8D=A2=E8=A1=8C?= =?UTF-8?q?=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/doc1.md | 48 ++++++++++++++++++++--------------- docs/docker_deploy.md | 21 ++++++++------- docs/manual_deploy_linux.md | 22 +++++++++++++--- docs/manual_deploy_windows.md | 14 ++++++++-- 4 files changed, 67 insertions(+), 38 deletions(-) diff --git a/docs/doc1.md b/docs/doc1.md index 158136b9c..e8aa0f0d6 100644 --- a/docs/doc1.md +++ b/docs/doc1.md @@ -1,6 +1,7 @@ # 📂 文件及功能介绍 (2025年更新) ## 根目录 + - **README.md**: 项目的概述和使用说明。 - **requirements.txt**: 项目所需的Python依赖包列表。 - **bot.py**: 主启动文件,负责环境配置加载和NoneBot初始化。 @@ -10,6 +11,7 @@ - **run_*.bat**: 各种启动脚本,包括数据库、maimai和thinking功能。 ## `src/` 目录结构 + - **`plugins/` 目录**: 存放不同功能模块的插件。 - **chat/**: 处理聊天相关的功能,如消息发送和接收。 - **memory_system/**: 处理机器人的记忆功能。 @@ -22,94 +24,96 @@ - **`common/` 目录**: 存放通用的工具和库。 - **database.py**: 处理与数据库的交互,负责数据的存储和检索。 - - **__init__.py**: 初始化模块。 + - ****init**.py**: 初始化模块。 ## `config/` 目录 + - **bot_config_template.toml**: 机器人配置模板。 - **auto_format.py**: 自动格式化工具。 ### `src/plugins/chat/` 目录文件详细介绍 -1. **`__init__.py`**: +1. **`__init__.py`**: - 初始化 `chat` 模块,使其可以作为一个包被导入。 -2. **`bot.py`**: +2. **`bot.py`**: - 主要的聊天机器人逻辑实现,处理消息的接收、思考和回复。 - 包含 `ChatBot` 类,负责消息处理流程控制。 - 集成记忆系统和意愿管理。 -3. **`config.py`**: +3. **`config.py`**: - 配置文件,定义了聊天机器人的各种参数和设置。 - 包含 `BotConfig` 和全局配置对象 `global_config`。 -4. **`cq_code.py`**: +4. **`cq_code.py`**: - 处理 CQ 码(CoolQ 码),用于发送和接收特定格式的消息。 -5. **`emoji_manager.py`**: +5. **`emoji_manager.py`**: - 管理表情包的发送和接收,根据情感选择合适的表情。 - 提供根据情绪获取表情的方法。 -6. **`llm_generator.py`**: +6. **`llm_generator.py`**: - 生成基于大语言模型的回复,处理用户输入并生成相应的文本。 - 通过 `ResponseGenerator` 类实现回复生成。 -7. **`message.py`**: +7. **`message.py`**: - 定义消息的结构和处理逻辑,包含多种消息类型: - `Message`: 基础消息类 - `MessageSet`: 消息集合 - `Message_Sending`: 发送中的消息 - `Message_Thinking`: 思考状态的消息 -8. **`message_sender.py`**: +8. **`message_sender.py`**: - 控制消息的发送逻辑,确保消息按照特定规则发送。 - 包含 `message_manager` 对象,用于管理消息队列。 -9. **`prompt_builder.py`**: +9. **`prompt_builder.py`**: - 构建用于生成回复的提示,优化机器人的响应质量。 -10. **`relationship_manager.py`**: +10. **`relationship_manager.py`**: - 管理用户之间的关系,记录用户的互动和偏好。 - 提供更新关系和关系值的方法。 -11. **`Segment_builder.py`**: +11. **`Segment_builder.py`**: - 构建消息片段的工具。 -12. **`storage.py`**: +12. **`storage.py`**: - 处理数据存储,负责将聊天记录和用户信息保存到数据库。 - 实现 `MessageStorage` 类管理消息存储。 -13. **`thinking_idea.py`**: +13. **`thinking_idea.py`**: - 实现机器人的思考机制。 -14. **`topic_identifier.py`**: +14. **`topic_identifier.py`**: - 识别消息中的主题,帮助机器人理解用户的意图。 -15. **`utils.py`** 和 **`utils_*.py`** 系列文件: +15. **`utils.py`** 和 **`utils_*.py`** 系列文件: - 存放各种工具函数,提供辅助功能以支持其他模块。 - 包括 `utils_cq.py`、`utils_image.py`、`utils_user.py` 等专门工具。 -16. **`willing_manager.py`**: +16. **`willing_manager.py`**: - 管理机器人的回复意愿,动态调整回复概率。 - 通过多种因素(如被提及、话题兴趣度)影响回复决策。 ### `src/plugins/memory_system/` 目录文件介绍 -1. **`memory.py`**: +1. **`memory.py`**: - 实现记忆管理核心功能,包含 `memory_graph` 对象。 - 提供相关项目检索,支持多层次记忆关联。 -2. **`draw_memory.py`**: +2. **`draw_memory.py`**: - 记忆可视化工具。 -3. **`memory_manual_build.py`**: +3. **`memory_manual_build.py`**: - 手动构建记忆的工具。 -4. **`offline_llm.py`**: +4. **`offline_llm.py`**: - 离线大语言模型处理功能。 ## 消息处理流程 ### 1. 消息接收与预处理 + - 通过 `ChatBot.handle_message()` 接收群消息。 - 进行用户和群组的权限检查。 - 更新用户关系信息。 @@ -117,12 +121,14 @@ - 对消息进行过滤和敏感词检测。 ### 2. 主题识别与决策 + - 使用 `topic_identifier` 识别消息主题。 - 通过记忆系统检查对主题的兴趣度。 - `willing_manager` 动态计算回复概率。 - 根据概率决定是否回复消息。 ### 3. 回复生成与发送 + - 如需回复,首先创建 `Message_Thinking` 对象表示思考状态。 - 调用 `ResponseGenerator.generate_response()` 生成回复内容和情感状态。 - 删除思考消息,创建 `MessageSet` 准备发送回复。 diff --git a/docs/docker_deploy.md b/docs/docker_deploy.md index 3958d2fc4..e33cc6144 100644 --- a/docs/docker_deploy.md +++ b/docs/docker_deploy.md @@ -2,8 +2,7 @@ ## 部署步骤(推荐,但不一定是最新) - -### 1. 获取Docker配置文件: +### 1. 获取Docker配置文件 ```bash wget https://raw.githubusercontent.com/SengokuCola/MaiMBot/main/docker-compose.yml -O docker-compose.yml @@ -12,56 +11,56 @@ wget https://raw.githubusercontent.com/SengokuCola/MaiMBot/main/docker-compose.y - 若需要启用MongoDB数据库的用户名和密码,可进入docker-compose.yml,取消MongoDB处的注释并修改变量`=`后方的值为你的用户名和密码\ 修改后请注意在之后配置`.env.prod`文件时指定MongoDB数据库的用户名密码 - -### 2. 启动服务: +### 2. 启动服务 - **!!! 请在第一次启动前确保当前工作目录下`.env.prod`与`bot_config.toml`文件存在 !!!**\ 由于Docker文件映射行为的特殊性,若宿主机的映射路径不存在,可能导致意外的目录创建,而不会创建文件,由于此处需要文件映射到文件,需提前确保文件存在且路径正确,可使用如下命令: + ```bash touch .env.prod touch bot_config.toml ``` - 启动Docker容器: + ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose up -d ``` - 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 - -### 3. 修改配置并重启Docker: +### 3. 修改配置并重启Docker - 请前往 [🎀 新手配置指南](docs/installation_cute.md) 或 [⚙️ 标准配置指南](docs/installation_standard.md) 完成`.env.prod`与`bot_config.toml`配置文件的编写\ **需要注意`.env.prod`中HOST处IP的填写,Docker中部署和系统中直接安装的配置会有所不同** - 重启Docker容器: + ```bash docker restart maimbot # 若修改过容器名称则替换maimbot为你自定的名臣 ``` - 下方命令可以但不推荐,只是同时重启NapCat、MongoDB、MaiMBot三个服务 + ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose restart ``` - 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 - ### 4. 登入NapCat管理页添加反向WebSocket - 在浏览器地址栏输入`http://<宿主机IP>:6099/`进入NapCat的管理Web页,添加一个Websocket客户端 + > 网络配置 -> 新建 -> Websocket客户端 - Websocket客户端的名称自定,URL栏填入`ws://maimbot:8080/onebot/v11/ws`,启用并保存即可\ (若修改过容器名称则替换maimbot为你自定的名称) - -### 5. 愉快地和麦麦对话吧! - +### 5. 愉快地和麦麦对话吧 ## ⚠️ 注意事项 - 目前部署方案仍在测试中,可能存在未知问题 - 配置文件中的API密钥请妥善保管,不要泄露 -- 建议先在测试环境中运行,确认无误后再部署到生产环境 \ No newline at end of file +- 建议先在测试环境中运行,确认无误后再部署到生产环境 diff --git a/docs/manual_deploy_linux.md b/docs/manual_deploy_linux.md index d310ffc59..a859a30e7 100644 --- a/docs/manual_deploy_linux.md +++ b/docs/manual_deploy_linux.md @@ -1,6 +1,7 @@ # 📦 Linux系统如何手动部署MaiMbot麦麦? ## 准备工作 + - 一台联网的Linux设备(本教程以Ubuntu/Debian系为例) - QQ小号(QQ框架的使用可能导致qq被风控,严重(小概率)可能会导致账号封禁,强烈不推荐使用大号) - 可用的大模型API @@ -20,6 +21,7 @@ - 数据库是什么?如何安装并启动MongoDB - 如何运行一个QQ机器人,以及NapCat框架是什么 + --- ## 环境配置 @@ -33,7 +35,9 @@ python --version # 或 python3 --version ``` + 如果版本低于3.9,请更新Python版本。 + ```bash # Ubuntu/Debian sudo apt update @@ -45,6 +49,7 @@ sudo update-alternatives --config python3 ``` ### 2️⃣ **创建虚拟环境** + ```bash # 方法1:使用venv(推荐) python3 -m venv maimbot @@ -65,32 +70,39 @@ pip install -r requirements.txt --- ## 数据库配置 + ### 3️⃣ **安装并启动MongoDB** + - 安装与启动:Debian参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-debian/),Ubuntu参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/) - 默认连接本地27017端口 + --- ## NapCat配置 + ### 4️⃣ **安装NapCat框架** - 参考[NapCat官方文档](https://www.napcat.wiki/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9)安装 -- 使用QQ小号登录,添加反向WS地址: +- 使用QQ小号登录,添加反向WS地址: `ws://127.0.0.1:8080/onebot/v11/ws` --- ## 配置文件设置 + ### 5️⃣ **配置文件设置,让麦麦Bot正常工作** + - 修改环境配置文件:`.env.prod` - 修改机器人配置文件:`bot_config.toml` - --- ## 启动机器人 + ### 6️⃣ **启动麦麦机器人** + ```bash # 在项目目录下操作 nb run @@ -101,16 +113,18 @@ python3 bot.py --- ## **其他组件(可选)** -- 直接运行 knowledge.py生成知识库 +- 直接运行 knowledge.py生成知识库 --- ## 常见问题 + 🔧 权限问题:在命令前加`sudo` 🔌 端口占用:使用`sudo lsof -i :8080`查看端口占用 🛡️ 防火墙:确保8080/27017端口开放 + ```bash sudo ufw allow 8080/tcp sudo ufw allow 27017/tcp -``` \ No newline at end of file +``` diff --git a/docs/manual_deploy_windows.md b/docs/manual_deploy_windows.md index 86238bcd4..bca46962a 100644 --- a/docs/manual_deploy_windows.md +++ b/docs/manual_deploy_windows.md @@ -30,12 +30,13 @@ 在创建虚拟环境之前,请确保你的电脑上安装了Python 3.9及以上版本。如果没有,可以按以下步骤安装: -1. 访问Python官网下载页面:https://www.python.org/downloads/release/python-3913/ +1. 访问Python官网下载页面: 2. 下载Windows安装程序 (64-bit): `python-3.9.13-amd64.exe` 3. 运行安装程序,并确保勾选"Add Python 3.9 to PATH"选项 4. 点击"Install Now"开始安装 或者使用PowerShell自动下载安装(需要管理员权限): + ```powershell # 下载并安装Python 3.9.13 $pythonUrl = "https://www.python.org/ftp/python/3.9.13/python-3.9.13-amd64.exe" @@ -46,7 +47,7 @@ Start-Process -Wait -FilePath $pythonInstaller -ArgumentList "/quiet", "InstallA ### 2️⃣ **创建Python虚拟环境来运行程序** - 你可以选择使用以下两种方法之一来创建Python环境: +> 你可以选择使用以下两种方法之一来创建Python环境: ```bash # ---方法1:使用venv(Python自带) @@ -60,6 +61,7 @@ maimbot\\Scripts\\activate # 安装依赖 pip install -r requirements.txt ``` + ```bash # ---方法2:使用conda # 创建一个新的conda环境(环境名为maimbot) @@ -74,27 +76,35 @@ pip install -r requirements.txt ``` ### 2️⃣ **然后你需要启动MongoDB数据库,来存储信息** + - 安装并启动MongoDB服务 - 默认连接本地27017端口 ### 3️⃣ **配置NapCat,让麦麦bot与qq取得联系** + - 安装并登录NapCat(用你的qq小号) - 添加反向WS:`ws://127.0.0.1:8080/onebot/v11/ws` ### 4️⃣ **配置文件设置,让麦麦Bot正常工作** + - 修改环境配置文件:`.env.prod` - 修改机器人配置文件:`bot_config.toml` ### 5️⃣ **启动麦麦机器人** + - 打开命令行,cd到对应路径 + ```bash nb run ``` + - 或者cd到对应路径后 + ```bash python bot.py ``` ### 6️⃣ **其他组件(可选)** + - `run_thingking.bat`: 启动可视化推理界面(未完善) - 直接运行 knowledge.py生成知识库 From ed505a4c11e066a9ae003d09a86ad1061763c18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Mon, 10 Mar 2025 15:51:39 +0900 Subject: [PATCH 015/162] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E8=B7=AF=E5=BE=84=E6=9B=BF=E6=8D=A2=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E7=9A=84=E9=A1=B9=E7=9B=AE=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将内存系统相关文件中硬编码的项目路径(C:/GitHub/MaiMBot)替换为动态计算的相对路径, 提高代码的可移植性和兼容性。 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/plugins/memory_system/draw_memory.py | 5 ++++- src/plugins/memory_system/memory_manual_build.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/plugins/memory_system/draw_memory.py b/src/plugins/memory_system/draw_memory.py index ffe2ba42c..9f15164f1 100644 --- a/src/plugins/memory_system/draw_memory.py +++ b/src/plugins/memory_system/draw_memory.py @@ -9,7 +9,10 @@ import networkx as nx from dotenv import load_dotenv from loguru import logger -sys.path.append("C:/GitHub/MaiMBot") # 添加项目根目录到 Python 路径 +# 添加项目根目录到 Python 路径 +root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) +sys.path.append(root_path) + from src.common.database import Database # 使用正确的导入语法 # 加载.env.dev文件 diff --git a/src/plugins/memory_system/memory_manual_build.py b/src/plugins/memory_system/memory_manual_build.py index 3a2961b69..9c1d43ce9 100644 --- a/src/plugins/memory_system/memory_manual_build.py +++ b/src/plugins/memory_system/memory_manual_build.py @@ -16,7 +16,10 @@ from loguru import logger import jieba # from chat.config import global_config -sys.path.append("C:/GitHub/MaiMBot") # 添加项目根目录到 Python 路径 +# 添加项目根目录到 Python 路径 +root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) +sys.path.append(root_path) + from src.common.database import Database from src.plugins.memory_system.offline_llm import LLMModel From 312f065ff681d30cd94cef1a14174dbc0cd3b9f6 Mon Sep 17 00:00:00 2001 From: Enchograph Date: Mon, 10 Mar 2025 15:13:19 +0800 Subject: [PATCH 016/162] Create linux_deploy_guide_for_beginners.md --- docs/linux_deploy_guide_for_beginners.md | 444 +++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 docs/linux_deploy_guide_for_beginners.md diff --git a/docs/linux_deploy_guide_for_beginners.md b/docs/linux_deploy_guide_for_beginners.md new file mode 100644 index 000000000..04601923f --- /dev/null +++ b/docs/linux_deploy_guide_for_beginners.md @@ -0,0 +1,444 @@ +# 面向纯新手的Linux服务器麦麦部署指南 + +## 你得先有一个服务器 + +为了能使麦麦在你的电脑关机之后还能运行,你需要一台不间断开机的主机,也就是我们常说的服务器。 + +华为云、阿里云、腾讯云等等都是在国内可以选择的选择。 + +你可以去租一台最低配置的就足敷需要了,按月租大概十几块钱就能租到了。 + +我们假设你已经租好了一台Linux架构的云服务器。我用的是阿里云ubuntu24.04,其他的原理相似。 + +## 0.我们就从零开始吧 + +### 网络问题 + +为访问github相关界面,推荐去下一款加速器,新手可以试试watttoolkit。 + +### 安装包下载 + +#### MongoDB + +对于ubuntu24.04 x86来说是这个: + +https://repo.mongodb.org/apt/ubuntu/dists/noble/mongodb-org/8.0/multiverse/binary-amd64/mongodb-org-server_8.0.5_amd64.deb + +如果不是就在这里自行选择对应版本 + +https://www.mongodb.com/try/download/community-kubernetes-operator + +#### Napcat + +在这里选择对应版本。 + +https://github.com/NapNeko/NapCatQQ/releases/tag/v4.6.7 + +对于ubuntu24.04 x86来说是这个: + +https://dldir1.qq.com/qqfile/qq/QQNT/ee4bd910/linuxqq_3.2.16-32793_amd64.deb + +#### 麦麦 + +https://github.com/SengokuCola/MaiMBot/archive/refs/tags/0.5.8-alpha.zip + +下载这个官方压缩包。 + +### 路径 + +我把麦麦相关文件放在了/moi/mai里面,你可以凭喜好更改,记得适当调整下面涉及到的部分即可。 + +文件结构: + +``` +moi +└─ mai + ├─ linuxqq_3.2.16-32793_amd64.deb + ├─ mongodb-org-server_8.0.5_amd64.deb + └─ bot + └─ MaiMBot-0.5.8-alpha.zip +``` + +### 网络 + +你可以在你的服务器控制台网页更改防火墙规则,允许6099,8080,27017这几个端口的出入。 + +## 1.正式开始! + +远程连接你的服务器,你会看到一个黑框框闪着白方格,这就是我们要进行设置的场所——终端了。以下的bash命令都是在这里输入。 + +## 2. Python的安装 + +- 导入 Python 的稳定版 PPA: + +```bash +sudo add-apt-repository ppa:deadsnakes/ppa +``` + +- 导入 PPA 后,更新 APT 缓存: + +```bash +sudo apt update +``` + +- 在「终端」中执行以下命令来安装 Python 3.12: + +```bash +sudo apt install python3.12 +``` + +- 验证安装是否成功: + +```bash +python3.12 --version +``` + +- 在「终端」中,执行以下命令安装 pip: + +```bash +sudo apt install python3-pip +``` + +- 检查Pip是否安装成功: + +```bash +pip --version +``` + +- 安装必要组件 + +``` bash +sudo apt install python-is-python3 +``` + +## 3.MongoDB的安装 + +``` bash +cd /moi/mai +``` + +``` bash +dpkg -i mongodb-org-server_8.0.5_amd64.deb +``` + +``` bash +mkdir -p /root/data/mongodb/{data,log} +``` + +## 4.MongoDB的运行 + +```bash +service mongod start +``` + +```bash +systemctl status mongod #通过这条指令检查运行状态 +``` + +有需要的话可以把这个服务注册成开机自启 + +```bash +sudo systemctl enable mongod +``` + +## 5.napcat的安装 + +``` bash +curl -o napcat.sh https://nclatest.znin.net/NapNeko/NapCat-Installer/main/script/install.sh && sudo bash napcat.sh +``` + +上面的不行试试下面的 + +``` bash +dpkg -i linuxqq_3.2.16-32793_amd64.deb +apt-get install -f +dpkg -i linuxqq_3.2.16-32793_amd64.deb +``` + +成功的标志是输入``` napcat ```出来炫酷的彩虹色界面 + +## 6.napcat的运行 + +此时你就可以根据提示在```napcat```里面登录你的QQ号了。 + +```bash +napcat start <你的QQ号> +napcat status #检查运行状态 +``` + +然后你就可以登录napcat的webui进行设置了: + +```http://<你服务器的公网IP>:6099/webui?token=napcat``` + +第一次是这个,后续改了密码之后token就会对应修改。你也可以使用```napcat log <你的QQ号>```来查看webui地址。把里面的```127.0.0.1```改成<你服务器的公网IP>即可。 + +登录上之后在网络配置界面添加websocket客户端,名称随便输一个,url改成`ws://127.0.0.1:8080/onebot/v11/ws`保存之后点启用,就大功告成了。 + +## 7.麦麦的安装 + +### step 1 安装解压软件 + +``` +sudo apt-get install unzip +``` + +### step 2 解压文件 + +```bash +cd /moi/mai/bot # 注意:要切换到压缩包的目录中去 +unzip MaiMBot-0.5.8-alpha.zip +``` + +### step 3 进入虚拟环境安装库 + +```bash +cd /moi/mai/bot +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### step 4 试运行 + +```bash +cd /moi/mai/bot +python -m venv venv +source venv/bin/activate +python bot.py +``` + +肯定运行不成功,不过你会发现结束之后多了一些文件 + +``` +bot +├─ .env.prod +└─ config + └─ bot_config.toml +``` + +你要会vim直接在终端里修改也行,不过也可以把它们下到本地改好再传上去: + +### step 5 文件配置 + +本项目需要配置两个主要文件: + +1. `.env.prod` - 配置API服务和系统环境 +2. `bot_config.toml` - 配置机器人行为和模型 + +#### API + +你可以注册一个硅基流动的账号,通过邀请码注册有14块钱的免费额度:https://cloud.siliconflow.cn/i/7Yld7cfg。 + +#### 在.env.prod中定义API凭证: + +``` +# API凭证配置 +SILICONFLOW_KEY=your_key # 硅基流动API密钥 +SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1/ # 硅基流动API地址 + +DEEP_SEEK_KEY=your_key # DeepSeek API密钥 +DEEP_SEEK_BASE_URL=https://api.deepseek.com/v1 # DeepSeek API地址 + +CHAT_ANY_WHERE_KEY=your_key # ChatAnyWhere API密钥 +CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 # ChatAnyWhere API地址 +``` + +#### 在bot_config.toml中引用API凭证: + +``` +[model.llm_reasoning] +name = "Pro/deepseek-ai/DeepSeek-R1" +base_url = "SILICONFLOW_BASE_URL" # 引用.env.prod中定义的地址 +key = "SILICONFLOW_KEY" # 引用.env.prod中定义的密钥 +``` + +如需切换到其他API服务,只需修改引用: + +``` +[model.llm_reasoning] +name = "Pro/deepseek-ai/DeepSeek-R1" +base_url = "DEEP_SEEK_BASE_URL" # 切换为DeepSeek服务 +key = "DEEP_SEEK_KEY" # 使用DeepSeek密钥 +``` + +#### 配置文件详解 + +##### 环境配置文件 (.env.prod) + +``` +# API配置 +SILICONFLOW_KEY=your_key +SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1/ +DEEP_SEEK_KEY=your_key +DEEP_SEEK_BASE_URL=https://api.deepseek.com/v1 +CHAT_ANY_WHERE_KEY=your_key +CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 + +# 服务配置 +HOST=127.0.0.1 # 如果使用Docker部署,需要改成0.0.0.0,否则QQ消息无法传入 +PORT=8080 + +# 数据库配置 +MONGODB_HOST=127.0.0.1 # 如果使用Docker部署,需要改成数据库容器的名字,默认是mongodb +MONGODB_PORT=27017 +DATABASE_NAME=MegBot +MONGODB_USERNAME = "" # 数据库用户名 +MONGODB_PASSWORD = "" # 数据库密码 +MONGODB_AUTH_SOURCE = "" # 认证数据库 + +# 插件配置 +PLUGINS=["src2.plugins.chat"] +``` + +##### 机器人配置文件 (bot_config.toml) + +``` +[bot] +qq = "机器人QQ号" # 必填 +nickname = "麦麦" # 机器人昵称(你希望机器人怎么称呼它自己) + +[personality] +prompt_personality = [ + "曾经是一个学习地质的女大学生,现在学习心理学和脑科学,你会刷贴吧", + "是一个女大学生,你有黑色头发,你会刷小红书" +] +prompt_schedule = "一个曾经学习地质,现在学习心理学和脑科学的女大学生,喜欢刷qq,贴吧,知乎和小红书" + +[message] +min_text_length = 2 # 最小回复长度 +max_context_size = 15 # 上下文记忆条数 +emoji_chance = 0.2 # 表情使用概率 +ban_words = [] # 禁用词列表 + +[emoji] +auto_save = true # 自动保存表情 +enable_check = false # 启用表情审核 +check_prompt = "符合公序良俗" + +[groups] +talk_allowed = [] # 允许对话的群号 +talk_frequency_down = [] # 降低回复频率的群号 +ban_user_id = [] # 禁止回复的用户QQ号 + +[others] +enable_advance_output = true # 启用详细日志 +enable_kuuki_read = true # 启用场景理解 + +# 模型配置 +[model.llm_reasoning] # 推理模型 +name = "Pro/deepseek-ai/DeepSeek-R1" +base_url = "SILICONFLOW_BASE_URL" +key = "SILICONFLOW_KEY" + +[model.llm_reasoning_minor] # 轻量推理模型 +name = "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B" +base_url = "SILICONFLOW_BASE_URL" +key = "SILICONFLOW_KEY" + +[model.llm_normal] # 对话模型 +name = "Pro/deepseek-ai/DeepSeek-V3" +base_url = "SILICONFLOW_BASE_URL" +key = "SILICONFLOW_KEY" + +[model.llm_normal_minor] # 备用对话模型 +name = "deepseek-ai/DeepSeek-V2.5" +base_url = "SILICONFLOW_BASE_URL" +key = "SILICONFLOW_KEY" + +[model.vlm] # 图像识别模型 +name = "deepseek-ai/deepseek-vl2" +base_url = "SILICONFLOW_BASE_URL" +key = "SILICONFLOW_KEY" + +[model.embedding] # 文本向量模型 +name = "BAAI/bge-m3" +base_url = "SILICONFLOW_BASE_URL" +key = "SILICONFLOW_KEY" + + +[topic.llm_topic] +name = "Pro/deepseek-ai/DeepSeek-V3" +base_url = "SILICONFLOW_BASE_URL" +key = "SILICONFLOW_KEY" +``` + +**step # 6** 运行 + +现在再运行 + +```bash +cd /moi/mai/bot +python -m venv venv +source venv/bin/activate +python bot.py +``` + +应该就能运行成功了。 + +## 8.事后配置 + +可是现在还有个问题:只要你一关闭终端,bot.py就会停止运行。那该怎么办呢?我们可以把bot.py注册成服务。 + +重启服务器,打开MongoDB和napcat服务。 + +新建一个文件,名为`bot.service`,内容如下 + +``` +[Unit] +Description=maimai bot + +[Service] +WorkingDirectory=/moi/mai/bot +ExecStart=/moi/mai/bot/venv/bin/python /moi/mai/bot/bot.py +Restart=on-failure +User=root + +[Install] +WantedBy=multi-user.target +``` + +里面的路径视自己的情况更改。 + +把它放到`/etc/systemd/system`里面。 + +重新加载 `systemd` 配置: + +```bash +sudo systemctl daemon-reload +``` + +启动服务: + +```bash +sudo systemctl start bot.service # 启动服务 +sudo systemctl restart bot.service # 或者重启服务 +``` + +检查服务状态: + +```bash +sudo systemctl status bot.service +``` + +现在再关闭终端,检查麦麦能不能正常回复QQ信息。如果可以的话就大功告成了! + +## 9.命令速查 + +```bash +service mongod start # 启动mongod服务 +napcat start <你的QQ号> # 登录napcat +cd /moi/mai/bot # 切换路径 +python -m venv venv # 创建虚拟环境 +source venv/bin/activate # 激活虚拟环境 + +sudo systemctl daemon-reload # 重新加载systemd配置 +sudo systemctl start bot.service # 启动bot服务 +sudo systemctl enable bot.service # 启动bot服务 + +sudo systemctl status bot.service # 检查bot服务状态 +``` + +``` +python bot.py +``` + From 68b3f578c4331298e69c43e5840b87689dad75c2 Mon Sep 17 00:00:00 2001 From: UnCLAS-Prommer Date: Mon, 10 Mar 2025 20:36:15 +0800 Subject: [PATCH 017/162] Minor Doc Update --- README.md | 15 +++++++-------- docs/installation_cute.md | 4 ++-- docs/installation_standard.md | 2 +- run.bat | 2 +- run.py | 2 ++ 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0c02d1cba..29e25b2b9 100644 --- a/README.md +++ b/README.md @@ -51,18 +51,17 @@ ### 部署方式 -如果你不知道Docker是什么,建议寻找相关教程或使用手动部署(现在不建议使用docker,更新慢,可能不适配) +- 📦 **Windows 一键傻瓜式部署**:请运行项目根目录中的 ```run.bat```,部署完成后请参照后续配置指南进行配置 + +- [📦 Windows 手动部署指南 ](docs/manual_deploy_windows.md) + +- [📦 Linux 手动部署指南 ](docs/manual_deploy_linux.md) + +如果你不知道Docker是什么,建议寻找相关教程或使用手动部署 **(现在不建议使用docker,更新慢,可能不适配)** - [🐳 Docker部署指南](docs/docker_deploy.md) -- [📦 手动部署指南 Windows](docs/manual_deploy_windows.md) - - -- [📦 手动部署指南 Linux](docs/manual_deploy_linux.md) - -- 📦 Windows 一键傻瓜式部署,请运行项目根目录中的 ```run.bat```,部署完成后请参照后续配置指南进行配置 - ### 配置说明 - [🎀 新手配置指南](docs/installation_cute.md) - 通俗易懂的配置教程,适合初次使用的猫娘 - [⚙️ 标准配置指南](docs/installation_standard.md) - 简明专业的配置说明,适合有经验的用户 diff --git a/docs/installation_cute.md b/docs/installation_cute.md index e7541f7d3..3a63988f1 100644 --- a/docs/installation_cute.md +++ b/docs/installation_cute.md @@ -52,12 +52,12 @@ key = "SILICONFLOW_KEY" # 用同一张门票就可以啦 如果你想用DeepSeek官方的服务,就要这样改: ```toml [model.llm_reasoning] -name = "Pro/deepseek-ai/DeepSeek-R1" +name = "deepseek-reasoner" # 改成对应的模型名称,这里为DeepseekR1 base_url = "DEEP_SEEK_BASE_URL" # 改成去DeepSeek游乐园 key = "DEEP_SEEK_KEY" # 用DeepSeek的门票 [model.llm_normal] -name = "Pro/deepseek-ai/DeepSeek-V3" +name = "deepseek-chat" # 改成对应的模型名称,这里为DeepseekV3 base_url = "DEEP_SEEK_BASE_URL" # 也去DeepSeek游乐园 key = "DEEP_SEEK_KEY" # 用同一张DeepSeek门票 ``` diff --git a/docs/installation_standard.md b/docs/installation_standard.md index 5f52676d1..71bfbac77 100644 --- a/docs/installation_standard.md +++ b/docs/installation_standard.md @@ -34,7 +34,7 @@ key = "SILICONFLOW_KEY" # 引用.env.prod中定义的密钥 如需切换到其他API服务,只需修改引用: ```toml [model.llm_reasoning] -name = "Pro/deepseek-ai/DeepSeek-R1" +name = "deepseek-reasoner" # 改成对应的模型名称,这里为DeepseekR1 base_url = "DEEP_SEEK_BASE_URL" # 切换为DeepSeek服务 key = "DEEP_SEEK_KEY" # 使用DeepSeek密钥 ``` diff --git a/run.bat b/run.bat index 659a7545a..91904bc34 100644 --- a/run.bat +++ b/run.bat @@ -3,7 +3,7 @@ chcp 65001 if not exist "venv" ( python -m venv venv call venv\Scripts\activate.bat - pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple --upgrade -r requirements.txt + pip install -i https://mirrors.aliyun.com/pypi/simple --upgrade -r requirements.txt ) else ( call venv\Scripts\activate.bat ) diff --git a/run.py b/run.py index baea4d13c..50e312c37 100644 --- a/run.py +++ b/run.py @@ -107,6 +107,8 @@ def install_napcat(): napcat_filename = input( "下载完成后请把文件复制到此文件夹,并将**不包含后缀的文件名**输入至此窗口,如 NapCat.32793.Shell:" ) + if(napcat_filename[-4:] == ".zip"): + napcat_filename = napcat_filename[:-4] extract_files(napcat_filename + ".zip", "napcat") print("NapCat 安装完成") os.remove(napcat_filename + ".zip") From e68040515a97549f879a81b57763d76f515f2aa2 Mon Sep 17 00:00:00 2001 From: Naptie Date: Mon, 10 Mar 2025 20:43:17 +0800 Subject: [PATCH 018/162] fix: typo 'discription' --- src/plugins/chat/bot.py | 4 ++-- src/plugins/chat/emoji_manager.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index c510fe4bf..415bdd6bb 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -178,7 +178,7 @@ class ChatBot: # 检查是否 <没有找到> emoji if emoji_raw != None: - emoji_path, discription = emoji_raw + emoji_path, description = emoji_raw emoji_cq = CQCode.create_emoji_cq(emoji_path) @@ -194,7 +194,7 @@ class ChatBot: raw_message=emoji_cq, plain_text=emoji_cq, processed_plain_text=emoji_cq, - detailed_plain_text=discription, + detailed_plain_text=description, user_nickname=global_config.BOT_NICKNAME, group_name=message.group_name, time=bot_response_time, diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 973bcad2d..dcb702c03 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -117,7 +117,7 @@ class EmojiManager: try: # 获取所有表情包 - all_emojis = list(self.db.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'discription': 1})) + all_emojis = list(self.db.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'description': 1})) if not all_emojis: logger.warning("数据库中没有任何表情包") @@ -160,9 +160,9 @@ class EmojiManager: {'$inc': {'usage_count': 1}} ) logger.success( - f"找到匹配的表情包: {selected_emoji.get('discription', '无描述')} (相似度: {similarity:.4f})") + f"找到匹配的表情包: {selected_emoji.get('description', '无描述')} (相似度: {similarity:.4f})") # 稍微改一下文本描述,不然容易产生幻觉,描述已经包含 表情包 了 - return selected_emoji['path'], "[ %s ]" % selected_emoji.get('discription', '无描述') + return selected_emoji['path'], "[ %s ]" % selected_emoji.get('description', '无描述') except Exception as search_error: logger.error(f"搜索表情包失败: {str(search_error)}") @@ -174,7 +174,7 @@ class EmojiManager: logger.error(f"获取表情包失败: {str(e)}") return None - async def _get_emoji_discription(self, image_base64: str) -> str: + async def _get_emoji_description(self, image_base64: str) -> str: """获取表情包的标签""" try: prompt = '这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感' @@ -236,31 +236,31 @@ class EmojiManager: continue # 获取表情包的描述 - discription = await self._get_emoji_discription(image_base64) + description = await self._get_emoji_description(image_base64) if global_config.EMOJI_CHECK: check = await self._check_emoji(image_base64) if '是' not in check: os.remove(image_path) - logger.info(f"描述: {discription}") + logger.info(f"描述: {description}") logger.info(f"其不满足过滤规则,被剔除 {check}") continue logger.info(f"check通过 {check}") - if discription is not None: - embedding = await get_embedding(discription) + if description is not None: + embedding = await get_embedding(description) # 准备数据库记录 emoji_record = { 'filename': filename, 'path': image_path, 'embedding': embedding, - 'discription': discription, + 'description': description, 'timestamp': int(time.time()) } # 保存到数据库 self.db.db['emoji'].insert_one(emoji_record) logger.success(f"注册新表情包: {filename}") - logger.info(f"描述: {discription}") + logger.info(f"描述: {description}") else: logger.warning(f"跳过表情包: {filename}") From 2ffdfef02e7e87357b472c1b9c080316f9e44607 Mon Sep 17 00:00:00 2001 From: UnCLAS-Prommer Date: Mon, 10 Mar 2025 20:45:12 +0800 Subject: [PATCH 019/162] More --- docs/installation_standard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation_standard.md b/docs/installation_standard.md index 71bfbac77..cf8e7eb9b 100644 --- a/docs/installation_standard.md +++ b/docs/installation_standard.md @@ -53,11 +53,11 @@ CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 # 服务配置 HOST=127.0.0.1 # 如果使用Docker部署,需要改成0.0.0.0,否则QQ消息无法传入 -PORT=8080 +PORT=8080 # 与反向端口相同 # 数据库配置 MONGODB_HOST=127.0.0.1 # 如果使用Docker部署,需要改成数据库容器的名字,默认是mongodb -MONGODB_PORT=27017 +MONGODB_PORT=27017 # MongoDB端口 DATABASE_NAME=MegBot MONGODB_USERNAME = "" # 数据库用户名 MONGODB_PASSWORD = "" # 数据库密码 From 6e2ea8261be3a8a36a9d93b1a20e03bae7b9562d Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Sun, 9 Mar 2025 22:12:10 +0800 Subject: [PATCH 020/162] =?UTF-8?q?refractor:=20=E5=87=A0=E4=B9=8E?= =?UTF-8?q?=E5=86=99=E5=AE=8C=E4=BA=86=EF=BC=8C=E8=BF=9B=E5=85=A5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 162 +++++++++-------- src/plugins/chat/chat_stream.py | 209 +++++++++++++++++++++ src/plugins/chat/cq_code.py | 18 ++ src/plugins/chat/emoji_manager.py | 4 +- src/plugins/chat/message.py | 84 ++++----- src/plugins/chat/message_cq.py | 2 +- src/plugins/chat/message_sender.py | 167 ++++++++--------- src/plugins/chat/relationship_manager.py | 221 ++++++++++++++++------- src/plugins/chat/storage.py | 43 ++--- src/plugins/chat/willing_manager.py | 82 ++++++--- 10 files changed, 654 insertions(+), 338 deletions(-) create mode 100644 src/plugins/chat/chat_stream.py diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index c9eea3ed9..5385f3afb 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -10,18 +10,19 @@ from .config import global_config from .cq_code import CQCode,cq_code_tool # 导入CQCode模块 from .emoji_manager import emoji_manager # 导入表情包管理器 from .llm_generator import ResponseGenerator +from .message import MessageSending, MessageRecv, MessageThinking, MessageSet from .message_cq import ( - Message, - Message_Sending, - Message_Thinking, # 导入 Message_Thinking 类 - MessageSet, + MessageRecvCQ, + MessageSendCQ, ) +from .chat_stream import chat_manager from .message_sender import message_manager # 导入新的消息管理器 from .relationship_manager import relationship_manager from .storage import MessageStorage from .utils import calculate_typing_time, is_mentioned_bot_in_txt +from .utils_image import image_path_to_base64 from .willing_manager import willing_manager # 导入意愿管理器 - +from .message_base import UserInfo, GroupInfo, Seg class ChatBot: def __init__(self): @@ -43,12 +44,9 @@ class ChatBot: async def handle_message(self, event: GroupMessageEvent, bot: Bot) -> None: """处理收到的群消息""" - if event.group_id not in global_config.talk_allowed_groups: - return self.bot = bot # 更新 bot 实例 - if event.user_id in global_config.ban_user_id: - return + group_info = await bot.get_group_info(group_id=event.group_id) sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) @@ -56,25 +54,46 @@ class ChatBot: await relationship_manager.update_relationship(user_id = event.user_id, data = sender_info) await relationship_manager.update_relationship_value(user_id = event.user_id, relationship_value = 0.5) - message = Message( - group_id=event.group_id, - user_id=event.user_id, + message_cq=MessageRecvCQ( message_id=event.message_id, - user_cardname=sender_info['card'], - raw_message=str(event.original_message), - plain_text=event.get_plaintext(), + user_id=event.user_id, + raw_message=str(event.original_message), + group_id=event.group_id, reply_message=event.reply, + platform='qq' ) - await message.initialize() + message_json=message_cq.to_dict() + # 进入maimbot + message=MessageRecv(**message_json) + await message.process() + groupinfo=message.message_info.group_info + userinfo=message.message_info.user_info + messageinfo=message.message_info + chat = await chat_manager.get_or_create_stream(platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo) + + # 消息过滤,涉及到config有待更新 + if groupinfo: + if groupinfo.group_id not in global_config.talk_allowed_groups: + return + else: + if userinfo: + if userinfo.user_id in []: + pass + else: + return + else: + return + if userinfo.user_id in global_config.ban_user_id: + return # 过滤词 for word in global_config.ban_words: - if word in message.detailed_plain_text: - logger.info(f"\033[1;32m[{message.group_name}]{message.user_nickname}:\033[0m {message.processed_plain_text}") + if word in message.processed_plain_text: + logger.info(f"\033[1;32m[{groupinfo.group_name}]{userinfo.user_nickname}:\033[0m {message.processed_plain_text}") logger.info(f"\033[1;32m[过滤词识别]\033[0m 消息中含有{word},filtered") return - current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.time)) + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) @@ -85,47 +104,55 @@ class ChatBot: print(f"\033[1;32m[记忆激活]\033[0m 对{message.processed_plain_text}的激活度:---------------------------------------{interested_rate}\n") # logger.info(f"\033[1;32m[主题识别]\033[0m 使用{global_config.topic_extract}主题: {topic}") - await self.storage.store_message(message, topic[0] if topic else None) + await self.storage.store_message(message,chat, topic[0] if topic else None) is_mentioned = is_mentioned_bot_in_txt(message.processed_plain_text) - reply_probability = willing_manager.change_reply_willing_received( - event.group_id, - topic[0] if topic else None, - is_mentioned, - global_config, - event.user_id, - message.is_emoji, - interested_rate + reply_probability = await willing_manager.change_reply_willing_received( + chat_stream=chat, + topic=topic[0] if topic else None, + is_mentioned_bot=is_mentioned, + config=global_config, + is_emoji=message.is_emoji, + interested_rate=interested_rate + ) + current_willing = willing_manager.get_willing( + chat_stream=chat ) - current_willing = willing_manager.get_willing(event.group_id) - - print(f"\033[1;32m[{current_time}][{message.group_name}]{message.user_nickname}:\033[0m {message.processed_plain_text}\033[1;36m[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]\033[0m") + print(f"\033[1;32m[{current_time}][{chat.group_info.group_name}]{chat.user_info.user_nickname}:\033[0m {message.processed_plain_text}\033[1;36m[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]\033[0m") - response = "" + response = None if random() < reply_probability: - - + bot_user_info=UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=messageinfo.platform + ) tinking_time_point = round(time.time(), 2) think_id = 'mt' + str(tinking_time_point) - thinking_message = Message_Thinking(message=message,message_id=think_id) + thinking_message = MessageThinking.from_chat_stream( + chat_stream=chat, + message_id=think_id, + reply=message + ) message_manager.add_message(thinking_message) - willing_manager.change_reply_willing_sent(thinking_message.group_id) + willing_manager.change_reply_willing_sent( + chat_stream=chat + ) response,raw_content = await self.gpt.generate_response(message) if response: - container = message_manager.get_container(event.group_id) + container = message_manager.get_container(chat.stream_id) thinking_message = None # 找到message,删除 for msg in container.messages: - if isinstance(msg, Message_Thinking) and msg.message_id == think_id: + if isinstance(msg, MessageThinking) and msg.message_info.message_id == think_id: thinking_message = msg container.messages.remove(msg) - # print(f"\033[1;32m[思考消息删除]\033[0m 已找到思考消息对象,开始删除") break # 如果找不到思考消息,直接返回 @@ -135,11 +162,10 @@ class ChatBot: #记录开始思考的时间,避免从思考到回复的时间太久 thinking_start_time = thinking_message.thinking_start_time - message_set = MessageSet(event.group_id, global_config.BOT_QQ, think_id) # 发送消息的id和产生发送消息的message_thinking是一致的 + message_set = MessageSet(chat, think_id) #计算打字时间,1是为了模拟打字,2是避免多条回复乱序 accu_typing_time = 0 - # print(f"\033[1;32m[开始回复]\033[0m 开始将回复1载入发送容器") mark_head = False for msg in response: # print(f"\033[1;32m[回复内容]\033[0m {msg}") @@ -148,22 +174,16 @@ class ChatBot: accu_typing_time += typing_time timepoint = tinking_time_point + accu_typing_time - bot_message = Message_Sending( - group_id=event.group_id, - user_id=global_config.BOT_QQ, + message_segment = Seg(type='text', data=msg) + bot_message = MessageSending( message_id=think_id, - raw_message=msg, - plain_text=msg, - processed_plain_text=msg, - user_nickname=global_config.BOT_NICKNAME, - group_name=message.group_name, - time=timepoint, #记录了回复生成的时间 - thinking_start_time=thinking_start_time, #记录了思考开始的时间 - reply_message_id=message.message_id + chat_stream=chat, + message_segment=message_segment, + reply=message, + is_head=not mark_head, + is_emoji=False ) - await bot_message.initialize() if not mark_head: - bot_message.is_head = True mark_head = True message_set.add_message(bot_message) @@ -180,30 +200,22 @@ class ChatBot: if emoji_raw != None: emoji_path,discription = emoji_raw - emoji_cq = cq_code_tool.create_emoji_cq(emoji_path) + emoji_cq = image_path_to_base64(emoji_path) if random() < 0.5: bot_response_time = tinking_time_point - 1 else: bot_response_time = bot_response_time + 1 - bot_message = Message_Sending( - group_id=event.group_id, - user_id=global_config.BOT_QQ, - message_id=0, - raw_message=emoji_cq, - plain_text=emoji_cq, - processed_plain_text=emoji_cq, - detailed_plain_text=discription, - user_nickname=global_config.BOT_NICKNAME, - group_name=message.group_name, - time=bot_response_time, - is_emoji=True, - translate_cq=False, - thinking_start_time=thinking_start_time, - # reply_message_id=message.message_id + message_segment = Seg(type='emoji', data=emoji_cq) + bot_message = MessageSending( + message_id=think_id, + chat_stream=chat, + message_segment=message_segment, + reply=message, + is_head=False, + is_emoji=True ) - await bot_message.initialize() message_manager.add_message(bot_message) emotion = await self.gpt._get_emotion_tags(raw_content) print(f"为 '{response}' 获取到的情感标签为:{emotion}") @@ -219,8 +231,12 @@ class ChatBot: await relationship_manager.update_relationship_value(message.user_id, relationship_value=valuedict[emotion[0]]) # 使用情绪管理器更新情绪 self.mood_manager.update_mood_from_emotion(emotion[0], global_config.mood_intensity_factor) - - # willing_manager.change_reply_willing_after_sent(event.group_id) + + willing_manager.change_reply_willing_after_sent( + platform=messageinfo.platform, + user_info=userinfo, + group_info=groupinfo + ) # 创建全局ChatBot实例 chat_bot = ChatBot() \ No newline at end of file diff --git a/src/plugins/chat/chat_stream.py b/src/plugins/chat/chat_stream.py new file mode 100644 index 000000000..e617054ec --- /dev/null +++ b/src/plugins/chat/chat_stream.py @@ -0,0 +1,209 @@ +import time +import asyncio +from typing import Optional, Dict, Tuple +import hashlib + +from loguru import logger +from ...common.database import Database +from .message_base import UserInfo, GroupInfo + + +class ChatStream: + """聊天流对象,存储一个完整的聊天上下文""" + def __init__(self, + stream_id: str, + platform: str, + user_info: UserInfo, + group_info: Optional[GroupInfo] = None, + data: dict = None): + self.stream_id = stream_id + self.platform = platform + self.user_info = user_info + self.group_info = group_info + self.create_time = data.get('create_time', int(time.time())) if data else int(time.time()) + self.last_active_time = data.get('last_active_time', self.create_time) if data else self.create_time + self.saved = False + + def to_dict(self) -> dict: + """转换为字典格式""" + result = { + 'stream_id': self.stream_id, + 'platform': self.platform, + 'user_info': self.user_info.to_dict() if self.user_info else None, + 'group_info': self.group_info.to_dict() if self.group_info else None, + 'create_time': self.create_time, + 'last_active_time': self.last_active_time + } + return result + + @classmethod + def from_dict(cls, data: dict) -> 'ChatStream': + """从字典创建实例""" + user_info = UserInfo(**data.get('user_info', {})) if data.get('user_info') else None + group_info = GroupInfo(**data.get('group_info', {})) if data.get('group_info') else None + + return cls( + stream_id=data['stream_id'], + platform=data['platform'], + user_info=user_info, + group_info=group_info, + data=data + ) + + def update_active_time(self): + """更新最后活跃时间""" + self.last_active_time = int(time.time()) + self.saved = False + + +class ChatManager: + """聊天管理器,管理所有聊天流""" + _instance = None + _initialized = False + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not self._initialized: + self.streams: Dict[str, ChatStream] = {} # stream_id -> ChatStream + self.db = Database.get_instance() + self._ensure_collection() + self._initialized = True + # 在事件循环中启动初始化 + asyncio.create_task(self._initialize()) + # 启动自动保存任务 + asyncio.create_task(self._auto_save_task()) + + async def _initialize(self): + """异步初始化""" + try: + await self.load_all_streams() + logger.success(f"聊天管理器已启动,已加载 {len(self.streams)} 个聊天流") + except Exception as e: + logger.error(f"聊天管理器启动失败: {str(e)}") + + async def _auto_save_task(self): + """定期自动保存所有聊天流""" + while True: + await asyncio.sleep(300) # 每5分钟保存一次 + try: + await self._save_all_streams() + logger.info("聊天流自动保存完成") + except Exception as e: + logger.error(f"聊天流自动保存失败: {str(e)}") + + def _ensure_collection(self): + """确保数据库集合存在并创建索引""" + if 'chat_streams' not in self.db.db.list_collection_names(): + self.db.db.create_collection('chat_streams') + # 创建索引 + self.db.db.chat_streams.create_index([('stream_id', 1)], unique=True) + self.db.db.chat_streams.create_index([ + ('platform', 1), + ('user_info.user_id', 1), + ('group_info.group_id', 1) + ]) + + def _generate_stream_id(self, platform: str, user_info: UserInfo, group_info: Optional[GroupInfo] = None) -> str: + """生成聊天流唯一ID""" + # 组合关键信息 + components = [ + platform, + str(user_info.user_id), + str(group_info.group_id) if group_info else 'private' + ] + + # 使用MD5生成唯一ID + key = '_'.join(components) + return hashlib.md5(key.encode()).hexdigest() + + async def get_or_create_stream(self, + platform: str, + user_info: UserInfo, + group_info: Optional[GroupInfo] = None) -> ChatStream: + """获取或创建聊天流 + + Args: + platform: 平台标识 + user_info: 用户信息 + group_info: 群组信息(可选) + + Returns: + ChatStream: 聊天流对象 + """ + # 生成stream_id + stream_id = self._generate_stream_id(platform, user_info, group_info) + + # 检查内存中是否存在 + if stream_id in self.streams: + stream = self.streams[stream_id] + # 更新用户信息和群组信息 + stream.user_info = user_info + if group_info: + stream.group_info = group_info + stream.update_active_time() + return stream + + # 检查数据库中是否存在 + data = self.db.db.chat_streams.find_one({'stream_id': stream_id}) + if data: + stream = ChatStream.from_dict(data) + # 更新用户信息和群组信息 + stream.user_info = user_info + if group_info: + stream.group_info = group_info + stream.update_active_time() + else: + # 创建新的聊天流 + stream = ChatStream( + stream_id=stream_id, + platform=platform, + user_info=user_info, + group_info=group_info + ) + + # 保存到内存和数据库 + self.streams[stream_id] = stream + await self._save_stream(stream) + return stream + + def get_stream(self, stream_id: str) -> Optional[ChatStream]: + """通过stream_id获取聊天流""" + return self.streams.get(stream_id) + + def get_stream_by_info(self, + platform: str, + user_info: UserInfo, + group_info: Optional[GroupInfo] = None) -> Optional[ChatStream]: + """通过信息获取聊天流""" + stream_id = self._generate_stream_id(platform, user_info, group_info) + return self.streams.get(stream_id) + + async def _save_stream(self, stream: ChatStream): + """保存聊天流到数据库""" + if not stream.saved: + self.db.db.chat_streams.update_one( + {'stream_id': stream.stream_id}, + {'$set': stream.to_dict()}, + upsert=True + ) + stream.saved = True + + async def _save_all_streams(self): + """保存所有聊天流""" + for stream in self.streams.values(): + await self._save_stream(stream) + + async def load_all_streams(self): + """从数据库加载所有聊天流""" + all_streams = self.db.db.chat_streams.find({}) + for data in all_streams: + stream = ChatStream.from_dict(data) + self.streams[stream.stream_id] = stream + + +# 创建全局单例 +chat_manager = ChatManager() diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index e908219b5..b29e25b4c 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -373,6 +373,24 @@ class CQCode_tool: # 生成CQ码,设置sub_type=1表示这是表情包 return f"[CQ:image,file=file:///{escaped_path},sub_type=1]" + @staticmethod + def create_emoji_cq_base64(base64_data: str) -> str: + """ + 创建表情包CQ码 + Args: + base64_data: base64编码的表情包数据 + Returns: + 表情包CQ码字符串 + """ + # 转义base64数据 + escaped_base64 = base64_data.replace('&', '&') \ + .replace('[', '[') \ + .replace(']', ']') \ + .replace(',', ',') + # 生成CQ码,设置sub_type=1表示这是表情包 + return f"[CQ:image,file=base64://{escaped_base64},sub_type=1]" + + diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index ff59220dc..3432f011c 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -3,7 +3,7 @@ import os import random import time import traceback -from typing import Optional +from typing import Optional, Tuple import base64 import hashlib @@ -92,7 +92,7 @@ class EmojiManager: except Exception as e: logger.error(f"记录表情使用失败: {str(e)}") - async def get_emoji_for_text(self, text: str) -> Optional[str]: + async def get_emoji_for_text(self, text: str) -> Optional[Tuple[str,str]]: """根据文本内容获取相关表情包 Args: text: 输入文本 diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index a561e6490..070241ac1 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -5,11 +5,10 @@ from typing import Dict, ForwardRef, List, Optional, Union import urllib3 from loguru import logger -from .cq_code import CQCode, cq_code_tool -from .utils_cq import parse_cq_code from .utils_user import get_groupname, get_user_cardname, get_user_nickname from .utils_image import image_manager from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase +from .chat_stream import ChatStream # 禁用SSL警告 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -40,6 +39,7 @@ class MessageRecv(MessageBase): # 处理消息内容 self.processed_plain_text = "" # 初始化为空字符串 self.detailed_plain_text = "" # 初始化为空字符串 + self.is_emoji=False async def process(self) -> None: """处理消息内容,生成纯文本和详细文本 @@ -88,6 +88,7 @@ class MessageRecv(MessageBase): return await image_manager.get_image_description(seg.data) return '[图片]' elif seg.type == 'emoji': + self.is_emoji=True if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): return await image_manager.get_emoji_description(seg.data) return '[表情]' @@ -115,36 +116,17 @@ class MessageProcessBase(MessageBase): def __init__( self, message_id: str, - user_id: int, - group_id: Optional[int] = None, - platform: str = "qq", + chat_stream: ChatStream, message_segment: Optional[Seg] = None, reply: Optional['MessageRecv'] = None ): - # 构造用户信息 - user_info = UserInfo( - platform=platform, - user_id=user_id, - user_nickname=get_user_nickname(user_id), - user_cardname=get_user_cardname(user_id) if group_id else None - ) - - # 构造群组信息(如果有) - group_info = None - if group_id: - group_info = GroupInfo( - platform=platform, - group_id=group_id, - group_name=get_groupname(group_id) - ) - # 构造基础消息信息 message_info = BaseMessageInfo( - platform=platform, + platform=chat_stream.platform, message_id=message_id, time=int(time.time()), - group_info=group_info, - user_info=user_info + group_info=chat_stream.group_info, + user_info=chat_stream.user_info ) # 调用父类初始化 @@ -241,17 +223,13 @@ class MessageThinking(MessageProcessBase): def __init__( self, message_id: str, - user_id: int, - group_id: Optional[int] = None, - platform: str = "qq", + chat_stream: ChatStream, reply: Optional['MessageRecv'] = None ): # 调用父类初始化 super().__init__( message_id=message_id, - user_id=user_id, - group_id=group_id, - platform=platform, + chat_stream=chat_stream, message_segment=None, # 思考状态不需要消息段 reply=reply ) @@ -259,6 +237,15 @@ class MessageThinking(MessageProcessBase): # 思考状态特有属性 self.interrupt = False + @classmethod + def from_chat_stream(cls, chat_stream: ChatStream, message_id: str, reply: Optional['MessageRecv'] = None) -> 'MessageThinking': + """从聊天流创建思考状态消息""" + return cls( + message_id=message_id, + chat_stream=chat_stream, + reply=reply + ) + @dataclass class MessageSending(MessageProcessBase): """发送状态的消息类""" @@ -266,19 +253,16 @@ class MessageSending(MessageProcessBase): def __init__( self, message_id: str, - user_id: int, + chat_stream: ChatStream, message_segment: Seg, - group_id: Optional[int] = None, reply: Optional['MessageRecv'] = None, - platform: str = "qq", - is_head: bool = False + is_head: bool = False, + is_emoji: bool = False ): # 调用父类初始化 super().__init__( message_id=message_id, - user_id=user_id, - group_id=group_id, - platform=platform, + chat_stream=chat_stream, message_segment=message_segment, reply=reply ) @@ -286,6 +270,12 @@ class MessageSending(MessageProcessBase): # 发送状态特有属性 self.reply_to_message_id = reply.message_info.message_id if reply else None self.is_head = is_head + self.is_emoji = is_emoji + if is_head: + self.message_segment = Seg(type='seglist', data=[ + Seg(type='reply', data=reply.message_info.message_id), + self.message_segment + ]) async def process(self) -> None: """处理消息内容,生成纯文本和详细文本""" @@ -298,26 +288,24 @@ class MessageSending(MessageProcessBase): cls, thinking: MessageThinking, message_segment: Seg, - reply: Optional['MessageRecv'] = None, - is_head: bool = False + is_head: bool = False, + is_emoji: bool = False ) -> 'MessageSending': """从思考状态消息创建发送状态消息""" return cls( message_id=thinking.message_info.message_id, - user_id=thinking.message_info.user_info.user_id, + chat_stream=thinking.chat_stream, message_segment=message_segment, - group_id=thinking.message_info.group_info.group_id if thinking.message_info.group_info else None, - reply=reply or thinking.reply, - platform=thinking.message_info.platform, - is_head=is_head + reply=thinking.reply, + is_head=is_head, + is_emoji=is_emoji ) @dataclass class MessageSet: """消息集合类,可以存储多个发送消息""" - def __init__(self, group_id: int, user_id: int, message_id: str): - self.group_id = group_id - self.user_id = user_id + def __init__(self, chat_stream: ChatStream, message_id: str): + self.chat_stream = chat_stream self.message_id = message_id self.messages: List[MessageSending] = [] self.time = round(time.time(), 2) diff --git a/src/plugins/chat/message_cq.py b/src/plugins/chat/message_cq.py index 80df4e340..7d9c6216d 100644 --- a/src/plugins/chat/message_cq.py +++ b/src/plugins/chat/message_cq.py @@ -176,7 +176,7 @@ class MessageSendCQ(MessageCQ): elif seg.type == 'image': # 如果是base64图片数据 if seg.data.startswith(('data:', 'base64:')): - return f"[CQ:image,file=base64://{seg.data}]" + return cq_code_tool.create_emoji_cq_base64(seg.data) # 如果是表情包(本地文件) return cq_code_tool.create_emoji_cq(seg.data) elif seg.type == 'at': diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 8ed30d69c..9b1ab66be 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -5,10 +5,11 @@ from typing import Dict, List, Optional, Union from nonebot.adapters.onebot.v11 import Bot from .cq_code import cq_code_tool -from .message_cq import Message, Message_Sending, Message_Thinking, MessageSet +from .message_cq import MessageSendCQ +from .message import MessageSending, MessageThinking, MessageRecv,MessageSet from .storage import MessageStorage -from .utils import calculate_typing_time from .config import global_config +from .chat_stream import chat_manager class Message_Sender: @@ -21,66 +22,59 @@ class Message_Sender: def set_bot(self, bot: Bot): """设置当前bot实例""" self._current_bot = bot - - async def send_group_message( - self, - group_id: int, - send_text: str, - auto_escape: bool = False, - reply_message_id: int = None, - at_user_id: int = None - ) -> None: - if not self._current_bot: - raise RuntimeError("Bot未设置,请先调用set_bot方法设置bot实例") - - message = send_text - - # 如果需要回复 - if reply_message_id: - reply_cq = cq_code_tool.create_reply_cq(reply_message_id) - message = reply_cq + message - - # 如果需要at - # if at_user_id: - # at_cq = cq_code_tool.create_at_cq(at_user_id) - # message = at_cq + " " + message - - - typing_time = calculate_typing_time(message) - if typing_time > 10: - typing_time = 10 - await asyncio.sleep(typing_time) - - # 发送消息 - try: - await self._current_bot.send_group_msg( - group_id=group_id, - message=message, - auto_escape=auto_escape + async def send_message( + self, + message: MessageSending, + ) -> None: + """发送消息""" + if isinstance(message, MessageSending): + message_send=MessageSendCQ( + message_id=message.message_id, + user_id=message.message_info.user_info.user_id, + message_segment=message.message_segment, + reply=message.reply ) - print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") - except Exception as e: - print(f"发生错误 {e}") - print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") + if message.message_info.group_info: + try: + await self._current_bot.send_group_msg( + group_id=message.message_info.group_info.group_id, + message=message_send.raw_message, + auto_escape=False + ) + print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") + except Exception as e: + print(f"发生错误 {e}") + print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") + else: + try: + await self._current_bot.send_private_msg( + user_id=message.message_info.user_info.user_id, + message=message_send.raw_message, + auto_escape=False + ) + print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") + except Exception as e: + print(f"发生错误 {e}") + print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") class MessageContainer: - """单个群的发送/思考消息容器""" - def __init__(self, group_id: int, max_size: int = 100): - self.group_id = group_id + """单个聊天流的发送/思考消息容器""" + def __init__(self, chat_id: str, max_size: int = 100): + self.chat_id = chat_id self.max_size = max_size self.messages = [] self.last_send_time = 0 self.thinking_timeout = 20 # 思考超时时间(秒) - def get_timeout_messages(self) -> List[Message_Sending]: + def get_timeout_messages(self) -> List[MessageSending]: """获取所有超时的Message_Sending对象(思考时间超过30秒),按thinking_start_time排序""" current_time = time.time() timeout_messages = [] for msg in self.messages: - if isinstance(msg, Message_Sending): + if isinstance(msg, MessageSending): if current_time - msg.thinking_start_time > self.thinking_timeout: timeout_messages.append(msg) @@ -89,7 +83,7 @@ class MessageContainer: return timeout_messages - def get_earliest_message(self) -> Optional[Union[Message_Thinking, Message_Sending]]: + def get_earliest_message(self) -> Optional[Union[MessageThinking, MessageSending]]: """获取thinking_start_time最早的消息对象""" if not self.messages: return None @@ -102,16 +96,15 @@ class MessageContainer: earliest_message = msg return earliest_message - def add_message(self, message: Union[Message_Thinking, Message_Sending]) -> None: + def add_message(self, message: Union[MessageThinking, MessageSending]) -> None: """添加消息到队列""" - # print(f"\033[1;32m[添加消息]\033[0m 添加消息到对应群") if isinstance(message, MessageSet): for single_message in message.messages: self.messages.append(single_message) else: self.messages.append(message) - def remove_message(self, message: Union[Message_Thinking, Message_Sending]) -> bool: + def remove_message(self, message: Union[MessageThinking, MessageSending]) -> bool: """移除消息,如果消息存在则返回True,否则返回False""" try: if message in self.messages: @@ -126,40 +119,42 @@ class MessageContainer: """检查是否有待发送的消息""" return bool(self.messages) - def get_all_messages(self) -> List[Union[Message, Message_Thinking]]: + def get_all_messages(self) -> List[Union[MessageSending, MessageThinking]]: """获取所有消息""" return list(self.messages) class MessageManager: - """管理所有群的消息容器""" + """管理所有聊天流的消息容器""" def __init__(self): - self.containers: Dict[int, MessageContainer] = {} + self.containers: Dict[str, MessageContainer] = {} # chat_id -> MessageContainer self.storage = MessageStorage() self._running = True - def get_container(self, group_id: int) -> MessageContainer: - """获取或创建群的消息容器""" - if group_id not in self.containers: - self.containers[group_id] = MessageContainer(group_id) - return self.containers[group_id] + def get_container(self, chat_id: str) -> MessageContainer: + """获取或创建聊天流的消息容器""" + if chat_id not in self.containers: + self.containers[chat_id] = MessageContainer(chat_id) + return self.containers[chat_id] - def add_message(self, message: Union[Message_Thinking, Message_Sending, MessageSet]) -> None: - container = self.get_container(message.group_id) + def add_message(self, message: Union[MessageThinking, MessageSending, MessageSet]) -> None: + chat_stream = chat_manager.get_stream_by_info( + platform=message.message_info.platform, + user_info=message.message_info.user_info, + group_info=message.message_info.group_info + ) + if not chat_stream: + raise ValueError("无法找到对应的聊天流") + container = self.get_container(chat_stream.stream_id) container.add_message(message) - async def process_group_messages(self, group_id: int): - """处理群消息""" - # if int(time.time() / 3) == time.time() / 3: - # print(f"\033[1;34m[调试]\033[0m 开始处理群{group_id}的消息") - container = self.get_container(group_id) + async def process_chat_messages(self, chat_id: str): + """处理聊天流消息""" + container = self.get_container(chat_id) if container.has_messages(): - #最早的对象,可能是思考消息,也可能是发送消息 - message_earliest = container.get_earliest_message() #一个message_thinking or message_sending + message_earliest = container.get_earliest_message() - #如果是思考消息 - if isinstance(message_earliest, Message_Thinking): - #优先等待这条消息 + if isinstance(message_earliest, MessageThinking): message_earliest.update_thinking_time() thinking_time = message_earliest.thinking_time print(f"\033[1;34m[调试]\033[0m 消息正在思考中,已思考{int(thinking_time)}秒\033[K\r", end='', flush=True) @@ -168,42 +163,36 @@ class MessageManager: if thinking_time > global_config.thinking_timeout: print(f"\033[1;33m[警告]\033[0m 消息思考超时({thinking_time}秒),移除该消息") container.remove_message(message_earliest) - else:# 如果不是message_thinking就只能是message_sending + else: print(f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中") - #直接发,等什么呢 - if message_earliest.is_head and message_earliest.update_thinking_time() >30: - await message_sender.send_group_message(group_id, message_earliest.processed_plain_text, auto_escape=False, reply_message_id=message_earliest.reply_message_id) + if message_earliest.is_head and message_earliest.update_thinking_time() > 30: + await message_sender.send_message(message_earliest) else: - await message_sender.send_group_message(group_id, message_earliest.processed_plain_text, auto_escape=False) - #移除消息 + await message_sender.send_message(message_earliest) + if message_earliest.is_emoji: message_earliest.processed_plain_text = "[表情包]" await self.storage.store_message(message_earliest, None) container.remove_message(message_earliest) - #获取并处理超时消息 - message_timeout = container.get_timeout_messages() #也许是一堆message_sending + message_timeout = container.get_timeout_messages() if message_timeout: print(f"\033[1;34m[调试]\033[0m 发现{len(message_timeout)}条超时消息") for msg in message_timeout: if msg == message_earliest: - continue # 跳过已经处理过的消息 + continue try: - #发送 - if msg.is_head and msg.update_thinking_time() >30: - await message_sender.send_group_message(group_id, msg.processed_plain_text, auto_escape=False, reply_message_id=msg.reply_message_id) + if msg.is_head and msg.update_thinking_time() > 30: + await message_sender.send_group_message(chat_id, msg.processed_plain_text, auto_escape=False, reply_message_id=msg.reply_message_id) else: - await message_sender.send_group_message(group_id, msg.processed_plain_text, auto_escape=False) - + await message_sender.send_group_message(chat_id, msg.processed_plain_text, auto_escape=False) - #如果是表情包,则替换为"[表情包]" if msg.is_emoji: msg.processed_plain_text = "[表情包]" await self.storage.store_message(msg, None) - # 安全地移除消息 if not container.remove_message(msg): print("\033[1;33m[警告]\033[0m 尝试删除不存在的消息") except Exception as e: @@ -215,8 +204,8 @@ class MessageManager: while self._running: await asyncio.sleep(1) tasks = [] - for group_id in self.containers.keys(): - tasks.append(self.process_group_messages(group_id)) + for chat_id in self.containers.keys(): + tasks.append(self.process_chat_messages(chat_id)) await asyncio.gather(*tasks) diff --git a/src/plugins/chat/relationship_manager.py b/src/plugins/chat/relationship_manager.py index 4ed7a2f11..b56cdc6e5 100644 --- a/src/plugins/chat/relationship_manager.py +++ b/src/plugins/chat/relationship_manager.py @@ -1,8 +1,9 @@ import asyncio -from typing import Optional +from typing import Optional, Union from ...common.database import Database - +from .message_base import UserInfo +from .chat_stream import ChatStream class Impression: traits: str = None @@ -13,60 +14,77 @@ class Impression: class Relationship: user_id: int = None - # impression: Impression = None - # group_id: int = None - # group_name: str = None + platform: str = None gender: str = None age: int = None nickname: str = None relationship_value: float = None saved = False - def __init__(self, user_id: int, data=None, **kwargs): - if isinstance(data, dict): - # 如果输入是字典,使用字典解析 - self.user_id = data.get('user_id') - self.gender = data.get('gender') - self.age = data.get('age') - self.nickname = data.get('nickname') - self.relationship_value = data.get('relationship_value', 0.0) - self.saved = data.get('saved', False) - else: - # 如果是直接传入属性值 - self.user_id = kwargs.get('user_id') - self.gender = kwargs.get('gender') - self.age = kwargs.get('age') - self.nickname = kwargs.get('nickname') - self.relationship_value = kwargs.get('relationship_value', 0.0) - self.saved = kwargs.get('saved', False) + def __init__(self, chat:ChatStream,data:dict): + self.user_id=chat.user_info.user_id + self.platform=chat.platform + self.nickname=chat.user_info.user_nickname + self.relationship_value=data.get('relationship_value',0) + self.age=data.get('age',0) + self.gender=data.get('gender','') - - class RelationshipManager: def __init__(self): - self.relationships: dict[int, Relationship] = {} + self.relationships: dict[tuple[int, str], Relationship] = {} # 修改为使用(user_id, platform)作为键 - async def update_relationship(self, user_id: int, data=None, **kwargs): + async def update_relationship(self, + chat_stream:ChatStream, + data: dict = None, + **kwargs) -> Optional[Relationship]: + """更新或创建关系 + Args: + user_id: 用户ID(可选,如果提供user_info则不需要) + platform: 平台(可选,如果提供user_info则不需要) + user_info: 用户信息对象(可选) + data: 字典格式的数据(可选) + **kwargs: 其他参数 + Returns: + Relationship: 关系对象 + """ + # 确定user_id和platform + if chat_stream.user_info is not None: + user_id = chat_stream.user_info.user_id + platform = chat_stream.user_info.platform or 'qq' + else: + platform = platform or 'qq' + + if user_id is None: + raise ValueError("必须提供user_id或user_info") + + # 使用(user_id, platform)作为键 + key = (user_id, platform) + # 检查是否在内存中已存在 - relationship = self.relationships.get(user_id) + relationship = self.relationships.get(key) if relationship: # 如果存在,更新现有对象 if isinstance(data, dict): - for key, value in data.items(): - if hasattr(relationship, key) and value is not None: - setattr(relationship, key, value) + for k, value in data.items(): + if hasattr(relationship, k) and value is not None: + setattr(relationship, k, value) else: - for key, value in kwargs.items(): - if hasattr(relationship, key) and value is not None: - setattr(relationship, key, value) + for k, value in kwargs.items(): + if hasattr(relationship, k) and value is not None: + setattr(relationship, k, value) else: # 如果不存在,创建新对象 - relationship = Relationship(user_id, data=data) if isinstance(data, dict) else Relationship(user_id, **kwargs) - self.relationships[user_id] = relationship - - # 更新 id_name_nickname_table - # self.id_name_nickname_table[user_id] = [relationship.nickname] # 别称设置为空列表 + if user_info is not None: + relationship = Relationship(user_info=user_info, **kwargs) + elif isinstance(data, dict): + data['platform'] = platform + relationship = Relationship(user_id=user_id, data=data) + else: + kwargs['platform'] = platform + kwargs['user_id'] = user_id + relationship = Relationship(**kwargs) + self.relationships[key] = relationship # 保存到数据库 await self.storage_relationship(relationship) @@ -74,33 +92,87 @@ class RelationshipManager: return relationship - async def update_relationship_value(self, user_id: int, **kwargs): + async def update_relationship_value(self, + user_id: int = None, + platform: str = None, + user_info: UserInfo = None, + **kwargs) -> Optional[Relationship]: + """更新关系值 + Args: + user_id: 用户ID(可选,如果提供user_info则不需要) + platform: 平台(可选,如果提供user_info则不需要) + user_info: 用户信息对象(可选) + **kwargs: 其他参数 + Returns: + Relationship: 关系对象 + """ + # 确定user_id和platform + if user_info is not None: + user_id = user_info.user_id + platform = user_info.platform or 'qq' + else: + platform = platform or 'qq' + + if user_id is None: + raise ValueError("必须提供user_id或user_info") + + # 使用(user_id, platform)作为键 + key = (user_id, platform) + # 检查是否在内存中已存在 - relationship = self.relationships.get(user_id) + relationship = self.relationships.get(key) if relationship: - for key, value in kwargs.items(): - if key == 'relationship_value': + for k, value in kwargs.items(): + if k == 'relationship_value': relationship.relationship_value += value await self.storage_relationship(relationship) relationship.saved = True return relationship else: - print(f"\033[1;31m[关系管理]\033[0m 用户 {user_id} 不存在,无法更新") + # 如果不存在且提供了user_info,则创建新的关系 + if user_info is not None: + return await self.update_relationship(user_info=user_info, **kwargs) + print(f"\033[1;31m[关系管理]\033[0m 用户 {user_id}({platform}) 不存在,无法更新") return None - - def get_relationship(self, user_id: int) -> Optional[Relationship]: - """获取用户关系对象""" - if user_id in self.relationships: - return self.relationships[user_id] + def get_relationship(self, + user_id: int = None, + platform: str = None, + user_info: UserInfo = None) -> Optional[Relationship]: + """获取用户关系对象 + Args: + user_id: 用户ID(可选,如果提供user_info则不需要) + platform: 平台(可选,如果提供user_info则不需要) + user_info: 用户信息对象(可选) + Returns: + Relationship: 关系对象 + """ + # 确定user_id和platform + if user_info is not None: + user_id = user_info.user_id + platform = user_info.platform or 'qq' + else: + platform = platform or 'qq' + + if user_id is None: + raise ValueError("必须提供user_id或user_info") + + key = (user_id, platform) + if key in self.relationships: + return self.relationships[key] else: return 0 async def load_relationship(self, data: dict) -> Relationship: - """从数据库加载或创建新的关系对象""" - rela = Relationship(user_id=data['user_id'], data=data) + """从数据库加载或创建新的关系对象""" + # 确保data中有platform字段,如果没有则默认为'qq' + if 'platform' not in data: + data['platform'] = 'qq' + + rela = Relationship(data=data) rela.saved = True - self.relationships[rela.user_id] = rela + key = (rela.user_id, rela.platform) + self.relationships[key] = rela return rela async def load_all_relationships(self): @@ -117,9 +189,7 @@ class RelationshipManager: all_relationships = db.db.relationships.find({}) # 依次加载每条记录 for data in all_relationships: - user_id = data['user_id'] - relationship = await self.load_relationship(data) - self.relationships[user_id] = relationship + await self.load_relationship(data) print(f"\033[1;32m[关系管理]\033[0m 已加载 {len(self.relationships)} 条关系记录") while True: @@ -130,16 +200,15 @@ class RelationshipManager: async def _save_all_relationships(self): """将所有关系数据保存到数据库""" # 保存所有关系数据 - for userid, relationship in self.relationships.items(): + for (userid, platform), relationship in self.relationships.items(): if not relationship.saved: relationship.saved = True await self.storage_relationship(relationship) - async def storage_relationship(self,relationship: Relationship): - """ - 将关系记录存储到数据库中 - """ + async def storage_relationship(self, relationship: Relationship): + """将关系记录存储到数据库中""" user_id = relationship.user_id + platform = relationship.platform nickname = relationship.nickname relationship_value = relationship.relationship_value gender = relationship.gender @@ -148,8 +217,9 @@ class RelationshipManager: db = Database.get_instance() db.db.relationships.update_one( - {'user_id': user_id}, + {'user_id': user_id, 'platform': platform}, {'$set': { + 'platform': platform, 'nickname': nickname, 'relationship_value': relationship_value, 'gender': gender, @@ -159,12 +229,35 @@ class RelationshipManager: upsert=True ) - def get_name(self, user_id: int) -> str: + def get_name(self, + user_id: int = None, + platform: str = None, + user_info: UserInfo = None) -> str: + """获取用户昵称 + Args: + user_id: 用户ID(可选,如果提供user_info则不需要) + platform: 平台(可选,如果提供user_info则不需要) + user_info: 用户信息对象(可选) + Returns: + str: 用户昵称 + """ + # 确定user_id和platform + if user_info is not None: + user_id = user_info.user_id + platform = user_info.platform or 'qq' + else: + platform = platform or 'qq' + + if user_id is None: + raise ValueError("必须提供user_id或user_info") + # 确保user_id是整数类型 user_id = int(user_id) - if user_id in self.relationships: - - return self.relationships[user_id].nickname + key = (user_id, platform) + if key in self.relationships: + return self.relationships[key].nickname + elif user_info is not None: + return user_info.user_nickname or user_info.user_cardname or "某人" else: return "某人" diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index 4b1bf0ca4..170b677dc 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -1,47 +1,26 @@ -from typing import Optional +from typing import Optional, Union from ...common.database import Database -from .message_cq import Message - +from .message_base import MessageBase +from .message import MessageSending, MessageRecv +from .chat_stream import ChatStream class MessageStorage: def __init__(self): self.db = Database.get_instance() - async def store_message(self, message: Message, topic: Optional[str] = None) -> None: + async def store_message(self, message: Union[MessageSending, MessageRecv],chat_stream:ChatStream, topic: Optional[str] = None) -> None: """存储消息到数据库""" try: - if not message.is_emoji: - message_data = { - "group_id": message.group_id, - "user_id": message.user_id, - "message_id": message.message_id, - "raw_message": message.raw_message, - "plain_text": message.plain_text, + message_data = { + "message_id": message.message_info.message_id, + "time": message.message_info.time, + "chat_id":chat_stream.stream_id, + "chat_info": chat_stream.to_dict(), + "detailed_plain_text": message.detailed_plain_text, "processed_plain_text": message.processed_plain_text, - "time": message.time, - "user_nickname": message.user_nickname, - "user_cardname": message.user_cardname, - "group_name": message.group_name, "topic": topic, - "detailed_plain_text": message.detailed_plain_text, } - else: - message_data = { - "group_id": message.group_id, - "user_id": message.user_id, - "message_id": message.message_id, - "raw_message": message.raw_message, - "plain_text": message.plain_text, - "processed_plain_text": '[表情包]', - "time": message.time, - "user_nickname": message.user_nickname, - "user_cardname": message.user_cardname, - "group_name": message.group_name, - "topic": topic, - "detailed_plain_text": message.detailed_plain_text, - } - self.db.db.messages.insert_one(message_data) except Exception as e: print(f"\033[1;31m[错误]\033[0m 存储消息失败: {e}") diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 001b66207..e3d928c10 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -1,10 +1,15 @@ import asyncio +from typing import Dict +from loguru import logger + from .config import global_config +from .message_base import UserInfo, GroupInfo +from .chat_stream import chat_manager,ChatStream class WillingManager: def __init__(self): - self.group_reply_willing = {} # 存储每个群的回复意愿 + self.chat_reply_willing: Dict[str, float] = {} # 存储每个聊天流的回复意愿 self._decay_task = None self._started = False @@ -12,20 +17,33 @@ class WillingManager: """定期衰减回复意愿""" while True: await asyncio.sleep(5) - for group_id in self.group_reply_willing: - self.group_reply_willing[group_id] = max(0, self.group_reply_willing[group_id] * 0.6) + for chat_id in self.chat_reply_willing: + self.chat_reply_willing[chat_id] = max(0, self.chat_reply_willing[chat_id] * 0.6) - def get_willing(self, group_id: int) -> float: - """获取指定群组的回复意愿""" - return self.group_reply_willing.get(group_id, 0) + def get_willing(self,chat_stream:ChatStream) -> float: + """获取指定聊天流的回复意愿""" + stream = chat_stream + if stream: + return self.chat_reply_willing.get(stream.stream_id, 0) + return 0 - def set_willing(self, group_id: int, willing: float): - """设置指定群组的回复意愿""" - self.group_reply_willing[group_id] = willing + def set_willing(self, chat_id: str, willing: float): + """设置指定聊天流的回复意愿""" + self.chat_reply_willing[chat_id] = willing - def change_reply_willing_received(self, group_id: int, topic: str, is_mentioned_bot: bool, config, user_id: int = None, is_emoji: bool = False, interested_rate: float = 0) -> float: - """改变指定群组的回复意愿并返回回复概率""" - current_willing = self.group_reply_willing.get(group_id, 0) + async def change_reply_willing_received(self, + chat_stream:ChatStream, + topic: str = None, + is_mentioned_bot: bool = False, + config = None, + is_emoji: bool = False, + interested_rate: float = 0) -> float: + """改变指定聊天流的回复意愿并返回回复概率""" + # 获取或创建聊天流 + stream = chat_stream + chat_id = stream.stream_id + + current_willing = self.chat_reply_willing.get(chat_id, 0) # print(f"初始意愿: {current_willing}") if is_mentioned_bot and current_willing < 1.0: @@ -49,31 +67,37 @@ class WillingManager: # print(f"放大系数_willing: {global_config.response_willing_amplifier}, 当前意愿: {current_willing}") reply_probability = max((current_willing - 0.45) * 2, 0) - if group_id not in config.talk_allowed_groups: - current_willing = 0 - reply_probability = 0 - - if group_id in config.talk_frequency_down_groups: - reply_probability = reply_probability / global_config.down_frequency_rate + + # 检查群组权限(如果是群聊) + if chat_stream.group_info: + if chat_stream.group_info.group_id not in config.talk_allowed_groups: + current_willing = 0 + reply_probability = 0 + + if chat_stream.group_info.group_id in config.talk_frequency_down_groups: + reply_probability = reply_probability / global_config.down_frequency_rate reply_probability = min(reply_probability, 1) if reply_probability < 0: reply_probability = 0 - - self.group_reply_willing[group_id] = min(current_willing, 3.0) + self.chat_reply_willing[chat_id] = min(current_willing, 3.0) return reply_probability - def change_reply_willing_sent(self, group_id: int): - """开始思考后降低群组的回复意愿""" - current_willing = self.group_reply_willing.get(group_id, 0) - self.group_reply_willing[group_id] = max(0, current_willing - 2) + def change_reply_willing_sent(self, chat_stream:ChatStream): + """开始思考后降低聊天流的回复意愿""" + stream = chat_stream + if stream: + current_willing = self.chat_reply_willing.get(stream.stream_id, 0) + self.chat_reply_willing[stream.stream_id] = max(0, current_willing - 2) - def change_reply_willing_after_sent(self, group_id: int): - """发送消息后提高群组的回复意愿""" - current_willing = self.group_reply_willing.get(group_id, 0) - if current_willing < 1: - self.group_reply_willing[group_id] = min(1, current_willing + 0.2) + def change_reply_willing_after_sent(self,chat_stream:ChatStream): + """发送消息后提高聊天流的回复意愿""" + stream = chat_stream + if stream: + current_willing = self.chat_reply_willing.get(stream.stream_id, 0) + if current_willing < 1: + self.chat_reply_willing[stream.stream_id] = min(1, current_willing + 0.2) async def ensure_started(self): """确保衰减任务已启动""" From 1b611ecce5178d71963403b8b1032d1bfcdc161f Mon Sep 17 00:00:00 2001 From: ChangingSelf Date: Mon, 10 Mar 2025 22:52:14 +0800 Subject: [PATCH 021/162] =?UTF-8?q?resolve=20SengokuCola/MaiMBot#167=20?= =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E6=AD=A3=E5=88=99=E8=A1=A8=E8=BE=BE=E5=BC=8F?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 9 +++++++++ src/plugins/chat/config.py | 4 ++++ template/bot_config_template.toml | 8 +++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index c510fe4bf..c72ff2380 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -1,3 +1,4 @@ +import re import time from random import random @@ -75,6 +76,14 @@ class ChatBot: logger.info(f"[过滤词识别]消息中含有{word},filtered") return + # 正则表达式过滤 + for pattern in global_config.ban_words_regex: + if re.search(pattern, message.detailed_plain_text): + logger.info( + f"[{message.group_name}]{message.user_nickname}:{message.processed_plain_text}") + logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") + return + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.time)) # topic=await topic_identifier.identify_topic_llm(message.processed_plain_text) diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index c37d23a46..7a7e4ebc9 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -43,6 +43,7 @@ class BotConfig: EMOJI_CHECK_PROMPT: str = "符合公序良俗" # 表情包过滤要求 ban_words = set() + ban_words_regex = set() max_response_length: int = 1024 # 最大回复长度 @@ -277,6 +278,9 @@ class BotConfig: config.response_interested_rate_amplifier = msg_config.get("response_interested_rate_amplifier", config.response_interested_rate_amplifier) config.down_frequency_rate = msg_config.get("down_frequency_rate", config.down_frequency_rate) + + if config.INNER_VERSION in SpecifierSet(">=0.0.5"): + config.ban_words_regex = msg_config.get("ban_words_regex", config.ban_words_regex) def memory(parent: dict): memory_config = parent["memory"] diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index bff64d05f..44c80fd2f 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "0.0.4" +version = "0.0.5" #如果你想要修改配置文件,请在修改后将version的值进行变更 #如果新增项目,请在BotConfig类下新增相应的变量 @@ -40,6 +40,12 @@ ban_words = [ # "403","张三" ] +ban_words_regex = [ + # 需要过滤的消息匹配的正则表达式,匹配到的消息将被过滤,若不了解正则表达式请勿修改 + #"https?://[^\\s]+", # 匹配https链接 + #"\\d{4}-\\d{2}-\\d{2}", # 匹配日期 +] + [emoji] check_interval = 120 # 检查表情包的时间间隔 register_interval = 10 # 注册表情包的时间间隔 From ee579bcf27a6d25dd5f4e0376ec14a591bee7320 Mon Sep 17 00:00:00 2001 From: Ziphyrien <111620796+Ziphyrien@users.noreply.github.com> Date: Mon, 10 Mar 2025 22:54:13 +0800 Subject: [PATCH 022/162] Update README.md --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0c02d1cba..c9e1275cd 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,17 @@ -> ⚠️ **注意事项** +> [!WARNING] > - 项目处于活跃开发阶段,代码可能随时更改 > - 文档未完善,有问题可以提交 Issue 或者 Discussion > - QQ机器人存在被限制风险,请自行了解,谨慎使用 > - 由于持续迭代,可能存在一些已知或未知的bug > - 由于开发中,可能消耗较多token -**交流群**: 766798517 一群人较多,建议加下面的(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 -**交流群**: 571780722 另一个群(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 -**交流群**: 1035228475 另一个群(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 +## 💬交流群 +- [一群](https://qm.qq.com/q/VQ3XZrWgMs) 766798517 ,建议加下面的(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 +- [二群](https://qm.qq.com/q/RzmCiRtHEW) 571780722 (开发和建议相关讨论)不一定有空回复,会优先写文档和代码 +- [三群](https://qm.qq.com/q/wlH5eT8OmQ)1035228475(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 **其他平台版本** @@ -141,8 +142,8 @@ ## 📌 注意事项 SengokuCola纯编程外行,面向cursor编程,很多代码史一样多多包涵 - -> ⚠️ **警告**:本应用生成内容来自人工智能模型,由 AI 生成,请仔细甄别,请勿用于违反法律的用途,AI生成内容不代表本人观点和立场。 +> [!WARNING] +> 本应用生成内容来自人工智能模型,由 AI 生成,请仔细甄别,请勿用于违反法律的用途,AI生成内容不代表本人观点和立场。 ## 致谢 [nonebot2](https://github.com/nonebot/nonebot2): 跨平台 Python 异步聊天机器人框架 From 72a674939671ff7e10473c1c998255e23c8971cc Mon Sep 17 00:00:00 2001 From: Rikki Date: Mon, 10 Mar 2025 23:04:12 +0800 Subject: [PATCH 023/162] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Ddocker?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=97=B6=E5=8C=BA=E6=8C=87=E5=AE=9A=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 512558558..e61763398 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: mongodb: container_name: mongodb environment: - - tz=Asia/Shanghai + - TZ=Asia/Shanghai # - MONGO_INITDB_ROOT_USERNAME=your_username # - MONGO_INITDB_ROOT_PASSWORD=your_password expose: From 2b2b3429472fbacd11481769318a7a28cf05e30c Mon Sep 17 00:00:00 2001 From: Rikki Date: Mon, 10 Mar 2025 23:04:37 +0800 Subject: [PATCH 024/162] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20ruff=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index 54737d640..3586857f0 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,7 @@ pythonEnv = pkgs.python3.withPackages ( ps: with ps; [ + ruff pymongo python-dotenv pydantic From a03b49084daead610f3aeca9aede0bdbb9d522d9 Mon Sep 17 00:00:00 2001 From: Ziphyrien <111620796+Ziphyrien@users.noreply.github.com> Date: Mon, 10 Mar 2025 23:05:10 +0800 Subject: [PATCH 025/162] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9e1275cd..7a23b62b5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ ## 💬交流群 - [一群](https://qm.qq.com/q/VQ3XZrWgMs) 766798517 ,建议加下面的(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 - [二群](https://qm.qq.com/q/RzmCiRtHEW) 571780722 (开发和建议相关讨论)不一定有空回复,会优先写文档和代码 -- [三群](https://qm.qq.com/q/wlH5eT8OmQ)1035228475(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 +- [三群](https://qm.qq.com/q/wlH5eT8OmQ) 1035228475(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 **其他平台版本** From 204744cd7121cb4b7528739cea69355e4519564b Mon Sep 17 00:00:00 2001 From: ChangingSelf Date: Tue, 11 Mar 2025 00:12:19 +0800 Subject: [PATCH 026/162] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=90=8D=E4=B8=8E=E4=BF=AE=E6=94=B9=E8=BF=87=E6=BB=A4=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E4=B8=BAraw=5Fmessage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 4 ++-- src/plugins/chat/config.py | 4 ++-- template/bot_config_template.toml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index c72ff2380..18652cfd4 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -77,10 +77,10 @@ class ChatBot: return # 正则表达式过滤 - for pattern in global_config.ban_words_regex: + for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.detailed_plain_text): logger.info( - f"[{message.group_name}]{message.user_nickname}:{message.processed_plain_text}") + f"[{message.group_name}]{message.user_nickname}:{message.raw_message}") logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 7a7e4ebc9..888e33a7f 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -43,7 +43,7 @@ class BotConfig: EMOJI_CHECK_PROMPT: str = "符合公序良俗" # 表情包过滤要求 ban_words = set() - ban_words_regex = set() + ban_msgs_regex = set() max_response_length: int = 1024 # 最大回复长度 @@ -280,7 +280,7 @@ class BotConfig: config.down_frequency_rate = msg_config.get("down_frequency_rate", config.down_frequency_rate) if config.INNER_VERSION in SpecifierSet(">=0.0.5"): - config.ban_words_regex = msg_config.get("ban_words_regex", config.ban_words_regex) + config.ban_msgs_regex = msg_config.get("ban_msgs_regex", config.ban_msgs_regex) def memory(parent: dict): memory_config = parent["memory"] diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 44c80fd2f..7dc15eb27 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -40,8 +40,8 @@ ban_words = [ # "403","张三" ] -ban_words_regex = [ - # 需要过滤的消息匹配的正则表达式,匹配到的消息将被过滤,若不了解正则表达式请勿修改 +ban_msgs_regex = [ + # 需要过滤的消息(原始消息)匹配的正则表达式,匹配到的消息将被过滤,若不了解正则表达式请勿修改 #"https?://[^\\s]+", # 匹配https链接 #"\\d{4}-\\d{2}-\\d{2}", # 匹配日期 ] From 84495f83291608d40d2543ee8167ddb2907428e5 Mon Sep 17 00:00:00 2001 From: ChangingSelf Date: Tue, 11 Mar 2025 00:20:55 +0800 Subject: [PATCH 027/162] fix --- src/plugins/chat/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 18652cfd4..86d0b6944 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -78,7 +78,7 @@ class ChatBot: # 正则表达式过滤 for pattern in global_config.ban_msgs_regex: - if re.search(pattern, message.detailed_plain_text): + if re.search(pattern, message.raw_message): logger.info( f"[{message.group_name}]{message.user_nickname}:{message.raw_message}") logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") From cd96644a4c387b77e7b157ace73ea3446b661512 Mon Sep 17 00:00:00 2001 From: ChangingSelf Date: Tue, 11 Mar 2025 00:33:53 +0800 Subject: [PATCH 028/162] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- template/bot_config_template.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 7dc15eb27..ff39c9a69 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -41,9 +41,10 @@ ban_words = [ ] ban_msgs_regex = [ - # 需要过滤的消息(原始消息)匹配的正则表达式,匹配到的消息将被过滤,若不了解正则表达式请勿修改 + # 需要过滤的消息(原始消息)匹配的正则表达式,匹配到的消息将被过滤(支持CQ码),若不了解正则表达式请勿修改 #"https?://[^\\s]+", # 匹配https链接 #"\\d{4}-\\d{2}-\\d{2}", # 匹配日期 + # "\\[CQ:at,qq=\\d+\\]" # 匹配@ ] [emoji] From 6cef8fd60b0efb98983090b4e4bb60519d08a08c Mon Sep 17 00:00:00 2001 From: Yan233_ Date: Tue, 11 Mar 2025 00:58:56 +0800 Subject: [PATCH 029/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=B6=E5=8C=BA?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=E5=8E=BBnapcat=E7=94=A8=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E7=AB=AF=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 512558558..227df606b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,6 @@ services: - NAPCAT_UID=${NAPCAT_UID} - NAPCAT_GID=${NAPCAT_GID} # 让 NapCat 获取当前用户 GID,UID,防止权限问题 ports: - - 3000:3000 - - 3001:3001 - 6099:6099 restart: unless-stopped volumes: @@ -19,7 +17,7 @@ services: mongodb: container_name: mongodb environment: - - tz=Asia/Shanghai + - TZ=Asia/Shanghai # - MONGO_INITDB_ROOT_USERNAME=your_username # - MONGO_INITDB_ROOT_PASSWORD=your_password expose: From 354d6d0deb70cdf67ba8cfb508bacee78aff9839 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Tue, 11 Mar 2025 01:13:17 +0800 Subject: [PATCH 030/162] =?UTF-8?q?=E8=AE=B0=E5=BF=86=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优化了记忆的连接建立 重启了遗忘功能 --- README.md | 2 +- docs/Jonathan R.md | 20 + src/plugins/chat/__init__.py | 6 +- src/plugins/chat/emoji_manager.py | 2 +- src/plugins/knowledege/knowledge_library.py | 2 +- src/plugins/memory_system/memory.py | 286 +++-- src/plugins/memory_system/memory_test1.py | 1208 +++++++++++++++++++ 7 files changed, 1422 insertions(+), 104 deletions(-) create mode 100644 docs/Jonathan R.md create mode 100644 src/plugins/memory_system/memory_test1.py diff --git a/README.md b/README.md index 29e25b2b9..5ae9c5f2c 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ ## 📌 注意事项 -SengokuCola纯编程外行,面向cursor编程,很多代码史一样多多包涵 +SengokuCola已得到大脑升级 > ⚠️ **警告**:本应用生成内容来自人工智能模型,由 AI 生成,请仔细甄别,请勿用于违反法律的用途,AI生成内容不代表本人观点和立场。 diff --git a/docs/Jonathan R.md b/docs/Jonathan R.md new file mode 100644 index 000000000..660caaeec --- /dev/null +++ b/docs/Jonathan R.md @@ -0,0 +1,20 @@ +Jonathan R. Wolpaw 在 “Memory in neuroscience: rhetoric versus reality.” 一文中提到,从神经科学的感觉运动假设出发,整个神经系统的功能是将经验与适当的行为联系起来,而不是单纯的信息存储。 +Jonathan R,Wolpaw. (2019). Memory in neuroscience: rhetoric versus reality.. Behavioral and cognitive neuroscience reviews(2). + +1. **单一过程理论** + - 单一过程理论认为,识别记忆主要是基于熟悉性这一单一因素的影响。熟悉性是指对刺激的一种自动的、无意识的感知,它可以使我们在没有回忆起具体细节的情况下,判断一个刺激是否曾经出现过。 + - 例如,在一些实验中,研究者发现被试可以在没有回忆起具体学习情境的情况下,对曾经出现过的刺激做出正确的判断,这被认为是熟悉性在起作用1。 +2. **双重过程理论** + - 双重过程理论则认为,识别记忆是基于两个过程:回忆和熟悉性。回忆是指对过去经验的有意识的回忆,它可以使我们回忆起具体的细节和情境;熟悉性则是一种自动的、无意识的感知。 + - 该理论认为,在识别记忆中,回忆和熟悉性共同作用,使我们能够判断一个刺激是否曾经出现过。例如,在 “记得 / 知道” 范式中,被试被要求判断他们对一个刺激的记忆是基于回忆还是熟悉性。研究发现,被试可以区分这两种不同的记忆过程,这为双重过程理论提供了支持1。 + + + +1. **神经元节点与连接**:借鉴神经网络原理,将每个记忆单元视为一个神经元节点。节点之间通过连接相互关联,连接的强度代表记忆之间的关联程度。在形态学联想记忆中,具有相似形态特征的记忆节点连接强度较高。例如,苹果和橘子的记忆节点,由于在形状、都是水果等形态语义特征上相似,它们之间的连接强度大于苹果与汽车记忆节点间的连接强度。 +2. **记忆聚类与层次结构**:依据形态特征的相似性对记忆进行聚类,形成不同的记忆簇。每个记忆簇内部的记忆具有较高的相似性,而不同记忆簇之间的记忆相似性较低。同时,构建记忆的层次结构,高层次的记忆节点代表更抽象、概括的概念,低层次的记忆节点对应具体的实例。比如,“水果” 作为高层次记忆节点,连接着 “苹果”“橘子”“香蕉” 等低层次具体水果的记忆节点。 +3. **网络的动态更新**:随着新记忆的不断加入,记忆网络动态调整。新记忆节点根据其形态特征与现有网络中的节点建立连接,同时影响相关连接的强度。若新记忆与某个记忆簇的特征高度相似,则被纳入该记忆簇;若具有独特特征,则可能引发新的记忆簇的形成。例如,当系统学习到一种新的水果 “番石榴”,它会根据番石榴的形态、语义等特征,在记忆网络中找到与之最相似的区域(如水果记忆簇),并建立相应连接,同时调整周围节点连接强度以适应这一新记忆。 + + + +- **相似性联想**:该理论认为,当两个或多个事物在形态上具有相似性时,它们在记忆中会形成关联。例如,梨和苹果在形状和都是水果这一属性上有相似性,所以当我们看到梨时,很容易通过形态学联想记忆联想到苹果。这种相似性联想有助于我们对新事物进行分类和理解,当遇到一个新的类似水果时,我们可以通过与已有的水果记忆进行相似性匹配,来推测它的一些特征。 +- **时空关联性联想**:除了相似性联想,MAM 还强调时空关联性联想。如果两个事物在时间或空间上经常同时出现,它们也会在记忆中形成关联。比如,每次在公园里看到花的时候,都能听到鸟儿的叫声,那么花和鸟儿叫声的形态特征(花的视觉形态和鸟叫的听觉形态)就会在记忆中形成关联,以后听到鸟叫可能就会联想到公园里的花。 \ No newline at end of file diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index bd71be019..c730466b3 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -121,9 +121,9 @@ async def build_memory_task(): @scheduler.scheduled_job("interval", seconds=global_config.forget_memory_interval, id="forget_memory") async def forget_memory_task(): """每30秒执行一次记忆构建""" - # print("\033[1;32m[记忆遗忘]\033[0m 开始遗忘记忆...") - # await hippocampus.operation_forget_topic(percentage=0.1) - # print("\033[1;32m[记忆遗忘]\033[0m 记忆遗忘完成") + print("\033[1;32m[记忆遗忘]\033[0m 开始遗忘记忆...") + await hippocampus.operation_forget_topic(percentage=0.1) + print("\033[1;32m[记忆遗忘]\033[0m 记忆遗忘完成") @scheduler.scheduled_job("interval", seconds=global_config.build_memory_interval + 10, id="merge_memory") diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 973bcad2d..740e9a677 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -203,7 +203,7 @@ class EmojiManager: try: prompt = f'这是{global_config.BOT_NICKNAME}将要发送的消息内容:\n{text}\n若要为其配上表情包,请你输出这个表情包应该表达怎样的情感,应该给人什么样的感觉,不要太简洁也不要太长,注意不要输出任何对消息内容的分析内容,只输出\"一种什么样的感觉\"中间的形容词部分。' - content, _ = await self.llm_emotion_judge.generate_response_async(prompt) + content, _ = await self.llm_emotion_judge.generate_response_async(prompt,temperature=1.5) logger.info(f"输出描述: {content}") return content diff --git a/src/plugins/knowledege/knowledge_library.py b/src/plugins/knowledege/knowledge_library.py index 481076961..4bf6227bb 100644 --- a/src/plugins/knowledege/knowledge_library.py +++ b/src/plugins/knowledege/knowledge_library.py @@ -79,7 +79,7 @@ class KnowledgeLibrary: content = f.read() # 按1024字符分段 - segments = [content[i:i+600] for i in range(0, len(content), 600)] + segments = [content[i:i+600] for i in range(0, len(content), 300)] # 处理每个分段 for segment in segments: diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 9b325b36d..0730f9e57 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -25,26 +25,46 @@ class Memory_graph: self.db = Database.get_instance() def connect_dot(self, concept1, concept2): - # 如果边已存在,增加 strength + # 避免自连接 + if concept1 == concept2: + return + + current_time = datetime.datetime.now().timestamp() + + # 如果边已存在,增加 strength if self.G.has_edge(concept1, concept2): self.G[concept1][concept2]['strength'] = self.G[concept1][concept2].get('strength', 1) + 1 + # 更新最后修改时间 + self.G[concept1][concept2]['last_modified'] = current_time else: - # 如果是新边,初始化 strength 为 1 - self.G.add_edge(concept1, concept2, strength=1) + # 如果是新边,初始化 strength 为 1 + self.G.add_edge(concept1, concept2, + strength=1, + created_time=current_time, # 添加创建时间 + last_modified=current_time) # 添加最后修改时间 def add_dot(self, concept, memory): + current_time = datetime.datetime.now().timestamp() + if concept in self.G: - # 如果节点已存在,将新记忆添加到现有列表中 if 'memory_items' in self.G.nodes[concept]: if not isinstance(self.G.nodes[concept]['memory_items'], list): - # 如果当前不是列表,将其转换为列表 self.G.nodes[concept]['memory_items'] = [self.G.nodes[concept]['memory_items']] self.G.nodes[concept]['memory_items'].append(memory) + # 更新最后修改时间 + self.G.nodes[concept]['last_modified'] = current_time else: self.G.nodes[concept]['memory_items'] = [memory] + # 如果节点存在但没有memory_items,说明是第一次添加memory,设置created_time + if 'created_time' not in self.G.nodes[concept]: + self.G.nodes[concept]['created_time'] = current_time + self.G.nodes[concept]['last_modified'] = current_time else: - # 如果是新节点,创建新的记忆列表 - self.G.add_node(concept, memory_items=[memory]) + # 如果是新节点,创建新的记忆列表 + self.G.add_node(concept, + memory_items=[memory], + created_time=current_time, # 添加创建时间 + last_modified=current_time) # 添加最后修改时间 def get_dot(self, concept): # 检查节点是否存在于图中 @@ -191,15 +211,11 @@ class Hippocampus: async def memory_compress(self, messages: list, compress_rate=0.1): """压缩消息记录为记忆 - Args: - messages: 消息记录字典列表,每个字典包含text和time字段 - compress_rate: 压缩率 - Returns: - set: (话题, 记忆) 元组集合 + tuple: (压缩记忆集合, 相似主题字典) """ if not messages: - return set() + return set(), {} # 合并消息文本,同时保留时间信息 input_text = "" @@ -246,12 +262,33 @@ class Hippocampus: # 等待所有任务完成 compressed_memory = set() + similar_topics_dict = {} # 存储每个话题的相似主题列表 for topic, task in tasks: response = await task if response: compressed_memory.add((topic, response[0])) + # 为每个话题查找相似的已存在主题 + existing_topics = list(self.memory_graph.G.nodes()) + similar_topics = [] + + for existing_topic in existing_topics: + topic_words = set(jieba.cut(topic)) + existing_words = set(jieba.cut(existing_topic)) + + all_words = topic_words | existing_words + v1 = [1 if word in topic_words else 0 for word in all_words] + v2 = [1 if word in existing_words else 0 for word in all_words] + + similarity = cosine_similarity(v1, v2) + + if similarity >= 0.6: + similar_topics.append((existing_topic, similarity)) + + similar_topics.sort(key=lambda x: x[1], reverse=True) + similar_topics = similar_topics[:5] + similar_topics_dict[topic] = similar_topics - return compressed_memory + return compressed_memory, similar_topics_dict def calculate_topic_num(self, text, compress_rate): """计算文本的话题数量""" @@ -265,33 +302,40 @@ class Hippocampus: return topic_num async def operation_build_memory(self, chat_size=20): - # 最近消息获取频率 - time_frequency = {'near': 2, 'mid': 4, 'far': 2} - memory_sample = self.get_memory_sample(chat_size, time_frequency) - - for i, input_text in enumerate(memory_sample, 1): - # 加载进度可视化 + time_frequency = {'near': 3, 'mid': 8, 'far': 5} + memory_samples = self.get_memory_sample(chat_size, time_frequency) + + for i, messages in enumerate(memory_samples, 1): all_topics = [] - progress = (i / len(memory_sample)) * 100 + # 加载进度可视化 + progress = (i / len(memory_samples)) * 100 bar_length = 30 - filled_length = int(bar_length * i // len(memory_sample)) + filled_length = int(bar_length * i // len(memory_samples)) bar = '█' * filled_length + '-' * (bar_length - filled_length) - logger.debug(f"进度: [{bar}] {progress:.1f}% ({i}/{len(memory_sample)})") + logger.debug(f"进度: [{bar}] {progress:.1f}% ({i}/{len(memory_samples)})") - # 生成压缩后记忆 ,表现为 (话题,记忆) 的元组 - compressed_memory = set() compress_rate = 0.1 - compressed_memory = await self.memory_compress(input_text, compress_rate) - logger.info(f"压缩后记忆数量: {len(compressed_memory)}") - - # 将记忆加入到图谱中 + compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate) + logger.info(f"压缩后记忆数量: {len(compressed_memory)},似曾相识的话题: {len(similar_topics_dict)}") + for topic, memory in compressed_memory: logger.info(f"添加节点: {topic}") self.memory_graph.add_dot(topic, memory) - all_topics.append(topic) # 收集所有话题 + all_topics.append(topic) + + # 连接相似的已存在主题 + if topic in similar_topics_dict: + similar_topics = similar_topics_dict[topic] + for similar_topic, similarity in similar_topics: + if topic != similar_topic: + strength = int(similarity * 10) + logger.info(f"连接相似节点: {topic} 和 {similar_topic} (强度: {strength})") + self.memory_graph.G.add_edge(topic, similar_topic, strength=strength) + + # 连接同批次的相关话题 for i in range(len(all_topics)): for j in range(i + 1, len(all_topics)): - logger.info(f"连接节点: {all_topics[i]} 和 {all_topics[j]}") + logger.info(f"连接同批次节点: {all_topics[i]} 和 {all_topics[j]}") self.memory_graph.connect_dot(all_topics[i], all_topics[j]) self.sync_memory_to_db() @@ -302,7 +346,7 @@ class Hippocampus: db_nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) - # 转换数据库节点为字典格式,方便查找 + # 转换数据库节点为字典格式,方便查找 db_nodes_dict = {node['concept']: node for node in db_nodes} # 检查并更新节点 @@ -313,13 +357,19 @@ class Hippocampus: # 计算内存中节点的特征值 memory_hash = self.calculate_node_hash(concept, memory_items) + + # 获取时间信息 + created_time = data.get('created_time', datetime.datetime.now().timestamp()) + last_modified = data.get('last_modified', datetime.datetime.now().timestamp()) if concept not in db_nodes_dict: - # 数据库中缺少的节点,添加 + # 数据库中缺少的节点,添加 node_data = { 'concept': concept, 'memory_items': memory_items, - 'hash': memory_hash + 'hash': memory_hash, + 'created_time': created_time, + 'last_modified': last_modified } self.memory_graph.db.db.graph_data.nodes.insert_one(node_data) else: @@ -327,25 +377,21 @@ class Hippocampus: db_node = db_nodes_dict[concept] db_hash = db_node.get('hash', None) - # 如果特征值不同,则更新节点 + # 如果特征值不同,则更新节点 if db_hash != memory_hash: self.memory_graph.db.db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'memory_items': memory_items, - 'hash': memory_hash + 'hash': memory_hash, + 'created_time': created_time, + 'last_modified': last_modified }} ) - # 检查并删除数据库中多余的节点 - memory_concepts = set(node[0] for node in memory_nodes) - for db_node in db_nodes: - if db_node['concept'] not in memory_concepts: - self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) - # 处理边的信息 db_edges = list(self.memory_graph.db.db.graph_data.edges.find()) - memory_edges = list(self.memory_graph.G.edges()) + memory_edges = list(self.memory_graph.G.edges(data=True)) # 创建边的哈希值字典 db_edge_dict = {} @@ -357,10 +403,14 @@ class Hippocampus: } # 检查并更新边 - for source, target in memory_edges: + for source, target, data in memory_edges: edge_hash = self.calculate_edge_hash(source, target) edge_key = (source, target) - strength = self.memory_graph.G[source][target].get('strength', 1) + strength = data.get('strength', 1) + + # 获取边的时间信息 + created_time = data.get('created_time', datetime.datetime.now().timestamp()) + last_modified = data.get('last_modified', datetime.datetime.now().timestamp()) if edge_key not in db_edge_dict: # 添加新边 @@ -368,7 +418,9 @@ class Hippocampus: 'source': source, 'target': target, 'strength': strength, - 'hash': edge_hash + 'hash': edge_hash, + 'created_time': created_time, + 'last_modified': last_modified } self.memory_graph.db.db.graph_data.edges.insert_one(edge_data) else: @@ -378,20 +430,12 @@ class Hippocampus: {'source': source, 'target': target}, {'$set': { 'hash': edge_hash, - 'strength': strength + 'strength': strength, + 'created_time': created_time, + 'last_modified': last_modified }} ) - # 删除多余的边 - memory_edge_set = set(memory_edges) - for edge_key in db_edge_dict: - if edge_key not in memory_edge_set: - source, target = edge_key - self.memory_graph.db.db.graph_data.edges.delete_one({ - 'source': source, - 'target': target - }) - def sync_memory_from_db(self): """从数据库同步数据到内存中的图结构""" # 清空当前图 @@ -405,61 +449,107 @@ class Hippocampus: # 确保memory_items是列表 if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] + + # 获取时间信息 + created_time = node.get('created_time', datetime.datetime.now().timestamp()) + last_modified = node.get('last_modified', datetime.datetime.now().timestamp()) + # 添加节点到图中 - self.memory_graph.G.add_node(concept, memory_items=memory_items) + self.memory_graph.G.add_node(concept, + memory_items=memory_items, + created_time=created_time, + last_modified=last_modified) # 从数据库加载所有边 edges = self.memory_graph.db.db.graph_data.edges.find() for edge in edges: source = edge['source'] target = edge['target'] - strength = edge.get('strength', 1) # 获取 strength,默认为 1 + strength = edge.get('strength', 1) # 获取 strength,默认为 1 + + # 获取时间信息 + created_time = edge.get('created_time', datetime.datetime.now().timestamp()) + last_modified = edge.get('last_modified', datetime.datetime.now().timestamp()) + # 只有当源节点和目标节点都存在时才添加边 if source in self.memory_graph.G and target in self.memory_graph.G: - self.memory_graph.G.add_edge(source, target, strength=strength) + self.memory_graph.G.add_edge(source, target, + strength=strength, + created_time=created_time, + last_modified=last_modified) async def operation_forget_topic(self, percentage=0.1): - """随机选择图中一定比例的节点进行检查,根据条件决定是否遗忘""" - # 获取所有节点 + """随机选择图中一定比例的节点和边进行检查,根据时间条件决定是否遗忘""" all_nodes = list(self.memory_graph.G.nodes()) - # 计算要检查的节点数量 - check_count = max(1, int(len(all_nodes) * percentage)) - # 随机选择节点 - nodes_to_check = random.sample(all_nodes, check_count) - - forgotten_nodes = [] + all_edges = list(self.memory_graph.G.edges()) + + check_nodes_count = max(1, int(len(all_nodes) * percentage)) + check_edges_count = max(1, int(len(all_edges) * percentage)) + + nodes_to_check = random.sample(all_nodes, check_nodes_count) + edges_to_check = random.sample(all_edges, check_edges_count) + + edge_changes = {'weakened': 0, 'removed': 0} + node_changes = {'reduced': 0, 'removed': 0} + + current_time = datetime.datetime.now().timestamp() + + # 检查并遗忘连接 + logger.info("开始检查连接...") + for source, target in edges_to_check: + edge_data = self.memory_graph.G[source][target] + last_modified = edge_data.get('last_modified') + # print(source,target) + # print(f"float(last_modified):{float(last_modified)}" ) + # print(f"current_time:{current_time}") + # print(f"current_time - last_modified:{current_time - last_modified}") + if current_time - last_modified > 3600*24: # test + current_strength = edge_data.get('strength', 1) + new_strength = current_strength - 1 + + if new_strength <= 0: + self.memory_graph.G.remove_edge(source, target) + edge_changes['removed'] += 1 + logger.info(f"\033[1;31m[连接移除]\033[0m {source} - {target}") + else: + edge_data['strength'] = new_strength + edge_data['last_modified'] = current_time + edge_changes['weakened'] += 1 + logger.info(f"\033[1;34m[连接减弱]\033[0m {source} - {target} (强度: {current_strength} -> {new_strength})") + + # 检查并遗忘话题 + logger.info("开始检查节点...") for node in nodes_to_check: - # 获取节点的连接数 - connections = self.memory_graph.G.degree(node) - - # 获取节点的内容条数 - memory_items = self.memory_graph.G.nodes[node].get('memory_items', []) - if not isinstance(memory_items, list): - memory_items = [memory_items] if memory_items else [] - content_count = len(memory_items) - - # 检查连接强度 - weak_connections = True - if connections > 1: # 只有当连接数大于1时才检查强度 - for neighbor in self.memory_graph.G.neighbors(node): - strength = self.memory_graph.G[node][neighbor].get('strength', 1) - if strength > 2: - weak_connections = False - break - - # 如果满足遗忘条件 - if (connections <= 1 and weak_connections) or content_count <= 2: - removed_item = self.memory_graph.forget_topic(node) - if removed_item: - forgotten_nodes.append((node, removed_item)) - logger.debug(f"遗忘节点 {node} 的记忆: {removed_item}") - - # 同步到数据库 - if forgotten_nodes: + node_data = self.memory_graph.G.nodes[node] + last_modified = node_data.get('last_modified', current_time) + + if current_time - last_modified > 3600*24: # test + memory_items = node_data.get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + if memory_items: + current_count = len(memory_items) + removed_item = random.choice(memory_items) + memory_items.remove(removed_item) + + if memory_items: + self.memory_graph.G.nodes[node]['memory_items'] = memory_items + self.memory_graph.G.nodes[node]['last_modified'] = current_time + node_changes['reduced'] += 1 + logger.info(f"\033[1;33m[记忆减少]\033[0m {node} (记忆数量: {current_count} -> {len(memory_items)})") + else: + self.memory_graph.G.remove_node(node) + node_changes['removed'] += 1 + logger.info(f"\033[1;31m[节点移除]\033[0m {node}") + + if any(count > 0 for count in edge_changes.values()) or any(count > 0 for count in node_changes.values()): self.sync_memory_to_db() - logger.debug(f"完成遗忘操作,共遗忘 {len(forgotten_nodes)} 个节点的记忆") + logger.info("\n遗忘操作统计:") + logger.info(f"连接变化: {edge_changes['weakened']} 个减弱, {edge_changes['removed']} 个移除") + logger.info(f"节点变化: {node_changes['reduced']} 个减少记忆, {node_changes['removed']} 个移除") else: - logger.debug("本次检查没有节点满足遗忘条件") + logger.info("\n本次检查没有节点或连接满足遗忘条件") async def merge_memory(self, topic): """ @@ -486,7 +576,7 @@ class Hippocampus: logger.debug(f"选择的记忆:\n{merged_text}") # 使用memory_compress生成新的压缩记忆 - compressed_memories = await self.memory_compress(selected_memories, 0.1) + compressed_memories, _ = await self.memory_compress(selected_memories, 0.1) # 从原记忆列表中移除被选中的记忆 for memory in selected_memories: diff --git a/src/plugins/memory_system/memory_test1.py b/src/plugins/memory_system/memory_test1.py new file mode 100644 index 000000000..bbd734ec2 --- /dev/null +++ b/src/plugins/memory_system/memory_test1.py @@ -0,0 +1,1208 @@ +# -*- coding: utf-8 -*- +import datetime +import math +import os +import random +import sys +import time +from collections import Counter +from pathlib import Path + +import matplotlib.pyplot as plt +import networkx as nx +import pymongo +from dotenv import load_dotenv +from loguru import logger +import jieba + +''' +该理论认为,当两个或多个事物在形态上具有相似性时, +它们在记忆中会形成关联。 +例如,梨和苹果在形状和都是水果这一属性上有相似性, +所以当我们看到梨时,很容易通过形态学联想记忆联想到苹果。 +这种相似性联想有助于我们对新事物进行分类和理解, +当遇到一个新的类似水果时, +我们可以通过与已有的水果记忆进行相似性匹配, +来推测它的一些特征。 + + + +时空关联性联想: +除了相似性联想,MAM 还强调时空关联性联想。 +如果两个事物在时间或空间上经常同时出现,它们也会在记忆中形成关联。 +比如,每次在公园里看到花的时候,都能听到鸟儿的叫声, +那么花和鸟儿叫声的形态特征(花的视觉形态和鸟叫的听觉形态)就会在记忆中形成关联, +以后听到鸟叫可能就会联想到公园里的花。 + +''' + +# from chat.config import global_config +sys.path.append("C:/GitHub/MaiMBot") # 添加项目根目录到 Python 路径 +from src.common.database import Database +from src.plugins.memory_system.offline_llm import LLMModel + +# 获取当前文件的目录 +current_dir = Path(__file__).resolve().parent +# 获取项目根目录(上三层目录) +project_root = current_dir.parent.parent.parent +# env.dev文件路径 +env_path = project_root / ".env.dev" + +# 加载环境变量 +if env_path.exists(): + logger.info(f"从 {env_path} 加载环境变量") + load_dotenv(env_path) +else: + logger.warning(f"未找到环境变量文件: {env_path}") + logger.info("将使用默认配置") + +class Database: + _instance = None + db = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + if not Database.db: + Database.initialize( + host=os.getenv("MONGODB_HOST"), + port=int(os.getenv("MONGODB_PORT")), + db_name=os.getenv("DATABASE_NAME"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE") + ) + + @classmethod + def initialize(cls, host, port, db_name, username=None, password=None, auth_source="admin"): + try: + if username and password: + uri = f"mongodb://{username}:{password}@{host}:{port}/{db_name}?authSource={auth_source}" + else: + uri = f"mongodb://{host}:{port}" + + client = pymongo.MongoClient(uri) + cls.db = client[db_name] + # 测试连接 + client.server_info() + logger.success("MongoDB连接成功!") + + except Exception as e: + logger.error(f"初始化MongoDB失败: {str(e)}") + raise + +def calculate_information_content(text): + """计算文本的信息量(熵)""" + char_count = Counter(text) + total_chars = len(text) + + entropy = 0 + for count in char_count.values(): + probability = count / total_chars + entropy -= probability * math.log2(probability) + + return entropy + +def get_cloest_chat_from_db(db, length: int, timestamp: str): + """从数据库中获取最接近指定时间戳的聊天记录,并记录读取次数 + + Returns: + list: 消息记录字典列表,每个字典包含消息内容和时间信息 + """ + chat_records = [] + closest_record = db.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) + + if closest_record and closest_record.get('memorized', 0) < 4: + closest_time = closest_record['time'] + group_id = closest_record['group_id'] + # 获取该时间戳之后的length条消息,且groupid相同 + records = list(db.db.messages.find( + {"time": {"$gt": closest_time}, "group_id": group_id} + ).sort('time', 1).limit(length)) + + # 更新每条消息的memorized属性 + for record in records: + current_memorized = record.get('memorized', 0) + if current_memorized > 3: + print("消息已读取3次,跳过") + return '' + + # 更新memorized值 + db.db.messages.update_one( + {"_id": record["_id"]}, + {"$set": {"memorized": current_memorized + 1}} + ) + + # 添加到记录列表中 + chat_records.append({ + 'text': record["detailed_plain_text"], + 'time': record["time"], + 'group_id': record["group_id"] + }) + + return chat_records + +class Memory_cortex: + def __init__(self, memory_graph: 'Memory_graph'): + self.memory_graph = memory_graph + + def sync_memory_from_db(self): + """ + 从数据库同步数据到内存中的图结构 + 将清空当前内存中的图,并从数据库重新加载所有节点和边 + """ + # 清空当前图 + self.memory_graph.G.clear() + + # 获取当前时间作为默认时间 + default_time = datetime.datetime.now().timestamp() + + # 从数据库加载所有节点 + nodes = self.memory_graph.db.db.graph_data.nodes.find() + for node in nodes: + concept = node['concept'] + memory_items = node.get('memory_items', []) + # 确保memory_items是列表 + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + # 获取时间属性,如果不存在则使用默认时间 + created_time = node.get('created_time') + last_modified = node.get('last_modified') + + # 如果时间属性不存在,则更新数据库 + if created_time is None or last_modified is None: + created_time = default_time + last_modified = default_time + # 更新数据库中的节点 + self.memory_graph.db.db.graph_data.nodes.update_one( + {'concept': concept}, + {'$set': { + 'created_time': created_time, + 'last_modified': last_modified + }} + ) + logger.info(f"为节点 {concept} 添加默认时间属性") + + # 添加节点到图中,包含时间属性 + self.memory_graph.G.add_node(concept, + memory_items=memory_items, + created_time=created_time, + last_modified=last_modified) + + # 从数据库加载所有边 + edges = self.memory_graph.db.db.graph_data.edges.find() + for edge in edges: + source = edge['source'] + target = edge['target'] + + # 只有当源节点和目标节点都存在时才添加边 + if source in self.memory_graph.G and target in self.memory_graph.G: + # 获取时间属性,如果不存在则使用默认时间 + created_time = edge.get('created_time') + last_modified = edge.get('last_modified') + + # 如果时间属性不存在,则更新数据库 + if created_time is None or last_modified is None: + created_time = default_time + last_modified = default_time + # 更新数据库中的边 + self.memory_graph.db.db.graph_data.edges.update_one( + {'source': source, 'target': target}, + {'$set': { + 'created_time': created_time, + 'last_modified': last_modified + }} + ) + logger.info(f"为边 {source} - {target} 添加默认时间属性") + + self.memory_graph.G.add_edge(source, target, + strength=edge.get('strength', 1), + created_time=created_time, + last_modified=last_modified) + + logger.success("从数据库同步记忆图谱完成") + + def calculate_node_hash(self, concept, memory_items): + """ + 计算节点的特征值 + """ + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + # 将记忆项排序以确保相同内容生成相同的哈希值 + sorted_items = sorted(memory_items) + # 组合概念和记忆项生成特征值 + content = f"{concept}:{'|'.join(sorted_items)}" + return hash(content) + + def calculate_edge_hash(self, source, target): + """ + 计算边的特征值 + """ + # 对源节点和目标节点排序以确保相同的边生成相同的哈希值 + nodes = sorted([source, target]) + return hash(f"{nodes[0]}:{nodes[1]}") + + def sync_memory_to_db(self): + """ + 检查并同步内存中的图结构与数据库 + 使用特征值(哈希值)快速判断是否需要更新 + """ + current_time = datetime.datetime.now().timestamp() + + # 获取数据库中所有节点和内存中所有节点 + db_nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) + memory_nodes = list(self.memory_graph.G.nodes(data=True)) + + # 转换数据库节点为字典格式,方便查找 + db_nodes_dict = {node['concept']: node for node in db_nodes} + + # 检查并更新节点 + for concept, data in memory_nodes: + memory_items = data.get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + # 计算内存中节点的特征值 + memory_hash = self.calculate_node_hash(concept, memory_items) + + if concept not in db_nodes_dict: + # 数据库中缺少的节点,添加 + node_data = { + 'concept': concept, + 'memory_items': memory_items, + 'hash': memory_hash, + 'created_time': data.get('created_time', current_time), + 'last_modified': data.get('last_modified', current_time) + } + self.memory_graph.db.db.graph_data.nodes.insert_one(node_data) + else: + # 获取数据库中节点的特征值 + db_node = db_nodes_dict[concept] + db_hash = db_node.get('hash', None) + + # 如果特征值不同,则更新节点 + if db_hash != memory_hash: + self.memory_graph.db.db.graph_data.nodes.update_one( + {'concept': concept}, + {'$set': { + 'memory_items': memory_items, + 'hash': memory_hash, + 'last_modified': current_time + }} + ) + + # 检查并删除数据库中多余的节点 + memory_concepts = set(node[0] for node in memory_nodes) + for db_node in db_nodes: + if db_node['concept'] not in memory_concepts: + self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) + + # 处理边的信息 + db_edges = list(self.memory_graph.db.db.graph_data.edges.find()) + memory_edges = list(self.memory_graph.G.edges(data=True)) + + # 创建边的哈希值字典 + db_edge_dict = {} + for edge in db_edges: + edge_hash = self.calculate_edge_hash(edge['source'], edge['target']) + db_edge_dict[(edge['source'], edge['target'])] = { + 'hash': edge_hash, + 'strength': edge.get('strength', 1) + } + + # 检查并更新边 + for source, target, data in memory_edges: + edge_hash = self.calculate_edge_hash(source, target) + edge_key = (source, target) + strength = data.get('strength', 1) + + if edge_key not in db_edge_dict: + # 添加新边 + edge_data = { + 'source': source, + 'target': target, + 'strength': strength, + 'hash': edge_hash, + 'created_time': data.get('created_time', current_time), + 'last_modified': data.get('last_modified', current_time) + } + self.memory_graph.db.db.graph_data.edges.insert_one(edge_data) + else: + # 检查边的特征值是否变化 + if db_edge_dict[edge_key]['hash'] != edge_hash: + self.memory_graph.db.db.graph_data.edges.update_one( + {'source': source, 'target': target}, + {'$set': { + 'hash': edge_hash, + 'strength': strength, + 'last_modified': current_time + }} + ) + + # 删除多余的边 + memory_edge_set = set((source, target) for source, target, _ in memory_edges) + for edge_key in db_edge_dict: + if edge_key not in memory_edge_set: + source, target = edge_key + self.memory_graph.db.db.graph_data.edges.delete_one({ + 'source': source, + 'target': target + }) + + logger.success("完成记忆图谱与数据库的差异同步") + + def remove_node_from_db(self, topic): + """ + 从数据库中删除指定节点及其相关的边 + + Args: + topic: 要删除的节点概念 + """ + # 删除节点 + self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': topic}) + # 删除所有涉及该节点的边 + self.memory_graph.db.db.graph_data.edges.delete_many({ + '$or': [ + {'source': topic}, + {'target': topic} + ] + }) + +class Memory_graph: + def __init__(self): + self.G = nx.Graph() # 使用 networkx 的图结构 + self.db = Database.get_instance() + + def connect_dot(self, concept1, concept2): + # 避免自连接 + if concept1 == concept2: + return + + current_time = datetime.datetime.now().timestamp() + + # 如果边已存在,增加 strength + if self.G.has_edge(concept1, concept2): + self.G[concept1][concept2]['strength'] = self.G[concept1][concept2].get('strength', 1) + 1 + # 更新最后修改时间 + self.G[concept1][concept2]['last_modified'] = current_time + else: + # 如果是新边,初始化 strength 为 1 + self.G.add_edge(concept1, concept2, + strength=1, + created_time=current_time, + last_modified=current_time) + + def add_dot(self, concept, memory): + current_time = datetime.datetime.now().timestamp() + + if concept in self.G: + # 如果节点已存在,将新记忆添加到现有列表中 + if 'memory_items' in self.G.nodes[concept]: + if not isinstance(self.G.nodes[concept]['memory_items'], list): + # 如果当前不是列表,将其转换为列表 + self.G.nodes[concept]['memory_items'] = [self.G.nodes[concept]['memory_items']] + self.G.nodes[concept]['memory_items'].append(memory) + # 更新最后修改时间 + self.G.nodes[concept]['last_modified'] = current_time + else: + self.G.nodes[concept]['memory_items'] = [memory] + self.G.nodes[concept]['last_modified'] = current_time + else: + # 如果是新节点,创建新的记忆列表 + self.G.add_node(concept, + memory_items=[memory], + created_time=current_time, + last_modified=current_time) + + def get_dot(self, concept): + # 检查节点是否存在于图中 + if concept in self.G: + # 从图中获取节点数据 + node_data = self.G.nodes[concept] + return concept, node_data + return None + + def get_related_item(self, topic, depth=1): + if topic not in self.G: + return [], [] + + first_layer_items = [] + second_layer_items = [] + + # 获取相邻节点 + neighbors = list(self.G.neighbors(topic)) + + # 获取当前节点的记忆项 + node_data = self.get_dot(topic) + if node_data: + concept, data = node_data + if 'memory_items' in data: + memory_items = data['memory_items'] + if isinstance(memory_items, list): + first_layer_items.extend(memory_items) + else: + first_layer_items.append(memory_items) + + # 只在depth=2时获取第二层记忆 + if depth >= 2: + # 获取相邻节点的记忆项 + for neighbor in neighbors: + node_data = self.get_dot(neighbor) + if node_data: + concept, data = node_data + if 'memory_items' in data: + memory_items = data['memory_items'] + if isinstance(memory_items, list): + second_layer_items.extend(memory_items) + else: + second_layer_items.append(memory_items) + + return first_layer_items, second_layer_items + + @property + def dots(self): + # 返回所有节点对应的 Memory_dot 对象 + return [self.get_dot(node) for node in self.G.nodes()] + +# 海马体 +class Hippocampus: + def __init__(self, memory_graph: Memory_graph): + self.memory_graph = memory_graph + self.memory_cortex = Memory_cortex(memory_graph) + self.llm_model = LLMModel() + self.llm_model_small = LLMModel(model_name="deepseek-ai/DeepSeek-V2.5") + self.llm_model_get_topic = LLMModel(model_name="Pro/Qwen/Qwen2.5-7B-Instruct") + self.llm_model_summary = LLMModel(model_name="Qwen/Qwen2.5-32B-Instruct") + + def get_memory_sample(self, chat_size=20, time_frequency:dict={'near':2,'mid':4,'far':3}): + """获取记忆样本 + + Returns: + list: 消息记录列表,每个元素是一个消息记录字典列表 + """ + current_timestamp = datetime.datetime.now().timestamp() + chat_samples = [] + + # 短期:1h 中期:4h 长期:24h + for _ in range(time_frequency.get('near')): + random_time = current_timestamp - random.randint(1, 3600*4) + messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + if messages: + chat_samples.append(messages) + + for _ in range(time_frequency.get('mid')): + random_time = current_timestamp - random.randint(3600*4, 3600*24) + messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + if messages: + chat_samples.append(messages) + + for _ in range(time_frequency.get('far')): + random_time = current_timestamp - random.randint(3600*24, 3600*24*7) + messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + if messages: + chat_samples.append(messages) + + return chat_samples + + def calculate_topic_num(self,text, compress_rate): + """计算文本的话题数量""" + information_content = calculate_information_content(text) + topic_by_length = text.count('\n')*compress_rate + topic_by_information_content = max(1, min(5, int((information_content-3) * 2))) + topic_num = int((topic_by_length + topic_by_information_content)/2) + print(f"topic_by_length: {topic_by_length}, topic_by_information_content: {topic_by_information_content}, topic_num: {topic_num}") + return topic_num + + async def memory_compress(self, messages: list, compress_rate=0.1): + """压缩消息记录为记忆 + + Args: + messages: 消息记录字典列表,每个字典包含text和time字段 + compress_rate: 压缩率 + + Returns: + tuple: (压缩记忆集合, 相似主题字典) + - 压缩记忆集合: set of (话题, 记忆) 元组 + - 相似主题字典: dict of {话题: [(相似主题, 相似度), ...]} + """ + if not messages: + return set(), {} + + # 合并消息文本,同时保留时间信息 + input_text = "" + time_info = "" + # 计算最早和最晚时间 + earliest_time = min(msg['time'] for msg in messages) + latest_time = max(msg['time'] for msg in messages) + + earliest_dt = datetime.datetime.fromtimestamp(earliest_time) + latest_dt = datetime.datetime.fromtimestamp(latest_time) + + # 如果是同一年 + if earliest_dt.year == latest_dt.year: + earliest_str = earliest_dt.strftime("%m-%d %H:%M:%S") + latest_str = latest_dt.strftime("%m-%d %H:%M:%S") + time_info += f"是在{earliest_dt.year}年,{earliest_str} 到 {latest_str} 的对话:\n" + else: + earliest_str = earliest_dt.strftime("%Y-%m-%d %H:%M:%S") + latest_str = latest_dt.strftime("%Y-%m-%d %H:%M:%S") + time_info += f"是从 {earliest_str} 到 {latest_str} 的对话:\n" + + for msg in messages: + input_text += f"{msg['text']}\n" + + print(input_text) + + topic_num = self.calculate_topic_num(input_text, compress_rate) + topics_response = self.llm_model_get_topic.generate_response(self.find_topic_llm(input_text, topic_num)) + + # 过滤topics + filter_keywords = ['表情包', '图片', '回复', '聊天记录'] + topics = [topic.strip() for topic in topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] + filtered_topics = [topic for topic in topics if not any(keyword in topic for keyword in filter_keywords)] + + print(f"过滤后话题: {filtered_topics}") + + # 为每个话题查找相似的已存在主题 + print("\n检查相似主题:") + similar_topics_dict = {} # 存储每个话题的相似主题列表 + + for topic in filtered_topics: + # 获取所有现有节点 + existing_topics = list(self.memory_graph.G.nodes()) + similar_topics = [] + + # 对每个现有节点计算相似度 + for existing_topic in existing_topics: + # 使用jieba分词并计算余弦相似度 + topic_words = set(jieba.cut(topic)) + existing_words = set(jieba.cut(existing_topic)) + + # 计算词向量 + all_words = topic_words | existing_words + v1 = [1 if word in topic_words else 0 for word in all_words] + v2 = [1 if word in existing_words else 0 for word in all_words] + + # 计算余弦相似度 + similarity = cosine_similarity(v1, v2) + + # 如果相似度超过阈值,添加到结果中 + if similarity >= 0.6: # 设置相似度阈值 + similar_topics.append((existing_topic, similarity)) + + # 按相似度降序排序 + similar_topics.sort(key=lambda x: x[1], reverse=True) + # 只保留前5个最相似的主题 + similar_topics = similar_topics[:5] + + # 存储到字典中 + similar_topics_dict[topic] = similar_topics + + # 输出结果 + if similar_topics: + print(f"\n主题「{topic}」的相似主题:") + for similar_topic, score in similar_topics: + print(f"- {similar_topic} (相似度: {score:.3f})") + else: + print(f"\n主题「{topic}」没有找到相似主题") + + # 创建所有话题的请求任务 + tasks = [] + for topic in filtered_topics: + topic_what_prompt = self.topic_what(input_text, topic , time_info) + # 创建异步任务 + task = self.llm_model_small.generate_response_async(topic_what_prompt) + tasks.append((topic.strip(), task)) + + # 等待所有任务完成 + compressed_memory = set() + for topic, task in tasks: + response = await task + if response: + compressed_memory.add((topic, response[0])) + + return compressed_memory, similar_topics_dict + + async def operation_build_memory(self, chat_size=12): + # 最近消息获取频率 + time_frequency = {'near': 3, 'mid': 8, 'far': 5} + memory_samples = self.get_memory_sample(chat_size, time_frequency) + + all_topics = [] # 用于存储所有话题 + + for i, messages in enumerate(memory_samples, 1): + # 加载进度可视化 + all_topics = [] + progress = (i / len(memory_samples)) * 100 + bar_length = 30 + filled_length = int(bar_length * i // len(memory_samples)) + bar = '█' * filled_length + '-' * (bar_length - filled_length) + print(f"\n进度: [{bar}] {progress:.1f}% ({i}/{len(memory_samples)})") + + # 生成压缩后记忆 + compress_rate = 0.1 + compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate) + print(f"\033[1;33m压缩后记忆数量\033[0m: {len(compressed_memory)},似曾相识的话题: {len(similar_topics_dict)}") + + # 将记忆加入到图谱中 + for topic, memory in compressed_memory: + print(f"\033[1;32m添加节点\033[0m: {topic}") + self.memory_graph.add_dot(topic, memory) + all_topics.append(topic) + + # 连接相似的已存在主题 + if topic in similar_topics_dict: + similar_topics = similar_topics_dict[topic] + for similar_topic, similarity in similar_topics: + # 避免自连接 + if topic != similar_topic: + # 根据相似度设置连接强度 + strength = int(similarity * 10) # 将0.3-1.0的相似度映射到3-10的强度 + print(f"\033[1;36m连接相似节点\033[0m: {topic} 和 {similar_topic} (强度: {strength})") + # 使用相似度作为初始连接强度 + self.memory_graph.G.add_edge(topic, similar_topic, strength=strength) + + # 连接同批次的相关话题 + for i in range(len(all_topics)): + for j in range(i + 1, len(all_topics)): + print(f"\033[1;32m连接同批次节点\033[0m: {all_topics[i]} 和 {all_topics[j]}") + self.memory_graph.connect_dot(all_topics[i], all_topics[j]) + + self.memory_cortex.sync_memory_to_db() + + def forget_connection(self, source, target): + """ + 检查并可能遗忘一个连接 + + Args: + source: 连接的源节点 + target: 连接的目标节点 + + Returns: + tuple: (是否有变化, 变化类型, 变化详情) + 变化类型: 0-无变化, 1-强度减少, 2-连接移除 + """ + current_time = datetime.datetime.now().timestamp() + # 获取边的属性 + edge_data = self.memory_graph.G[source][target] + last_modified = edge_data.get('last_modified', current_time) + + # 如果连接超过7天未更新 + if current_time - last_modified > 6000: # test + # 获取当前强度 + current_strength = edge_data.get('strength', 1) + # 减少连接强度 + new_strength = current_strength - 1 + edge_data['strength'] = new_strength + edge_data['last_modified'] = current_time + + # 如果强度降为0,移除连接 + if new_strength <= 0: + self.memory_graph.G.remove_edge(source, target) + return True, 2, f"移除连接: {source} - {target} (强度降至0)" + else: + return True, 1, f"减弱连接: {source} - {target} (强度: {current_strength} -> {new_strength})" + + return False, 0, "" + + def forget_topic(self, topic): + """ + 检查并可能遗忘一个话题的记忆 + + Args: + topic: 要检查的话题 + + Returns: + tuple: (是否有变化, 变化类型, 变化详情) + 变化类型: 0-无变化, 1-记忆减少, 2-节点移除 + """ + current_time = datetime.datetime.now().timestamp() + # 获取节点的最后修改时间 + node_data = self.memory_graph.G.nodes[topic] + last_modified = node_data.get('last_modified', current_time) + + # 如果话题超过7天未更新 + if current_time - last_modified > 3000: # test + memory_items = node_data.get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + if memory_items: + # 获取当前记忆数量 + current_count = len(memory_items) + # 随机选择一条记忆删除 + removed_item = random.choice(memory_items) + memory_items.remove(removed_item) + + if memory_items: + # 更新节点的记忆项和最后修改时间 + self.memory_graph.G.nodes[topic]['memory_items'] = memory_items + self.memory_graph.G.nodes[topic]['last_modified'] = current_time + return True, 1, f"减少记忆: {topic} (记忆数量: {current_count} -> {len(memory_items)})\n被移除的记忆: {removed_item}" + else: + # 如果没有记忆了,删除节点及其所有连接 + self.memory_graph.G.remove_node(topic) + return True, 2, f"移除节点: {topic} (无剩余记忆)\n最后一条记忆: {removed_item}" + + return False, 0, "" + + async def operation_forget_topic(self, percentage=0.1): + """ + 随机选择图中一定比例的节点和边进行检查,根据时间条件决定是否遗忘 + + Args: + percentage: 要检查的节点和边的比例,默认为0.1(10%) + """ + # 获取所有节点和边 + all_nodes = list(self.memory_graph.G.nodes()) + all_edges = list(self.memory_graph.G.edges()) + + # 计算要检查的数量 + check_nodes_count = max(1, int(len(all_nodes) * percentage)) + check_edges_count = max(1, int(len(all_edges) * percentage)) + + # 随机选择要检查的节点和边 + nodes_to_check = random.sample(all_nodes, check_nodes_count) + edges_to_check = random.sample(all_edges, check_edges_count) + + # 用于统计不同类型的变化 + edge_changes = {'weakened': 0, 'removed': 0} + node_changes = {'reduced': 0, 'removed': 0} + + # 检查并遗忘连接 + print("\n开始检查连接...") + for source, target in edges_to_check: + changed, change_type, details = self.forget_connection(source, target) + if changed: + if change_type == 1: + edge_changes['weakened'] += 1 + logger.info(f"\033[1;34m[连接减弱]\033[0m {details}") + elif change_type == 2: + edge_changes['removed'] += 1 + logger.info(f"\033[1;31m[连接移除]\033[0m {details}") + + # 检查并遗忘话题 + print("\n开始检查节点...") + for node in nodes_to_check: + changed, change_type, details = self.forget_topic(node) + if changed: + if change_type == 1: + node_changes['reduced'] += 1 + logger.info(f"\033[1;33m[记忆减少]\033[0m {details}") + elif change_type == 2: + node_changes['removed'] += 1 + logger.info(f"\033[1;31m[节点移除]\033[0m {details}") + + # 同步到数据库 + if any(count > 0 for count in edge_changes.values()) or any(count > 0 for count in node_changes.values()): + self.memory_cortex.sync_memory_to_db() + print("\n遗忘操作统计:") + print(f"连接变化: {edge_changes['weakened']} 个减弱, {edge_changes['removed']} 个移除") + print(f"节点变化: {node_changes['reduced']} 个减少记忆, {node_changes['removed']} 个移除") + else: + print("\n本次检查没有节点或连接满足遗忘条件") + + async def merge_memory(self, topic): + """ + 对指定话题的记忆进行合并压缩 + + Args: + topic: 要合并的话题节点 + """ + # 获取节点的记忆项 + memory_items = self.memory_graph.G.nodes[topic].get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + # 如果记忆项不足,直接返回 + if len(memory_items) < 10: + return + + # 随机选择10条记忆 + selected_memories = random.sample(memory_items, 10) + + # 拼接成文本 + merged_text = "\n".join(selected_memories) + print(f"\n[合并记忆] 话题: {topic}") + print(f"选择的记忆:\n{merged_text}") + + # 使用memory_compress生成新的压缩记忆 + compressed_memories, _ = await self.memory_compress(selected_memories, 0.1) + + # 从原记忆列表中移除被选中的记忆 + for memory in selected_memories: + memory_items.remove(memory) + + # 添加新的压缩记忆 + for _, compressed_memory in compressed_memories: + memory_items.append(compressed_memory) + print(f"添加压缩记忆: {compressed_memory}") + + # 更新节点的记忆项 + self.memory_graph.G.nodes[topic]['memory_items'] = memory_items + print(f"完成记忆合并,当前记忆数量: {len(memory_items)}") + + async def operation_merge_memory(self, percentage=0.1): + """ + 随机检查一定比例的节点,对内容数量超过100的节点进行记忆合并 + + Args: + percentage: 要检查的节点比例,默认为0.1(10%) + """ + # 获取所有节点 + all_nodes = list(self.memory_graph.G.nodes()) + # 计算要检查的节点数量 + check_count = max(1, int(len(all_nodes) * percentage)) + # 随机选择节点 + nodes_to_check = random.sample(all_nodes, check_count) + + merged_nodes = [] + for node in nodes_to_check: + # 获取节点的内容条数 + memory_items = self.memory_graph.G.nodes[node].get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + content_count = len(memory_items) + + # 如果内容数量超过100,进行合并 + if content_count > 100: + print(f"\n检查节点: {node}, 当前记忆数量: {content_count}") + await self.merge_memory(node) + merged_nodes.append(node) + + # 同步到数据库 + if merged_nodes: + self.memory_cortex.sync_memory_to_db() + print(f"\n完成记忆合并操作,共处理 {len(merged_nodes)} 个节点") + else: + print("\n本次检查没有需要合并的节点") + + async def _identify_topics(self, text: str) -> list: + """从文本中识别可能的主题""" + topics_response = self.llm_model_get_topic.generate_response(self.find_topic_llm(text, 5)) + topics = [topic.strip() for topic in topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] + return topics + + def _find_similar_topics(self, topics: list, similarity_threshold: float = 0.4, debug_info: str = "") -> list: + """查找与给定主题相似的记忆主题""" + all_memory_topics = list(self.memory_graph.G.nodes()) + all_similar_topics = [] + + for topic in topics: + if debug_info: + pass + + topic_vector = text_to_vector(topic) + has_similar_topic = False + + for memory_topic in all_memory_topics: + memory_vector = text_to_vector(memory_topic) + all_words = set(topic_vector.keys()) | set(memory_vector.keys()) + v1 = [topic_vector.get(word, 0) for word in all_words] + v2 = [memory_vector.get(word, 0) for word in all_words] + similarity = cosine_similarity(v1, v2) + + if similarity >= similarity_threshold: + has_similar_topic = True + all_similar_topics.append((memory_topic, similarity)) + + return all_similar_topics + + def _get_top_topics(self, similar_topics: list, max_topics: int = 5) -> list: + """获取相似度最高的主题""" + seen_topics = set() + top_topics = [] + + for topic, score in sorted(similar_topics, key=lambda x: x[1], reverse=True): + if topic not in seen_topics and len(top_topics) < max_topics: + seen_topics.add(topic) + top_topics.append((topic, score)) + + return top_topics + + async def memory_activate_value(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.3) -> int: + """计算输入文本对记忆的激活程度""" + logger.info(f"[记忆激活]识别主题: {await self._identify_topics(text)}") + + identified_topics = await self._identify_topics(text) + if not identified_topics: + return 0 + + all_similar_topics = self._find_similar_topics( + identified_topics, + similarity_threshold=similarity_threshold, + debug_info="记忆激活" + ) + + if not all_similar_topics: + return 0 + + top_topics = self._get_top_topics(all_similar_topics, max_topics) + + if len(top_topics) == 1: + topic, score = top_topics[0] + memory_items = self.memory_graph.G.nodes[topic].get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + content_count = len(memory_items) + penalty = 1.0 / (1 + math.log(content_count + 1)) + + activation = int(score * 50 * penalty) + print(f"\033[1;32m[记忆激活]\033[0m 单主题「{topic}」- 相似度: {score:.3f}, 内容数: {content_count}, 激活值: {activation}") + return activation + + matched_topics = set() + topic_similarities = {} + + for memory_topic, similarity in top_topics: + memory_items = self.memory_graph.G.nodes[memory_topic].get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + content_count = len(memory_items) + penalty = 1.0 / (1 + math.log(content_count + 1)) + + for input_topic in identified_topics: + topic_vector = text_to_vector(input_topic) + memory_vector = text_to_vector(memory_topic) + all_words = set(topic_vector.keys()) | set(memory_vector.keys()) + v1 = [topic_vector.get(word, 0) for word in all_words] + v2 = [memory_vector.get(word, 0) for word in all_words] + sim = cosine_similarity(v1, v2) + if sim >= similarity_threshold: + matched_topics.add(input_topic) + adjusted_sim = sim * penalty + topic_similarities[input_topic] = max(topic_similarities.get(input_topic, 0), adjusted_sim) + print(f"\033[1;32m[记忆激活]\033[0m 主题「{input_topic}」-> 「{memory_topic}」(内容数: {content_count}, 相似度: {adjusted_sim:.3f})") + + topic_match = len(matched_topics) / len(identified_topics) + average_similarities = sum(topic_similarities.values()) / len(topic_similarities) if topic_similarities else 0 + + activation = int((topic_match + average_similarities) / 2 * 100) + print(f"\033[1;32m[记忆激活]\033[0m 匹配率: {topic_match:.3f}, 平均相似度: {average_similarities:.3f}, 激活值: {activation}") + + return activation + + async def get_relevant_memories(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.4, max_memory_num: int = 5) -> list: + """根据输入文本获取相关的记忆内容""" + identified_topics = await self._identify_topics(text) + + all_similar_topics = self._find_similar_topics( + identified_topics, + similarity_threshold=similarity_threshold, + debug_info="记忆检索" + ) + + relevant_topics = self._get_top_topics(all_similar_topics, max_topics) + + relevant_memories = [] + for topic, score in relevant_topics: + first_layer, _ = self.memory_graph.get_related_item(topic, depth=1) + if first_layer: + if len(first_layer) > max_memory_num/2: + first_layer = random.sample(first_layer, max_memory_num//2) + for memory in first_layer: + relevant_memories.append({ + 'topic': topic, + 'similarity': score, + 'content': memory + }) + + relevant_memories.sort(key=lambda x: x['similarity'], reverse=True) + + if len(relevant_memories) > max_memory_num: + relevant_memories = random.sample(relevant_memories, max_memory_num) + + return relevant_memories + + def find_topic_llm(self,text, topic_num): + prompt = f'这是一段文字:{text}。请你从这段话中总结出{topic_num}个关键的概念,可以是名词,动词,或者特定人物,帮我列出来,用逗号,隔开,尽可能精简。只需要列举{topic_num}个话题就好,不要有序号,不要告诉我其他内容。' + return prompt + + def topic_what(self,text, topic, time_info): + prompt = f'这是一段文字,{time_info}:{text}。我想让你基于这段文字来概括"{topic}"这个概念,帮我总结成一句自然的话,可以包含时间和人物,以及具体的观点。只输出这句话就好' + return prompt + +def segment_text(text): + """使用jieba进行文本分词""" + seg_text = list(jieba.cut(text)) + return seg_text + +def text_to_vector(text): + """将文本转换为词频向量""" + words = segment_text(text) + vector = {} + for word in words: + vector[word] = vector.get(word, 0) + 1 + return vector + +def cosine_similarity(v1, v2): + """计算两个向量的余弦相似度""" + dot_product = sum(a * b for a, b in zip(v1, v2)) + norm1 = math.sqrt(sum(a * a for a in v1)) + norm2 = math.sqrt(sum(b * b for b in v2)) + if norm1 == 0 or norm2 == 0: + return 0 + return dot_product / (norm1 * norm2) + +def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = False): + # 设置中文字体 + plt.rcParams['font.sans-serif'] = ['SimHei'] # 用来正常显示中文标签 + plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号 + + G = memory_graph.G + + # 创建一个新图用于可视化 + H = G.copy() + + # 过滤掉内容数量小于2的节点 + nodes_to_remove = [] + for node in H.nodes(): + memory_items = H.nodes[node].get('memory_items', []) + memory_count = len(memory_items) if isinstance(memory_items, list) else (1 if memory_items else 0) + if memory_count < 2: + nodes_to_remove.append(node) + + H.remove_nodes_from(nodes_to_remove) + + # 如果没有符合条件的节点,直接返回 + if len(H.nodes()) == 0: + print("没有找到内容数量大于等于2的节点") + return + + # 计算节点大小和颜色 + node_colors = [] + node_sizes = [] + nodes = list(H.nodes()) + + # 获取最大记忆数用于归一化节点大小 + max_memories = 1 + for node in nodes: + memory_items = H.nodes[node].get('memory_items', []) + memory_count = len(memory_items) if isinstance(memory_items, list) else (1 if memory_items else 0) + max_memories = max(max_memories, memory_count) + + # 计算每个节点的大小和颜色 + for node in nodes: + # 计算节点大小(基于记忆数量) + memory_items = H.nodes[node].get('memory_items', []) + memory_count = len(memory_items) if isinstance(memory_items, list) else (1 if memory_items else 0) + # 使用指数函数使变化更明显 + ratio = memory_count / max_memories + size = 400 + 2000 * (ratio ** 2) # 增大节点大小 + node_sizes.append(size) + + # 计算节点颜色(基于连接数) + degree = H.degree(node) + if degree >= 30: + node_colors.append((1.0, 0, 0)) # 亮红色 (#FF0000) + else: + # 将1-10映射到0-1的范围 + color_ratio = (degree - 1) / 29.0 if degree > 1 else 0 + # 使用蓝到红的渐变 + red = min(0.9, color_ratio) + blue = max(0.0, 1.0 - color_ratio) + node_colors.append((red, 0, blue)) + + # 绘制图形 + plt.figure(figsize=(16, 12)) # 减小图形尺寸 + pos = nx.spring_layout(H, + k=1, # 调整节点间斥力 + iterations=100, # 增加迭代次数 + scale=1.5, # 减小布局尺寸 + weight='strength') # 使用边的strength属性作为权重 + + nx.draw(H, pos, + with_labels=True, + node_color=node_colors, + node_size=node_sizes, + font_size=12, # 保持增大的字体大小 + font_family='SimHei', + font_weight='bold', + edge_color='gray', + width=1.5) # 统一的边宽度 + + title = '记忆图谱可视化(仅显示内容≥2的节点)\n节点大小表示记忆数量\n节点颜色:蓝(弱连接)到红(强连接)渐变,边的透明度表示连接强度\n连接强度越大的节点距离越近' + plt.title(title, fontsize=16, fontfamily='SimHei') + plt.show() + +async def main(): + # 初始化数据库 + logger.info("正在初始化数据库连接...") + db = Database.get_instance() + start_time = time.time() + + test_pare = {'do_build_memory':True,'do_forget_topic':False,'do_visualize_graph':True,'do_query':False,'do_merge_memory':False} + + # 创建记忆图 + memory_graph = Memory_graph() + + # 创建海马体 + hippocampus = Hippocampus(memory_graph) + + # 从数据库同步数据 + hippocampus.memory_cortex.sync_memory_from_db() + + end_time = time.time() + logger.info(f"\033[32m[加载海马体耗时: {end_time - start_time:.2f} 秒]\033[0m") + + # 构建记忆 + if test_pare['do_build_memory']: + logger.info("开始构建记忆...") + chat_size = 20 + await hippocampus.operation_build_memory(chat_size=chat_size) + + end_time = time.time() + logger.info(f"\033[32m[构建记忆耗时: {end_time - start_time:.2f} 秒,chat_size={chat_size},chat_count = 16]\033[0m") + + if test_pare['do_forget_topic']: + logger.info("开始遗忘记忆...") + await hippocampus.operation_forget_topic(percentage=0.01) + + end_time = time.time() + logger.info(f"\033[32m[遗忘记忆耗时: {end_time - start_time:.2f} 秒]\033[0m") + + if test_pare['do_merge_memory']: + logger.info("开始合并记忆...") + await hippocampus.operation_merge_memory(percentage=0.1) + + end_time = time.time() + logger.info(f"\033[32m[合并记忆耗时: {end_time - start_time:.2f} 秒]\033[0m") + + if test_pare['do_visualize_graph']: + # 展示优化后的图形 + logger.info("生成记忆图谱可视化...") + print("\n生成优化后的记忆图谱:") + visualize_graph_lite(memory_graph) + + if test_pare['do_query']: + # 交互式查询 + while True: + query = input("\n请输入新的查询概念(输入'退出'以结束):") + if query.lower() == '退出': + break + + items_list = memory_graph.get_related_item(query) + if items_list: + first_layer, second_layer = items_list + if first_layer: + print("\n直接相关的记忆:") + for item in first_layer: + print(f"- {item}") + if second_layer: + print("\n间接相关的记忆:") + for item in second_layer: + print(f"- {item}") + else: + print("未找到相关记忆。") + + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) + + From 7899e67cb24d87638ea24e9573ff774c9f15fa86 Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 01:15:32 +0800 Subject: [PATCH 031/162] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=BC=80=E5=A7=8B=E6=B5=8B=E8=AF=95debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 107 +++------- src/plugins/chat/chat_stream.py | 147 ++++++------- src/plugins/chat/cq_code.py | 251 +++++++++++------------ src/plugins/chat/emoji_manager.py | 8 +- src/plugins/chat/llm_generator.py | 153 +++++++++----- src/plugins/chat/message.py | 77 ++++--- src/plugins/chat/message_base.py | 33 ++- src/plugins/chat/message_cq.py | 23 +-- src/plugins/chat/message_sender.py | 9 +- src/plugins/chat/prompt_builder.py | 33 ++- src/plugins/chat/relationship_manager.py | 159 ++------------ src/plugins/chat/storage.py | 3 +- src/plugins/chat/utils.py | 55 ++--- 13 files changed, 486 insertions(+), 572 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 17aa0630d..a2fdab873 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -1,6 +1,5 @@ import time from random import random - from loguru import logger from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent @@ -11,25 +10,18 @@ from .cq_code import CQCode,cq_code_tool # 导入CQCode模块 from .emoji_manager import emoji_manager # 导入表情包管理器 from .llm_generator import ResponseGenerator from .message import MessageSending, MessageRecv, MessageThinking, MessageSet -from .message import MessageSending, MessageRecv, MessageThinking, MessageSet from .message_cq import ( MessageRecvCQ, - MessageSendCQ, -) -from .chat_stream import chat_manager - MessageRecvCQ, - MessageSendCQ, ) from .chat_stream import chat_manager + from .message_sender import message_manager # 导入新的消息管理器 from .relationship_manager import relationship_manager from .storage import MessageStorage from .utils import calculate_typing_time, is_mentioned_bot_in_txt from .utils_image import image_path_to_base64 -from .utils_image import image_path_to_base64 from .willing_manager import willing_manager # 导入意愿管理器 from .message_base import UserInfo, GroupInfo, Seg -from .message_base import UserInfo, GroupInfo, Seg class ChatBot: def __init__(self): @@ -53,24 +45,21 @@ class ChatBot: self.bot = bot # 更新 bot 实例 - - - group_info = await bot.get_group_info(group_id=event.group_id) sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) - - await relationship_manager.update_relationship(user_id = event.user_id, data = sender_info) - await relationship_manager.update_relationship_value(user_id = event.user_id, relationship_value = 0.5) - - message_cq=MessageRecvCQ( + + # 白名单设定由nontbot侧完成 + if event.group_id: + if event.group_id not in global_config.talk_allowed_groups: + return + if event.user_id in global_config.ban_user_id: + return + message_cq=MessageRecvCQ( message_id=event.message_id, user_id=event.user_id, raw_message=str(event.original_message), group_id=event.group_id, - user_id=event.user_id, - raw_message=str(event.original_message), - group_id=event.group_id, reply_message=event.reply, platform='qq' ) @@ -78,37 +67,26 @@ class ChatBot: # 进入maimbot message=MessageRecv(**message_json) - await message.process() + groupinfo=message.message_info.group_info userinfo=message.message_info.user_info messageinfo=message.message_info - chat = await chat_manager.get_or_create_stream(platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo) # 消息过滤,涉及到config有待更新 - if groupinfo: - if groupinfo.group_id not in global_config.talk_allowed_groups: - return - else: - if userinfo: - if userinfo.user_id in []: - pass - else: - return - else: - return - if userinfo.user_id in global_config.ban_user_id: - return + + chat = await chat_manager.get_or_create_stream(platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo) + await relationship_manager.update_relationship(chat_stream=chat,) + await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value = 0.5) + + await message.process() # 过滤词 for word in global_config.ban_words: - if word in message.processed_plain_text: - logger.info(f"\033[1;32m[{groupinfo.group_name}]{userinfo.user_nickname}:\033[0m {message.processed_plain_text}") if word in message.processed_plain_text: logger.info(f"\033[1;32m[{groupinfo.group_name}]{userinfo.user_nickname}:\033[0m {message.processed_plain_text}") logger.info(f"\033[1;32m[过滤词识别]\033[0m 消息中含有{word},filtered") return current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) - current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) @@ -130,20 +108,13 @@ class ChatBot: is_emoji=message.is_emoji, interested_rate=interested_rate ) - current_willing = willing_manager.get_willing( - chat_stream=chat - ) + current_willing = willing_manager.get_willing(chat_stream=chat) print(f"\033[1;32m[{current_time}][{chat.group_info.group_name}]{chat.user_info.user_nickname}:\033[0m {message.processed_plain_text}\033[1;36m[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]\033[0m") response = None if random() < reply_probability: - bot_user_info=UserInfo( - user_id=global_config.BOT_QQ, - user_nickname=global_config.BOT_NICKNAME, - platform=messageinfo.platform - ) bot_user_info=UserInfo( user_id=global_config.BOT_QQ, user_nickname=global_config.BOT_NICKNAME, @@ -151,22 +122,16 @@ class ChatBot: ) tinking_time_point = round(time.time(), 2) think_id = 'mt' + str(tinking_time_point) - thinking_message = MessageThinking.from_chat_stream( - chat_stream=chat, + thinking_message = MessageThinking( message_id=think_id, - reply=message - ) - thinking_message = MessageThinking.from_chat_stream( chat_stream=chat, - message_id=think_id, + bot_user_info=bot_user_info, reply=message ) message_manager.add_message(thinking_message) - willing_manager.change_reply_willing_sent( - chat_stream=chat - ) + willing_manager.change_reply_willing_sent(chat) response,raw_content = await self.gpt.generate_response(message) @@ -201,18 +166,11 @@ class ChatBot: accu_typing_time += typing_time timepoint = tinking_time_point + accu_typing_time - message_segment = Seg(type='text', data=msg) - bot_message = MessageSending( message_segment = Seg(type='text', data=msg) bot_message = MessageSending( message_id=think_id, chat_stream=chat, - message_segment=message_segment, - reply=message, - is_head=not mark_head, - is_emoji=False - ) - chat_stream=chat, + bot_user_info=bot_user_info, message_segment=message_segment, reply=message, is_head=not mark_head, @@ -235,7 +193,6 @@ class ChatBot: if emoji_raw != None: emoji_path,discription = emoji_raw - emoji_cq = image_path_to_base64(emoji_path) emoji_cq = image_path_to_base64(emoji_path) if random() < 0.5: @@ -247,15 +204,7 @@ class ChatBot: bot_message = MessageSending( message_id=think_id, chat_stream=chat, - message_segment=message_segment, - reply=message, - is_head=False, - is_emoji=True - ) - message_segment = Seg(type='emoji', data=emoji_cq) - bot_message = MessageSending( - message_id=think_id, - chat_stream=chat, + bot_user_info=bot_user_info, message_segment=message_segment, reply=message, is_head=False, @@ -273,20 +222,12 @@ class ChatBot: 'fearful': -0.7, 'neutral': 0.1 } - await relationship_manager.update_relationship_value(message.user_id, relationship_value=valuedict[emotion[0]]) + await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value=valuedict[emotion[0]]) # 使用情绪管理器更新情绪 self.mood_manager.update_mood_from_emotion(emotion[0], global_config.mood_intensity_factor) willing_manager.change_reply_willing_after_sent( - platform=messageinfo.platform, - user_info=userinfo, - group_info=groupinfo - ) - - willing_manager.change_reply_willing_after_sent( - platform=messageinfo.platform, - user_info=userinfo, - group_info=groupinfo + chat_stream=chat ) # 创建全局ChatBot实例 diff --git a/src/plugins/chat/chat_stream.py b/src/plugins/chat/chat_stream.py index e617054ec..36c97bed0 100644 --- a/src/plugins/chat/chat_stream.py +++ b/src/plugins/chat/chat_stream.py @@ -1,55 +1,67 @@ -import time import asyncio -from typing import Optional, Dict, Tuple import hashlib +import time +from typing import Dict, Optional from loguru import logger + from ...common.database import Database -from .message_base import UserInfo, GroupInfo +from .message_base import GroupInfo, UserInfo class ChatStream: """聊天流对象,存储一个完整的聊天上下文""" - def __init__(self, - stream_id: str, - platform: str, - user_info: UserInfo, - group_info: Optional[GroupInfo] = None, - data: dict = None): + + def __init__( + self, + stream_id: str, + platform: str, + user_info: UserInfo, + group_info: Optional[GroupInfo] = None, + data: dict = None, + ): self.stream_id = stream_id self.platform = platform self.user_info = user_info self.group_info = group_info - self.create_time = data.get('create_time', int(time.time())) if data else int(time.time()) - self.last_active_time = data.get('last_active_time', self.create_time) if data else self.create_time + self.create_time = ( + data.get("create_time", int(time.time())) if data else int(time.time()) + ) + self.last_active_time = ( + data.get("last_active_time", self.create_time) if data else self.create_time + ) self.saved = False - + def to_dict(self) -> dict: """转换为字典格式""" result = { - 'stream_id': self.stream_id, - 'platform': self.platform, - 'user_info': self.user_info.to_dict() if self.user_info else None, - 'group_info': self.group_info.to_dict() if self.group_info else None, - 'create_time': self.create_time, - 'last_active_time': self.last_active_time + "stream_id": self.stream_id, + "platform": self.platform, + "user_info": self.user_info.to_dict() if self.user_info else None, + "group_info": self.group_info.to_dict() if self.group_info else None, + "create_time": self.create_time, + "last_active_time": self.last_active_time, } return result - + @classmethod - def from_dict(cls, data: dict) -> 'ChatStream': + def from_dict(cls, data: dict) -> "ChatStream": """从字典创建实例""" - user_info = UserInfo(**data.get('user_info', {})) if data.get('user_info') else None - group_info = GroupInfo(**data.get('group_info', {})) if data.get('group_info') else None - + user_info = ( + UserInfo(**data.get("user_info", {})) if data.get("user_info") else None + ) + group_info = ( + GroupInfo(**data.get("group_info", {})) if data.get("group_info") else None + ) + return cls( - stream_id=data['stream_id'], - platform=data['platform'], + stream_id=data["stream_id"], + platform=data["platform"], user_info=user_info, group_info=group_info, - data=data + data=data, ) - + def update_active_time(self): """更新最后活跃时间""" self.last_active_time = int(time.time()) @@ -58,14 +70,15 @@ class ChatStream: class ChatManager: """聊天管理器,管理所有聊天流""" + _instance = None _initialized = False - + def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance - + def __init__(self): if not self._initialized: self.streams: Dict[str, ChatStream] = {} # stream_id -> ChatStream @@ -76,7 +89,7 @@ class ChatManager: asyncio.create_task(self._initialize()) # 启动自动保存任务 asyncio.create_task(self._auto_save_task()) - + async def _initialize(self): """异步初始化""" try: @@ -84,7 +97,7 @@ class ChatManager: logger.success(f"聊天管理器已启动,已加载 {len(self.streams)} 个聊天流") except Exception as e: logger.error(f"聊天管理器启动失败: {str(e)}") - + async def _auto_save_task(self): """定期自动保存所有聊天流""" while True: @@ -94,49 +107,48 @@ class ChatManager: logger.info("聊天流自动保存完成") except Exception as e: logger.error(f"聊天流自动保存失败: {str(e)}") - + def _ensure_collection(self): """确保数据库集合存在并创建索引""" - if 'chat_streams' not in self.db.db.list_collection_names(): - self.db.db.create_collection('chat_streams') + if "chat_streams" not in self.db.db.list_collection_names(): + self.db.db.create_collection("chat_streams") # 创建索引 - self.db.db.chat_streams.create_index([('stream_id', 1)], unique=True) - self.db.db.chat_streams.create_index([ - ('platform', 1), - ('user_info.user_id', 1), - ('group_info.group_id', 1) - ]) - - def _generate_stream_id(self, platform: str, user_info: UserInfo, group_info: Optional[GroupInfo] = None) -> str: + self.db.db.chat_streams.create_index([("stream_id", 1)], unique=True) + self.db.db.chat_streams.create_index( + [("platform", 1), ("user_info.user_id", 1), ("group_info.group_id", 1)] + ) + + def _generate_stream_id( + self, platform: str, user_info: UserInfo, group_info: Optional[GroupInfo] = None + ) -> str: """生成聊天流唯一ID""" # 组合关键信息 components = [ platform, str(user_info.user_id), - str(group_info.group_id) if group_info else 'private' + str(group_info.group_id) if group_info else "private", ] - + # 使用MD5生成唯一ID - key = '_'.join(components) + key = "_".join(components) return hashlib.md5(key.encode()).hexdigest() - - async def get_or_create_stream(self, - platform: str, - user_info: UserInfo, - group_info: Optional[GroupInfo] = None) -> ChatStream: + + async def get_or_create_stream( + self, platform: str, user_info: UserInfo, group_info: Optional[GroupInfo] = None + ) -> ChatStream: """获取或创建聊天流 - + Args: platform: 平台标识 user_info: 用户信息 group_info: 群组信息(可选) - + Returns: ChatStream: 聊天流对象 """ # 生成stream_id stream_id = self._generate_stream_id(platform, user_info, group_info) - + # 检查内存中是否存在 if stream_id in self.streams: stream = self.streams[stream_id] @@ -146,9 +158,9 @@ class ChatManager: stream.group_info = group_info stream.update_active_time() return stream - + # 检查数据库中是否存在 - data = self.db.db.chat_streams.find_one({'stream_id': stream_id}) + data = self.db.db.chat_streams.find_one({"stream_id": stream_id}) if data: stream = ChatStream.from_dict(data) # 更新用户信息和群组信息 @@ -162,41 +174,38 @@ class ChatManager: stream_id=stream_id, platform=platform, user_info=user_info, - group_info=group_info + group_info=group_info, ) - + # 保存到内存和数据库 self.streams[stream_id] = stream await self._save_stream(stream) return stream - + def get_stream(self, stream_id: str) -> Optional[ChatStream]: """通过stream_id获取聊天流""" return self.streams.get(stream_id) - - def get_stream_by_info(self, - platform: str, - user_info: UserInfo, - group_info: Optional[GroupInfo] = None) -> Optional[ChatStream]: + + def get_stream_by_info( + self, platform: str, user_info: UserInfo, group_info: Optional[GroupInfo] = None + ) -> Optional[ChatStream]: """通过信息获取聊天流""" stream_id = self._generate_stream_id(platform, user_info, group_info) return self.streams.get(stream_id) - + async def _save_stream(self, stream: ChatStream): """保存聊天流到数据库""" if not stream.saved: self.db.db.chat_streams.update_one( - {'stream_id': stream.stream_id}, - {'$set': stream.to_dict()}, - upsert=True + {"stream_id": stream.stream_id}, {"$set": stream.to_dict()}, upsert=True ) stream.saved = True - + async def _save_all_streams(self): """保存所有聊天流""" for stream in self.streams.values(): await self._save_stream(stream) - + async def load_all_streams(self): """从数据库加载所有聊天流""" all_streams = self.db.db.chat_streams.find({}) diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index b29e25b4c..7581f8a33 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -3,23 +3,22 @@ import html import os import time from dataclasses import dataclass -from typing import Dict, Optional, List, Union +from typing import Dict, List, Optional, Union import requests # 解析各种CQ码 # 包含CQ码类 import urllib3 +from loguru import logger from nonebot import get_driver from urllib3.util import create_urllib3_context -from loguru import logger from ..models.utils_model import LLM_request from .config import global_config from .mapper import emojimapper -from .utils_image import image_manager -from .utils_user import get_user_nickname from .message_base import Seg +from .utils_user import get_user_nickname driver = get_driver() config = driver.config @@ -37,21 +36,25 @@ class TencentSSLAdapter(requests.adapters.HTTPAdapter): def init_poolmanager(self, connections, maxsize, block=False): self.poolmanager = urllib3.poolmanager.PoolManager( - num_pools=connections, maxsize=maxsize, - block=block, ssl_context=self.ssl_context) + num_pools=connections, + maxsize=maxsize, + block=block, + ssl_context=self.ssl_context, + ) @dataclass class CQCode: """ CQ码数据类,用于存储和处理CQ码 - + 属性: type: CQ码类型(如'image', 'at', 'face'等) params: CQ码的参数字典 raw_code: 原始CQ码字符串 translated_segments: 经过处理后的Seg对象列表 """ + type: str params: Dict[str, str] group_id: int @@ -65,77 +68,52 @@ class CQCode: def __post_init__(self): """初始化LLM实例""" - self._llm = LLM_request(model=global_config.vlm, temperature=0.4, max_tokens=300) + self._llm = LLM_request( + model=global_config.vlm, temperature=0.4, max_tokens=300 + ) def translate(self): """根据CQ码类型进行相应的翻译处理,转换为Seg对象""" - if self.type == 'text': + if self.type == "text": self.translated_segments = Seg( - type='text', - data=self.params.get('text', '') + type="text", data=self.params.get("text", "") ) - elif self.type == 'image': + elif self.type == "image": base64_data = self.translate_image() if base64_data: - if self.params.get('sub_type') == '0': - self.translated_segments = Seg( - type='image', - data=base64_data - ) + if self.params.get("sub_type") == "0": + self.translated_segments = Seg(type="image", data=base64_data) else: - self.translated_segments = Seg( - type='emoji', - data=base64_data - ) + self.translated_segments = Seg(type="emoji", data=base64_data) else: - self.translated_segments = Seg( - type='text', - data='[图片]' - ) - elif self.type == 'at': - user_nickname = get_user_nickname(self.params.get('qq', '')) + self.translated_segments = Seg(type="text", data="[图片]") + elif self.type == "at": + user_nickname = get_user_nickname(self.params.get("qq", "")) self.translated_segments = Seg( - type='text', - data=f"[@{user_nickname or '某人'}]" + type="text", data=f"[@{user_nickname or '某人'}]" ) - elif self.type == 'reply': + elif self.type == "reply": reply_segments = self.translate_reply() if reply_segments: - self.translated_segments = Seg( - type='seglist', - data=reply_segments - ) + self.translated_segments = Seg(type="seglist", data=reply_segments) else: - self.translated_segments = Seg( - type='text', - data='[回复某人消息]' - ) - elif self.type == 'face': - face_id = self.params.get('id', '') + self.translated_segments = Seg(type="text", data="[回复某人消息]") + elif self.type == "face": + face_id = self.params.get("id", "") self.translated_segments = Seg( - type='text', - data=f"[{emojimapper.get(int(face_id), '表情')}]" + type="text", data=f"[{emojimapper.get(int(face_id), '表情')}]" ) - elif self.type == 'forward': + elif self.type == "forward": forward_segments = self.translate_forward() if forward_segments: - self.translated_segments = Seg( - type='seglist', - data=forward_segments - ) + self.translated_segments = Seg(type="seglist", data=forward_segments) else: - self.translated_segments = Seg( - type='text', - data='[转发消息]' - ) + self.translated_segments = Seg(type="text", data="[转发消息]") else: - self.translated_segments = Seg( - type='text', - data=f"[{self.type}]" - ) + self.translated_segments = Seg(type="text", data=f"[{self.type}]") def get_img(self): - ''' + """ headers = { 'User-Agent': 'QQ/8.9.68.11565 CFNetwork/1220.1 Darwin/20.3.0', 'Accept': 'image/*;q=0.8', @@ -144,18 +122,18 @@ class CQCode: 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' } - ''' + """ # 腾讯专用请求头配置 headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.87 Safari/537.36', - 'Accept': 'text/html, application/xhtml xml, */*', - 'Accept-Encoding': 'gbk, GB2312', - 'Accept-Language': 'zh-cn', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Cache-Control': 'no-cache' + "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.87 Safari/537.36", + "Accept": "text/html, application/xhtml xml, */*", + "Accept-Encoding": "gbk, GB2312", + "Accept-Language": "zh-cn", + "Content-Type": "application/x-www-form-urlencoded", + "Cache-Control": "no-cache", } - url = html.unescape(self.params['url']) - if not url.startswith(('http://', 'https://')): + url = html.unescape(self.params["url"]) + if not url.startswith(("http://", "https://")): return None # 创建专用会话 @@ -171,30 +149,30 @@ class CQCode: headers=headers, timeout=15, allow_redirects=True, - stream=True # 流式传输避免大内存问题 + stream=True, # 流式传输避免大内存问题 ) # 腾讯服务器特殊状态码处理 - if response.status_code == 400 and 'multimedia.nt.qq.com.cn' in url: + if response.status_code == 400 and "multimedia.nt.qq.com.cn" in url: return None if response.status_code != 200: raise requests.exceptions.HTTPError(f"HTTP {response.status_code}") # 验证内容类型 - content_type = response.headers.get('Content-Type', '') - if not content_type.startswith('image/'): + content_type = response.headers.get("Content-Type", "") + if not content_type.startswith("image/"): raise ValueError(f"非图片内容类型: {content_type}") # 转换为Base64 - image_base64 = base64.b64encode(response.content).decode('utf-8') + image_base64 = base64.b64encode(response.content).decode("utf-8") self.image_base64 = image_base64 return image_base64 except (requests.exceptions.SSLError, requests.exceptions.HTTPError) as e: if retry == max_retries - 1: print(f"\033[1;31m[致命错误]\033[0m 最终请求失败: {str(e)}") - time.sleep(1.5 ** retry) # 指数退避 + time.sleep(1.5**retry) # 指数退避 except Exception as e: print(f"\033[1;33m[未知错误]\033[0m {str(e)}") @@ -202,21 +180,21 @@ class CQCode: return None - def translate_image(self) -> Optional[str]: """处理图片类型的CQ码,返回base64字符串""" - if 'url' not in self.params: + if "url" not in self.params: return None return self.get_img() def translate_forward(self) -> Optional[List[Seg]]: """处理转发消息,返回Seg列表""" try: - if 'content' not in self.params: + if "content" not in self.params: return None - content = self.unescape(self.params['content']) + content = self.unescape(self.params["content"]) import ast + try: messages = ast.literal_eval(content) except ValueError as e: @@ -225,46 +203,52 @@ class CQCode: formatted_segments = [] for msg in messages: - sender = msg.get('sender', {}) - nickname = sender.get('card') or sender.get('nickname', '未知用户') - raw_message = msg.get('raw_message', '') - message_array = msg.get('message', []) + sender = msg.get("sender", {}) + nickname = sender.get("card") or sender.get("nickname", "未知用户") + raw_message = msg.get("raw_message", "") + message_array = msg.get("message", []) if message_array and isinstance(message_array, list): for message_part in message_array: - if message_part.get('type') == 'forward': - content_seg = Seg(type='text', data='[转发消息]') + if message_part.get("type") == "forward": + content_seg = Seg(type="text", data="[转发消息]") break else: if raw_message: from .message_cq import MessageRecvCQ + message_obj = MessageRecvCQ( - user_id=msg.get('user_id', 0), - message_id=msg.get('message_id', 0), + user_id=msg.get("user_id", 0), + message_id=msg.get("message_id", 0), raw_message=raw_message, plain_text=raw_message, - group_id=msg.get('group_id', 0) + group_id=msg.get("group_id", 0), + ) + content_seg = Seg( + type="seglist", data=message_obj.message_segments ) - content_seg = Seg(type='seglist', data=message_obj.message_segments) else: - content_seg = Seg(type='text', data='[空消息]') + content_seg = Seg(type="text", data="[空消息]") else: if raw_message: from .message_cq import MessageRecvCQ + message_obj = MessageRecvCQ( - user_id=msg.get('user_id', 0), - message_id=msg.get('message_id', 0), + user_id=msg.get("user_id", 0), + message_id=msg.get("message_id", 0), raw_message=raw_message, plain_text=raw_message, - group_id=msg.get('group_id', 0) + group_id=msg.get("group_id", 0), + ) + content_seg = Seg( + type="seglist", data=message_obj.message_segments ) - content_seg = Seg(type='seglist', data=message_obj.message_segments) else: - content_seg = Seg(type='text', data='[空消息]') + content_seg = Seg(type="text", data="[空消息]") - formatted_segments.append(Seg(type='text', data=f"{nickname}: ")) + formatted_segments.append(Seg(type="text", data=f"{nickname}: ")) formatted_segments.append(content_seg) - formatted_segments.append(Seg(type='text', data='\n')) + formatted_segments.append(Seg(type="text", data="\n")) return formatted_segments @@ -275,6 +259,7 @@ class CQCode: def translate_reply(self) -> Optional[List[Seg]]: """处理回复类型的CQ码,返回Seg列表""" from .message_cq import MessageRecvCQ + if self.reply_message is None: return None @@ -283,17 +268,26 @@ class CQCode: user_id=self.reply_message.sender.user_id, message_id=self.reply_message.message_id, raw_message=str(self.reply_message.message), - group_id=self.group_id + group_id=self.group_id, ) - + segments = [] if message_obj.user_id == global_config.BOT_QQ: - segments.append(Seg(type='text', data=f"[回复 {global_config.BOT_NICKNAME} 的消息: ")) + segments.append( + Seg( + type="text", data=f"[回复 {global_config.BOT_NICKNAME} 的消息: " + ) + ) else: - segments.append(Seg(type='text', data=f"[回复 {self.reply_message.sender.nickname} 的消息: ")) - - segments.append(Seg(type='seglist', data=message_obj.message_segments)) - segments.append(Seg(type='text', data="]")) + segments.append( + Seg( + type="text", + data=f"[回复 {self.reply_message.sender.nickname} 的消息: ", + ) + ) + + segments.append(Seg(type="seglist", data=message_obj.message_segments)) + segments.append(Seg(type="text", data="]")) return segments else: return None @@ -301,12 +295,12 @@ class CQCode: @staticmethod def unescape(text: str) -> str: """反转义CQ码中的特殊字符""" - return text.replace(',', ',') \ - .replace('[', '[') \ - .replace(']', ']') \ - .replace('&', '&') - - + return ( + text.replace(",", ",") + .replace("[", "[") + .replace("]", "]") + .replace("&", "&") + ) class CQCode_tool: @@ -314,29 +308,25 @@ class CQCode_tool: def cq_from_dict_to_class(cq_code: Dict, reply: Optional[Dict] = None) -> CQCode: """ 将CQ码字典转换为CQCode对象 - + Args: cq_code: CQ码字典 reply: 回复消息的字典(可选) - + Returns: CQCode对象 """ # 处理字典形式的CQ码 # 从cq_code字典中获取type字段的值,如果不存在则默认为'text' - cq_type = cq_code.get('type', 'text') + cq_type = cq_code.get("type", "text") params = {} - if cq_type == 'text': - params['text'] = cq_code.get('data', {}).get('text', '') + if cq_type == "text": + params["text"] = cq_code.get("data", {}).get("text", "") else: - params = cq_code.get('data', {}) + params = cq_code.get("data", {}) instance = CQCode( - type=cq_type, - params=params, - group_id=0, - user_id=0, - reply_message=reply + type=cq_type, params=params, group_id=0, user_id=0, reply_message=reply ) # 进行翻译处理 @@ -353,7 +343,7 @@ class CQCode_tool: 回复CQ码字符串 """ return f"[CQ:reply,id={message_id}]" - + @staticmethod def create_emoji_cq(file_path: str) -> str: """ @@ -366,13 +356,15 @@ class CQCode_tool: # 确保使用绝对路径 abs_path = os.path.abspath(file_path) # 转义特殊字符 - escaped_path = abs_path.replace('&', '&') \ - .replace('[', '[') \ - .replace(']', ']') \ - .replace(',', ',') + escaped_path = ( + abs_path.replace("&", "&") + .replace("[", "[") + .replace("]", "]") + .replace(",", ",") + ) # 生成CQ码,设置sub_type=1表示这是表情包 return f"[CQ:image,file=file:///{escaped_path},sub_type=1]" - + @staticmethod def create_emoji_cq_base64(base64_data: str) -> str: """ @@ -383,15 +375,14 @@ class CQCode_tool: 表情包CQ码字符串 """ # 转义base64数据 - escaped_base64 = base64_data.replace('&', '&') \ - .replace('[', '[') \ - .replace(']', ']') \ - .replace(',', ',') + escaped_base64 = ( + base64_data.replace("&", "&") + .replace("[", "[") + .replace("]", "]") + .replace(",", ",") + ) # 生成CQ码,设置sub_type=1表示这是表情包 return f"[CQ:image,file=base64://{escaped_base64},sub_type=1]" - - - cq_code_tool = CQCode_tool() diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 3432f011c..837ee245d 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -1,11 +1,11 @@ import asyncio +import base64 +import hashlib import os import random import time import traceback from typing import Optional, Tuple -import base64 -import hashlib from loguru import logger from nonebot import get_driver @@ -13,9 +13,8 @@ from nonebot import get_driver from ...common.database import Database from ..chat.config import global_config from ..chat.utils import get_embedding -from ..chat.utils_image import image_path_to_base64 +from ..chat.utils_image import ImageManager, image_path_to_base64 from ..models.utils_model import LLM_request -from ..chat.utils_image import ImageManager driver = get_driver() config = driver.config @@ -78,7 +77,6 @@ class EmojiManager: if 'emoji' not in self.db.db.list_collection_names(): self.db.db.create_collection('emoji') self.db.db.emoji.create_index([('embedding', '2dsphere')]) - self.db.db.emoji.create_index([('tags', 1)]) self.db.db.emoji.create_index([('filename', 1)], unique=True) def record_usage(self, emoji_id: str): diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index 2803e5a14..dc019038e 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -7,7 +7,7 @@ from nonebot import get_driver from ...common.database import Database from ..models.utils_model import LLM_request from .config import global_config -from .message_cq import Message +from .message import MessageRecv, MessageThinking, MessageSending from .prompt_builder import prompt_builder from .relationship_manager import relationship_manager from .utils import process_llm_response @@ -18,58 +18,88 @@ config = driver.config class ResponseGenerator: def __init__(self): - self.model_r1 = LLM_request(model=global_config.llm_reasoning, temperature=0.7,max_tokens=1000,stream=True) - self.model_v3 = LLM_request(model=global_config.llm_normal, temperature=0.7,max_tokens=1000) - self.model_r1_distill = LLM_request(model=global_config.llm_reasoning_minor, temperature=0.7,max_tokens=1000) - self.model_v25 = LLM_request(model=global_config.llm_normal_minor, temperature=0.7,max_tokens=1000) + self.model_r1 = LLM_request( + model=global_config.llm_reasoning, + temperature=0.7, + max_tokens=1000, + stream=True, + ) + self.model_v3 = LLM_request( + model=global_config.llm_normal, temperature=0.7, max_tokens=1000 + ) + self.model_r1_distill = LLM_request( + model=global_config.llm_reasoning_minor, temperature=0.7, max_tokens=1000 + ) + self.model_v25 = LLM_request( + model=global_config.llm_normal_minor, temperature=0.7, max_tokens=1000 + ) self.db = Database.get_instance() - self.current_model_type = 'r1' # 默认使用 R1 + self.current_model_type = "r1" # 默认使用 R1 - async def generate_response(self, message: Message) -> Optional[Union[str, List[str]]]: + async def generate_response( + self, message: MessageThinking + ) -> Optional[Union[str, List[str]]]: """根据当前模型类型选择对应的生成函数""" # 从global_config中获取模型概率值并选择模型 rand = random.random() if rand < global_config.MODEL_R1_PROBABILITY: - self.current_model_type = 'r1' + self.current_model_type = "r1" current_model = self.model_r1 - elif rand < global_config.MODEL_R1_PROBABILITY + global_config.MODEL_V3_PROBABILITY: - self.current_model_type = 'v3' + elif ( + rand + < global_config.MODEL_R1_PROBABILITY + global_config.MODEL_V3_PROBABILITY + ): + self.current_model_type = "v3" current_model = self.model_v3 else: - self.current_model_type = 'r1_distill' + self.current_model_type = "r1_distill" current_model = self.model_r1_distill - print(f"+++++++++++++++++{global_config.BOT_NICKNAME}{self.current_model_type}思考中+++++++++++++++++") - - model_response = await self._generate_response_with_model(message, current_model) - raw_content=model_response - + print( + f"+++++++++++++++++{global_config.BOT_NICKNAME}{self.current_model_type}思考中+++++++++++++++++" + ) + + model_response = await self._generate_response_with_model( + message, current_model + ) + raw_content = model_response + if model_response: - print(f'{global_config.BOT_NICKNAME}的回复是:{model_response}') + print(f"{global_config.BOT_NICKNAME}的回复是:{model_response}") model_response = await self._process_response(model_response) if model_response: + return model_response, raw_content + return None, raw_content - return model_response ,raw_content - return None,raw_content - - async def _generate_response_with_model(self, message: Message, model: LLM_request) -> Optional[str]: + async def _generate_response_with_model( + self, message: MessageThinking, model: LLM_request + ) -> Optional[str]: """使用指定的模型生成回复""" - sender_name = message.user_nickname or f"用户{message.user_id}" - if message.user_cardname: - sender_name=f"[({message.user_id}){message.user_nickname}]{message.user_cardname}" - + sender_name = ( + message.chat_stream.user_info.user_nickname + or f"用户{message.chat_stream.user_info.user_id}" + ) + if message.chat_stream.user_info.user_cardname: + sender_name = f"[({message.chat_stream.user_info.user_id}){message.chat_stream.user_info.user_nickname}]{message.chat_stream.user_info.user_cardname}" + # 获取关系值 - relationship_value = relationship_manager.get_relationship(message.user_id).relationship_value if relationship_manager.get_relationship(message.user_id) else 0.0 + relationship_value = ( + relationship_manager.get_relationship( + message.chat_stream + ).relationship_value + if relationship_manager.get_relationship(message.chat_stream) + else 0.0 + ) if relationship_value != 0.0: # print(f"\033[1;32m[关系管理]\033[0m 回复中_当前关系值: {relationship_value}") pass - + # 构建prompt prompt, prompt_check = await prompt_builder._build_prompt( message_txt=message.processed_plain_text, sender_name=sender_name, relationship_value=relationship_value, - group_id=message.group_id + stream_id=message.chat_stream.stream_id, ) # 读空气模块 简化逻辑,先停用 @@ -95,7 +125,7 @@ class ResponseGenerator: except Exception as e: print(f"生成回复时出错: {e}") return None - + # 保存到数据库 self._save_to_db( message=message, @@ -107,54 +137,71 @@ class ResponseGenerator: reasoning_content=reasoning_content, # reasoning_content_check=reasoning_content_check if global_config.enable_kuuki_read else "" ) - + return content # def _save_to_db(self, message: Message, sender_name: str, prompt: str, prompt_check: str, # content: str, content_check: str, reasoning_content: str, reasoning_content_check: str): - def _save_to_db(self, message: Message, sender_name: str, prompt: str, prompt_check: str, - content: str, reasoning_content: str,): + def _save_to_db( + self, + message: Message, + sender_name: str, + prompt: str, + prompt_check: str, + content: str, + reasoning_content: str, + ): """保存对话记录到数据库""" - self.db.db.reasoning_logs.insert_one({ - 'time': time.time(), - 'group_id': message.group_id, - 'user': sender_name, - 'message': message.processed_plain_text, - 'model': self.current_model_type, - # 'reasoning_check': reasoning_content_check, - # 'response_check': content_check, - 'reasoning': reasoning_content, - 'response': content, - 'prompt': prompt, - 'prompt_check': prompt_check - }) + self.db.db.reasoning_logs.insert_one( + { + "time": time.time(), + "group_id": message.group_id, + "user": sender_name, + "message": message.processed_plain_text, + "model": self.current_model_type, + # 'reasoning_check': reasoning_content_check, + # 'response_check': content_check, + "reasoning": reasoning_content, + "response": content, + "prompt": prompt, + "prompt_check": prompt_check, + } + ) async def _get_emotion_tags(self, content: str) -> List[str]: """提取情感标签""" try: - prompt = f'''请从以下内容中,从"happy,angry,sad,surprised,disgusted,fearful,neutral"中选出最匹配的1个情感标签并输出 + prompt = f"""请从以下内容中,从"happy,angry,sad,surprised,disgusted,fearful,neutral"中选出最匹配的1个情感标签并输出 只输出标签就好,不要输出其他内容: 内容:{content} 输出: - ''' + """ content, _ = await self.model_v25.generate_response(prompt) - content=content.strip() - if content in ['happy','angry','sad','surprised','disgusted','fearful','neutral']: + content = content.strip() + if content in [ + "happy", + "angry", + "sad", + "surprised", + "disgusted", + "fearful", + "neutral", + ]: return [content] else: return ["neutral"] - + except Exception as e: print(f"获取情感标签时出错: {e}") return ["neutral"] - + async def _process_response(self, content: str) -> Tuple[List[str], List[str]]: """处理响应内容,返回处理后的内容和情感标签""" if not content: return None, [] - + processed_response = process_llm_response(content) - + return processed_response diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index 070241ac1..408937fad 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -5,7 +5,6 @@ from typing import Dict, ForwardRef, List, Optional, Union import urllib3 from loguru import logger -from .utils_user import get_groupname, get_user_cardname, get_user_nickname from .utils_image import image_manager from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase from .chat_stream import ChatStream @@ -108,25 +107,32 @@ class MessageRecv(MessageBase): else f"{user_info.user_nickname}(ta的id:{user_info.user_id})" ) return f"[{time_str}] {name}: {self.processed_plain_text}\n" - -@dataclass -class MessageProcessBase(MessageBase): - """消息处理基类,用于处理中和发送中的消息""" +@dataclass +class Message(MessageBase): + chat_stream: ChatStream=None + reply: Optional['Message'] = None + detailed_plain_text: str = "" + processed_plain_text: str = "" + def __init__( self, message_id: str, + time: int, chat_stream: ChatStream, + user_info: UserInfo, message_segment: Optional[Seg] = None, - reply: Optional['MessageRecv'] = None + reply: Optional['MessageRecv'] = None, + detailed_plain_text: str = "", + processed_plain_text: str = "", ): # 构造基础消息信息 message_info = BaseMessageInfo( platform=chat_stream.platform, message_id=message_id, - time=int(time.time()), + time=time, group_info=chat_stream.group_info, - user_info=chat_stream.user_info + user_info=user_info ) # 调用父类初始化 @@ -136,17 +142,41 @@ class MessageProcessBase(MessageBase): raw_message=None ) - # 处理状态相关属性 - self.thinking_start_time = int(time.time()) - self.thinking_time = 0 - + self.chat_stream = chat_stream # 文本处理相关属性 - self.processed_plain_text = "" - self.detailed_plain_text = "" + self.processed_plain_text = detailed_plain_text + self.detailed_plain_text = processed_plain_text # 回复消息 self.reply = reply + +@dataclass +class MessageProcessBase(Message): + """消息处理基类,用于处理中和发送中的消息""" + + def __init__( + self, + message_id: str, + chat_stream: ChatStream, + bot_user_info: UserInfo, + message_segment: Optional[Seg] = None, + reply: Optional['MessageRecv'] = None + ): + # 调用父类初始化 + super().__init__( + message_id=message_id, + time=int(time.time()), + chat_stream=chat_stream, + user_info=bot_user_info, + message_segment=message_segment, + reply=reply + ) + + # 处理状态相关属性 + self.thinking_start_time = int(time.time()) + self.thinking_time = 0 + def update_thinking_time(self) -> float: """更新思考时间""" self.thinking_time = round(time.time() - self.thinking_start_time, 2) @@ -224,12 +254,14 @@ class MessageThinking(MessageProcessBase): self, message_id: str, chat_stream: ChatStream, + bot_user_info: UserInfo, reply: Optional['MessageRecv'] = None ): # 调用父类初始化 super().__init__( message_id=message_id, chat_stream=chat_stream, + bot_user_info=bot_user_info, message_segment=None, # 思考状态不需要消息段 reply=reply ) @@ -237,15 +269,6 @@ class MessageThinking(MessageProcessBase): # 思考状态特有属性 self.interrupt = False - @classmethod - def from_chat_stream(cls, chat_stream: ChatStream, message_id: str, reply: Optional['MessageRecv'] = None) -> 'MessageThinking': - """从聊天流创建思考状态消息""" - return cls( - message_id=message_id, - chat_stream=chat_stream, - reply=reply - ) - @dataclass class MessageSending(MessageProcessBase): """发送状态的消息类""" @@ -254,6 +277,7 @@ class MessageSending(MessageProcessBase): self, message_id: str, chat_stream: ChatStream, + bot_user_info: UserInfo, message_segment: Seg, reply: Optional['MessageRecv'] = None, is_head: bool = False, @@ -263,6 +287,7 @@ class MessageSending(MessageProcessBase): super().__init__( message_id=message_id, chat_stream=chat_stream, + bot_user_info=bot_user_info, message_segment=message_segment, reply=reply ) @@ -296,10 +321,16 @@ class MessageSending(MessageProcessBase): message_id=thinking.message_info.message_id, chat_stream=thinking.chat_stream, message_segment=message_segment, + bot_user_info=thinking.message_info.user_info, reply=thinking.reply, is_head=is_head, is_emoji=is_emoji ) + + def to_dict(self): + ret= super().to_dict() + ret['mesage_info']['user_info']=self.chat_stream.user_info.to_dict() + return ret @dataclass class MessageSet: diff --git a/src/plugins/chat/message_base.py b/src/plugins/chat/message_base.py index 77694ad8c..7b76403de 100644 --- a/src/plugins/chat/message_base.py +++ b/src/plugins/chat/message_base.py @@ -78,6 +78,21 @@ class GroupInfo: def to_dict(self) -> Dict: """转换为字典格式""" return {k: v for k, v in asdict(self).items() if v is not None} + + def from_dict(cls, data: Dict) -> 'GroupInfo': + """从字典创建GroupInfo实例 + + Args: + data: 包含必要字段的字典 + + Returns: + GroupInfo: 新的实例 + """ + return cls( + platform=data.get('platform'), + group_id=data.get('group_id'), + group_name=data.get('group_name',None) + ) @dataclass class UserInfo: @@ -90,6 +105,22 @@ class UserInfo: def to_dict(self) -> Dict: """转换为字典格式""" return {k: v for k, v in asdict(self).items() if v is not None} + + def from_dict(cls, data: Dict) -> 'UserInfo': + """从字典创建UserInfo实例 + + Args: + data: 包含必要字段的字典 + + Returns: + UserInfo: 新的实例 + """ + return cls( + platform=data.get('platform'), + user_id=data.get('user_id'), + user_nickname=data.get('user_nickname',None), + user_cardname=data.get('user_cardname',None) + ) @dataclass class BaseMessageInfo: @@ -147,7 +178,7 @@ class MessageBase: """ message_info = BaseMessageInfo(**data.get('message_info', {})) message_segment = Seg(**data.get('message_segment', {})) - raw_message = data.get('raw_message') + raw_message = data.get('raw_message',None) return cls( message_info=message_info, message_segment=message_segment, diff --git a/src/plugins/chat/message_cq.py b/src/plugins/chat/message_cq.py index 7d9c6216d..4d7489bbf 100644 --- a/src/plugins/chat/message_cq.py +++ b/src/plugins/chat/message_cq.py @@ -139,26 +139,23 @@ class MessageSendCQ(MessageCQ): def __init__( self, - message_id: int, - user_id: int, - message_segment: Seg, - group_id: Optional[int] = None, - reply_to_message_id: Optional[int] = None, - platform: str = "qq" + data: Dict ): # 调用父类初始化 - super().__init__(message_id, user_id, group_id, platform) + message_info = BaseMessageInfo(**data.get('message_info', {})) + message_segment = Seg(**data.get('message_segment', {})) + super().__init__( + message_info.message_id, + message_info.user_info.user_id, + message_info.group_info.group_id if message_info.group_info else None, + message_info.platform) self.message_segment = message_segment - self.raw_message = self._generate_raw_message(reply_to_message_id) + self.raw_message = self._generate_raw_message() - def _generate_raw_message(self, reply_to_message_id: Optional[int] = None) -> str: + def _generate_raw_message(self, ) -> str: """将Seg对象转换为raw_message""" segments = [] - - # 添加回复消息 - if reply_to_message_id: - segments.append(cq_code_tool.create_reply_cq(reply_to_message_id)) # 处理消息段 if self.message_segment.type == 'seglist': diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 9b1ab66be..ed91b614e 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -29,13 +29,12 @@ class Message_Sender: ) -> None: """发送消息""" if isinstance(message, MessageSending): + message_json = message.to_dict() message_send=MessageSendCQ( - message_id=message.message_id, - user_id=message.message_info.user_info.user_id, - message_segment=message.message_segment, - reply=message.reply + data=message_json ) - if message.message_info.group_info: + + if message_send.message_info.group_info: try: await self._current_bot.send_group_msg( group_id=message.message_info.group_info.group_id, diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index e337cef45..46adc343e 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -8,6 +8,7 @@ from ..moods.moods import MoodManager from ..schedule.schedule_generator import bot_schedule from .config import global_config from .utils import get_embedding, get_recent_group_detailed_plain_text +from .chat_stream import ChatStream, chat_manager class PromptBuilder: @@ -22,7 +23,7 @@ class PromptBuilder: message_txt: str, sender_name: str = "某人", relationship_value: float = 0.0, - group_id: Optional[int] = None) -> tuple[str, str]: + stream_id: Optional[int] = None) -> tuple[str, str]: """构建prompt Args: @@ -72,11 +73,17 @@ class PromptBuilder: print(f"\033[1;32m[知识检索]\033[0m 耗时: {(end_time - start_time):.3f}秒") # 获取聊天上下文 + chat_in_group=True chat_talking_prompt = '' - if group_id: - chat_talking_prompt = get_recent_group_detailed_plain_text(self.db, group_id, limit=global_config.MAX_CONTEXT_SIZE,combine = True) - - chat_talking_prompt = f"以下是群里正在聊天的内容:\n{chat_talking_prompt}" + if stream_id: + chat_talking_prompt = get_recent_group_detailed_plain_text(self.db, stream_id, limit=global_config.MAX_CONTEXT_SIZE,combine = True) + chat_stream=chat_manager.get_stream(stream_id) + if chat_stream.group_info: + chat_talking_prompt = f"以下是群里正在聊天的内容:\n{chat_talking_prompt}" + else: + chat_in_group=False + chat_talking_prompt = f"以下是你正在和{sender_name}私聊的内容:\n{chat_talking_prompt}" + # print(f"\033[1;34m[调试]\033[0m 已从数据库获取群 {group_id} 的消息记录:{chat_talking_prompt}") @@ -112,8 +119,10 @@ class PromptBuilder: #激活prompt构建 activate_prompt = '' - activate_prompt = f"以上是群里正在进行的聊天,{memory_prompt} 现在昵称为 '{sender_name}' 的用户说的:{message_txt}。引起了你的注意,你和他{relation_prompt},{mood_prompt},你想要{relation_prompt_2}。" - + if chat_in_group: + activate_prompt = f"以上是群里正在进行的聊天,{memory_prompt} 现在昵称为 '{sender_name}' 的用户说的:{message_txt}。引起了你的注意,你和ta{relation_prompt},{mood_prompt},你想要{relation_prompt_2}。" + else: + activate_prompt = f"以上是你正在和{sender_name}私聊的内容,{memory_prompt} 现在昵称为 '{sender_name}' 的用户说的:{message_txt}。引起了你的注意,你和ta{relation_prompt},{mood_prompt},你想要{relation_prompt_2}。" #检测机器人相关词汇 bot_keywords = ['人机', 'bot', '机器', '入机', 'robot', '机器人'] is_bot = any(keyword in message_txt.lower() for keyword in bot_keywords) @@ -129,16 +138,20 @@ class PromptBuilder: probability_3 = global_config.PERSONALITY_3 prompt_personality = '' personality_choice = random.random() + if chat_in_group: + prompt_in_group=f"你正在浏览{chat_stream.platform}群" + else: + prompt_in_group=f"你正在{chat_stream.platform}上和{sender_name}私聊" if personality_choice < probability_1: # 第一种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[0]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[0]},{prompt_in_group},{promt_info_prompt}, 现在请你给出日常且口语化的回复,平淡一些,尽量简短一些。{is_bot_prompt} 请注意把握群里的聊天内容,不要刻意突出自身学科背景,不要回复的太有条理,可以有个性。''' elif personality_choice < probability_1 + probability_2: # 第二种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[1]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[1]},{prompt_in_group},{promt_info_prompt}, 现在请你给出日常且口语化的回复,请表现你自己的见解,不要一昧迎合,尽量简短一些。{is_bot_prompt} 请你表达自己的见解和观点。可以有个性。''' else: # 第三种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[2]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[2]},{prompt_in_group},{promt_info_prompt}, 现在请你给出日常且口语化的回复,请表现你自己的见解,不要一昧迎合,尽量简短一些。{is_bot_prompt} 请你表达自己的见解和观点。可以有个性。''' diff --git a/src/plugins/chat/relationship_manager.py b/src/plugins/chat/relationship_manager.py index 17fc2ac6a..c08b962ed 100644 --- a/src/plugins/chat/relationship_manager.py +++ b/src/plugins/chat/relationship_manager.py @@ -16,17 +16,16 @@ class Impression: class Relationship: user_id: int = None platform: str = None - platform: str = None gender: str = None age: int = None nickname: str = None relationship_value: float = None saved = False - def __init__(self, chat:ChatStream,data:dict): - self.user_id=chat.user_info.user_id - self.platform=chat.platform - self.nickname=chat.user_info.user_nickname + def __init__(self, chat:ChatStream=None,data:dict=None): + self.user_id=chat.user_info.user_id if chat.user_info else data.get('user_id',0) + self.platform=chat.platform if chat.user_info else data.get('platform','') + self.nickname=chat.user_info.user_nickname if chat.user_info else data.get('nickname','') self.relationship_value=data.get('relationship_value',0) self.age=data.get('age',0) self.gender=data.get('gender','') @@ -35,7 +34,6 @@ class Relationship: class RelationshipManager: def __init__(self): self.relationships: dict[tuple[int, str], Relationship] = {} # 修改为使用(user_id, platform)作为键 - self.relationships: dict[tuple[int, str], Relationship] = {} # 修改为使用(user_id, platform)作为键 async def update_relationship(self, chat_stream:ChatStream, @@ -43,9 +41,7 @@ class RelationshipManager: **kwargs) -> Optional[Relationship]: """更新或创建关系 Args: - user_id: 用户ID(可选,如果提供user_info则不需要) - platform: 平台(可选,如果提供user_info则不需要) - user_info: 用户信息对象(可选) + chat_stream: 聊天流对象 data: 字典格式的数据(可选) **kwargs: 其他参数 Returns: @@ -66,44 +62,18 @@ class RelationshipManager: # 检查是否在内存中已存在 relationship = self.relationships.get(key) - relationship = self.relationships.get(key) if relationship: # 如果存在,更新现有对象 if isinstance(data, dict): for k, value in data.items(): if hasattr(relationship, k) and value is not None: setattr(relationship, k, value) - for k, value in data.items(): - if hasattr(relationship, k) and value is not None: - setattr(relationship, k, value) - else: - for k, value in kwargs.items(): - if hasattr(relationship, k) and value is not None: - setattr(relationship, k, value) - for k, value in kwargs.items(): - if hasattr(relationship, k) and value is not None: - setattr(relationship, k, value) else: # 如果不存在,创建新对象 - if user_info is not None: - relationship = Relationship(user_info=user_info, **kwargs) - elif isinstance(data, dict): - data['platform'] = platform - relationship = Relationship(user_id=user_id, data=data) + if chat_stream.user_info is not None: + relationship = Relationship(chat=chat_stream, **kwargs) else: - kwargs['platform'] = platform - kwargs['user_id'] = user_id - relationship = Relationship(**kwargs) - self.relationships[key] = relationship - if user_info is not None: - relationship = Relationship(user_info=user_info, **kwargs) - elif isinstance(data, dict): - data['platform'] = platform - relationship = Relationship(user_id=user_id, data=data) - else: - kwargs['platform'] = platform - kwargs['user_id'] = user_id - relationship = Relationship(**kwargs) + raise ValueError("必须提供user_id或user_info") self.relationships[key] = relationship # 保存到数据库 @@ -113,36 +83,7 @@ class RelationshipManager: return relationship async def update_relationship_value(self, - user_id: int = None, - platform: str = None, - user_info: UserInfo = None, - **kwargs) -> Optional[Relationship]: - """更新关系值 - Args: - user_id: 用户ID(可选,如果提供user_info则不需要) - platform: 平台(可选,如果提供user_info则不需要) - user_info: 用户信息对象(可选) - **kwargs: 其他参数 - Returns: - Relationship: 关系对象 - """ - # 确定user_id和platform - if user_info is not None: - user_id = user_info.user_id - platform = user_info.platform or 'qq' - else: - platform = platform or 'qq' - - if user_id is None: - raise ValueError("必须提供user_id或user_info") - - # 使用(user_id, platform)作为键 - key = (user_id, platform) - - async def update_relationship_value(self, - user_id: int = None, - platform: str = None, - user_info: UserInfo = None, + chat_stream:ChatStream, **kwargs) -> Optional[Relationship]: """更新关系值 Args: @@ -154,6 +95,7 @@ class RelationshipManager: Relationship: 关系对象 """ # 确定user_id和platform + user_info = chat_stream.user_info if user_info is not None: user_id = user_info.user_id platform = user_info.platform or 'qq' @@ -168,10 +110,7 @@ class RelationshipManager: # 检查是否在内存中已存在 relationship = self.relationships.get(key) - relationship = self.relationships.get(key) if relationship: - for k, value in kwargs.items(): - if k == 'relationship_value': for k, value in kwargs.items(): if k == 'relationship_value': relationship.relationship_value += value @@ -181,43 +120,12 @@ class RelationshipManager: else: # 如果不存在且提供了user_info,则创建新的关系 if user_info is not None: - return await self.update_relationship(user_info=user_info, **kwargs) - print(f"\033[1;31m[关系管理]\033[0m 用户 {user_id}({platform}) 不存在,无法更新") - # 如果不存在且提供了user_info,则创建新的关系 - if user_info is not None: - return await self.update_relationship(user_info=user_info, **kwargs) + return await self.update_relationship(chat_stream=chat_stream, **kwargs) print(f"\033[1;31m[关系管理]\033[0m 用户 {user_id}({platform}) 不存在,无法更新") return None def get_relationship(self, - user_id: int = None, - platform: str = None, - user_info: UserInfo = None) -> Optional[Relationship]: - """获取用户关系对象 - Args: - user_id: 用户ID(可选,如果提供user_info则不需要) - platform: 平台(可选,如果提供user_info则不需要) - user_info: 用户信息对象(可选) - Returns: - Relationship: 关系对象 - """ - # 确定user_id和platform - if user_info is not None: - user_id = user_info.user_id - platform = user_info.platform or 'qq' - else: - platform = platform or 'qq' - - if user_id is None: - raise ValueError("必须提供user_id或user_info") - - key = (user_id, platform) - if key in self.relationships: - return self.relationships[key] - def get_relationship(self, - user_id: int = None, - platform: str = None, - user_info: UserInfo = None) -> Optional[Relationship]: + chat_stream:ChatStream) -> Optional[Relationship]: """获取用户关系对象 Args: user_id: 用户ID(可选,如果提供user_info则不需要) @@ -227,6 +135,8 @@ class RelationshipManager: Relationship: 关系对象 """ # 确定user_id和platform + user_info = chat_stream.user_info + platform = chat_stream.user_info.platform or 'qq' if user_info is not None: user_id = user_info.user_id platform = user_info.platform or 'qq' @@ -248,18 +158,10 @@ class RelationshipManager: if 'platform' not in data: data['platform'] = 'qq' - rela = Relationship(data=data) - """从数据库加载或创建新的关系对象""" - # 确保data中有platform字段,如果没有则默认为'qq' - if 'platform' not in data: - data['platform'] = 'qq' - rela = Relationship(data=data) rela.saved = True key = (rela.user_id, rela.platform) self.relationships[key] = rela - key = (rela.user_id, rela.platform) - self.relationships[key] = rela return rela async def load_all_relationships(self): @@ -277,7 +179,6 @@ class RelationshipManager: # 依次加载每条记录 for data in all_relationships: await self.load_relationship(data) - await self.load_relationship(data) print(f"\033[1;32m[关系管理]\033[0m 已加载 {len(self.relationships)} 条关系记录") while True: @@ -288,19 +189,15 @@ class RelationshipManager: async def _save_all_relationships(self): """将所有关系数据保存到数据库""" # 保存所有关系数据 - for (userid, platform), relationship in self.relationships.items(): for (userid, platform), relationship in self.relationships.items(): if not relationship.saved: relationship.saved = True await self.storage_relationship(relationship) - async def storage_relationship(self, relationship: Relationship): - """将关系记录存储到数据库中""" async def storage_relationship(self, relationship: Relationship): """将关系记录存储到数据库中""" user_id = relationship.user_id platform = relationship.platform - platform = relationship.platform nickname = relationship.nickname relationship_value = relationship.relationship_value gender = relationship.gender @@ -309,10 +206,8 @@ class RelationshipManager: db = Database.get_instance() db.db.relationships.update_one( - {'user_id': user_id, 'platform': platform}, {'user_id': user_id, 'platform': platform}, {'$set': { - 'platform': platform, 'platform': platform, 'nickname': nickname, 'relationship_value': relationship_value, @@ -323,27 +218,6 @@ class RelationshipManager: upsert=True ) - def get_name(self, - user_id: int = None, - platform: str = None, - user_info: UserInfo = None) -> str: - """获取用户昵称 - Args: - user_id: 用户ID(可选,如果提供user_info则不需要) - platform: 平台(可选,如果提供user_info则不需要) - user_info: 用户信息对象(可选) - Returns: - str: 用户昵称 - """ - # 确定user_id和platform - if user_info is not None: - user_id = user_info.user_id - platform = user_info.platform or 'qq' - else: - platform = platform or 'qq' - - if user_id is None: - raise ValueError("必须提供user_id或user_info") def get_name(self, user_id: int = None, @@ -370,11 +244,6 @@ class RelationshipManager: # 确保user_id是整数类型 user_id = int(user_id) key = (user_id, platform) - if key in self.relationships: - return self.relationships[key].nickname - elif user_info is not None: - return user_info.user_nickname or user_info.user_cardname or "某人" - key = (user_id, platform) if key in self.relationships: return self.relationships[key].nickname elif user_info is not None: diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index 85abd5150..614246d26 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -18,8 +18,9 @@ class MessageStorage: "time": message.message_info.time, "chat_id":chat_stream.stream_id, "chat_info": chat_stream.to_dict(), - "detailed_plain_text": message.detailed_plain_text, + "user_info": message.message_info.user_info.to_dict(), "processed_plain_text": message.processed_plain_text, + "detailed_plain_text": message.detailed_plain_text, "topic": topic, } self.db.db.messages.insert_one(message_data) diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index 07c3e2a6f..495d0480d 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -11,7 +11,9 @@ from nonebot import get_driver from ..models.utils_model import LLM_request from ..utils.typo_generator import ChineseTypoGenerator from .config import global_config -from .message_cq import Message +from .message import MessageThinking, MessageRecv,MessageSending,MessageProcessBase,Message +from .message_base import MessageBase,BaseMessageInfo,UserInfo,GroupInfo +from .chat_stream import ChatStream driver = get_driver() config = driver.config @@ -32,7 +34,7 @@ def db_message_to_str(message_dict: Dict) -> str: return result -def is_mentioned_bot_in_message(message: Message) -> bool: +def is_mentioned_bot_in_message(message: MessageRecv) -> bool: """检查消息是否提到了机器人""" keywords = [global_config.BOT_NICKNAME] for keyword in keywords: @@ -41,15 +43,6 @@ def is_mentioned_bot_in_message(message: Message) -> bool: return False -def is_mentioned_bot_in_txt(message: str) -> bool: - """检查消息是否提到了机器人""" - keywords = [global_config.BOT_NICKNAME] - for keyword in keywords: - if keyword in message: - return True - return False - - async def get_embedding(text): """获取文本的embedding向量""" llm = LLM_request(model=global_config.embedding) @@ -84,10 +77,10 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): if closest_record and closest_record.get('memorized', 0) < 4: closest_time = closest_record['time'] - group_id = closest_record['group_id'] # 获取groupid + chat_id = closest_record['chat_id'] # 获取groupid # 获取该时间戳之后的length条消息,且groupid相同 chat_records = list(db.db.messages.find( - {"time": {"$gt": closest_time}, "group_id": group_id} + {"time": {"$gt": closest_time}, "chat_id": chat_id} ).sort('time', 1).limit(length)) # 更新每条消息的memorized属性 @@ -111,7 +104,7 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): return '' -async def get_recent_group_messages(db, group_id: int, limit: int = 12) -> list: +async def get_recent_group_messages(db, chat_id:str, limit: int = 12) -> list: """从数据库获取群组最近的消息记录 Args: @@ -125,35 +118,28 @@ async def get_recent_group_messages(db, group_id: int, limit: int = 12) -> list: # 从数据库获取最近消息 recent_messages = list(db.db.messages.find( - {"group_id": group_id}, - # { - # "time": 1, - # "user_id": 1, - # "user_nickname": 1, - # "message_id": 1, - # "raw_message": 1, - # "processed_text": 1 - # } + {"chat_id": chat_id}, ).sort("time", -1).limit(limit)) if not recent_messages: return [] # 转换为 Message对象列表 - from .message_cq import Message message_objects = [] for msg_data in recent_messages: try: + chat_info=msg_data.get("chat_info",{}) + chat_stream=ChatStream.from_dict(chat_info) + user_info=msg_data.get("user_info",{}) + user_info=UserInfo.from_dict(user_info) msg = Message( - time=msg_data["time"], - user_id=msg_data["user_id"], - user_nickname=msg_data.get("user_nickname", ""), message_id=msg_data["message_id"], - raw_message=msg_data["raw_message"], + chat_stream=chat_stream, + time=msg_data["time"], + user_info=user_info, processed_plain_text=msg_data.get("processed_text", ""), - group_id=group_id + detailed_plain_text=msg_data.get("detailed_plain_text", "") ) - await msg.initialize() message_objects.append(msg) except KeyError: print("[WARNING] 数据库中存在无效的消息") @@ -164,13 +150,14 @@ async def get_recent_group_messages(db, group_id: int, limit: int = 12) -> list: return message_objects -def get_recent_group_detailed_plain_text(db, group_id: int, limit: int = 12, combine=False): +def get_recent_group_detailed_plain_text(db, chat_stream_id: int, limit: int = 12, combine=False): recent_messages = list(db.db.messages.find( - {"group_id": group_id}, + {"chat_id": chat_stream_id}, { "time": 1, # 返回时间字段 - "user_id": 1, # 返回用户ID字段 - "user_nickname": 1, # 返回用户昵称字段 + "chat_id":1, + "chat_info":1, + "user_info": 1, "message_id": 1, # 返回消息ID字段 "detailed_plain_text": 1 # 返回处理后的文本字段 } From 7b35ddd07fc00b1c4f8dc25255b347ae49d95e7c Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Tue, 11 Mar 2025 01:19:56 +0800 Subject: [PATCH 032/162] =?UTF-8?q?ruff=20=E5=93=A5=E5=8F=88=E6=9C=89?= =?UTF-8?q?=E6=96=B0=E7=82=B9=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/reasoning_gui.py | 4 ++-- src/plugins/chat/bot.py | 2 +- src/plugins/chat/config.py | 6 +++--- src/plugins/chat/cq_code.py | 6 +++--- src/plugins/chat/emoji_manager.py | 12 ++++++------ src/plugins/chat/llm_generator.py | 4 ++-- src/plugins/chat/message_sender.py | 6 +++--- src/plugins/chat/storage.py | 2 +- src/plugins/models/utils_model.py | 12 ++++++------ src/plugins/schedule/schedule_generator.py | 2 +- src/plugins/utils/statistic.py | 2 +- 11 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/gui/reasoning_gui.py b/src/gui/reasoning_gui.py index 514a95dfb..5768ddc09 100644 --- a/src/gui/reasoning_gui.py +++ b/src/gui/reasoning_gui.py @@ -87,7 +87,7 @@ class ReasoningGUI: self.db = Database.get_instance().db logger.success("数据库初始化成功") except Exception: - logger.exception(f"数据库初始化失败") + logger.exception("数据库初始化失败") sys.exit(1) # 存储群组数据 @@ -342,7 +342,7 @@ class ReasoningGUI: 'group_id': self.selected_group_id }) except Exception: - logger.exception(f"自动更新出错") + logger.exception("自动更新出错") # 每5秒更新一次 time.sleep(5) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index c510fe4bf..59d94b5e5 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -129,7 +129,7 @@ class ChatBot: # 如果找不到思考消息,直接返回 if not thinking_message: - logger.warning(f"未找到对应的思考消息,可能已超时被移除") + logger.warning("未找到对应的思考消息,可能已超时被移除") return # 记录开始思考的时间,避免从思考到回复的时间太久 diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index c37d23a46..407624f2a 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -113,7 +113,7 @@ class BotConfig: try: converted = SpecifierSet(value) - except InvalidSpecifier as e: + except InvalidSpecifier: logger.error( f"{value} 分类使用了错误的版本约束表达式\n", "请阅读 https://semver.org/lang/zh-CN/ 修改代码" @@ -135,7 +135,7 @@ class BotConfig: try: config_version: str = toml["inner"]["version"] except KeyError as e: - logger.error(f"配置文件中 inner 段 不存在, 这是错误的配置文件") + logger.error("配置文件中 inner 段 不存在, 这是错误的配置文件") raise KeyError(f"配置文件中 inner 段 不存在 {e}, 这是错误的配置文件") else: toml["inner"] = {"version": "0.0.0"} @@ -143,7 +143,7 @@ class BotConfig: try: ver = version.parse(config_version) - except InvalidVersion as e: + except InvalidVersion: logger.error( "配置文件中 inner段 的 version 键是错误的版本描述\n" "请阅读 https://semver.org/lang/zh-CN/ 修改配置,并参考本项目指定的模板进行修改\n" diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index b13e33e48..21541a78b 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -155,8 +155,8 @@ class CQCode: logger.error(f"最终请求失败: {str(e)}") time.sleep(1.5 ** retry) # 指数退避 - except Exception as e: - logger.exception(f"[未知错误]") + except Exception: + logger.exception("[未知错误]") return None return None @@ -281,7 +281,7 @@ class CQCode: logger.debug(f"合并后的转发消息: {combined_messages}") return f"[转发消息:\n{combined_messages}]" - except Exception as e: + except Exception: logger.exception("处理转发消息失败") return '[转发消息]' diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 740e9a677..5a601d434 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -51,8 +51,8 @@ class EmojiManager: self._initialized = True # 启动时执行一次完整性检查 self.check_emoji_file_integrity() - except Exception as e: - logger.exception(f"初始化表情管理器失败") + except Exception: + logger.exception("初始化表情管理器失败") def _ensure_db(self): """确保数据库已初始化""" @@ -87,8 +87,8 @@ class EmojiManager: {'_id': emoji_id}, {'$inc': {'usage_count': 1}} ) - except Exception as e: - logger.exception(f"记录表情使用失败") + except Exception: + logger.exception("记录表情使用失败") async def get_emoji_for_text(self, text: str) -> Optional[str]: """根据文本内容获取相关表情包 @@ -264,8 +264,8 @@ class EmojiManager: else: logger.warning(f"跳过表情包: {filename}") - except Exception as e: - logger.exception(f"扫描表情包失败") + except Exception: + logger.exception("扫描表情包失败") async def _periodic_scan(self, interval_MINS: int = 10): """定期扫描新表情包""" diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index f40c9a441..4e431d9fd 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -94,7 +94,7 @@ class ResponseGenerator: try: content, reasoning_content = await model.generate_response(prompt) except Exception: - logger.exception(f"生成回复时出错") + logger.exception("生成回复时出错") return None # 保存到数据库 @@ -146,7 +146,7 @@ class ResponseGenerator: return ["neutral"] except Exception: - logger.exception(f"获取情感标签时出错") + logger.exception("获取情感标签时出错") return ["neutral"] async def _process_response(self, content: str) -> Tuple[List[str], List[str]]: diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 4abbd3b3f..0fb40373e 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -61,7 +61,7 @@ class Message_Sender: auto_escape=auto_escape ) logger.debug(f"发送消息{message}成功") - except Exception as e: + except Exception: logger.exception(f"发送消息{message}失败") @@ -120,7 +120,7 @@ class MessageContainer: return True return False except Exception: - logger.exception(f"移除消息时发生错误") + logger.exception("移除消息时发生错误") return False def has_messages(self) -> bool: @@ -214,7 +214,7 @@ class MessageManager: if not container.remove_message(msg): logger.warning("尝试删除不存在的消息") except Exception: - logger.exception(f"处理超时消息时发生错误") + logger.exception("处理超时消息时发生错误") continue async def start_processor(self): diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index 1c2d05071..4081f8984 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -45,6 +45,6 @@ class MessageStorage: self.db.db.messages.insert_one(message_data) except Exception: - logger.exception(f"存储消息失败") + logger.exception("存储消息失败") # 如果需要其他存储相关的函数,可以在这里添加 diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index fe8e1a100..ac567600a 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -44,8 +44,8 @@ class LLM_request: self.db.db.llm_usage.create_index([("model_name", 1)]) self.db.db.llm_usage.create_index([("user_id", 1)]) self.db.db.llm_usage.create_index([("request_type", 1)]) - except Exception as e: - logger.error(f"创建数据库索引失败") + except Exception: + logger.error("创建数据库索引失败") def _record_usage(self, prompt_tokens: int, completion_tokens: int, total_tokens: int, user_id: str = "system", request_type: str = "chat", @@ -80,7 +80,7 @@ class LLM_request: f"总计: {total_tokens}" ) except Exception: - logger.error(f"记录token使用情况失败") + logger.error("记录token使用情况失败") def _calculate_cost(self, prompt_tokens: int, completion_tokens: int) -> float: """计算API调用成本 @@ -194,7 +194,7 @@ class LLM_request: if hasattr(global_config, 'llm_normal') and global_config.llm_normal.get( 'name') == old_model_name: global_config.llm_normal['name'] = self.model_name - logger.warning(f"已将全局配置中的 llm_normal 模型降级") + logger.warning("已将全局配置中的 llm_normal 模型降级") # 更新payload中的模型名 if payload and 'model' in payload: @@ -227,7 +227,7 @@ class LLM_request: delta_content = "" accumulated_content += delta_content except Exception: - logger.exception(f"解析流式输出错") + logger.exception("解析流式输出错") content = accumulated_content reasoning_content = "" think_match = re.search(r'(.*?)', content, re.DOTALL) @@ -355,7 +355,7 @@ class LLM_request: """构建请求头""" if no_key: return { - "Authorization": f"Bearer **********", + "Authorization": "Bearer **********", "Content-Type": "application/json" } else: diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index fc07a152d..b5494f4cf 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -91,7 +91,7 @@ class ScheduleGenerator: try: schedule_dict = json.loads(schedule_text) return schedule_dict - except json.JSONDecodeError as e: + except json.JSONDecodeError: logger.exception("解析日程失败: {}".format(schedule_text)) return False diff --git a/src/plugins/utils/statistic.py b/src/plugins/utils/statistic.py index a071355a3..2974389e6 100644 --- a/src/plugins/utils/statistic.py +++ b/src/plugins/utils/statistic.py @@ -155,7 +155,7 @@ class LLMStatistics: all_stats = self._collect_all_statistics() self._save_statistics(all_stats) except Exception: - logger.exception(f"统计数据处理失败") + logger.exception("统计数据处理失败") # 等待1分钟 for _ in range(60): From e4b8865f9978960941f276ffa8dd0fdb3ee94f33 Mon Sep 17 00:00:00 2001 From: Hosigus Date: Mon, 10 Mar 2025 14:33:30 +0800 Subject: [PATCH 033/162] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=AB=E5=90=8D?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E4=BB=A5=E7=94=A8=E4=B8=8D=E5=90=8C=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E5=8F=AC=E5=94=A4=E6=9C=BA=E5=99=A8=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/installation_cute.md | 3 ++- docs/installation_standard.md | 3 +++ src/plugins/chat/config.py | 4 +++- src/plugins/chat/prompt_builder.py | 9 +++++---- src/plugins/chat/utils.py | 16 +++++----------- template/bot_config_template.toml | 3 ++- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/installation_cute.md b/docs/installation_cute.md index 3a63988f1..4465660f9 100644 --- a/docs/installation_cute.md +++ b/docs/installation_cute.md @@ -110,7 +110,8 @@ PLUGINS=["src2.plugins.chat"] # 这里是机器人的插件列表呢 ```toml [bot] qq = "把这里改成你的机器人QQ号喵" # 填写你的机器人QQ号 -nickname = "麦麦" # 机器人的名字,你可以改成你喜欢的任何名字哦 +nickname = "麦麦" # 机器人的名字,你可以改成你喜欢的任何名字哦,建议和机器人QQ名称/群昵称一样哦 +alias_names = ["小麦", "阿麦"] # 也可以用这个招呼机器人,可以不设置呢 [personality] # 这里可以设置机器人的性格呢,让它更有趣一些喵 diff --git a/docs/installation_standard.md b/docs/installation_standard.md index cf8e7eb9b..ec5a05149 100644 --- a/docs/installation_standard.md +++ b/docs/installation_standard.md @@ -72,6 +72,9 @@ PLUGINS=["src2.plugins.chat"] [bot] qq = "机器人QQ号" # 必填 nickname = "麦麦" # 机器人昵称 +# alias_names: 配置机器人可使用的别名。当机器人在群聊或对话中被调用时,别名可以作为直接命令或提及机器人的关键字使用。 +# 该配置项为字符串数组。例如: ["小麦", "阿麦"] +alias_names = ["小麦", "阿麦"] # 机器人别名 [personality] prompt_personality = [ diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index c37d23a46..8c45d1664 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -1,6 +1,6 @@ import os from dataclasses import dataclass, field -from typing import Dict, Optional +from typing import Dict, List, Optional import tomli from loguru import logger @@ -16,6 +16,7 @@ class BotConfig: BOT_QQ: Optional[int] = 1 BOT_NICKNAME: Optional[str] = None + BOT_ALIAS_NAMES: List[str] = field(default_factory=list) # 别名,可以通过这个叫它 # 消息处理相关配置 MIN_TEXT_LENGTH: int = 2 # 最小处理文本长度 @@ -190,6 +191,7 @@ class BotConfig: bot_qq = bot_config.get("qq") config.BOT_QQ = int(bot_qq) config.BOT_NICKNAME = bot_config.get("nickname", config.BOT_NICKNAME) + config.BOT_ALIAS_NAMES = bot_config.get("alias_names", config.BOT_ALIAS_NAMES) def response(parent: dict): response_config = parent["response"] diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index 4cf21af19..0805caa5a 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -131,18 +131,19 @@ class PromptBuilder: probability_1 = global_config.PERSONALITY_1 probability_2 = global_config.PERSONALITY_2 probability_3 = global_config.PERSONALITY_3 - prompt_personality = '' + + prompt_personality = f'{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},你还有很多别名:{"/".join(global_config.BOT_ALIAS_NAMES)},' personality_choice = random.random() if personality_choice < probability_1: # 第一种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[0]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality += f'''{personality[0]}, 你正在浏览qq群,{promt_info_prompt}, 现在请你给出日常且口语化的回复,平淡一些,尽量简短一些。{keywords_reaction_prompt} 请注意把握群里的聊天内容,不要刻意突出自身学科背景,不要回复的太有条理,可以有个性。''' elif personality_choice < probability_1 + probability_2: # 第二种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[1]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality += f'''{personality[1]}, 你正在浏览qq群,{promt_info_prompt}, 现在请你给出日常且口语化的回复,请表现你自己的见解,不要一昧迎合,尽量简短一些。{keywords_reaction_prompt} 请你表达自己的见解和观点。可以有个性。''' else: # 第三种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[2]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality += f'''{personality[2]}, 你正在浏览qq群,{promt_info_prompt}, 现在请你给出日常且口语化的回复,请表现你自己的见解,不要一昧迎合,尽量简短一些。{keywords_reaction_prompt} 请你表达自己的见解和观点。可以有个性。''' diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index 054526e94..6619f37af 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -53,19 +53,13 @@ def db_message_to_str(message_dict: Dict) -> str: return result -def is_mentioned_bot_in_message(message: Message) -> bool: - """检查消息是否提到了机器人""" - keywords = [global_config.BOT_NICKNAME] - for keyword in keywords: - if keyword in message.processed_plain_text: - return True - return False - - def is_mentioned_bot_in_txt(message: str) -> bool: """检查消息是否提到了机器人""" - keywords = [global_config.BOT_NICKNAME] - for keyword in keywords: + if global_config.BOT_NICKNAME is None: + return True + if global_config.BOT_NICKNAME in message: + return True + for keyword in global_config.BOT_ALIAS_NAMES: if keyword in message: return True return False diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index bff64d05f..24b2f93c2 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "0.0.4" +version = "0.0.5" #如果你想要修改配置文件,请在修改后将version的值进行变更 #如果新增项目,请在BotConfig类下新增相应的变量 @@ -15,6 +15,7 @@ version = "0.0.4" [bot] qq = 123 nickname = "麦麦" +alias_names = ["小麦", "阿麦"] [personality] prompt_personality = [ From 043a7246433c0451c34adc0ee921751afdada59c Mon Sep 17 00:00:00 2001 From: Yan233_ Date: Tue, 11 Mar 2025 01:24:39 +0800 Subject: [PATCH 034/162] =?UTF-8?q?=E4=BF=AE=E4=B8=80=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E8=B7=B3=E8=BD=AC=EF=BC=8C=E5=B0=8F=E7=BE=8E=E5=8C=96?= =?UTF-8?q?=EF=BC=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/docker_deploy.md | 14 +++++++------- docs/installation_standard.md | 2 +- docs/manual_deploy_linux.md | 15 +++++++-------- docs/manual_deploy_windows.md | 8 ++++---- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 29e25b2b9..3c37acf96 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ ### 部署方式 -- 📦 **Windows 一键傻瓜式部署**:请运行项目根目录中的 ```run.bat```,部署完成后请参照后续配置指南进行配置 +- 📦 **Windows 一键傻瓜式部署**:请运行项目根目录中的 `run.bat`,部署完成后请参照后续配置指南进行配置 - [📦 Windows 手动部署指南 ](docs/manual_deploy_windows.md) diff --git a/docs/docker_deploy.md b/docs/docker_deploy.md index 3958d2fc4..c00d0cb04 100644 --- a/docs/docker_deploy.md +++ b/docs/docker_deploy.md @@ -9,13 +9,13 @@ wget https://raw.githubusercontent.com/SengokuCola/MaiMBot/main/docker-compose.yml -O docker-compose.yml ``` -- 若需要启用MongoDB数据库的用户名和密码,可进入docker-compose.yml,取消MongoDB处的注释并修改变量`=`后方的值为你的用户名和密码\ -修改后请注意在之后配置`.env.prod`文件时指定MongoDB数据库的用户名密码 +- 若需要启用MongoDB数据库的用户名和密码,可进入docker-compose.yml,取消MongoDB处的注释并修改变量旁 `=` 后方的值为你的用户名和密码\ +修改后请注意在之后配置 `.env.prod` 文件时指定MongoDB数据库的用户名密码 ### 2. 启动服务: -- **!!! 请在第一次启动前确保当前工作目录下`.env.prod`与`bot_config.toml`文件存在 !!!**\ +- **!!! 请在第一次启动前确保当前工作目录下 `.env.prod` 与 `bot_config.toml` 文件存在 !!!**\ 由于Docker文件映射行为的特殊性,若宿主机的映射路径不存在,可能导致意外的目录创建,而不会创建文件,由于此处需要文件映射到文件,需提前确保文件存在且路径正确,可使用如下命令: ```bash touch .env.prod @@ -32,8 +32,8 @@ NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose up -d ### 3. 修改配置并重启Docker: -- 请前往 [🎀 新手配置指南](docs/installation_cute.md) 或 [⚙️ 标准配置指南](docs/installation_standard.md) 完成`.env.prod`与`bot_config.toml`配置文件的编写\ -**需要注意`.env.prod`中HOST处IP的填写,Docker中部署和系统中直接安装的配置会有所不同** +- 请前往 [🎀 新手配置指南](./installation_cute.md) 或 [⚙️ 标准配置指南](./installation_standard.md) 完成 `.env.prod` 与 `bot_config.toml` 配置文件的编写\ +**需要注意 `.env.prod` 中HOST处IP的填写,Docker中部署和系统中直接安装的配置会有所不同** - 重启Docker容器: ```bash @@ -50,10 +50,10 @@ NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose restart ### 4. 登入NapCat管理页添加反向WebSocket -- 在浏览器地址栏输入`http://<宿主机IP>:6099/`进入NapCat的管理Web页,添加一个Websocket客户端 +- 在浏览器地址栏输入 `http://<宿主机IP>:6099/` 进入NapCat的管理Web页,添加一个Websocket客户端 > 网络配置 -> 新建 -> Websocket客户端 -- Websocket客户端的名称自定,URL栏填入`ws://maimbot:8080/onebot/v11/ws`,启用并保存即可\ +- Websocket客户端的名称自定,URL栏填入 `ws://maimbot:8080/onebot/v11/ws`,启用并保存即可\ (若修改过容器名称则替换maimbot为你自定的名称) diff --git a/docs/installation_standard.md b/docs/installation_standard.md index cf8e7eb9b..b3a9867f9 100644 --- a/docs/installation_standard.md +++ b/docs/installation_standard.md @@ -8,7 +8,7 @@ ## API配置说明 -`.env.prod`和`bot_config.toml`中的API配置关系如下: +`.env.prod` 和 `bot_config.toml` 中的API配置关系如下: ### 在.env.prod中定义API凭证: ```ini diff --git a/docs/manual_deploy_linux.md b/docs/manual_deploy_linux.md index d310ffc59..41f0390b8 100644 --- a/docs/manual_deploy_linux.md +++ b/docs/manual_deploy_linux.md @@ -66,7 +66,7 @@ pip install -r requirements.txt ## 数据库配置 ### 3️⃣ **安装并启动MongoDB** -- 安装与启动:Debian参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-debian/),Ubuntu参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/) +- 安装与启动: Debian参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-debian/),Ubuntu参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/) - 默认连接本地27017端口 --- @@ -76,15 +76,14 @@ pip install -r requirements.txt - 参考[NapCat官方文档](https://www.napcat.wiki/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9)安装 -- 使用QQ小号登录,添加反向WS地址: -`ws://127.0.0.1:8080/onebot/v11/ws` +- 使用QQ小号登录,添加反向WS地址: `ws://127.0.0.1:8080/onebot/v11/ws` --- ## 配置文件设置 ### 5️⃣ **配置文件设置,让麦麦Bot正常工作** -- 修改环境配置文件:`.env.prod` -- 修改机器人配置文件:`bot_config.toml` +- 修改环境配置文件: `.env.prod` +- 修改机器人配置文件: `bot_config.toml` --- @@ -107,9 +106,9 @@ python3 bot.py --- ## 常见问题 -🔧 权限问题:在命令前加`sudo` -🔌 端口占用:使用`sudo lsof -i :8080`查看端口占用 -🛡️ 防火墙:确保8080/27017端口开放 +🔧 权限问题: 在命令前加 `sudo` +🔌 端口占用: 使用 `sudo lsof -i :8080` 查看端口占用 +🛡️ 防火墙: 确保8080/27017端口开放 ```bash sudo ufw allow 8080/tcp sudo ufw allow 27017/tcp diff --git a/docs/manual_deploy_windows.md b/docs/manual_deploy_windows.md index 86238bcd4..eebdc4f41 100644 --- a/docs/manual_deploy_windows.md +++ b/docs/manual_deploy_windows.md @@ -30,7 +30,7 @@ 在创建虚拟环境之前,请确保你的电脑上安装了Python 3.9及以上版本。如果没有,可以按以下步骤安装: -1. 访问Python官网下载页面:https://www.python.org/downloads/release/python-3913/ +1. 访问Python官网下载页面: https://www.python.org/downloads/release/python-3913/ 2. 下载Windows安装程序 (64-bit): `python-3.9.13-amd64.exe` 3. 运行安装程序,并确保勾选"Add Python 3.9 to PATH"选项 4. 点击"Install Now"开始安装 @@ -79,11 +79,11 @@ pip install -r requirements.txt ### 3️⃣ **配置NapCat,让麦麦bot与qq取得联系** - 安装并登录NapCat(用你的qq小号) -- 添加反向WS:`ws://127.0.0.1:8080/onebot/v11/ws` +- 添加反向WS: `ws://127.0.0.1:8080/onebot/v11/ws` ### 4️⃣ **配置文件设置,让麦麦Bot正常工作** -- 修改环境配置文件:`.env.prod` -- 修改机器人配置文件:`bot_config.toml` +- 修改环境配置文件: `.env.prod` +- 修改机器人配置文件: `bot_config.toml` ### 5️⃣ **启动麦麦机器人** - 打开命令行,cd到对应路径 From 2e8cd47133dca0f5f9b83231eb93ad255cfe08a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E7=8C=AB?= Date: Tue, 11 Mar 2025 02:28:10 +0900 Subject: [PATCH 035/162] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E5=87=BA=E7=8E=B0=E7=9A=84=E6=97=A5=E7=A8=8B=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/schedule/schedule_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index b5494f4cf..e280c6bce 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -68,7 +68,7 @@ class ScheduleGenerator: 1. 早上的学习和工作安排 2. 下午的活动和任务 3. 晚上的计划和休息时间 - 请按照时间顺序列出具体时间点和对应的活动,用一个时间点而不是时间段来表示时间,用JSON格式返回日程表,仅返回内容,不要返回注释,时间采用24小时制,格式为{"时间": "活动","时间": "活动",...}。""" + 请按照时间顺序列出具体时间点和对应的活动,用一个时间点而不是时间段来表示时间,用JSON格式返回日程表,仅返回内容,不要返回注释,不要添加任何markdown或代码块样式,时间采用24小时制,格式为{"时间": "活动","时间": "活动",...}。""" try: schedule_text, _ = await self.llm_scheduler.generate_response(prompt) From 1294c88b148a65daf42ffbff7eaec2dce6b853a8 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 01:31:12 +0800 Subject: [PATCH 036/162] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=A0=87?= =?UTF-8?q?=E5=87=86=E5=8C=96=E6=A0=BC=E5=BC=8F=E5=8C=96=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++--- .vscode/settings.json | 3 +++ pyproject.toml | 46 ++++++++++++++++++++++++++++--------------- 3 files changed, 35 insertions(+), 19 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 4e1606a54..e51abc5cc 100644 --- a/.gitignore +++ b/.gitignore @@ -193,9 +193,8 @@ cython_debug/ # jieba jieba.cache - -# vscode -/.vscode +# .vscode +!.vscode/settings.json # direnv /.direnv \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..23fd35f0e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e54dcdacd..1eff99a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,37 @@ -[project] -name = "Megbot" -version = "0.1.0" -description = "New Bot Project" - -[tool.nonebot] -plugins = ["src.plugins.chat"] -plugin_dirs = ["src/plugins"] - [tool.ruff] -# 设置 Python 版本 -target-version = "py39" +include = ["*.py"] +fixable = ["ALL"] # 启用的规则 select = [ - "E", # pycodestyle 错误 - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear + "E", # pycodestyle 错误 + "F", # pyflakes + "B", # flake8-bugbear ] +ignore = ["E711"] + +# 如果一个变量的名称以下划线开头,即使它未被使用,也不应该被视为错误或警告。 +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + # 行长度设置 -line-length = 88 \ No newline at end of file +line-length = 120 + +[tool.ruff.format] +docstring-code-format = true +indent-style = "space" + +# 使用双引号表示字符串 +quote-style = "double" + +# 尊重魔法尾随逗号 +# 例如: +# items = [ +# "apple", +# "banana", +# "cherry", +# ] +skip-magic-trailing-comma = false + +# 自动检测合适的换行符 +line-ending = "auto" From a63ce969c7e7199da0e73b1c666add2b8544dada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E7=8C=AB?= Date: Tue, 11 Mar 2025 02:38:23 +0900 Subject: [PATCH 037/162] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E6=83=85?= =?UTF-8?q?=E6=84=9F=E5=88=A4=E6=96=AD=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=88=E4=BD=BF=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20llm=5Femotion=5Fjudge=20=E7=94=9F=E6=95=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 5a601d434..df26daa70 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -33,7 +33,7 @@ class EmojiManager: self.db = Database.get_instance() self._scan_task = None self.vlm = LLM_request(model=global_config.vlm, temperature=0.3, max_tokens=1000) - self.llm_emotion_judge = LLM_request(model=global_config.llm_normal_minor, max_tokens=60, + self.llm_emotion_judge = LLM_request(model=global_config.llm_emotion_judge, max_tokens=60, temperature=0.8) # 更高的温度,更少的token(后续可以根据情绪来调整温度) From efcf00f6af0456a35b938ed9d2ed321849a8dfbf Mon Sep 17 00:00:00 2001 From: Yan233_ Date: Tue, 11 Mar 2025 02:00:00 +0800 Subject: [PATCH 038/162] =?UTF-8?q?Docker=E9=83=A8=E7=BD=B2=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E8=BF=BD=E5=8A=A0=E6=9B=B4=E6=96=B0=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docker_deploy.md | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/docker_deploy.md b/docs/docker_deploy.md index c00d0cb04..b4d0a3c7d 100644 --- a/docs/docker_deploy.md +++ b/docs/docker_deploy.md @@ -1,10 +1,17 @@ # 🐳 Docker 部署指南 -## 部署步骤(推荐,但不一定是最新) +## 部署步骤 (推荐,但不一定是最新) +"更新镜像与容器"部分在Part 6 + +### 0. 前提说明 + +**本文假设读者已具备一定的 Docker 基础知识。若您对 Docker 不熟悉,建议先参考相关教程或文档进行学习,或选择使用 [📦Linux手动部署指南](./manual_deploy_linux.md) 或 [📦Windows手动部署指南](./manual_deploy_windows.md) 。** ### 1. 获取Docker配置文件: +- 建议先单独创建好一个文件夹并进入,作为工作目录 + ```bash wget https://raw.githubusercontent.com/SengokuCola/MaiMBot/main/docker-compose.yml -O docker-compose.yml ``` @@ -25,14 +32,14 @@ touch bot_config.toml - 启动Docker容器: ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose up -d +# 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose up -d ``` -- 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 - ### 3. 修改配置并重启Docker: -- 请前往 [🎀 新手配置指南](./installation_cute.md) 或 [⚙️ 标准配置指南](./installation_standard.md) 完成 `.env.prod` 与 `bot_config.toml` 配置文件的编写\ +- 请前往 [🎀新手配置指南](./installation_cute.md) 或 [⚙️标准配置指南](./installation_standard.md) 完成 `.env.prod` 与 `bot_config.toml` 配置文件的编写\ **需要注意 `.env.prod` 中HOST处IP的填写,Docker中部署和系统中直接安装的配置会有所不同** - 重启Docker容器: @@ -43,10 +50,10 @@ docker restart maimbot # 若修改过容器名称则替换maimbot为你自定 - 下方命令可以但不推荐,只是同时重启NapCat、MongoDB、MaiMBot三个服务 ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose restart +# 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose restart ``` -- 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 - ### 4. 登入NapCat管理页添加反向WebSocket @@ -57,7 +64,24 @@ NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose restart (若修改过容器名称则替换maimbot为你自定的名称) -### 5. 愉快地和麦麦对话吧! +### 5. 部署完成,愉快地和麦麦对话吧! + + +### 6. 更新镜像与容器 + +- 以更新MaiMBot为例,其他两个容器可以但没必要 + +- 先拉取最新镜像 +```bash +docker pull sengokucola/maimbot:latest +``` + +- 拉取完最新镜像后回到 `docker-compose.yml` 所在工作目录执行以下命令,该指令会自动重建镜像有更新的容器并启动 +```bash +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose up -d +# 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose up -d +``` ## ⚠️ 注意事项 From d9a28639ac95eeb555f3c7415991742f4bb577a0 Mon Sep 17 00:00:00 2001 From: Yan233_ Date: Tue, 11 Mar 2025 02:15:46 +0800 Subject: [PATCH 039/162] =?UTF-8?q?=E4=BC=98=E5=8C=96Docker=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=96=87=E6=A1=A3=E6=9B=B4=E6=96=B0=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docker_deploy.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/docker_deploy.md b/docs/docker_deploy.md index b4d0a3c7d..a5d648885 100644 --- a/docs/docker_deploy.md +++ b/docs/docker_deploy.md @@ -1,14 +1,15 @@ # 🐳 Docker 部署指南 ## 部署步骤 (推荐,但不一定是最新) -"更新镜像与容器"部分在Part 6 + +**"更新镜像与容器"部分在本文档 [Part 6](#6-更新镜像与容器)** ### 0. 前提说明 **本文假设读者已具备一定的 Docker 基础知识。若您对 Docker 不熟悉,建议先参考相关教程或文档进行学习,或选择使用 [📦Linux手动部署指南](./manual_deploy_linux.md) 或 [📦Windows手动部署指南](./manual_deploy_windows.md) 。** -### 1. 获取Docker配置文件: +### 1. 获取Docker配置文件 - 建议先单独创建好一个文件夹并进入,作为工作目录 @@ -20,16 +21,18 @@ wget https://raw.githubusercontent.com/SengokuCola/MaiMBot/main/docker-compose.y 修改后请注意在之后配置 `.env.prod` 文件时指定MongoDB数据库的用户名密码 -### 2. 启动服务: +### 2. 启动服务 - **!!! 请在第一次启动前确保当前工作目录下 `.env.prod` 与 `bot_config.toml` 文件存在 !!!**\ 由于Docker文件映射行为的特殊性,若宿主机的映射路径不存在,可能导致意外的目录创建,而不会创建文件,由于此处需要文件映射到文件,需提前确保文件存在且路径正确,可使用如下命令: + ```bash touch .env.prod touch bot_config.toml ``` - 启动Docker容器: + ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose up -d # 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 @@ -37,17 +40,19 @@ NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose up -d ``` -### 3. 修改配置并重启Docker: +### 3. 修改配置并重启Docker - 请前往 [🎀新手配置指南](./installation_cute.md) 或 [⚙️标准配置指南](./installation_standard.md) 完成 `.env.prod` 与 `bot_config.toml` 配置文件的编写\ **需要注意 `.env.prod` 中HOST处IP的填写,Docker中部署和系统中直接安装的配置会有所不同** - 重启Docker容器: + ```bash docker restart maimbot # 若修改过容器名称则替换maimbot为你自定的名臣 ``` - 下方命令可以但不推荐,只是同时重启NapCat、MongoDB、MaiMBot三个服务 + ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose restart # 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 @@ -58,6 +63,7 @@ NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose restart ### 4. 登入NapCat管理页添加反向WebSocket - 在浏览器地址栏输入 `http://<宿主机IP>:6099/` 进入NapCat的管理Web页,添加一个Websocket客户端 + > 网络配置 -> 新建 -> Websocket客户端 - Websocket客户端的名称自定,URL栏填入 `ws://maimbot:8080/onebot/v11/ws`,启用并保存即可\ @@ -69,14 +75,14 @@ NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose restart ### 6. 更新镜像与容器 -- 以更新MaiMBot为例,其他两个容器可以但没必要 +- 拉取最新镜像 -- 先拉取最新镜像 ```bash -docker pull sengokucola/maimbot:latest +docker-compose pull ``` -- 拉取完最新镜像后回到 `docker-compose.yml` 所在工作目录执行以下命令,该指令会自动重建镜像有更新的容器并启动 +- 执行启动容器指令,该指令会自动重建镜像有更新的容器并启动 + ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose up -d # 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 @@ -88,4 +94,4 @@ NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose up -d - 目前部署方案仍在测试中,可能存在未知问题 - 配置文件中的API密钥请妥善保管,不要泄露 -- 建议先在测试环境中运行,确认无误后再部署到生产环境 \ No newline at end of file +- 建议先在测试环境中运行,确认无误后再部署到生产环境 \ No newline at end of file From bf9701365f2d68e8c1fdaf083139e58cc8f27118 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Tue, 11 Mar 2025 02:46:13 +0800 Subject: [PATCH 040/162] =?UTF-8?q?feat:=20=E7=B2=BE=E7=AE=80=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=8C=E7=A6=81=E7=94=A8Uvicorn/NoneBot=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=97=A5=E5=BF=97=EF=BC=9B=E5=90=AF=E5=8A=A8=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E6=94=B9=E4=B8=BA=E6=98=BE=E7=A4=BA=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?uvicorn=EF=BC=8C=E4=BB=A5=E4=BE=BF=E4=BC=98=E9=9B=85shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/bot.py b/bot.py index c2ed3dfdf..471a98eaf 100644 --- a/bot.py +++ b/bot.py @@ -1,9 +1,12 @@ +import asyncio import os import shutil import sys import nonebot import time + +import uvicorn from dotenv import load_dotenv from loguru import logger from nonebot.adapters.onebot.v11 import Adapter @@ -12,6 +15,8 @@ import platform # 获取没有加载env时的环境变量 env_mask = {key: os.getenv(key) for key in os.environ} +uvicorn_server = None + def easter_egg(): # 彩蛋 @@ -100,10 +105,12 @@ def load_logger(): "#777777>| {name:.<8}:{function:.<8}:{line: >4} - {message}", colorize=True, - level=os.getenv("LOG_LEVEL", "DEBUG") # 根据环境设置日志级别,默认为INFO + level=os.getenv("LOG_LEVEL", "INFO"), # 根据环境设置日志级别,默认为INFO + filter=lambda record: "nonebot" not in record["name"] ) + def scan_provider(env_config: dict): provider = {} @@ -138,7 +145,39 @@ def scan_provider(env_config: dict): raise ValueError(f"请检查 '{provider_name}' 提供商配置是否丢失 BASE_URL 或 KEY 环境变量") -if __name__ == "__main__": +async def graceful_shutdown(): + try: + global uvicorn_server + if uvicorn_server: + uvicorn_server.force_exit = True # 强制退出 + await uvicorn_server.shutdown() + + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + except Exception as e: + logger.error(f"麦麦关闭失败: {e}") + + +async def uvicorn_main(): + global uvicorn_server + config = uvicorn.Config( + app="__main__:app", + host=os.getenv("HOST", "127.0.0.1"), + port=int(os.getenv("PORT", 8080)), + reload=os.getenv("ENVIRONMENT") == "dev", + timeout_graceful_shutdown=5, + log_config=None, + access_log=False + ) + server = uvicorn.Server(config) + uvicorn_server = server + await server.serve() + + +def raw_main(): # 利用 TZ 环境变量设定程序工作的时区 # 仅保证行为一致,不依赖 localtime(),实际对生产环境几乎没有作用 if platform.system().lower() != 'windows': @@ -165,10 +204,30 @@ if __name__ == "__main__": nonebot.init(**base_config, **env_config) # 注册适配器 + global driver driver = nonebot.get_driver() driver.register_adapter(Adapter) # 加载插件 nonebot.load_plugins("src/plugins") - nonebot.run() + +if __name__ == "__main__": + + try: + raw_main() + + global app + app = nonebot.get_asgi() + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(uvicorn_main()) + except KeyboardInterrupt: + logger.warning("麦麦会努力做的更好的!正在停止中......") + except Exception as e: + logger.error(f"主程序异常: {e}") + finally: + loop.run_until_complete(graceful_shutdown()) + loop.close() + logger.info("进程终止完毕,麦麦开始休眠......下次再见哦!") From ff65ab8d72f4e3abd8279087bbf799c12a463bc1 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 03:14:22 +0800 Subject: [PATCH 041/162] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=9A=84ruff=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=97=B6=E6=B6=88=E9=99=A4config=E7=9A=84?= =?UTF-8?q?=E6=89=80=E6=9C=89=E4=B8=8D=E7=AC=A6=E5=90=88=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E7=9A=84=E5=9C=B0=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml => ruff.toml | 19 ++-- src/plugins/chat/config.py | 177 ++++++++++++++---------------------- 2 files changed, 78 insertions(+), 118 deletions(-) rename pyproject.toml => ruff.toml (95%) diff --git a/pyproject.toml b/ruff.toml similarity index 95% rename from pyproject.toml rename to ruff.toml index 1eff99a97..54231339f 100644 --- a/pyproject.toml +++ b/ruff.toml @@ -1,6 +1,14 @@ -[tool.ruff] include = ["*.py"] + +# 行长度设置 +line-length = 120 + +[lint] fixable = ["ALL"] +unfixable = [] + +# 如果一个变量的名称以下划线开头,即使它未被使用,也不应该被视为错误或警告。 +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" # 启用的规则 select = [ @@ -11,16 +19,11 @@ select = [ ignore = ["E711"] -# 如果一个变量的名称以下划线开头,即使它未被使用,也不应该被视为错误或警告。 -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -# 行长度设置 -line-length = 120 - -[tool.ruff.format] +[format] docstring-code-format = true indent-style = "space" + # 使用双引号表示字符串 quote-style = "double" diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 407624f2a..3888c0b94 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -12,6 +12,7 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier @dataclass class BotConfig: """机器人配置类""" + INNER_VERSION: Version = None BOT_QQ: Optional[int] = 1 @@ -81,23 +82,25 @@ class BotConfig: PROMPT_PERSONALITY = [ "曾经是一个学习地质的女大学生,现在学习心理学和脑科学,你会刷贴吧", "是一个女大学生,你有黑色头发,你会刷小红书", - "是一个女大学生,你会刷b站,对ACG文化感兴趣" + "是一个女大学生,你会刷b站,对ACG文化感兴趣", ] - PROMPT_SCHEDULE_GEN="一个曾经学习地质,现在学习心理学和脑科学的女大学生,喜欢刷qq,贴吧,知乎和小红书" - - PERSONALITY_1: float = 0.6 # 第一种人格概率 - PERSONALITY_2: float = 0.3 # 第二种人格概率 - PERSONALITY_3: float = 0.1 # 第三种人格概率 - - memory_ban_words: list = field(default_factory=lambda: ['表情包', '图片', '回复', '聊天记录']) # 添加新的配置项默认值 - + PROMPT_SCHEDULE_GEN = "一个曾经学习地质,现在学习心理学和脑科学的女大学生,喜欢刷qq,贴吧,知乎和小红书" + + PERSONALITY_1: float = 0.6 # 第一种人格概率 + PERSONALITY_2: float = 0.3 # 第二种人格概率 + PERSONALITY_3: float = 0.1 # 第三种人格概率 + + memory_ban_words: list = field( + default_factory=lambda: ["表情包", "图片", "回复", "聊天记录"] + ) # 添加新的配置项默认值 + @staticmethod def get_config_dir() -> str: """获取配置文件目录""" current_dir = os.path.dirname(os.path.abspath(__file__)) - root_dir = os.path.abspath(os.path.join(current_dir, '..', '..', '..')) - config_dir = os.path.join(root_dir, 'config') + root_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "..")) + config_dir = os.path.join(root_dir, "config") if not os.path.exists(config_dir): os.makedirs(config_dir) return config_dir @@ -108,48 +111,45 @@ class BotConfig: Args: value[str]: 版本表达式(字符串) Returns: - SpecifierSet + SpecifierSet """ try: converted = SpecifierSet(value) except InvalidSpecifier: - logger.error( - f"{value} 分类使用了错误的版本约束表达式\n", - "请阅读 https://semver.org/lang/zh-CN/ 修改代码" - ) + logger.error(f"{value} 分类使用了错误的版本约束表达式\n", "请阅读 https://semver.org/lang/zh-CN/ 修改代码") exit(1) return converted @classmethod def get_config_version(cls, toml: dict) -> Version: - """提取配置文件的 SpecifierSet 版本数据 + """提取配置文件的 SpecifierSet 版本数据 Args: toml[dict]: 输入的配置文件字典 Returns: - Version + Version """ - if 'inner' in toml: + if "inner" in toml: try: config_version: str = toml["inner"]["version"] except KeyError as e: logger.error("配置文件中 inner 段 不存在, 这是错误的配置文件") - raise KeyError(f"配置文件中 inner 段 不存在 {e}, 这是错误的配置文件") + raise KeyError(f"配置文件中 inner 段 不存在 {e}, 这是错误的配置文件") from e else: toml["inner"] = {"version": "0.0.0"} config_version = toml["inner"]["version"] try: ver = version.parse(config_version) - except InvalidVersion: + except InvalidVersion as e: logger.error( "配置文件中 inner段 的 version 键是错误的版本描述\n" "请阅读 https://semver.org/lang/zh-CN/ 修改配置,并参考本项目指定的模板进行修改\n" "本项目在不同的版本下有不同的模板,请注意识别" ) - raise InvalidVersion("配置文件中 inner段 的 version 键是错误的版本描述\n") + raise InvalidVersion("配置文件中 inner段 的 version 键是错误的版本描述\n") from e return ver @@ -159,26 +159,26 @@ class BotConfig: config = cls() def personality(parent: dict): - personality_config = parent['personality'] - personality = personality_config.get('prompt_personality') + personality_config = parent["personality"] + personality = personality_config.get("prompt_personality") if len(personality) >= 2: logger.debug(f"载入自定义人格:{personality}") - config.PROMPT_PERSONALITY = personality_config.get('prompt_personality', config.PROMPT_PERSONALITY) + config.PROMPT_PERSONALITY = personality_config.get("prompt_personality", config.PROMPT_PERSONALITY) logger.info(f"载入自定义日程prompt:{personality_config.get('prompt_schedule', config.PROMPT_SCHEDULE_GEN)}") - config.PROMPT_SCHEDULE_GEN = personality_config.get('prompt_schedule', config.PROMPT_SCHEDULE_GEN) + config.PROMPT_SCHEDULE_GEN = personality_config.get("prompt_schedule", config.PROMPT_SCHEDULE_GEN) if config.INNER_VERSION in SpecifierSet(">=0.0.2"): - config.PERSONALITY_1 = personality_config.get('personality_1_probability', config.PERSONALITY_1) - config.PERSONALITY_2 = personality_config.get('personality_2_probability', config.PERSONALITY_2) - config.PERSONALITY_3 = personality_config.get('personality_3_probability', config.PERSONALITY_3) + config.PERSONALITY_1 = personality_config.get("personality_1_probability", config.PERSONALITY_1) + config.PERSONALITY_2 = personality_config.get("personality_2_probability", config.PERSONALITY_2) + config.PERSONALITY_3 = personality_config.get("personality_3_probability", config.PERSONALITY_3) def emoji(parent: dict): emoji_config = parent["emoji"] config.EMOJI_CHECK_INTERVAL = emoji_config.get("check_interval", config.EMOJI_CHECK_INTERVAL) config.EMOJI_REGISTER_INTERVAL = emoji_config.get("register_interval", config.EMOJI_REGISTER_INTERVAL) - config.EMOJI_CHECK_PROMPT = emoji_config.get('check_prompt', config.EMOJI_CHECK_PROMPT) - config.EMOJI_SAVE = emoji_config.get('auto_save', config.EMOJI_SAVE) - config.EMOJI_CHECK = emoji_config.get('enable_check', config.EMOJI_CHECK) + config.EMOJI_CHECK_PROMPT = emoji_config.get("check_prompt", config.EMOJI_CHECK_PROMPT) + config.EMOJI_SAVE = emoji_config.get("auto_save", config.EMOJI_SAVE) + config.EMOJI_CHECK = emoji_config.get("enable_check", config.EMOJI_CHECK) def cq_code(parent: dict): cq_code_config = parent["cq_code"] @@ -195,8 +195,9 @@ class BotConfig: response_config = parent["response"] config.MODEL_R1_PROBABILITY = response_config.get("model_r1_probability", config.MODEL_R1_PROBABILITY) config.MODEL_V3_PROBABILITY = response_config.get("model_v3_probability", config.MODEL_V3_PROBABILITY) - config.MODEL_R1_DISTILL_PROBABILITY = response_config.get("model_r1_distill_probability", - config.MODEL_R1_DISTILL_PROBABILITY) + config.MODEL_R1_DISTILL_PROBABILITY = response_config.get( + "model_r1_distill_probability", config.MODEL_R1_DISTILL_PROBABILITY + ) config.max_response_length = response_config.get("max_response_length", config.max_response_length) def model(parent: dict): @@ -213,7 +214,7 @@ class BotConfig: "llm_emotion_judge", "vlm", "embedding", - "moderation" + "moderation", ] for item in config_list: @@ -222,13 +223,7 @@ class BotConfig: # base_url 的例子: SILICONFLOW_BASE_URL # key 的例子: SILICONFLOW_KEY - cfg_target = { - "name": "", - "base_url": "", - "key": "", - "pri_in": 0, - "pri_out": 0 - } + cfg_target = {"name": "", "base_url": "", "key": "", "pri_in": 0, "pri_out": 0} if config.INNER_VERSION in SpecifierSet("<=0.0.0"): cfg_target = cfg_item @@ -247,7 +242,7 @@ class BotConfig: cfg_target[i] = cfg_item[i] except KeyError as e: logger.error(f"{item} 中的必要字段不存在,请检查") - raise KeyError(f"{item} 中的必要字段 {e} 不存在,请检查") + raise KeyError(f"{item} 中的必要字段 {e} 不存在,请检查") from e provider = cfg_item.get("provider") if provider is None: @@ -272,17 +267,19 @@ class BotConfig: if config.INNER_VERSION in SpecifierSet(">=0.0.2"): config.thinking_timeout = msg_config.get("thinking_timeout", config.thinking_timeout) - config.response_willing_amplifier = msg_config.get("response_willing_amplifier", - config.response_willing_amplifier) - config.response_interested_rate_amplifier = msg_config.get("response_interested_rate_amplifier", - config.response_interested_rate_amplifier) + config.response_willing_amplifier = msg_config.get( + "response_willing_amplifier", config.response_willing_amplifier + ) + config.response_interested_rate_amplifier = msg_config.get( + "response_interested_rate_amplifier", config.response_interested_rate_amplifier + ) config.down_frequency_rate = msg_config.get("down_frequency_rate", config.down_frequency_rate) def memory(parent: dict): memory_config = parent["memory"] config.build_memory_interval = memory_config.get("build_memory_interval", config.build_memory_interval) config.forget_memory_interval = memory_config.get("forget_memory_interval", config.forget_memory_interval) - + # 在版本 >= 0.0.4 时才处理新增的配置项 if config.INNER_VERSION in SpecifierSet(">=0.0.4"): config.memory_ban_words = set(memory_config.get("memory_ban_words", [])) @@ -303,10 +300,12 @@ class BotConfig: config.chinese_typo_enable = chinese_typo_config.get("enable", config.chinese_typo_enable) config.chinese_typo_error_rate = chinese_typo_config.get("error_rate", config.chinese_typo_error_rate) config.chinese_typo_min_freq = chinese_typo_config.get("min_freq", config.chinese_typo_min_freq) - config.chinese_typo_tone_error_rate = chinese_typo_config.get("tone_error_rate", - config.chinese_typo_tone_error_rate) - config.chinese_typo_word_replace_rate = chinese_typo_config.get("word_replace_rate", - config.chinese_typo_word_replace_rate) + config.chinese_typo_tone_error_rate = chinese_typo_config.get( + "tone_error_rate", config.chinese_typo_tone_error_rate + ) + config.chinese_typo_word_replace_rate = chinese_typo_config.get( + "word_replace_rate", config.chinese_typo_word_replace_rate + ) def groups(parent: dict): groups_config = parent["groups"] @@ -325,61 +324,19 @@ class BotConfig: # 例如:"notice": "personality 将在 1.3.2 后被移除",那么在有效版本中的用户就会虽然可以 # 正常执行程序,但是会看到这条自定义提示 include_configs = { - "personality": { - "func": personality, - "support": ">=0.0.0" - }, - "emoji": { - "func": emoji, - "support": ">=0.0.0" - }, - "cq_code": { - "func": cq_code, - "support": ">=0.0.0" - }, - "bot": { - "func": bot, - "support": ">=0.0.0" - }, - "response": { - "func": response, - "support": ">=0.0.0" - }, - "model": { - "func": model, - "support": ">=0.0.0" - }, - "message": { - "func": message, - "support": ">=0.0.0" - }, - "memory": { - "func": memory, - "support": ">=0.0.0", - "necessary": False - }, - "mood": { - "func": mood, - "support": ">=0.0.0" - }, - "keywords_reaction": { - "func": keywords_reaction, - "support": ">=0.0.2", - "necessary": False - }, - "chinese_typo": { - "func": chinese_typo, - "support": ">=0.0.3", - "necessary": False - }, - "groups": { - "func": groups, - "support": ">=0.0.0" - }, - "others": { - "func": others, - "support": ">=0.0.0" - } + "personality": {"func": personality, "support": ">=0.0.0"}, + "emoji": {"func": emoji, "support": ">=0.0.0"}, + "cq_code": {"func": cq_code, "support": ">=0.0.0"}, + "bot": {"func": bot, "support": ">=0.0.0"}, + "response": {"func": response, "support": ">=0.0.0"}, + "model": {"func": model, "support": ">=0.0.0"}, + "message": {"func": message, "support": ">=0.0.0"}, + "memory": {"func": memory, "support": ">=0.0.0", "necessary": False}, + "mood": {"func": mood, "support": ">=0.0.0"}, + "keywords_reaction": {"func": keywords_reaction, "support": ">=0.0.2", "necessary": False}, + "chinese_typo": {"func": chinese_typo, "support": ">=0.0.3", "necessary": False}, + "groups": {"func": groups, "support": ">=0.0.0"}, + "others": {"func": others, "support": ">=0.0.0"}, } # 原地修改,将 字符串版本表达式 转换成 版本对象 @@ -391,7 +348,7 @@ class BotConfig: with open(config_path, "rb") as f: try: toml_dict = tomli.load(f) - except(tomli.TOMLDecodeError) as e: + except tomli.TOMLDecodeError as e: logger.critical(f"配置文件bot_config.toml填写有误,请检查第{e.lineno}行第{e.colno}处:{e.msg}") exit(1) @@ -406,7 +363,7 @@ class BotConfig: # 检查配置文件版本是否在支持范围内 if config.INNER_VERSION in group_specifierset: # 如果版本在支持范围内,检查是否存在通知 - if 'notice' in include_configs[key]: + if "notice" in include_configs[key]: logger.warning(include_configs[key]["notice"]) include_configs[key]["func"](toml_dict) @@ -420,7 +377,7 @@ class BotConfig: raise InvalidVersion(f"当前程序仅支持以下版本范围: {group_specifierset}") # 如果 necessary 项目存在,而且显式声明是 False,进入特殊处理 - elif "necessary" in include_configs[key] and include_configs[key].get("necessary") == False: + elif "necessary" in include_configs[key] and include_configs[key].get("necessary") is False: # 通过 pass 处理的项虽然直接忽略也是可以的,但是为了不增加理解困难,依然需要在这里显式处理 if key == "keywords_reaction": pass From c789074c5da0bfc121bf402671d73840403b8dfa Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 03:15:01 +0800 Subject: [PATCH 042/162] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0ruff=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | Bin 600 -> 612 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4f969682f9b0ebbbd9aebe4fe94758da052d23b4..0acaade5e6d465465ee87009b5c28551e591953d 100644 GIT binary patch delta 20 acmcb?@`PnW1QQQ00~bRPLn%WV5CQ-^9t5QT delta 7 OcmaFDa)V_<1QP%aWdhFt From 0f99d6a5b2c10c034ca964440d3e089c2d76b149 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 03:34:45 +0800 Subject: [PATCH 043/162] Update docs/docker_deploy.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- docs/docker_deploy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docker_deploy.md b/docs/docker_deploy.md index a5d648885..db759dfd0 100644 --- a/docs/docker_deploy.md +++ b/docs/docker_deploy.md @@ -48,7 +48,7 @@ NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose up -d - 重启Docker容器: ```bash -docker restart maimbot # 若修改过容器名称则替换maimbot为你自定的名臣 +docker restart maimbot # 若修改过容器名称则替换maimbot为你自定的名称 ``` - 下方命令可以但不推荐,只是同时重启NapCat、MongoDB、MaiMBot三个服务 From 00e02edc73c46d3bcb250b3bcdc6b063447e0f08 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 04:00:23 +0800 Subject: [PATCH 044/162] =?UTF-8?q?fix:=200.0.5=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=9A=84=E5=A2=9E=E5=8A=A0=E5=88=86=E5=B1=82=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index bc69ffd4e..c088e1a76 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -17,7 +17,7 @@ class BotConfig: BOT_QQ: Optional[int] = 1 BOT_NICKNAME: Optional[str] = None - BOT_ALIAS_NAMES: List[str] = field(default_factory=list) # 别名,可以通过这个叫它 + BOT_ALIAS_NAMES: List[str] = field(default_factory=list) # 别名,可以通过这个叫它 # 消息处理相关配置 MIN_TEXT_LENGTH: int = 2 # 最小处理文本长度 @@ -191,7 +191,9 @@ class BotConfig: bot_qq = bot_config.get("qq") config.BOT_QQ = int(bot_qq) config.BOT_NICKNAME = bot_config.get("nickname", config.BOT_NICKNAME) - config.BOT_ALIAS_NAMES = bot_config.get("alias_names", config.BOT_ALIAS_NAMES) + + if config.INNER_VERSION in SpecifierSet(">=0.0.5"): + config.BOT_ALIAS_NAMES = bot_config.get("alias_names", config.BOT_ALIAS_NAMES) def response(parent: dict): response_config = parent["response"] From eede406e55f1b25130ce65d37d9c08018166fcf8 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 04:00:39 +0800 Subject: [PATCH 045/162] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dnonebot?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=8A=A0=E8=BD=BD=E9=A1=B9=E7=9B=AE=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruff.toml => pyproject.toml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) rename ruff.toml => pyproject.toml (77%) diff --git a/ruff.toml b/pyproject.toml similarity index 77% rename from ruff.toml rename to pyproject.toml index 54231339f..0a4805744 100644 --- a/ruff.toml +++ b/pyproject.toml @@ -1,9 +1,20 @@ +[project] +name = "MaiMaiBot" +version = "0.1.0" +description = "MaiMaiBot" + +[tool.nonebot] +plugins = ["src.plugins.chat"] +plugin_dirs = ["src/plugins"] + +[tool.ruff] + include = ["*.py"] # 行长度设置 line-length = 120 -[lint] +[tool.ruff.lint] fixable = ["ALL"] unfixable = [] @@ -19,7 +30,7 @@ select = [ ignore = ["E711"] -[format] +[tool.ruff.format] docstring-code-format = true indent-style = "space" From 9e41c4f4c5c7ea90297507d9670c38f0abf04e97 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 04:07:56 +0800 Subject: [PATCH 046/162] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=20bot=5Fconf?= =?UTF-8?q?ig=200.0.5=20=E7=89=88=E6=9C=AC=E7=9A=84=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog_config.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/changelog_config.md b/changelog_config.md index 7101fe828..c4c560644 100644 --- a/changelog_config.md +++ b/changelog_config.md @@ -1,6 +1,12 @@ # Changelog +## [0.0.5] - 2025-3-11 +### Added +- 新增了 `alias_names` 配置项,用于指定麦麦的别名。 + ## [0.0.4] - 2025-3-9 ### Added - 新增了 `memory_ban_words` 配置项,用于指定不希望记忆的词汇。 + + From 8cbf9bb0483630dc73469232637e4dea667cc9e1 Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 04:42:24 +0800 Subject: [PATCH 047/162] =?UTF-8?q?feat:=20=E5=8F=B2=E4=B8=8A=E6=9C=80?= =?UTF-8?q?=E5=A5=BD=E7=9A=84=E6=B6=88=E6=81=AF=E6=B5=81=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=92=8C=E5=9B=BE=E7=89=87=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/__init__.py | 3 + src/plugins/chat/bot.py | 28 +++++-- src/plugins/chat/chat_stream.py | 30 +++++--- src/plugins/chat/cq_code.py | 44 ++++++++--- src/plugins/chat/emoji_manager.py | 10 +-- src/plugins/chat/llm_generator.py | 6 +- src/plugins/chat/message.py | 31 +++++--- src/plugins/chat/message_base.py | 83 ++++++++++----------- src/plugins/chat/message_cq.py | 54 +++++--------- src/plugins/chat/message_sender.py | 34 ++++----- src/plugins/chat/relationship_manager.py | 12 +-- src/plugins/chat/utils_image.py | 93 +++--------------------- src/plugins/models/utils_model.py | 79 +++++++++++++++++++- 13 files changed, 272 insertions(+), 235 deletions(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index 0bffaed19..a62343d0c 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -18,6 +18,7 @@ from .config import global_config from .emoji_manager import emoji_manager from .relationship_manager import relationship_manager from .willing_manager import willing_manager +from .chat_stream import chat_manager # 创建LLM统计实例 llm_stats = LLMStatistics("llm_statistics.txt") @@ -101,6 +102,8 @@ async def _(bot: Bot): asyncio.create_task(emoji_manager._periodic_scan(interval_MINS=global_config.EMOJI_REGISTER_INTERVAL)) print("\033[1;38;5;208m-----------开始偷表情包!-----------\033[0m") + asyncio.create_task(chat_manager._initialize()) + asyncio.create_task(chat_manager._auto_save_task()) @group_msg.handle() async def _(bot: Bot, event: GroupMessageEvent, state: T_State): diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index a2fdab873..a5f4ac476 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -18,7 +18,7 @@ from .chat_stream import chat_manager from .message_sender import message_manager # 导入新的消息管理器 from .relationship_manager import relationship_manager from .storage import MessageStorage -from .utils import calculate_typing_time, is_mentioned_bot_in_txt +from .utils import calculate_typing_time, is_mentioned_bot_in_message from .utils_image import image_path_to_base64 from .willing_manager import willing_manager # 导入意愿管理器 from .message_base import UserInfo, GroupInfo, Seg @@ -45,8 +45,8 @@ class ChatBot: self.bot = bot # 更新 bot 实例 - group_info = await bot.get_group_info(group_id=event.group_id) - sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) + # group_info = await bot.get_group_info(group_id=event.group_id) + # sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) # 白名单设定由nontbot侧完成 if event.group_id: @@ -54,19 +54,32 @@ class ChatBot: return if event.user_id in global_config.ban_user_id: return + + user_info=UserInfo( + user_id=event.user_id, + user_nickname=event.sender.nickname, + user_cardname=event.sender.card or None, + platform='qq' + ) + + group_info=GroupInfo( + group_id=event.group_id, + group_name=None, + platform='qq' + ) message_cq=MessageRecvCQ( message_id=event.message_id, - user_id=event.user_id, + user_info=user_info, raw_message=str(event.original_message), - group_id=event.group_id, + group_info=group_info, reply_message=event.reply, platform='qq' ) message_json=message_cq.to_dict() # 进入maimbot - message=MessageRecv(**message_json) + message=MessageRecv(message_json) groupinfo=message.message_info.group_info userinfo=message.message_info.user_info @@ -75,6 +88,7 @@ class ChatBot: # 消息过滤,涉及到config有待更新 chat = await chat_manager.get_or_create_stream(platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo) + message.update_chat_stream(chat) await relationship_manager.update_relationship(chat_stream=chat,) await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value = 0.5) @@ -99,7 +113,7 @@ class ChatBot: await self.storage.store_message(message,chat, topic[0] if topic else None) - is_mentioned = is_mentioned_bot_in_txt(message.processed_plain_text) + is_mentioned = is_mentioned_bot_in_message(message) reply_probability = await willing_manager.change_reply_willing_received( chat_stream=chat, topic=topic[0] if topic else None, diff --git a/src/plugins/chat/chat_stream.py b/src/plugins/chat/chat_stream.py index 36c97bed0..bee679173 100644 --- a/src/plugins/chat/chat_stream.py +++ b/src/plugins/chat/chat_stream.py @@ -1,6 +1,7 @@ import asyncio import hashlib import time +import copy from typing import Dict, Optional from loguru import logger @@ -86,9 +87,9 @@ class ChatManager: self._ensure_collection() self._initialized = True # 在事件循环中启动初始化 - asyncio.create_task(self._initialize()) - # 启动自动保存任务 - asyncio.create_task(self._auto_save_task()) + # asyncio.create_task(self._initialize()) + # # 启动自动保存任务 + # asyncio.create_task(self._auto_save_task()) async def _initialize(self): """异步初始化""" @@ -122,12 +123,18 @@ class ChatManager: self, platform: str, user_info: UserInfo, group_info: Optional[GroupInfo] = None ) -> str: """生成聊天流唯一ID""" - # 组合关键信息 - components = [ - platform, - str(user_info.user_id), - str(group_info.group_id) if group_info else "private", - ] + if group_info: + # 组合关键信息 + components = [ + platform, + str(group_info.group_id) + ] + else: + components = [ + platform, + str(user_info.user_id), + "private" + ] # 使用MD5生成唯一ID key = "_".join(components) @@ -153,10 +160,11 @@ class ChatManager: if stream_id in self.streams: stream = self.streams[stream_id] # 更新用户信息和群组信息 + stream.update_active_time() + stream=copy.deepcopy(stream) stream.user_info = user_info if group_info: stream.group_info = group_info - stream.update_active_time() return stream # 检查数据库中是否存在 @@ -180,7 +188,7 @@ class ChatManager: # 保存到内存和数据库 self.streams[stream_id] = stream await self._save_stream(stream) - return stream + return copy.deepcopy(stream) def get_stream(self, stream_id: str) -> Optional[ChatStream]: """通过stream_id获取聊天流""" diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index 7581f8a33..6030b893f 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -59,6 +59,7 @@ class CQCode: params: Dict[str, str] group_id: int user_id: int + user_nickname: str group_name: str = "" user_nickname: str = "" translated_segments: Optional[Union[Seg, List[Seg]]] = None @@ -68,9 +69,7 @@ class CQCode: def __post_init__(self): """初始化LLM实例""" - self._llm = LLM_request( - model=global_config.vlm, temperature=0.4, max_tokens=300 - ) + pass def translate(self): """根据CQ码类型进行相应的翻译处理,转换为Seg对象""" @@ -225,8 +224,7 @@ class CQCode: group_id=msg.get("group_id", 0), ) content_seg = Seg( - type="seglist", data=message_obj.message_segments - ) + type="seglist", data=message_obj.message_segment ) else: content_seg = Seg(type="text", data="[空消息]") else: @@ -241,7 +239,7 @@ class CQCode: group_id=msg.get("group_id", 0), ) content_seg = Seg( - type="seglist", data=message_obj.message_segments + type="seglist", data=message_obj.message_segment ) else: content_seg = Seg(type="text", data="[空消息]") @@ -272,7 +270,7 @@ class CQCode: ) segments = [] - if message_obj.user_id == global_config.BOT_QQ: + if message_obj.message_info.user_info.user_id == global_config.BOT_QQ: segments.append( Seg( type="text", data=f"[回复 {global_config.BOT_NICKNAME} 的消息: " @@ -286,7 +284,7 @@ class CQCode: ) ) - segments.append(Seg(type="seglist", data=message_obj.message_segments)) + segments.append(Seg(type="seglist", data=message_obj.message_segment)) segments.append(Seg(type="text", data="]")) return segments else: @@ -305,12 +303,13 @@ class CQCode: class CQCode_tool: @staticmethod - def cq_from_dict_to_class(cq_code: Dict, reply: Optional[Dict] = None) -> CQCode: + def cq_from_dict_to_class(cq_code: Dict,msg ,reply: Optional[Dict] = None) -> CQCode: """ 将CQ码字典转换为CQCode对象 Args: cq_code: CQ码字典 + msg: MessageCQ对象 reply: 回复消息的字典(可选) Returns: @@ -326,7 +325,13 @@ class CQCode_tool: params = cq_code.get("data", {}) instance = CQCode( - type=cq_type, params=params, group_id=0, user_id=0, reply_message=reply + type=cq_type, + params=params, + group_id=msg.message_info.group_info.group_id, + user_id=msg.message_info.user_info.user_id, + user_nickname=msg.message_info.user_info.user_nickname, + group_name=msg.message_info.group_info.group_name, + reply_message=reply ) # 进行翻译处理 @@ -383,6 +388,25 @@ class CQCode_tool: ) # 生成CQ码,设置sub_type=1表示这是表情包 return f"[CQ:image,file=base64://{escaped_base64},sub_type=1]" + + @staticmethod + def create_image_cq_base64(base64_data: str) -> str: + """ + 创建表情包CQ码 + Args: + base64_data: base64编码的表情包数据 + Returns: + 表情包CQ码字符串 + """ + # 转义base64数据 + escaped_base64 = ( + base64_data.replace("&", "&") + .replace("[", "[") + .replace("]", "]") + .replace(",", ",") + ) + # 生成CQ码,设置sub_type=1表示这是表情包 + return f"[CQ:image,file=base64://{escaped_base64},sub_type=0]" cq_code_tool = CQCode_tool() diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 837ee245d..f3728ce92 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -239,7 +239,7 @@ class EmojiManager: # 即使表情包已存在,也检查是否需要同步到images集合 description = existing_emoji.get('discription') # 检查是否在images集合中存在 - existing_image = await image_manager.db.db.images.find_one({'hash': image_hash}) + existing_image = image_manager.db.db.images.find_one({'hash': image_hash}) if not existing_image: # 同步到images集合 image_doc = { @@ -249,7 +249,7 @@ class EmojiManager: 'description': description, 'timestamp': int(time.time()) } - await image_manager.db.db.images.update_one( + image_manager.db.db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True @@ -260,7 +260,7 @@ class EmojiManager: continue # 检查是否在images集合中已有描述 - existing_description = await image_manager._get_description_from_db(image_hash, 'emoji') + existing_description = image_manager._get_description_from_db(image_hash, 'emoji') if existing_description: description = existing_description @@ -302,13 +302,13 @@ class EmojiManager: 'description': description, 'timestamp': int(time.time()) } - await image_manager.db.db.images.update_one( + image_manager.db.db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True ) # 保存描述到image_descriptions集合 - await image_manager._save_description_to_db(image_hash, description, 'emoji') + image_manager._save_description_to_db(image_hash, description, 'emoji') logger.success(f"同步保存到images集合: {filename}") else: logger.warning(f"跳过表情包: {filename}") diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index dc019038e..bfd5eec2e 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -7,7 +7,7 @@ from nonebot import get_driver from ...common.database import Database from ..models.utils_model import LLM_request from .config import global_config -from .message import MessageRecv, MessageThinking, MessageSending +from .message import MessageRecv, MessageThinking, MessageSending,Message from .prompt_builder import prompt_builder from .relationship_manager import relationship_manager from .utils import process_llm_response @@ -144,7 +144,7 @@ class ResponseGenerator: # content: str, content_check: str, reasoning_content: str, reasoning_content_check: str): def _save_to_db( self, - message: Message, + message: MessageRecv, sender_name: str, prompt: str, prompt_check: str, @@ -155,7 +155,7 @@ class ResponseGenerator: self.db.db.reasoning_logs.insert_one( { "time": time.time(), - "group_id": message.group_id, + "chat_id": message.chat_stream.stream_id, "user": sender_name, "message": message.processed_plain_text, "model": self.current_model_type, diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index 408937fad..5eb93d700 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -7,7 +7,7 @@ from loguru import logger from .utils_image import image_manager from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase -from .chat_stream import ChatStream +from .chat_stream import ChatStream, chat_manager # 禁用SSL警告 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -25,8 +25,8 @@ class MessageRecv(MessageBase): Args: message_dict: MessageCQ序列化后的字典 """ - message_info = BaseMessageInfo(**message_dict.get('message_info', {})) - message_segment = Seg(**message_dict.get('message_segment', {})) + message_info = BaseMessageInfo.from_dict(message_dict.get('message_info', {})) + message_segment = Seg.from_dict(message_dict.get('message_segment', {})) raw_message = message_dict.get('raw_message') super().__init__( @@ -39,7 +39,9 @@ class MessageRecv(MessageBase): self.processed_plain_text = "" # 初始化为空字符串 self.detailed_plain_text = "" # 初始化为空字符串 self.is_emoji=False - + def update_chat_stream(self,chat_stream:ChatStream): + self.chat_stream=chat_stream + async def process(self) -> None: """处理消息内容,生成纯文本和详细文本 @@ -83,12 +85,12 @@ class MessageRecv(MessageBase): return seg.data elif seg.type == 'image': # 如果是base64图片数据 - if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): + if isinstance(seg.data, str): return await image_manager.get_image_description(seg.data) return '[图片]' elif seg.type == 'emoji': self.is_emoji=True - if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): + if isinstance(seg.data, str): return await image_manager.get_emoji_description(seg.data) return '[表情]' else: @@ -217,11 +219,11 @@ class MessageProcessBase(Message): return seg.data elif seg.type == 'image': # 如果是base64图片数据 - if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): + if isinstance(seg.data, str): return await image_manager.get_image_description(seg.data) return '[图片]' elif seg.type == 'emoji': - if isinstance(seg.data, str) and seg.data.startswith(('data:', 'base64:')): + if isinstance(seg.data, str): return await image_manager.get_emoji_description(seg.data) return '[表情]' elif seg.type == 'at': @@ -296,10 +298,15 @@ class MessageSending(MessageProcessBase): self.reply_to_message_id = reply.message_info.message_id if reply else None self.is_head = is_head self.is_emoji = is_emoji - if is_head: + + def set_reply(self, reply: Optional['MessageRecv']) -> None: + """设置回复消息""" + if reply: + self.reply = reply + self.reply_to_message_id = self.reply.message_info.message_id self.message_segment = Seg(type='seglist', data=[ - Seg(type='reply', data=reply.message_info.message_id), - self.message_segment + Seg(type='reply', data=reply.message_info.message_id), + self.message_segment ]) async def process(self) -> None: @@ -329,7 +336,7 @@ class MessageSending(MessageProcessBase): def to_dict(self): ret= super().to_dict() - ret['mesage_info']['user_info']=self.chat_stream.user_info.to_dict() + ret['message_info']['user_info']=self.chat_stream.user_info.to_dict() return ret @dataclass diff --git a/src/plugins/chat/message_base.py b/src/plugins/chat/message_base.py index 7b76403de..d17c2c357 100644 --- a/src/plugins/chat/message_base.py +++ b/src/plugins/chat/message_base.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, asdict from typing import List, Optional, Union, Any, Dict @dataclass -class Seg(dict): +class Seg: """消息片段类,用于表示消息的不同部分 Attributes: @@ -15,47 +15,25 @@ class Seg(dict): """ type: str data: Union[str, List['Seg']] - translated_data: Optional[str] = None + - def __init__(self, type: str, data: Union[str, List['Seg']], translated_data: Optional[str] = None): - """初始化实例,确保字典和属性同步""" - # 先初始化字典 - super().__init__(type=type, data=data) - if translated_data is not None: - self['translated_data'] = translated_data - - # 再初始化属性 - object.__setattr__(self, 'type', type) - object.__setattr__(self, 'data', data) - object.__setattr__(self, 'translated_data', translated_data) + # def __init__(self, type: str, data: Union[str, List['Seg']],): + # """初始化实例,确保字典和属性同步""" + # # 先初始化字典 + # self.type = type + # self.data = data - # 验证数据类型 - self._validate_data() - - def _validate_data(self) -> None: - """验证数据类型的正确性""" - if self.type == 'seglist' and not isinstance(self.data, list): - raise ValueError("seglist类型的data必须是列表") - elif self.type == 'text' and not isinstance(self.data, str): - raise ValueError("text类型的data必须是字符串") - elif self.type == 'image' and not isinstance(self.data, str): - raise ValueError("image类型的data必须是字符串") - - def __setattr__(self, name: str, value: Any) -> None: - """重写属性设置,同时更新字典值""" - # 更新属性 - object.__setattr__(self, name, value) - # 同步更新字典 - if name in ['type', 'data', 'translated_data']: - self[name] = value - - def __setitem__(self, key: str, value: Any) -> None: - """重写字典值设置,同时更新属性""" - # 更新字典 - super().__setitem__(key, value) - # 同步更新属性 - if key in ['type', 'data', 'translated_data']: - object.__setattr__(self, key, value) + @classmethod + def from_dict(cls, data: Dict) -> 'Seg': + """从字典创建Seg实例""" + type=data.get('type') + data=data.get('data') + if type == 'seglist': + data = [Seg.from_dict(seg) for seg in data] + return cls( + type=type, + data=data + ) def to_dict(self) -> Dict: """转换为字典格式""" @@ -64,8 +42,6 @@ class Seg(dict): result['data'] = [seg.to_dict() for seg in self.data] else: result['data'] = self.data - if self.translated_data is not None: - result['translated_data'] = self.translated_data return result @dataclass @@ -79,6 +55,7 @@ class GroupInfo: """转换为字典格式""" return {k: v for k, v in asdict(self).items() if v is not None} + @classmethod def from_dict(cls, data: Dict) -> 'GroupInfo': """从字典创建GroupInfo实例 @@ -106,6 +83,7 @@ class UserInfo: """转换为字典格式""" return {k: v for k, v in asdict(self).items() if v is not None} + @classmethod def from_dict(cls, data: Dict) -> 'UserInfo': """从字典创建UserInfo实例 @@ -126,7 +104,7 @@ class UserInfo: class BaseMessageInfo: """消息信息类""" platform: Optional[str] = None - message_id: Optional[int,str] = None + message_id: Union[str,int,None] = None time: Optional[int] = None group_info: Optional[GroupInfo] = None user_info: Optional[UserInfo] = None @@ -141,6 +119,25 @@ class BaseMessageInfo: else: result[field] = value return result + @classmethod + def from_dict(cls, data: Dict) -> 'BaseMessageInfo': + """从字典创建BaseMessageInfo实例 + + Args: + data: 包含必要字段的字典 + + Returns: + BaseMessageInfo: 新的实例 + """ + group_info = GroupInfo(**data.get('group_info', {})) + user_info = UserInfo(**data.get('user_info', {})) + return cls( + platform=data.get('platform'), + message_id=data.get('message_id'), + time=data.get('time'), + group_info=group_info, + user_info=user_info + ) @dataclass class MessageBase: diff --git a/src/plugins/chat/message_cq.py b/src/plugins/chat/message_cq.py index 4d7489bbf..6bfa47c3f 100644 --- a/src/plugins/chat/message_cq.py +++ b/src/plugins/chat/message_cq.py @@ -27,27 +27,10 @@ class MessageCQ(MessageBase): def __init__( self, message_id: int, - user_id: int, - group_id: Optional[int] = None, + user_info: UserInfo, + group_info: Optional[GroupInfo] = None, platform: str = "qq" ): - # 构造用户信息 - user_info = UserInfo( - platform=platform, - user_id=user_id, - user_nickname=get_user_nickname(user_id), - user_cardname=get_user_cardname(user_id) if group_id else None - ) - - # 构造群组信息(如果有) - group_info = None - if group_id: - group_info = GroupInfo( - platform=platform, - group_id=group_id, - group_name=get_groupname(group_id) - ) - # 构造基础消息信息 message_info = BaseMessageInfo( platform=platform, @@ -56,7 +39,6 @@ class MessageCQ(MessageBase): group_info=group_info, user_info=user_info ) - # 调用父类初始化,message_segment 由子类设置 super().__init__( message_info=message_info, @@ -71,14 +53,17 @@ class MessageRecvCQ(MessageCQ): def __init__( self, message_id: int, - user_id: int, + user_info: UserInfo, raw_message: str, - group_id: Optional[int] = None, + group_info: Optional[GroupInfo] = None, + platform: str = "qq", reply_message: Optional[Dict] = None, - platform: str = "qq" ): # 调用父类初始化 - super().__init__(message_id, user_id, group_id, platform) + super().__init__(message_id, user_info, group_info, platform) + + if group_info.group_name is None: + group_info.group_name = get_groupname(group_info.group_id) # 解析消息段 self.message_segment = self._parse_message(raw_message, reply_message) @@ -117,7 +102,7 @@ class MessageRecvCQ(MessageCQ): # 转换CQ码为Seg对象 for code_item in cq_code_dict_list: - message_obj = cq_code_tool.cq_from_dict_to_class(code_item, reply=reply_message) + message_obj = cq_code_tool.cq_from_dict_to_class(code_item,msg=self,reply=reply_message) if message_obj.translated_segments: segments.append(message_obj.translated_segments) @@ -142,13 +127,14 @@ class MessageSendCQ(MessageCQ): data: Dict ): # 调用父类初始化 - message_info = BaseMessageInfo(**data.get('message_info', {})) - message_segment = Seg(**data.get('message_segment', {})) + message_info = BaseMessageInfo.from_dict(data.get('message_info', {})) + message_segment = Seg.from_dict(data.get('message_segment', {})) super().__init__( message_info.message_id, - message_info.user_info.user_id, - message_info.group_info.group_id if message_info.group_info else None, - message_info.platform) + message_info.user_info, + message_info.group_info if message_info.group_info else None, + message_info.platform + ) self.message_segment = message_segment self.raw_message = self._generate_raw_message() @@ -171,11 +157,9 @@ class MessageSendCQ(MessageCQ): if seg.type == 'text': return str(seg.data) elif seg.type == 'image': - # 如果是base64图片数据 - if seg.data.startswith(('data:', 'base64:')): - return cq_code_tool.create_emoji_cq_base64(seg.data) - # 如果是表情包(本地文件) - return cq_code_tool.create_emoji_cq(seg.data) + return cq_code_tool.create_image_cq_base64(seg.data) + elif seg.type == 'emoji': + return cq_code_tool.create_emoji_cq_base64(seg.data) elif seg.type == 'at': return f"[CQ:at,qq={seg.data}]" elif seg.type == 'reply': diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index ed91b614e..2c3880bb8 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -41,10 +41,10 @@ class Message_Sender: message=message_send.raw_message, auto_escape=False ) - print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") + print(f"\033[1;34m[调试]\033[0m 发送消息{message.processed_plain_text}成功") except Exception as e: print(f"发生错误 {e}") - print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") + print(f"\033[1;34m[调试]\033[0m 发送消息{message.processed_plain_text}失败") else: try: await self._current_bot.send_private_msg( @@ -52,10 +52,10 @@ class Message_Sender: message=message_send.raw_message, auto_escape=False ) - print(f"\033[1;34m[调试]\033[0m 发送消息{message}成功") + print(f"\033[1;34m[调试]\033[0m 发送消息{message.processed_plain_text}成功") except Exception as e: print(f"发生错误 {e}") - print(f"\033[1;34m[调试]\033[0m 发送消息{message}失败") + print(f"\033[1;34m[调试]\033[0m 发送消息{message.processed_plain_text}失败") class MessageContainer: @@ -137,11 +137,7 @@ class MessageManager: return self.containers[chat_id] def add_message(self, message: Union[MessageThinking, MessageSending, MessageSet]) -> None: - chat_stream = chat_manager.get_stream_by_info( - platform=message.message_info.platform, - user_info=message.message_info.user_info, - group_info=message.message_info.group_info - ) + chat_stream = message.chat_stream if not chat_stream: raise ValueError("无法找到对应的聊天流") container = self.get_container(chat_stream.stream_id) @@ -165,13 +161,14 @@ class MessageManager: else: print(f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中") if message_earliest.is_head and message_earliest.update_thinking_time() > 30: - await message_sender.send_message(message_earliest) + await message_sender.send_message(message_earliest.set_reply()) else: await message_sender.send_message(message_earliest) - if message_earliest.is_emoji: - message_earliest.processed_plain_text = "[表情包]" - await self.storage.store_message(message_earliest, None) + # if message_earliest.is_emoji: + # message_earliest.processed_plain_text = "[表情包]" + await message_earliest.process() + await self.storage.store_message(message_earliest, message_earliest.chat_stream,None) container.remove_message(message_earliest) @@ -184,13 +181,14 @@ class MessageManager: try: if msg.is_head and msg.update_thinking_time() > 30: - await message_sender.send_group_message(chat_id, msg.processed_plain_text, auto_escape=False, reply_message_id=msg.reply_message_id) + await message_sender.send_message(msg.set_reply()) else: - await message_sender.send_group_message(chat_id, msg.processed_plain_text, auto_escape=False) + await message_sender.send_message(msg) - if msg.is_emoji: - msg.processed_plain_text = "[表情包]" - await self.storage.store_message(msg, None) + # if msg.is_emoji: + # msg.processed_plain_text = "[表情包]" + await msg.process() + await self.storage.store_message(msg,msg.chat_stream, None) if not container.remove_message(msg): print("\033[1;33m[警告]\033[0m 尝试删除不存在的消息") diff --git a/src/plugins/chat/relationship_manager.py b/src/plugins/chat/relationship_manager.py index c08b962ed..5552aee8c 100644 --- a/src/plugins/chat/relationship_manager.py +++ b/src/plugins/chat/relationship_manager.py @@ -23,12 +23,12 @@ class Relationship: saved = False def __init__(self, chat:ChatStream=None,data:dict=None): - self.user_id=chat.user_info.user_id if chat.user_info else data.get('user_id',0) - self.platform=chat.platform if chat.user_info else data.get('platform','') - self.nickname=chat.user_info.user_nickname if chat.user_info else data.get('nickname','') - self.relationship_value=data.get('relationship_value',0) - self.age=data.get('age',0) - self.gender=data.get('gender','') + self.user_id=chat.user_info.user_id if chat else data.get('user_id',0) + self.platform=chat.platform if chat else data.get('platform','') + self.nickname=chat.user_info.user_nickname if chat else data.get('nickname','') + self.relationship_value=data.get('relationship_value',0) if data else 0 + self.age=data.get('age',0) if data else 0 + self.gender=data.get('gender','') if data else '' class RelationshipManager: diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index aba09714c..ac3ff5ac4 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -59,7 +59,7 @@ class ImageManager: self.db.db.image_descriptions.create_index([('hash', 1)], unique=True) self.db.db.image_descriptions.create_index([('type', 1)]) - async def _get_description_from_db(self, image_hash: str, description_type: str) -> Optional[str]: + def _get_description_from_db(self, image_hash: str, description_type: str) -> Optional[str]: """从数据库获取图片描述 Args: @@ -69,13 +69,13 @@ class ImageManager: Returns: Optional[str]: 描述文本,如果不存在则返回None """ - result = await self.db.db.image_descriptions.find_one({ + result= self.db.db.image_descriptions.find_one({ 'hash': image_hash, 'type': description_type }) return result['description'] if result else None - async def _save_description_to_db(self, image_hash: str, description: str, description_type: str) -> None: + def _save_description_to_db(self, image_hash: str, description: str, description_type: str) -> None: """保存图片描述到数据库 Args: @@ -83,7 +83,7 @@ class ImageManager: description: 描述文本 description_type: 描述类型 ('emoji' 或 'image') """ - await self.db.db.image_descriptions.update_one( + self.db.db.image_descriptions.update_one( {'hash': image_hash, 'type': description_type}, { '$set': { @@ -253,8 +253,9 @@ class ImageManager: image_hash = hashlib.md5(image_bytes).hexdigest() # 查询缓存的描述 - cached_description = await self._get_description_from_db(image_hash, 'emoji') + cached_description = self._get_description_from_db(image_hash, 'emoji') if cached_description: + logger.info(f"缓存表情包描述: {cached_description}") return f"[表情包:{cached_description}]" # 调用AI获取描述 @@ -281,7 +282,7 @@ class ImageManager: 'description': description, 'timestamp': timestamp } - await self.db.db.images.update_one( + self.db.db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True @@ -291,7 +292,7 @@ class ImageManager: logger.error(f"保存表情包文件失败: {str(e)}") # 保存描述到数据库 - await self._save_description_to_db(image_hash, description, 'emoji') + self._save_description_to_db(image_hash, description, 'emoji') return f"[表情包:{description}]" except Exception as e: @@ -306,7 +307,7 @@ class ImageManager: image_hash = hashlib.md5(image_bytes).hexdigest() # 查询缓存的描述 - cached_description = await self._get_description_from_db(image_hash, 'image') + cached_description = self._get_description_from_db(image_hash, 'image') if cached_description: return f"[图片:{cached_description}]" @@ -334,7 +335,7 @@ class ImageManager: 'description': description, 'timestamp': timestamp } - await self.db.db.images.update_one( + self.db.db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True @@ -357,80 +358,6 @@ class ImageManager: image_manager = ImageManager() -def compress_base64_image_by_scale(base64_data: str, target_size: int = 0.8 * 1024 * 1024) -> str: - """压缩base64格式的图片到指定大小 - Args: - base64_data: base64编码的图片数据 - target_size: 目标文件大小(字节),默认0.8MB - Returns: - str: 压缩后的base64图片数据 - """ - try: - # 将base64转换为字节数据 - image_data = base64.b64decode(base64_data) - - # 如果已经小于目标大小,直接返回原图 - if len(image_data) <= 2*1024*1024: - return base64_data - - # 将字节数据转换为图片对象 - img = Image.open(io.BytesIO(image_data)) - - # 获取原始尺寸 - original_width, original_height = img.size - - # 计算缩放比例 - scale = min(1.0, (target_size / len(image_data)) ** 0.5) - - # 计算新的尺寸 - new_width = int(original_width * scale) - new_height = int(original_height * scale) - - # 创建内存缓冲区 - output_buffer = io.BytesIO() - - # 如果是GIF,处理所有帧 - if getattr(img, "is_animated", False): - frames = [] - for frame_idx in range(img.n_frames): - img.seek(frame_idx) - new_frame = img.copy() - new_frame = new_frame.resize((new_width//2, new_height//2), Image.Resampling.LANCZOS) # 动图折上折 - frames.append(new_frame) - - # 保存到缓冲区 - frames[0].save( - output_buffer, - format='GIF', - save_all=True, - append_images=frames[1:], - optimize=True, - duration=img.info.get('duration', 100), - loop=img.info.get('loop', 0) - ) - else: - # 处理静态图片 - resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - # 保存到缓冲区,保持原始格式 - if img.format == 'PNG' and img.mode in ('RGBA', 'LA'): - resized_img.save(output_buffer, format='PNG', optimize=True) - else: - resized_img.save(output_buffer, format='JPEG', quality=95, optimize=True) - - # 获取压缩后的数据并转换为base64 - compressed_data = output_buffer.getvalue() - logger.success(f"压缩图片: {original_width}x{original_height} -> {new_width}x{new_height}") - logger.info(f"压缩前大小: {len(image_data)/1024:.1f}KB, 压缩后大小: {len(compressed_data)/1024:.1f}KB") - - return base64.b64encode(compressed_data).decode('utf-8') - - except Exception as e: - logger.error(f"压缩图片失败: {str(e)}") - import traceback - logger.error(traceback.format_exc()) - return base64_data - def image_path_to_base64(image_path: str) -> str: """将图片路径转换为base64编码 Args: diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index c70c26ff9..56ed80693 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -7,10 +7,11 @@ from typing import Tuple, Union import aiohttp from loguru import logger from nonebot import get_driver - +import base64 +from PIL import Image +import io from ...common.database import Database from ..chat.config import global_config -from ..chat.utils_image import compress_base64_image_by_scale driver = get_driver() config = driver.config @@ -405,3 +406,77 @@ class LLM_request: ) return embedding +def compress_base64_image_by_scale(base64_data: str, target_size: int = 0.8 * 1024 * 1024) -> str: + """压缩base64格式的图片到指定大小 + Args: + base64_data: base64编码的图片数据 + target_size: 目标文件大小(字节),默认0.8MB + Returns: + str: 压缩后的base64图片数据 + """ + try: + # 将base64转换为字节数据 + image_data = base64.b64decode(base64_data) + + # 如果已经小于目标大小,直接返回原图 + if len(image_data) <= 2*1024*1024: + return base64_data + + # 将字节数据转换为图片对象 + img = Image.open(io.BytesIO(image_data)) + + # 获取原始尺寸 + original_width, original_height = img.size + + # 计算缩放比例 + scale = min(1.0, (target_size / len(image_data)) ** 0.5) + + # 计算新的尺寸 + new_width = int(original_width * scale) + new_height = int(original_height * scale) + + # 创建内存缓冲区 + output_buffer = io.BytesIO() + + # 如果是GIF,处理所有帧 + if getattr(img, "is_animated", False): + frames = [] + for frame_idx in range(img.n_frames): + img.seek(frame_idx) + new_frame = img.copy() + new_frame = new_frame.resize((new_width//2, new_height//2), Image.Resampling.LANCZOS) # 动图折上折 + frames.append(new_frame) + + # 保存到缓冲区 + frames[0].save( + output_buffer, + format='GIF', + save_all=True, + append_images=frames[1:], + optimize=True, + duration=img.info.get('duration', 100), + loop=img.info.get('loop', 0) + ) + else: + # 处理静态图片 + resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # 保存到缓冲区,保持原始格式 + if img.format == 'PNG' and img.mode in ('RGBA', 'LA'): + resized_img.save(output_buffer, format='PNG', optimize=True) + else: + resized_img.save(output_buffer, format='JPEG', quality=95, optimize=True) + + # 获取压缩后的数据并转换为base64 + compressed_data = output_buffer.getvalue() + logger.success(f"压缩图片: {original_width}x{original_height} -> {new_width}x{new_height}") + logger.info(f"压缩前大小: {len(image_data)/1024:.1f}KB, 压缩后大小: {len(compressed_data)/1024:.1f}KB") + + return base64.b64encode(compressed_data).decode('utf-8') + + except Exception as e: + logger.error(f"压缩图片失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return base64_data + From a54ca8ce1819c3136627e0d2f89903f37d034545 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 05:05:07 +0800 Subject: [PATCH 048/162] Merge remote-tracking branch 'upstream/debug' into feat_regix --- .gitignore | 5 +- .vscode/settings.json | 3 + README.md | 29 +- bot.py | 65 +- changelog_config.md | 6 + docker-compose.yml | 4 +- docs/Jonathan R.md | 20 + docs/docker_deploy.md | 66 +- docs/installation_cute.md | 7 +- docs/installation_standard.md | 11 +- docs/manual_deploy_linux.md | 15 +- docs/manual_deploy_windows.md | 8 +- flake.nix | 1 + pyproject.toml | 50 +- requirements.txt | Bin 600 -> 612 bytes run.bat | 2 +- run.py | 2 + src/gui/reasoning_gui.py | 4 +- src/plugins/chat/__init__.py | 6 +- src/plugins/chat/bot.py | 6 +- src/plugins/chat/config.py | 185 ++- src/plugins/chat/cq_code.py | 6 +- src/plugins/chat/emoji_manager.py | 34 +- src/plugins/chat/llm_generator.py | 4 +- src/plugins/chat/message_sender.py | 6 +- src/plugins/chat/prompt_builder.py | 9 +- src/plugins/chat/storage.py | 2 +- src/plugins/chat/utils.py | 16 +- src/plugins/knowledege/knowledge_library.py | 2 +- src/plugins/memory_system/memory.py | 286 +++-- src/plugins/memory_system/memory_test1.py | 1208 +++++++++++++++++++ src/plugins/models/utils_model.py | 12 +- src/plugins/schedule/schedule_generator.py | 4 +- src/plugins/utils/statistic.py | 2 +- template/bot_config_template.toml | 1 + 35 files changed, 1746 insertions(+), 341 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 docs/Jonathan R.md create mode 100644 src/plugins/memory_system/memory_test1.py diff --git a/.gitignore b/.gitignore index 4e1606a54..e51abc5cc 100644 --- a/.gitignore +++ b/.gitignore @@ -193,9 +193,8 @@ cython_debug/ # jieba jieba.cache - -# vscode -/.vscode +# .vscode +!.vscode/settings.json # direnv /.direnv \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..23fd35f0e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/README.md b/README.md index 0c02d1cba..f4ebca07d 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,17 @@ -> ⚠️ **注意事项** +> [!WARNING] > - 项目处于活跃开发阶段,代码可能随时更改 > - 文档未完善,有问题可以提交 Issue 或者 Discussion > - QQ机器人存在被限制风险,请自行了解,谨慎使用 > - 由于持续迭代,可能存在一些已知或未知的bug > - 由于开发中,可能消耗较多token -**交流群**: 766798517 一群人较多,建议加下面的(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 -**交流群**: 571780722 另一个群(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 -**交流群**: 1035228475 另一个群(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 +## 💬交流群 +- [一群](https://qm.qq.com/q/VQ3XZrWgMs) 766798517 ,建议加下面的(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 +- [二群](https://qm.qq.com/q/RzmCiRtHEW) 571780722 (开发和建议相关讨论)不一定有空回复,会优先写文档和代码 +- [三群](https://qm.qq.com/q/wlH5eT8OmQ) 1035228475(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 **其他平台版本** @@ -51,18 +52,17 @@ ### 部署方式 -如果你不知道Docker是什么,建议寻找相关教程或使用手动部署(现在不建议使用docker,更新慢,可能不适配) +- 📦 **Windows 一键傻瓜式部署**:请运行项目根目录中的 `run.bat`,部署完成后请参照后续配置指南进行配置 + +- [📦 Windows 手动部署指南 ](docs/manual_deploy_windows.md) + +- [📦 Linux 手动部署指南 ](docs/manual_deploy_linux.md) + +如果你不知道Docker是什么,建议寻找相关教程或使用手动部署 **(现在不建议使用docker,更新慢,可能不适配)** - [🐳 Docker部署指南](docs/docker_deploy.md) -- [📦 手动部署指南 Windows](docs/manual_deploy_windows.md) - - -- [📦 手动部署指南 Linux](docs/manual_deploy_linux.md) - -- 📦 Windows 一键傻瓜式部署,请运行项目根目录中的 ```run.bat```,部署完成后请参照后续配置指南进行配置 - ### 配置说明 - [🎀 新手配置指南](docs/installation_cute.md) - 通俗易懂的配置教程,适合初次使用的猫娘 - [⚙️ 标准配置指南](docs/installation_standard.md) - 简明专业的配置说明,适合有经验的用户 @@ -140,9 +140,10 @@ ## 📌 注意事项 -SengokuCola纯编程外行,面向cursor编程,很多代码史一样多多包涵 -> ⚠️ **警告**:本应用生成内容来自人工智能模型,由 AI 生成,请仔细甄别,请勿用于违反法律的用途,AI生成内容不代表本人观点和立场。 +SengokuCola纯编程外行,面向cursor编程,很多代码史一样多多包涵 +> [!WARNING] +> 本应用生成内容来自人工智能模型,由 AI 生成,请仔细甄别,请勿用于违反法律的用途,AI生成内容不代表本人观点和立场。 ## 致谢 [nonebot2](https://github.com/nonebot/nonebot2): 跨平台 Python 异步聊天机器人框架 diff --git a/bot.py b/bot.py index c2ed3dfdf..471a98eaf 100644 --- a/bot.py +++ b/bot.py @@ -1,9 +1,12 @@ +import asyncio import os import shutil import sys import nonebot import time + +import uvicorn from dotenv import load_dotenv from loguru import logger from nonebot.adapters.onebot.v11 import Adapter @@ -12,6 +15,8 @@ import platform # 获取没有加载env时的环境变量 env_mask = {key: os.getenv(key) for key in os.environ} +uvicorn_server = None + def easter_egg(): # 彩蛋 @@ -100,10 +105,12 @@ def load_logger(): "#777777>| {name:.<8}:{function:.<8}:{line: >4} - {message}", colorize=True, - level=os.getenv("LOG_LEVEL", "DEBUG") # 根据环境设置日志级别,默认为INFO + level=os.getenv("LOG_LEVEL", "INFO"), # 根据环境设置日志级别,默认为INFO + filter=lambda record: "nonebot" not in record["name"] ) + def scan_provider(env_config: dict): provider = {} @@ -138,7 +145,39 @@ def scan_provider(env_config: dict): raise ValueError(f"请检查 '{provider_name}' 提供商配置是否丢失 BASE_URL 或 KEY 环境变量") -if __name__ == "__main__": +async def graceful_shutdown(): + try: + global uvicorn_server + if uvicorn_server: + uvicorn_server.force_exit = True # 强制退出 + await uvicorn_server.shutdown() + + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + except Exception as e: + logger.error(f"麦麦关闭失败: {e}") + + +async def uvicorn_main(): + global uvicorn_server + config = uvicorn.Config( + app="__main__:app", + host=os.getenv("HOST", "127.0.0.1"), + port=int(os.getenv("PORT", 8080)), + reload=os.getenv("ENVIRONMENT") == "dev", + timeout_graceful_shutdown=5, + log_config=None, + access_log=False + ) + server = uvicorn.Server(config) + uvicorn_server = server + await server.serve() + + +def raw_main(): # 利用 TZ 环境变量设定程序工作的时区 # 仅保证行为一致,不依赖 localtime(),实际对生产环境几乎没有作用 if platform.system().lower() != 'windows': @@ -165,10 +204,30 @@ if __name__ == "__main__": nonebot.init(**base_config, **env_config) # 注册适配器 + global driver driver = nonebot.get_driver() driver.register_adapter(Adapter) # 加载插件 nonebot.load_plugins("src/plugins") - nonebot.run() + +if __name__ == "__main__": + + try: + raw_main() + + global app + app = nonebot.get_asgi() + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(uvicorn_main()) + except KeyboardInterrupt: + logger.warning("麦麦会努力做的更好的!正在停止中......") + except Exception as e: + logger.error(f"主程序异常: {e}") + finally: + loop.run_until_complete(graceful_shutdown()) + loop.close() + logger.info("进程终止完毕,麦麦开始休眠......下次再见哦!") diff --git a/changelog_config.md b/changelog_config.md index 7101fe828..c4c560644 100644 --- a/changelog_config.md +++ b/changelog_config.md @@ -1,6 +1,12 @@ # Changelog +## [0.0.5] - 2025-3-11 +### Added +- 新增了 `alias_names` 配置项,用于指定麦麦的别名。 + ## [0.0.4] - 2025-3-9 ### Added - 新增了 `memory_ban_words` 配置项,用于指定不希望记忆的词汇。 + + diff --git a/docker-compose.yml b/docker-compose.yml index 512558558..227df606b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,6 @@ services: - NAPCAT_UID=${NAPCAT_UID} - NAPCAT_GID=${NAPCAT_GID} # 让 NapCat 获取当前用户 GID,UID,防止权限问题 ports: - - 3000:3000 - - 3001:3001 - 6099:6099 restart: unless-stopped volumes: @@ -19,7 +17,7 @@ services: mongodb: container_name: mongodb environment: - - tz=Asia/Shanghai + - TZ=Asia/Shanghai # - MONGO_INITDB_ROOT_USERNAME=your_username # - MONGO_INITDB_ROOT_PASSWORD=your_password expose: diff --git a/docs/Jonathan R.md b/docs/Jonathan R.md new file mode 100644 index 000000000..660caaeec --- /dev/null +++ b/docs/Jonathan R.md @@ -0,0 +1,20 @@ +Jonathan R. Wolpaw 在 “Memory in neuroscience: rhetoric versus reality.” 一文中提到,从神经科学的感觉运动假设出发,整个神经系统的功能是将经验与适当的行为联系起来,而不是单纯的信息存储。 +Jonathan R,Wolpaw. (2019). Memory in neuroscience: rhetoric versus reality.. Behavioral and cognitive neuroscience reviews(2). + +1. **单一过程理论** + - 单一过程理论认为,识别记忆主要是基于熟悉性这一单一因素的影响。熟悉性是指对刺激的一种自动的、无意识的感知,它可以使我们在没有回忆起具体细节的情况下,判断一个刺激是否曾经出现过。 + - 例如,在一些实验中,研究者发现被试可以在没有回忆起具体学习情境的情况下,对曾经出现过的刺激做出正确的判断,这被认为是熟悉性在起作用1。 +2. **双重过程理论** + - 双重过程理论则认为,识别记忆是基于两个过程:回忆和熟悉性。回忆是指对过去经验的有意识的回忆,它可以使我们回忆起具体的细节和情境;熟悉性则是一种自动的、无意识的感知。 + - 该理论认为,在识别记忆中,回忆和熟悉性共同作用,使我们能够判断一个刺激是否曾经出现过。例如,在 “记得 / 知道” 范式中,被试被要求判断他们对一个刺激的记忆是基于回忆还是熟悉性。研究发现,被试可以区分这两种不同的记忆过程,这为双重过程理论提供了支持1。 + + + +1. **神经元节点与连接**:借鉴神经网络原理,将每个记忆单元视为一个神经元节点。节点之间通过连接相互关联,连接的强度代表记忆之间的关联程度。在形态学联想记忆中,具有相似形态特征的记忆节点连接强度较高。例如,苹果和橘子的记忆节点,由于在形状、都是水果等形态语义特征上相似,它们之间的连接强度大于苹果与汽车记忆节点间的连接强度。 +2. **记忆聚类与层次结构**:依据形态特征的相似性对记忆进行聚类,形成不同的记忆簇。每个记忆簇内部的记忆具有较高的相似性,而不同记忆簇之间的记忆相似性较低。同时,构建记忆的层次结构,高层次的记忆节点代表更抽象、概括的概念,低层次的记忆节点对应具体的实例。比如,“水果” 作为高层次记忆节点,连接着 “苹果”“橘子”“香蕉” 等低层次具体水果的记忆节点。 +3. **网络的动态更新**:随着新记忆的不断加入,记忆网络动态调整。新记忆节点根据其形态特征与现有网络中的节点建立连接,同时影响相关连接的强度。若新记忆与某个记忆簇的特征高度相似,则被纳入该记忆簇;若具有独特特征,则可能引发新的记忆簇的形成。例如,当系统学习到一种新的水果 “番石榴”,它会根据番石榴的形态、语义等特征,在记忆网络中找到与之最相似的区域(如水果记忆簇),并建立相应连接,同时调整周围节点连接强度以适应这一新记忆。 + + + +- **相似性联想**:该理论认为,当两个或多个事物在形态上具有相似性时,它们在记忆中会形成关联。例如,梨和苹果在形状和都是水果这一属性上有相似性,所以当我们看到梨时,很容易通过形态学联想记忆联想到苹果。这种相似性联想有助于我们对新事物进行分类和理解,当遇到一个新的类似水果时,我们可以通过与已有的水果记忆进行相似性匹配,来推测它的一些特征。 +- **时空关联性联想**:除了相似性联想,MAM 还强调时空关联性联想。如果两个事物在时间或空间上经常同时出现,它们也会在记忆中形成关联。比如,每次在公园里看到花的时候,都能听到鸟儿的叫声,那么花和鸟儿叫声的形态特征(花的视觉形态和鸟叫的听觉形态)就会在记忆中形成关联,以后听到鸟叫可能就会联想到公园里的花。 \ No newline at end of file diff --git a/docs/docker_deploy.md b/docs/docker_deploy.md index 3958d2fc4..db759dfd0 100644 --- a/docs/docker_deploy.md +++ b/docs/docker_deploy.md @@ -1,67 +1,97 @@ # 🐳 Docker 部署指南 -## 部署步骤(推荐,但不一定是最新) +## 部署步骤 (推荐,但不一定是最新) + +**"更新镜像与容器"部分在本文档 [Part 6](#6-更新镜像与容器)** + +### 0. 前提说明 + +**本文假设读者已具备一定的 Docker 基础知识。若您对 Docker 不熟悉,建议先参考相关教程或文档进行学习,或选择使用 [📦Linux手动部署指南](./manual_deploy_linux.md) 或 [📦Windows手动部署指南](./manual_deploy_windows.md) 。** -### 1. 获取Docker配置文件: +### 1. 获取Docker配置文件 + +- 建议先单独创建好一个文件夹并进入,作为工作目录 ```bash wget https://raw.githubusercontent.com/SengokuCola/MaiMBot/main/docker-compose.yml -O docker-compose.yml ``` -- 若需要启用MongoDB数据库的用户名和密码,可进入docker-compose.yml,取消MongoDB处的注释并修改变量`=`后方的值为你的用户名和密码\ -修改后请注意在之后配置`.env.prod`文件时指定MongoDB数据库的用户名密码 +- 若需要启用MongoDB数据库的用户名和密码,可进入docker-compose.yml,取消MongoDB处的注释并修改变量旁 `=` 后方的值为你的用户名和密码\ +修改后请注意在之后配置 `.env.prod` 文件时指定MongoDB数据库的用户名密码 -### 2. 启动服务: +### 2. 启动服务 -- **!!! 请在第一次启动前确保当前工作目录下`.env.prod`与`bot_config.toml`文件存在 !!!**\ +- **!!! 请在第一次启动前确保当前工作目录下 `.env.prod` 与 `bot_config.toml` 文件存在 !!!**\ 由于Docker文件映射行为的特殊性,若宿主机的映射路径不存在,可能导致意外的目录创建,而不会创建文件,由于此处需要文件映射到文件,需提前确保文件存在且路径正确,可使用如下命令: + ```bash touch .env.prod touch bot_config.toml ``` - 启动Docker容器: + ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose up -d +# 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose up -d ``` -- 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 +### 3. 修改配置并重启Docker -### 3. 修改配置并重启Docker: - -- 请前往 [🎀 新手配置指南](docs/installation_cute.md) 或 [⚙️ 标准配置指南](docs/installation_standard.md) 完成`.env.prod`与`bot_config.toml`配置文件的编写\ -**需要注意`.env.prod`中HOST处IP的填写,Docker中部署和系统中直接安装的配置会有所不同** +- 请前往 [🎀新手配置指南](./installation_cute.md) 或 [⚙️标准配置指南](./installation_standard.md) 完成 `.env.prod` 与 `bot_config.toml` 配置文件的编写\ +**需要注意 `.env.prod` 中HOST处IP的填写,Docker中部署和系统中直接安装的配置会有所不同** - 重启Docker容器: + ```bash -docker restart maimbot # 若修改过容器名称则替换maimbot为你自定的名臣 +docker restart maimbot # 若修改过容器名称则替换maimbot为你自定的名称 ``` - 下方命令可以但不推荐,只是同时重启NapCat、MongoDB、MaiMBot三个服务 + ```bash NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose restart +# 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose restart ``` -- 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 - ### 4. 登入NapCat管理页添加反向WebSocket -- 在浏览器地址栏输入`http://<宿主机IP>:6099/`进入NapCat的管理Web页,添加一个Websocket客户端 +- 在浏览器地址栏输入 `http://<宿主机IP>:6099/` 进入NapCat的管理Web页,添加一个Websocket客户端 + > 网络配置 -> 新建 -> Websocket客户端 -- Websocket客户端的名称自定,URL栏填入`ws://maimbot:8080/onebot/v11/ws`,启用并保存即可\ +- Websocket客户端的名称自定,URL栏填入 `ws://maimbot:8080/onebot/v11/ws`,启用并保存即可\ (若修改过容器名称则替换maimbot为你自定的名称) -### 5. 愉快地和麦麦对话吧! +### 5. 部署完成,愉快地和麦麦对话吧! + + +### 6. 更新镜像与容器 + +- 拉取最新镜像 + +```bash +docker-compose pull +``` + +- 执行启动容器指令,该指令会自动重建镜像有更新的容器并启动 + +```bash +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose up -d +# 旧版Docker中可能找不到docker compose,请使用docker-compose工具替代 +NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker-compose up -d +``` ## ⚠️ 注意事项 - 目前部署方案仍在测试中,可能存在未知问题 - 配置文件中的API密钥请妥善保管,不要泄露 -- 建议先在测试环境中运行,确认无误后再部署到生产环境 \ No newline at end of file +- 建议先在测试环境中运行,确认无误后再部署到生产环境 \ No newline at end of file diff --git a/docs/installation_cute.md b/docs/installation_cute.md index e7541f7d3..4465660f9 100644 --- a/docs/installation_cute.md +++ b/docs/installation_cute.md @@ -52,12 +52,12 @@ key = "SILICONFLOW_KEY" # 用同一张门票就可以啦 如果你想用DeepSeek官方的服务,就要这样改: ```toml [model.llm_reasoning] -name = "Pro/deepseek-ai/DeepSeek-R1" +name = "deepseek-reasoner" # 改成对应的模型名称,这里为DeepseekR1 base_url = "DEEP_SEEK_BASE_URL" # 改成去DeepSeek游乐园 key = "DEEP_SEEK_KEY" # 用DeepSeek的门票 [model.llm_normal] -name = "Pro/deepseek-ai/DeepSeek-V3" +name = "deepseek-chat" # 改成对应的模型名称,这里为DeepseekV3 base_url = "DEEP_SEEK_BASE_URL" # 也去DeepSeek游乐园 key = "DEEP_SEEK_KEY" # 用同一张DeepSeek门票 ``` @@ -110,7 +110,8 @@ PLUGINS=["src2.plugins.chat"] # 这里是机器人的插件列表呢 ```toml [bot] qq = "把这里改成你的机器人QQ号喵" # 填写你的机器人QQ号 -nickname = "麦麦" # 机器人的名字,你可以改成你喜欢的任何名字哦 +nickname = "麦麦" # 机器人的名字,你可以改成你喜欢的任何名字哦,建议和机器人QQ名称/群昵称一样哦 +alias_names = ["小麦", "阿麦"] # 也可以用这个招呼机器人,可以不设置呢 [personality] # 这里可以设置机器人的性格呢,让它更有趣一些喵 diff --git a/docs/installation_standard.md b/docs/installation_standard.md index 5f52676d1..03b66dc46 100644 --- a/docs/installation_standard.md +++ b/docs/installation_standard.md @@ -8,7 +8,7 @@ ## API配置说明 -`.env.prod`和`bot_config.toml`中的API配置关系如下: +`.env.prod` 和 `bot_config.toml` 中的API配置关系如下: ### 在.env.prod中定义API凭证: ```ini @@ -34,7 +34,7 @@ key = "SILICONFLOW_KEY" # 引用.env.prod中定义的密钥 如需切换到其他API服务,只需修改引用: ```toml [model.llm_reasoning] -name = "Pro/deepseek-ai/DeepSeek-R1" +name = "deepseek-reasoner" # 改成对应的模型名称,这里为DeepseekR1 base_url = "DEEP_SEEK_BASE_URL" # 切换为DeepSeek服务 key = "DEEP_SEEK_KEY" # 使用DeepSeek密钥 ``` @@ -53,11 +53,11 @@ CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 # 服务配置 HOST=127.0.0.1 # 如果使用Docker部署,需要改成0.0.0.0,否则QQ消息无法传入 -PORT=8080 +PORT=8080 # 与反向端口相同 # 数据库配置 MONGODB_HOST=127.0.0.1 # 如果使用Docker部署,需要改成数据库容器的名字,默认是mongodb -MONGODB_PORT=27017 +MONGODB_PORT=27017 # MongoDB端口 DATABASE_NAME=MegBot MONGODB_USERNAME = "" # 数据库用户名 MONGODB_PASSWORD = "" # 数据库密码 @@ -72,6 +72,9 @@ PLUGINS=["src2.plugins.chat"] [bot] qq = "机器人QQ号" # 必填 nickname = "麦麦" # 机器人昵称 +# alias_names: 配置机器人可使用的别名。当机器人在群聊或对话中被调用时,别名可以作为直接命令或提及机器人的关键字使用。 +# 该配置项为字符串数组。例如: ["小麦", "阿麦"] +alias_names = ["小麦", "阿麦"] # 机器人别名 [personality] prompt_personality = [ diff --git a/docs/manual_deploy_linux.md b/docs/manual_deploy_linux.md index d310ffc59..41f0390b8 100644 --- a/docs/manual_deploy_linux.md +++ b/docs/manual_deploy_linux.md @@ -66,7 +66,7 @@ pip install -r requirements.txt ## 数据库配置 ### 3️⃣ **安装并启动MongoDB** -- 安装与启动:Debian参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-debian/),Ubuntu参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/) +- 安装与启动: Debian参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-debian/),Ubuntu参考[官方文档](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/) - 默认连接本地27017端口 --- @@ -76,15 +76,14 @@ pip install -r requirements.txt - 参考[NapCat官方文档](https://www.napcat.wiki/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9)安装 -- 使用QQ小号登录,添加反向WS地址: -`ws://127.0.0.1:8080/onebot/v11/ws` +- 使用QQ小号登录,添加反向WS地址: `ws://127.0.0.1:8080/onebot/v11/ws` --- ## 配置文件设置 ### 5️⃣ **配置文件设置,让麦麦Bot正常工作** -- 修改环境配置文件:`.env.prod` -- 修改机器人配置文件:`bot_config.toml` +- 修改环境配置文件: `.env.prod` +- 修改机器人配置文件: `bot_config.toml` --- @@ -107,9 +106,9 @@ python3 bot.py --- ## 常见问题 -🔧 权限问题:在命令前加`sudo` -🔌 端口占用:使用`sudo lsof -i :8080`查看端口占用 -🛡️ 防火墙:确保8080/27017端口开放 +🔧 权限问题: 在命令前加 `sudo` +🔌 端口占用: 使用 `sudo lsof -i :8080` 查看端口占用 +🛡️ 防火墙: 确保8080/27017端口开放 ```bash sudo ufw allow 8080/tcp sudo ufw allow 27017/tcp diff --git a/docs/manual_deploy_windows.md b/docs/manual_deploy_windows.md index 86238bcd4..eebdc4f41 100644 --- a/docs/manual_deploy_windows.md +++ b/docs/manual_deploy_windows.md @@ -30,7 +30,7 @@ 在创建虚拟环境之前,请确保你的电脑上安装了Python 3.9及以上版本。如果没有,可以按以下步骤安装: -1. 访问Python官网下载页面:https://www.python.org/downloads/release/python-3913/ +1. 访问Python官网下载页面: https://www.python.org/downloads/release/python-3913/ 2. 下载Windows安装程序 (64-bit): `python-3.9.13-amd64.exe` 3. 运行安装程序,并确保勾选"Add Python 3.9 to PATH"选项 4. 点击"Install Now"开始安装 @@ -79,11 +79,11 @@ pip install -r requirements.txt ### 3️⃣ **配置NapCat,让麦麦bot与qq取得联系** - 安装并登录NapCat(用你的qq小号) -- 添加反向WS:`ws://127.0.0.1:8080/onebot/v11/ws` +- 添加反向WS: `ws://127.0.0.1:8080/onebot/v11/ws` ### 4️⃣ **配置文件设置,让麦麦Bot正常工作** -- 修改环境配置文件:`.env.prod` -- 修改机器人配置文件:`bot_config.toml` +- 修改环境配置文件: `.env.prod` +- 修改机器人配置文件: `bot_config.toml` ### 5️⃣ **启动麦麦机器人** - 打开命令行,cd到对应路径 diff --git a/flake.nix b/flake.nix index 54737d640..3586857f0 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,7 @@ pythonEnv = pkgs.python3.withPackages ( ps: with ps; [ + ruff pymongo python-dotenv pydantic diff --git a/pyproject.toml b/pyproject.toml index e54dcdacd..0a4805744 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,51 @@ [project] -name = "Megbot" +name = "MaiMaiBot" version = "0.1.0" -description = "New Bot Project" +description = "MaiMaiBot" [tool.nonebot] plugins = ["src.plugins.chat"] -plugin_dirs = ["src/plugins"] +plugin_dirs = ["src/plugins"] [tool.ruff] -# 设置 Python 版本 -target-version = "py39" + +include = ["*.py"] + +# 行长度设置 +line-length = 120 + +[tool.ruff.lint] +fixable = ["ALL"] +unfixable = [] + +# 如果一个变量的名称以下划线开头,即使它未被使用,也不应该被视为错误或警告。 +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" # 启用的规则 select = [ - "E", # pycodestyle 错误 - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear + "E", # pycodestyle 错误 + "F", # pyflakes + "B", # flake8-bugbear ] -# 行长度设置 -line-length = 88 \ No newline at end of file +ignore = ["E711"] + +[tool.ruff.format] +docstring-code-format = true +indent-style = "space" + + +# 使用双引号表示字符串 +quote-style = "double" + +# 尊重魔法尾随逗号 +# 例如: +# items = [ +# "apple", +# "banana", +# "cherry", +# ] +skip-magic-trailing-comma = false + +# 自动检测合适的换行符 +line-ending = "auto" diff --git a/requirements.txt b/requirements.txt index 4f969682f9b0ebbbd9aebe4fe94758da052d23b4..0acaade5e6d465465ee87009b5c28551e591953d 100644 GIT binary patch delta 20 acmcb?@`PnW1QQQ00~bRPLn%WV5CQ-^9t5QT delta 7 OcmaFDa)V_<1QP%aWdhFt diff --git a/run.bat b/run.bat index 659a7545a..91904bc34 100644 --- a/run.bat +++ b/run.bat @@ -3,7 +3,7 @@ chcp 65001 if not exist "venv" ( python -m venv venv call venv\Scripts\activate.bat - pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple --upgrade -r requirements.txt + pip install -i https://mirrors.aliyun.com/pypi/simple --upgrade -r requirements.txt ) else ( call venv\Scripts\activate.bat ) diff --git a/run.py b/run.py index baea4d13c..50e312c37 100644 --- a/run.py +++ b/run.py @@ -107,6 +107,8 @@ def install_napcat(): napcat_filename = input( "下载完成后请把文件复制到此文件夹,并将**不包含后缀的文件名**输入至此窗口,如 NapCat.32793.Shell:" ) + if(napcat_filename[-4:] == ".zip"): + napcat_filename = napcat_filename[:-4] extract_files(napcat_filename + ".zip", "napcat") print("NapCat 安装完成") os.remove(napcat_filename + ".zip") diff --git a/src/gui/reasoning_gui.py b/src/gui/reasoning_gui.py index 514a95dfb..5768ddc09 100644 --- a/src/gui/reasoning_gui.py +++ b/src/gui/reasoning_gui.py @@ -87,7 +87,7 @@ class ReasoningGUI: self.db = Database.get_instance().db logger.success("数据库初始化成功") except Exception: - logger.exception(f"数据库初始化失败") + logger.exception("数据库初始化失败") sys.exit(1) # 存储群组数据 @@ -342,7 +342,7 @@ class ReasoningGUI: 'group_id': self.selected_group_id }) except Exception: - logger.exception(f"自动更新出错") + logger.exception("自动更新出错") # 每5秒更新一次 time.sleep(5) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index bd71be019..c730466b3 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -121,9 +121,9 @@ async def build_memory_task(): @scheduler.scheduled_job("interval", seconds=global_config.forget_memory_interval, id="forget_memory") async def forget_memory_task(): """每30秒执行一次记忆构建""" - # print("\033[1;32m[记忆遗忘]\033[0m 开始遗忘记忆...") - # await hippocampus.operation_forget_topic(percentage=0.1) - # print("\033[1;32m[记忆遗忘]\033[0m 记忆遗忘完成") + print("\033[1;32m[记忆遗忘]\033[0m 开始遗忘记忆...") + await hippocampus.operation_forget_topic(percentage=0.1) + print("\033[1;32m[记忆遗忘]\033[0m 记忆遗忘完成") @scheduler.scheduled_job("interval", seconds=global_config.build_memory_interval + 10, id="merge_memory") diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 86d0b6944..490b171b5 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -138,7 +138,7 @@ class ChatBot: # 如果找不到思考消息,直接返回 if not thinking_message: - logger.warning(f"未找到对应的思考消息,可能已超时被移除") + logger.warning("未找到对应的思考消息,可能已超时被移除") return # 记录开始思考的时间,避免从思考到回复的时间太久 @@ -187,7 +187,7 @@ class ChatBot: # 检查是否 <没有找到> emoji if emoji_raw != None: - emoji_path, discription = emoji_raw + emoji_path, description = emoji_raw emoji_cq = CQCode.create_emoji_cq(emoji_path) @@ -203,7 +203,7 @@ class ChatBot: raw_message=emoji_cq, plain_text=emoji_cq, processed_plain_text=emoji_cq, - detailed_plain_text=discription, + detailed_plain_text=description, user_nickname=global_config.BOT_NICKNAME, group_name=message.group_name, time=bot_response_time, diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 888e33a7f..547c44a82 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -1,6 +1,6 @@ import os from dataclasses import dataclass, field -from typing import Dict, Optional +from typing import Dict, List, Optional import tomli from loguru import logger @@ -12,10 +12,12 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier @dataclass class BotConfig: """机器人配置类""" + INNER_VERSION: Version = None BOT_QQ: Optional[int] = 1 BOT_NICKNAME: Optional[str] = None + BOT_ALIAS_NAMES: List[str] = field(default_factory=list) # 别名,可以通过这个叫它 # 消息处理相关配置 MIN_TEXT_LENGTH: int = 2 # 最小处理文本长度 @@ -82,23 +84,25 @@ class BotConfig: PROMPT_PERSONALITY = [ "曾经是一个学习地质的女大学生,现在学习心理学和脑科学,你会刷贴吧", "是一个女大学生,你有黑色头发,你会刷小红书", - "是一个女大学生,你会刷b站,对ACG文化感兴趣" + "是一个女大学生,你会刷b站,对ACG文化感兴趣", ] - PROMPT_SCHEDULE_GEN="一个曾经学习地质,现在学习心理学和脑科学的女大学生,喜欢刷qq,贴吧,知乎和小红书" - - PERSONALITY_1: float = 0.6 # 第一种人格概率 - PERSONALITY_2: float = 0.3 # 第二种人格概率 - PERSONALITY_3: float = 0.1 # 第三种人格概率 - - memory_ban_words: list = field(default_factory=lambda: ['表情包', '图片', '回复', '聊天记录']) # 添加新的配置项默认值 - + PROMPT_SCHEDULE_GEN = "一个曾经学习地质,现在学习心理学和脑科学的女大学生,喜欢刷qq,贴吧,知乎和小红书" + + PERSONALITY_1: float = 0.6 # 第一种人格概率 + PERSONALITY_2: float = 0.3 # 第二种人格概率 + PERSONALITY_3: float = 0.1 # 第三种人格概率 + + memory_ban_words: list = field( + default_factory=lambda: ["表情包", "图片", "回复", "聊天记录"] + ) # 添加新的配置项默认值 + @staticmethod def get_config_dir() -> str: """获取配置文件目录""" current_dir = os.path.dirname(os.path.abspath(__file__)) - root_dir = os.path.abspath(os.path.join(current_dir, '..', '..', '..')) - config_dir = os.path.join(root_dir, 'config') + root_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "..")) + config_dir = os.path.join(root_dir, "config") if not os.path.exists(config_dir): os.makedirs(config_dir) return config_dir @@ -109,35 +113,32 @@ class BotConfig: Args: value[str]: 版本表达式(字符串) Returns: - SpecifierSet + SpecifierSet """ try: converted = SpecifierSet(value) - except InvalidSpecifier as e: - logger.error( - f"{value} 分类使用了错误的版本约束表达式\n", - "请阅读 https://semver.org/lang/zh-CN/ 修改代码" - ) + except InvalidSpecifier: + logger.error(f"{value} 分类使用了错误的版本约束表达式\n", "请阅读 https://semver.org/lang/zh-CN/ 修改代码") exit(1) return converted @classmethod def get_config_version(cls, toml: dict) -> Version: - """提取配置文件的 SpecifierSet 版本数据 + """提取配置文件的 SpecifierSet 版本数据 Args: toml[dict]: 输入的配置文件字典 Returns: - Version + Version """ - if 'inner' in toml: + if "inner" in toml: try: config_version: str = toml["inner"]["version"] except KeyError as e: - logger.error(f"配置文件中 inner 段 不存在, 这是错误的配置文件") - raise KeyError(f"配置文件中 inner 段 不存在 {e}, 这是错误的配置文件") + logger.error("配置文件中 inner 段 不存在, 这是错误的配置文件") + raise KeyError(f"配置文件中 inner 段 不存在 {e}, 这是错误的配置文件") from e else: toml["inner"] = {"version": "0.0.0"} config_version = toml["inner"]["version"] @@ -150,7 +151,7 @@ class BotConfig: "请阅读 https://semver.org/lang/zh-CN/ 修改配置,并参考本项目指定的模板进行修改\n" "本项目在不同的版本下有不同的模板,请注意识别" ) - raise InvalidVersion("配置文件中 inner段 的 version 键是错误的版本描述\n") + raise InvalidVersion("配置文件中 inner段 的 version 键是错误的版本描述\n") from e return ver @@ -160,26 +161,26 @@ class BotConfig: config = cls() def personality(parent: dict): - personality_config = parent['personality'] - personality = personality_config.get('prompt_personality') + personality_config = parent["personality"] + personality = personality_config.get("prompt_personality") if len(personality) >= 2: logger.debug(f"载入自定义人格:{personality}") - config.PROMPT_PERSONALITY = personality_config.get('prompt_personality', config.PROMPT_PERSONALITY) + config.PROMPT_PERSONALITY = personality_config.get("prompt_personality", config.PROMPT_PERSONALITY) logger.info(f"载入自定义日程prompt:{personality_config.get('prompt_schedule', config.PROMPT_SCHEDULE_GEN)}") - config.PROMPT_SCHEDULE_GEN = personality_config.get('prompt_schedule', config.PROMPT_SCHEDULE_GEN) + config.PROMPT_SCHEDULE_GEN = personality_config.get("prompt_schedule", config.PROMPT_SCHEDULE_GEN) if config.INNER_VERSION in SpecifierSet(">=0.0.2"): - config.PERSONALITY_1 = personality_config.get('personality_1_probability', config.PERSONALITY_1) - config.PERSONALITY_2 = personality_config.get('personality_2_probability', config.PERSONALITY_2) - config.PERSONALITY_3 = personality_config.get('personality_3_probability', config.PERSONALITY_3) + config.PERSONALITY_1 = personality_config.get("personality_1_probability", config.PERSONALITY_1) + config.PERSONALITY_2 = personality_config.get("personality_2_probability", config.PERSONALITY_2) + config.PERSONALITY_3 = personality_config.get("personality_3_probability", config.PERSONALITY_3) def emoji(parent: dict): emoji_config = parent["emoji"] config.EMOJI_CHECK_INTERVAL = emoji_config.get("check_interval", config.EMOJI_CHECK_INTERVAL) config.EMOJI_REGISTER_INTERVAL = emoji_config.get("register_interval", config.EMOJI_REGISTER_INTERVAL) - config.EMOJI_CHECK_PROMPT = emoji_config.get('check_prompt', config.EMOJI_CHECK_PROMPT) - config.EMOJI_SAVE = emoji_config.get('auto_save', config.EMOJI_SAVE) - config.EMOJI_CHECK = emoji_config.get('enable_check', config.EMOJI_CHECK) + config.EMOJI_CHECK_PROMPT = emoji_config.get("check_prompt", config.EMOJI_CHECK_PROMPT) + config.EMOJI_SAVE = emoji_config.get("auto_save", config.EMOJI_SAVE) + config.EMOJI_CHECK = emoji_config.get("enable_check", config.EMOJI_CHECK) def cq_code(parent: dict): cq_code_config = parent["cq_code"] @@ -192,12 +193,16 @@ class BotConfig: config.BOT_QQ = int(bot_qq) config.BOT_NICKNAME = bot_config.get("nickname", config.BOT_NICKNAME) + if config.INNER_VERSION in SpecifierSet(">=0.0.5"): + config.BOT_ALIAS_NAMES = bot_config.get("alias_names", config.BOT_ALIAS_NAMES) + def response(parent: dict): response_config = parent["response"] config.MODEL_R1_PROBABILITY = response_config.get("model_r1_probability", config.MODEL_R1_PROBABILITY) config.MODEL_V3_PROBABILITY = response_config.get("model_v3_probability", config.MODEL_V3_PROBABILITY) - config.MODEL_R1_DISTILL_PROBABILITY = response_config.get("model_r1_distill_probability", - config.MODEL_R1_DISTILL_PROBABILITY) + config.MODEL_R1_DISTILL_PROBABILITY = response_config.get( + "model_r1_distill_probability", config.MODEL_R1_DISTILL_PROBABILITY + ) config.max_response_length = response_config.get("max_response_length", config.max_response_length) def model(parent: dict): @@ -214,7 +219,7 @@ class BotConfig: "llm_emotion_judge", "vlm", "embedding", - "moderation" + "moderation", ] for item in config_list: @@ -223,13 +228,7 @@ class BotConfig: # base_url 的例子: SILICONFLOW_BASE_URL # key 的例子: SILICONFLOW_KEY - cfg_target = { - "name": "", - "base_url": "", - "key": "", - "pri_in": 0, - "pri_out": 0 - } + cfg_target = {"name": "", "base_url": "", "key": "", "pri_in": 0, "pri_out": 0} if config.INNER_VERSION in SpecifierSet("<=0.0.0"): cfg_target = cfg_item @@ -248,7 +247,7 @@ class BotConfig: cfg_target[i] = cfg_item[i] except KeyError as e: logger.error(f"{item} 中的必要字段不存在,请检查") - raise KeyError(f"{item} 中的必要字段 {e} 不存在,请检查") + raise KeyError(f"{item} 中的必要字段 {e} 不存在,请检查") from e provider = cfg_item.get("provider") if provider is None: @@ -273,10 +272,12 @@ class BotConfig: if config.INNER_VERSION in SpecifierSet(">=0.0.2"): config.thinking_timeout = msg_config.get("thinking_timeout", config.thinking_timeout) - config.response_willing_amplifier = msg_config.get("response_willing_amplifier", - config.response_willing_amplifier) - config.response_interested_rate_amplifier = msg_config.get("response_interested_rate_amplifier", - config.response_interested_rate_amplifier) + config.response_willing_amplifier = msg_config.get( + "response_willing_amplifier", config.response_willing_amplifier + ) + config.response_interested_rate_amplifier = msg_config.get( + "response_interested_rate_amplifier", config.response_interested_rate_amplifier + ) config.down_frequency_rate = msg_config.get("down_frequency_rate", config.down_frequency_rate) if config.INNER_VERSION in SpecifierSet(">=0.0.5"): @@ -286,7 +287,7 @@ class BotConfig: memory_config = parent["memory"] config.build_memory_interval = memory_config.get("build_memory_interval", config.build_memory_interval) config.forget_memory_interval = memory_config.get("forget_memory_interval", config.forget_memory_interval) - + # 在版本 >= 0.0.4 时才处理新增的配置项 if config.INNER_VERSION in SpecifierSet(">=0.0.4"): config.memory_ban_words = set(memory_config.get("memory_ban_words", [])) @@ -307,10 +308,12 @@ class BotConfig: config.chinese_typo_enable = chinese_typo_config.get("enable", config.chinese_typo_enable) config.chinese_typo_error_rate = chinese_typo_config.get("error_rate", config.chinese_typo_error_rate) config.chinese_typo_min_freq = chinese_typo_config.get("min_freq", config.chinese_typo_min_freq) - config.chinese_typo_tone_error_rate = chinese_typo_config.get("tone_error_rate", - config.chinese_typo_tone_error_rate) - config.chinese_typo_word_replace_rate = chinese_typo_config.get("word_replace_rate", - config.chinese_typo_word_replace_rate) + config.chinese_typo_tone_error_rate = chinese_typo_config.get( + "tone_error_rate", config.chinese_typo_tone_error_rate + ) + config.chinese_typo_word_replace_rate = chinese_typo_config.get( + "word_replace_rate", config.chinese_typo_word_replace_rate + ) def groups(parent: dict): groups_config = parent["groups"] @@ -329,61 +332,19 @@ class BotConfig: # 例如:"notice": "personality 将在 1.3.2 后被移除",那么在有效版本中的用户就会虽然可以 # 正常执行程序,但是会看到这条自定义提示 include_configs = { - "personality": { - "func": personality, - "support": ">=0.0.0" - }, - "emoji": { - "func": emoji, - "support": ">=0.0.0" - }, - "cq_code": { - "func": cq_code, - "support": ">=0.0.0" - }, - "bot": { - "func": bot, - "support": ">=0.0.0" - }, - "response": { - "func": response, - "support": ">=0.0.0" - }, - "model": { - "func": model, - "support": ">=0.0.0" - }, - "message": { - "func": message, - "support": ">=0.0.0" - }, - "memory": { - "func": memory, - "support": ">=0.0.0", - "necessary": False - }, - "mood": { - "func": mood, - "support": ">=0.0.0" - }, - "keywords_reaction": { - "func": keywords_reaction, - "support": ">=0.0.2", - "necessary": False - }, - "chinese_typo": { - "func": chinese_typo, - "support": ">=0.0.3", - "necessary": False - }, - "groups": { - "func": groups, - "support": ">=0.0.0" - }, - "others": { - "func": others, - "support": ">=0.0.0" - } + "personality": {"func": personality, "support": ">=0.0.0"}, + "emoji": {"func": emoji, "support": ">=0.0.0"}, + "cq_code": {"func": cq_code, "support": ">=0.0.0"}, + "bot": {"func": bot, "support": ">=0.0.0"}, + "response": {"func": response, "support": ">=0.0.0"}, + "model": {"func": model, "support": ">=0.0.0"}, + "message": {"func": message, "support": ">=0.0.0"}, + "memory": {"func": memory, "support": ">=0.0.0", "necessary": False}, + "mood": {"func": mood, "support": ">=0.0.0"}, + "keywords_reaction": {"func": keywords_reaction, "support": ">=0.0.2", "necessary": False}, + "chinese_typo": {"func": chinese_typo, "support": ">=0.0.3", "necessary": False}, + "groups": {"func": groups, "support": ">=0.0.0"}, + "others": {"func": others, "support": ">=0.0.0"}, } # 原地修改,将 字符串版本表达式 转换成 版本对象 @@ -395,7 +356,7 @@ class BotConfig: with open(config_path, "rb") as f: try: toml_dict = tomli.load(f) - except(tomli.TOMLDecodeError) as e: + except tomli.TOMLDecodeError as e: logger.critical(f"配置文件bot_config.toml填写有误,请检查第{e.lineno}行第{e.colno}处:{e.msg}") exit(1) @@ -410,7 +371,7 @@ class BotConfig: # 检查配置文件版本是否在支持范围内 if config.INNER_VERSION in group_specifierset: # 如果版本在支持范围内,检查是否存在通知 - if 'notice' in include_configs[key]: + if "notice" in include_configs[key]: logger.warning(include_configs[key]["notice"]) include_configs[key]["func"](toml_dict) @@ -424,7 +385,7 @@ class BotConfig: raise InvalidVersion(f"当前程序仅支持以下版本范围: {group_specifierset}") # 如果 necessary 项目存在,而且显式声明是 False,进入特殊处理 - elif "necessary" in include_configs[key] and include_configs[key].get("necessary") == False: + elif "necessary" in include_configs[key] and include_configs[key].get("necessary") is False: # 通过 pass 处理的项虽然直接忽略也是可以的,但是为了不增加理解困难,依然需要在这里显式处理 if key == "keywords_reaction": pass diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index b13e33e48..21541a78b 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -155,8 +155,8 @@ class CQCode: logger.error(f"最终请求失败: {str(e)}") time.sleep(1.5 ** retry) # 指数退避 - except Exception as e: - logger.exception(f"[未知错误]") + except Exception: + logger.exception("[未知错误]") return None return None @@ -281,7 +281,7 @@ class CQCode: logger.debug(f"合并后的转发消息: {combined_messages}") return f"[转发消息:\n{combined_messages}]" - except Exception as e: + except Exception: logger.exception("处理转发消息失败") return '[转发消息]' diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 973bcad2d..feba23076 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -51,8 +51,8 @@ class EmojiManager: self._initialized = True # 启动时执行一次完整性检查 self.check_emoji_file_integrity() - except Exception as e: - logger.exception(f"初始化表情管理器失败") + except Exception: + logger.exception("初始化表情管理器失败") def _ensure_db(self): """确保数据库已初始化""" @@ -87,8 +87,8 @@ class EmojiManager: {'_id': emoji_id}, {'$inc': {'usage_count': 1}} ) - except Exception as e: - logger.exception(f"记录表情使用失败") + except Exception: + logger.exception("记录表情使用失败") async def get_emoji_for_text(self, text: str) -> Optional[str]: """根据文本内容获取相关表情包 @@ -117,7 +117,7 @@ class EmojiManager: try: # 获取所有表情包 - all_emojis = list(self.db.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'discription': 1})) + all_emojis = list(self.db.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'description': 1})) if not all_emojis: logger.warning("数据库中没有任何表情包") @@ -160,9 +160,9 @@ class EmojiManager: {'$inc': {'usage_count': 1}} ) logger.success( - f"找到匹配的表情包: {selected_emoji.get('discription', '无描述')} (相似度: {similarity:.4f})") + f"找到匹配的表情包: {selected_emoji.get('description', '无描述')} (相似度: {similarity:.4f})") # 稍微改一下文本描述,不然容易产生幻觉,描述已经包含 表情包 了 - return selected_emoji['path'], "[ %s ]" % selected_emoji.get('discription', '无描述') + return selected_emoji['path'], "[ %s ]" % selected_emoji.get('description', '无描述') except Exception as search_error: logger.error(f"搜索表情包失败: {str(search_error)}") @@ -174,7 +174,7 @@ class EmojiManager: logger.error(f"获取表情包失败: {str(e)}") return None - async def _get_emoji_discription(self, image_base64: str) -> str: + async def _get_emoji_description(self, image_base64: str) -> str: """获取表情包的标签""" try: prompt = '这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感' @@ -203,7 +203,7 @@ class EmojiManager: try: prompt = f'这是{global_config.BOT_NICKNAME}将要发送的消息内容:\n{text}\n若要为其配上表情包,请你输出这个表情包应该表达怎样的情感,应该给人什么样的感觉,不要太简洁也不要太长,注意不要输出任何对消息内容的分析内容,只输出\"一种什么样的感觉\"中间的形容词部分。' - content, _ = await self.llm_emotion_judge.generate_response_async(prompt) + content, _ = await self.llm_emotion_judge.generate_response_async(prompt,temperature=1.5) logger.info(f"输出描述: {content}") return content @@ -236,36 +236,36 @@ class EmojiManager: continue # 获取表情包的描述 - discription = await self._get_emoji_discription(image_base64) + description = await self._get_emoji_description(image_base64) if global_config.EMOJI_CHECK: check = await self._check_emoji(image_base64) if '是' not in check: os.remove(image_path) - logger.info(f"描述: {discription}") + logger.info(f"描述: {description}") logger.info(f"其不满足过滤规则,被剔除 {check}") continue logger.info(f"check通过 {check}") - if discription is not None: - embedding = await get_embedding(discription) + if description is not None: + embedding = await get_embedding(description) # 准备数据库记录 emoji_record = { 'filename': filename, 'path': image_path, 'embedding': embedding, - 'discription': discription, + 'description': description, 'timestamp': int(time.time()) } # 保存到数据库 self.db.db['emoji'].insert_one(emoji_record) logger.success(f"注册新表情包: {filename}") - logger.info(f"描述: {discription}") + logger.info(f"描述: {description}") else: logger.warning(f"跳过表情包: {filename}") - except Exception as e: - logger.exception(f"扫描表情包失败") + except Exception: + logger.exception("扫描表情包失败") async def _periodic_scan(self, interval_MINS: int = 10): """定期扫描新表情包""" diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index f40c9a441..4e431d9fd 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -94,7 +94,7 @@ class ResponseGenerator: try: content, reasoning_content = await model.generate_response(prompt) except Exception: - logger.exception(f"生成回复时出错") + logger.exception("生成回复时出错") return None # 保存到数据库 @@ -146,7 +146,7 @@ class ResponseGenerator: return ["neutral"] except Exception: - logger.exception(f"获取情感标签时出错") + logger.exception("获取情感标签时出错") return ["neutral"] async def _process_response(self, content: str) -> Tuple[List[str], List[str]]: diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 4abbd3b3f..0fb40373e 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -61,7 +61,7 @@ class Message_Sender: auto_escape=auto_escape ) logger.debug(f"发送消息{message}成功") - except Exception as e: + except Exception: logger.exception(f"发送消息{message}失败") @@ -120,7 +120,7 @@ class MessageContainer: return True return False except Exception: - logger.exception(f"移除消息时发生错误") + logger.exception("移除消息时发生错误") return False def has_messages(self) -> bool: @@ -214,7 +214,7 @@ class MessageManager: if not container.remove_message(msg): logger.warning("尝试删除不存在的消息") except Exception: - logger.exception(f"处理超时消息时发生错误") + logger.exception("处理超时消息时发生错误") continue async def start_processor(self): diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index 4cf21af19..0805caa5a 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -131,18 +131,19 @@ class PromptBuilder: probability_1 = global_config.PERSONALITY_1 probability_2 = global_config.PERSONALITY_2 probability_3 = global_config.PERSONALITY_3 - prompt_personality = '' + + prompt_personality = f'{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},你还有很多别名:{"/".join(global_config.BOT_ALIAS_NAMES)},' personality_choice = random.random() if personality_choice < probability_1: # 第一种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[0]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality += f'''{personality[0]}, 你正在浏览qq群,{promt_info_prompt}, 现在请你给出日常且口语化的回复,平淡一些,尽量简短一些。{keywords_reaction_prompt} 请注意把握群里的聊天内容,不要刻意突出自身学科背景,不要回复的太有条理,可以有个性。''' elif personality_choice < probability_1 + probability_2: # 第二种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[1]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality += f'''{personality[1]}, 你正在浏览qq群,{promt_info_prompt}, 现在请你给出日常且口语化的回复,请表现你自己的见解,不要一昧迎合,尽量简短一些。{keywords_reaction_prompt} 请你表达自己的见解和观点。可以有个性。''' else: # 第三种人格 - prompt_personality = f'''{activate_prompt}你的网名叫{global_config.BOT_NICKNAME},{personality[2]}, 你正在浏览qq群,{promt_info_prompt}, + prompt_personality += f'''{personality[2]}, 你正在浏览qq群,{promt_info_prompt}, 现在请你给出日常且口语化的回复,请表现你自己的见解,不要一昧迎合,尽量简短一些。{keywords_reaction_prompt} 请你表达自己的见解和观点。可以有个性。''' diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index 1c2d05071..4081f8984 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -45,6 +45,6 @@ class MessageStorage: self.db.db.messages.insert_one(message_data) except Exception: - logger.exception(f"存储消息失败") + logger.exception("存储消息失败") # 如果需要其他存储相关的函数,可以在这里添加 diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index 054526e94..6619f37af 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -53,19 +53,13 @@ def db_message_to_str(message_dict: Dict) -> str: return result -def is_mentioned_bot_in_message(message: Message) -> bool: - """检查消息是否提到了机器人""" - keywords = [global_config.BOT_NICKNAME] - for keyword in keywords: - if keyword in message.processed_plain_text: - return True - return False - - def is_mentioned_bot_in_txt(message: str) -> bool: """检查消息是否提到了机器人""" - keywords = [global_config.BOT_NICKNAME] - for keyword in keywords: + if global_config.BOT_NICKNAME is None: + return True + if global_config.BOT_NICKNAME in message: + return True + for keyword in global_config.BOT_ALIAS_NAMES: if keyword in message: return True return False diff --git a/src/plugins/knowledege/knowledge_library.py b/src/plugins/knowledege/knowledge_library.py index 481076961..4bf6227bb 100644 --- a/src/plugins/knowledege/knowledge_library.py +++ b/src/plugins/knowledege/knowledge_library.py @@ -79,7 +79,7 @@ class KnowledgeLibrary: content = f.read() # 按1024字符分段 - segments = [content[i:i+600] for i in range(0, len(content), 600)] + segments = [content[i:i+600] for i in range(0, len(content), 300)] # 处理每个分段 for segment in segments: diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 9b325b36d..0730f9e57 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -25,26 +25,46 @@ class Memory_graph: self.db = Database.get_instance() def connect_dot(self, concept1, concept2): - # 如果边已存在,增加 strength + # 避免自连接 + if concept1 == concept2: + return + + current_time = datetime.datetime.now().timestamp() + + # 如果边已存在,增加 strength if self.G.has_edge(concept1, concept2): self.G[concept1][concept2]['strength'] = self.G[concept1][concept2].get('strength', 1) + 1 + # 更新最后修改时间 + self.G[concept1][concept2]['last_modified'] = current_time else: - # 如果是新边,初始化 strength 为 1 - self.G.add_edge(concept1, concept2, strength=1) + # 如果是新边,初始化 strength 为 1 + self.G.add_edge(concept1, concept2, + strength=1, + created_time=current_time, # 添加创建时间 + last_modified=current_time) # 添加最后修改时间 def add_dot(self, concept, memory): + current_time = datetime.datetime.now().timestamp() + if concept in self.G: - # 如果节点已存在,将新记忆添加到现有列表中 if 'memory_items' in self.G.nodes[concept]: if not isinstance(self.G.nodes[concept]['memory_items'], list): - # 如果当前不是列表,将其转换为列表 self.G.nodes[concept]['memory_items'] = [self.G.nodes[concept]['memory_items']] self.G.nodes[concept]['memory_items'].append(memory) + # 更新最后修改时间 + self.G.nodes[concept]['last_modified'] = current_time else: self.G.nodes[concept]['memory_items'] = [memory] + # 如果节点存在但没有memory_items,说明是第一次添加memory,设置created_time + if 'created_time' not in self.G.nodes[concept]: + self.G.nodes[concept]['created_time'] = current_time + self.G.nodes[concept]['last_modified'] = current_time else: - # 如果是新节点,创建新的记忆列表 - self.G.add_node(concept, memory_items=[memory]) + # 如果是新节点,创建新的记忆列表 + self.G.add_node(concept, + memory_items=[memory], + created_time=current_time, # 添加创建时间 + last_modified=current_time) # 添加最后修改时间 def get_dot(self, concept): # 检查节点是否存在于图中 @@ -191,15 +211,11 @@ class Hippocampus: async def memory_compress(self, messages: list, compress_rate=0.1): """压缩消息记录为记忆 - Args: - messages: 消息记录字典列表,每个字典包含text和time字段 - compress_rate: 压缩率 - Returns: - set: (话题, 记忆) 元组集合 + tuple: (压缩记忆集合, 相似主题字典) """ if not messages: - return set() + return set(), {} # 合并消息文本,同时保留时间信息 input_text = "" @@ -246,12 +262,33 @@ class Hippocampus: # 等待所有任务完成 compressed_memory = set() + similar_topics_dict = {} # 存储每个话题的相似主题列表 for topic, task in tasks: response = await task if response: compressed_memory.add((topic, response[0])) + # 为每个话题查找相似的已存在主题 + existing_topics = list(self.memory_graph.G.nodes()) + similar_topics = [] + + for existing_topic in existing_topics: + topic_words = set(jieba.cut(topic)) + existing_words = set(jieba.cut(existing_topic)) + + all_words = topic_words | existing_words + v1 = [1 if word in topic_words else 0 for word in all_words] + v2 = [1 if word in existing_words else 0 for word in all_words] + + similarity = cosine_similarity(v1, v2) + + if similarity >= 0.6: + similar_topics.append((existing_topic, similarity)) + + similar_topics.sort(key=lambda x: x[1], reverse=True) + similar_topics = similar_topics[:5] + similar_topics_dict[topic] = similar_topics - return compressed_memory + return compressed_memory, similar_topics_dict def calculate_topic_num(self, text, compress_rate): """计算文本的话题数量""" @@ -265,33 +302,40 @@ class Hippocampus: return topic_num async def operation_build_memory(self, chat_size=20): - # 最近消息获取频率 - time_frequency = {'near': 2, 'mid': 4, 'far': 2} - memory_sample = self.get_memory_sample(chat_size, time_frequency) - - for i, input_text in enumerate(memory_sample, 1): - # 加载进度可视化 + time_frequency = {'near': 3, 'mid': 8, 'far': 5} + memory_samples = self.get_memory_sample(chat_size, time_frequency) + + for i, messages in enumerate(memory_samples, 1): all_topics = [] - progress = (i / len(memory_sample)) * 100 + # 加载进度可视化 + progress = (i / len(memory_samples)) * 100 bar_length = 30 - filled_length = int(bar_length * i // len(memory_sample)) + filled_length = int(bar_length * i // len(memory_samples)) bar = '█' * filled_length + '-' * (bar_length - filled_length) - logger.debug(f"进度: [{bar}] {progress:.1f}% ({i}/{len(memory_sample)})") + logger.debug(f"进度: [{bar}] {progress:.1f}% ({i}/{len(memory_samples)})") - # 生成压缩后记忆 ,表现为 (话题,记忆) 的元组 - compressed_memory = set() compress_rate = 0.1 - compressed_memory = await self.memory_compress(input_text, compress_rate) - logger.info(f"压缩后记忆数量: {len(compressed_memory)}") - - # 将记忆加入到图谱中 + compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate) + logger.info(f"压缩后记忆数量: {len(compressed_memory)},似曾相识的话题: {len(similar_topics_dict)}") + for topic, memory in compressed_memory: logger.info(f"添加节点: {topic}") self.memory_graph.add_dot(topic, memory) - all_topics.append(topic) # 收集所有话题 + all_topics.append(topic) + + # 连接相似的已存在主题 + if topic in similar_topics_dict: + similar_topics = similar_topics_dict[topic] + for similar_topic, similarity in similar_topics: + if topic != similar_topic: + strength = int(similarity * 10) + logger.info(f"连接相似节点: {topic} 和 {similar_topic} (强度: {strength})") + self.memory_graph.G.add_edge(topic, similar_topic, strength=strength) + + # 连接同批次的相关话题 for i in range(len(all_topics)): for j in range(i + 1, len(all_topics)): - logger.info(f"连接节点: {all_topics[i]} 和 {all_topics[j]}") + logger.info(f"连接同批次节点: {all_topics[i]} 和 {all_topics[j]}") self.memory_graph.connect_dot(all_topics[i], all_topics[j]) self.sync_memory_to_db() @@ -302,7 +346,7 @@ class Hippocampus: db_nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) - # 转换数据库节点为字典格式,方便查找 + # 转换数据库节点为字典格式,方便查找 db_nodes_dict = {node['concept']: node for node in db_nodes} # 检查并更新节点 @@ -313,13 +357,19 @@ class Hippocampus: # 计算内存中节点的特征值 memory_hash = self.calculate_node_hash(concept, memory_items) + + # 获取时间信息 + created_time = data.get('created_time', datetime.datetime.now().timestamp()) + last_modified = data.get('last_modified', datetime.datetime.now().timestamp()) if concept not in db_nodes_dict: - # 数据库中缺少的节点,添加 + # 数据库中缺少的节点,添加 node_data = { 'concept': concept, 'memory_items': memory_items, - 'hash': memory_hash + 'hash': memory_hash, + 'created_time': created_time, + 'last_modified': last_modified } self.memory_graph.db.db.graph_data.nodes.insert_one(node_data) else: @@ -327,25 +377,21 @@ class Hippocampus: db_node = db_nodes_dict[concept] db_hash = db_node.get('hash', None) - # 如果特征值不同,则更新节点 + # 如果特征值不同,则更新节点 if db_hash != memory_hash: self.memory_graph.db.db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'memory_items': memory_items, - 'hash': memory_hash + 'hash': memory_hash, + 'created_time': created_time, + 'last_modified': last_modified }} ) - # 检查并删除数据库中多余的节点 - memory_concepts = set(node[0] for node in memory_nodes) - for db_node in db_nodes: - if db_node['concept'] not in memory_concepts: - self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) - # 处理边的信息 db_edges = list(self.memory_graph.db.db.graph_data.edges.find()) - memory_edges = list(self.memory_graph.G.edges()) + memory_edges = list(self.memory_graph.G.edges(data=True)) # 创建边的哈希值字典 db_edge_dict = {} @@ -357,10 +403,14 @@ class Hippocampus: } # 检查并更新边 - for source, target in memory_edges: + for source, target, data in memory_edges: edge_hash = self.calculate_edge_hash(source, target) edge_key = (source, target) - strength = self.memory_graph.G[source][target].get('strength', 1) + strength = data.get('strength', 1) + + # 获取边的时间信息 + created_time = data.get('created_time', datetime.datetime.now().timestamp()) + last_modified = data.get('last_modified', datetime.datetime.now().timestamp()) if edge_key not in db_edge_dict: # 添加新边 @@ -368,7 +418,9 @@ class Hippocampus: 'source': source, 'target': target, 'strength': strength, - 'hash': edge_hash + 'hash': edge_hash, + 'created_time': created_time, + 'last_modified': last_modified } self.memory_graph.db.db.graph_data.edges.insert_one(edge_data) else: @@ -378,20 +430,12 @@ class Hippocampus: {'source': source, 'target': target}, {'$set': { 'hash': edge_hash, - 'strength': strength + 'strength': strength, + 'created_time': created_time, + 'last_modified': last_modified }} ) - # 删除多余的边 - memory_edge_set = set(memory_edges) - for edge_key in db_edge_dict: - if edge_key not in memory_edge_set: - source, target = edge_key - self.memory_graph.db.db.graph_data.edges.delete_one({ - 'source': source, - 'target': target - }) - def sync_memory_from_db(self): """从数据库同步数据到内存中的图结构""" # 清空当前图 @@ -405,61 +449,107 @@ class Hippocampus: # 确保memory_items是列表 if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] + + # 获取时间信息 + created_time = node.get('created_time', datetime.datetime.now().timestamp()) + last_modified = node.get('last_modified', datetime.datetime.now().timestamp()) + # 添加节点到图中 - self.memory_graph.G.add_node(concept, memory_items=memory_items) + self.memory_graph.G.add_node(concept, + memory_items=memory_items, + created_time=created_time, + last_modified=last_modified) # 从数据库加载所有边 edges = self.memory_graph.db.db.graph_data.edges.find() for edge in edges: source = edge['source'] target = edge['target'] - strength = edge.get('strength', 1) # 获取 strength,默认为 1 + strength = edge.get('strength', 1) # 获取 strength,默认为 1 + + # 获取时间信息 + created_time = edge.get('created_time', datetime.datetime.now().timestamp()) + last_modified = edge.get('last_modified', datetime.datetime.now().timestamp()) + # 只有当源节点和目标节点都存在时才添加边 if source in self.memory_graph.G and target in self.memory_graph.G: - self.memory_graph.G.add_edge(source, target, strength=strength) + self.memory_graph.G.add_edge(source, target, + strength=strength, + created_time=created_time, + last_modified=last_modified) async def operation_forget_topic(self, percentage=0.1): - """随机选择图中一定比例的节点进行检查,根据条件决定是否遗忘""" - # 获取所有节点 + """随机选择图中一定比例的节点和边进行检查,根据时间条件决定是否遗忘""" all_nodes = list(self.memory_graph.G.nodes()) - # 计算要检查的节点数量 - check_count = max(1, int(len(all_nodes) * percentage)) - # 随机选择节点 - nodes_to_check = random.sample(all_nodes, check_count) - - forgotten_nodes = [] + all_edges = list(self.memory_graph.G.edges()) + + check_nodes_count = max(1, int(len(all_nodes) * percentage)) + check_edges_count = max(1, int(len(all_edges) * percentage)) + + nodes_to_check = random.sample(all_nodes, check_nodes_count) + edges_to_check = random.sample(all_edges, check_edges_count) + + edge_changes = {'weakened': 0, 'removed': 0} + node_changes = {'reduced': 0, 'removed': 0} + + current_time = datetime.datetime.now().timestamp() + + # 检查并遗忘连接 + logger.info("开始检查连接...") + for source, target in edges_to_check: + edge_data = self.memory_graph.G[source][target] + last_modified = edge_data.get('last_modified') + # print(source,target) + # print(f"float(last_modified):{float(last_modified)}" ) + # print(f"current_time:{current_time}") + # print(f"current_time - last_modified:{current_time - last_modified}") + if current_time - last_modified > 3600*24: # test + current_strength = edge_data.get('strength', 1) + new_strength = current_strength - 1 + + if new_strength <= 0: + self.memory_graph.G.remove_edge(source, target) + edge_changes['removed'] += 1 + logger.info(f"\033[1;31m[连接移除]\033[0m {source} - {target}") + else: + edge_data['strength'] = new_strength + edge_data['last_modified'] = current_time + edge_changes['weakened'] += 1 + logger.info(f"\033[1;34m[连接减弱]\033[0m {source} - {target} (强度: {current_strength} -> {new_strength})") + + # 检查并遗忘话题 + logger.info("开始检查节点...") for node in nodes_to_check: - # 获取节点的连接数 - connections = self.memory_graph.G.degree(node) - - # 获取节点的内容条数 - memory_items = self.memory_graph.G.nodes[node].get('memory_items', []) - if not isinstance(memory_items, list): - memory_items = [memory_items] if memory_items else [] - content_count = len(memory_items) - - # 检查连接强度 - weak_connections = True - if connections > 1: # 只有当连接数大于1时才检查强度 - for neighbor in self.memory_graph.G.neighbors(node): - strength = self.memory_graph.G[node][neighbor].get('strength', 1) - if strength > 2: - weak_connections = False - break - - # 如果满足遗忘条件 - if (connections <= 1 and weak_connections) or content_count <= 2: - removed_item = self.memory_graph.forget_topic(node) - if removed_item: - forgotten_nodes.append((node, removed_item)) - logger.debug(f"遗忘节点 {node} 的记忆: {removed_item}") - - # 同步到数据库 - if forgotten_nodes: + node_data = self.memory_graph.G.nodes[node] + last_modified = node_data.get('last_modified', current_time) + + if current_time - last_modified > 3600*24: # test + memory_items = node_data.get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + if memory_items: + current_count = len(memory_items) + removed_item = random.choice(memory_items) + memory_items.remove(removed_item) + + if memory_items: + self.memory_graph.G.nodes[node]['memory_items'] = memory_items + self.memory_graph.G.nodes[node]['last_modified'] = current_time + node_changes['reduced'] += 1 + logger.info(f"\033[1;33m[记忆减少]\033[0m {node} (记忆数量: {current_count} -> {len(memory_items)})") + else: + self.memory_graph.G.remove_node(node) + node_changes['removed'] += 1 + logger.info(f"\033[1;31m[节点移除]\033[0m {node}") + + if any(count > 0 for count in edge_changes.values()) or any(count > 0 for count in node_changes.values()): self.sync_memory_to_db() - logger.debug(f"完成遗忘操作,共遗忘 {len(forgotten_nodes)} 个节点的记忆") + logger.info("\n遗忘操作统计:") + logger.info(f"连接变化: {edge_changes['weakened']} 个减弱, {edge_changes['removed']} 个移除") + logger.info(f"节点变化: {node_changes['reduced']} 个减少记忆, {node_changes['removed']} 个移除") else: - logger.debug("本次检查没有节点满足遗忘条件") + logger.info("\n本次检查没有节点或连接满足遗忘条件") async def merge_memory(self, topic): """ @@ -486,7 +576,7 @@ class Hippocampus: logger.debug(f"选择的记忆:\n{merged_text}") # 使用memory_compress生成新的压缩记忆 - compressed_memories = await self.memory_compress(selected_memories, 0.1) + compressed_memories, _ = await self.memory_compress(selected_memories, 0.1) # 从原记忆列表中移除被选中的记忆 for memory in selected_memories: diff --git a/src/plugins/memory_system/memory_test1.py b/src/plugins/memory_system/memory_test1.py new file mode 100644 index 000000000..bbd734ec2 --- /dev/null +++ b/src/plugins/memory_system/memory_test1.py @@ -0,0 +1,1208 @@ +# -*- coding: utf-8 -*- +import datetime +import math +import os +import random +import sys +import time +from collections import Counter +from pathlib import Path + +import matplotlib.pyplot as plt +import networkx as nx +import pymongo +from dotenv import load_dotenv +from loguru import logger +import jieba + +''' +该理论认为,当两个或多个事物在形态上具有相似性时, +它们在记忆中会形成关联。 +例如,梨和苹果在形状和都是水果这一属性上有相似性, +所以当我们看到梨时,很容易通过形态学联想记忆联想到苹果。 +这种相似性联想有助于我们对新事物进行分类和理解, +当遇到一个新的类似水果时, +我们可以通过与已有的水果记忆进行相似性匹配, +来推测它的一些特征。 + + + +时空关联性联想: +除了相似性联想,MAM 还强调时空关联性联想。 +如果两个事物在时间或空间上经常同时出现,它们也会在记忆中形成关联。 +比如,每次在公园里看到花的时候,都能听到鸟儿的叫声, +那么花和鸟儿叫声的形态特征(花的视觉形态和鸟叫的听觉形态)就会在记忆中形成关联, +以后听到鸟叫可能就会联想到公园里的花。 + +''' + +# from chat.config import global_config +sys.path.append("C:/GitHub/MaiMBot") # 添加项目根目录到 Python 路径 +from src.common.database import Database +from src.plugins.memory_system.offline_llm import LLMModel + +# 获取当前文件的目录 +current_dir = Path(__file__).resolve().parent +# 获取项目根目录(上三层目录) +project_root = current_dir.parent.parent.parent +# env.dev文件路径 +env_path = project_root / ".env.dev" + +# 加载环境变量 +if env_path.exists(): + logger.info(f"从 {env_path} 加载环境变量") + load_dotenv(env_path) +else: + logger.warning(f"未找到环境变量文件: {env_path}") + logger.info("将使用默认配置") + +class Database: + _instance = None + db = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + if not Database.db: + Database.initialize( + host=os.getenv("MONGODB_HOST"), + port=int(os.getenv("MONGODB_PORT")), + db_name=os.getenv("DATABASE_NAME"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE") + ) + + @classmethod + def initialize(cls, host, port, db_name, username=None, password=None, auth_source="admin"): + try: + if username and password: + uri = f"mongodb://{username}:{password}@{host}:{port}/{db_name}?authSource={auth_source}" + else: + uri = f"mongodb://{host}:{port}" + + client = pymongo.MongoClient(uri) + cls.db = client[db_name] + # 测试连接 + client.server_info() + logger.success("MongoDB连接成功!") + + except Exception as e: + logger.error(f"初始化MongoDB失败: {str(e)}") + raise + +def calculate_information_content(text): + """计算文本的信息量(熵)""" + char_count = Counter(text) + total_chars = len(text) + + entropy = 0 + for count in char_count.values(): + probability = count / total_chars + entropy -= probability * math.log2(probability) + + return entropy + +def get_cloest_chat_from_db(db, length: int, timestamp: str): + """从数据库中获取最接近指定时间戳的聊天记录,并记录读取次数 + + Returns: + list: 消息记录字典列表,每个字典包含消息内容和时间信息 + """ + chat_records = [] + closest_record = db.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) + + if closest_record and closest_record.get('memorized', 0) < 4: + closest_time = closest_record['time'] + group_id = closest_record['group_id'] + # 获取该时间戳之后的length条消息,且groupid相同 + records = list(db.db.messages.find( + {"time": {"$gt": closest_time}, "group_id": group_id} + ).sort('time', 1).limit(length)) + + # 更新每条消息的memorized属性 + for record in records: + current_memorized = record.get('memorized', 0) + if current_memorized > 3: + print("消息已读取3次,跳过") + return '' + + # 更新memorized值 + db.db.messages.update_one( + {"_id": record["_id"]}, + {"$set": {"memorized": current_memorized + 1}} + ) + + # 添加到记录列表中 + chat_records.append({ + 'text': record["detailed_plain_text"], + 'time': record["time"], + 'group_id': record["group_id"] + }) + + return chat_records + +class Memory_cortex: + def __init__(self, memory_graph: 'Memory_graph'): + self.memory_graph = memory_graph + + def sync_memory_from_db(self): + """ + 从数据库同步数据到内存中的图结构 + 将清空当前内存中的图,并从数据库重新加载所有节点和边 + """ + # 清空当前图 + self.memory_graph.G.clear() + + # 获取当前时间作为默认时间 + default_time = datetime.datetime.now().timestamp() + + # 从数据库加载所有节点 + nodes = self.memory_graph.db.db.graph_data.nodes.find() + for node in nodes: + concept = node['concept'] + memory_items = node.get('memory_items', []) + # 确保memory_items是列表 + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + # 获取时间属性,如果不存在则使用默认时间 + created_time = node.get('created_time') + last_modified = node.get('last_modified') + + # 如果时间属性不存在,则更新数据库 + if created_time is None or last_modified is None: + created_time = default_time + last_modified = default_time + # 更新数据库中的节点 + self.memory_graph.db.db.graph_data.nodes.update_one( + {'concept': concept}, + {'$set': { + 'created_time': created_time, + 'last_modified': last_modified + }} + ) + logger.info(f"为节点 {concept} 添加默认时间属性") + + # 添加节点到图中,包含时间属性 + self.memory_graph.G.add_node(concept, + memory_items=memory_items, + created_time=created_time, + last_modified=last_modified) + + # 从数据库加载所有边 + edges = self.memory_graph.db.db.graph_data.edges.find() + for edge in edges: + source = edge['source'] + target = edge['target'] + + # 只有当源节点和目标节点都存在时才添加边 + if source in self.memory_graph.G and target in self.memory_graph.G: + # 获取时间属性,如果不存在则使用默认时间 + created_time = edge.get('created_time') + last_modified = edge.get('last_modified') + + # 如果时间属性不存在,则更新数据库 + if created_time is None or last_modified is None: + created_time = default_time + last_modified = default_time + # 更新数据库中的边 + self.memory_graph.db.db.graph_data.edges.update_one( + {'source': source, 'target': target}, + {'$set': { + 'created_time': created_time, + 'last_modified': last_modified + }} + ) + logger.info(f"为边 {source} - {target} 添加默认时间属性") + + self.memory_graph.G.add_edge(source, target, + strength=edge.get('strength', 1), + created_time=created_time, + last_modified=last_modified) + + logger.success("从数据库同步记忆图谱完成") + + def calculate_node_hash(self, concept, memory_items): + """ + 计算节点的特征值 + """ + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + # 将记忆项排序以确保相同内容生成相同的哈希值 + sorted_items = sorted(memory_items) + # 组合概念和记忆项生成特征值 + content = f"{concept}:{'|'.join(sorted_items)}" + return hash(content) + + def calculate_edge_hash(self, source, target): + """ + 计算边的特征值 + """ + # 对源节点和目标节点排序以确保相同的边生成相同的哈希值 + nodes = sorted([source, target]) + return hash(f"{nodes[0]}:{nodes[1]}") + + def sync_memory_to_db(self): + """ + 检查并同步内存中的图结构与数据库 + 使用特征值(哈希值)快速判断是否需要更新 + """ + current_time = datetime.datetime.now().timestamp() + + # 获取数据库中所有节点和内存中所有节点 + db_nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) + memory_nodes = list(self.memory_graph.G.nodes(data=True)) + + # 转换数据库节点为字典格式,方便查找 + db_nodes_dict = {node['concept']: node for node in db_nodes} + + # 检查并更新节点 + for concept, data in memory_nodes: + memory_items = data.get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + # 计算内存中节点的特征值 + memory_hash = self.calculate_node_hash(concept, memory_items) + + if concept not in db_nodes_dict: + # 数据库中缺少的节点,添加 + node_data = { + 'concept': concept, + 'memory_items': memory_items, + 'hash': memory_hash, + 'created_time': data.get('created_time', current_time), + 'last_modified': data.get('last_modified', current_time) + } + self.memory_graph.db.db.graph_data.nodes.insert_one(node_data) + else: + # 获取数据库中节点的特征值 + db_node = db_nodes_dict[concept] + db_hash = db_node.get('hash', None) + + # 如果特征值不同,则更新节点 + if db_hash != memory_hash: + self.memory_graph.db.db.graph_data.nodes.update_one( + {'concept': concept}, + {'$set': { + 'memory_items': memory_items, + 'hash': memory_hash, + 'last_modified': current_time + }} + ) + + # 检查并删除数据库中多余的节点 + memory_concepts = set(node[0] for node in memory_nodes) + for db_node in db_nodes: + if db_node['concept'] not in memory_concepts: + self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) + + # 处理边的信息 + db_edges = list(self.memory_graph.db.db.graph_data.edges.find()) + memory_edges = list(self.memory_graph.G.edges(data=True)) + + # 创建边的哈希值字典 + db_edge_dict = {} + for edge in db_edges: + edge_hash = self.calculate_edge_hash(edge['source'], edge['target']) + db_edge_dict[(edge['source'], edge['target'])] = { + 'hash': edge_hash, + 'strength': edge.get('strength', 1) + } + + # 检查并更新边 + for source, target, data in memory_edges: + edge_hash = self.calculate_edge_hash(source, target) + edge_key = (source, target) + strength = data.get('strength', 1) + + if edge_key not in db_edge_dict: + # 添加新边 + edge_data = { + 'source': source, + 'target': target, + 'strength': strength, + 'hash': edge_hash, + 'created_time': data.get('created_time', current_time), + 'last_modified': data.get('last_modified', current_time) + } + self.memory_graph.db.db.graph_data.edges.insert_one(edge_data) + else: + # 检查边的特征值是否变化 + if db_edge_dict[edge_key]['hash'] != edge_hash: + self.memory_graph.db.db.graph_data.edges.update_one( + {'source': source, 'target': target}, + {'$set': { + 'hash': edge_hash, + 'strength': strength, + 'last_modified': current_time + }} + ) + + # 删除多余的边 + memory_edge_set = set((source, target) for source, target, _ in memory_edges) + for edge_key in db_edge_dict: + if edge_key not in memory_edge_set: + source, target = edge_key + self.memory_graph.db.db.graph_data.edges.delete_one({ + 'source': source, + 'target': target + }) + + logger.success("完成记忆图谱与数据库的差异同步") + + def remove_node_from_db(self, topic): + """ + 从数据库中删除指定节点及其相关的边 + + Args: + topic: 要删除的节点概念 + """ + # 删除节点 + self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': topic}) + # 删除所有涉及该节点的边 + self.memory_graph.db.db.graph_data.edges.delete_many({ + '$or': [ + {'source': topic}, + {'target': topic} + ] + }) + +class Memory_graph: + def __init__(self): + self.G = nx.Graph() # 使用 networkx 的图结构 + self.db = Database.get_instance() + + def connect_dot(self, concept1, concept2): + # 避免自连接 + if concept1 == concept2: + return + + current_time = datetime.datetime.now().timestamp() + + # 如果边已存在,增加 strength + if self.G.has_edge(concept1, concept2): + self.G[concept1][concept2]['strength'] = self.G[concept1][concept2].get('strength', 1) + 1 + # 更新最后修改时间 + self.G[concept1][concept2]['last_modified'] = current_time + else: + # 如果是新边,初始化 strength 为 1 + self.G.add_edge(concept1, concept2, + strength=1, + created_time=current_time, + last_modified=current_time) + + def add_dot(self, concept, memory): + current_time = datetime.datetime.now().timestamp() + + if concept in self.G: + # 如果节点已存在,将新记忆添加到现有列表中 + if 'memory_items' in self.G.nodes[concept]: + if not isinstance(self.G.nodes[concept]['memory_items'], list): + # 如果当前不是列表,将其转换为列表 + self.G.nodes[concept]['memory_items'] = [self.G.nodes[concept]['memory_items']] + self.G.nodes[concept]['memory_items'].append(memory) + # 更新最后修改时间 + self.G.nodes[concept]['last_modified'] = current_time + else: + self.G.nodes[concept]['memory_items'] = [memory] + self.G.nodes[concept]['last_modified'] = current_time + else: + # 如果是新节点,创建新的记忆列表 + self.G.add_node(concept, + memory_items=[memory], + created_time=current_time, + last_modified=current_time) + + def get_dot(self, concept): + # 检查节点是否存在于图中 + if concept in self.G: + # 从图中获取节点数据 + node_data = self.G.nodes[concept] + return concept, node_data + return None + + def get_related_item(self, topic, depth=1): + if topic not in self.G: + return [], [] + + first_layer_items = [] + second_layer_items = [] + + # 获取相邻节点 + neighbors = list(self.G.neighbors(topic)) + + # 获取当前节点的记忆项 + node_data = self.get_dot(topic) + if node_data: + concept, data = node_data + if 'memory_items' in data: + memory_items = data['memory_items'] + if isinstance(memory_items, list): + first_layer_items.extend(memory_items) + else: + first_layer_items.append(memory_items) + + # 只在depth=2时获取第二层记忆 + if depth >= 2: + # 获取相邻节点的记忆项 + for neighbor in neighbors: + node_data = self.get_dot(neighbor) + if node_data: + concept, data = node_data + if 'memory_items' in data: + memory_items = data['memory_items'] + if isinstance(memory_items, list): + second_layer_items.extend(memory_items) + else: + second_layer_items.append(memory_items) + + return first_layer_items, second_layer_items + + @property + def dots(self): + # 返回所有节点对应的 Memory_dot 对象 + return [self.get_dot(node) for node in self.G.nodes()] + +# 海马体 +class Hippocampus: + def __init__(self, memory_graph: Memory_graph): + self.memory_graph = memory_graph + self.memory_cortex = Memory_cortex(memory_graph) + self.llm_model = LLMModel() + self.llm_model_small = LLMModel(model_name="deepseek-ai/DeepSeek-V2.5") + self.llm_model_get_topic = LLMModel(model_name="Pro/Qwen/Qwen2.5-7B-Instruct") + self.llm_model_summary = LLMModel(model_name="Qwen/Qwen2.5-32B-Instruct") + + def get_memory_sample(self, chat_size=20, time_frequency:dict={'near':2,'mid':4,'far':3}): + """获取记忆样本 + + Returns: + list: 消息记录列表,每个元素是一个消息记录字典列表 + """ + current_timestamp = datetime.datetime.now().timestamp() + chat_samples = [] + + # 短期:1h 中期:4h 长期:24h + for _ in range(time_frequency.get('near')): + random_time = current_timestamp - random.randint(1, 3600*4) + messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + if messages: + chat_samples.append(messages) + + for _ in range(time_frequency.get('mid')): + random_time = current_timestamp - random.randint(3600*4, 3600*24) + messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + if messages: + chat_samples.append(messages) + + for _ in range(time_frequency.get('far')): + random_time = current_timestamp - random.randint(3600*24, 3600*24*7) + messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + if messages: + chat_samples.append(messages) + + return chat_samples + + def calculate_topic_num(self,text, compress_rate): + """计算文本的话题数量""" + information_content = calculate_information_content(text) + topic_by_length = text.count('\n')*compress_rate + topic_by_information_content = max(1, min(5, int((information_content-3) * 2))) + topic_num = int((topic_by_length + topic_by_information_content)/2) + print(f"topic_by_length: {topic_by_length}, topic_by_information_content: {topic_by_information_content}, topic_num: {topic_num}") + return topic_num + + async def memory_compress(self, messages: list, compress_rate=0.1): + """压缩消息记录为记忆 + + Args: + messages: 消息记录字典列表,每个字典包含text和time字段 + compress_rate: 压缩率 + + Returns: + tuple: (压缩记忆集合, 相似主题字典) + - 压缩记忆集合: set of (话题, 记忆) 元组 + - 相似主题字典: dict of {话题: [(相似主题, 相似度), ...]} + """ + if not messages: + return set(), {} + + # 合并消息文本,同时保留时间信息 + input_text = "" + time_info = "" + # 计算最早和最晚时间 + earliest_time = min(msg['time'] for msg in messages) + latest_time = max(msg['time'] for msg in messages) + + earliest_dt = datetime.datetime.fromtimestamp(earliest_time) + latest_dt = datetime.datetime.fromtimestamp(latest_time) + + # 如果是同一年 + if earliest_dt.year == latest_dt.year: + earliest_str = earliest_dt.strftime("%m-%d %H:%M:%S") + latest_str = latest_dt.strftime("%m-%d %H:%M:%S") + time_info += f"是在{earliest_dt.year}年,{earliest_str} 到 {latest_str} 的对话:\n" + else: + earliest_str = earliest_dt.strftime("%Y-%m-%d %H:%M:%S") + latest_str = latest_dt.strftime("%Y-%m-%d %H:%M:%S") + time_info += f"是从 {earliest_str} 到 {latest_str} 的对话:\n" + + for msg in messages: + input_text += f"{msg['text']}\n" + + print(input_text) + + topic_num = self.calculate_topic_num(input_text, compress_rate) + topics_response = self.llm_model_get_topic.generate_response(self.find_topic_llm(input_text, topic_num)) + + # 过滤topics + filter_keywords = ['表情包', '图片', '回复', '聊天记录'] + topics = [topic.strip() for topic in topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] + filtered_topics = [topic for topic in topics if not any(keyword in topic for keyword in filter_keywords)] + + print(f"过滤后话题: {filtered_topics}") + + # 为每个话题查找相似的已存在主题 + print("\n检查相似主题:") + similar_topics_dict = {} # 存储每个话题的相似主题列表 + + for topic in filtered_topics: + # 获取所有现有节点 + existing_topics = list(self.memory_graph.G.nodes()) + similar_topics = [] + + # 对每个现有节点计算相似度 + for existing_topic in existing_topics: + # 使用jieba分词并计算余弦相似度 + topic_words = set(jieba.cut(topic)) + existing_words = set(jieba.cut(existing_topic)) + + # 计算词向量 + all_words = topic_words | existing_words + v1 = [1 if word in topic_words else 0 for word in all_words] + v2 = [1 if word in existing_words else 0 for word in all_words] + + # 计算余弦相似度 + similarity = cosine_similarity(v1, v2) + + # 如果相似度超过阈值,添加到结果中 + if similarity >= 0.6: # 设置相似度阈值 + similar_topics.append((existing_topic, similarity)) + + # 按相似度降序排序 + similar_topics.sort(key=lambda x: x[1], reverse=True) + # 只保留前5个最相似的主题 + similar_topics = similar_topics[:5] + + # 存储到字典中 + similar_topics_dict[topic] = similar_topics + + # 输出结果 + if similar_topics: + print(f"\n主题「{topic}」的相似主题:") + for similar_topic, score in similar_topics: + print(f"- {similar_topic} (相似度: {score:.3f})") + else: + print(f"\n主题「{topic}」没有找到相似主题") + + # 创建所有话题的请求任务 + tasks = [] + for topic in filtered_topics: + topic_what_prompt = self.topic_what(input_text, topic , time_info) + # 创建异步任务 + task = self.llm_model_small.generate_response_async(topic_what_prompt) + tasks.append((topic.strip(), task)) + + # 等待所有任务完成 + compressed_memory = set() + for topic, task in tasks: + response = await task + if response: + compressed_memory.add((topic, response[0])) + + return compressed_memory, similar_topics_dict + + async def operation_build_memory(self, chat_size=12): + # 最近消息获取频率 + time_frequency = {'near': 3, 'mid': 8, 'far': 5} + memory_samples = self.get_memory_sample(chat_size, time_frequency) + + all_topics = [] # 用于存储所有话题 + + for i, messages in enumerate(memory_samples, 1): + # 加载进度可视化 + all_topics = [] + progress = (i / len(memory_samples)) * 100 + bar_length = 30 + filled_length = int(bar_length * i // len(memory_samples)) + bar = '█' * filled_length + '-' * (bar_length - filled_length) + print(f"\n进度: [{bar}] {progress:.1f}% ({i}/{len(memory_samples)})") + + # 生成压缩后记忆 + compress_rate = 0.1 + compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate) + print(f"\033[1;33m压缩后记忆数量\033[0m: {len(compressed_memory)},似曾相识的话题: {len(similar_topics_dict)}") + + # 将记忆加入到图谱中 + for topic, memory in compressed_memory: + print(f"\033[1;32m添加节点\033[0m: {topic}") + self.memory_graph.add_dot(topic, memory) + all_topics.append(topic) + + # 连接相似的已存在主题 + if topic in similar_topics_dict: + similar_topics = similar_topics_dict[topic] + for similar_topic, similarity in similar_topics: + # 避免自连接 + if topic != similar_topic: + # 根据相似度设置连接强度 + strength = int(similarity * 10) # 将0.3-1.0的相似度映射到3-10的强度 + print(f"\033[1;36m连接相似节点\033[0m: {topic} 和 {similar_topic} (强度: {strength})") + # 使用相似度作为初始连接强度 + self.memory_graph.G.add_edge(topic, similar_topic, strength=strength) + + # 连接同批次的相关话题 + for i in range(len(all_topics)): + for j in range(i + 1, len(all_topics)): + print(f"\033[1;32m连接同批次节点\033[0m: {all_topics[i]} 和 {all_topics[j]}") + self.memory_graph.connect_dot(all_topics[i], all_topics[j]) + + self.memory_cortex.sync_memory_to_db() + + def forget_connection(self, source, target): + """ + 检查并可能遗忘一个连接 + + Args: + source: 连接的源节点 + target: 连接的目标节点 + + Returns: + tuple: (是否有变化, 变化类型, 变化详情) + 变化类型: 0-无变化, 1-强度减少, 2-连接移除 + """ + current_time = datetime.datetime.now().timestamp() + # 获取边的属性 + edge_data = self.memory_graph.G[source][target] + last_modified = edge_data.get('last_modified', current_time) + + # 如果连接超过7天未更新 + if current_time - last_modified > 6000: # test + # 获取当前强度 + current_strength = edge_data.get('strength', 1) + # 减少连接强度 + new_strength = current_strength - 1 + edge_data['strength'] = new_strength + edge_data['last_modified'] = current_time + + # 如果强度降为0,移除连接 + if new_strength <= 0: + self.memory_graph.G.remove_edge(source, target) + return True, 2, f"移除连接: {source} - {target} (强度降至0)" + else: + return True, 1, f"减弱连接: {source} - {target} (强度: {current_strength} -> {new_strength})" + + return False, 0, "" + + def forget_topic(self, topic): + """ + 检查并可能遗忘一个话题的记忆 + + Args: + topic: 要检查的话题 + + Returns: + tuple: (是否有变化, 变化类型, 变化详情) + 变化类型: 0-无变化, 1-记忆减少, 2-节点移除 + """ + current_time = datetime.datetime.now().timestamp() + # 获取节点的最后修改时间 + node_data = self.memory_graph.G.nodes[topic] + last_modified = node_data.get('last_modified', current_time) + + # 如果话题超过7天未更新 + if current_time - last_modified > 3000: # test + memory_items = node_data.get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + if memory_items: + # 获取当前记忆数量 + current_count = len(memory_items) + # 随机选择一条记忆删除 + removed_item = random.choice(memory_items) + memory_items.remove(removed_item) + + if memory_items: + # 更新节点的记忆项和最后修改时间 + self.memory_graph.G.nodes[topic]['memory_items'] = memory_items + self.memory_graph.G.nodes[topic]['last_modified'] = current_time + return True, 1, f"减少记忆: {topic} (记忆数量: {current_count} -> {len(memory_items)})\n被移除的记忆: {removed_item}" + else: + # 如果没有记忆了,删除节点及其所有连接 + self.memory_graph.G.remove_node(topic) + return True, 2, f"移除节点: {topic} (无剩余记忆)\n最后一条记忆: {removed_item}" + + return False, 0, "" + + async def operation_forget_topic(self, percentage=0.1): + """ + 随机选择图中一定比例的节点和边进行检查,根据时间条件决定是否遗忘 + + Args: + percentage: 要检查的节点和边的比例,默认为0.1(10%) + """ + # 获取所有节点和边 + all_nodes = list(self.memory_graph.G.nodes()) + all_edges = list(self.memory_graph.G.edges()) + + # 计算要检查的数量 + check_nodes_count = max(1, int(len(all_nodes) * percentage)) + check_edges_count = max(1, int(len(all_edges) * percentage)) + + # 随机选择要检查的节点和边 + nodes_to_check = random.sample(all_nodes, check_nodes_count) + edges_to_check = random.sample(all_edges, check_edges_count) + + # 用于统计不同类型的变化 + edge_changes = {'weakened': 0, 'removed': 0} + node_changes = {'reduced': 0, 'removed': 0} + + # 检查并遗忘连接 + print("\n开始检查连接...") + for source, target in edges_to_check: + changed, change_type, details = self.forget_connection(source, target) + if changed: + if change_type == 1: + edge_changes['weakened'] += 1 + logger.info(f"\033[1;34m[连接减弱]\033[0m {details}") + elif change_type == 2: + edge_changes['removed'] += 1 + logger.info(f"\033[1;31m[连接移除]\033[0m {details}") + + # 检查并遗忘话题 + print("\n开始检查节点...") + for node in nodes_to_check: + changed, change_type, details = self.forget_topic(node) + if changed: + if change_type == 1: + node_changes['reduced'] += 1 + logger.info(f"\033[1;33m[记忆减少]\033[0m {details}") + elif change_type == 2: + node_changes['removed'] += 1 + logger.info(f"\033[1;31m[节点移除]\033[0m {details}") + + # 同步到数据库 + if any(count > 0 for count in edge_changes.values()) or any(count > 0 for count in node_changes.values()): + self.memory_cortex.sync_memory_to_db() + print("\n遗忘操作统计:") + print(f"连接变化: {edge_changes['weakened']} 个减弱, {edge_changes['removed']} 个移除") + print(f"节点变化: {node_changes['reduced']} 个减少记忆, {node_changes['removed']} 个移除") + else: + print("\n本次检查没有节点或连接满足遗忘条件") + + async def merge_memory(self, topic): + """ + 对指定话题的记忆进行合并压缩 + + Args: + topic: 要合并的话题节点 + """ + # 获取节点的记忆项 + memory_items = self.memory_graph.G.nodes[topic].get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + + # 如果记忆项不足,直接返回 + if len(memory_items) < 10: + return + + # 随机选择10条记忆 + selected_memories = random.sample(memory_items, 10) + + # 拼接成文本 + merged_text = "\n".join(selected_memories) + print(f"\n[合并记忆] 话题: {topic}") + print(f"选择的记忆:\n{merged_text}") + + # 使用memory_compress生成新的压缩记忆 + compressed_memories, _ = await self.memory_compress(selected_memories, 0.1) + + # 从原记忆列表中移除被选中的记忆 + for memory in selected_memories: + memory_items.remove(memory) + + # 添加新的压缩记忆 + for _, compressed_memory in compressed_memories: + memory_items.append(compressed_memory) + print(f"添加压缩记忆: {compressed_memory}") + + # 更新节点的记忆项 + self.memory_graph.G.nodes[topic]['memory_items'] = memory_items + print(f"完成记忆合并,当前记忆数量: {len(memory_items)}") + + async def operation_merge_memory(self, percentage=0.1): + """ + 随机检查一定比例的节点,对内容数量超过100的节点进行记忆合并 + + Args: + percentage: 要检查的节点比例,默认为0.1(10%) + """ + # 获取所有节点 + all_nodes = list(self.memory_graph.G.nodes()) + # 计算要检查的节点数量 + check_count = max(1, int(len(all_nodes) * percentage)) + # 随机选择节点 + nodes_to_check = random.sample(all_nodes, check_count) + + merged_nodes = [] + for node in nodes_to_check: + # 获取节点的内容条数 + memory_items = self.memory_graph.G.nodes[node].get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + content_count = len(memory_items) + + # 如果内容数量超过100,进行合并 + if content_count > 100: + print(f"\n检查节点: {node}, 当前记忆数量: {content_count}") + await self.merge_memory(node) + merged_nodes.append(node) + + # 同步到数据库 + if merged_nodes: + self.memory_cortex.sync_memory_to_db() + print(f"\n完成记忆合并操作,共处理 {len(merged_nodes)} 个节点") + else: + print("\n本次检查没有需要合并的节点") + + async def _identify_topics(self, text: str) -> list: + """从文本中识别可能的主题""" + topics_response = self.llm_model_get_topic.generate_response(self.find_topic_llm(text, 5)) + topics = [topic.strip() for topic in topics_response[0].replace(",", ",").replace("、", ",").replace(" ", ",").split(",") if topic.strip()] + return topics + + def _find_similar_topics(self, topics: list, similarity_threshold: float = 0.4, debug_info: str = "") -> list: + """查找与给定主题相似的记忆主题""" + all_memory_topics = list(self.memory_graph.G.nodes()) + all_similar_topics = [] + + for topic in topics: + if debug_info: + pass + + topic_vector = text_to_vector(topic) + has_similar_topic = False + + for memory_topic in all_memory_topics: + memory_vector = text_to_vector(memory_topic) + all_words = set(topic_vector.keys()) | set(memory_vector.keys()) + v1 = [topic_vector.get(word, 0) for word in all_words] + v2 = [memory_vector.get(word, 0) for word in all_words] + similarity = cosine_similarity(v1, v2) + + if similarity >= similarity_threshold: + has_similar_topic = True + all_similar_topics.append((memory_topic, similarity)) + + return all_similar_topics + + def _get_top_topics(self, similar_topics: list, max_topics: int = 5) -> list: + """获取相似度最高的主题""" + seen_topics = set() + top_topics = [] + + for topic, score in sorted(similar_topics, key=lambda x: x[1], reverse=True): + if topic not in seen_topics and len(top_topics) < max_topics: + seen_topics.add(topic) + top_topics.append((topic, score)) + + return top_topics + + async def memory_activate_value(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.3) -> int: + """计算输入文本对记忆的激活程度""" + logger.info(f"[记忆激活]识别主题: {await self._identify_topics(text)}") + + identified_topics = await self._identify_topics(text) + if not identified_topics: + return 0 + + all_similar_topics = self._find_similar_topics( + identified_topics, + similarity_threshold=similarity_threshold, + debug_info="记忆激活" + ) + + if not all_similar_topics: + return 0 + + top_topics = self._get_top_topics(all_similar_topics, max_topics) + + if len(top_topics) == 1: + topic, score = top_topics[0] + memory_items = self.memory_graph.G.nodes[topic].get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + content_count = len(memory_items) + penalty = 1.0 / (1 + math.log(content_count + 1)) + + activation = int(score * 50 * penalty) + print(f"\033[1;32m[记忆激活]\033[0m 单主题「{topic}」- 相似度: {score:.3f}, 内容数: {content_count}, 激活值: {activation}") + return activation + + matched_topics = set() + topic_similarities = {} + + for memory_topic, similarity in top_topics: + memory_items = self.memory_graph.G.nodes[memory_topic].get('memory_items', []) + if not isinstance(memory_items, list): + memory_items = [memory_items] if memory_items else [] + content_count = len(memory_items) + penalty = 1.0 / (1 + math.log(content_count + 1)) + + for input_topic in identified_topics: + topic_vector = text_to_vector(input_topic) + memory_vector = text_to_vector(memory_topic) + all_words = set(topic_vector.keys()) | set(memory_vector.keys()) + v1 = [topic_vector.get(word, 0) for word in all_words] + v2 = [memory_vector.get(word, 0) for word in all_words] + sim = cosine_similarity(v1, v2) + if sim >= similarity_threshold: + matched_topics.add(input_topic) + adjusted_sim = sim * penalty + topic_similarities[input_topic] = max(topic_similarities.get(input_topic, 0), adjusted_sim) + print(f"\033[1;32m[记忆激活]\033[0m 主题「{input_topic}」-> 「{memory_topic}」(内容数: {content_count}, 相似度: {adjusted_sim:.3f})") + + topic_match = len(matched_topics) / len(identified_topics) + average_similarities = sum(topic_similarities.values()) / len(topic_similarities) if topic_similarities else 0 + + activation = int((topic_match + average_similarities) / 2 * 100) + print(f"\033[1;32m[记忆激活]\033[0m 匹配率: {topic_match:.3f}, 平均相似度: {average_similarities:.3f}, 激活值: {activation}") + + return activation + + async def get_relevant_memories(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.4, max_memory_num: int = 5) -> list: + """根据输入文本获取相关的记忆内容""" + identified_topics = await self._identify_topics(text) + + all_similar_topics = self._find_similar_topics( + identified_topics, + similarity_threshold=similarity_threshold, + debug_info="记忆检索" + ) + + relevant_topics = self._get_top_topics(all_similar_topics, max_topics) + + relevant_memories = [] + for topic, score in relevant_topics: + first_layer, _ = self.memory_graph.get_related_item(topic, depth=1) + if first_layer: + if len(first_layer) > max_memory_num/2: + first_layer = random.sample(first_layer, max_memory_num//2) + for memory in first_layer: + relevant_memories.append({ + 'topic': topic, + 'similarity': score, + 'content': memory + }) + + relevant_memories.sort(key=lambda x: x['similarity'], reverse=True) + + if len(relevant_memories) > max_memory_num: + relevant_memories = random.sample(relevant_memories, max_memory_num) + + return relevant_memories + + def find_topic_llm(self,text, topic_num): + prompt = f'这是一段文字:{text}。请你从这段话中总结出{topic_num}个关键的概念,可以是名词,动词,或者特定人物,帮我列出来,用逗号,隔开,尽可能精简。只需要列举{topic_num}个话题就好,不要有序号,不要告诉我其他内容。' + return prompt + + def topic_what(self,text, topic, time_info): + prompt = f'这是一段文字,{time_info}:{text}。我想让你基于这段文字来概括"{topic}"这个概念,帮我总结成一句自然的话,可以包含时间和人物,以及具体的观点。只输出这句话就好' + return prompt + +def segment_text(text): + """使用jieba进行文本分词""" + seg_text = list(jieba.cut(text)) + return seg_text + +def text_to_vector(text): + """将文本转换为词频向量""" + words = segment_text(text) + vector = {} + for word in words: + vector[word] = vector.get(word, 0) + 1 + return vector + +def cosine_similarity(v1, v2): + """计算两个向量的余弦相似度""" + dot_product = sum(a * b for a, b in zip(v1, v2)) + norm1 = math.sqrt(sum(a * a for a in v1)) + norm2 = math.sqrt(sum(b * b for b in v2)) + if norm1 == 0 or norm2 == 0: + return 0 + return dot_product / (norm1 * norm2) + +def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = False): + # 设置中文字体 + plt.rcParams['font.sans-serif'] = ['SimHei'] # 用来正常显示中文标签 + plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号 + + G = memory_graph.G + + # 创建一个新图用于可视化 + H = G.copy() + + # 过滤掉内容数量小于2的节点 + nodes_to_remove = [] + for node in H.nodes(): + memory_items = H.nodes[node].get('memory_items', []) + memory_count = len(memory_items) if isinstance(memory_items, list) else (1 if memory_items else 0) + if memory_count < 2: + nodes_to_remove.append(node) + + H.remove_nodes_from(nodes_to_remove) + + # 如果没有符合条件的节点,直接返回 + if len(H.nodes()) == 0: + print("没有找到内容数量大于等于2的节点") + return + + # 计算节点大小和颜色 + node_colors = [] + node_sizes = [] + nodes = list(H.nodes()) + + # 获取最大记忆数用于归一化节点大小 + max_memories = 1 + for node in nodes: + memory_items = H.nodes[node].get('memory_items', []) + memory_count = len(memory_items) if isinstance(memory_items, list) else (1 if memory_items else 0) + max_memories = max(max_memories, memory_count) + + # 计算每个节点的大小和颜色 + for node in nodes: + # 计算节点大小(基于记忆数量) + memory_items = H.nodes[node].get('memory_items', []) + memory_count = len(memory_items) if isinstance(memory_items, list) else (1 if memory_items else 0) + # 使用指数函数使变化更明显 + ratio = memory_count / max_memories + size = 400 + 2000 * (ratio ** 2) # 增大节点大小 + node_sizes.append(size) + + # 计算节点颜色(基于连接数) + degree = H.degree(node) + if degree >= 30: + node_colors.append((1.0, 0, 0)) # 亮红色 (#FF0000) + else: + # 将1-10映射到0-1的范围 + color_ratio = (degree - 1) / 29.0 if degree > 1 else 0 + # 使用蓝到红的渐变 + red = min(0.9, color_ratio) + blue = max(0.0, 1.0 - color_ratio) + node_colors.append((red, 0, blue)) + + # 绘制图形 + plt.figure(figsize=(16, 12)) # 减小图形尺寸 + pos = nx.spring_layout(H, + k=1, # 调整节点间斥力 + iterations=100, # 增加迭代次数 + scale=1.5, # 减小布局尺寸 + weight='strength') # 使用边的strength属性作为权重 + + nx.draw(H, pos, + with_labels=True, + node_color=node_colors, + node_size=node_sizes, + font_size=12, # 保持增大的字体大小 + font_family='SimHei', + font_weight='bold', + edge_color='gray', + width=1.5) # 统一的边宽度 + + title = '记忆图谱可视化(仅显示内容≥2的节点)\n节点大小表示记忆数量\n节点颜色:蓝(弱连接)到红(强连接)渐变,边的透明度表示连接强度\n连接强度越大的节点距离越近' + plt.title(title, fontsize=16, fontfamily='SimHei') + plt.show() + +async def main(): + # 初始化数据库 + logger.info("正在初始化数据库连接...") + db = Database.get_instance() + start_time = time.time() + + test_pare = {'do_build_memory':True,'do_forget_topic':False,'do_visualize_graph':True,'do_query':False,'do_merge_memory':False} + + # 创建记忆图 + memory_graph = Memory_graph() + + # 创建海马体 + hippocampus = Hippocampus(memory_graph) + + # 从数据库同步数据 + hippocampus.memory_cortex.sync_memory_from_db() + + end_time = time.time() + logger.info(f"\033[32m[加载海马体耗时: {end_time - start_time:.2f} 秒]\033[0m") + + # 构建记忆 + if test_pare['do_build_memory']: + logger.info("开始构建记忆...") + chat_size = 20 + await hippocampus.operation_build_memory(chat_size=chat_size) + + end_time = time.time() + logger.info(f"\033[32m[构建记忆耗时: {end_time - start_time:.2f} 秒,chat_size={chat_size},chat_count = 16]\033[0m") + + if test_pare['do_forget_topic']: + logger.info("开始遗忘记忆...") + await hippocampus.operation_forget_topic(percentage=0.01) + + end_time = time.time() + logger.info(f"\033[32m[遗忘记忆耗时: {end_time - start_time:.2f} 秒]\033[0m") + + if test_pare['do_merge_memory']: + logger.info("开始合并记忆...") + await hippocampus.operation_merge_memory(percentage=0.1) + + end_time = time.time() + logger.info(f"\033[32m[合并记忆耗时: {end_time - start_time:.2f} 秒]\033[0m") + + if test_pare['do_visualize_graph']: + # 展示优化后的图形 + logger.info("生成记忆图谱可视化...") + print("\n生成优化后的记忆图谱:") + visualize_graph_lite(memory_graph) + + if test_pare['do_query']: + # 交互式查询 + while True: + query = input("\n请输入新的查询概念(输入'退出'以结束):") + if query.lower() == '退出': + break + + items_list = memory_graph.get_related_item(query) + if items_list: + first_layer, second_layer = items_list + if first_layer: + print("\n直接相关的记忆:") + for item in first_layer: + print(f"- {item}") + if second_layer: + print("\n间接相关的记忆:") + for item in second_layer: + print(f"- {item}") + else: + print("未找到相关记忆。") + + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) + + diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index fe8e1a100..ac567600a 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -44,8 +44,8 @@ class LLM_request: self.db.db.llm_usage.create_index([("model_name", 1)]) self.db.db.llm_usage.create_index([("user_id", 1)]) self.db.db.llm_usage.create_index([("request_type", 1)]) - except Exception as e: - logger.error(f"创建数据库索引失败") + except Exception: + logger.error("创建数据库索引失败") def _record_usage(self, prompt_tokens: int, completion_tokens: int, total_tokens: int, user_id: str = "system", request_type: str = "chat", @@ -80,7 +80,7 @@ class LLM_request: f"总计: {total_tokens}" ) except Exception: - logger.error(f"记录token使用情况失败") + logger.error("记录token使用情况失败") def _calculate_cost(self, prompt_tokens: int, completion_tokens: int) -> float: """计算API调用成本 @@ -194,7 +194,7 @@ class LLM_request: if hasattr(global_config, 'llm_normal') and global_config.llm_normal.get( 'name') == old_model_name: global_config.llm_normal['name'] = self.model_name - logger.warning(f"已将全局配置中的 llm_normal 模型降级") + logger.warning("已将全局配置中的 llm_normal 模型降级") # 更新payload中的模型名 if payload and 'model' in payload: @@ -227,7 +227,7 @@ class LLM_request: delta_content = "" accumulated_content += delta_content except Exception: - logger.exception(f"解析流式输出错") + logger.exception("解析流式输出错") content = accumulated_content reasoning_content = "" think_match = re.search(r'(.*?)', content, re.DOTALL) @@ -355,7 +355,7 @@ class LLM_request: """构建请求头""" if no_key: return { - "Authorization": f"Bearer **********", + "Authorization": "Bearer **********", "Content-Type": "application/json" } else: diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index fc07a152d..e280c6bce 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -68,7 +68,7 @@ class ScheduleGenerator: 1. 早上的学习和工作安排 2. 下午的活动和任务 3. 晚上的计划和休息时间 - 请按照时间顺序列出具体时间点和对应的活动,用一个时间点而不是时间段来表示时间,用JSON格式返回日程表,仅返回内容,不要返回注释,时间采用24小时制,格式为{"时间": "活动","时间": "活动",...}。""" + 请按照时间顺序列出具体时间点和对应的活动,用一个时间点而不是时间段来表示时间,用JSON格式返回日程表,仅返回内容,不要返回注释,不要添加任何markdown或代码块样式,时间采用24小时制,格式为{"时间": "活动","时间": "活动",...}。""" try: schedule_text, _ = await self.llm_scheduler.generate_response(prompt) @@ -91,7 +91,7 @@ class ScheduleGenerator: try: schedule_dict = json.loads(schedule_text) return schedule_dict - except json.JSONDecodeError as e: + except json.JSONDecodeError: logger.exception("解析日程失败: {}".format(schedule_text)) return False diff --git a/src/plugins/utils/statistic.py b/src/plugins/utils/statistic.py index a071355a3..2974389e6 100644 --- a/src/plugins/utils/statistic.py +++ b/src/plugins/utils/statistic.py @@ -155,7 +155,7 @@ class LLMStatistics: all_stats = self._collect_all_statistics() self._save_statistics(all_stats) except Exception: - logger.exception(f"统计数据处理失败") + logger.exception("统计数据处理失败") # 等待1分钟 for _ in range(60): diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index ff39c9a69..1b33b42cf 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -15,6 +15,7 @@ version = "0.0.5" [bot] qq = 123 nickname = "麦麦" +alias_names = ["小麦", "阿麦"] [personality] prompt_personality = [ From c32c4fb1e2f62f7e73eae4841427a92a6448c02e Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 05:06:58 +0800 Subject: [PATCH 049/162] =?UTF-8?q?refactor:=20=E4=BF=AE=E6=94=B9=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E4=BB=B6=E7=9A=84=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/config.py | 2 +- template/bot_config_template.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 547c44a82..7aed9eee8 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -280,7 +280,7 @@ class BotConfig: ) config.down_frequency_rate = msg_config.get("down_frequency_rate", config.down_frequency_rate) - if config.INNER_VERSION in SpecifierSet(">=0.0.5"): + if config.INNER_VERSION in SpecifierSet(">=0.0.6"): config.ban_msgs_regex = msg_config.get("ban_msgs_regex", config.ban_msgs_regex) def memory(parent: dict): diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 1b33b42cf..126fc501d 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "0.0.5" +version = "0.0.6" #如果你想要修改配置文件,请在修改后将version的值进行变更 #如果新增项目,请在BotConfig类下新增相应的变量 From 67f6d7cd4211df7180118696d3cb4934638dce12 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 06:54:04 +0800 Subject: [PATCH 050/162] =?UTF-8?q?fix:=20=E4=BF=9D=E8=AF=81=E8=83=BD?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=9A=84=E5=B0=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 4 +- src/plugins/chat/emoji_manager.py | 2 +- src/plugins/chat/willing_manager.py | 104 +++++++++++----------------- 3 files changed, 45 insertions(+), 65 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index a695cea77..6f18e4da0 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -164,7 +164,6 @@ class ChatBot: response,raw_content = await self.gpt.generate_response(message) if response: - container = message_manager.get_container(chat.stream_id) container = message_manager.get_container(chat.stream_id) thinking_message = None # 找到message,删除 @@ -182,12 +181,12 @@ class ChatBot: # 记录开始思考的时间,避免从思考到回复的时间太久 thinking_start_time = thinking_message.thinking_start_time message_set = MessageSet(chat, think_id) - message_set = MessageSet(chat, think_id) #计算打字时间,1是为了模拟打字,2是避免多条回复乱序 accu_typing_time = 0 mark_head = False for msg in response: + print("test") # print(f"\033[1;32m[回复内容]\033[0m {msg}") # 通过时间改变时间戳 typing_time = calculate_typing_time(msg) @@ -239,6 +238,7 @@ class ChatBot: is_emoji=True ) message_manager.add_message(bot_message) + emotion = await self.gpt._get_emotion_tags(raw_content) logger.debug(f"为 '{response}' 获取到的情感标签为:{emotion}") valuedict = { diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index a26f4dc4b..0056a724a 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -259,7 +259,7 @@ class EmojiManager: upsert=True ) # 保存描述到image_descriptions集合 - await image_manager._save_description_to_db(image_hash, description, 'emoji') + image_manager._save_description_to_db(image_hash, description, 'emoji') logger.success(f"同步已存在的表情包到images集合: {filename}") continue diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 96cf74095..26353e74c 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -16,9 +16,7 @@ class WillingManager: self.chat_reply_willing: Dict[str, float] = {} # 存储每个聊天流的回复意愿 self._decay_task = None self._started = False - self.min_reply_willing = 0.01 - self.attenuation_coefficient = 0.75 - + async def _decay_reply_willing(self): """定期衰减回复意愿""" while True: @@ -35,9 +33,12 @@ class WillingManager: return self.chat_reply_willing.get(stream.stream_id, 0) return 0 - def set_willing(self, chat_id: int, willing: float): - """设置指定群组的回复意愿""" - self.group_reply_willing[chat_id] = willing + def set_willing(self, chat_id: str, willing: float): + """设置指定聊天流的回复意愿""" + self.chat_reply_willing[chat_id] = willing + def set_willing(self, chat_id: str, willing: float): + """设置指定聊天流的回复意愿""" + self.chat_reply_willing[chat_id] = willing async def change_reply_willing_received(self, chat_stream:ChatStream, @@ -50,67 +51,47 @@ class WillingManager: # 获取或创建聊天流 stream = chat_stream chat_id = stream.stream_id - group_id = stream.group_info.group_id - - # 若非目标回复群组,则直接return - if group_id not in config.talk_allowed_groups: - reply_probability = 0 - return reply_probability - current_willing = self.chat_reply_willing.get(chat_id, 0) - logger.debug(f"[{chat_id}]的初始回复意愿: {current_willing}") - - - # 根据消息类型(被cue/表情包)调控 - if is_mentioned_bot: - current_willing = min( - 3.0, - current_willing + 0.9 - ) - logger.debug(f"被提及, 当前意愿: {current_willing}") - + # print(f"初始意愿: {current_willing}") + if is_mentioned_bot and current_willing < 1.0: + current_willing += 0.9 + print(f"被提及, 当前意愿: {current_willing}") + elif is_mentioned_bot: + current_willing += 0.05 + print(f"被重复提及, 当前意愿: {current_willing}") + if is_emoji: current_willing *= 0.1 - logger.debug(f"表情包, 当前意愿: {current_willing}") - - # 兴趣放大系数,若兴趣 > 0.4则增加回复概率 - interested_rate_amplifier = global_config.response_interested_rate_amplifier - logger.debug(f"放大系数_interested_rate: {interested_rate_amplifier}") - interested_rate *= interested_rate_amplifier - - current_willing += max( - 0.0, - interested_rate - 0.4 - ) - - # 回复意愿系数调控,独立乘区 - willing_amplifier = max( - global_config.response_willing_amplifier, - self.min_reply_willing - ) - current_willing *= willing_amplifier - logger.debug(f"放大系数_willing: {global_config.response_willing_amplifier}, 当前意愿: {current_willing}") - - # 回复概率迭代,保底0.01回复概率 - reply_probability = max( - (current_willing - 0.45) * 2, - self.min_reply_willing - ) - - # 降低目标低频群组回复概率 - down_frequency_rate = max( - 1.0, - global_config.down_frequency_rate - ) - if group_id in config.talk_frequency_down_groups: - reply_probability = reply_probability / down_frequency_rate + print(f"表情包, 当前意愿: {current_willing}") + + print(f"放大系数_interested_rate: {global_config.response_interested_rate_amplifier}") + interested_rate *= global_config.response_interested_rate_amplifier #放大回复兴趣度 + if interested_rate > 0.4: + # print(f"兴趣度: {interested_rate}, 当前意愿: {current_willing}") + current_willing += interested_rate-0.4 + + current_willing *= global_config.response_willing_amplifier #放大回复意愿 + # print(f"放大系数_willing: {global_config.response_willing_amplifier}, 当前意愿: {current_willing}") + + reply_probability = max((current_willing - 0.45) * 2, 0) + + # 检查群组权限(如果是群聊) + if chat_stream.group_info: + if chat_stream.group_info.group_id not in config.talk_allowed_groups: + current_willing = 0 + reply_probability = 0 + + if chat_stream.group_info.group_id in config.talk_frequency_down_groups: + reply_probability = reply_probability / global_config.down_frequency_rate reply_probability = min(reply_probability, 1) - - self.group_reply_willing[group_id] = min(current_willing, 3.0) - logger.debug(f"当前群组{group_id}回复概率:{reply_probability}") + if reply_probability < 0: + reply_probability = 0 + + self.chat_reply_willing[chat_id] = min(current_willing, 3.0) + self.chat_reply_willing[chat_id] = min(current_willing, 3.0) return reply_probability def change_reply_willing_sent(self, chat_stream:ChatStream): @@ -135,6 +116,5 @@ class WillingManager: self._decay_task = asyncio.create_task(self._decay_reply_willing()) self._started = True - # 创建全局实例 -willing_manager = WillingManager() +willing_manager = WillingManager() \ No newline at end of file From 52c93ba080da5e0b2990c4540d7f4d724687d4a3 Mon Sep 17 00:00:00 2001 From: Naptie Date: Tue, 11 Mar 2025 10:16:04 +0800 Subject: [PATCH 051/162] refactor: use Base64 for emoji CQ codes --- src/plugins/chat/cq_code.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index b13e33e48..5ebe4ab3d 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -1,6 +1,5 @@ import base64 import html -import os import time from dataclasses import dataclass from typing import Dict, Optional @@ -17,7 +16,7 @@ from urllib3.util import create_urllib3_context from ..models.utils_model import LLM_request from .config import global_config from .mapper import emojimapper -from .utils_image import storage_emoji, storage_image +from .utils_image import image_path_to_base64, storage_emoji, storage_image from .utils_user import get_user_nickname driver = get_driver() @@ -328,15 +327,10 @@ class CQCode: Returns: 表情包CQ码字符串 """ - # 确保使用绝对路径 - abs_path = os.path.abspath(file_path) - # 转义特殊字符 - escaped_path = abs_path.replace('&', '&') \ - .replace('[', '[') \ - .replace(']', ']') \ - .replace(',', ',') + base64_content = image_path_to_base64(file_path) + # 生成CQ码,设置sub_type=1表示这是表情包 - return f"[CQ:image,file=file:///{escaped_path},sub_type=1]" + return f"[CQ:image,file=base64://{base64_content},sub_type=1]" class CQCode_tool: From 73a3e41e47432aab499cbe97ad9070a07f25dac9 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Tue, 11 Mar 2025 11:11:36 +0800 Subject: [PATCH 052/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E6=9B=B4=E6=96=B0bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/plugins/memory_system/memory.py | 57 ++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f4ebca07d..f919e8902 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ ## 📌 注意事项 -SengokuCola纯编程外行,面向cursor编程,很多代码史一样多多包涵 +SengokuCola~~纯编程外行,面向cursor编程,很多代码写得不好多多包涵~~已得到大脑升级 > [!WARNING] > 本应用生成内容来自人工智能模型,由 AI 生成,请仔细甄别,请勿用于违反法律的用途,AI生成内容不代表本人观点和立场。 diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 0730f9e57..c02b4ba4f 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -438,21 +438,39 @@ class Hippocampus: def sync_memory_from_db(self): """从数据库同步数据到内存中的图结构""" + current_time = datetime.datetime.now().timestamp() + need_update = False + # 清空当前图 self.memory_graph.G.clear() # 从数据库加载所有节点 - nodes = self.memory_graph.db.db.graph_data.nodes.find() + nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) for node in nodes: concept = node['concept'] memory_items = node.get('memory_items', []) - # 确保memory_items是列表 if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] - # 获取时间信息 - created_time = node.get('created_time', datetime.datetime.now().timestamp()) - last_modified = node.get('last_modified', datetime.datetime.now().timestamp()) + # 检查时间字段是否存在 + if 'created_time' not in node or 'last_modified' not in node: + need_update = True + # 更新数据库中的节点 + update_data = {} + if 'created_time' not in node: + update_data['created_time'] = current_time + if 'last_modified' not in node: + update_data['last_modified'] = current_time + + self.memory_graph.db.db.graph_data.nodes.update_one( + {'concept': concept}, + {'$set': update_data} + ) + logger.info(f"为节点 {concept} 添加缺失的时间字段") + + # 获取时间信息(如果不存在则使用当前时间) + created_time = node.get('created_time', current_time) + last_modified = node.get('last_modified', current_time) # 添加节点到图中 self.memory_graph.G.add_node(concept, @@ -461,15 +479,31 @@ class Hippocampus: last_modified=last_modified) # 从数据库加载所有边 - edges = self.memory_graph.db.db.graph_data.edges.find() + edges = list(self.memory_graph.db.db.graph_data.edges.find()) for edge in edges: source = edge['source'] target = edge['target'] - strength = edge.get('strength', 1) # 获取 strength,默认为 1 + strength = edge.get('strength', 1) - # 获取时间信息 - created_time = edge.get('created_time', datetime.datetime.now().timestamp()) - last_modified = edge.get('last_modified', datetime.datetime.now().timestamp()) + # 检查时间字段是否存在 + if 'created_time' not in edge or 'last_modified' not in edge: + need_update = True + # 更新数据库中的边 + update_data = {} + if 'created_time' not in edge: + update_data['created_time'] = current_time + if 'last_modified' not in edge: + update_data['last_modified'] = current_time + + self.memory_graph.db.db.graph_data.edges.update_one( + {'source': source, 'target': target}, + {'$set': update_data} + ) + logger.info(f"为边 {source} - {target} 添加缺失的时间字段") + + # 获取时间信息(如果不存在则使用当前时间) + created_time = edge.get('created_time', current_time) + last_modified = edge.get('last_modified', current_time) # 只有当源节点和目标节点都存在时才添加边 if source in self.memory_graph.G and target in self.memory_graph.G: @@ -477,6 +511,9 @@ class Hippocampus: strength=strength, created_time=created_time, last_modified=last_modified) + + if need_update: + logger.success("已为缺失的时间字段进行补充") async def operation_forget_topic(self, percentage=0.1): """随机选择图中一定比例的节点和边进行检查,根据时间条件决定是否遗忘""" From 5e069f74e2172d814716e7d121f3d0af475121d0 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Tue, 11 Mar 2025 12:06:01 +0800 Subject: [PATCH 053/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=97=B6=E6=97=A0=E6=97=B6=E9=97=B4=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/memory_system/memory.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index c02b4ba4f..e7198b10e 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -318,6 +318,8 @@ class Hippocampus: compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate) logger.info(f"压缩后记忆数量: {len(compressed_memory)},似曾相识的话题: {len(similar_topics_dict)}") + current_time = datetime.datetime.now().timestamp() + for topic, memory in compressed_memory: logger.info(f"添加节点: {topic}") self.memory_graph.add_dot(topic, memory) @@ -330,7 +332,10 @@ class Hippocampus: if topic != similar_topic: strength = int(similarity * 10) logger.info(f"连接相似节点: {topic} 和 {similar_topic} (强度: {strength})") - self.memory_graph.G.add_edge(topic, similar_topic, strength=strength) + self.memory_graph.G.add_edge(topic, similar_topic, + strength=strength, + created_time=current_time, + last_modified=current_time) # 连接同批次的相关话题 for i in range(len(all_topics)): From b6867b904d358bd6e1f285556ac950f4a576df76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Tue, 11 Mar 2025 17:07:37 +0900 Subject: [PATCH 054/162] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E4=BD=BF?= =?UTF-8?q?=E7=94=A8os.getenv=E8=8E=B7=E5=8F=96=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E4=BF=A1=E6=81=AF=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E4=BB=8Econfig=E5=AF=B9=E8=B1=A1=E8=8E=B7=E5=8F=96=E4=B8=8D?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E7=9A=84=E5=80=BC=E6=97=B6=E5=87=BA=E7=8E=B0?= =?UTF-8?q?KeyError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/__init__.py | 15 ++++++++------- src/plugins/memory_system/memory.py | 15 ++++++++------- src/plugins/memory_system/memory_test1.py | 15 ++++++++------- src/plugins/schedule/schedule_generator.py | 17 +++++++++-------- 4 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index 2e01262b1..11c059c1c 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -1,5 +1,6 @@ import asyncio import time +import os from loguru import logger from nonebot import get_driver, on_message, require @@ -31,13 +32,13 @@ driver = get_driver() config = driver.config Database.initialize( - uri=config.MONGODB_URI, - host=config.MONGODB_HOST, - port=int(config.MONGODB_PORT), - db_name=config.DATABASE_NAME, - username=config.MONGODB_USERNAME, - password=config.MONGODB_PASSWORD, - auth_source=config.MONGODB_AUTH_SOURCE + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), ) logger.success("初始化数据库成功") diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 13a9f8c38..2884b6dae 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -3,6 +3,7 @@ import datetime import math import random import time +import os import jieba import networkx as nx @@ -887,13 +888,13 @@ config = driver.config start_time = time.time() Database.initialize( - uri=config.MONGODB_URI, - host=config.MONGODB_HOST, - port=config.MONGODB_PORT, - db_name=config.DATABASE_NAME, - username=config.MONGODB_USERNAME, - password=config.MONGODB_PASSWORD, - auth_source=config.MONGODB_AUTH_SOURCE + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), ) # 创建记忆图 memory_graph = Memory_graph() diff --git a/src/plugins/memory_system/memory_test1.py b/src/plugins/memory_system/memory_test1.py index bbd734ec2..72accc2b3 100644 --- a/src/plugins/memory_system/memory_test1.py +++ b/src/plugins/memory_system/memory_test1.py @@ -69,13 +69,14 @@ class Database: def __init__(self): if not Database.db: Database.initialize( - host=os.getenv("MONGODB_HOST"), - port=int(os.getenv("MONGODB_PORT")), - db_name=os.getenv("DATABASE_NAME"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE") - ) + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), + ) @classmethod def initialize(cls, host, port, db_name, username=None, password=None, auth_source="admin"): diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index 2fe259c47..12c6ce3b5 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -1,3 +1,4 @@ +import os import datetime import json from typing import Dict, Union @@ -14,13 +15,13 @@ driver = get_driver() config = driver.config Database.initialize( - uri=config.MONGODB_URI, - host=config.MONGODB_HOST, - port=int(config.MONGODB_PORT), - db_name=config.DATABASE_NAME, - username=config.MONGODB_USERNAME, - password=config.MONGODB_PASSWORD, - auth_source=config.MONGODB_AUTH_SOURCE + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), ) class ScheduleGenerator: @@ -176,6 +177,6 @@ class ScheduleGenerator: # print(scheduler.tomorrow_schedule) # if __name__ == "__main__": -# main() +# main() bot_schedule = ScheduleGenerator() From 1c9b0ccbe33078d97c3620f39ed44a1d435e781c Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 16:16:00 +0800 Subject: [PATCH 055/162] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=83=A8?= =?UTF-8?q?=E5=88=86cq=E7=A0=81=E8=A7=A3=E6=9E=90=E9=94=99=E8=AF=AF?= =?UTF-8?q?=EF=BC=8Cmerge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/cq_code.py | 50 ++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index d0f50c5ae..ea271ac7d 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -18,7 +18,8 @@ from ..models.utils_model import LLM_request from .config import global_config from .mapper import emojimapper from .message_base import Seg -from .utils_user import get_user_nickname +from .utils_user import get_user_nickname,get_groupname +from .message_base import GroupInfo, UserInfo driver = get_driver() config = driver.config @@ -57,11 +58,8 @@ class CQCode: type: str params: Dict[str, str] - group_id: int - user_id: int - user_nickname: str - group_name: str = "" - user_nickname: str = "" + group_info: Optional[GroupInfo] = None + user_info: Optional[UserInfo] = None translated_segments: Optional[Union[Seg, List[Seg]]] = None reply_message: Dict = None # 存储回复消息 image_base64: Optional[str] = None @@ -215,13 +213,23 @@ class CQCode: else: if raw_message: from .message_cq import MessageRecvCQ + user_info=UserInfo( + platform='qq', + user_id=msg.get("user_id", 0), + user_nickname=nickname, + ) + group_info=GroupInfo( + platform='qq', + group_id=msg.get("group_id", 0), + group_name=get_groupname(msg.get("group_id", 0)) + ) message_obj = MessageRecvCQ( - user_id=msg.get("user_id", 0), message_id=msg.get("message_id", 0), + user_info=user_info, raw_message=raw_message, plain_text=raw_message, - group_id=msg.get("group_id", 0), + group_info=group_info, ) content_seg = Seg( type="seglist", data=message_obj.message_segment ) @@ -231,12 +239,22 @@ class CQCode: if raw_message: from .message_cq import MessageRecvCQ - message_obj = MessageRecvCQ( + user_info=UserInfo( + platform='qq', user_id=msg.get("user_id", 0), + user_nickname=nickname, + ) + group_info=GroupInfo( + platform='qq', + group_id=msg.get("group_id", 0), + group_name=get_groupname(msg.get("group_id", 0)) + ) + message_obj = MessageRecvCQ( message_id=msg.get("message_id", 0), + user_info=user_info, raw_message=raw_message, plain_text=raw_message, - group_id=msg.get("group_id", 0), + group_info=group_info, ) content_seg = Seg( type="seglist", data=message_obj.message_segment @@ -262,11 +280,12 @@ class CQCode: return None if self.reply_message.sender.user_id: + message_obj = MessageRecvCQ( - user_id=self.reply_message.sender.user_id, + user_info=UserInfo(user_id=self.reply_message.sender.user_id,user_nickname=self.reply_message.sender.get("nickname",None)), message_id=self.reply_message.message_id, raw_message=str(self.reply_message.message), - group_id=self.group_id, + group_info=GroupInfo(group_id=self.reply_message.group_id), ) segments = [] @@ -300,7 +319,6 @@ class CQCode: .replace("&", "&") ) - class CQCode_tool: @staticmethod def cq_from_dict_to_class(cq_code: Dict,msg ,reply: Optional[Dict] = None) -> CQCode: @@ -327,10 +345,8 @@ class CQCode_tool: instance = CQCode( type=cq_type, params=params, - group_id=msg.message_info.group_info.group_id, - user_id=msg.message_info.user_info.user_id, - user_nickname=msg.message_info.user_info.user_nickname, - group_name=msg.message_info.group_info.group_name, + group_info=msg.message_info.group_info, + user_info=msg.message_info.user_info, reply_message=reply ) From 5760412382bf072a6863393c0b5d5aab7360cf51 Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 16:32:35 +0800 Subject: [PATCH 056/162] =?UTF-8?q?fix:=20=E5=B0=8F=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/utils_image.py | 2 +- src/plugins/chat/willing_manager.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index ac3ff5ac4..24687a8f5 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -345,7 +345,7 @@ class ImageManager: logger.error(f"保存图片文件失败: {str(e)}") # 保存描述到数据库 - await self._save_description_to_db(image_hash, description, 'image') + self._save_description_to_db(image_hash, description, 'image') return f"[图片:{description}]" except Exception as e: diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 26353e74c..39083f0b8 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -78,11 +78,7 @@ class WillingManager: reply_probability = max((current_willing - 0.45) * 2, 0) # 检查群组权限(如果是群聊) - if chat_stream.group_info: - if chat_stream.group_info.group_id not in config.talk_allowed_groups: - current_willing = 0 - reply_probability = 0 - + if chat_stream.group_info: if chat_stream.group_info.group_id in config.talk_frequency_down_groups: reply_probability = reply_probability / global_config.down_frequency_rate @@ -90,7 +86,6 @@ class WillingManager: if reply_probability < 0: reply_probability = 0 - self.chat_reply_willing[chat_id] = min(current_willing, 3.0) self.chat_reply_willing[chat_id] = min(current_willing, 3.0) return reply_probability From e48b32abe033dee55e27de870d8e64b717916998 Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Tue, 11 Mar 2025 16:40:53 +0800 Subject: [PATCH 057/162] =?UTF-8?q?=E5=9C=A8=E6=89=8B=E5=8A=A8=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=95=99=E7=A8=8B=E4=B8=AD=E5=A2=9E=E5=8A=A0=E4=BD=BF?= =?UTF-8?q?=E7=94=A8systemctl=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/manual_deploy_linux.md | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/manual_deploy_linux.md b/docs/manual_deploy_linux.md index 4711c81a9..0a68c6da9 100644 --- a/docs/manual_deploy_linux.md +++ b/docs/manual_deploy_linux.md @@ -110,6 +110,53 @@ python3 bot.py --- +### 7️⃣ **使用systemctl管理maimbot** + +使用以下命令添加服务文件: + +```bash +sudo nano /etc/systemd/system/maimbot.service +``` + +输入以下内容: + +```ini +[Unit] +Description=MaiMbot 麦麦 +After=network.target mongod.service + +[Service] +Type=simple +WorkingDirectory=/path/to/your/maimbot/ +ExecStart=/path/to/your/venv/python3 bot.py +Restart=always +RestartSec=10s + +[Install] +WantedBy=multi-user.target +``` + +输入以下命令重新加载 systemd: + +```bash +sudo systemctl daemon-reload +``` + +启动并设置开机自启: + +```bash +sudo systemctl start maimbot +sudo systemctl enable maimbot +``` + +输入以下命令查看日志: + +```bash +sudo journalctl -xeu maimbot +``` + +--- + ## **其他组件(可选)** - 直接运行 knowledge.py生成知识库 From 0586700467a5f91106d13bb0ad490b48726cc6d0 Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Tue, 11 Mar 2025 16:49:52 +0800 Subject: [PATCH 058/162] =?UTF-8?q?=E6=8C=89=E7=85=A7Sourcery=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E7=9A=84=E5=BB=BA=E8=AE=AE=E4=BF=AE=E6=94=B9systemctl?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/manual_deploy_linux.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/manual_deploy_linux.md b/docs/manual_deploy_linux.md index 0a68c6da9..1dbd74692 100644 --- a/docs/manual_deploy_linux.md +++ b/docs/manual_deploy_linux.md @@ -120,6 +120,9 @@ sudo nano /etc/systemd/system/maimbot.service 输入以下内容: +``:你的maimbot目录 +``:你的venv环境(就是上文创建环境后,执行的代码`source maimbot/bin/activate`中source后面的路径的绝对路径) + ```ini [Unit] Description=MaiMbot 麦麦 @@ -127,8 +130,8 @@ After=network.target mongod.service [Service] Type=simple -WorkingDirectory=/path/to/your/maimbot/ -ExecStart=/path/to/your/venv/python3 bot.py +WorkingDirectory= +ExecStart=/python3 bot.py Restart=always RestartSec=10s @@ -136,7 +139,7 @@ RestartSec=10s WantedBy=multi-user.target ``` -输入以下命令重新加载 systemd: +输入以下命令重新加载systemd: ```bash sudo systemctl daemon-reload From af962c2e84ed68c6cadb54d5d930a3beb1cd644f Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Tue, 11 Mar 2025 16:50:40 +0800 Subject: [PATCH 059/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E6=83=85?= =?UTF-8?q?=E7=BB=AA=E7=AE=A1=E7=90=86=E5=99=A8=E6=B2=A1=E6=9C=89=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E5=AF=BC=E5=85=A5=E5=AF=BC=E8=87=B4=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=87=BA=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 --- src/plugins/chat/bot.py | 11 +++++-- src/plugins/chat/llm_generator.py | 5 +++ src/plugins/chat/message_sender.py | 9 +++--- src/plugins/chat/utils.py | 48 ++++++++++++++--------------- src/plugins/chat/utils_image.py | 4 +++ src/plugins/memory_system/memory.py | 2 +- 6 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 6f18e4da0..81361d81b 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -163,12 +163,16 @@ class ChatBot: response,raw_content = await self.gpt.generate_response(message) + # print(f"response: {response}") if response: + # print(f"有response: {response}") container = message_manager.get_container(chat.stream_id) thinking_message = None # 找到message,删除 + # print(f"开始找思考消息") for msg in container.messages: if isinstance(msg, MessageThinking) and msg.message_info.message_id == think_id: + # print(f"找到思考消息: {msg}") thinking_message = msg container.messages.remove(msg) break @@ -186,14 +190,14 @@ class ChatBot: mark_head = False for msg in response: - print("test") # print(f"\033[1;32m[回复内容]\033[0m {msg}") # 通过时间改变时间戳 typing_time = calculate_typing_time(msg) + print(f"typing_time: {typing_time}") accu_typing_time += typing_time timepoint = tinking_time_point + accu_typing_time - message_segment = Seg(type='text', data=msg) + print(f"message_segment: {message_segment}") bot_message = MessageSending( message_id=think_id, chat_stream=chat, @@ -203,12 +207,15 @@ class ChatBot: is_head=not mark_head, is_emoji=False ) + print(f"bot_message: {bot_message}") if not mark_head: mark_head = True + print(f"添加消息到message_set: {bot_message}") message_set.add_message(bot_message) # message_set 可以直接加入 message_manager # print(f"\033[1;32m[回复]\033[0m 将回复载入发送容器") + print(f"添加message_set到message_manager") message_manager.add_message(message_set) bot_response_time = tinking_time_point diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index 517e8aa7a..af7334afe 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -63,6 +63,9 @@ class ResponseGenerator: ) raw_content = model_response + # print(f"raw_content: {raw_content}") + # print(f"model_response: {model_response}") + if model_response: logger.info(f'{global_config.BOT_NICKNAME}的回复是:{model_response}') model_response = await self._process_response(model_response) @@ -200,6 +203,8 @@ class ResponseGenerator: return None, [] processed_response = process_llm_response(content) + + # print(f"得到了处理后的llm返回{processed_response}") return processed_response diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index d5f710bbf..9db74633f 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -149,6 +149,7 @@ class MessageManager: """处理聊天流消息""" container = self.get_container(chat_id) if container.has_messages(): + # print(f"处理有message的容器chat_id: {chat_id}") message_earliest = container.get_earliest_message() if isinstance(message_earliest, MessageThinking): @@ -161,15 +162,15 @@ class MessageManager: logger.warning(f"消息思考超时({thinking_time}秒),移除该消息") container.remove_message(message_earliest) else: - print(f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中") + if message_earliest.is_head and message_earliest.update_thinking_time() > 30: await message_sender.send_message(message_earliest.set_reply()) else: await message_sender.send_message(message_earliest) - - # if message_earliest.is_emoji: - # message_earliest.processed_plain_text = "[表情包]" await message_earliest.process() + + print(f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中") + await self.storage.store_message(message_earliest, message_earliest.chat_stream,None) container.remove_message(message_earliest) diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index a889ef177..55fb9eb43 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -15,6 +15,7 @@ from .config import global_config from .message import MessageThinking, MessageRecv,MessageSending,MessageProcessBase,Message from .message_base import MessageBase,BaseMessageInfo,UserInfo,GroupInfo from .chat_stream import ChatStream +from ..moods.moods import MoodManager driver = get_driver() config = driver.config @@ -72,43 +73,42 @@ def calculate_information_content(text): def get_cloest_chat_from_db(db, length: int, timestamp: str): - """从数据库中获取最接近指定时间戳的聊天记录,并记录读取次数 + """从数据库中获取最接近指定时间戳的聊天记录 + Args: + db: 数据库实例 + length: 要获取的消息数量 + timestamp: 时间戳 + Returns: - list: 消息记录字典列表,每个字典包含消息内容和时间信息 + list: 消息记录列表,每个记录包含时间和文本信息 """ chat_records = [] closest_record = db.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) - if closest_record and closest_record.get('memorized', 0) < 4: + if closest_record: closest_time = closest_record['time'] - chat_id = closest_record['chat_id'] # 获取groupid - # 获取该时间戳之后的length条消息,且groupid相同 + chat_id = closest_record['chat_id'] # 获取chat_id + # 获取该时间戳之后的length条消息,保持相同的chat_id chat_records = list(db.db.messages.find( - {"time": {"$gt": closest_time}, "chat_id": chat_id} + { + "time": {"$gt": closest_time}, + "chat_id": chat_id # 添加chat_id过滤 + } ).sort('time', 1).limit(length)) - # 更新每条消息的memorized属性 - for record in records: - current_memorized = record.get('memorized', 0) - if current_memorized > 3: - print("消息已读取3次,跳过") - return '' - - # 更新memorized值 - db.db.messages.update_one( - {"_id": record["_id"]}, - {"$set": {"memorized": current_memorized + 1}} - ) - - # 添加到记录列表中 - chat_records.append({ - 'text': record["detailed_plain_text"], + # 转换记录格式 + formatted_records = [] + for record in chat_records: + formatted_records.append({ 'time': record["time"], - 'group_id': record["group_id"] + 'chat_id': record["chat_id"], + 'detailed_plain_text': record.get("detailed_plain_text", "") # 添加文本内容 }) - return chat_records + return formatted_records + + return [] async def get_recent_group_messages(db, chat_id:str, limit: int = 12) -> list: diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index ac3ff5ac4..208cbf15d 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -315,6 +315,10 @@ class ImageManager: prompt = "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" description, _ = await self._llm.generate_response_for_image(prompt, image_base64) + if description is None: + logger.warning("AI未能生成图片描述") + return "[图片]" + # 根据配置决定是否保存图片 if global_config.EMOJI_SAVE: # 生成文件名和路径 diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 0730f9e57..d122252fb 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -238,7 +238,7 @@ class Hippocampus: time_info += f"是从 {earliest_str} 到 {latest_str} 的对话:\n" for msg in messages: - input_text += f"{msg['text']}\n" + input_text += f"{msg['detailed_plain_text']}\n" logger.debug(input_text) From 20f009d0bb2c9f84d0b575be24ad874607833f90 Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Tue, 11 Mar 2025 16:55:59 +0800 Subject: [PATCH 060/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dsystemctl=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E5=81=9C=E6=AD=A2maimbot=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/manual_deploy_linux.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/manual_deploy_linux.md b/docs/manual_deploy_linux.md index 1dbd74692..b19f3d6a7 100644 --- a/docs/manual_deploy_linux.md +++ b/docs/manual_deploy_linux.md @@ -132,6 +132,7 @@ After=network.target mongod.service Type=simple WorkingDirectory= ExecStart=/python3 bot.py +ExecStop=/bin/kill -2 $MAINPID Restart=always RestartSec=10s From 01a6fa80ae34c1f17e367c349b2e9e357890a208 Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 16:59:19 +0800 Subject: [PATCH 061/162] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E7=A5=9E?= =?UTF-8?q?=E7=A7=98test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 6f18e4da0..1ae715a26 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -186,7 +186,6 @@ class ChatBot: mark_head = False for msg in response: - print("test") # print(f"\033[1;32m[回复内容]\033[0m {msg}") # 通过时间改变时间戳 typing_time = calculate_typing_time(msg) From b6edbea96c87413d678b2333f3b52fcd6fd3706e Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 18:17:29 +0800 Subject: [PATCH 062/162] =?UTF-8?q?fix:=20=E5=9B=BE=E7=89=87=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E8=B7=AF=E5=BE=84=E4=B8=8D=E6=AD=A3=E7=A1=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/utils_image.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index dc14d4bca..25f23359b 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -199,15 +199,6 @@ class ImageManager: logger.error(f"获取base64失败: {str(e)}") return None - async def save_base64_image(self, base64_str: str, description: str = None) -> Optional[str]: - """保存base64图像(带查重) - Args: - base64_str: base64字符串 - description: 图像描述 - Returns: - str: 保存路径,失败返回None - """ - return await self.save_image(base64_str, description=description, is_base64=True) def check_url_exists(self, url: str) -> bool: """检查URL是否已存在 @@ -266,8 +257,8 @@ class ImageManager: if global_config.EMOJI_SAVE: # 生成文件名和路径 timestamp = int(time.time()) - filename = f"emoji_{timestamp}_{image_hash[:8]}.jpg" - file_path = os.path.join(self.IMAGE_DIR, filename) + filename = f"{timestamp}_{image_hash[:8]}.jpg" + file_path = os.path.join(self.IMAGE_DIR, 'emoji',filename) try: # 保存文件 @@ -323,8 +314,8 @@ class ImageManager: if global_config.EMOJI_SAVE: # 生成文件名和路径 timestamp = int(time.time()) - filename = f"image_{timestamp}_{image_hash[:8]}.jpg" - file_path = os.path.join(self.IMAGE_DIR, filename) + filename = f"{timestamp}_{image_hash[:8]}.jpg" + file_path = os.path.join(self.IMAGE_DIR,'image', filename) try: # 保存文件 From dea14c1d8ab525e72427242070d806cafc21b658 Mon Sep 17 00:00:00 2001 From: HYY1116 Date: Tue, 11 Mar 2025 18:21:46 +0800 Subject: [PATCH 063/162] =?UTF-8?q?fix:=20=E6=A8=A1=E5=9E=8B=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E7=9B=AE=E5=89=8D=E5=8F=AA=E5=AF=B9=E7=A1=85=E5=9F=BA?= =?UTF-8?q?=E6=B5=81=E5=8A=A8=E7=9A=84V3=E5=92=8CR1=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/models/utils_model.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index c6ed6b619..e9d11f339 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -184,9 +184,9 @@ class LLM_request: elif response.status in policy["abort_codes"]: logger.error(f"错误码: {response.status} - {error_code_mapping.get(response.status)}") if response.status == 403: - # 尝试降级Pro模型 - if self.model_name.startswith( - "Pro/") and self.base_url == "https://api.siliconflow.cn/v1/": + #只针对硅基流动的V3和R1进行降级处理 + if self.model_name.startswith( + "Pro/deepseek-ai") and self.base_url == "https://api.siliconflow.cn/v1/": old_model_name = self.model_name self.model_name = self.model_name[4:] # 移除"Pro/"前缀 logger.warning(f"检测到403错误,模型从 {old_model_name} 降级为 {self.model_name}") @@ -195,7 +195,12 @@ class LLM_request: if hasattr(global_config, 'llm_normal') and global_config.llm_normal.get( 'name') == old_model_name: global_config.llm_normal['name'] = self.model_name - logger.warning("已将全局配置中的 llm_normal 模型降级") + logger.warning(f"将全局配置中的 llm_normal 模型临时降级至{self.model_name}") + + if hasattr(global_config, 'llm_reasoning') and global_config.llm_reasoning.get( + 'name') == old_model_name: + global_config.llm_reasoning['name'] = self.model_name + logger.warning(f"将全局配置中的 llm_reasoning 模型临时降级至{self.model_name}") # 更新payload中的模型名 if payload and 'model' in payload: From 4cc5c8ef2c263981917c7b891779af7a9ee45077 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 18:37:40 +0800 Subject: [PATCH 064/162] =?UTF-8?q?=E4=BF=AE=E6=AD=A3.env.prod=E5=92=8C.en?= =?UTF-8?q?v.dev=E7=9A=84=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot.py b/bot.py index 471a98eaf..9a5d47291 100644 --- a/bot.py +++ b/bot.py @@ -51,15 +51,15 @@ def init_env(): with open(".env", "w") as f: f.write("ENVIRONMENT=prod") - # 检测.env.prod文件是否存在 - if not os.path.exists(".env.prod"): - logger.error("检测到.env.prod文件不存在") - shutil.copy("template.env", "./.env.prod") + # 检测.env.prod文件是否存在 + if not os.path.exists(".env.prod"): + logger.error("检测到.env.prod文件不存在") + shutil.copy("template.env", "./.env.prod") # 检测.env.dev文件是否存在,不存在的话直接复制生产环境配置 if not os.path.exists(".env.dev"): logger.error("检测到.env.dev文件不存在") - shutil.copy(".env.prod", "./.env.dev") + shutil.copy("template.env", "./.env.dev") # 首先加载基础环境变量.env if os.path.exists(".env"): From bfa9a3c02690f4787b8ab074c859b0844db9de43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AB=E5=88=86=E6=A9=98=E5=AD=90?= Date: Tue, 11 Mar 2025 18:40:03 +0800 Subject: [PATCH 065/162] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E7=BE=A4?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E8=8E=B7=E5=8F=96=E7=9A=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 140 +++++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 68 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 81361d81b..9b39a9be6 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -7,7 +7,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent from ..memory_system.memory import hippocampus from ..moods.moods import MoodManager # 导入情绪管理器 from .config import global_config -from .cq_code import CQCode,cq_code_tool # 导入CQCode模块 +from .cq_code import CQCode, cq_code_tool # 导入CQCode模块 from .emoji_manager import emoji_manager # 导入表情包管理器 from .llm_generator import ResponseGenerator from .message import MessageSending, MessageRecv, MessageThinking, MessageSet @@ -24,6 +24,7 @@ from .utils_image import image_path_to_base64 from .willing_manager import willing_manager # 导入意愿管理器 from .message_base import UserInfo, GroupInfo, Seg + class ChatBot: def __init__(self): self.storage = MessageStorage() @@ -46,8 +47,13 @@ class ChatBot: self.bot = bot # 更新 bot 实例 - # group_info = await bot.get_group_info(group_id=event.group_id) - # sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) + try: + group_info_api = await bot.get_group_info(group_id=event.group_id) + logger.info(f"成功获取群信息: {group_info_api}") + group_name = group_info_api["group_name"] + except Exception as e: + logger.error(f"获取群信息失败: {str(e)}") + group_name = None # 白名单设定由nontbot侧完成 if event.group_id: @@ -55,50 +61,53 @@ class ChatBot: return if event.user_id in global_config.ban_user_id: return - - user_info=UserInfo( + + user_info = UserInfo( user_id=event.user_id, user_nickname=event.sender.nickname, user_cardname=event.sender.card or None, - platform='qq' + platform="qq", ) - group_info=GroupInfo( + group_info = GroupInfo( group_id=event.group_id, - group_name=None, - platform='qq' + group_name=group_name, # 使用获取到的群名称或None + platform="qq", ) - message_cq=MessageRecvCQ( + message_cq = MessageRecvCQ( message_id=event.message_id, user_info=user_info, raw_message=str(event.original_message), group_info=group_info, reply_message=event.reply, - platform='qq' + platform="qq", ) - message_json=message_cq.to_dict() + message_json = message_cq.to_dict() # 进入maimbot - message=MessageRecv(message_json) - - groupinfo=message.message_info.group_info - userinfo=message.message_info.user_info - messageinfo=message.message_info + message = MessageRecv(message_json) + + groupinfo = message.message_info.group_info + userinfo = message.message_info.user_info + messageinfo = message.message_info # 消息过滤,涉及到config有待更新 - - chat = await chat_manager.get_or_create_stream(platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo) + + chat = await chat_manager.get_or_create_stream( + platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo + ) message.update_chat_stream(chat) - await relationship_manager.update_relationship(chat_stream=chat,) - await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value = 0.5) + await relationship_manager.update_relationship( + chat_stream=chat, + ) + await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value=0.5) await message.process() # 过滤词 for word in global_config.ban_words: if word in message.processed_plain_text: - logger.info( - f"[{groupinfo.group_name}]{userinfo.user_nickname}:{message.processed_plain_text}") + logger.info(f"[群{groupinfo.group_id}]{userinfo.user_nickname}:{message.processed_plain_text}") logger.info(f"[过滤词识别]消息中含有{word},filtered") return @@ -106,23 +115,21 @@ class ChatBot: for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.raw_message): logger.info( - f"[{message.group_name}]{message.user_nickname}:{message.raw_message}") + f"[群{message.message_info.group_info.group_id}]{message.user_nickname}:{message.raw_message}" + ) logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return - + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) - - # topic=await topic_identifier.identify_topic_llm(message.processed_plain_text) - topic = '' + topic = "" interested_rate = 0 interested_rate = await hippocampus.memory_activate_value(message.processed_plain_text) / 100 - logger.debug(f"对{message.processed_plain_text}" - f"的激活度:{interested_rate}") + logger.debug(f"对{message.processed_plain_text}的激活度:{interested_rate}") # logger.info(f"\033[1;32m[主题识别]\033[0m 使用{global_config.topic_extract}主题: {topic}") - - await self.storage.store_message(message,chat, topic[0] if topic else None) + + await self.storage.store_message(message, chat, topic[0] if topic else None) is_mentioned = is_mentioned_bot_in_message(message) reply_probability = await willing_manager.change_reply_willing_received( @@ -131,38 +138,33 @@ class ChatBot: is_mentioned_bot=is_mentioned, config=global_config, is_emoji=message.is_emoji, - interested_rate=interested_rate + interested_rate=interested_rate, ) current_willing = willing_manager.get_willing(chat_stream=chat) - + logger.info( - f"[{current_time}][{chat.group_info.group_name}]{chat.user_info.user_nickname}:" + f"[{current_time}][群{chat.group_info.group_id}]{chat.user_info.user_nickname}:" f"{message.processed_plain_text}[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]" ) response = None - + if random() < reply_probability: - bot_user_info=UserInfo( - user_id=global_config.BOT_QQ, - user_nickname=global_config.BOT_NICKNAME, - platform=messageinfo.platform + bot_user_info = UserInfo( + user_id=global_config.BOT_QQ, user_nickname=global_config.BOT_NICKNAME, platform=messageinfo.platform ) tinking_time_point = round(time.time(), 2) - think_id = 'mt' + str(tinking_time_point) + think_id = "mt" + str(tinking_time_point) thinking_message = MessageThinking( - message_id=think_id, - chat_stream=chat, - bot_user_info=bot_user_info, - reply=message + message_id=think_id, chat_stream=chat, bot_user_info=bot_user_info, reply=message ) - + message_manager.add_message(thinking_message) willing_manager.change_reply_willing_sent(chat) - - response,raw_content = await self.gpt.generate_response(message) - + + response, raw_content = await self.gpt.generate_response(message) + # print(f"response: {response}") if response: # print(f"有response: {response}") @@ -185,9 +187,9 @@ class ChatBot: # 记录开始思考的时间,避免从思考到回复的时间太久 thinking_start_time = thinking_message.thinking_start_time message_set = MessageSet(chat, think_id) - #计算打字时间,1是为了模拟打字,2是避免多条回复乱序 + # 计算打字时间,1是为了模拟打字,2是避免多条回复乱序 accu_typing_time = 0 - + mark_head = False for msg in response: # print(f"\033[1;32m[回复内容]\033[0m {msg}") @@ -196,7 +198,7 @@ class ChatBot: print(f"typing_time: {typing_time}") accu_typing_time += typing_time timepoint = tinking_time_point + accu_typing_time - message_segment = Seg(type='text', data=msg) + message_segment = Seg(type="text", data=msg) print(f"message_segment: {message_segment}") bot_message = MessageSending( message_id=think_id, @@ -205,7 +207,7 @@ class ChatBot: message_segment=message_segment, reply=message, is_head=not mark_head, - is_emoji=False + is_emoji=False, ) print(f"bot_message: {bot_message}") if not mark_head: @@ -227,14 +229,14 @@ class ChatBot: if emoji_raw != None: emoji_path, description = emoji_raw - emoji_cq = image_path_to_base64(emoji_path) - + emoji_cq = image_path_to_base64(emoji_path) + if random() < 0.5: bot_response_time = tinking_time_point - 1 else: bot_response_time = bot_response_time + 1 - - message_segment = Seg(type='emoji', data=emoji_cq) + + message_segment = Seg(type="emoji", data=emoji_cq) bot_message = MessageSending( message_id=think_id, chat_stream=chat, @@ -242,25 +244,27 @@ class ChatBot: message_segment=message_segment, reply=message, is_head=False, - is_emoji=True + is_emoji=True, ) message_manager.add_message(bot_message) - + emotion = await self.gpt._get_emotion_tags(raw_content) logger.debug(f"为 '{response}' 获取到的情感标签为:{emotion}") valuedict = { - 'happy': 0.5, - 'angry': -1, - 'sad': -0.5, - 'surprised': 0.2, - 'disgusted': -1.5, - 'fearful': -0.7, - 'neutral': 0.1 + "happy": 0.5, + "angry": -1, + "sad": -0.5, + "surprised": 0.2, + "disgusted": -1.5, + "fearful": -0.7, + "neutral": 0.1, } - await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value=valuedict[emotion[0]]) + await relationship_manager.update_relationship_value( + chat_stream=chat, relationship_value=valuedict[emotion[0]] + ) # 使用情绪管理器更新情绪 self.mood_manager.update_mood_from_emotion(emotion[0], global_config.mood_intensity_factor) - + # willing_manager.change_reply_willing_after_sent( # chat_stream=chat # ) From 60a93766c7b99b71e518a5da649ae8327f386183 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 18:42:35 +0800 Subject: [PATCH 066/162] =?UTF-8?q?=E6=B7=BB=E5=8A=A0logger=E7=9A=84debug?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=BC=80=E5=85=B3,=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E4=B8=BA=E4=B8=8D=E5=BC=80=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/config.py | 9 ++++++++- template/bot_config_template.toml | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 7aed9eee8..596d120f9 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -1,4 +1,5 @@ import os +import sys from dataclasses import dataclass, field from typing import Dict, List, Optional @@ -67,6 +68,7 @@ class BotConfig: enable_advance_output: bool = False # 是否启用高级输出 enable_kuuki_read: bool = True # 是否启用读空气功能 + enable_debug_output: bool = False # 是否启用调试输出 mood_update_interval: float = 1.0 # 情绪更新间隔 单位秒 mood_decay_rate: float = 0.95 # 情绪衰减率 @@ -325,6 +327,7 @@ class BotConfig: others_config = parent["others"] config.enable_advance_output = others_config.get("enable_advance_output", config.enable_advance_output) config.enable_kuuki_read = others_config.get("enable_kuuki_read", config.enable_kuuki_read) + config.enable_debug_output = others_config.get("enable_debug_output", config.enable_debug_output) # 版本表达式:>=1.0.0,<2.0.0 # 允许字段:func: method, support: str, notice: str, necessary: bool @@ -419,4 +422,8 @@ global_config = BotConfig.load_config(config_path=bot_config_path) if not global_config.enable_advance_output: logger.remove() - pass + +# 调试输出功能 +if global_config.enable_debug_output: + logger.remove() + logger.add(sys.stdout, level="DEBUG") diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 126fc501d..bea6ab7b7 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -100,6 +100,7 @@ word_replace_rate=0.006 # 整词替换概率 [others] enable_advance_output = true # 是否启用高级输出 enable_kuuki_read = true # 是否启用读空气功能 +enable_debug_output = false # 是否启用调试输出 [groups] talk_allowed = [ From c24bb70291b276d5e471ec554188708c86e7c14c Mon Sep 17 00:00:00 2001 From: pine Date: Tue, 11 Mar 2025 18:51:28 +0800 Subject: [PATCH 067/162] =?UTF-8?q?fix:=20=E6=B5=81=E5=BC=8F=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E6=A8=A1=E5=BC=8F=E5=A2=9E=E5=8A=A0=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E5=88=A4=E6=96=AD=E4=B8=8Etoken=E7=94=A8=E9=87=8F=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/models/utils_model.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index e9d11f339..461f542d1 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -216,6 +216,7 @@ class LLM_request: # 将流式输出转化为非流式输出 if stream_mode: + flag_delta_content_finished = False accumulated_content = "" async for line_bytes in response.content: line = line_bytes.decode("utf-8").strip() @@ -227,13 +228,25 @@ class LLM_request: break try: chunk = json.loads(data_str) - delta = chunk["choices"][0]["delta"] - delta_content = delta.get("content") - if delta_content is None: - delta_content = "" - accumulated_content += delta_content + if flag_delta_content_finished: + usage = chunk.get("usage", None) # 获取tokn用量 + else: + delta = chunk["choices"][0]["delta"] + delta_content = delta.get("content") + if delta_content is None: + delta_content = "" + accumulated_content += delta_content + # 检测流式输出文本是否结束 + finish_reason = chunk["choices"][0]["finish_reason"] + if finish_reason == "stop": + usage = chunk.get("usage", None) + if usage: + break + # 部分平台在文本输出结束前不会返回token用量,此时需要再获取一次chunk + flag_delta_content_finished = True + except Exception: - logger.exception("解析流式输出错") + logger.exception("解析流式输出错误") content = accumulated_content reasoning_content = "" think_match = re.search(r'(.*?)', content, re.DOTALL) @@ -242,7 +255,7 @@ class LLM_request: content = re.sub(r'.*?', '', content, flags=re.DOTALL).strip() # 构造一个伪result以便调用自定义响应处理器或默认处理器 result = { - "choices": [{"message": {"content": content, "reasoning_content": reasoning_content}}]} + "choices": [{"message": {"content": content, "reasoning_content": reasoning_content}}], "usage": usage} return response_handler(result) if response_handler else self._default_response_handler( result, user_id, request_type, endpoint) else: From e1019ade3c16d91774ff37665d3595afee35f12d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AB=E5=88=86=E6=A9=98=E5=AD=90?= Date: Tue, 11 Mar 2025 18:52:32 +0800 Subject: [PATCH 068/162] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E6=8B=BC=E5=86=99=E9=94=99=E8=AF=AF=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BB=A3=E7=A0=81=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 9b39a9be6..a3bab2f53 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -153,8 +153,8 @@ class ChatBot: bot_user_info = UserInfo( user_id=global_config.BOT_QQ, user_nickname=global_config.BOT_NICKNAME, platform=messageinfo.platform ) - tinking_time_point = round(time.time(), 2) - think_id = "mt" + str(tinking_time_point) + thinking_time_point = round(time.time(), 2) + think_id = "mt" + str(thinking_time_point) thinking_message = MessageThinking( message_id=think_id, chat_stream=chat, bot_user_info=bot_user_info, reply=message ) @@ -197,7 +197,7 @@ class ChatBot: typing_time = calculate_typing_time(msg) print(f"typing_time: {typing_time}") accu_typing_time += typing_time - timepoint = tinking_time_point + accu_typing_time + timepoint = thinking_time_point + accu_typing_time message_segment = Seg(type="text", data=msg) print(f"message_segment: {message_segment}") bot_message = MessageSending( @@ -220,7 +220,7 @@ class ChatBot: print(f"添加message_set到message_manager") message_manager.add_message(message_set) - bot_response_time = tinking_time_point + bot_response_time = thinking_time_point if random() < global_config.emoji_chance: emoji_raw = await emoji_manager.get_emoji_for_text(response) @@ -232,7 +232,7 @@ class ChatBot: emoji_cq = image_path_to_base64(emoji_path) if random() < 0.5: - bot_response_time = tinking_time_point - 1 + bot_response_time = thinking_time_point - 1 else: bot_response_time = bot_response_time + 1 From 7d017be9f7a8f9ea3a54bacceb34474818b37ab4 Mon Sep 17 00:00:00 2001 From: HYY1116 Date: Tue, 11 Mar 2025 19:23:48 +0800 Subject: [PATCH 069/162] =?UTF-8?q?fix:=E6=A8=A1=E5=9E=8B=E9=99=8D?= =?UTF-8?q?=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/models/utils_model.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index e9d11f339..6ed0a0b29 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -192,13 +192,11 @@ class LLM_request: logger.warning(f"检测到403错误,模型从 {old_model_name} 降级为 {self.model_name}") # 对全局配置进行更新 - if hasattr(global_config, 'llm_normal') and global_config.llm_normal.get( - 'name') == old_model_name: + if global_config.llm_normal.get('name') == old_model_name: global_config.llm_normal['name'] = self.model_name logger.warning(f"将全局配置中的 llm_normal 模型临时降级至{self.model_name}") - if hasattr(global_config, 'llm_reasoning') and global_config.llm_reasoning.get( - 'name') == old_model_name: + if global_config.llm_reasoning.get('name') == old_model_name: global_config.llm_reasoning['name'] = self.model_name logger.warning(f"将全局配置中的 llm_reasoning 模型临时降级至{self.model_name}") From ef8691cd9e91696780d816cc144a91c1241d18f4 Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 19:30:20 +0800 Subject: [PATCH 070/162] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9message?= =?UTF-8?q?=E7=BB=A7=E6=89=BF=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E6=B6=88=E6=81=AF=E6=97=A0=E6=B3=95=E8=AF=86?= =?UTF-8?q?=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 4 +- src/plugins/chat/cq_code.py | 10 ++-- src/plugins/chat/message.py | 103 +++++++++++++++++------------------- 3 files changed, 58 insertions(+), 59 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 81361d81b..4af3a6b02 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -50,6 +50,7 @@ class ChatBot: # sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) # 白名单设定由nontbot侧完成 + # 消息过滤,涉及到config有待更新 if event.group_id: if event.group_id not in global_config.talk_allowed_groups: return @@ -68,7 +69,6 @@ class ChatBot: group_name=None, platform='qq' ) - message_cq=MessageRecvCQ( message_id=event.message_id, user_info=user_info, @@ -86,7 +86,7 @@ class ChatBot: userinfo=message.message_info.user_info messageinfo=message.message_info - # 消息过滤,涉及到config有待更新 + chat = await chat_manager.get_or_create_stream(platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo) message.update_chat_stream(chat) diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index 185e98edf..0a8a71df3 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -231,7 +231,8 @@ class CQCode: group_info=group_info, ) content_seg = Seg( - type="seglist", data=message_obj.message_segment ) + type="seglist", data=[message_obj.message_segment] + ) else: content_seg = Seg(type="text", data="[空消息]") else: @@ -256,7 +257,7 @@ class CQCode: group_info=group_info, ) content_seg = Seg( - type="seglist", data=message_obj.message_segment + type="seglist", data=[message_obj.message_segment] ) else: content_seg = Seg(type="text", data="[空消息]") @@ -281,11 +282,12 @@ class CQCode: if self.reply_message.sender.user_id: message_obj = MessageRecvCQ( - user_info=UserInfo(user_id=self.reply_message.sender.user_id,user_nickname=self.reply_message.sender.get("nickname",None)), + user_info=UserInfo(user_id=self.reply_message.sender.user_id,user_nickname=self.reply_message.sender.nickname), message_id=self.reply_message.message_id, raw_message=str(self.reply_message.message), group_info=GroupInfo(group_id=self.reply_message.group_id), ) + segments = [] if message_obj.message_info.user_info.user_id == global_config.BOT_QQ: @@ -302,7 +304,7 @@ class CQCode: ) ) - segments.append(Seg(type="seglist", data=message_obj.message_segment)) + segments.append(Seg(type="seglist", data=[message_obj.message_segment])) segments.append(Seg(type="text", data="]")) return segments else: diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index 5eb93d700..d9f54dd47 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -14,9 +14,52 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) #这个类是消息数据类,用于存储和管理消息数据。 #它定义了消息的属性,包括群组ID、用户ID、消息ID、原始消息内容、纯文本内容和时间戳。 #它还定义了两个辅助属性:keywords用于提取消息的关键词,is_plain_text用于判断消息是否为纯文本。 - + @dataclass -class MessageRecv(MessageBase): +class Message(MessageBase): + chat_stream: ChatStream=None + reply: Optional['Message'] = None + detailed_plain_text: str = "" + processed_plain_text: str = "" + + def __init__( + self, + message_id: str, + time: int, + chat_stream: ChatStream, + user_info: UserInfo, + message_segment: Optional[Seg] = None, + reply: Optional['MessageRecv'] = None, + detailed_plain_text: str = "", + processed_plain_text: str = "", + ): + # 构造基础消息信息 + message_info = BaseMessageInfo( + platform=chat_stream.platform, + message_id=message_id, + time=time, + group_info=chat_stream.group_info, + user_info=user_info + ) + + # 调用父类初始化 + super().__init__( + message_info=message_info, + message_segment=message_segment, + raw_message=None + ) + + self.chat_stream = chat_stream + # 文本处理相关属性 + self.processed_plain_text = detailed_plain_text + self.detailed_plain_text = processed_plain_text + + # 回复消息 + self.reply = reply + + +@dataclass +class MessageRecv(Message): """接收消息类,用于处理从MessageCQ序列化的消息""" def __init__(self, message_dict: Dict): @@ -25,20 +68,16 @@ class MessageRecv(MessageBase): Args: message_dict: MessageCQ序列化后的字典 """ - message_info = BaseMessageInfo.from_dict(message_dict.get('message_info', {})) - message_segment = Seg.from_dict(message_dict.get('message_segment', {})) - raw_message = message_dict.get('raw_message') - - super().__init__( - message_info=message_info, - message_segment=message_segment, - raw_message=raw_message - ) + self.message_info = BaseMessageInfo.from_dict(message_dict.get('message_info', {})) + self.message_segment = Seg.from_dict(message_dict.get('message_segment', {})) + self.raw_message = message_dict.get('raw_message') # 处理消息内容 self.processed_plain_text = "" # 初始化为空字符串 self.detailed_plain_text = "" # 初始化为空字符串 self.is_emoji=False + + def update_chat_stream(self,chat_stream:ChatStream): self.chat_stream=chat_stream @@ -110,48 +149,6 @@ class MessageRecv(MessageBase): ) return f"[{time_str}] {name}: {self.processed_plain_text}\n" -@dataclass -class Message(MessageBase): - chat_stream: ChatStream=None - reply: Optional['Message'] = None - detailed_plain_text: str = "" - processed_plain_text: str = "" - - def __init__( - self, - message_id: str, - time: int, - chat_stream: ChatStream, - user_info: UserInfo, - message_segment: Optional[Seg] = None, - reply: Optional['MessageRecv'] = None, - detailed_plain_text: str = "", - processed_plain_text: str = "", - ): - # 构造基础消息信息 - message_info = BaseMessageInfo( - platform=chat_stream.platform, - message_id=message_id, - time=time, - group_info=chat_stream.group_info, - user_info=user_info - ) - - # 调用父类初始化 - super().__init__( - message_info=message_info, - message_segment=message_segment, - raw_message=None - ) - - self.chat_stream = chat_stream - # 文本处理相关属性 - self.processed_plain_text = detailed_plain_text - self.detailed_plain_text = processed_plain_text - - # 回复消息 - self.reply = reply - @dataclass class MessageProcessBase(Message): From aa41f0d1d81d300709f475c8e6b2398a6f67df4e Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 19:47:09 +0800 Subject: [PATCH 071/162] =?UTF-8?q?fix:=20=E6=94=BE=E5=8F=8D=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index d9f54dd47..c777e7273 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -51,8 +51,8 @@ class Message(MessageBase): self.chat_stream = chat_stream # 文本处理相关属性 - self.processed_plain_text = detailed_plain_text - self.detailed_plain_text = processed_plain_text + self.processed_plain_text = processed_plain_text + self.detailed_plain_text = detailed_plain_text # 回复消息 self.reply = reply From 33cd83b895c015c425825e399ed760b08f4360fc Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 20:01:38 +0800 Subject: [PATCH 072/162] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=A7=81=E8=81=8A?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/__init__.py | 10 +- src/plugins/chat/bot.py | 203 +++++++++++++++++------------ src/plugins/chat/message.py | 2 + src/plugins/chat/message_cq.py | 6 +- src/plugins/chat/message_sender.py | 8 +- 5 files changed, 140 insertions(+), 89 deletions(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index ec3d4f01d..4833a0f5b 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -4,7 +4,7 @@ import os from loguru import logger from nonebot import get_driver, on_message, require -from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment +from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment,MessageEvent from nonebot.typing import T_State from ...common.database import Database @@ -50,8 +50,8 @@ emoji_manager.initialize() logger.debug(f"正在唤醒{global_config.BOT_NICKNAME}......") # 创建机器人实例 chat_bot = ChatBot() -# 注册群消息处理器 -group_msg = on_message(priority=5) +# 注册消息处理器 +msg_in = on_message(priority=5) # 创建定时任务 scheduler = require("nonebot_plugin_apscheduler").scheduler @@ -103,8 +103,8 @@ async def _(bot: Bot): asyncio.create_task(chat_manager._auto_save_task()) -@group_msg.handle() -async def _(bot: Bot, event: GroupMessageEvent, state: T_State): +@msg_in.handle() +async def _(bot: Bot, event: MessageEvent, state: T_State): await chat_bot.handle_message(event, bot) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 81361d81b..8359b9712 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -2,12 +2,17 @@ import re import time from random import random from loguru import logger -from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent +from nonebot.adapters.onebot.v11 import ( + Bot, + GroupMessageEvent, + MessageEvent, + PrivateMessageEvent, +) from ..memory_system.memory import hippocampus from ..moods.moods import MoodManager # 导入情绪管理器 from .config import global_config -from .cq_code import CQCode,cq_code_tool # 导入CQCode模块 +from .cq_code import CQCode, cq_code_tool # 导入CQCode模块 from .emoji_manager import emoji_manager # 导入表情包管理器 from .llm_generator import ResponseGenerator from .message import MessageSending, MessageRecv, MessageThinking, MessageSet @@ -24,6 +29,7 @@ from .utils_image import image_path_to_base64 from .willing_manager import willing_manager # 导入意愿管理器 from .message_base import UserInfo, GroupInfo, Seg + class ChatBot: def __init__(self): self.storage = MessageStorage() @@ -41,64 +47,91 @@ class ChatBot: if not self._started: self._started = True - async def handle_message(self, event: GroupMessageEvent, bot: Bot) -> None: - """处理收到的群消息""" + async def handle_message(self, event: MessageEvent, bot: Bot) -> None: + """处理收到的消息""" self.bot = bot # 更新 bot 实例 + if event.user_id in global_config.ban_user_id: + return + + # 处理私聊消息的逻辑 + if isinstance(event, PrivateMessageEvent): + if not 0 in global_config.talk_allowed_groups: + return + else: + user_info = UserInfo( + user_id=event.user_id, + user_nickname=( + await bot.get_stranger_info( + user_id=event.user_id, no_cache=True + ) + )["nickname"], + user_cardname=None, + platform="qq", + ) + logger.debug(user_info) + + # group_info = GroupInfo(group_id=0, group_name="私聊", platform="qq") + group_info = None + + else: + # 白名单设定由nontbot侧完成 + if event.group_id: + if event.group_id not in global_config.talk_allowed_groups: + return + + user_info = UserInfo( + user_id=event.user_id, + user_nickname=event.sender.nickname, + user_cardname=event.sender.card or None, + platform="qq", + ) + + group_info = GroupInfo( + group_id=event.group_id, group_name=None, platform="qq" + ) + # group_info = await bot.get_group_info(group_id=event.group_id) # sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) - # 白名单设定由nontbot侧完成 - if event.group_id: - if event.group_id not in global_config.talk_allowed_groups: - return - if event.user_id in global_config.ban_user_id: - return - - user_info=UserInfo( - user_id=event.user_id, - user_nickname=event.sender.nickname, - user_cardname=event.sender.card or None, - platform='qq' - ) - - group_info=GroupInfo( - group_id=event.group_id, - group_name=None, - platform='qq' - ) - - message_cq=MessageRecvCQ( + message_cq = MessageRecvCQ( message_id=event.message_id, user_info=user_info, raw_message=str(event.original_message), group_info=group_info, reply_message=event.reply, - platform='qq' + platform="qq", ) - message_json=message_cq.to_dict() + message_json = message_cq.to_dict() # 进入maimbot - message=MessageRecv(message_json) - - groupinfo=message.message_info.group_info - userinfo=message.message_info.user_info - messageinfo=message.message_info + message = MessageRecv(message_json) + + groupinfo = message.message_info.group_info + userinfo = message.message_info.user_info + messageinfo = message.message_info # 消息过滤,涉及到config有待更新 - - chat = await chat_manager.get_or_create_stream(platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo) + + chat = await chat_manager.get_or_create_stream( + platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo + ) message.update_chat_stream(chat) - await relationship_manager.update_relationship(chat_stream=chat,) - await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value = 0.5) + await relationship_manager.update_relationship( + chat_stream=chat, + ) + await relationship_manager.update_relationship_value( + chat_stream=chat, relationship_value=0.5 + ) await message.process() # 过滤词 for word in global_config.ban_words: if word in message.processed_plain_text: logger.info( - f"[{groupinfo.group_name}]{userinfo.user_nickname}:{message.processed_plain_text}") + f"[{groupinfo.group_name}]{userinfo.user_nickname}:{message.processed_plain_text}" + ) logger.info(f"[过滤词识别]消息中含有{word},filtered") return @@ -106,23 +139,25 @@ class ChatBot: for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.raw_message): logger.info( - f"[{message.group_name}]{message.user_nickname}:{message.raw_message}") + f"[{message.group_name}]{message.user_nickname}:{message.raw_message}" + ) logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return - - current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) - + current_time = time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time) + ) # topic=await topic_identifier.identify_topic_llm(message.processed_plain_text) - topic = '' + topic = "" interested_rate = 0 - interested_rate = await hippocampus.memory_activate_value(message.processed_plain_text) / 100 - logger.debug(f"对{message.processed_plain_text}" - f"的激活度:{interested_rate}") + interested_rate = ( + await hippocampus.memory_activate_value(message.processed_plain_text) / 100 + ) + logger.debug(f"对{message.processed_plain_text}" f"的激活度:{interested_rate}") # logger.info(f"\033[1;32m[主题识别]\033[0m 使用{global_config.topic_extract}主题: {topic}") - - await self.storage.store_message(message,chat, topic[0] if topic else None) + + await self.storage.store_message(message, chat, topic[0] if topic else None) is_mentioned = is_mentioned_bot_in_message(message) reply_probability = await willing_manager.change_reply_willing_received( @@ -131,38 +166,38 @@ class ChatBot: is_mentioned_bot=is_mentioned, config=global_config, is_emoji=message.is_emoji, - interested_rate=interested_rate + interested_rate=interested_rate, ) current_willing = willing_manager.get_willing(chat_stream=chat) - + logger.info( - f"[{current_time}][{chat.group_info.group_name}]{chat.user_info.user_nickname}:" + f"[{current_time}][{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{chat.user_info.user_nickname}:" f"{message.processed_plain_text}[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]" ) response = None - + if random() < reply_probability: - bot_user_info=UserInfo( + bot_user_info = UserInfo( user_id=global_config.BOT_QQ, user_nickname=global_config.BOT_NICKNAME, - platform=messageinfo.platform + platform=messageinfo.platform, ) tinking_time_point = round(time.time(), 2) - think_id = 'mt' + str(tinking_time_point) + think_id = "mt" + str(tinking_time_point) thinking_message = MessageThinking( message_id=think_id, chat_stream=chat, bot_user_info=bot_user_info, - reply=message + reply=message, ) - + message_manager.add_message(thinking_message) willing_manager.change_reply_willing_sent(chat) - - response,raw_content = await self.gpt.generate_response(message) - + + response, raw_content = await self.gpt.generate_response(message) + # print(f"response: {response}") if response: # print(f"有response: {response}") @@ -171,7 +206,10 @@ class ChatBot: # 找到message,删除 # print(f"开始找思考消息") for msg in container.messages: - if isinstance(msg, MessageThinking) and msg.message_info.message_id == think_id: + if ( + isinstance(msg, MessageThinking) + and msg.message_info.message_id == think_id + ): # print(f"找到思考消息: {msg}") thinking_message = msg container.messages.remove(msg) @@ -185,9 +223,9 @@ class ChatBot: # 记录开始思考的时间,避免从思考到回复的时间太久 thinking_start_time = thinking_message.thinking_start_time message_set = MessageSet(chat, think_id) - #计算打字时间,1是为了模拟打字,2是避免多条回复乱序 + # 计算打字时间,1是为了模拟打字,2是避免多条回复乱序 accu_typing_time = 0 - + mark_head = False for msg in response: # print(f"\033[1;32m[回复内容]\033[0m {msg}") @@ -196,16 +234,17 @@ class ChatBot: print(f"typing_time: {typing_time}") accu_typing_time += typing_time timepoint = tinking_time_point + accu_typing_time - message_segment = Seg(type='text', data=msg) + message_segment = Seg(type="text", data=msg) print(f"message_segment: {message_segment}") bot_message = MessageSending( message_id=think_id, chat_stream=chat, bot_user_info=bot_user_info, + sender_info=userinfo, message_segment=message_segment, reply=message, is_head=not mark_head, - is_emoji=False + is_emoji=False, ) print(f"bot_message: {bot_message}") if not mark_head: @@ -227,14 +266,14 @@ class ChatBot: if emoji_raw != None: emoji_path, description = emoji_raw - emoji_cq = image_path_to_base64(emoji_path) - + emoji_cq = image_path_to_base64(emoji_path) + if random() < 0.5: bot_response_time = tinking_time_point - 1 else: bot_response_time = bot_response_time + 1 - - message_segment = Seg(type='emoji', data=emoji_cq) + + message_segment = Seg(type="emoji", data=emoji_cq) bot_message = MessageSending( message_id=think_id, chat_stream=chat, @@ -242,25 +281,29 @@ class ChatBot: message_segment=message_segment, reply=message, is_head=False, - is_emoji=True + is_emoji=True, ) message_manager.add_message(bot_message) - + emotion = await self.gpt._get_emotion_tags(raw_content) logger.debug(f"为 '{response}' 获取到的情感标签为:{emotion}") valuedict = { - 'happy': 0.5, - 'angry': -1, - 'sad': -0.5, - 'surprised': 0.2, - 'disgusted': -1.5, - 'fearful': -0.7, - 'neutral': 0.1 + "happy": 0.5, + "angry": -1, + "sad": -0.5, + "surprised": 0.2, + "disgusted": -1.5, + "fearful": -0.7, + "neutral": 0.1, } - await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value=valuedict[emotion[0]]) + await relationship_manager.update_relationship_value( + chat_stream=chat, relationship_value=valuedict[emotion[0]] + ) # 使用情绪管理器更新情绪 - self.mood_manager.update_mood_from_emotion(emotion[0], global_config.mood_intensity_factor) - + self.mood_manager.update_mood_from_emotion( + emotion[0], global_config.mood_intensity_factor + ) + # willing_manager.change_reply_willing_after_sent( # chat_stream=chat # ) diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index 5eb93d700..9301a20a4 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -280,6 +280,7 @@ class MessageSending(MessageProcessBase): message_id: str, chat_stream: ChatStream, bot_user_info: UserInfo, + sender_info:UserInfo, # 用来记录发送者信息,用于私聊回复 message_segment: Seg, reply: Optional['MessageRecv'] = None, is_head: bool = False, @@ -295,6 +296,7 @@ class MessageSending(MessageProcessBase): ) # 发送状态特有属性 + self.sender_info=sender_info self.reply_to_message_id = reply.message_info.message_id if reply else None self.is_head = is_head self.is_emoji = is_emoji diff --git a/src/plugins/chat/message_cq.py b/src/plugins/chat/message_cq.py index 6bfa47c3f..dc65b65ea 100644 --- a/src/plugins/chat/message_cq.py +++ b/src/plugins/chat/message_cq.py @@ -61,8 +61,12 @@ class MessageRecvCQ(MessageCQ): ): # 调用父类初始化 super().__init__(message_id, user_info, group_info, platform) + + # 私聊消息不携带group_info + if group_info is None: + pass - if group_info.group_name is None: + elif group_info.group_name is None: group_info.group_name = get_groupname(group_info.group_id) # 解析消息段 diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 9db74633f..f987cf999 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -30,13 +30,14 @@ class Message_Sender: message: MessageSending, ) -> None: """发送消息""" + if isinstance(message, MessageSending): message_json = message.to_dict() message_send=MessageSendCQ( data=message_json ) - - if message_send.message_info.group_info: + # logger.debug(message_send.message_info,message_send.raw_message) + if message_send.message_info.group_info.group_id: try: await self._current_bot.send_group_msg( group_id=message.message_info.group_info.group_id, @@ -49,8 +50,9 @@ class Message_Sender: logger.error(f"[调试] 发送消息{message.processed_plain_text}失败") else: try: + logger.debug(message.message_info.user_info) await self._current_bot.send_private_msg( - user_id=message.message_info.user_info.user_id, + user_id=message.sender_info.user_id, message=message_send.raw_message, auto_escape=False ) From 3bf5cd6131456e630705e4e8e02a25647a48670d Mon Sep 17 00:00:00 2001 From: HYY1116 Date: Tue, 11 Mar 2025 20:11:17 +0800 Subject: [PATCH 073/162] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=87=8D=E8=BD=BD=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6=EF=BC=9B=E6=96=B0=E5=A2=9E=E6=A0=B9=E6=8D=AE=E4=B8=8D?= =?UTF-8?q?=E5=90=8C=E7=8E=AF=E5=A2=83(dev;prod)=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E7=BA=A7=E5=88=AB=E7=9A=84log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 38 +++++++++++++++++---------- src/plugins/config_reload/__init__.py | 10 +++++++ src/plugins/config_reload/api.py | 17 ++++++++++++ src/plugins/config_reload/test.py | 3 +++ 4 files changed, 54 insertions(+), 14 deletions(-) create mode 100644 src/plugins/config_reload/__init__.py create mode 100644 src/plugins/config_reload/api.py create mode 100644 src/plugins/config_reload/test.py diff --git a/bot.py b/bot.py index 9a5d47291..68068a9f9 100644 --- a/bot.py +++ b/bot.py @@ -51,15 +51,15 @@ def init_env(): with open(".env", "w") as f: f.write("ENVIRONMENT=prod") - # 检测.env.prod文件是否存在 - if not os.path.exists(".env.prod"): - logger.error("检测到.env.prod文件不存在") - shutil.copy("template.env", "./.env.prod") + # 检测.env.prod文件是否存在 + if not os.path.exists(".env.prod"): + logger.error("检测到.env.prod文件不存在") + shutil.copy("template.env", "./.env.prod") # 检测.env.dev文件是否存在,不存在的话直接复制生产环境配置 if not os.path.exists(".env.dev"): logger.error("检测到.env.dev文件不存在") - shutil.copy("template.env", "./.env.dev") + shutil.copy(".env.prod", "./.env.dev") # 首先加载基础环境变量.env if os.path.exists(".env"): @@ -99,15 +99,25 @@ def load_env(): def load_logger(): logger.remove() # 移除默认配置 - logger.add( - sys.stderr, - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <7} | {name:.<8}:{function:.<8}:{line: >4} - {message}", - colorize=True, - level=os.getenv("LOG_LEVEL", "INFO"), # 根据环境设置日志级别,默认为INFO - filter=lambda record: "nonebot" not in record["name"] - ) + if os.getenv("ENVIRONMENT") == "dev": + logger.add( + sys.stderr, + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <7} | {name:.<8}:{function:.<8}:{line: >4} - {message}", + colorize=True, + level=os.getenv("LOG_LEVEL", "DEBUG"), # 根据环境设置日志级别,默认为DEBUG + ) + else: + logger.add( + sys.stderr, + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <7} | {name:.<8}:{function:.<8}:{line: >4} - {message}", + colorize=True, + level=os.getenv("LOG_LEVEL", "INFO"), # 根据环境设置日志级别,默认为INFO + filter=lambda record: "nonebot" not in record["name"] + ) diff --git a/src/plugins/config_reload/__init__.py b/src/plugins/config_reload/__init__.py new file mode 100644 index 000000000..ddb7fa754 --- /dev/null +++ b/src/plugins/config_reload/__init__.py @@ -0,0 +1,10 @@ +from nonebot import get_app +from .api import router +from loguru import logger + +# 获取主应用实例并挂载路由 +app = get_app() +app.include_router(router, prefix="/api") + +# 打印日志,方便确认API已注册 +logger.success("配置重载API已注册,可通过 /api/reload-config 访问") \ No newline at end of file diff --git a/src/plugins/config_reload/api.py b/src/plugins/config_reload/api.py new file mode 100644 index 000000000..4202ba9bd --- /dev/null +++ b/src/plugins/config_reload/api.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, HTTPException +from src.plugins.chat.config import BotConfig +import os + +# 创建APIRouter而不是FastAPI实例 +router = APIRouter() + +@router.post("/reload-config") +async def reload_config(): + try: + bot_config_path = os.path.join(BotConfig.get_config_dir(), "bot_config.toml") + global_config = BotConfig.load_config(config_path=bot_config_path) + return {"message": "配置重载成功", "status": "success"} + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"重载配置时发生错误: {str(e)}") \ No newline at end of file diff --git a/src/plugins/config_reload/test.py b/src/plugins/config_reload/test.py new file mode 100644 index 000000000..b3b8a9e92 --- /dev/null +++ b/src/plugins/config_reload/test.py @@ -0,0 +1,3 @@ +import requests +response = requests.post("http://localhost:8080/api/reload-config") +print(response.json()) \ No newline at end of file From 66a0f18e694a055e8f4b49137288305c8784900f Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 20:38:14 +0800 Subject: [PATCH 074/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E7=A7=81?= =?UTF-8?q?=E8=81=8A=E6=97=B6=E4=BA=A7=E7=94=9Freply=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/message.py | 223 ++++++++++++++++------------- src/plugins/chat/message_sender.py | 93 +++++++----- 2 files changed, 181 insertions(+), 135 deletions(-) diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index 9301a20a4..e502e357a 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -8,112 +8,122 @@ from loguru import logger from .utils_image import image_manager from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase from .chat_stream import ChatStream, chat_manager + # 禁用SSL警告 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -#这个类是消息数据类,用于存储和管理消息数据。 -#它定义了消息的属性,包括群组ID、用户ID、消息ID、原始消息内容、纯文本内容和时间戳。 -#它还定义了两个辅助属性:keywords用于提取消息的关键词,is_plain_text用于判断消息是否为纯文本。 - +# 这个类是消息数据类,用于存储和管理消息数据。 +# 它定义了消息的属性,包括群组ID、用户ID、消息ID、原始消息内容、纯文本内容和时间戳。 +# 它还定义了两个辅助属性:keywords用于提取消息的关键词,is_plain_text用于判断消息是否为纯文本。 + + @dataclass class MessageRecv(MessageBase): """接收消息类,用于处理从MessageCQ序列化的消息""" - + def __init__(self, message_dict: Dict): """从MessageCQ的字典初始化 - + Args: message_dict: MessageCQ序列化后的字典 """ - message_info = BaseMessageInfo.from_dict(message_dict.get('message_info', {})) - message_segment = Seg.from_dict(message_dict.get('message_segment', {})) - raw_message = message_dict.get('raw_message') - + message_info = BaseMessageInfo.from_dict(message_dict.get("message_info", {})) + message_segment = Seg.from_dict(message_dict.get("message_segment", {})) + raw_message = message_dict.get("raw_message") + super().__init__( message_info=message_info, message_segment=message_segment, - raw_message=raw_message + raw_message=raw_message, ) - + # 处理消息内容 self.processed_plain_text = "" # 初始化为空字符串 - self.detailed_plain_text = "" # 初始化为空字符串 - self.is_emoji=False - def update_chat_stream(self,chat_stream:ChatStream): - self.chat_stream=chat_stream - + self.detailed_plain_text = "" # 初始化为空字符串 + self.is_emoji = False + + def update_chat_stream(self, chat_stream: ChatStream): + self.chat_stream = chat_stream + async def process(self) -> None: """处理消息内容,生成纯文本和详细文本 - + 这个方法必须在创建实例后显式调用,因为它包含异步操作。 """ - self.processed_plain_text = await self._process_message_segments(self.message_segment) + self.processed_plain_text = await self._process_message_segments( + self.message_segment + ) self.detailed_plain_text = self._generate_detailed_text() async def _process_message_segments(self, segment: Seg) -> str: """递归处理消息段,转换为文字描述 - + Args: segment: 要处理的消息段 - + Returns: str: 处理后的文本 """ - if segment.type == 'seglist': + if segment.type == "seglist": # 处理消息段列表 segments_text = [] for seg in segment.data: processed = await self._process_message_segments(seg) if processed: segments_text.append(processed) - return ' '.join(segments_text) + return " ".join(segments_text) else: # 处理单个消息段 return await self._process_single_segment(segment) async def _process_single_segment(self, seg: Seg) -> str: """处理单个消息段 - + Args: seg: 要处理的消息段 - + Returns: str: 处理后的文本 """ try: - if seg.type == 'text': + if seg.type == "text": return seg.data - elif seg.type == 'image': + elif seg.type == "image": # 如果是base64图片数据 if isinstance(seg.data, str): return await image_manager.get_image_description(seg.data) - return '[图片]' - elif seg.type == 'emoji': - self.is_emoji=True + return "[图片]" + elif seg.type == "emoji": + self.is_emoji = True if isinstance(seg.data, str): return await image_manager.get_emoji_description(seg.data) - return '[表情]' + return "[表情]" else: return f"[{seg.type}:{str(seg.data)}]" except Exception as e: - logger.error(f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}") + logger.error( + f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}" + ) return f"[处理失败的{seg.type}消息]" def _generate_detailed_text(self) -> str: """生成详细文本,包含时间和用户信息""" - time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(self.message_info.time)) + time_str = time.strftime( + "%m-%d %H:%M:%S", time.localtime(self.message_info.time) + ) user_info = self.message_info.user_info name = ( f"{user_info.user_nickname}(ta的昵称:{user_info.user_cardname},ta的id:{user_info.user_id})" - if user_info.user_cardname!='' + if user_info.user_cardname != "" else f"{user_info.user_nickname}(ta的id:{user_info.user_id})" ) return f"[{time_str}] {name}: {self.processed_plain_text}\n" - + + @dataclass class Message(MessageBase): - chat_stream: ChatStream=None - reply: Optional['Message'] = None + chat_stream: ChatStream = None + reply: Optional["Message"] = None detailed_plain_text: str = "" processed_plain_text: str = "" @@ -124,7 +134,7 @@ class Message(MessageBase): chat_stream: ChatStream, user_info: UserInfo, message_segment: Optional[Seg] = None, - reply: Optional['MessageRecv'] = None, + reply: Optional["MessageRecv"] = None, detailed_plain_text: str = "", processed_plain_text: str = "", ): @@ -134,21 +144,19 @@ class Message(MessageBase): message_id=message_id, time=time, group_info=chat_stream.group_info, - user_info=user_info + user_info=user_info, ) # 调用父类初始化 super().__init__( - message_info=message_info, - message_segment=message_segment, - raw_message=None + message_info=message_info, message_segment=message_segment, raw_message=None ) self.chat_stream = chat_stream # 文本处理相关属性 self.processed_plain_text = detailed_plain_text self.detailed_plain_text = processed_plain_text - + # 回复消息 self.reply = reply @@ -156,14 +164,14 @@ class Message(MessageBase): @dataclass class MessageProcessBase(Message): """消息处理基类,用于处理中和发送中的消息""" - + def __init__( self, message_id: str, chat_stream: ChatStream, bot_user_info: UserInfo, message_segment: Optional[Seg] = None, - reply: Optional['MessageRecv'] = None + reply: Optional["MessageRecv"] = None, ): # 调用父类初始化 super().__init__( @@ -172,7 +180,7 @@ class MessageProcessBase(Message): chat_stream=chat_stream, user_info=bot_user_info, message_segment=message_segment, - reply=reply + reply=reply, ) # 处理状态相关属性 @@ -186,78 +194,83 @@ class MessageProcessBase(Message): async def _process_message_segments(self, segment: Seg) -> str: """递归处理消息段,转换为文字描述 - + Args: segment: 要处理的消息段 - + Returns: str: 处理后的文本 """ - if segment.type == 'seglist': + if segment.type == "seglist": # 处理消息段列表 segments_text = [] for seg in segment.data: processed = await self._process_message_segments(seg) if processed: segments_text.append(processed) - return ' '.join(segments_text) + return " ".join(segments_text) else: # 处理单个消息段 return await self._process_single_segment(segment) async def _process_single_segment(self, seg: Seg) -> str: """处理单个消息段 - + Args: seg: 要处理的消息段 - + Returns: str: 处理后的文本 """ try: - if seg.type == 'text': + if seg.type == "text": return seg.data - elif seg.type == 'image': + elif seg.type == "image": # 如果是base64图片数据 if isinstance(seg.data, str): return await image_manager.get_image_description(seg.data) - return '[图片]' - elif seg.type == 'emoji': + return "[图片]" + elif seg.type == "emoji": if isinstance(seg.data, str): return await image_manager.get_emoji_description(seg.data) - return '[表情]' - elif seg.type == 'at': + return "[表情]" + elif seg.type == "at": return f"[@{seg.data}]" - elif seg.type == 'reply': - if self.reply and hasattr(self.reply, 'processed_plain_text'): + elif seg.type == "reply": + if self.reply and hasattr(self.reply, "processed_plain_text"): return f"[回复:{self.reply.processed_plain_text}]" else: return f"[{seg.type}:{str(seg.data)}]" except Exception as e: - logger.error(f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}") + logger.error( + f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}" + ) return f"[处理失败的{seg.type}消息]" def _generate_detailed_text(self) -> str: """生成详细文本,包含时间和用户信息""" - time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(self.message_info.time)) + time_str = time.strftime( + "%m-%d %H:%M:%S", time.localtime(self.message_info.time) + ) user_info = self.message_info.user_info name = ( f"{user_info.user_nickname}(ta的昵称:{user_info.user_cardname},ta的id:{user_info.user_id})" - if user_info.user_cardname != '' + if user_info.user_cardname != "" else f"{user_info.user_nickname}(ta的id:{user_info.user_id})" ) return f"[{time_str}] {name}: {self.processed_plain_text}\n" + @dataclass class MessageThinking(MessageProcessBase): """思考状态的消息类""" - + def __init__( self, message_id: str, chat_stream: ChatStream, bot_user_info: UserInfo, - reply: Optional['MessageRecv'] = None + reply: Optional["MessageRecv"] = None, ): # 调用父类初始化 super().__init__( @@ -265,26 +278,27 @@ class MessageThinking(MessageProcessBase): chat_stream=chat_stream, bot_user_info=bot_user_info, message_segment=None, # 思考状态不需要消息段 - reply=reply + reply=reply, ) - + # 思考状态特有属性 self.interrupt = False + @dataclass class MessageSending(MessageProcessBase): """发送状态的消息类""" - + def __init__( self, message_id: str, chat_stream: ChatStream, bot_user_info: UserInfo, - sender_info:UserInfo, # 用来记录发送者信息,用于私聊回复 + sender_info: UserInfo, # 用来记录发送者信息,用于私聊回复 message_segment: Seg, - reply: Optional['MessageRecv'] = None, + reply: Optional["MessageRecv"] = None, is_head: bool = False, - is_emoji: bool = False + is_emoji: bool = False, ): # 调用父类初始化 super().__init__( @@ -292,29 +306,34 @@ class MessageSending(MessageProcessBase): chat_stream=chat_stream, bot_user_info=bot_user_info, message_segment=message_segment, - reply=reply + reply=reply, ) - + # 发送状态特有属性 - self.sender_info=sender_info + self.sender_info = sender_info self.reply_to_message_id = reply.message_info.message_id if reply else None self.is_head = is_head self.is_emoji = is_emoji - - def set_reply(self, reply: Optional['MessageRecv']) -> None: + + def set_reply(self, reply: Optional["MessageRecv"]) -> None: """设置回复消息""" if reply: self.reply = reply self.reply_to_message_id = self.reply.message_info.message_id - self.message_segment = Seg(type='seglist', data=[ - Seg(type='reply', data=reply.message_info.message_id), - self.message_segment - ]) + self.message_segment = Seg( + type="seglist", + data=[ + Seg(type="reply", data=reply.message_info.message_id), + self.message_segment, + ], + ) async def process(self) -> None: """处理消息内容,生成纯文本和详细文本""" if self.message_segment: - self.processed_plain_text = await self._process_message_segments(self.message_segment) + self.processed_plain_text = await self._process_message_segments( + self.message_segment + ) self.detailed_plain_text = self._generate_detailed_text() @classmethod @@ -323,8 +342,8 @@ class MessageSending(MessageProcessBase): thinking: MessageThinking, message_segment: Seg, is_head: bool = False, - is_emoji: bool = False - ) -> 'MessageSending': + is_emoji: bool = False, + ) -> "MessageSending": """从思考状态消息创建发送状态消息""" return cls( message_id=thinking.message_info.message_id, @@ -333,41 +352,50 @@ class MessageSending(MessageProcessBase): bot_user_info=thinking.message_info.user_info, reply=thinking.reply, is_head=is_head, - is_emoji=is_emoji + is_emoji=is_emoji, ) - + def to_dict(self): - ret= super().to_dict() - ret['message_info']['user_info']=self.chat_stream.user_info.to_dict() + ret = super().to_dict() + ret["message_info"]["user_info"] = self.chat_stream.user_info.to_dict() return ret + def is_private_message(self) -> bool: + """判断是否为私聊消息""" + return ( + self.message_info.group_info is None + or self.message_info.group_info.group_id is None + ) + + @dataclass class MessageSet: """消息集合类,可以存储多个发送消息""" + def __init__(self, chat_stream: ChatStream, message_id: str): self.chat_stream = chat_stream self.message_id = message_id self.messages: List[MessageSending] = [] self.time = round(time.time(), 2) - + def add_message(self, message: MessageSending) -> None: """添加消息到集合""" if not isinstance(message, MessageSending): raise TypeError("MessageSet只能添加MessageSending类型的消息") self.messages.append(message) self.messages.sort(key=lambda x: x.message_info.time) - + def get_message_by_index(self, index: int) -> Optional[MessageSending]: """通过索引获取消息""" if 0 <= index < len(self.messages): return self.messages[index] return None - + def get_message_by_time(self, target_time: float) -> Optional[MessageSending]: """获取最接近指定时间的消息""" if not self.messages: return None - + left, right = 0, len(self.messages) - 1 while left < right: mid = (left + right) // 2 @@ -375,25 +403,22 @@ class MessageSet: left = mid + 1 else: right = mid - + return self.messages[left] - + def clear_messages(self) -> None: """清空所有消息""" self.messages.clear() - + def remove_message(self, message: MessageSending) -> bool: """移除指定消息""" if message in self.messages: self.messages.remove(message) return True return False - + def __str__(self) -> str: return f"MessageSet(id={self.message_id}, count={len(self.messages)})" - + def __len__(self) -> int: return len(self.messages) - - - diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index f987cf999..55272953c 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -7,7 +7,7 @@ from nonebot.adapters.onebot.v11 import Bot from .cq_code import cq_code_tool from .message_cq import MessageSendCQ -from .message import MessageSending, MessageThinking, MessageRecv,MessageSet +from .message import MessageSending, MessageThinking, MessageRecv, MessageSet from .storage import MessageStorage from .config import global_config from .chat_stream import chat_manager @@ -26,23 +26,24 @@ class Message_Sender: self._current_bot = bot async def send_message( - self, - message: MessageSending, + self, + message: MessageSending, ) -> None: """发送消息""" - + if isinstance(message, MessageSending): message_json = message.to_dict() - message_send=MessageSendCQ( - data=message_json - ) + message_send = MessageSendCQ(data=message_json) # logger.debug(message_send.message_info,message_send.raw_message) - if message_send.message_info.group_info.group_id: + if ( + message_send.message_info.group_info + and message_send.message_info.group_info.group_id + ): try: await self._current_bot.send_group_msg( group_id=message.message_info.group_info.group_id, message=message_send.raw_message, - auto_escape=False + auto_escape=False, ) logger.success(f"[调试] 发送消息{message.processed_plain_text}成功") except Exception as e: @@ -54,7 +55,7 @@ class Message_Sender: await self._current_bot.send_private_msg( user_id=message.sender_info.user_id, message=message_send.raw_message, - auto_escape=False + auto_escape=False, ) logger.success(f"[调试] 发送消息{message.processed_plain_text}成功") except Exception as e: @@ -64,13 +65,14 @@ class Message_Sender: class MessageContainer: """单个聊天流的发送/思考消息容器""" + def __init__(self, chat_id: str, max_size: int = 100): self.chat_id = chat_id self.max_size = max_size self.messages = [] self.last_send_time = 0 self.thinking_timeout = 20 # 思考超时时间(秒) - + def get_timeout_messages(self) -> List[MessageSending]: """获取所有超时的Message_Sending对象(思考时间超过30秒),按thinking_start_time排序""" current_time = time.time() @@ -85,12 +87,12 @@ class MessageContainer: timeout_messages.sort(key=lambda x: x.thinking_start_time) return timeout_messages - + def get_earliest_message(self) -> Optional[Union[MessageThinking, MessageSending]]: """获取thinking_start_time最早的消息对象""" if not self.messages: return None - earliest_time = float('inf') + earliest_time = float("inf") earliest_message = None for msg in self.messages: msg_time = msg.thinking_start_time @@ -98,7 +100,7 @@ class MessageContainer: earliest_time = msg_time earliest_message = msg return earliest_message - + def add_message(self, message: Union[MessageThinking, MessageSending]) -> None: """添加消息到队列""" if isinstance(message, MessageSet): @@ -106,7 +108,7 @@ class MessageContainer: self.messages.append(single_message) else: self.messages.append(message) - + def remove_message(self, message: Union[MessageThinking, MessageSending]) -> bool: """移除消息,如果消息存在则返回True,否则返回False""" try: @@ -121,7 +123,7 @@ class MessageContainer: def has_messages(self) -> bool: """检查是否有待发送的消息""" return bool(self.messages) - + def get_all_messages(self) -> List[Union[MessageSending, MessageThinking]]: """获取所有消息""" return list(self.messages) @@ -129,72 +131,91 @@ class MessageContainer: class MessageManager: """管理所有聊天流的消息容器""" + def __init__(self): self.containers: Dict[str, MessageContainer] = {} # chat_id -> MessageContainer self.storage = MessageStorage() self._running = True - + def get_container(self, chat_id: str) -> MessageContainer: """获取或创建聊天流的消息容器""" if chat_id not in self.containers: self.containers[chat_id] = MessageContainer(chat_id) return self.containers[chat_id] - - def add_message(self, message: Union[MessageThinking, MessageSending, MessageSet]) -> None: + + def add_message( + self, message: Union[MessageThinking, MessageSending, MessageSet] + ) -> None: chat_stream = message.chat_stream if not chat_stream: raise ValueError("无法找到对应的聊天流") container = self.get_container(chat_stream.stream_id) container.add_message(message) - + async def process_chat_messages(self, chat_id: str): """处理聊天流消息""" container = self.get_container(chat_id) if container.has_messages(): # print(f"处理有message的容器chat_id: {chat_id}") message_earliest = container.get_earliest_message() - + if isinstance(message_earliest, MessageThinking): message_earliest.update_thinking_time() thinking_time = message_earliest.thinking_time - print(f"消息正在思考中,已思考{int(thinking_time)}秒\r", end='', flush=True) + print( + f"消息正在思考中,已思考{int(thinking_time)}秒\r", + end="", + flush=True, + ) # 检查是否超时 if thinking_time > global_config.thinking_timeout: logger.warning(f"消息思考超时({thinking_time}秒),移除该消息") container.remove_message(message_earliest) else: - - if message_earliest.is_head and message_earliest.update_thinking_time() > 30: + + if ( + message_earliest.is_head + and message_earliest.update_thinking_time() > 30 + and not message_earliest.is_private_message() # 避免在私聊时插入reply + ): await message_sender.send_message(message_earliest.set_reply()) else: await message_sender.send_message(message_earliest) await message_earliest.process() - - print(f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中") - - await self.storage.store_message(message_earliest, message_earliest.chat_stream,None) - + + print( + f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中" + ) + + await self.storage.store_message( + message_earliest, message_earliest.chat_stream, None + ) + container.remove_message(message_earliest) - + message_timeout = container.get_timeout_messages() if message_timeout: logger.warning(f"发现{len(message_timeout)}条超时消息") for msg in message_timeout: if msg == message_earliest: continue - + try: - if msg.is_head and msg.update_thinking_time() > 30: + if ( + msg.is_head + and msg.update_thinking_time() > 30 + and not message_earliest.is_private_message() # 避免在私聊时插入reply + ): await message_sender.send_message(msg.set_reply()) else: await message_sender.send_message(msg) - + # if msg.is_emoji: # msg.processed_plain_text = "[表情包]" await msg.process() - await self.storage.store_message(msg,msg.chat_stream, None) - + await self.storage.store_message(msg, msg.chat_stream, None) + if not container.remove_message(msg): logger.warning("尝试删除不存在的消息") except Exception: @@ -208,7 +229,7 @@ class MessageManager: tasks = [] for chat_id in self.containers.keys(): tasks.append(self.process_chat_messages(chat_id)) - + await asyncio.gather(*tasks) From 6b11b6ef29dcc0089a86bfea17baf8918964383d Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Tue, 11 Mar 2025 20:41:03 +0800 Subject: [PATCH 075/162] =?UTF-8?q?fix:=20=E4=B8=80=E4=BA=9Bfrom=20disct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/message_base.py | 8 +++++--- src/plugins/chat/message_cq.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/plugins/chat/message_base.py b/src/plugins/chat/message_base.py index d17c2c357..334859e5b 100644 --- a/src/plugins/chat/message_base.py +++ b/src/plugins/chat/message_base.py @@ -65,6 +65,8 @@ class GroupInfo: Returns: GroupInfo: 新的实例 """ + if data.get('group_id') is None: + return None return cls( platform=data.get('platform'), group_id=data.get('group_id'), @@ -129,8 +131,8 @@ class BaseMessageInfo: Returns: BaseMessageInfo: 新的实例 """ - group_info = GroupInfo(**data.get('group_info', {})) - user_info = UserInfo(**data.get('user_info', {})) + group_info = GroupInfo.from_dict(data.get('group_info', {})) + user_info = UserInfo.from_dict(data.get('user_info', {})) return cls( platform=data.get('platform'), message_id=data.get('message_id'), @@ -173,7 +175,7 @@ class MessageBase: Returns: MessageBase: 新的实例 """ - message_info = BaseMessageInfo(**data.get('message_info', {})) + message_info = BaseMessageInfo.from_dict(data.get('message_info', {})) message_segment = Seg(**data.get('message_segment', {})) raw_message = data.get('raw_message',None) return cls( diff --git a/src/plugins/chat/message_cq.py b/src/plugins/chat/message_cq.py index 6bfa47c3f..aa82d8822 100644 --- a/src/plugins/chat/message_cq.py +++ b/src/plugins/chat/message_cq.py @@ -62,7 +62,7 @@ class MessageRecvCQ(MessageCQ): # 调用父类初始化 super().__init__(message_id, user_info, group_info, platform) - if group_info.group_name is None: + if group_info and group_info.group_name is None: group_info.group_name = get_groupname(group_info.group_id) # 解析消息段 From baed8560fbc3bbb288806be7c800d7ba937400dc Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 20:48:11 +0800 Subject: [PATCH 076/162] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E4=BA=86=E7=A7=81?= =?UTF-8?q?=E8=81=8A=E5=B1=8F=E8=94=BD=E8=AF=8D=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 37f621bbb..985c264c5 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -97,7 +97,6 @@ class ChatBot: # group_info = await bot.get_group_info(group_id=event.group_id) # sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) - message_cq = MessageRecvCQ( message_cq = MessageRecvCQ( message_id=event.message_id, user_info=user_info, @@ -113,11 +112,6 @@ class ChatBot: # 进入maimbot message = MessageRecv(message_json) - groupinfo = message.message_info.group_info - userinfo = message.message_info.user_info - messageinfo = message.message_info - message = MessageRecv(message_json) - groupinfo = message.message_info.group_info userinfo = message.message_info.user_info messageinfo = message.message_info @@ -144,7 +138,7 @@ class ChatBot: for word in global_config.ban_words: if word in message.processed_plain_text: logger.info( - f"[{groupinfo.group_name}]{userinfo.user_nickname}:{message.processed_plain_text}" + f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{userinfo.user_nickname}:{message.processed_plain_text}" ) logger.info(f"[过滤词识别]消息中含有{word},filtered") return @@ -153,7 +147,7 @@ class ChatBot: for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.raw_message): logger.info( - f"[{message.group_name}]{message.user_nickname}:{message.raw_message}" + f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{message.user_nickname}:{message.raw_message}" ) logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return From cda6281cc670979f3dbc8a45b1b17d769c959ae4 Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Tue, 11 Mar 2025 21:49:18 +0900 Subject: [PATCH 077/162] chore: update emoji_manager.py discription -> description --- src/plugins/chat/emoji_manager.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 4f2637738..025677455 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -115,7 +115,7 @@ class EmojiManager: try: # 获取所有表情包 - all_emojis = list(self.db.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'discription': 1})) + all_emojis = list(self.db.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'description': 1})) if not all_emojis: logger.warning("数据库中没有任何表情包") @@ -157,9 +157,9 @@ class EmojiManager: {'_id': selected_emoji['_id']}, {'$inc': {'usage_count': 1}} ) - logger.success(f"找到匹配的表情包: {selected_emoji.get('discription', '无描述')} (相似度: {similarity:.4f})") + logger.success(f"找到匹配的表情包: {selected_emoji.get('description', '无描述')} (相似度: {similarity:.4f})") # 稍微改一下文本描述,不然容易产生幻觉,描述已经包含 表情包 了 - return selected_emoji['path'],"[ %s ]" % selected_emoji.get('discription', '无描述') + return selected_emoji['path'],"[ %s ]" % selected_emoji.get('description', '无描述') except Exception as search_error: logger.error(f"搜索表情包失败: {str(search_error)}") @@ -171,7 +171,7 @@ class EmojiManager: logger.error(f"获取表情包失败: {str(e)}") return None - async def _get_emoji_discription(self, image_base64: str) -> str: + async def _get_emoji_description(self, image_base64: str) -> str: """获取表情包的标签""" try: prompt = '这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感' @@ -232,30 +232,30 @@ class EmojiManager: continue # 获取表情包的描述 - discription = await self._get_emoji_discription(image_base64) + description = await self._get_emoji_description(image_base64) if global_config.EMOJI_CHECK: check = await self._check_emoji(image_base64) if '是' not in check: os.remove(image_path) - logger.info(f"描述: {discription}") + logger.info(f"描述: {description}") logger.info(f"其不满足过滤规则,被剔除 {check}") continue logger.info(f"check通过 {check}") - embedding = await get_embedding(discription) - if discription is not None: + embedding = await get_embedding(description) + if description is not None: # 准备数据库记录 emoji_record = { 'filename': filename, 'path': image_path, 'embedding':embedding, - 'discription': discription, + 'description': description, 'timestamp': int(time.time()) } # 保存到数据库 self.db.db['emoji'].insert_one(emoji_record) logger.success(f"注册新表情包: {filename}") - logger.info(f"描述: {discription}") + logger.info(f"描述: {description}") else: logger.warning(f"跳过表情包: {filename}") @@ -330,4 +330,4 @@ class EmojiManager: # 创建全局单例 -emoji_manager = EmojiManager() \ No newline at end of file +emoji_manager = EmojiManager() From aea3bffd996121d4462f1906ad9d5f2c9d0f5a3c Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 20:55:54 +0800 Subject: [PATCH 078/162] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=A7=81=E8=81=8A?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=BC=80=E5=85=B3,=E6=9B=B4=E6=96=B0config,?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 7 +------ src/plugins/chat/config.py | 5 ++++- template/bot_config_template.toml | 3 ++- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 985c264c5..d9f410a74 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -31,7 +31,6 @@ from .willing_manager import willing_manager # 导入意愿管理器 from .message_base import UserInfo, GroupInfo, Seg - class ChatBot: def __init__(self): self.storage = MessageStorage() @@ -59,7 +58,7 @@ class ChatBot: # 处理私聊消息的逻辑 if isinstance(event, PrivateMessageEvent): - if not 0 in global_config.talk_allowed_groups: + if not global_config.enable_friend_chat: # 私聊过滤 return else: user_info = UserInfo( @@ -182,7 +181,6 @@ class ChatBot: ) current_willing = willing_manager.get_willing(chat_stream=chat) - logger.info( f"[{current_time}][{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{chat.user_info.user_nickname}:" f"{message.processed_plain_text}[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]" @@ -190,7 +188,6 @@ class ChatBot: response = None - if random() < reply_probability: bot_user_info = UserInfo( user_id=global_config.BOT_QQ, @@ -206,14 +203,12 @@ class ChatBot: reply=message, ) - message_manager.add_message(thinking_message) willing_manager.change_reply_willing_sent(chat) response, raw_content = await self.gpt.generate_response(message) - response, raw_content = await self.gpt.generate_response(message) # print(f"response: {response}") diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 596d120f9..a53237e6a 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -69,6 +69,7 @@ class BotConfig: enable_advance_output: bool = False # 是否启用高级输出 enable_kuuki_read: bool = True # 是否启用读空气功能 enable_debug_output: bool = False # 是否启用调试输出 + enable_friend_chat: bool = False # 是否启用好友聊天 mood_update_interval: float = 1.0 # 情绪更新间隔 单位秒 mood_decay_rate: float = 0.95 # 情绪衰减率 @@ -327,7 +328,9 @@ class BotConfig: others_config = parent["others"] config.enable_advance_output = others_config.get("enable_advance_output", config.enable_advance_output) config.enable_kuuki_read = others_config.get("enable_kuuki_read", config.enable_kuuki_read) - config.enable_debug_output = others_config.get("enable_debug_output", config.enable_debug_output) + if config.INNER_VERSION in SpecifierSet(">=0.0.7"): + config.enable_debug_output = others_config.get("enable_debug_output", config.enable_debug_output) + config.enable_friend_chat = others_config.get("enable_friend_chat", config.enable_friend_chat) # 版本表达式:>=1.0.0,<2.0.0 # 允许字段:func: method, support: str, notice: str, necessary: bool diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index bea6ab7b7..eb0323cec 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "0.0.6" +version = "0.0.7" #如果你想要修改配置文件,请在修改后将version的值进行变更 #如果新增项目,请在BotConfig类下新增相应的变量 @@ -101,6 +101,7 @@ word_replace_rate=0.006 # 整词替换概率 enable_advance_output = true # 是否启用高级输出 enable_kuuki_read = true # 是否启用读空气功能 enable_debug_output = false # 是否启用调试输出 +enable_friend_chat = false # 是否启用好友聊天 [groups] talk_allowed = [ From 3180426727ed52504cafb0313dc141cac8ab8faa Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 21:08:19 +0800 Subject: [PATCH 079/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E6=94=B9=E6=8E=89=E7=9A=84typo=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index d9f410a74..920c24931 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -103,10 +103,8 @@ class ChatBot: group_info=group_info, reply_message=event.reply, platform="qq", - platform="qq", ) message_json = message_cq.to_dict() - message_json = message_cq.to_dict() # 进入maimbot message = MessageRecv(message_json) @@ -177,7 +175,6 @@ class ChatBot: config=global_config, is_emoji=message.is_emoji, interested_rate=interested_rate, - interested_rate=interested_rate, ) current_willing = willing_manager.get_willing(chat_stream=chat) @@ -194,8 +191,8 @@ class ChatBot: user_nickname=global_config.BOT_NICKNAME, platform=messageinfo.platform, ) - tinking_time_point = round(time.time(), 2) - think_id = "mt" + str(tinking_time_point) + thinking_time_point = round(time.time(), 2) + think_id = "mt" + str(thinking_time_point) thinking_message = MessageThinking( message_id=think_id, chat_stream=chat, @@ -246,7 +243,7 @@ class ChatBot: typing_time = calculate_typing_time(msg) print(f"typing_time: {typing_time}") accu_typing_time += typing_time - timepoint = tinking_time_point + accu_typing_time + timepoint = thinking_time_point + accu_typing_time message_segment = Seg(type="text", data=msg) print(f"message_segment: {message_segment}") bot_message = MessageSending( From a41274156b3378c3c963fefd5ff55a7866e61261 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 21:14:32 +0800 Subject: [PATCH 080/162] =?UTF-8?q?=E5=B0=86print=E5=8F=98=E4=B8=BAlogger.?= =?UTF-8?q?debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 920c24931..4e1c6b78d 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -241,11 +241,11 @@ class ChatBot: # print(f"\033[1;32m[回复内容]\033[0m {msg}") # 通过时间改变时间戳 typing_time = calculate_typing_time(msg) - print(f"typing_time: {typing_time}") + logger.debug(f"typing_time: {typing_time}") accu_typing_time += typing_time timepoint = thinking_time_point + accu_typing_time message_segment = Seg(type="text", data=msg) - print(f"message_segment: {message_segment}") + logger.info(f"message_segment: {message_segment}") bot_message = MessageSending( message_id=think_id, chat_stream=chat, @@ -264,7 +264,7 @@ class ChatBot: # message_set 可以直接加入 message_manager # print(f"\033[1;32m[回复]\033[0m 将回复载入发送容器") - print(f"添加message_set到message_manager") + logger.debug("添加message_set到message_manager") message_manager.add_message(message_set) bot_response_time = thinking_time_point From 956135cad90490dcdb6912f4d3cb89f79a85679a Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 21:23:07 +0800 Subject: [PATCH 081/162] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 4e1c6b78d..91253ad8b 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -53,10 +53,11 @@ class ChatBot: self.bot = bot # 更新 bot 实例 + # 用户屏蔽,不区分私聊/群聊 if event.user_id in global_config.ban_user_id: return - # 处理私聊消息的逻辑 + # 处理私聊消息 if isinstance(event, PrivateMessageEvent): if not global_config.enable_friend_chat: # 私聊过滤 return @@ -76,6 +77,7 @@ class ChatBot: # group_info = GroupInfo(group_id=0, group_name="私聊", platform="qq") group_info = None + # 处理群聊消息 else: # 白名单设定由nontbot侧完成 if event.group_id: From 9d0152a2b2f6bca120738f24884362e77281d82a Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 21:41:36 +0800 Subject: [PATCH 082/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E8=BF=87=E7=A8=8B=E4=B8=AD=E9=80=A0=E6=88=90=E7=9A=84?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 60 +++++++++++++---------------------------- 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 91253ad8b..d17ff4ba2 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -13,7 +13,6 @@ from ..memory_system.memory import hippocampus from ..moods.moods import MoodManager # 导入情绪管理器 from .config import global_config from .cq_code import CQCode, cq_code_tool # 导入CQCode模块 -from .cq_code import CQCode, cq_code_tool # 导入CQCode模块 from .emoji_manager import emoji_manager # 导入表情包管理器 from .llm_generator import ResponseGenerator from .message import MessageSending, MessageRecv, MessageThinking, MessageSet @@ -62,16 +61,16 @@ class ChatBot: if not global_config.enable_friend_chat: # 私聊过滤 return else: - user_info = UserInfo( - user_id=event.user_id, - user_nickname=( - await bot.get_stranger_info( - user_id=event.user_id, no_cache=True - ) - )["nickname"], - user_cardname=None, - platform="qq", - ) + try: + user_info = UserInfo( + user_id=event.user_id, + user_nickname=(await bot.get_stranger_info(user_id=event.user_id, no_cache=True))["nickname"], + user_cardname=None, + platform="qq", + ) + except Exception as e: + logger.error(f"获取陌生人信息失败: {e}") + return logger.debug(user_info) # group_info = GroupInfo(group_id=0, group_name="私聊", platform="qq") @@ -91,9 +90,7 @@ class ChatBot: platform="qq", ) - group_info = GroupInfo( - group_id=event.group_id, group_name=None, platform="qq" - ) + group_info = GroupInfo(group_id=event.group_id, group_name=None, platform="qq") # group_info = await bot.get_group_info(group_id=event.group_id) # sender_info = await bot.get_group_member_info(group_id=event.group_id, user_id=event.user_id, no_cache=True) @@ -110,17 +107,12 @@ class ChatBot: # 进入maimbot message = MessageRecv(message_json) - groupinfo = message.message_info.group_info userinfo = message.message_info.user_info messageinfo = message.message_info # 消息过滤,涉及到config有待更新 - chat = await chat_manager.get_or_create_stream( - platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo - ) - chat = await chat_manager.get_or_create_stream( platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo ) @@ -128,9 +120,7 @@ class ChatBot: await relationship_manager.update_relationship( chat_stream=chat, ) - await relationship_manager.update_relationship_value( - chat_stream=chat, relationship_value=0.5 - ) + await relationship_manager.update_relationship_value(chat_stream=chat, relationship_value=0.5) await message.process() # 过滤词 @@ -151,24 +141,17 @@ class ChatBot: logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return - current_time = time.strftime( - "%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time) - ) + current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) # topic=await topic_identifier.identify_topic_llm(message.processed_plain_text) + topic = "" - topic = "" - interested_rate = 0 - interested_rate = ( - await hippocampus.memory_activate_value(message.processed_plain_text) / 100 - ) - logger.debug(f"对{message.processed_plain_text}" f"的激活度:{interested_rate}") + interested_rate = await hippocampus.memory_activate_value(message.processed_plain_text) / 100 + logger.debug(f"对{message.processed_plain_text}的激活度:{interested_rate}") # logger.info(f"\033[1;32m[主题识别]\033[0m 使用{global_config.topic_extract}主题: {topic}") await self.storage.store_message(message, chat, topic[0] if topic else None) - await self.storage.store_message(message, chat, topic[0] if topic else None) - is_mentioned = is_mentioned_bot_in_message(message) reply_probability = await willing_manager.change_reply_willing_received( chat_stream=chat, @@ -208,8 +191,6 @@ class ChatBot: response, raw_content = await self.gpt.generate_response(message) - response, raw_content = await self.gpt.generate_response(message) - # print(f"response: {response}") if response: # print(f"有response: {response}") @@ -218,10 +199,7 @@ class ChatBot: # 找到message,删除 # print(f"开始找思考消息") for msg in container.messages: - if ( - isinstance(msg, MessageThinking) - and msg.message_info.message_id == think_id - ): + if isinstance(msg, MessageThinking) and msg.message_info.message_id == think_id: # print(f"找到思考消息: {msg}") thinking_message = msg container.messages.remove(msg) @@ -312,9 +290,7 @@ class ChatBot: chat_stream=chat, relationship_value=valuedict[emotion[0]] ) # 使用情绪管理器更新情绪 - self.mood_manager.update_mood_from_emotion( - emotion[0], global_config.mood_intensity_factor - ) + self.mood_manager.update_mood_from_emotion(emotion[0], global_config.mood_intensity_factor) # willing_manager.change_reply_willing_after_sent( # chat_stream=chat From 3c8c8977e662faeef7b206aad13be993c47db6dd Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 22:11:10 +0800 Subject: [PATCH 083/162] =?UTF-8?q?=E5=B1=8F=E8=94=BD=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E8=87=83=E8=82=BF=E7=9A=84debug=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index d17ff4ba2..8ba5fedfa 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -225,7 +225,7 @@ class ChatBot: accu_typing_time += typing_time timepoint = thinking_time_point + accu_typing_time message_segment = Seg(type="text", data=msg) - logger.info(f"message_segment: {message_segment}") + # logger.debug(f"message_segment: {message_segment}") bot_message = MessageSending( message_id=think_id, chat_stream=chat, From b362c355aa98dbd1a355cea1e40fa11d0e90f38e Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 22:17:41 +0800 Subject: [PATCH 084/162] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20flake.nix?= =?UTF-8?q?=20=EF=BC=8C=E9=87=87=E7=94=A8=20venv=20=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E7=94=9F=E6=88=90=E7=8E=AF=E5=A2=83=EF=BC=8Cnixos?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=B9=9F=E5=8F=AF=E4=BB=A5=E6=9C=AC=E6=9C=BA?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=A1=B9=E7=9B=AE=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flake.lock | 56 +++++++++++++++++------------------ flake.nix | 85 +++++++++++++++++++----------------------------------- 2 files changed, 56 insertions(+), 85 deletions(-) diff --git a/flake.lock b/flake.lock index dd215f1c6..894acd486 100644 --- a/flake.lock +++ b/flake.lock @@ -1,43 +1,21 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1741196730, - "narHash": "sha256-0Sj6ZKjCpQMfWnN0NURqRCQn2ob7YtXTAOTwCuz7fkA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "48913d8f9127ea6530a2a2f1bd4daa1b8685d8a3", - "type": "github" + "lastModified": 0, + "narHash": "sha256-nJj8f78AYAxl/zqLiFGXn5Im1qjFKU8yBPKoWEeZN5M=", + "path": "/nix/store/f30jn7l0bf7a01qj029fq55i466vmnkh-source", + "type": "path" }, "original": { - "owner": "NixOS", - "ref": "nixos-24.11", - "repo": "nixpkgs", - "type": "github" + "id": "nixpkgs", + "type": "indirect" } }, "root": { "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "utils": "utils" } }, "systems": { @@ -54,6 +32,24 @@ "repo": "default", "type": "github" } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 3586857f0..7022dd68e 100644 --- a/flake.nix +++ b/flake.nix @@ -1,62 +1,37 @@ { description = "MaiMBot Nix Dev Env"; - # 本配置仅方便用于开发,但是因为 nb-cli 上游打包中并未包含 nonebot2,因此目前本配置并不能用于运行和调试 inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; - flake-utils.url = "github:numtide/flake-utils"; + utils.url = "github:numtide/flake-utils"; }; - outputs = - { - self, - nixpkgs, - flake-utils, - }: - flake-utils.lib.eachDefaultSystem ( - system: - let - pkgs = import nixpkgs { - inherit system; - }; + outputs = { + self, + nixpkgs, + utils, + ... + }: + utils.lib.eachDefaultSystem (system: let + pkgs = import nixpkgs {inherit system;}; + pythonPackages = pkgs.python3Packages; + in { + devShells.default = pkgs.mkShell { + name = "python-venv"; + venvDir = "./.venv"; + buildInputs = [ + pythonPackages.python + pythonPackages.venvShellHook + ]; - pythonEnv = pkgs.python3.withPackages ( - ps: with ps; [ - ruff - pymongo - python-dotenv - pydantic - jieba - openai - aiohttp - requests - urllib3 - numpy - pandas - matplotlib - networkx - python-dateutil - APScheduler - loguru - tomli - customtkinter - colorama - pypinyin - pillow - setuptools - ] - ); - in - { - devShell = pkgs.mkShell { - buildInputs = [ - pythonEnv - pkgs.nb-cli - ]; - - shellHook = '' - ''; - }; - } - ); -} + postVenvCreation = '' + unset SOURCE_DATE_EPOCH + pip install -r requirements.txt + ''; + + postShellHook = '' + # allow pip to install wheels + unset SOURCE_DATE_EPOCH + ''; + }; + }); +} \ No newline at end of file From cd16e682238d1da1ae65211c4f5bd93ae2dfdaab Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Tue, 11 Mar 2025 22:47:20 +0800 Subject: [PATCH 085/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A1=A8=E6=83=85?= =?UTF-8?q?=E5=8C=85=E5=8F=91=E9=80=81=E6=97=B6=E7=9A=84=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 8ba5fedfa..3bc491171 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -268,6 +268,7 @@ class ChatBot: message_id=think_id, chat_stream=chat, bot_user_info=bot_user_info, + sender_info=userinfo, message_segment=message_segment, reply=message, is_head=False, From 2688a96986702d0368f378c230f144360446a9fb Mon Sep 17 00:00:00 2001 From: ChangingSelf Date: Tue, 11 Mar 2025 22:47:23 +0800 Subject: [PATCH 086/162] =?UTF-8?q?close=20SengokuCola/MaiMBot#225=20?= =?UTF-8?q?=E8=AE=A9=E9=BA=A6=E9=BA=A6=E5=8F=AF=E4=BB=A5=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E5=88=86=E4=BA=AB=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/message.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index c777e7273..20b9869f6 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -1,4 +1,7 @@ import time +import html +import re +import json from dataclasses import dataclass from typing import Dict, ForwardRef, List, Optional, Union @@ -69,6 +72,17 @@ class MessageRecv(Message): message_dict: MessageCQ序列化后的字典 """ self.message_info = BaseMessageInfo.from_dict(message_dict.get('message_info', {})) + + message_segment = message_dict.get('message_segment', {}) + + if message_segment.get('data','') == '[json]': + # 提取json消息中的展示信息 + pattern = r'\[CQ:json,data=(?P.+?)\]' + match = re.search(pattern, message_dict.get('raw_message','')) + raw_json = html.unescape(match.group('json_data')) + json_message = json.loads(raw_json) + message_segment['data'] = json_message.get('prompt','') + self.message_segment = Seg.from_dict(message_dict.get('message_segment', {})) self.raw_message = message_dict.get('raw_message') From fd19b0d6019efa816b3927d6de2238a7bd7a3233 Mon Sep 17 00:00:00 2001 From: Naptie Date: Tue, 11 Mar 2025 22:51:49 +0800 Subject: [PATCH 087/162] feat(utils): truncate_message --- src/plugins/chat/utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index 55fb9eb43..fe6ba7f7c 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -402,3 +402,10 @@ def find_similar_topics_simple(text: str, topics: list, top_k: int = 5) -> list: # 按相似度降序排序并返回前k个 return sorted(similarities, key=lambda x: x[1], reverse=True)[:top_k] + + +def truncate_message(message: str, max_length=20) -> str: + """截断消息,使其不超过指定长度""" + if len(message) > max_length: + return message[:max_length] + "..." + return message From 28fd1c8a8eed3d36ddc9d3a8a2681bf8f82d2a6f Mon Sep 17 00:00:00 2001 From: Naptie Date: Tue, 11 Mar 2025 22:53:47 +0800 Subject: [PATCH 088/162] refactor(message_sender): log format consistency --- src/plugins/chat/message_sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 9db74633f..711ec8d54 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -56,7 +56,7 @@ class Message_Sender: ) logger.success(f"[调试] 发送消息{message.processed_plain_text}成功") except Exception as e: - logger.error(f"发生错误 {e}") + logger.error(f"[调试] 发生错误 {e}") logger.error(f"[调试] 发送消息{message.processed_plain_text}失败") From 0a55ccb253646fd28165d376f761ad98d42cd1a3 Mon Sep 17 00:00:00 2001 From: Naptie Date: Tue, 11 Mar 2025 22:57:29 +0800 Subject: [PATCH 089/162] refactor: truncate messages for console logging --- src/plugins/chat/message_sender.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 711ec8d54..b7ed174af 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -11,6 +11,7 @@ from .message import MessageSending, MessageThinking, MessageRecv,MessageSet from .storage import MessageStorage from .config import global_config from .chat_stream import chat_manager +from .utils import truncate_message class Message_Sender: @@ -36,6 +37,8 @@ class Message_Sender: data=message_json ) + message_preview = truncate_message(message.processed_plain_text) + if message_send.message_info.group_info: try: await self._current_bot.send_group_msg( @@ -43,10 +46,10 @@ class Message_Sender: message=message_send.raw_message, auto_escape=False ) - logger.success(f"[调试] 发送消息{message.processed_plain_text}成功") + logger.success(f"[调试] 发送消息“{message_preview}”成功") except Exception as e: logger.error(f"[调试] 发生错误 {e}") - logger.error(f"[调试] 发送消息{message.processed_plain_text}失败") + logger.error(f"[调试] 发送消息“{message_preview}”失败") else: try: await self._current_bot.send_private_msg( @@ -54,10 +57,10 @@ class Message_Sender: message=message_send.raw_message, auto_escape=False ) - logger.success(f"[调试] 发送消息{message.processed_plain_text}成功") + logger.success(f"[调试] 发送消息“{message_preview}”成功") except Exception as e: logger.error(f"[调试] 发生错误 {e}") - logger.error(f"[调试] 发送消息{message.processed_plain_text}失败") + logger.error(f"[调试] 发送消息“{message_preview}”失败") class MessageContainer: @@ -169,7 +172,7 @@ class MessageManager: await message_sender.send_message(message_earliest) await message_earliest.process() - print(f"\033[1;34m[调试]\033[0m 消息'{message_earliest.processed_plain_text}'正在发送中") + print(f"\033[1;34m[调试]\033[0m 消息“{truncate_message(message_earliest.processed_plain_text)}”正在发送中") await self.storage.store_message(message_earliest, message_earliest.chat_stream,None) From 8c346377cbb221fab0550a325c3137dae2c37605 Mon Sep 17 00:00:00 2001 From: ChangingSelf Date: Tue, 11 Mar 2025 22:58:07 +0800 Subject: [PATCH 090/162] =?UTF-8?q?=E6=8F=90=E9=AB=98=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/message.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index 20b9869f6..0505c05a6 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -80,7 +80,10 @@ class MessageRecv(Message): pattern = r'\[CQ:json,data=(?P.+?)\]' match = re.search(pattern, message_dict.get('raw_message','')) raw_json = html.unescape(match.group('json_data')) - json_message = json.loads(raw_json) + try: + json_message = json.loads(raw_json) + except json.JSONDecodeError: + json_message = {} message_segment['data'] = json_message.get('prompt','') self.message_segment = Seg.from_dict(message_dict.get('message_segment', {})) From 26782c9181917fb7fb06f30588fc44ff2cd4fb79 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 22:58:40 +0800 Subject: [PATCH 091/162] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ENVIRONMENT?= =?UTF-8?q?=20=E5=8F=98=E9=87=8F=E5=9C=A8=E5=90=8C=E4=B8=80=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E4=B8=8B=E4=B8=8D=E8=83=BD=E8=A2=AB=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot.py b/bot.py index 68068a9f9..f6db08dca 100644 --- a/bot.py +++ b/bot.py @@ -63,7 +63,8 @@ def init_env(): # 首先加载基础环境变量.env if os.path.exists(".env"): - load_dotenv(".env") + load_dotenv(".env",override=True) + print(os.getenv("ENVIRONMENT")) logger.success("成功加载基础环境变量配置") From e54038f3d086c254213e9f85dd2a09edc7edf737 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 22:59:56 +0800 Subject: [PATCH 092/162] =?UTF-8?q?fix:=20=E4=BB=8E=20nixpkgs=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20numpy=20=E4=BE=9D=E8=B5=96=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=87=BA=E7=8E=B0=20libc++.so=20=E6=89=BE?= =?UTF-8?q?=E4=B8=8D=E5=88=B0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flake.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 7022dd68e..404f7555c 100644 --- a/flake.nix +++ b/flake.nix @@ -21,13 +21,14 @@ buildInputs = [ pythonPackages.python pythonPackages.venvShellHook + pythonPackages.numpy ]; postVenvCreation = '' unset SOURCE_DATE_EPOCH pip install -r requirements.txt ''; - + postShellHook = '' # allow pip to install wheels unset SOURCE_DATE_EPOCH From c681a827f1127cfdd2f4c5773041b7d73e926f00 Mon Sep 17 00:00:00 2001 From: UnCLAS-Prommer Date: Tue, 11 Mar 2025 23:21:57 +0800 Subject: [PATCH 093/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B0=8F=E5=90=8D?= =?UTF-8?q?=E6=97=A0=E6=95=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index 55fb9eb43..7c658fbf7 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -39,9 +39,13 @@ def db_message_to_str(message_dict: Dict) -> str: def is_mentioned_bot_in_message(message: MessageRecv) -> bool: """检查消息是否提到了机器人""" keywords = [global_config.BOT_NICKNAME] + nicknames = global_config.BOT_ALIAS_NAMES for keyword in keywords: if keyword in message.processed_plain_text: return True + for nickname in nicknames: + if nickname in message.processed_plain_text: + return True return False From 80ed56835cb1ca7b310f3c81bcc276dd9cca1a65 Mon Sep 17 00:00:00 2001 From: Rikki Date: Tue, 11 Mar 2025 23:39:25 +0800 Subject: [PATCH 094/162] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4print=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot.py b/bot.py index f6db08dca..36d621a6e 100644 --- a/bot.py +++ b/bot.py @@ -64,7 +64,6 @@ def init_env(): # 首先加载基础环境变量.env if os.path.exists(".env"): load_dotenv(".env",override=True) - print(os.getenv("ENVIRONMENT")) logger.success("成功加载基础环境变量配置") From ed18f2e96de80562db8e4dcbfa3de52a4d7a7219 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Tue, 11 Mar 2025 23:46:49 +0800 Subject: [PATCH 095/162] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BA=86=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93=E4=B8=80=E9=94=AE=E5=90=AF=E5=8A=A8=E6=BC=82?= =?UTF-8?q?=E4=BA=AE=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + run.py | 12 +- src/common/database.py | 25 +- src/plugins/chat/bot.py | 3 +- src/plugins/chat/llm_generator.py | 2 +- src/plugins/chat/message.py | 6 +- src/plugins/chat/message_base.py | 2 +- src/plugins/chat/message_cq.py | 6 +- src/plugins/chat/message_sender.py | 4 +- src/plugins/chat/prompt_builder.py | 2 +- src/plugins/chat/relationship_manager.py | 3 +- src/plugins/chat/storage.py | 2 - src/plugins/chat/utils.py | 4 +- src/plugins/chat/utils_image.py | 6 +- src/plugins/chat/willing_manager.py | 6 +- src/plugins/knowledege/knowledge_library.py | 199 --------- .../memory_system/memory_manual_build.py | 1 - src/plugins/zhishi/knowledge_library.py | 383 ++++++++++++++++++ 麦麦开始学习.bat | 45 ++ 19 files changed, 454 insertions(+), 258 deletions(-) delete mode 100644 src/plugins/knowledege/knowledge_library.py create mode 100644 src/plugins/zhishi/knowledge_library.py create mode 100644 麦麦开始学习.bat diff --git a/.gitignore b/.gitignore index e51abc5cc..6e1be60b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ data/ +data1/ mongodb/ NapCat.Framework.Windows.Once/ log/ diff --git a/run.py b/run.py index 50e312c37..cfd3a5f14 100644 --- a/run.py +++ b/run.py @@ -128,13 +128,17 @@ if __name__ == "__main__": ) os.system("cls") if choice == "1": - install_napcat() - install_mongodb() + confirm = input("首次安装将下载并配置所需组件\n1.确认\n2.取消\n") + if confirm == "1": + install_napcat() + install_mongodb() + else: + print("已取消安装") elif choice == "2": run_maimbot() - choice = input("是否启动推理可视化?(y/N)").upper() + choice = input("是否启动推理可视化?(未完善)(y/N)").upper() if choice == "Y": run_cmd(r"python src\gui\reasoning_gui.py") - choice = input("是否启动记忆可视化?(y/N)").upper() + choice = input("是否启动记忆可视化?(未完善)(y/N)").upper() if choice == "Y": run_cmd(r"python src/plugins/memory_system/memory_manual_build.py") diff --git a/src/common/database.py b/src/common/database.py index f0954b07c..d592b0f90 100644 --- a/src/common/database.py +++ b/src/common/database.py @@ -1,8 +1,6 @@ from typing import Optional - from pymongo import MongoClient - class Database: _instance: Optional["Database"] = None @@ -50,25 +48,4 @@ class Database: def get_instance(cls) -> "Database": if cls._instance is None: raise RuntimeError("Database not initialized") - return cls._instance - - - #测试用 - - def get_random_group_messages(self, group_id: str, limit: int = 5): - # 先随机获取一条消息 - random_message = list(self.db.messages.aggregate([ - {"$match": {"group_id": group_id}}, - {"$sample": {"size": 1}} - ]))[0] - - # 获取该消息之后的消息 - subsequent_messages = list(self.db.messages.find({ - "group_id": group_id, - "time": {"$gt": random_message["time"]} - }).sort("time", 1).limit(limit)) - - # 将随机消息和后续消息合并 - messages = [random_message] + subsequent_messages - - return messages \ No newline at end of file + return cls._instance \ No newline at end of file diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 9b2ac06f1..5bd502a7e 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -7,7 +7,6 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent from ..memory_system.memory import hippocampus from ..moods.moods import MoodManager # 导入情绪管理器 from .config import global_config -from .cq_code import CQCode, cq_code_tool # 导入CQCode模块 from .emoji_manager import emoji_manager # 导入表情包管理器 from .llm_generator import ResponseGenerator from .message import MessageSending, MessageRecv, MessageThinking, MessageSet @@ -218,7 +217,7 @@ class ChatBot: # message_set 可以直接加入 message_manager # print(f"\033[1;32m[回复]\033[0m 将回复载入发送容器") - print(f"添加message_set到message_manager") + print("添加message_set到message_manager") message_manager.add_message(message_set) bot_response_time = thinking_time_point diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index af7334afe..46dc34e92 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -8,7 +8,7 @@ from loguru import logger from ...common.database import Database from ..models.utils_model import LLM_request from .config import global_config -from .message import MessageRecv, MessageThinking, MessageSending,Message +from .message import MessageRecv, MessageThinking, Message from .prompt_builder import prompt_builder from .relationship_manager import relationship_manager from .utils import process_llm_response diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index 0505c05a6..d848f068f 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -3,14 +3,14 @@ import html import re import json from dataclasses import dataclass -from typing import Dict, ForwardRef, List, Optional, Union +from typing import Dict, List, Optional import urllib3 from loguru import logger from .utils_image import image_manager -from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase -from .chat_stream import ChatStream, chat_manager +from .message_base import Seg, UserInfo, BaseMessageInfo, MessageBase +from .chat_stream import ChatStream # 禁用SSL警告 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) diff --git a/src/plugins/chat/message_base.py b/src/plugins/chat/message_base.py index d17c2c357..ae7ec3872 100644 --- a/src/plugins/chat/message_base.py +++ b/src/plugins/chat/message_base.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, asdict -from typing import List, Optional, Union, Any, Dict +from typing import List, Optional, Union, Dict @dataclass class Seg: diff --git a/src/plugins/chat/message_cq.py b/src/plugins/chat/message_cq.py index 6bfa47c3f..cb47ae4b3 100644 --- a/src/plugins/chat/message_cq.py +++ b/src/plugins/chat/message_cq.py @@ -1,12 +1,12 @@ import time from dataclasses import dataclass -from typing import Dict, ForwardRef, List, Optional, Union +from typing import Dict, Optional import urllib3 -from .cq_code import CQCode, cq_code_tool +from .cq_code import cq_code_tool from .utils_cq import parse_cq_code -from .utils_user import get_groupname, get_user_cardname, get_user_nickname +from .utils_user import get_groupname from .message_base import Seg, GroupInfo, UserInfo, BaseMessageInfo, MessageBase # 禁用SSL警告 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) diff --git a/src/plugins/chat/message_sender.py b/src/plugins/chat/message_sender.py index 9db74633f..eefa6f4ae 100644 --- a/src/plugins/chat/message_sender.py +++ b/src/plugins/chat/message_sender.py @@ -5,12 +5,10 @@ from typing import Dict, List, Optional, Union from loguru import logger from nonebot.adapters.onebot.v11 import Bot -from .cq_code import cq_code_tool from .message_cq import MessageSendCQ -from .message import MessageSending, MessageThinking, MessageRecv,MessageSet +from .message import MessageSending, MessageThinking, MessageSet from .storage import MessageStorage from .config import global_config -from .chat_stream import chat_manager class Message_Sender: diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index fec6c7926..b97666763 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -9,7 +9,7 @@ from ..moods.moods import MoodManager from ..schedule.schedule_generator import bot_schedule from .config import global_config from .utils import get_embedding, get_recent_group_detailed_plain_text -from .chat_stream import ChatStream, chat_manager +from .chat_stream import chat_manager class PromptBuilder: diff --git a/src/plugins/chat/relationship_manager.py b/src/plugins/chat/relationship_manager.py index 9e7cafda0..90e92e7b6 100644 --- a/src/plugins/chat/relationship_manager.py +++ b/src/plugins/chat/relationship_manager.py @@ -1,6 +1,5 @@ import asyncio -from typing import Optional, Union -from typing import Optional, Union +from typing import Optional from loguru import logger from ...common.database import Database diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index f403b2c8b..c3986a2d0 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -1,8 +1,6 @@ from typing import Optional, Union -from typing import Optional, Union from ...common.database import Database -from .message_base import MessageBase from .message import MessageSending, MessageRecv from .chat_stream import ChatStream from loguru import logger diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index 7c658fbf7..186f2ab79 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -12,8 +12,8 @@ from loguru import logger from ..models.utils_model import LLM_request from ..utils.typo_generator import ChineseTypoGenerator from .config import global_config -from .message import MessageThinking, MessageRecv,MessageSending,MessageProcessBase,Message -from .message_base import MessageBase,BaseMessageInfo,UserInfo,GroupInfo +from .message import MessageRecv,Message +from .message_base import UserInfo from .chat_stream import ChatStream from ..moods.moods import MoodManager diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 25f23359b..42d5f9efc 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -1,16 +1,12 @@ import base64 -import io import os import time -import zlib import aiohttp import hashlib -from typing import Optional, Tuple, Union -from urllib.parse import urlparse +from typing import Optional, Union from loguru import logger from nonebot import get_driver -from PIL import Image from ...common.database import Database from ..chat.config import global_config diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 39083f0b8..f34afb746 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -1,13 +1,9 @@ import asyncio from typing import Dict -from loguru import logger -from typing import Dict -from loguru import logger from .config import global_config -from .message_base import UserInfo, GroupInfo -from .chat_stream import chat_manager,ChatStream +from .chat_stream import ChatStream class WillingManager: diff --git a/src/plugins/knowledege/knowledge_library.py b/src/plugins/knowledege/knowledge_library.py deleted file mode 100644 index e9d7167fd..000000000 --- a/src/plugins/knowledege/knowledge_library.py +++ /dev/null @@ -1,199 +0,0 @@ -import os -import sys -import time - -import requests -from dotenv import load_dotenv - -# 添加项目根目录到 Python 路径 -root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) -sys.path.append(root_path) - -# 加载根目录下的env.edv文件 -env_path = os.path.join(root_path, ".env.dev") -if not os.path.exists(env_path): - raise FileNotFoundError(f"配置文件不存在: {env_path}") -load_dotenv(env_path) - -from src.common.database import Database - -# 从环境变量获取配置 -Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), -) - -class KnowledgeLibrary: - def __init__(self): - self.db = Database.get_instance() - self.raw_info_dir = "data/raw_info" - self._ensure_dirs() - self.api_key = os.getenv("SILICONFLOW_KEY") - if not self.api_key: - raise ValueError("SILICONFLOW_API_KEY 环境变量未设置") - - def _ensure_dirs(self): - """确保必要的目录存在""" - os.makedirs(self.raw_info_dir, exist_ok=True) - - def get_embedding(self, text: str) -> list: - """获取文本的embedding向量""" - url = "https://api.siliconflow.cn/v1/embeddings" - payload = { - "model": "BAAI/bge-m3", - "input": text, - "encoding_format": "float" - } - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json" - } - - response = requests.post(url, json=payload, headers=headers) - if response.status_code != 200: - print(f"获取embedding失败: {response.text}") - return None - - return response.json()['data'][0]['embedding'] - - def process_files(self): - """处理raw_info目录下的所有txt文件""" - for filename in os.listdir(self.raw_info_dir): - if filename.endswith('.txt'): - file_path = os.path.join(self.raw_info_dir, filename) - self.process_single_file(file_path) - - def process_single_file(self, file_path: str): - """处理单个文件""" - try: - # 检查文件是否已处理 - if self.db.db.processed_files.find_one({"file_path": file_path}): - print(f"文件已处理过,跳过: {file_path}") - return - - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # 按1024字符分段 - segments = [content[i:i+600] for i in range(0, len(content), 300)] - - # 处理每个分段 - for segment in segments: - if not segment.strip(): # 跳过空段 - continue - - # 获取embedding - embedding = self.get_embedding(segment) - if not embedding: - continue - - # 存储到数据库 - doc = { - "content": segment, - "embedding": embedding, - "file_path": file_path, - "segment_length": len(segment) - } - - # 使用文本内容的哈希值作为唯一标识 - content_hash = hash(segment) - - # 更新或插入文档 - self.db.db.knowledges.update_one( - {"content_hash": content_hash}, - {"$set": doc}, - upsert=True - ) - - # 记录文件已处理 - self.db.db.processed_files.insert_one({ - "file_path": file_path, - "processed_time": time.time() - }) - - print(f"成功处理文件: {file_path}") - - except Exception as e: - print(f"处理文件 {file_path} 时出错: {str(e)}") - - def search_similar_segments(self, query: str, limit: int = 5) -> list: - """搜索与查询文本相似的片段""" - query_embedding = self.get_embedding(query) - if not query_embedding: - return [] - - # 使用余弦相似度计算 - pipeline = [ - { - "$addFields": { - "dotProduct": { - "$reduce": { - "input": {"$range": [0, {"$size": "$embedding"}]}, - "initialValue": 0, - "in": { - "$add": [ - "$$value", - {"$multiply": [ - {"$arrayElemAt": ["$embedding", "$$this"]}, - {"$arrayElemAt": [query_embedding, "$$this"]} - ]} - ] - } - } - }, - "magnitude1": { - "$sqrt": { - "$reduce": { - "input": "$embedding", - "initialValue": 0, - "in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]} - } - } - }, - "magnitude2": { - "$sqrt": { - "$reduce": { - "input": query_embedding, - "initialValue": 0, - "in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]} - } - } - } - } - }, - { - "$addFields": { - "similarity": { - "$divide": ["$dotProduct", {"$multiply": ["$magnitude1", "$magnitude2"]}] - } - } - }, - {"$sort": {"similarity": -1}}, - {"$limit": limit}, - {"$project": {"content": 1, "similarity": 1, "file_path": 1}} - ] - - results = list(self.db.db.knowledges.aggregate(pipeline)) - return results - -# 创建单例实例 -knowledge_library = KnowledgeLibrary() - -if __name__ == "__main__": - # 测试知识库功能 - print("开始处理知识库文件...") - knowledge_library.process_files() - - # 测试搜索功能 - test_query = "麦麦评价一下僕と花" - print(f"\n搜索与'{test_query}'相似的内容:") - results = knowledge_library.search_similar_segments(test_query) - for result in results: - print(f"相似度: {result['similarity']:.4f}") - print(f"内容: {result['content'][:100]}...") - print("-" * 50) diff --git a/src/plugins/memory_system/memory_manual_build.py b/src/plugins/memory_system/memory_manual_build.py index 9c1d43ce9..736a50e97 100644 --- a/src/plugins/memory_system/memory_manual_build.py +++ b/src/plugins/memory_system/memory_manual_build.py @@ -10,7 +10,6 @@ from pathlib import Path import matplotlib.pyplot as plt import networkx as nx -import pymongo from dotenv import load_dotenv from loguru import logger import jieba diff --git a/src/plugins/zhishi/knowledge_library.py b/src/plugins/zhishi/knowledge_library.py new file mode 100644 index 000000000..2411e3112 --- /dev/null +++ b/src/plugins/zhishi/knowledge_library.py @@ -0,0 +1,383 @@ +import os +import sys +import time +import requests +from dotenv import load_dotenv +import hashlib +from datetime import datetime +from tqdm import tqdm +from rich.console import Console +from rich.table import Table + +# 添加项目根目录到 Python 路径 +root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) +sys.path.append(root_path) + +# 现在可以导入src模块 +from src.common.database import Database + +# 加载根目录下的env.edv文件 +env_path = os.path.join(root_path, ".env.prod") +if not os.path.exists(env_path): + raise FileNotFoundError(f"配置文件不存在: {env_path}") +load_dotenv(env_path) + +class KnowledgeLibrary: + def __init__(self): + # 初始化数据库连接 + if Database._instance is None: + Database.initialize( + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), + ) + self.db = Database.get_instance() + self.raw_info_dir = "data/raw_info" + self._ensure_dirs() + self.api_key = os.getenv("SILICONFLOW_KEY") + if not self.api_key: + raise ValueError("SILICONFLOW_API_KEY 环境变量未设置") + self.console = Console() + + def _ensure_dirs(self): + """确保必要的目录存在""" + os.makedirs(self.raw_info_dir, exist_ok=True) + + def read_file(self, file_path: str) -> str: + """读取文件内容""" + with open(file_path, 'r', encoding='utf-8') as f: + return f.read() + + def split_content(self, content: str, max_length: int = 512) -> list: + """将内容分割成适当大小的块,保持段落完整性 + + Args: + content: 要分割的文本内容 + max_length: 每个块的最大长度 + + Returns: + list: 分割后的文本块列表 + """ + # 首先按段落分割 + paragraphs = [p.strip() for p in content.split('\n\n') if p.strip()] + chunks = [] + current_chunk = [] + current_length = 0 + + for para in paragraphs: + para_length = len(para) + + # 如果单个段落就超过最大长度 + if para_length > max_length: + # 如果当前chunk不为空,先保存 + if current_chunk: + chunks.append('\n'.join(current_chunk)) + current_chunk = [] + current_length = 0 + + # 将长段落按句子分割 + sentences = [s.strip() for s in para.replace('。', '。\n').replace('!', '!\n').replace('?', '?\n').split('\n') if s.strip()] + temp_chunk = [] + temp_length = 0 + + for sentence in sentences: + sentence_length = len(sentence) + if sentence_length > max_length: + # 如果单个句子超长,强制按长度分割 + if temp_chunk: + chunks.append('\n'.join(temp_chunk)) + temp_chunk = [] + temp_length = 0 + for i in range(0, len(sentence), max_length): + chunks.append(sentence[i:i + max_length]) + elif temp_length + sentence_length + 1 <= max_length: + temp_chunk.append(sentence) + temp_length += sentence_length + 1 + else: + chunks.append('\n'.join(temp_chunk)) + temp_chunk = [sentence] + temp_length = sentence_length + + if temp_chunk: + chunks.append('\n'.join(temp_chunk)) + + # 如果当前段落加上现有chunk不超过最大长度 + elif current_length + para_length + 1 <= max_length: + current_chunk.append(para) + current_length += para_length + 1 + else: + # 保存当前chunk并开始新的chunk + chunks.append('\n'.join(current_chunk)) + current_chunk = [para] + current_length = para_length + + # 添加最后一个chunk + if current_chunk: + chunks.append('\n'.join(current_chunk)) + + return chunks + + def get_embedding(self, text: str) -> list: + """获取文本的embedding向量""" + url = "https://api.siliconflow.cn/v1/embeddings" + payload = { + "model": "BAAI/bge-m3", + "input": text, + "encoding_format": "float" + } + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + response = requests.post(url, json=payload, headers=headers) + if response.status_code != 200: + print(f"获取embedding失败: {response.text}") + return None + + return response.json()['data'][0]['embedding'] + + def process_files(self, knowledge_length:int=512): + """处理raw_info目录下的所有txt文件""" + txt_files = [f for f in os.listdir(self.raw_info_dir) if f.endswith('.txt')] + + if not txt_files: + self.console.print("[red]警告:在 {} 目录下没有找到任何txt文件[/red]".format(self.raw_info_dir)) + self.console.print("[yellow]请将需要处理的文本文件放入该目录后再运行程序[/yellow]") + return + + total_stats = { + "processed_files": 0, + "total_chunks": 0, + "failed_files": [], + "skipped_files": [] + } + + self.console.print(f"\n[bold blue]开始处理知识库文件 - 共{len(txt_files)}个文件[/bold blue]") + + for filename in tqdm(txt_files, desc="处理文件进度"): + file_path = os.path.join(self.raw_info_dir, filename) + result = self.process_single_file(file_path, knowledge_length) + self._update_stats(total_stats, result, filename) + + self._display_processing_results(total_stats) + + def process_single_file(self, file_path: str, knowledge_length: int = 512): + """处理单个文件""" + result = { + "status": "success", + "chunks_processed": 0, + "error": None + } + + try: + current_hash = self.calculate_file_hash(file_path) + processed_record = self.db.db.processed_files.find_one({"file_path": file_path}) + + if processed_record: + if processed_record.get("hash") == current_hash: + if knowledge_length in processed_record.get("split_by", []): + result["status"] = "skipped" + return result + + content = self.read_file(file_path) + chunks = self.split_content(content, knowledge_length) + + for chunk in tqdm(chunks, desc=f"处理 {os.path.basename(file_path)} 的文本块", leave=False): + embedding = self.get_embedding(chunk) + if embedding: + knowledge = { + "content": chunk, + "embedding": embedding, + "source_file": file_path, + "split_length": knowledge_length, + "created_at": datetime.now() + } + self.db.db.knowledges.insert_one(knowledge) + result["chunks_processed"] += 1 + + split_by = processed_record.get("split_by", []) if processed_record else [] + if knowledge_length not in split_by: + split_by.append(knowledge_length) + + self.db.db.processed_files.update_one( + {"file_path": file_path}, + { + "$set": { + "hash": current_hash, + "last_processed": datetime.now(), + "split_by": split_by + } + }, + upsert=True + ) + + except Exception as e: + result["status"] = "failed" + result["error"] = str(e) + + return result + + def _update_stats(self, total_stats, result, filename): + """更新总体统计信息""" + if result["status"] == "success": + total_stats["processed_files"] += 1 + total_stats["total_chunks"] += result["chunks_processed"] + elif result["status"] == "failed": + total_stats["failed_files"].append((filename, result["error"])) + elif result["status"] == "skipped": + total_stats["skipped_files"].append(filename) + + def _display_processing_results(self, stats): + """显示处理结果统计""" + self.console.print("\n[bold green]处理完成!统计信息如下:[/bold green]") + + table = Table(show_header=True, header_style="bold magenta") + table.add_column("统计项", style="dim") + table.add_column("数值") + + table.add_row("成功处理文件数", str(stats["processed_files"])) + table.add_row("处理的知识块总数", str(stats["total_chunks"])) + table.add_row("跳过的文件数", str(len(stats["skipped_files"]))) + table.add_row("失败的文件数", str(len(stats["failed_files"]))) + + self.console.print(table) + + if stats["failed_files"]: + self.console.print("\n[bold red]处理失败的文件:[/bold red]") + for filename, error in stats["failed_files"]: + self.console.print(f"[red]- {filename}: {error}[/red]") + + if stats["skipped_files"]: + self.console.print("\n[bold yellow]跳过的文件(已处理):[/bold yellow]") + for filename in stats["skipped_files"]: + self.console.print(f"[yellow]- {filename}[/yellow]") + + def calculate_file_hash(self, file_path): + """计算文件的MD5哈希值""" + hash_md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + + def search_similar_segments(self, query: str, limit: int = 5) -> list: + """搜索与查询文本相似的片段""" + query_embedding = self.get_embedding(query) + if not query_embedding: + return [] + + # 使用余弦相似度计算 + pipeline = [ + { + "$addFields": { + "dotProduct": { + "$reduce": { + "input": {"$range": [0, {"$size": "$embedding"}]}, + "initialValue": 0, + "in": { + "$add": [ + "$$value", + {"$multiply": [ + {"$arrayElemAt": ["$embedding", "$$this"]}, + {"$arrayElemAt": [query_embedding, "$$this"]} + ]} + ] + } + } + }, + "magnitude1": { + "$sqrt": { + "$reduce": { + "input": "$embedding", + "initialValue": 0, + "in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]} + } + } + }, + "magnitude2": { + "$sqrt": { + "$reduce": { + "input": query_embedding, + "initialValue": 0, + "in": {"$add": ["$$value", {"$multiply": ["$$this", "$$this"]}]} + } + } + } + } + }, + { + "$addFields": { + "similarity": { + "$divide": ["$dotProduct", {"$multiply": ["$magnitude1", "$magnitude2"]}] + } + } + }, + {"$sort": {"similarity": -1}}, + {"$limit": limit}, + {"$project": {"content": 1, "similarity": 1, "file_path": 1}} + ] + + results = list(self.db.db.knowledges.aggregate(pipeline)) + return results + +# 创建单例实例 +knowledge_library = KnowledgeLibrary() + +if __name__ == "__main__": + console = Console() + console.print("[bold green]知识库处理工具[/bold green]") + + while True: + console.print("\n请选择要执行的操作:") + console.print("[1] 麦麦开始学习") + console.print("[2] 麦麦全部忘光光(仅知识)") + console.print("[q] 退出程序") + + choice = input("\n请输入选项: ").strip() + + if choice.lower() == 'q': + console.print("[yellow]程序退出[/yellow]") + sys.exit(0) + elif choice == '2': + confirm = input("确定要删除所有知识吗?这个操作不可撤销!(y/n): ").strip().lower() + if confirm == 'y': + knowledge_library.db.db.knowledges.delete_many({}) + console.print("[green]已清空所有知识![/green]") + continue + elif choice == '1': + if not os.path.exists(knowledge_library.raw_info_dir): + console.print(f"[yellow]创建目录:{knowledge_library.raw_info_dir}[/yellow]") + os.makedirs(knowledge_library.raw_info_dir, exist_ok=True) + + # 询问分割长度 + while True: + try: + length_input = input("请输入知识分割长度(默认512,输入q退出,回车使用默认值): ").strip() + if length_input.lower() == 'q': + break + if not length_input: # 如果直接回车,使用默认值 + knowledge_length = 512 + break + knowledge_length = int(length_input) + if knowledge_length <= 0: + print("分割长度必须大于0,请重新输入") + continue + break + except ValueError: + print("请输入有效的数字") + continue + + if length_input.lower() == 'q': + continue + + # 测试知识库功能 + print(f"开始处理知识库文件,使用分割长度: {knowledge_length}...") + knowledge_library.process_files(knowledge_length=knowledge_length) + else: + console.print("[red]无效的选项,请重新选择[/red]") + continue diff --git a/麦麦开始学习.bat b/麦麦开始学习.bat new file mode 100644 index 000000000..f7391150f --- /dev/null +++ b/麦麦开始学习.bat @@ -0,0 +1,45 @@ +@echo off +setlocal enabledelayedexpansion +chcp 65001 +cd /d %~dp0 + +echo ===================================== +echo 选择Python环境: +echo 1 - venv (推荐) +echo 2 - conda +echo ===================================== +choice /c 12 /n /m "输入数字(1或2): " + +if errorlevel 2 ( + echo ===================================== + set "CONDA_ENV=" + set /p CONDA_ENV="请输入要激活的 conda 环境名称: " + + :: 检查输入是否为空 + if "!CONDA_ENV!"=="" ( + echo 错误:环境名称不能为空 + pause + exit /b 1 + ) + + call conda activate !CONDA_ENV! + if errorlevel 1 ( + echo 激活 conda 环境失败 + pause + exit /b 1 + ) + + echo Conda 环境 "!CONDA_ENV!" 激活成功 + python src/plugins/zhishi/knowledge_library.py +) else ( + if exist "venv\Scripts\python.exe" ( + venv\Scripts\python src/plugins/zhishi/knowledge_library.py + ) else ( + echo ===================================== + echo 错误: venv环境不存在,请先创建虚拟环境 + pause + exit /b 1 + ) +) +endlocal +pause From a91ef7bf489e5be7c30850066c4954e904013a36 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 12 Mar 2025 00:05:19 +0800 Subject: [PATCH 096/162] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/auto_update.py | 59 +++++++++++++++++++++++++++++ requirements.txt | Bin 612 -> 630 bytes 如果你的配置文件版本太老就点我.bat | 45 ++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 config/auto_update.py create mode 100644 如果你的配置文件版本太老就点我.bat diff --git a/config/auto_update.py b/config/auto_update.py new file mode 100644 index 000000000..28ab108da --- /dev/null +++ b/config/auto_update.py @@ -0,0 +1,59 @@ +import os +import shutil +import tomlkit +from pathlib import Path + +def update_config(): + # 获取根目录路径 + root_dir = Path(__file__).parent.parent + template_dir = root_dir / "template" + config_dir = root_dir / "config" + + # 定义文件路径 + template_path = template_dir / "bot_config_template.toml" + old_config_path = config_dir / "bot_config.toml" + new_config_path = config_dir / "bot_config.toml" + + # 读取旧配置文件 + old_config = {} + if old_config_path.exists(): + with open(old_config_path, "r", encoding="utf-8") as f: + old_config = tomlkit.load(f) + + # 删除旧的配置文件 + if old_config_path.exists(): + os.remove(old_config_path) + + # 复制模板文件到配置目录 + shutil.copy2(template_path, new_config_path) + + # 读取新配置文件 + with open(new_config_path, "r", encoding="utf-8") as f: + new_config = tomlkit.load(f) + + # 递归更新配置 + def update_dict(target, source): + for key, value in source.items(): + # 跳过version字段的更新 + if key == "version": + continue + if key in target: + if isinstance(value, dict) and isinstance(target[key], (dict, tomlkit.items.Table)): + update_dict(target[key], value) + else: + try: + # 直接使用tomlkit的item方法创建新值 + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + # 如果转换失败,直接赋值 + target[key] = value + + # 将旧配置的值更新到新配置中 + update_dict(new_config, old_config) + + # 保存更新后的配置(保留注释和格式) + with open(new_config_path, "w", encoding="utf-8") as f: + f.write(tomlkit.dumps(new_config)) + +if __name__ == "__main__": + update_config() diff --git a/requirements.txt b/requirements.txt index 0acaade5e6d465465ee87009b5c28551e591953d..8330c8d06fa1c57ae6bce0de82cec7764a199d94 100644 GIT binary patch delta 25 gcmaFD@{MJ~Jx1OVhJ1!xh8%`$hD?T%$u}9h0d$}UdjJ3c delta 11 Scmeyy@`Po>J;uo&7~23MmIZtO diff --git a/如果你的配置文件版本太老就点我.bat b/如果你的配置文件版本太老就点我.bat new file mode 100644 index 000000000..fec1f4cdb --- /dev/null +++ b/如果你的配置文件版本太老就点我.bat @@ -0,0 +1,45 @@ +@echo off +setlocal enabledelayedexpansion +chcp 65001 +cd /d %~dp0 + +echo ===================================== +echo 选择Python环境: +echo 1 - venv (推荐) +echo 2 - conda +echo ===================================== +choice /c 12 /n /m "输入数字(1或2): " + +if errorlevel 2 ( + echo ===================================== + set "CONDA_ENV=" + set /p CONDA_ENV="请输入要激活的 conda 环境名称: " + + :: 检查输入是否为空 + if "!CONDA_ENV!"=="" ( + echo 错误:环境名称不能为空 + pause + exit /b 1 + ) + + call conda activate !CONDA_ENV! + if errorlevel 1 ( + echo 激活 conda 环境失败 + pause + exit /b 1 + ) + + echo Conda 环境 "!CONDA_ENV!" 激活成功 + python config/auto_update.py +) else ( + if exist "venv\Scripts\python.exe" ( + venv\Scripts\python config/auto_update.py + ) else ( + echo ===================================== + echo 错误: venv环境不存在,请先创建虚拟环境 + pause + exit /b 1 + ) +) +endlocal +pause From cbb569e7767ac85d84d60fe47d2d6e09f56de265 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 12 Mar 2025 00:18:28 +0800 Subject: [PATCH 097/162] =?UTF-8?q?Create=20=E5=A6=82=E6=9E=9C=E4=BD=A0?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BA=86=E7=89=88=E6=9C=AC=EF=BC=8C=E7=82=B9?= =?UTF-8?q?=E6=88=91.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 如果你更新了版本,点我.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 如果你更新了版本,点我.txt diff --git a/如果你更新了版本,点我.txt b/如果你更新了版本,点我.txt new file mode 100644 index 000000000..400e8ae0c --- /dev/null +++ b/如果你更新了版本,点我.txt @@ -0,0 +1,4 @@ +更新版本后,建议删除数据库messages中所有内容,不然会出现报错 +该操作不会影响你的记忆 + +如果显示配置文件版本过低,运行根目录的bat \ No newline at end of file From 39018440d7b81688e5df4c01eafbd63c8e71600a Mon Sep 17 00:00:00 2001 From: Rikki Date: Wed, 12 Mar 2025 00:51:56 +0800 Subject: [PATCH 098/162] =?UTF-8?q?refactor:=20=E4=BF=AE=E5=A4=8Ddatabase?= =?UTF-8?q?=E5=8D=95=E4=BE=8B=E5=A4=9A=E6=AC=A1=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E6=94=B9=E5=8F=98instance?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=BF=94=E5=9B=9E=E5=AE=9E=E4=BE=8B=E7=9A=84?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=EF=BC=8C=E7=BC=A9=E7=9F=ADdb=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=87=BD=E6=95=B0=E8=B0=83=E7=94=A8=E6=97=B6=E7=9A=84?= =?UTF-8?q?object=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 15 ++++++++- src/common/database.py | 12 +++---- src/gui/reasoning_gui.py | 4 +-- src/plugins/chat/__init__.py | 12 ------- src/plugins/chat/chat_stream.py | 14 ++++---- src/plugins/chat/emoji_manager.py | 28 ++++++++-------- src/plugins/chat/llm_generator.py | 2 +- src/plugins/chat/prompt_builder.py | 2 +- src/plugins/chat/relationship_manager.py | 6 ++-- src/plugins/chat/storage.py | 2 +- src/plugins/chat/utils_image.py | 36 ++++++++++----------- src/plugins/knowledege/knowledge_library.py | 19 +++-------- src/plugins/memory_system/draw_memory.py | 16 ++++----- src/plugins/memory_system/memory.py | 9 ------ src/plugins/models/utils_model.py | 10 +++--- src/plugins/schedule/schedule_generator.py | 16 ++------- src/plugins/utils/statistic.py | 2 +- 17 files changed, 88 insertions(+), 117 deletions(-) diff --git a/bot.py b/bot.py index 36d621a6e..8d51cee3c 100644 --- a/bot.py +++ b/bot.py @@ -12,6 +12,8 @@ from loguru import logger from nonebot.adapters.onebot.v11 import Adapter import platform +from src.common.database import Database + # 获取没有加载env时的环境变量 env_mask = {key: os.getenv(key) for key in os.environ} @@ -96,6 +98,17 @@ def load_env(): logger.error(f"ENVIRONMENT 配置错误,请检查 .env 文件中的 ENVIRONMENT 变量及对应 .env.{env} 是否存在") RuntimeError(f"ENVIRONMENT 配置错误,请检查 .env 文件中的 ENVIRONMENT 变量及对应 .env.{env} 是否存在") +def init_database(): + Database.initialize( + uri=os.getenv("MONGODB_URI"), + host=os.getenv("MONGODB_HOST", "127.0.0.1"), + port=int(os.getenv("MONGODB_PORT", "27017")), + db_name=os.getenv("DATABASE_NAME", "MegBot"), + username=os.getenv("MONGODB_USERNAME"), + password=os.getenv("MONGODB_PASSWORD"), + auth_source=os.getenv("MONGODB_AUTH_SOURCE"), + ) + def load_logger(): logger.remove() # 移除默认配置 @@ -198,6 +211,7 @@ def raw_main(): init_config() init_env() load_env() + init_database() # 加载完成环境后初始化database load_logger() env_config = {key: os.getenv(key) for key in os.environ} @@ -223,7 +237,6 @@ def raw_main(): if __name__ == "__main__": - try: raw_main() diff --git a/src/common/database.py b/src/common/database.py index f0954b07c..9d9a596d1 100644 --- a/src/common/database.py +++ b/src/common/database.py @@ -1,7 +1,7 @@ from typing import Optional from pymongo import MongoClient - +from pymongo.database import Database as MongoDatabase class Database: _instance: Optional["Database"] = None @@ -27,7 +27,7 @@ class Database: else: # 否则使用无认证连接 self.client = MongoClient(host, port) - self.db = self.client[db_name] + self.db: MongoDatabase = self.client[db_name] @classmethod def initialize( @@ -39,18 +39,18 @@ class Database: password: Optional[str] = None, auth_source: Optional[str] = None, uri: Optional[str] = None, - ) -> "Database": + ) -> MongoDatabase: if cls._instance is None: cls._instance = cls( host, port, db_name, username, password, auth_source, uri ) - return cls._instance + return cls._instance.db @classmethod - def get_instance(cls) -> "Database": + def get_instance(cls) -> MongoDatabase: if cls._instance is None: raise RuntimeError("Database not initialized") - return cls._instance + return cls._instance.db #测试用 diff --git a/src/gui/reasoning_gui.py b/src/gui/reasoning_gui.py index e131658b8..84b95adaf 100644 --- a/src/gui/reasoning_gui.py +++ b/src/gui/reasoning_gui.py @@ -46,7 +46,7 @@ class ReasoningGUI: # 初始化数据库连接 try: - self.db = Database.get_instance().db + self.db = Database.get_instance() logger.success("数据库连接成功") except RuntimeError: logger.warning("数据库未初始化,正在尝试初始化...") @@ -60,7 +60,7 @@ class ReasoningGUI: password=os.getenv("MONGODB_PASSWORD"), auth_source=os.getenv("MONGODB_AUTH_SOURCE"), ) - self.db = Database.get_instance().db + self.db = Database.get_instance() logger.success("数据库初始化成功") except Exception: logger.exception("数据库初始化失败") diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index ec3d4f01d..8ae525708 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -32,18 +32,6 @@ _message_manager_started = False driver = get_driver() config = driver.config -Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), -) -logger.success("初始化数据库成功") - - # 初始化表情管理器 emoji_manager.initialize() diff --git a/src/plugins/chat/chat_stream.py b/src/plugins/chat/chat_stream.py index bee679173..3ccd03f81 100644 --- a/src/plugins/chat/chat_stream.py +++ b/src/plugins/chat/chat_stream.py @@ -111,11 +111,11 @@ class ChatManager: def _ensure_collection(self): """确保数据库集合存在并创建索引""" - if "chat_streams" not in self.db.db.list_collection_names(): - self.db.db.create_collection("chat_streams") + if "chat_streams" not in self.db.list_collection_names(): + self.db.create_collection("chat_streams") # 创建索引 - self.db.db.chat_streams.create_index([("stream_id", 1)], unique=True) - self.db.db.chat_streams.create_index( + self.db.chat_streams.create_index([("stream_id", 1)], unique=True) + self.db.chat_streams.create_index( [("platform", 1), ("user_info.user_id", 1), ("group_info.group_id", 1)] ) @@ -168,7 +168,7 @@ class ChatManager: return stream # 检查数据库中是否存在 - data = self.db.db.chat_streams.find_one({"stream_id": stream_id}) + data = self.db.chat_streams.find_one({"stream_id": stream_id}) if data: stream = ChatStream.from_dict(data) # 更新用户信息和群组信息 @@ -204,7 +204,7 @@ class ChatManager: async def _save_stream(self, stream: ChatStream): """保存聊天流到数据库""" if not stream.saved: - self.db.db.chat_streams.update_one( + self.db.chat_streams.update_one( {"stream_id": stream.stream_id}, {"$set": stream.to_dict()}, upsert=True ) stream.saved = True @@ -216,7 +216,7 @@ class ChatManager: async def load_all_streams(self): """从数据库加载所有聊天流""" - all_streams = self.db.db.chat_streams.find({}) + all_streams = self.db.chat_streams.find({}) for data in all_streams: stream = ChatStream.from_dict(data) self.streams[stream.stream_id] = stream diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 3adb952d3..1743571e9 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -76,16 +76,16 @@ class EmojiManager: 没有索引的话,数据库每次查询都需要扫描全部数据,建立索引后可以大大提高查询效率。 """ - if 'emoji' not in self.db.db.list_collection_names(): - self.db.db.create_collection('emoji') - self.db.db.emoji.create_index([('embedding', '2dsphere')]) - self.db.db.emoji.create_index([('filename', 1)], unique=True) + if 'emoji' not in self.db.list_collection_names(): + self.db.create_collection('emoji') + self.db.emoji.create_index([('embedding', '2dsphere')]) + self.db.emoji.create_index([('filename', 1)], unique=True) def record_usage(self, emoji_id: str): """记录表情使用次数""" try: self._ensure_db() - self.db.db.emoji.update_one( + self.db.emoji.update_one( {'_id': emoji_id}, {'$inc': {'usage_count': 1}} ) @@ -119,7 +119,7 @@ class EmojiManager: try: # 获取所有表情包 - all_emojis = list(self.db.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'description': 1})) + all_emojis = list(self.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'description': 1})) if not all_emojis: logger.warning("数据库中没有任何表情包") @@ -157,7 +157,7 @@ class EmojiManager: if selected_emoji and 'path' in selected_emoji: # 更新使用次数 - self.db.db.emoji.update_one( + self.db.emoji.update_one( {'_id': selected_emoji['_id']}, {'$inc': {'usage_count': 1}} ) @@ -236,7 +236,7 @@ class EmojiManager: image_hash = hashlib.md5(image_bytes).hexdigest() # 检查是否已经注册过 - existing_emoji = self.db.db['emoji'].find_one({'filename': filename}) + existing_emoji = self.db['emoji'].find_one({'filename': filename}) description = None if existing_emoji: @@ -298,7 +298,7 @@ class EmojiManager: } # 保存到emoji数据库 - self.db.db['emoji'].insert_one(emoji_record) + self.db['emoji'].insert_one(emoji_record) logger.success(f"注册新表情包: {filename}") logger.info(f"描述: {description}") @@ -338,7 +338,7 @@ class EmojiManager: try: self._ensure_db() # 获取所有表情包记录 - all_emojis = list(self.db.db.emoji.find()) + all_emojis = list(self.db.emoji.find()) removed_count = 0 total_count = len(all_emojis) @@ -346,13 +346,13 @@ class EmojiManager: try: if 'path' not in emoji: logger.warning(f"发现无效记录(缺少path字段),ID: {emoji.get('_id', 'unknown')}") - self.db.db.emoji.delete_one({'_id': emoji['_id']}) + self.db.emoji.delete_one({'_id': emoji['_id']}) removed_count += 1 continue if 'embedding' not in emoji: logger.warning(f"发现过时记录(缺少embedding字段),ID: {emoji.get('_id', 'unknown')}") - self.db.db.emoji.delete_one({'_id': emoji['_id']}) + self.db.emoji.delete_one({'_id': emoji['_id']}) removed_count += 1 continue @@ -360,7 +360,7 @@ class EmojiManager: if not os.path.exists(emoji['path']): logger.warning(f"表情包文件已被删除: {emoji['path']}") # 从数据库中删除记录 - result = self.db.db.emoji.delete_one({'_id': emoji['_id']}) + result = self.db.emoji.delete_one({'_id': emoji['_id']}) if result.deleted_count > 0: logger.debug(f"成功删除数据库记录: {emoji['_id']}") removed_count += 1 @@ -371,7 +371,7 @@ class EmojiManager: continue # 验证清理结果 - remaining_count = self.db.db.emoji.count_documents({}) + remaining_count = self.db.emoji.count_documents({}) if removed_count > 0: logger.success(f"已清理 {removed_count} 个失效的表情包记录") logger.info(f"清理前总数: {total_count} | 清理后总数: {remaining_count}") diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index af7334afe..285ea59b7 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -154,7 +154,7 @@ class ResponseGenerator: reasoning_content: str, ): """保存对话记录到数据库""" - self.db.db.reasoning_logs.insert_one( + self.db.reasoning_logs.insert_one( { "time": time.time(), "chat_id": message.chat_stream.stream_id, diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index fec6c7926..ea3da777a 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -311,7 +311,7 @@ class PromptBuilder: {"$project": {"content": 1, "similarity": 1}} ] - results = list(self.db.db.knowledges.aggregate(pipeline)) + results = list(self.db.knowledges.aggregate(pipeline)) # print(f"\033[1;34m[调试]\033[0m获取知识库内容结果: {results}") if not results: diff --git a/src/plugins/chat/relationship_manager.py b/src/plugins/chat/relationship_manager.py index 9e7cafda0..baebb1fe8 100644 --- a/src/plugins/chat/relationship_manager.py +++ b/src/plugins/chat/relationship_manager.py @@ -169,7 +169,7 @@ class RelationshipManager: async def load_all_relationships(self): """加载所有关系对象""" db = Database.get_instance() - all_relationships = db.db.relationships.find({}) + all_relationships = db.relationships.find({}) for data in all_relationships: await self.load_relationship(data) @@ -177,7 +177,7 @@ class RelationshipManager: """每5分钟自动保存一次关系数据""" db = Database.get_instance() # 获取所有关系记录 - all_relationships = db.db.relationships.find({}) + all_relationships = db.relationships.find({}) # 依次加载每条记录 for data in all_relationships: await self.load_relationship(data) @@ -207,7 +207,7 @@ class RelationshipManager: saved = relationship.saved db = Database.get_instance() - db.db.relationships.update_one( + db.relationships.update_one( {'user_id': user_id, 'platform': platform}, {'$set': { 'platform': platform, diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index f403b2c8b..dc03e4ced 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -25,7 +25,7 @@ class MessageStorage: "detailed_plain_text": message.detailed_plain_text, "topic": topic, } - self.db.db.messages.insert_one(message_data) + self.db.messages.insert_one(message_data) except Exception: logger.exception("存储消息失败") diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 25f23359b..ddb3b04cd 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -44,20 +44,20 @@ class ImageManager: def _ensure_image_collection(self): """确保images集合存在并创建索引""" - if 'images' not in self.db.db.list_collection_names(): - self.db.db.create_collection('images') + if 'images' not in self.db.list_collection_names(): + self.db.create_collection('images') # 创建索引 - self.db.db.images.create_index([('hash', 1)], unique=True) - self.db.db.images.create_index([('url', 1)]) - self.db.db.images.create_index([('path', 1)]) + self.db.images.create_index([('hash', 1)], unique=True) + self.db.images.create_index([('url', 1)]) + self.db.images.create_index([('path', 1)]) def _ensure_description_collection(self): """确保image_descriptions集合存在并创建索引""" - if 'image_descriptions' not in self.db.db.list_collection_names(): - self.db.db.create_collection('image_descriptions') + if 'image_descriptions' not in self.db.list_collection_names(): + self.db.create_collection('image_descriptions') # 创建索引 - self.db.db.image_descriptions.create_index([('hash', 1)], unique=True) - self.db.db.image_descriptions.create_index([('type', 1)]) + self.db.image_descriptions.create_index([('hash', 1)], unique=True) + self.db.image_descriptions.create_index([('type', 1)]) def _get_description_from_db(self, image_hash: str, description_type: str) -> Optional[str]: """从数据库获取图片描述 @@ -69,7 +69,7 @@ class ImageManager: Returns: Optional[str]: 描述文本,如果不存在则返回None """ - result= self.db.db.image_descriptions.find_one({ + result= self.db.image_descriptions.find_one({ 'hash': image_hash, 'type': description_type }) @@ -83,7 +83,7 @@ class ImageManager: description: 描述文本 description_type: 描述类型 ('emoji' 或 'image') """ - self.db.db.image_descriptions.update_one( + self.db.image_descriptions.update_one( {'hash': image_hash, 'type': description_type}, { '$set': { @@ -125,7 +125,7 @@ class ImageManager: image_hash = hashlib.md5(image_bytes).hexdigest() # 查重 - existing = self.db.db.images.find_one({'hash': image_hash}) + existing = self.db.images.find_one({'hash': image_hash}) if existing: return existing['path'] @@ -146,7 +146,7 @@ class ImageManager: 'description': description, 'timestamp': timestamp } - self.db.db.images.insert_one(image_doc) + self.db.images.insert_one(image_doc) return file_path @@ -163,7 +163,7 @@ class ImageManager: """ try: # 先查找是否已存在 - existing = self.db.db.images.find_one({'url': url}) + existing = self.db.images.find_one({'url': url}) if existing: return existing['path'] @@ -207,7 +207,7 @@ class ImageManager: Returns: bool: 是否存在 """ - return self.db.db.images.find_one({'url': url}) is not None + return self.db.images.find_one({'url': url}) is not None def check_hash_exists(self, image_data: Union[str, bytes], is_base64: bool = False) -> bool: """检查图像是否已存在 @@ -230,7 +230,7 @@ class ImageManager: return False image_hash = hashlib.md5(image_bytes).hexdigest() - return self.db.db.images.find_one({'hash': image_hash}) is not None + return self.db.images.find_one({'hash': image_hash}) is not None except Exception as e: logger.error(f"检查哈希失败: {str(e)}") @@ -273,7 +273,7 @@ class ImageManager: 'description': description, 'timestamp': timestamp } - self.db.db.images.update_one( + self.db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True @@ -330,7 +330,7 @@ class ImageManager: 'description': description, 'timestamp': timestamp } - self.db.db.images.update_one( + self.db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True diff --git a/src/plugins/knowledege/knowledge_library.py b/src/plugins/knowledege/knowledge_library.py index e9d7167fd..1bebf0930 100644 --- a/src/plugins/knowledege/knowledge_library.py +++ b/src/plugins/knowledege/knowledge_library.py @@ -17,17 +17,6 @@ load_dotenv(env_path) from src.common.database import Database -# 从环境变量获取配置 -Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), -) - class KnowledgeLibrary: def __init__(self): self.db = Database.get_instance() @@ -72,7 +61,7 @@ class KnowledgeLibrary: """处理单个文件""" try: # 检查文件是否已处理 - if self.db.db.processed_files.find_one({"file_path": file_path}): + if self.db.processed_files.find_one({"file_path": file_path}): print(f"文件已处理过,跳过: {file_path}") return @@ -104,14 +93,14 @@ class KnowledgeLibrary: content_hash = hash(segment) # 更新或插入文档 - self.db.db.knowledges.update_one( + self.db.knowledges.update_one( {"content_hash": content_hash}, {"$set": doc}, upsert=True ) # 记录文件已处理 - self.db.db.processed_files.insert_one({ + self.db.processed_files.insert_one({ "file_path": file_path, "processed_time": time.time() }) @@ -178,7 +167,7 @@ class KnowledgeLibrary: {"$project": {"content": 1, "similarity": 1, "file_path": 1}} ] - results = list(self.db.db.knowledges.aggregate(pipeline)) + results = list(self.db.knowledges.aggregate(pipeline)) return results # 创建单例实例 diff --git a/src/plugins/memory_system/draw_memory.py b/src/plugins/memory_system/draw_memory.py index 9f15164f1..d6ba8f3b2 100644 --- a/src/plugins/memory_system/draw_memory.py +++ b/src/plugins/memory_system/draw_memory.py @@ -96,7 +96,7 @@ class Memory_graph: dot_data = { "concept": node } - self.db.db.store_memory_dots.insert_one(dot_data) + self.db.store_memory_dots.insert_one(dot_data) @property def dots(self): @@ -106,7 +106,7 @@ class Memory_graph: def get_random_chat_from_db(self, length: int, timestamp: str): # 从数据库中根据时间戳获取离其最近的聊天记录 chat_text = '' - closest_record = self.db.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) # 调试输出 + closest_record = self.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) # 调试输出 logger.info( f"距离time最近的消息时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(closest_record['time'])))}") @@ -115,7 +115,7 @@ class Memory_graph: group_id = closest_record['group_id'] # 获取groupid # 获取该时间戳之后的length条消息,且groupid相同 chat_record = list( - self.db.db.messages.find({"time": {"$gt": closest_time}, "group_id": group_id}).sort('time', 1).limit( + self.db.messages.find({"time": {"$gt": closest_time}, "group_id": group_id}).sort('time', 1).limit( length)) for record in chat_record: time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(record['time']))) @@ -130,34 +130,34 @@ class Memory_graph: def save_graph_to_db(self): # 清空现有的图数据 - self.db.db.graph_data.delete_many({}) + self.db.graph_data.delete_many({}) # 保存节点 for node in self.G.nodes(data=True): node_data = { 'concept': node[0], 'memory_items': node[1].get('memory_items', []) # 默认为空列表 } - self.db.db.graph_data.nodes.insert_one(node_data) + self.db.graph_data.nodes.insert_one(node_data) # 保存边 for edge in self.G.edges(): edge_data = { 'source': edge[0], 'target': edge[1] } - self.db.db.graph_data.edges.insert_one(edge_data) + self.db.graph_data.edges.insert_one(edge_data) def load_graph_from_db(self): # 清空当前图 self.G.clear() # 加载节点 - nodes = self.db.db.graph_data.nodes.find() + nodes = self.db.graph_data.nodes.find() for node in nodes: memory_items = node.get('memory_items', []) if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] self.G.add_node(node['concept'], memory_items=memory_items) # 加载边 - edges = self.db.db.graph_data.edges.find() + edges = self.db.graph_data.edges.find() for edge in edges: self.G.add_edge(edge['source'], edge['target']) diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index c0b551b58..3c844c3ff 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -887,15 +887,6 @@ config = driver.config start_time = time.time() -Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), -) # 创建记忆图 memory_graph = Memory_graph() # 创建海马体 diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index 3424d662c..75b46f611 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -41,10 +41,10 @@ class LLM_request: """初始化数据库集合""" try: # 创建llm_usage集合的索引 - self.db.db.llm_usage.create_index([("timestamp", 1)]) - self.db.db.llm_usage.create_index([("model_name", 1)]) - self.db.db.llm_usage.create_index([("user_id", 1)]) - self.db.db.llm_usage.create_index([("request_type", 1)]) + self.db.llm_usage.create_index([("timestamp", 1)]) + self.db.llm_usage.create_index([("model_name", 1)]) + self.db.llm_usage.create_index([("user_id", 1)]) + self.db.llm_usage.create_index([("request_type", 1)]) except Exception: logger.error("创建数据库索引失败") @@ -73,7 +73,7 @@ class LLM_request: "status": "success", "timestamp": datetime.now() } - self.db.db.llm_usage.insert_one(usage_data) + self.db.llm_usage.insert_one(usage_data) logger.info( f"Token使用情况 - 模型: {self.model_name}, " f"用户: {user_id}, 类型: {request_type}, " diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index 12c6ce3b5..bde593890 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -14,16 +14,6 @@ from ..models.utils_model import LLM_request driver = get_driver() config = driver.config -Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), -) - class ScheduleGenerator: def __init__(self): # 根据global_config.llm_normal这一字典配置指定模型 @@ -56,7 +46,7 @@ class ScheduleGenerator: schedule_text = str - existing_schedule = self.db.db.schedule.find_one({"date": date_str}) + existing_schedule = self.db.schedule.find_one({"date": date_str}) if existing_schedule: logger.debug(f"{date_str}的日程已存在:") schedule_text = existing_schedule["schedule"] @@ -73,7 +63,7 @@ class ScheduleGenerator: try: schedule_text, _ = await self.llm_scheduler.generate_response(prompt) - self.db.db.schedule.insert_one({"date": date_str, "schedule": schedule_text}) + self.db.schedule.insert_one({"date": date_str, "schedule": schedule_text}) except Exception as e: logger.error(f"生成日程失败: {str(e)}") schedule_text = "生成日程时出错了" @@ -153,7 +143,7 @@ class ScheduleGenerator: """打印完整的日程安排""" if not self._parse_schedule(self.today_schedule_text): logger.warning("今日日程有误,将在下次运行时重新生成") - self.db.db.schedule.delete_one({"date": datetime.datetime.now().strftime("%Y-%m-%d")}) + self.db.schedule.delete_one({"date": datetime.datetime.now().strftime("%Y-%m-%d")}) else: logger.info("=== 今日日程安排 ===") for time_str, activity in self.today_schedule.items(): diff --git a/src/plugins/utils/statistic.py b/src/plugins/utils/statistic.py index 2974389e6..4629f0e0b 100644 --- a/src/plugins/utils/statistic.py +++ b/src/plugins/utils/statistic.py @@ -53,7 +53,7 @@ class LLMStatistics: "costs_by_model": defaultdict(float) } - cursor = self.db.db.llm_usage.find({ + cursor = self.db.llm_usage.find({ "timestamp": {"$gte": start_time} }) From 443878b5c41182fc32d6cda4c9712c035b4bb1f3 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 12 Mar 2025 00:56:54 +0800 Subject: [PATCH 099/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E5=BA=93=E5=86=92=E7=83=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.md | 84 +++++++++++++++++ hort --pretty=format-ad -s | 141 ++++++++++++++++++++++++++++ src/plugins/memory_system/memory.py | 5 + 3 files changed, 230 insertions(+) create mode 100644 hort --pretty=format-ad -s diff --git a/changelog.md b/changelog.md index c68a16ad9..d545dee4c 100644 --- a/changelog.md +++ b/changelog.md @@ -4,3 +4,87 @@ ### Added - 新增了 我是测试 +## [0.5.13] - 2025-3-12 +AI总结 +### 🌟 核心功能增强 +#### 记忆系统升级 +- 新增了记忆系统的时间戳功能,包括创建时间和最后修改时间 +- 新增了记忆图节点和边的时间追踪功能 +- 新增了自动补充缺失时间字段的功能 +- 新增了记忆遗忘机制,基于时间条件自动遗忘旧记忆 +- 新增了记忆合并功能,可以合并超过100条的记忆内容 +- 新增了记忆读取次数限制,每条消息最多被读取3次 +- 优化了记忆系统的数据同步机制 +- 优化了记忆系统的数据结构,确保所有数据类型的一致性 + +#### 私聊功能完善 +- 新增了完整的私聊功能支持,包括消息处理和回复 +- 新增了聊天流管理器,支持群聊和私聊的上下文管理 +- 新增了私聊过滤开关功能 +- 优化了关系管理系统,支持跨平台用户关系 + +#### 消息处理升级 +- 新增了消息队列管理系统,支持按时间顺序处理消息 +- 新增了消息发送控制器,实现人性化的发送速度和间隔 +- 新增了JSON格式分享卡片读取支持 +- 新增了Base64格式表情包CQ码支持 +- 改进了消息处理流程,支持多种消息类型 + +### 💻 系统架构优化 +#### 配置系统改进 +- 新增了配置文件自动更新和版本检测功能 +- 新增了配置文件热重载API接口 +- 新增了配置文件版本兼容性检查 +- 新增了根据不同环境(dev/prod)显示不同级别的日志功能 +- 优化了配置文件格式和结构 + +#### 部署支持扩展 +- 新增了Linux系统部署指南 +- 新增了Docker部署支持的详细文档 +- 新增了NixOS环境支持(使用venv方式) +- 新增了优雅的shutdown机制 +- 优化了Docker部署文档 + +### 🛠️ 开发体验提升 +#### 工具链升级 +- 新增了ruff代码格式化和检查工具 +- 新增了知识库一键启动脚本 +- 新增了自动保存脚本,定期保存聊天记录和关系数据 +- 新增了表情包自动获取脚本 +- 优化了日志记录(使用logger.debug替代print) +- 精简了日志输出,禁用了Uvicorn/NoneBot默认日志 + +#### 安全性强化 +- 新增了API密钥安全管理机制 +- 新增了数据库完整性检查功能 +- 新增了表情包文件完整性自动检查 +- 新增了异常处理和自动恢复机制 +- 优化了安全性检查机制 + +### 🐛 关键问题修复 +#### 系统稳定性 +- 修复了systemctl强制停止的问题 +- 修复了ENVIRONMENT变量在同一终端下不能被覆盖的问题 +- 修复了libc++.so依赖问题 +- 修复了数据库索引创建失败的问题 +- 修复了MongoDB连接配置相关问题 +- 修复了消息队列溢出问题 +- 修复了配置文件加载时的版本兼容性问题 + +#### 功能完善性 +- 修复了私聊时产生reply消息的bug +- 修复了回复消息无法识别的问题 +- 修复了CQ码解析错误 +- 修复了情绪管理器导入问题 +- 修复了小名无效的问题 +- 修复了表情包发送时的参数缺失问题 +- 修复了表情包重复注册问题 +- 修复了变量拼写错误问题 + +### 主要改进方向 +1. 提升记忆系统的智能性和可靠性 +2. 完善私聊功能的完整生态 +3. 优化系统架构和部署便利性 +4. 提升开发体验和代码质量 +5. 加强系统安全性和稳定性 + diff --git a/hort --pretty=format-ad -s b/hort --pretty=format-ad -s new file mode 100644 index 000000000..faeacdd5f --- /dev/null +++ b/hort --pretty=format-ad -s @@ -0,0 +1,141 @@ +cbb569e - Create 如果你更新了版本,点我.txt +a91ef7b - 自动升级配置文件脚本 +ed18f2e - 新增了知识库一键启动漂亮脚本 +80ed568 - fix: 删除print调试代码 +c681a82 - 修复小名无效问题 +e54038f - fix: 从 nixpkgs 增加 numpy 依赖,以避免出现 libc++.so 找不到的问题 +26782c9 - fix: 修复 ENVIRONMENT 变量在同一终端下不能被覆盖的问题 +8c34637 - 提高健壮性 +2688a96 - close SengokuCola/MaiMBot#225 让麦麦可以正确读取分享卡片 +cd16e68 - 修复表情包发送时的缺失参数 +b362c35 - feat: 更新 flake.nix ,采用 venv 的方式生成环境,nixos用户也可以本机运行项目了 +3c8c897 - 屏蔽一个臃肿的debug信息 +9d0152a - 修复了合并过程中造成的代码重复 +956135c - 添加一些注释 +a412741 - 将print变为logger.debug +3180426 - 修复了没有改掉的typo字段 +aea3bff - 添加私聊过滤开关,更新config,增加约束 +cda6281 - chore: update emoji_manager.py +baed856 - 修正了私聊屏蔽词输出 +66a0f18 - 修复了私聊时产生reply消息的bug +3bf5cd6 - feat: 新增运行时重载配置文件;新增根据不同环境(dev;prod)显示不同级别的log +33cd83b - 添加私聊功能 +aa41f0d - fix: 放反了 +ef8691c - fix: 修改message继承逻辑,修复回复消息无法识别 +7d017be - fix:模型降级 +e1019ad - fix: 修复变量拼写错误并优化代码可读性 +c24bb70 - fix: 流式输出模式增加结束判断与token用量记录 +60a9376 - 添加logger的debug输出开关,默认为不开启 +bfa9a3c - fix: 添加群信息获取的错误处理 (#173) +4cc5c8e - 修正.env.prod和.env.dev的生成 +dea14c1 - fix: 模型降级目前只对硅基流动的V3和R1生效 +b6edbea - fix: 图片保存路径不正确 +01a6fa8 - fix: 删除神秘test +20f009d - 修复systemctl强制停止maimbot的问题 +af962c2 - 修复了情绪管理器没有正确导入导致发布出消息 +0586700 - 按照Sourcery提供的建议修改systemctl管理指南 +e48b32a - 在手动部署教程中增加使用systemctl管理 +5760412 - fix: 小修 +1c9b0cc - fix: 修复部分cq码解析错误,merge +b6867b9 - fix: 统一使用os.getenv获取数据库连接信息,避免从config对象获取不存在的值时出现KeyError +5e069f7 - 修复记忆保存时无时间信息的bug +73a3e41 - 修复记忆更新bug +52c93ba - refactor: use Base64 for emoji CQ codes +67f6d7c - fix: 保证能运行的小修改 +c32c4fb - refactor: 修改配置文件的版本号 +a54ca8c - Merge remote-tracking branch 'upstream/debug' into feat_regix +8cbf9bb - feat: 史上最好的消息流重构和图片管理 +9e41c4f - feat: 修改 bot_config 0.0.5 版本的变更日志 +eede406 - fix: 修复nonebot无法加载项目的问题 +00e02ed - fix: 0.0.5 版本的增加分层控制项 +0f99d6a - Update docs/docker_deploy.md +c789074 - feat: 增加ruff依赖 +ff65ab8 - feat: 修改默认的ruff配置文件,同时消除config的所有不符合规范的地方 +bf97013 - feat: 精简日志,禁用Uvicorn/NoneBot默认日志;启动方式改为显示加载uvicorn,以便优雅shutdown +d9a2863 - 优化Docker部署文档更新容器部分 +efcf00f - Docker部署文档追加更新部分 +a63ce96 - fix: 更新情感判断模型配置(使配置文件里的 llm_emotion_judge 生效) +1294c88 - feat: 增加标准化格式化设置 +2e8cd47 - fix: 避免可能出现的日程解析错误 +043a724 - 修一下文档跳转,小美化( +e4b8865 - 支持别名,可以用不同名称召唤机器人 +7b35ddd - ruff 哥又有新点子 +7899e67 - feat: 重构完成开始测试debug +354d6d0 - 记忆系统优化 +6cef8fd - 修复时区,删去napcat用不到的端口 +cd96644 - 添加使用说明 +84495f8 - fix +204744c - 修改配置名与修改过滤对象为raw_message +a03b490 - Update README.md +2b2b342 - feat: 增加 ruff 依赖 +72a6749 - fix: 修复docker部署时区指定问题 +ee579bc - Update README.md +1b611ec - resolve SengokuCola/MaiMBot#167 根据正则表达式过滤消息 +6e2ea82 - refractor: 几乎写完了,进入测试阶段 +2ffdfef - More +e680405 - fix: typo 'discription' +68b3f57 - Minor Doc Update +312f065 - Create linux_deploy_guide_for_beginners.md +ed505a4 - fix: 使用动态路径替换硬编码的项目路径 +8ff7bb6 - docs: 更新文档,修正格式并添加必要的换行符 +6e36a56 - feat: 增加 MONGODB_URI 的配置项,并将所有env文件的注释单独放在一行(python的dotenv有时无法正确处理行内注释) +4baa6c6 - feat: 实现MongoDB URI方式连接,并统一数据库连接代码。 +8a32d18 - feat: 优化willing_manager逻辑,增加回复保底概率 +c9f1244 - docs: 改进README.md文档格式和排版 +e1b484a - docs: 添加CLAUDE.md开发指南文件(用于Claude Code) +a43f949 - fix: remove duplicate message(CR comments) +fddb641 - fix: 修复错误的空值检测逻辑 +8b7876c - fix: 修复没有上传tag的问题 +6b4130e - feat: 增加stable-dev分支的打包 +052e67b - refactor: 日志打印优化(终于改完了,爽了 +a7f9d05 - 修复记忆整理传入格式问题 +536bb1d - fix: 更新情感判断模型配置 +8d99592 - fix: logger初始化顺序 +052802c - refactor: logger promotion +8661d94 - doc: README.md - telegram version information +5746afa - refactor: logger in src\plugins\chat\bot.py +288dbb6 - refactor: logger in src\plugins\chat\__init__.py +8428a06 - fix: memory logger optimization (CR comment) +665c459 - 改进了可视化脚本 +6c35704 - fix: 调用了错误的函数 +3223153 - feat: 一键脚本新增记忆可视化 +3149dd3 - fix: mongodb.zip 无法解压 fix:更换执行命令的方法 fix:当 db 不存在时自动创建 feat: 一键安装完成后启动麦麦 +089d6a6 - feat: 针对硅基流动的Pro模型添加了自动降级功能 +c4b0917 - 一个记忆可视化小脚本 +6a71ea4 - 修复了记忆时间bug,config添加了记忆屏蔽关键词 +1b5344f - fix: 优化bot初始化的日志&格式 +41aa974 - fix: 优化chat/config.py的日志&格式 +980cde7 - fix: 优化scheduler_generator日志&格式 +31a5514 - fix: 调整全局logger加载顺序 +8baef07 - feat: 添加全局logger初始化设置 +5566f17 - refractor: 几乎写完了,进入测试阶段 +6a66933 - feat: 添加开发环境.env.dev初始化 +411ff1a - feat: 安装 MongoDB Compass +0de9eba - feat: 增加实时更新贡献者列表的功能 +f327f45 - fix: 优化src/plugins/chat/__init__.py的import +826daa5 - fix: 当虚拟环境存在时跳过创建 +f54de42 - fix: time.tzset 仅在类 Unix 系统可用 +47c4990 - fix: 修复docker部署场景下时间错误的问题 +e23a371 - docs: 添加 compose 注释 +1002822 - docs: 标注 Python 最低版本 +564350d - feat: 校验 Python 版本 +4cc4482 - docs: 添加傻瓜式脚本 +757173a - 带麦麦看了心理医生,让她没那么容易陷入负面情绪 +39bb99c - 将错别字生成提取到配置,一句一个错别字太烦了! +fe36847 - feat: 超大型重构 +e304dd7 - Update README.md +b7cfe6d - feat: 发布第 0.0.2 版本配置模板 +ca929d5 - 补充Docker部署文档 +1e97120 - 补充Docker部署文档 +25f7052 - fix: 修复兼容性选项和目前第一个版本之间的版本间隙 0.0.0 版,并将所有的直接退出修改为抛出异常 +c5bdc4f - 防ipv6炸,虽然小概率事件 +d86610d - fix: 修复不能加载环境变量的问题 +2306ebf - feat: 因为判断临界版本范围比较麻烦,增加 notice 字段,删除原本的判断逻辑(存在故障) +dd09576 - fix: 修复 TypeError: BotConfig.convert_to_specifierset() takes 1 positional argument but 2 were given +18f839b - fix: 修复 missing 1 required positional argument: 'INNER_VERSION' +6adb5ed - 调整一些细节,docker部署时可选数据库账密 +07f48e9 - fix: 利用filter来过滤环境变量,避免直接删除key造成的 RuntimeError: dictionary changed size during iteration +5856074 - fix: 修复无法进行基础设置的问题 +32aa032 - feat: 发布 0.0.1 版本的配置文件 +edc07ac - feat: 重构配置加载器,增加配置文件版本控制和程序兼容能力 +0f492ed - fix: 修复 BASE_URL/KEY 组合检查中被 GPG_KEY 干扰的问题 \ No newline at end of file diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index c0b551b58..d79ed5f91 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -523,9 +523,14 @@ class Hippocampus: async def operation_forget_topic(self, percentage=0.1): """随机选择图中一定比例的节点和边进行检查,根据时间条件决定是否遗忘""" + # 检查数据库是否为空 all_nodes = list(self.memory_graph.G.nodes()) all_edges = list(self.memory_graph.G.edges()) + if not all_nodes and not all_edges: + logger.info("记忆图为空,无需进行遗忘操作") + return + check_nodes_count = max(1, int(len(all_nodes) * percentage)) check_edges_count = max(1, int(len(all_edges) * percentage)) From 05d955a0e0e0e86be79b8e75bac86c7b5e9e5062 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 12 Mar 2025 01:07:09 +0800 Subject: [PATCH 100/162] =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=A1=A5=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 --- README.md | 13 +++++++++++-- changelog.md | 6 ------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5fddcb320..ad318aecc 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,11 @@ - MongoDB 提供数据持久化支持 - NapCat 作为QQ协议端支持 -**最新版本: v0.5.*** +**最新版本: v0.5.13** +> [!WARNING] +> 注意,3月12日的v0.5.13, 该版本更新较大,建议单独开文件夹部署,然后转移/data文件 和数据库,数据库可能需要删除messages下的内容(不需要删除记忆) + +
@@ -40,7 +44,12 @@ - [二群](https://qm.qq.com/q/RzmCiRtHEW) 571780722 (开发和建议相关讨论)不一定有空回复,会优先写文档和代码 - [三群](https://qm.qq.com/q/wlH5eT8OmQ) 1035228475(开发和建议相关讨论)不一定有空回复,会优先写文档和代码 -**其他平台版本** + + +**📚 有热心网友创作的wiki:** https://maimbot.pages.dev/ + + +**😊 其他平台版本** - (由 [CabLate](https://github.com/cablate) 贡献) [Telegram 与其他平台(未来可能会有)的版本](https://github.com/cablate/MaiMBot/tree/telegram) - [集中讨论串](https://github.com/SengokuCola/MaiMBot/discussions/149) diff --git a/changelog.md b/changelog.md index d545dee4c..b9beed81e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,9 +1,5 @@ # Changelog -## [0.5.12] - 2025-3-9 -### Added -- 新增了 我是测试 - ## [0.5.13] - 2025-3-12 AI总结 ### 🌟 核心功能增强 @@ -12,8 +8,6 @@ AI总结 - 新增了记忆图节点和边的时间追踪功能 - 新增了自动补充缺失时间字段的功能 - 新增了记忆遗忘机制,基于时间条件自动遗忘旧记忆 -- 新增了记忆合并功能,可以合并超过100条的记忆内容 -- 新增了记忆读取次数限制,每条消息最多被读取3次 - 优化了记忆系统的数据同步机制 - 优化了记忆系统的数据结构,确保所有数据类型的一致性 From 727457373643888ba0195c4bc79dd72634381cff Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 12 Mar 2025 01:32:20 +0800 Subject: [PATCH 101/162] =?UTF-8?q?=E8=AE=B0=E5=BF=86=E5=92=8C=E9=81=97?= =?UTF-8?q?=E5=BF=98=E7=9A=84=E5=8F=AF=E8=87=AA=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/__init__.py | 2 +- src/plugins/chat/config.py | 16 +++++++++++++--- src/plugins/memory_system/memory.py | 6 +++--- template/bot_config_template.toml | 11 ++++++++--- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index 4833a0f5b..38af5443d 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -127,7 +127,7 @@ async def build_memory_task(): async def forget_memory_task(): """每30秒执行一次记忆构建""" print("\033[1;32m[记忆遗忘]\033[0m 开始遗忘记忆...") - await hippocampus.operation_forget_topic(percentage=0.1) + await hippocampus.operation_forget_topic(percentage=global_config.memory_forget_percentage) print("\033[1;32m[记忆遗忘]\033[0m 记忆遗忘完成") diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index a53237e6a..88cb31ed5 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -37,8 +37,7 @@ class BotConfig: ban_user_id = set() - build_memory_interval: int = 30 # 记忆构建间隔(秒) - forget_memory_interval: int = 300 # 记忆遗忘间隔(秒) + EMOJI_CHECK_INTERVAL: int = 120 # 表情包检查间隔(分钟) EMOJI_REGISTER_INTERVAL: int = 10 # 表情包注册间隔(分钟) EMOJI_SAVE: bool = True # 偷表情包 @@ -95,7 +94,13 @@ class BotConfig: PERSONALITY_1: float = 0.6 # 第一种人格概率 PERSONALITY_2: float = 0.3 # 第二种人格概率 PERSONALITY_3: float = 0.1 # 第三种人格概率 - + + build_memory_interval: int = 600 # 记忆构建间隔(秒) + + forget_memory_interval: int = 600 # 记忆遗忘间隔(秒) + memory_forget_time: int = 24 # 记忆遗忘时间(小时) + memory_forget_percentage: float = 0.01 # 记忆遗忘比例 + memory_compress_rate: float = 0.1 # 记忆压缩率 memory_ban_words: list = field( default_factory=lambda: ["表情包", "图片", "回复", "聊天记录"] ) # 添加新的配置项默认值 @@ -294,6 +299,11 @@ class BotConfig: # 在版本 >= 0.0.4 时才处理新增的配置项 if config.INNER_VERSION in SpecifierSet(">=0.0.4"): config.memory_ban_words = set(memory_config.get("memory_ban_words", [])) + + if config.INNER_VERSION in SpecifierSet(">=0.0.7"): + config.memory_forget_time = memory_config.get("memory_forget_time", config.memory_forget_time) + config.memory_forget_percentage = memory_config.get("memory_forget_percentage", config.memory_forget_percentage) + config.memory_compress_rate = memory_config.get("memory_compress_rate", config.memory_compress_rate) def mood(parent: dict): mood_config = parent["mood"] diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index d79ed5f91..0679c3294 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -303,7 +303,7 @@ class Hippocampus: return topic_num async def operation_build_memory(self, chat_size=20): - time_frequency = {'near': 3, 'mid': 8, 'far': 5} + time_frequency = {'near': 1, 'mid': 4, 'far': 4} memory_samples = self.get_memory_sample(chat_size, time_frequency) for i, messages in enumerate(memory_samples, 1): @@ -315,7 +315,7 @@ class Hippocampus: bar = '█' * filled_length + '-' * (bar_length - filled_length) logger.debug(f"进度: [{bar}] {progress:.1f}% ({i}/{len(memory_samples)})") - compress_rate = 0.1 + compress_rate = global_config.memory_compress_rate compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate) logger.info(f"压缩后记忆数量: {len(compressed_memory)},似曾相识的话题: {len(similar_topics_dict)}") @@ -551,7 +551,7 @@ class Hippocampus: # print(f"float(last_modified):{float(last_modified)}" ) # print(f"current_time:{current_time}") # print(f"current_time - last_modified:{current_time - last_modified}") - if current_time - last_modified > 3600*24: # test + if current_time - last_modified > 3600*global_config.memory_forget_time: # test current_strength = edge_data.get('strength', 1) new_strength = current_strength - 1 diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index eb0323cec..089be69b0 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "0.0.7" +version = "0.0.8" #如果你想要修改配置文件,请在修改后将version的值进行变更 #如果新增项目,请在BotConfig类下新增相应的变量 @@ -65,8 +65,13 @@ model_r1_distill_probability = 0.1 # 麦麦回答时选择次要回复模型3 max_response_length = 1024 # 麦麦回答的最大token数 [memory] -build_memory_interval = 300 # 记忆构建间隔 单位秒 -forget_memory_interval = 300 # 记忆遗忘间隔 单位秒 +build_memory_interval = 600 # 记忆构建间隔 单位秒 间隔越低,麦麦学习越多,但是冗余信息也会增多 +memory_compress_rate = 0.1 # 记忆压缩率 控制记忆精简程度 建议保持默认,调高可以获得更多信息,但是冗余信息也会增多 + +forget_memory_interval = 600 # 记忆遗忘间隔 单位秒 间隔越低,麦麦遗忘越频繁,记忆更精简,但更难学习 +memory_forget_time = 24 #多长时间后的记忆会被遗忘 单位小时 +memory_forget_percentage = 0.01 # 记忆遗忘比例 控制记忆遗忘程度 越大遗忘越多 建议保持默认 + memory_ban_words = [ #不希望记忆的词 # "403","张三" From 3287f2a49eada635a86ba0971673dafd720b87ca Mon Sep 17 00:00:00 2001 From: Twds_0x13 Date: Wed, 12 Mar 2025 01:58:24 +0800 Subject: [PATCH 102/162] =?UTF-8?q?=E5=BF=AB=E9=80=9F=20Q=20&=20A=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E6=96=B9=E4=BE=BF=E7=94=B5=E8=84=91?= =?UTF-8?q?=E5=B0=8F=E7=99=BD=E5=92=8C=E4=B8=80=E6=97=B6=E8=84=91=E6=A2=97?= =?UTF-8?q?=E7=9A=84=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++ docs/API_KEY.png | Bin 0 -> 47899 bytes docs/MONGO_DB_0.png | Bin 0 -> 13516 bytes docs/MONGO_DB_1.png | Bin 0 -> 27228 bytes docs/MONGO_DB_2.png | Bin 0 -> 31358 bytes docs/fast_q_a.md | 109 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+) create mode 100644 docs/API_KEY.png create mode 100644 docs/MONGO_DB_0.png create mode 100644 docs/MONGO_DB_1.png create mode 100644 docs/MONGO_DB_2.png create mode 100644 docs/fast_q_a.md diff --git a/README.md b/README.md index 5fddcb320..917e6e72c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ - [🎀 新手配置指南](docs/installation_cute.md) - 通俗易懂的配置教程,适合初次使用的猫娘 - [⚙️ 标准配置指南](docs/installation_standard.md) - 简明专业的配置说明,适合有经验的用户 +### 常见问题 + +- [❓ 快速 Q & A ](docs/fast_q_a.md) - 针对新手的疑难解答,适合完全没接触过编程的新手 +

了解麦麦

diff --git a/docs/API_KEY.png b/docs/API_KEY.png new file mode 100644 index 0000000000000000000000000000000000000000..901d1d137016b2c31f564e9ce521dda7260d4ee2 GIT binary patch literal 47899 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
B%RtupVyv;!YmMjf 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 literal 0 HcmV?d00001 diff --git a/docs/MONGO_DB_0.png b/docs/MONGO_DB_0.png new file mode 100644 index 0000000000000000000000000000000000000000..8d91d37d843cfdd9b26d4bacdb828a3c39665c1b GIT binary patch literal 13516 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

lZ6O>{wpA(~y6;7Q!B@elZW9 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%0ACC2NS;AK{ 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%0Anzi!YxUk}x2|hUy0VG3AmCO$$b1xZR~jJe)@q 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 N)O2rW-?Dn~zW^kh@GAfS literal 0 HcmV?d00001 diff --git a/docs/synology_deploy.md b/docs/synology_deploy.md new file mode 100644 index 000000000..1f13503f3 --- /dev/null +++ b/docs/synology_deploy.md @@ -0,0 +1,67 @@ +# 群晖 NAS 部署指南 + +**笔者使用的是 DSM 7.2.2,其他 DSM 版本的操作可能不完全一样** +**需要使用 Container Manager,群晖的部分部分入门级 NAS 可能不支持** + +## 部署步骤 + +### 创建配置文件目录 + +打开 `DSM ➡️ 控制面板 ➡️ 共享文件夹`,点击 `新增` ,创建一个共享文件夹 +只需要设置名称,其他设置均保持默认即可。如果你已经有 docker 专用的共享文件夹了,就跳过这一步 + +打开 `DSM ➡️ FileStation`, 在共享文件夹中创建一个 `MaiMBot` 文件夹 + +### 准备配置文件 + +docker-compose.yml: https://github.com/SengokuCola/MaiMBot/blob/main/docker-compose.yml +下载后打开,将 `services-mongodb-image` 修改为 `mongo:4.4.24`。这是因为最新的 MongoDB 强制要求 AVX 指令集,而群晖似乎不支持这个指令集 +![](/Users/propersama/Desktop/synology_docker-compose.png) + +bot_config.toml: https://github.com/SengokuCola/MaiMBot/blob/main/template/bot_config_template.toml +下载后,重命名为 `bot_config.toml` +打开它,按自己的需求填写配置文件 + +.env.prod: https://github.com/SengokuCola/MaiMBot/blob/main/template.env +下载后,重命名为 `.env.prod` +按下图修改 mongodb 设置,使用 `MONGODB_URI` +![](/Users/propersama/Desktop/synology_.env.prod.png) + +把 `bot_config.toml` 和 `.env.prod` 放入之前创建的 `MaiMBot`文件夹 + +#### 如何下载? + +点这里!![](/Users/propersama/Desktop/synology_how_to_download.png) + +### 创建项目 + +打开 `DSM ➡️ ContainerManager ➡️ 项目`,点击 `新增` 创建项目,填写以下内容: + +- 项目名称: `maimbot` +- 路径:之前创建的 `MaiMBot` 文件夹 +- 来源: `上传 docker-compose.yml` +- 文件:之前下载的 `docker-compose.yml` 文件 + +图例: + +![](/Users/propersama/Desktop/synology_create_project.png) + +一路点下一步,等待项目创建完成 + +### 设置 Napcat + +1. 登陆 napcat + 打开 napcat: `http://<你的nas地址>:6099` ,输入token登陆 + token可以打开 `DSM ➡️ ContainerManager ➡️ 项目 ➡️ MaiMBot ➡️ 容器 ➡️ Napcat ➡️ 日志`,找到类似 `[WebUi] WebUi Local Panel Url: http://127.0.0.1:6099/webui?token=xxxx` 的日志 + 这个 `token=` 后面的就是你的 napcat token + +2. 按提示,登陆你给麦麦准备的QQ小号 + +3. 设置 websocket 客户端 + `网络配置 -> 新建 -> Websocket客户端`,名称自定,URL栏填入 `ws://maimbot:8080/onebot/v11/ws`,启用并保存即可。 + 若修改过容器名称,则替换 `maimbot` 为你自定的名称 + +### 部署完成 + +找个群,发送 `麦麦,你在吗` 之类的 +如果一切正常,应该能正常回复了 \ No newline at end of file diff --git a/docs/synology_docker-compose.png b/docs/synology_docker-compose.png new file mode 100644 index 0000000000000000000000000000000000000000..f70003e2957942aa8bf7c67bd9976ca9c7244c52 GIT binary patch literal 173543 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

aYBuMdY_C!`uOK;RCMh@l%4r5g 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 Xdv;U~AJQ)XAL$qJ&kLTu`S||;tRe#a literal 0 HcmV?d00001 diff --git a/docs/synology_how_to_download.png b/docs/synology_how_to_download.png new file mode 100644 index 0000000000000000000000000000000000000000..011f9887656eaa12744e6c23ab7c4f0aaf02f4ed GIT binary patch literal 136527 zcmb5WcT`i`x;~7EqFVvk7NomHM5RmbvIRl9^bQhfLI^E{79w&~l%^uRD$+}&g&Kl| zM0zK*5ClRCA+!)6Bz*DQbMN^6+J_%wtg+@;%(dp6>ut~bymRHXnTa09AAEnXu&{7E z)Yma*VL6+~!or$*<}~xlLRlX3!g&+#`}fTr-oJm%ED+@3?d#6MqW?O@hTYbp^GdFR z`Cn{jbnY1}8((<)=RJcnk~@jl?)-K3fCEQevA}$e?BuEuPtVB0WQtO6Jh!o? zmJeIG8?VH|y=uoyOdcIpG!IKVRPWdj82eJ@+5PwY2ifnf8htrJRXp$ccnaIVdylDgZz=Bil4>{B|EYtUv7}T zaF9foiT=yTp=tBieMN8GiRqHZ{2A9xc;im}xclzLjeCVjR!_Q~ z&%a!Q5V~IhFeV;xVUOBMVSZ^F}4d$dy*k1-S=jYhM9v^Oue`9S_wmJxXU3cxKHbKle zmwfm^w zVnyA*!MgP>;6x1ay4jh1(*j#jF`-|UHtcUzMv5&vPnllpWB+xHYexKZ$_){r8_}4z z+(+L*NH|h%U4~> z7nN8z8^yC<#n8TH;I4HGZaHLJ9=k28c(FINg@MSd5>lxa`9!vBowQUk|{12z}C_A>r~lLgWuX;{z7CECk6I#4dK*wC~4huy>}3C=^eSX=ei?EwPa z9i1HPU^m=*u^lc(<`hnD11mTax- zWZC9j&y%u6^yiP7K>y)}p`3wZBx;qBC5jY}pL% z@!Fmmi@x*al<&!US1x6i%zNU>r=r++zPkGhPDhs)^Q*H%XBGCIT|0C3<6q}>gk`lQ zxFdd?dMwhWf8o#QAAjA}SAG9N|H7%eJa+LP{_=Ty=ZU!at2MpEYsGKR>5B#OhdtZ5 z2D?svblyr2Y$h`DhhS38BTl}=oB0+462YhK|0Shulb_88x_|%9*b9mq+5dW9yItc+$FCnluS>Oi z&TelKLN7bVDb0Cql<%Ev;5E|CoNG>h zfs<$6kN&**geOo&B}L*do@DW#Ek7}xHa|@}g>PkEGJL}$ntApb*OUcfu*YQN9E|8+qq} z+rfsauQXGfb>R8JpItstpPI{8$~R?3_U>nnX6ngd{)@9d!s^6+l^&t&4=TnTb zJ_8E{KTD1{e?l75>UKp=qh&n(d0EkJ=n%Bsg9gq=82eYn$pscgwR*+(i%o4DuvSVB zA*p`#>#{hho9H^Xt@N;-l%A74{5@1aJlY=}is4WT@r@sIQV;Omhe_w^skhg9_0lB=Ir7XrBA=^Ymo>g3|(vH^{~UvOXW zKgVzV(0^L@b-C^lTy}UIuZ(G&-fIj#-eFYw!fQmRzk_bz}{P2ZOTBM#&H z#+Qp6jMa>^((STDGcRY66k0q@6g;y$Gt;sfGN^XxwmSAb^(75^9q-*b5j_Y;P*~qq zLQ1_=&8T_#&2rDN@G*7bbz(TNMNyt#D~nfAK@scw8sR(TIAx4TBbliwwfMF?Z86*} zLAD^rqmrRZ%HNdx1KtNH%=@X9sQC;0A-F0iq zg`fh2G|yL%1Qs3Frsoce<8tEK3nvMaY?J?*J$vq^ylLplQ}Ufa+*){d!mleUh>^-( zQgX|ymLB9+BsPr$WW1zUrVkT9(* zFO5g?0kj`_njV^wd6k+@Z4&rkJQ><@Xhh$OQi$TB_tIZ5)DH6keN|MlaoId4Oit81 z`+fq{=sX=Zog8iJs_*LUs`oa;%+1VdfbLp5TQPeu+jgPht;%0w_s@R(^|AauXe&OH z)macO-6P`bJf7iC+1T4C+jzwt!d(-asxN<6A`rNMyVZT`p{0dWM@ithMzigY{#S&S*J6p@ZAU9 z%nPmYpWsxvo_8H2H4v?JK>CBfL+K6si?$b|FF>9*d)1WjX~Xj0qR{h~IV0v+qXpi=6PBz_M621T+}d_3{eJ0UNWqwjf8hcO$l<-VROFgRhn`}OJx$3gRm<3jR z3}W@bQW5lJ{L9ntn6kAp;^>Ld2qiv0N5p3D&0j_0S#%{OU)f1>1c6SSBZGh(6O*<8 zD~sxc#!COGWYWW_etJ9Oqu;Bqi8X?GAqF8Kqv+Ax((RJ+DfdPncMW@Z33(z1LEFl0 z*XZTo4+2i(YL_Rw_L zjWvCTI7s)ef1 zRI0c4E~CVYt4=%9H~Gku9mGdiHPDTjMz9>o11tHf(z9XP4|Q0$k;VnNG9x-Ii7XC- z&$a!cXSx7kxGBO6cujjYX7|-57g_yJ^>BtQV-G-mFIFdZ+0;09Od}|4Z1UoC(0)TF z3api;c{Jd#)iOrrcNuS0-g$K-OoApKG`n;;a~?JwrZCELU`>qCG2vm@5tu>b0vX^} zQI$|k6l5K`MJpEeh`Ir-IDA4YB?Rp)wVd2PdD6TsUTaVM*$Kkn&5O^pAguQcPdq!( zpD@x{w*AXh+tPDVn~ibNI_EF;I5ifIjXX^R4+A&N=86wtsmRyV;CObGkL7GGOODLr z&zyOL)BQPJfB(xfhCHw7zTo)`W?|_$1V0qQu@;tzU&S%badgH=(fyp;m*3(Gy0hdSDpp(oaG z-fx|-(5rhfP`vgte*I_XuDt)$sQ+mjucYed#+P!E0)QGN?@Sl@#f&DEFYh$;|Ayc(J zN)NmJ_xpZ7+o-fXjNd4v?UZxQIckK|^!E=@IH?E!INX1xG^>6=ndf<9-;0Q=t5!K& z+GZAENfu$1AD!Q7A9LvRcFWTt#)NwG`QWP42FANkh7XtZD-Q#IT`_C2j( zyiotj$!9#^lyAj+x4O#z-SCeMPZ!sU6ZYQ|g+r!gST8n>$zX}`Dh|<)E(YKpk6raJ zx_4g9Pk#Khy=NV9S?r&c4P()Iu){$3gHLEssk%VaHXEZ zL3I2W{$z03-CPwQaRiuL?SX8&`;T+{kEo)zwO#K%FGANYdIdcoauFY+&xeb7s=@{) zHJ}!kTZ4Lo`bUx?%Mul(wmg9lk^J;O-| zhK$d70lCX3%sc}Vw1Y5HHuw6188XBVvVq6280La*TDwYowTeM=u8Kp{y?R1vU6b@J zZQ^d0Tre$vw)7(puZ({ykt-7mhr2{HA*1N@Nxdwa1I+uRkjm-`cZp-YlZMQ8ozsR1 z2x}B0iqz5U0z>VlE0w@2rc#q2&Qtapz~sw68v4M|um6bW>sF%;22H|AyWAFipS@VwXh&3*H1C|4;(=1bYRfr@)RaGJ<#wxJ4=m!V&)D{oqqHR}9m0T(~ zUi3Ih>#upn=*fkV{Q3h5#fPiK!=-jcu_!%nWxU1&)e59X0oW0yPuhoUv8zgL{~oSl zLen1U8V_;Iz=x2UO*4*Xxux7!FBy%(;S)0zX=)%uk~LXLdNM1BJwXx*3`2n@(YrvO z(`FPwWdn>Yc15Y}cfTnw1eVgQ-m@14_=E&j-fio9C1}(C=;m>jbqRg1JED^=A|yJ?lfpu^bR*JncchZ?$`HL z(uiBO=&|jC)4G)Ns}?IfW}}L&z%?0Quk7!h3d92$!eaL0RoNmF3V3xlicprXNqP%7Rr=D~Uit zGcbL#*sngBBhvw`fcdbCh?D@RZDaU<$gi~`0(ueJlxDaTrZ+9TlU6@Uis|ZYLxk6* zSh_USjqZEVDYov%S})t=sR!rY@QvWt_$}eI)xc@1(zI3=2b^Zf^qNmO)hBM(ql{cW zfwdPl^&kEcG>KN2Iz38kLu`EkZq5P$?Daeq;WPP&MSJ&(?Bkin>&weLH%7-a?HzDh zsex1D2t?>s6zTWuQ3x+k(Sd4j8#e{fUcwzy_VO{NA)$O~HPEiDE=7G(PdttB9?NtQ zs|=4OJNS|k?UQry;&?OMB}_f&ptA6oDtt$#=vlD^mx`B3ExZa#uq~@fvi2iN14V^B zdc)UBF)Iu!=xFL{O?*}sFn+HX5dd~@N+b{uya+@19wPWOWpw_fB&Jc{ z9hY4HjxyxDwYWDVb<8jn8a?>@Vf+_BV;AJo(%9)|Su_F1H)YZC?!GU<=VXn{Xrp&}H4w0hXcka1!x}a%fMMR^`}u9!fXT&C;G@J51iCYKrcE1IXlimCr1h-* z#~*d4^c1F6!<`b57taw1q|afD(n6^3(}eA)Xd!y#$KMhY9p(Czl3|MMZsT46~ zmTvK%R}t6xA&VJJv*wx0k=izNrs}Gg>%gB^@P+zQU@3Ru_lY!qu(IY{tffX)T!?3&CH6c*a z&{z+^==0#DX`(9KlNZ)p*dT8YBYPp0@o{ZY^ouy> zDxR>11inY`ZXo4uabH%@YMb9POH)9>UHbuxT9_GfU7WDF@V)0YU@qX3<7#i~4mPY- z-3SRZs(X06wHw9XbC9vD1)3puQq#q+GT*y`PT3pmDnv;N zBP4}xp%WF`Dp5TN>gFg}u?SNtwH)hneAH%=FL|MCVhrv)X($A->H>x_16Fq{0-+6! zL8pbi)4Ky;PsxHl0u_blCKMo zg_V^%^#riPzl~Ehg*Xb-_-Y2mcxfB;GMZU59hacG$iopzvtvJRk>ethpqX6V zy`^^tt7@l4_eAXui+uR6ndrZD=c;kO!p3dWrwFEGNrmdS;Iz{zuSjuAan*Em8bdn_ zhm~j;EvPghOyUp|xS_ps;AfT&O?4@)j-?|a!lq3so^|1l4W2zU?%f{rKJsB7EhuWZ zCqoXd8g;nmu9qlam%JS&W|Ex_SsSlzYEmzs{&LJPwtfE^=A>Yjg3s75UO5keF2enw z#hx(9rKv#mf#en@R3zh%Xt1N0Y~gOx;qzs0t`-bgr|Cs(q+84-uU3O29GV=(7e5dc zwwvL0i3jNxQwfOr(Q`^n-D1vvbU(C9wpaxuK?!}yi=sH7z#XZQ4vZ+y^DXX23_5?1 z|9I=cuMcOB2QP@{7V>NEpW~5=RA*1cZ7&xjF|b;Ud95f_E%4d7vh7<*#WUF#OMpK0 z^=SxIVN1VVU1UZVJ$TY^)=HzKveE^nfv9wxoZRx_FG9wbV>qA2MUYldD@9pXjut_T zMXkQCZh80y5FVYKR=>JreoVy5_s7C)^~Ja=l5einpcuni^x>mOJd{e(=rChN8Vro) zhfk#=5VqmzyNit$4I?=YRHbfr-<&j`kjY8blF7*~*4{Rz!;BXv{6`OH^|}$LwqRmq zLscVvck0NU^9n5`l8x|b>%uW((wVxm3nG$K0&_x#T{dl7diec5NJuW zlAW`n66AcmROSRL;Ii!(*RSPa+hzD8?9qV>j50E27=AL*{g- z`>QmY<0;Lv_ViO8J?ZaKrJN2(M|;9Y^QiG=FfqFI9@{OB?Tgr_{8(SI4SdQ_WlJCY z))KYV`L6hAEitaWTvdTs2>&IR)pNkjNMPx5rcQNr37JRhWX3vJ&ZGaxl$}ec z@kwtPp}45~cpmDSOaN;bwVKBzjF1A`B{OiSh+FWb!jv3XKjs4kUr2vitU^B4EOg){weuxyQ;lnjgEw!aA{NW;Q z-myrf@`rWGZnJZj=x-HVFYl(L`!%g8E3US$$0xrCk96`oPqZvR30bU)N(Y@zxD8MX z(;<>3YL^YaxjL9mrM2x8N(*R4?isolS2srMhX|;x%$gnx zKC%@VxjxWr-l8|)LZ%RE>x}@PZY$Hex_X6_bS7$N!uYItw4=0IA|=b#r9vugv#@fZ z5Vw1zu+Cn0V%6h@JN05#zJ zSpgrZG4dx02R6tc!oeA>KpWCJS~@z^5>WP^eSi1f@p2}X=pfx|ZdupSDLcvWHRt-#J!gq@JSAabG zeAGl?^k^ZumB+O9lI#i~psh@gLtg>v$zLy*L)yMYWy#G#6sn^g};I zuVg9<>TaZg`ao>+2pdq%siw1HCT}9@6{N3Ht}4w`YXpH%^j%ok!q0c7fINf8DrP)m zs>`EmZn@WYOPd>V8%w5VDs1lSUBaj$n(p-ozdJaUm~eG`XrS3up=x>~U;QPPjyvd8 z!nfYio1q)j=UzXiukSk-9SePPwIs4uRP)Yy%LkDpODrwLG|~5{wqkfjEg!5N4WM)w zG_|dqNF%P?2{Hhf(v_m-l^72bThA%KJ{DQS)Wno~KQwjETY`r*HXk)1?cgsFSGxCp zilnsKtp_LWV!W7x%oS8x9q46Uq`Mp4PAbV$4w&0^*FI)df~`*}+CtV03ixcIVY(u` z6Dr#FbRgXW->w3T1^_Q+0(--!bOAor3Vqk?xW>C}e9bNY z`jxU~a;|de^vsp}R_Kwi`X8}w$OplS#7ZD@BoXT>zL)lOe0aDmd|0A?oS84+`v*h7 z5h!roarG|^5Tqm&g{gUcdeN~j-lUgAl0`kr64T!3-60V+4PZHG zZD+gLPbH@C-wA8-`i*rpQyJmD#fMWU`joJ8$l7>oP5SXNC#Fu7-}~{muF*&-O_*DH zbZBO`gwp!>pnLCqQo7tNf8QUosV{9C$#V&tl<{VP?_GRy))lq^06^}Y<64FTQ>7l^ zu9mK9uPT={J92nSCc66)S?4(f#JL__Awfjk9XkP$wT`Pfk&8VUHYvKtjyd>KW$7iV z)@2{&Hz&Km4u8cWJR)kH(VRV5(SVpMydySJe*#wXyvRy$KWPFlCP^4oA#wBA;+68h zbF++J#7e`|yF631V@N2BJ}iK(NDJ%TEuroxoMgHtqbCmi9j20wldAjgMD)gt7EV>_ z9t$F|MAzOn>(HV6Fi==8-BHLCD^?wAuil(->=6&jBIJs74}!PD`7DnKdi{mY?Yw@y z%42FZi8=TI$W*eQ9nbIh1Tup)ZAJkMB9MLY7sX7@2#5zBGxO?;_${-_O>?MFRXk}J z=Up{lHHqeMEO40}Z-1CjzS9zo*wrS6957+2VncP?|AQ{vx%qTtuXOdVsX6$k7MBQx z`r`5M-l%AR1{3{P)YVG`mjw+Nk8T@;>Et%JXIV+z_bOsJaB?*>vclP<_5Xab%iAmD7dY+P^y@vQ z$J}r6d*#zh{~ONKFN6|CvBL#P8t+0#Gs#;=ajuRKbtVZ>=ezR0&V6`vZybnk6 zKZ;aYjP1Jhst&_@F(3%+FantVTo{h2{zy$Y;MKXuT7Gx7j^Ijlz*JK*X64>70ZMy(j0iyN0rwaa~05mmhRiEz2W`e znuh*|Rt8lSXzH@+CFSTtH0s1O_J=;odyd5ilMYU;lPpcHF79DQ<5?K*ea-FjYQddujg;RME^0T5UXUQY zZSF8@Yb2!DF&-Yt1cnvE6?zd44!p3LCCoNaBl0sfeJ~>o*3eXoVT`kLQLE%cq-8;U z<5*q}FmaoG7_cukR4t$rLY}7$UFPXCEKkj47!@5fbx{)Ylm|D}_t!*}y0TKT7URE% z$S2q0P@d$rU@RATWg0!#5jVt+hjjmIX2=JQ%w;KthW;d{MvyJcz1mCSH^AiUc2yr<)f3+GAb78e9C!kp5`2!R2IIIQ` z2tLY46^o$DAyFTfj^TDgI3<1 z-|R#t`#=ABL3*WUgx@{*mD^rj8dZfLCNy?haKtV4_fS=fgHSduccm*G?=EkzH*__P zwn1r$!5ae~`;04{>jS@m)_=sJAX}n=Z)cjaC;iAe4)aZi7uFM4q65y#!?0PD0LiES zCFd?Oan;!vV{>T+(h-P0cd@%QMQqTNR#Hlr*x)aFaAAnTD#Of3JcvZDutA*$AD4Y?w{%PJY~( zt)&fDDoGggZzXys=R~!FLE(R~kEhgKXRqj}7rPF`SJ$@mJYh=&^Vl2*2&dnPj!k(p z`{{ieQIS}vu{C6~)eLeZ$gV*Zswl*V0mJ{!(LQM+>w;qeH-#g9xvB<4t_nr$R`9Oe$4WmG?$LUO-f}D2YLaqRP@?4|hN*`x=u9?I zy(a6aw$j##-G?gx+R!3++d_S6KXsRc&*KzOA8#Tx1#QboYwSDsH7xm~j+6R}uw74g zUwHSj_*IH)8bY?9H^J(Gpq$a{_{vm9x_Ma1d()n>rS3cXR(?>1Yq-r!uMaaV@0U#} z=v5nL1R&FIM^+`ZLLb@>{Ta%Gc@{-u;8w<~hf&h65(P}*OSpC^dzB?4yP`{{)N5_9|2VdHPNe=xIRPt96xxR51mWH zE58pR`K;wDEZo;+8TA1PJp`?(q{0%5&g9y+d4(*EcUl>G7I%;RNOY=wP=NqY2X|b- zb%5!a2)I-B;`t^A>=P|)EWe+;Z==6+^JdqJ70)Cxab+Unn!waaj9Wtu#zpxp0vV#* z(FolS9SEJ#-S#ZJ9OqeROuX{h&^jU8!Qlls$4#$^tnsCI>XKTnQ={7gAMOP74-JfE zmGgw>$m}ALwp*wtjLV#f-0rOd;Td&LI_K)1j+WJV&yRY4eSRuNjJg8-)xk47Y^s&K zyEc)$yN*j^;<{lcNcW=geR#y&kJmfX2;jpUcA- zhnf&s$L;%E8re*9otE2c_JKd#UhE}$Pvt=%5jWIGKlNhJK{-E<9BM)z+Gm{Ht(vG& zk#uft^o(?xI2fPbpD~36r8x&av~LM9cJ>5K?j9m`4!5O0-+#qfXc$OS^J^>34Szrg zZE$GNOq~gxPgrYE5>$&)clr_z#!9b4(fOz+CQfvt8BdfaR}yf)sewRk@xiVq&g=3x zjZ{~VZ?CIOGa2^5u$Oy^(rtU6>eh!0?Rz2QnVj6vt?<;u8L={-z&%A`;xZ`i7Fzto4ua z{o25V^evWZG^J&9@QBc?7TohfRiaFa%ie1|AZAQ?iMA`Ko{K<6DLdt!Y&5^x zn=;z{AZqB`Vy@;`O)l+c5c?39aOVl7QBL#Cxs%h2fmJ z3Ol)BU;{2w5j1JrjQ)V9#Iy`UsW`mc(rP(;?d<#4_marWi#xM1Tn);|=?vPu@X-z6 zM9ppJ!gt|SwtwwNq>F2{P}}N6*fmpj)*WV2LKa-BKv1GuVQI3kM!4_ate8Y0jm-7E z^_-mi>N9QS!-mqitxH}l3-9BGNqPI0_}P}%p?SdmsD(J}Xy|%{UGw9}{C6qqoXc@Dz{H_3}7aJh!v70@yat^iA6db^*SU&8da)HRcj~rV$ zXmH6}Z{4QlK*o1s1GKuZdmFDb(-k@P66e%n@o6sV8?LHR74*p2yraYDI^X@8oy{bi z>Rj6@?R7h155Xn8N{>*^t^9mhJhj*qwt6wA9Wi9RbaB&Zj5tc*aS6YTivW?y9il3> zAkt(#<%{3`$5+DZyu#5Q+(x|KWq261sPEn1Yza7MfB%DN2Ox(SbXWS% zBmzq=D01E*XZBMwG(C{5MputRj74NFJ8i+N!}x-^0aVH45V z`Z>0<1lzY!Dft3{G$A&tvxXrkh^GUVw?VK+&5b^Bv=`SW6$sQer<`(*+$`L|AFcIA zgrwb}U)Fvj#=PSqPs63aR$Ej~FB==Uy+u_}t1{RI8463uVav*;Z)7)OwsCHEO z&_l%4`N^oZhEgev2>^IIL|`g(^d&;-zigCbrYe0Qv$i@vbnonUPC9J#$Jz86ZrghM+>KD`tQyw`ywi7v^KfdbUz}P( z3PFqa>dq`YE!sb~Se&un*zOTd8O09e;8Eoh*-1C1bK4+GPLc%QYzD_^a8>wG7M>T29yHDI^a|As8O77v;` zqGoh`>NWu@Njc~_TZ~6$2E@}xZA>ol^e`2yV zv)UL5pj{InZ-lb&#rRrSzR%;e)roAnSX6oD5ayG@$1-=r-#WqP*vNcAtqSC}OI9KX!b^>Et|hMNg}_ zSZL<9=@`UL>Jr9XkzB|tV2wr(<_GL}=A0(6geuO!pdbsU#QEEX&Gh7kLKnt#v8q-c zZ3nZaXzlvXC83>*w~ox6x1#26)+4it5NnW|dESPAow}P(Roj!Oa2fVcUDbC0f1z&7 zwV6=DH*U!IbiL1SSS<(N!AGz8YuX18w~L=Em{cMnH`jt`lHDyBarEGu>h7^^c#fu~ zWa5)ZZ`!-y{mB^-alPq*XD>S=tD@5AjQbHoLodXVwBr*#U1tI5MYSQru=T1P)hWVl zQ41zcT0PrYEAK~xh9$Fj3KjNkyY5ejwxW(^*IO@+uDl~GcNr5Gizz-!h4_P!Q#pNb z#uJ;#)RQL+?W>=J9L&86kI^x}e+d&*Mubzw$KHrgGjBiF^=te^TG{HB?pA}6I=`y* z2Z!7sHW*rHXt5$Qqs?rboBqOWRY3~RN!jMOc~*PoL8YML!u>W$ao656v(lm^Ta;~i zQwtWIU{eS|Zs+8FXq}(JLrFfRnvBg~uY@0B zU0_c3L0>x|c;4})Bnna5kXeV;wQun-o&ns|lS(Q1t~syg6ZPu_kHy+0>*6y;2jf6t zW1=)ly`wK!8ntEM$zG2UifC1n2#*O8P0;m!yAs!jnv0Bg@}V9D@>CElC5Eb~k>cpGSoYP%SD6+e%p;?j0W zYJ%7JFzk%I+`P|IesnKL=NbQafB{jSnbQRO2nBV#q%etzrW{vHMATMopOm+|qk!50 zE>e9_`e7V;1%{Hw92K6V>r8 zcz86fSu3x}-A>6%V$3BYCPUL7_w!nr277o+Ms3GA-L7pBpn;C(TA3_vZ|0ChGH@cpUz1ekt4_vTm3B# z*2_Ru|7$eOhRR}D)(XvcNs;mS)%x6ywE^>^D+R*){>9Em)nMXE&NpV7k>%W@eHZ-Z z86pX`hFIs+KeyO#id~?^)5?d>^`i#P`j$>*62^Pi#9j8EsydV6uMjb{dS+HRs#Ne( zYNJ=uwY+}Vdhmd4-HRztt?iXdYHvqNIlIcHh+7FtZ5nXu#14X3W$ZKNIB-_Tp(O;U z@uY`PX(eNuArNh2;{vv#?$6;KT(jHG6^8CCM5O*C@-GdIEXT`vkWH9+`afIaB#9J&u82rRdOo8;~7 z0YySS#(@G|I1zWeA@wxNI^A|RLHR2{=1kogxP9C3BN~2+o*}msJiGn`mKMPEP7ye* z?3UN>nHPo_wmBM{el5YeJx4{ls4^om9_jVZlxcg|odFsbF8T0+g`l#vkf(ugjpL5* z5_jumHT#rHCyb<#0iP|^_J88g;W548r}N5sy1sIgryFk}eTG-^M9-P5|FlX_kEM^~B6L<9iyc?z`(6yl?NS>7A-lu0J^@W2n_Y+UNd$z0vCtK$)@op_-8_QTAG^}O!G)eb7$cDek^w8-sl;FxN8kt7OXOWp(jyklwL`@8fB-z zr)jlAj9?M}HT@+A=*M8lmht`!dtvP+gB_Qo`CmPh$Vr=`*+snvusWBWifKn$Wj4Mt zYr6^nM~103in4Y{Ifva%mryhaKkOLR{xjhG0@_fIOTg!ifO=%>#kW_Yy?>K20Urpz z3dB?ia_W-suu?zho!oLq-@Y|o@6OVKI|Y)tAo5p%>~|+s3{>CeQ9FHkF@{kLr1;Rc zFfqUls?Zw8jkrx73G$JTzqaQ3-H>SU+lRXZg|0^#h0yEUB(;!`G>4|Zu%%_B-}hTD zlwa0lWPWu7!`P-<_vQ@27N%j?s+rlsWk)8t(4XG?CzSh=mAL@*>Q{5_&|1&_nW5=U zk=Sa^%u+P~Pbn8{`6PI_eJc%h*dTHM6J>sK!rZ>46Coho(DJJeOdON1tyZ(juw_T- zPUW@+P3Fu0jG!e7u1FuPcE=P!XjLc%P(_~{lM~J+{K7rQO{_L*r3Rc#N7?VBC6*NY8JN2<>aOAD{DX{X#ygXg4O>B*TG zwmz8L0`8x|2x~Mek*0%G2TFm{PwI>pgA~YAtY%}4n7_mUV5#oR#v3Q}YJsLm=sHnr zDkW+kDz4hS24YL>%^c6hq!xI?)Ny^_ z8jJL0)Ge6GBueL4k4X^VtT3~+gJ(fLD46*cO zZYBmsTZqxnjJ&kb@&nj+Af|O)i=sBPD|r5Hg46C?7S2NOVH14v6o`I6St)Ccn@l#< zv=XXK#z=*7N7}Mp08kss;ccqIoy|=_|MKRY4$-=}@03dXpdJ!QTc@tC36c0t+-5TsU*hYA_Vt}`^|9%B=n{(W+ELP)%y7nq zh1)b>1NZN7?%KbF2>J-g@ot}n_5=mOM~=+p%iABl zD<~EO03?Q~Vc>!?55!nRMt6*m699~?zf!yD%@M#LV($LGW4jP$&eKBv^ND0vReLixf?-tW!%C)iNrHY zXM?L$k>|fOQ&0_RuE-tmmhOq9Yv_PjacJ3q@{xWNv>Iei$I(^BF<&&eAFY zO5oiO8Nbmc|G*U@TDC&ee{I4ukJw}>uyiNE)OBJ=)O#tbMKu|YLJgv_}d^^#u+GnD*wP0deMQ#|!m89PFMHV)Gzh zDl%|RfjF>p*8}b^JYNA|)elJ&_4K6X>^$h?P*xbG#^aCvPJ`-#o?M(*(%B3g8 zMf4^5nCkGZSZ?TDjr1g+$SE*(SHfz&*l+sFW9S=z+SJhNL+aDD1;oY&-R!%{p!M+> zgd6Eh;3V_kQVF?Z6?Qnmk-E6Vz82ho;9bj)`)i(Cny;E?FP#scPso8LsgtfMFlue1 z^fIpml!FpCX?~|GbvJAUqz@QWWO42GxCN88c8(&Xj7+acR)MeQMbJnf{JT^M=WMAK zUtBD{{%Y;x?+a0BSxP_wZspx~T(Dcn-2;`b|LV8cia`0+m zXOP|joY^YF!Cz0655Lmfi{43tQ3xjYOzg;gWPFNL+w!-%GSxnr5aX;Ak@HD{YGJ{S z@yOp4Clh8Pjk4<06W0!>J+mTT#KDQ zHB+jCyIW>dKGA2t#{90-Q0m~lUpYi)p8&hm6JI*nSawSUBr7gP;i zUcd+M!&{^olXycOXXULZw?i?_&FZ?Rgsz=j>h$~dHX3Gd2O?YFlqQv>gtK;|$fer-N4R zDT365c3we_Zs8)xP^Xq|X_UmjQ^*+O2L}mMXb5OQf+vcx?eaOn#wFz>(StJmkZnoF zMXs{ydC&jH-dhI6)ots-!2-d8hTs<5gF8V21WRyhtb@C|yEX2X;7)L8G!U8ucXxuj zd-zuNK6~GDZ|(EG`Tw1&Rn($tNv}D_9P*53jG4at)p6K*Z=ZE~L6&(6rO3qM%nA~I zJ4Uj92Fb8}$GhkzaXb9Dp(FsOZM?u#)v6s);w}nc+OR7fALJGS0X3A`vMT6n;rrvf z#mXYb5lv1ztMS6JmIu^z+%Pe=uX^jXoRNRQQ0U}(VTOifX-UjGx+4=|&t|yhhRCBQ zAi7xVY`f(C@#nsr#;}6i>4&85!rAE0(vS_YlzUFosh!j|-qT8L(Iu$u3{9`F4ONb1 zIdApvl<;`(Wr1}S1f3)jEJzJ%cd<#&0zpi2;Xke^F2w zl;$Iod+~vsMy1SwEM5VF==2Z2(m-TPm-#u=6g>~wB?hZEm_G$Z4%)Y}b6gt8?veL z=1*i}D%t$&9~1Quw}c+M&~|RD*_`gyo4SsCu7-Z6ALgv*n@)BCngI}}R^pmDJ7zD- zFE_7y@_*TkvvEZ1ez+y@t=GvZ?Shq6?mi=lTz}qiGiX}`g!Y%&Iy3Fw(h&w&_7~mCe6yeUQY8&Fzo1_c$O<* ze;04>e!KT-LIJROH|8J_qt|1r-Jt-NWBswnw6Vo?W2MjJ{YOMnn2mRJt%j^)8CWUf z%8_>2n563VAL$_P{pulZ+uvZ_oMl5|zsuQR2G0c|Rd<~u?^&8cywgyS_A(# z^t#MxcojlE7lDYKR@#iz41IQ26HKG@6e_clw1#p)(#{E>U|9OoZ2#ntX3G_+obRs( z-m%-(U#*Tok?%*OLg+s9(3Xzj&-?zEFbV1AD2&HcCvh zWvkk5J%-Quavjc+$G=)+)mL_iNPW?ir8~X5b(6Si`hI-9&%;L6ry}d&j@ZisQbKyqMZnEj zu$gzoYJmjr;A>dP*q3j}v2|n`5me4`y=Y!Nm@Y~yg4jl_RH5{hxi0J>rf)1KUF=O4 zO_phF7*q@IwBPw=>v`=IWP297j#{?locN?%Z7oibxgCEVs;5dl7F=$o24!~~Y>s}h zC)orjNvBsnV@y6Bdvy$tl`;uz$3Mr13wv|CXsuf6UaHo!ddY%ez zt3HKXw8Y@NR|k;xC5HoSiYauGj2wi%sTljBQfGpJgZ(-+1i;DS`*68r` zq9&z$H(=Qr5T<7GY|^yuI0ahLwmy))R>W*OCR=vk??fcr)LMVrCw_jR_sqYK^` z7a!K|Z@}G%5UCO6XLFkERy$#NSA7?6`@7b#XJO#55# zRzs^2!30%BIX;D^SB2f7G5No7<1-Z)_gRe3IW;xpgV&azt)i0fgOkU@#lhlg=yOs< zfEKWHv%{kv?0&XYu(v!#e5~ReLl46=2Vk|{vB&$pjvfB=3&@dAm80rH1U}PPtNYoQ z_06weo3@XK=l3LRxNs&X<7^1zO2+)>;&(e*we$A`g%rR5}q7`j+-#W{3zdP0}IUr8Jsh8)>JU^C$V$vMKH3k9>~ zYUyro)5d-Ltv?tPCCe;Sa}n;uh5THa)y@j%GmFAsa8_3qu;~2_X+$kuolm_QYh7*t zb_dF}_5@%2>8{*n(dKAq9)r~TvAas0cvoB^p;K$(7n`7rr!i?^OW_`soNXyHqX#L= zjZ`e4nHFlzYoE)-{_%AD133M`Js3mSQHzT#bhG9%Yo!wErU!j>bn8-Reg{-w_9deoA~}{z0VX2pqlsFb9)1kR=IiI;@eKhf3G=d`KgIytW1+V>ETn9z_d# zbOS^Kj0Aw9obB^4?ZeFixPqyxiyYOKGlI75%{nn%qtw#l8XcF>LmXKjmVuz*_9vZ4 zvI+07NAyrKp0ujZuiC~K!B17jKR9?n-jhi?4f7)LM}}egKku);dM-Uwp07Ul{ihCnI??Qoz}2;4aA@UqNWTr(xNYWO ze=5d@Z!nv4LBL*U&-NFr>H_hiN1K>Oe!$h;e*Jn{v&H?DRh?8s*3jHoX{*XysoMHu zOR=?r^#MFK*Nc*$2YGckf0-FKFhUBsBRC9i+pN=Mfk>9C)RK8)Z`a?9-RvyUDUz2o z05SMi5$q;BIQ`z39Bq!_4DMRjhsKR{EP~;jv_nq^-?=!~_0kwC-Asg{rDppNbE#GJ z7r_X^-WT4377LCG`kW)>WaO>@d-Au@n7_4;#%5UFtGUm#pZK}kiZHWlT&sfk<$^IQ zyPjU>Va!uKKQu>hgg@6|D|>0N*XX00)%TW;Heuw2p6Z0av7+UAA>phT1aGJLuji*j z-8Y&o31{mw{f0(fMh6G;bhJP~3y^rMZ!h#8CjqT|&t*L*7)UCrE|Pt(c!mLT9pv6w zJZdg*BmqYOfyng=5ktU8`|-_iK`6P;E8**r?g~L&sm_7IvMNR1CAaQQgM?r-g2Ilg ze*JBdysP(mmeTm_1S!!+bS_AdPqMB$j@lR?aOEn9Io#s|up^1{-m|vju}EQQ1z4Q7 zz2t`HlDC{`_>B2Z02-^zZ|uxt|0F^1*Liu?k+E@miq6u-P#ut&5C#>zF0&Wq)IKbT z$_l%myFZ+&)Eei%JPvOyn;6aO^+=8;otye;8j**$-qh2;_2I~I#+2MeciuB|_EdOk z7_Y_mf$edH7vM}ie5vKso2_Sq4P*(@fqWn4#{mDBi%P6a53J(TSWP|qa6|tCP-#@; zd!G4+hl%S=KW3`;p^YG3DzkEQNFF=epmgq%^YrsErmFTSM?8zDaKDZuCJmPW#^sYC z*K?35ReS%nFkm@S#w$TBf*mRvTg!uJXUk~T<`=t@H?B7BbIoUi*@t$7i6P!q-4No< zhvCdwVw43Q(-Gl4>c65I1En|P@YotEKJP3is3+ZPbvGvtum?XhrZ||hx!0vQ{B(AQ z9Xq!)SKQ`2k2O(S zP%gDw4NSD-5k@z;0(PEOe7GY0Vd*4tPJ%QU##9>W+}{fU9G<&++pSOS>-^urZ=nw= zjRpJ?hkg^Fd?iYkFSGTYu0Xu=j4{4XP!i7ib$HZ1#kt}8nB_ZK(R(!doB8V> zh&&!oBR4j7KmNjOCTm1F?1%!=zEJC`g!x1`ZY@K%#?4+k(-)S$=4#VRY@6R5`rP%9 z4xTDQFe$oWGtDQZs{K-1J28+OdC^yQUGyu?7op9AoYo55V!mu?NS^O+tjV>|#l^Vw0e4*sM)H>!{t$w{=yjlI81-id zNu>xT$nJ20bZ38AjAl*@RaHGu9`*6E6MqEVRB(z9=v8r5gXTtH25M9;8f zLG{zj1Gn`-nm&7C>YFeW>ki~1O2ffaX95PGe zd|(B;LPy7h-A%g$%$Q+S`W<>BGxT;Rt`|eRuFh|hI;%buM$M7_9oU)52sbLFLUSO1 z_!Pye3Sz07h2Z}R7$W(?PKleDS#S5PPM{3@vtz#TaQ&$b_{gnSL0Ey28mYkw$_UV! z+NcGGKG975O^G?X8a#Y&;k!9!6m%>sKAtDi1aR=UtjCK-OS6j`Uz)Ek0D2lsuz-8o zi)o7OzvB@x2utJBj&-In>kQzcY80YK?!QSA%P08(1nqbiQh0Af2)XEz@S)Y)^tgS# zDdcyYaPSF?>|j{b`O;OpVB5M$OEd-=slQ%8Tgc>V$X^7sK!u%x*vd@v<=chfI$S=F zeNE_je5AmzuMw1^U86Nvl;7xJ3NZYE24B=#n>wGExAjFiyQv zHx8iHC9<|(|6G<@*O_GgODROb;>$rqgk&k%_P4duAZ-q%CD^pDTHF3GJ$sKxt86fs zjy7g0@=y}4>o8Y&!S<{1(Rog!1^KTtm{0r@;2SJYX#Ru}4P0Z8{tSgA!-Q>N{jW{( zzkMsQl~mh*|MNE|n|F;kkzSj%kuTlGd`wgikzlCiU?W*YyZ@}W) zB>wl^e|^%)g$u~0i@Y z|82h52LX9fs3kbG{{uev?{`wz4eahrvr2EXi;?@AY5&*9_=DU3@Doww zIpO@lA#3|4Tgnn`r#6i2sLm{l8lLKj^UkpJ;J* z&vfAr1v>6ij)>6q-kn1Xd6l%Di-u>Ug!T$lq`F$T9S;3k49Sv3drus&IbBY=IeEx7Hb`S@h`5tZ>3u zWdD81U!8OkqM(onBDDIbth@CAQ&ZFowC@T=+vl1ZmKFRnt7<9@j0Iu~c|RN&`8la9 zr^$efy=1|kHNW{UXBe>~E-(~x3tuVU-Mz6|i}OU0y=vpnMi8iOjZ?t&(`2*1J|0zc z{?*#-T>Hlj0|)_c0Qa5|LMAw+Wq-M^h1**5hp(kz=lDHwu`XZ zKbgXg6>x?HQ`1>I z^(V75e|Y`>PQrhZod0|Jfk^-oH5vA7Ds*<|+oO1*09Mp5+B+VOd*XO{%W0C&1%?f9 zS*qU;yaUL5hxhV}i_L#id;G82Nnij3?zQu9bv^a_{oiyjpaa&;kfKz0{Q?*b06((yeKdI2t{B&&N~kMd$3)vCKxM)L57w)SW8h zs8Y+t%_S4xPs5^sQDgN(ncv|wii@{FCcZF#|HG)JzHN+0B#lhtWsBQR>8qmr2*FVn z{@D^`?M%Abcaf^^lbJvu(5_JOtDPP5wXu_zuf*X0UIERI(ffjd(K({r4*Yj}`P84FRGfqwK)>ElcwFV9- zC_RXqsN*UrIf#Wq1)81D8dTL2`!Y|p;u8{B4oLNWjHJMgX}$&q^G^;kOn5s&&#RVY z7P%BwZ$8B^(>MPZ0d_;gPlU70jzt3*yh`nt^;NRc(!D{XkqEshPbzZP`W@dzh14b5 zAqJW;bF-+8sXZJyv=|@2qHE5=>5Mm^(GEK>(&p)r(h>&Gd}$<;>$j-=aLx?ft^tJw zBrlJ{hmW{-7#@6b1R`m|q(MK1GE2sIe0KJ?$1V{zHTh@*|LC{N7~JCejn8;4-|0ob z3Lgu862?Ax7TbwH^Ilz@mi)RC`4u(Kyp@zO~l`MH$ax!yt zboA@k6=lLyz1?rGnl zEn(yrtNN#ww!Px#mcUWlo}3$_McT#U`2;cYeh{I?lHd;lBgjan?T^(+PKKAPr=s?^ zrbBVMIA%smxzh@Z_miN|wi8m$>9$L9{YH5UdRn^>T2%l0eYhaG6+r_p?mr*Rz6t*Nb%hYpxOOHAd*_rn97 z^T!n;pK|->WT6lch>YL!S1zIX&6retS{!!h2l7EmBBG3+Ya5pF@$t;W(-mX{L3_Nm z{ewEL6JI%`8f>&hD>-Ju5ot5TY;AK$1U$Or!8>b6%9^p{&hH3VL6fR;7{B$6r8;Zm z+#!p%>D85|ab`^v+#UCDBC8%Xske+ zJ*G(R_O2D$#IGTR#xORwz$k?z6`z%0IYi0J1gK?G8EK6wr>k?>B7xu%%Cv~@_vz^l zVjZd!h2k8p$~CAu@oE%LDboteRW)AcK+|c8M+PcPjOfqyx(_Dnl6(|`cP0<=df8;& zX-AN?NN$ZwotqPIwkuO9lvP&NWtNf( z#_o};-drqND+&pD3qrSzKR@RJrLu9M9l>Z=)qB4{-J)@Mtn99i+K-<@XULn9kr>I` z9*5i6G4WP1^A0(biz2mhA zd{#N*yPb+%QH-a)NHmTpF7T~Z=`eXSS)JeEK5=$=Ryog6HW{BhkL`*_E8i`e!c0W!ooE-IIEbRsEjTD0?YbOJ2c zfR3xT40TizK#t+aKFPbVFPq=aMUuE;UxL%9T8^3$GU8uC$l$Gv)37 zSiM0MOHVH;=>z$k&O*L#+0372{Err-kQlC<4gbXp#YS_-_J3+QmDndyI?B!pMP*E1 zl6jDdzB4euq4pNuSU-6aF+FelLc7ho%>n3uK>bk$SnSpn*6|q$)P#1J%AZq;A-l7t zfV*<%r$wR`o%(Z-VI0Y#LYe(7U=Fx7d(kFsk81hQ0)Zu`s5mYANrRqt3Q_U4)3}`+HvNDOB8V z`L+=$i_T@W67tu0_rqd0-H`9M>l>pGo-?jcgngmH;8Slq+v!!%68d$00rEFRhpX1o z*U=FQ!AG&9mm?>>^qz?9Y`t-w^kAl(MlpG1bCP@6+9-| z5G(ZyHXL73@~>#F@9ZbR8d&*VPRrdqS{Xx*Wo>tNP1G=}Ee0Ncc;s-F z9>Ys2J+?3FdHKT6Tcipad13ihPC;#UI&ZBSPxjqpcInl&dbL(Zm9)j4CCyo(PB2(b z)oaRe`DS=Ey}|H7fZy%d?zr_i2_OC077{Z?U@#<_S{f1(217+dGeR9C0nvG#trhg2 z0*x)oYIQ!wp}(l4{N=e*VEaAt$U_gnny~E^0pKbgK*6NTB`AYjG^+FxZf`x@e>J=A z`VuN*Ra*m@!sMkDa?J=OTq3ppUPPtHlW3mZk;3WBm}uHlM0#XW$i)Nm9@K2dz=fa?OPk6 z5LDEqwbwJ==rtd)$96V;WIsq8kqGLRw#V>P>RV@qXsf1LBCx(qsnq2aPv$O5o;UJQ z0>j%a)(q*$@Cu~gkw9WLu$R1F_KKWE61;U`ZLNS9skgp-MhwGrm5iFeG(Onv+lU&y zl*nmq)%A}ZfF)V-?iU*R;mxcXO4+zclL@-RREIOQm}Rk*A49I{@9d6iUi7&A%9T>E z7UNUTf2?13`<0`i_<=~$L2*^H4h$(GnZ7&l4n^!0i`x7Z?nJUz`BbA;*|_h&f`k9t z-2Ee5Kz?Ju{PvW5&t~fLcf4ObzuIRY$$(80pe1OUNu_uDIJe7cm{RI@ z`E_1&SBY1Z>w;Dkg6`gnEllq0#PKdK73Eg@;Jee9sns}fb6fb%AkfOD7wQJgjaR^P zO~#kl$+vWu$X|V_2#m1!5nH5uN4rz~5T9IGl3V8>2Kv4-kdPTqf5la=zmb_1UxhZ3 zn&(VCn!O(3Y&reCx%6y|qv=Vc)QL*bFiKx{p4wu}P$oZta;xPQgo8gP$O*EJDG3(! zk;*FUbYPF4AVo!6$znVY9FM}V@?F(A7@~^7~G9Z2eZ4KXHOe=0}3ZgJ${3BVt|z8!*<#0cHkN#qlHZ*gQJL9dBJ? zW}SAYxTb+xEav#glq#ZLmc)-;lWgK*6mG}gPd=F;=H_MY@1w9tYkXY%mRtXJmz!z7 zsRaJpZ3yi+o_JGSWh8#XCy5S`Mifr5VisOaAOkw6?hud6CZa%a3;j`v6{QkvHbnp0kBL_e%1UFP>< zpooDU`J3QKmG=VlIK4ce<|Byjl5q4TBHn`!ex~{EcOPfsz`azZ#a@qj9R;1qGQwU? z<^i6@#vSov;aZeP{-vX?*DpF;t(F-dFZ4xWJL!C-YbD10$*3fLnbp3JU$JxfG_O-@dpK9ILskt~m34cW>_H#?aZe|e?1j!4HD%|ihzsjT4S%+}zL zUMW>^Uacu~8RX$lwtTv`&+z6Y@=0p+Zn^+tssdF#M?j_EG1`Z zej@6~YD0t{1t}ayr9zQ;zkCmh?`uxZ{QP3OAoaMiiapRz?c{$t_J0!_cRm2|?*1=A z#W@tskzhM|!_G1r87$&ePKu&ovr>8%{p^_FK1r4jq3qlwCDs;6<*i=MvSaF%ubd}j zKOl1?B)%Te2sYhicv_N{yjQVG6#LPcD)0#m?_Et{fht^D#E6IL1mai1Z>9p!{Ni-NWW z>xn2Vr0pg+6j^z#LReT((6j5X>`bSag%*LrxH+JFm8oMwKFviZC`UoU$*^^P8C-@z zQe(H=d4a6Rm%^$)O=m2otZG2Wz)iVehe;}ojb}6aOk%`PinPIsYgwZbX^a9=Xr0gV z*&$qpB{wDdr)o$x)}lX}E|jNShg9tq906IrlVs(^kIP|Y|yR0CCEl`DwSmKSiYgrmFO#7wtc2V ze&+*ZS;$md^Su7h(dGe^R?2g5&HHu7aVxN>-p@bUcn$eph3kK#C@&AyOz zsfcP6n3Zo6oHCz@`SJf8b^ISJ&Y%?grS@Q!1?Re#5e^D-Y3VEZiuU%?0=7OhHlZq< z3MY{2QEc-WV^0Cvg%5~^bmZJ)@xh1Rrxak1eHCsrC@fUkjjK6D^-l<$Uu61`zR4~u zETznyr5=F#@24?#=484udU-Ayllb#|nljB&Y3+=*v4b~#1A6u? zPWGU2&t{zX{JcCRZEb=4CcfePR!3HpUZLujNizI5j zQM<+7CnY7}lVBSK=Ji6#R`xZ+n6$q5+jJ)Fh4;FKqEJydT@SfFa?V&~pDxxI*Lt00 zEI)&K%)a}%wfHHwLV=h!C=6|m)SQ0QWhm2Op5|u@@ZOK)J7bw}Mdf{c5eB0X8=q}H zHoz-749PrmG>Pox6TKZ}V*IvkM+UPZ7swy72&}f8#?-2qrdM|3s@R$32%##|kJ1UrOQ(O_*V|S}oSG=H?C0 zo}~l;#t#8mbT7H~<0$;rs*#O;lM{`}gBeq;>d#tlIf_BuIuVwNinmWfJlMYzw}zGn zYSsDBhsvP%yCN@yjrhhvAA)=F@pET9Jx&5{Zbkg%FGkV$=(Pw6%uJsf;vNz%m@01N zn;lRm0Xy6fzoid8PuJn8#O34vTJY?u9s|XHs$7?qrYDXxE|%&jNu3G{z{*r_E5I88)}DX&UvCsJ-AVd-!BgB z@~+ly7MJ(jbxOi3Ii6#{L^l`e%L1^45Fy+qU&TFdQ-mv=>s1jCqbnC`-8$zX+l)d^ z-G!@j+?LG20iGodJ!hr*0Q<}%vz&Sxg$E4YA2l`CVVfZe5Trjs9gqD`-t%4hiS^#G zPeteL?wc#9HEAuX#H)S5*Zdy(MpJf{88qu^zAM|ox(!uj9%0t&kWqeD`V-%zWIjqi z!ZdkT+$uv>aOdhm&b3M-nB}!R1jX&Jt^pBSNU903`a%!2g9X?rDPhl~; zf`D_Jw^&RdUZn8i10#iFT3w2>^PoLG1izH}qK36$*(fAHt$v8l zdK$=}o6fVTNVl0mv?A5GoU5R3OcSUT6z|4r= zmeSQ5bc`@$7-r->Wio)0-->QaU`{TM^M%j-w%p#Li_eZ(oJf#qduL|{pXrl-zgkb` zY+OkCobjzqrs8K&B-&%WfNKV23sDsCa}p8S)h<$}Z`=z23?-XF^wNBrZK*`2x%qMA zgJYIy36q-4OrWJ9Sfy6C)r%$9fB@6$?&3P;Xu)KmTpTt++Vn`7MF}AwTVx!AmM|#m zsLnhy)NS&d3s*7aZd+MAwlN`RFc@uAS$?)nj360E(h7?{nMZEq%cPS8qoAObvFh=_ zx5mN9Y=tEc>jOI$&$iSh@uT^BpA$eP88hTOp0AP3e&LDj!m0F;F$`b6C0IJi$1aHe zdAKF?wDF(F{{Jxo|1mkUV)#2f#MKrtbyJY*P?%rvs@SYd=i4&d1GGtpOJW>>*0dBM zZ9c5iX1e(0kF3faAh#k1>Uv$X5TE=RH7rQa8Skukgh{reRQD8*S$;luhTl%BCxW!(c0Z>e>S1VW3hq zyQ%W7r^GU$?t7P{X`;53ET}gP@b!plGx+!~h}17+76K3GvA6bAury)M1}A4{q7NwF0(mHo1HlATbmDW0 zi4t}J&tIp#DCWbQ&eJ2fq_nGs+p4LzRL>C5aS>g{lI*nq;V!xc4MLuKLl5^^JQ~XU z<`p&rq_kzYxl~SBSJiAGJ>ndgOiVa}B03jqa@rvO>$N+!ju+-l$m$_yXas8C)>k%a z-_gh+WrRX9u~0l#&JjN^B}ZFETfX=vI<;dFF2F{H_4ZF(+ zQ0q#15TB0oo1^vI*ga+ERps|wA?N9}7eo?XL8jY7ix$m3XASNqN|_>b#sxGVj>^sm z5Qf~{)K}_4xKgkZ7MnG|i0@>Cy^g=z&(?$G!q7mDj%A%vXjjZe$?dhPQ6XDsMpUgW zamrs5vYyB6*0x#RnT%ul+6}35B^P(-pluD~HfGs z%A!$j=LU5)NjRn=j3a4$NBr4@sa-6FTTue!`pLmkrP9r{7?-^`O)pZARXRhi0-snN zea(a2;0Bb$WgcGci;~1E5-^xaM-Y3Et0tCddI!Lpb70#&CH_%8y2P|Rk-!xfJ`gTP)j$vnx;!B_xlt zuUh2@MnXv(fM)Rw08)(7(CoNnqq_Q%2*2dX8r{;F5j*~jHGaDHmFMzvR1%6lGqpEw z+Y7^pty?dqvaygW`FFqL9pb$Zc~Ln!t#=#3&#L@HJLTm(CiBw68rlaW(uPGhnEPpX zulyCzpSeFAS0bR%TBlJ`p~pNX^hh2*=KkE%mnv1U?sN5r_KS+6R3xtni6?$FX*4IY zNKfH{@PCN?+>qA@U!Qf`xDf6hpEEE0;l7Kf4m5a_VQ{^;#YjjVJ6(Al^DkmX}2tc z>SS?IVJyVhwtI^$t}6$?3+2-?lRG&P_s`%?HpqbzDd?+rp2b1%hTh45Lpy!EB@$-a z%fyRtyf1C{T85{jaN*OQs%$@()RC#MkP5`|`@EmTyTr4FhDmzs6IzV!@2Z)A+PDG@ zeSKapt~s$TutwPSxe{d886-$p>09TwJs==F`^#}s_#|5RPR(zZN;QhF|Jh6L0a}1{Pq1d902leeP((LZm)m3G%(}wBE1h zy&??3+QHx9P7Bj4oo>@pDX-LQ=c6AY-Ql|wX*{Sw9#`)+9D1sYh3)TpA;unESwY_4 z*rwDw7Ha)RM#X>;3xH}Eb%X5(;7Ta5+aB7+3L+m$$KyY`)@XfnE$GCic|&mKeY-K$ z^FFLpbzo!6+IiCN6BDl}#r5v?e$s-Kf@~4moUrgBBhmOoa(H^~*n!4s5b6()yMo#_ z(A`CG=K#whv@-`l)nJ^?_8=(}Qfq5Q5yuRqTSVWOS2Wk(s@6D~p1BK5?#Oo!P40hN z9o|)A+SqSqG$(Z$wU2l}+0c~jKXglj0T<8x9 zYO`jiz^!}1TY$%&m}DtXb+c>cD783ZRmYeyirTx98p4&vL$U9f^6ceQLY4xXsc63cv?HwXeH7dq2wD+;!M6JH8v{xh zEd^c}EeR7VBfH`|8IVRf#DRuJ>aA0J@fF55dOM&qig%>_ctF{U%pWT+Gz3sx`B3b# zXX;tpe5kE6C%7!^&IsisaiTtpO5&Iff!N`3qV}9jiTbap>lHpo7m@1lrs3IPOJ;K;;}_@O$8R)Dv!phd_jPVaxU#Ui8C4{9BSaJO4x1SZghau%1&6L5Kx8? zDTp-%cRpO60&Q&yBe9hSE9hfpTrT>IyPMX9K_&2N8M$@i>(5YokE2@mvXrDq`d=I-M*bEvx^zaEo}K}N1-cdrO`Uqr@zKV zE5wQ~XeyAd2m65N2Z#F;Dn+W?oH!aPD%9dt_mc|IvSic8YMPrRS{Yg8)K<0LIYe-6 zQ$eLwuuQhQJR+dcZk}pEg=iH^a2JGU=Q07%gk~IFM%ul}YR{hODkF}`V7Io1UAIl{ z$O+agKil`rMq5N|6Pk$2&E6UJTUWk(R^NdHUdj60@i{~j57tYF1^dc@v{On^41lwB z)eIMz2L~=THkATZ=t{D|bxDe=sgA4jm(k}KpC_up5=J=81EOWQj7=-I1Mc1#AY?!5O>Iz!uIQg?1h->|UM-uMOJd5*%DjF^}Uba*LP znu07TzVUfqdX0o5LsSJDY`9p#Sc_jj_eL<}Mn^iJ=)~68*TEUiX&Mz464jmgT|di3 zCdPUe>l0?8$H-3l`U=umF0BysmXk%8Aar;hI?^DK{h&7sXwhKF>+nd6Clu7l=* zEDM;F9>Jd4Zk-tRcgfKX0L)3f7W4_1)&4hx*}iT?zOZ~9gq#hIigx-uRF@cs=)LMK zT^n=7Br*!0%MrFh>CNue=6ziZkxT4%SDL2F2RDy4>zHh3uVOl%3747_|z-q6hK& zpl95AZgMCr-{JEj%LN-yX@IMyU)^iZ4cjTMX`kGi5#DaS_TE_JSDq!oF6{q<}&6NqZ8l-k+acAAtm1K zsBut!!@;uUF22J?e6|v%>XEd&iwK91X}6g;tlU^PT4Y9C_LAAlr}}dBHlZkh7jbCy zx}=tJkmE|wwJbVe{3bF52~&VJOJAn!dY|G#uR;LEk5EE}hIS?qL^pK%VDJ5IxPB}3ijHtx-`XuvChBKmBVjv5K1U-*zO$1NtYlz`3&8Pry_!=wCe~wtt!lkhJ zD^NN*a&d8&=*-sd!ct?7R^=D*p(#@`3y)2R-FZ3G?#I3$9VBrvj8b;}Jxex1n5#X; zabLrgrnZHc>a*3gC4|tyhiFq$|0^2!=|k1jFN0HW5;gLVxS#$RsZAOl52DapR_Mr6 zXk-$tFNMVT(vhx!D731s4t-E@XG%(?5j*;`EEKxq=Jck8-ZLaZQuSkDWR*ALQ?tq7 za}l+G=DIlj;3rw6*1OD`aZdOKH*IZWvr|T6ss-KN--=KEK30=`Nt9h2VHH&T1uHnbJUo5AZTDdNLUD;3 zH=|dpqh`HA&bLwfi^MXcCU-&&o+-@g({blAlBRtvKc4@A&gScQ{dDYaIiiPp9*thV)y+h2|~}eLvJ-S8nGe#F=^Q%E^ioaB@8y4T5Eg5wi?Gb4!R1M0+hn&RZXxb}|*ig=G%EE}?9hwjX5Vlqb<&!M> zD-sA1O~Z@Cm-Cv6I4&O$l}nOK{KjFEdyN6+eAa~RaS`?EDn{ao9v8O?>o}Wuj&~QN zft~Kb$cGXDARTxZz=O{?RzvmP^hl5i)e(YSf(|sRWx<2+zp?)#$X)xls?`Qne*Qt* z`5-5y{|n9w>_1UZQZ#`rJFej3>J+YzQ&8KY?|^zbfzO~N1=8Euxs+*OJ)|vl%P(rt z@tFL~2pXqsD6UPFb5tmukcSG)e;c2sHV6Cx#q&rVHDm9&siL)-1wtg{_wVV=cWuVTnY>d2f3dx&^WJsZ7+c@kL?Yp1pAHW@4qp9+g4T1{BznEtZ}MU0P(qB; z(j|@c@&!>GJc*5-=K+R}@anSZPWg}AArN;y07 z`7bU>BF{_SuHg6MKg&f8!?m*D&vbp`lZ|cC1;?O$ZqBHNLI~S~5STAGv>j23_#Yqi zO%gMHO9gLYmmFyJQ{?iiIDc)b`^*;PEMweg_f>cO+G%BkV_ntx)G6ttA-z~N4V=Zx z*KLh<7F_nhZN@S&k;`5*P3FRFbxh6C45g$i*cq#WMA&|AV%?`pm_H6Kv@%dy5%D?P z>MPwcc3zb2&Rhv!OU0}T)Q0c|f4pibj=GqsIJ5`O*9hh~S<|0u$Enk>#}K3i$@=1O z8uW5BBx@qz;zF<%DA47*jxq#gG08>wDWfdzZ28hdVX8UX(9Ex@@(=A9Bnk z>)Q{*!LqlRZ`Hdz)G%dn){~g)`=|hF^V(TkVV?woyf8*>zGbjkpla?F6N%j9vcWy( zd|1+$%fT-GX&CPaO7SazST=_0aCI0Zi*6fIi@iC)s{fy8{z1{sU>o925Q?p(E` z%m9AJ^TH=VcHk^jA*(*#Fi3;-?5C0yNGeZ}s!&KODiEhuQBu%H{2W5^k1pv@>gC{+ zNfZS#qMRi}AKE0PfS$^ka2(W#tXNu<#$>w5H8roZ6xN{8cU@0O3aFYhwOncES?72& z&y1uzr9Y8T$?sin%InNeEs4-h3s)5?H4ML43RDtqr{=44B*W_DC+*I3&RcX50d~>? z`WHEO^Ic;E3%*#JEpo{?Iu?~V*mc@*oO-@(D-fiJa{RUB>5f`2aWg;AvU6Nuw#c06 zL?q+$RXiu^xmHiTUC5b_*qpxUw|Bw2Rs>bnVSTYK1Pow%+ttNdnVu4qvm>Ghmlg@o z=zG(wqZJ>sBUu!`ZYf*0BQ{WLXOkqEO7*6^u>St~Y>Q)+b|V4N7Ux0=H)GA85!pMP zX#W9RduZ+lp+WQ)+hR*`hV&7j7`>3nAsgql?Uej)c(SSoZ*oj*`uh4}Tdpi<_-WKy zcPkUB(0W`a%=6o8Wqa5d;$MmU7_uunoAH~NUPuNq7(doq8Y2by6?)K($Ht~8!(|yZ zQ`_vsb~UimBc@x0v)G9^BR6IU)`OmIai)AML{@G$tV|#TNKDC`j7&`8lHlT&0_NDa zwz=!~!I7u8#I~i}^DA-Zw?bzV3E+21v*n6nJggunvs^Mdf6vURx-)^Ls$mXi%_L}u z@Pw$BpSFRRIAemvT+!{MZeB@NR$V%c$>C11zR;T30bgAE|Ksef!?NnO^7au zC8blkyGxMnZjkQoM!J#iluqgHZs~?^vG>{MyuW>}_iX&U{A2kb&zft_u}0qG9%6yC z1R`@)&w-5~_E;6NBs&@5;ZhAbx|H>Ts*c#-zNyRC-w@$MGSXnBuQ);aB3ngz^B#K$ z>LE|z^4Nb8`1XbnvWvz_(H62xN~9EuoGNG8#|d6{8gLG5MhM3p%3IM;lz=G;J;AaB z)2V?*Q|NifON6T$@Ai^;p{uNyOq@+m_=uZ*vZwVp>JVKMx=lS0!iGD(Z;zHjTX3-K zPdeK$c{w1j2)YO*r>Of}ONGNGo2@Qa8{?lS%?x-u$K7%GXs7)82Dd6`Z}Rh;+42}C zNmI#W@yN=P&|uFa=U_g2FL|wzC3*J%X%RnaUQL)XP;_k@gTjnbYG|cV%daj(Dck|s zp;V<-MV_Xve_%`e!qLNted_nA(0p%IgJ_1}LldEfFC=-@@7ZibedLW{$}|e>nR3K( zkJbBn@-;WeG4e$MlzJ!vECGQ#$AA_@WH_}7qB|kfkYv7_fWL+C{#A+MfxdW~6Ct4r zA%V-S9-0>djbfTi%mMiO(|zo$x!B2EwI z7PTiAiO&NCNU~&-_lvfshY!XVmU0Uz&Y3at3eE%buH?Rp7d3{gA-8bhojTt$X~wYI z?^X{h6ZSMF5E7KM@nRcJ)?~|cg9W>bA*EXN+ zo1$@7g(T4S7niqilVD8{k7Tfnveth=j3SbS@TxLEXy6+zEs0G@<{#@ZEpIE7V$!TM z+mnmoM_dnWay&-lkSYsKtE>VGIoQQ6x2B7qz=XbERo74;VKj_S?7rWt|A0iEtGqI6 z+9e50m=%zY!`PyfNHPW|7r~xoOJqmX4YxY&Hn#U`^w_8s^?LvQ%kIaX0B9K9$lvJ2 z$lR$@b`hTlCRN%Da+ktxAFrN1lcf=0srKd?;X0Tty)QM9*!sn*HbchBN+WBS-Y`Hh zZ~t*R)z6BE!(l!2OAIm}{8w@i!agnx1VVFNk0*FywF>*w2O&yc0VY1Fh`cR934tQ9 zoB~c4In~{UbOs` zu$bbsFAKuwa~~rPE|;6F%|%?HuL!7bU?PO_` zo{%8SPyqF++tzJI#i`9~{>OBVWX)))BwL6>lt8*M5b8%YP6k6-N37wZ9l39DlD)Q` zNAeG~SOiM1eliL+ zE9c^CU1Ls;ad1nLoJJzm7UOv?!AJ2lO#;Zhc)S{_0|nt+oWNlwda`l-%WL~$A93%z zgNcpWDPxll8LELSS@am(BxFTKC3M2?9xI$Obh?mZf*J(mn@LS!8@cuUzQIi4xx5z>`#G9`n*`J`*c2g70m1xlFzl0O&!Bo;)^YJZV%~%=S>(Y`06SFZl^I>=AKT zzjCW^vW$qzoUwvkcVC&Se}3Du!HkF)!=mTEVMJubaDV04YB=CWjn^n1wyQ`{=9@wYawuU7c6zzEa#Cf|0 ztSh`1C?dJ>I&wtQT$R1t}VVSI}W6_HM7tYVGBs<}QpV_Eb8)k+9+s>a^Cx{OggIFz77 zP|*0g=s;l>!|9p-L#@v8V&qt@3HxkaHBz{<{3u2tn53t>51WVM7odC*j}Z* z3eU%5v?%0xhBa@TN!5kEt@=8}p6%+D{axzLe8?L!c z<*J-`GuEH@3KzkCPj~IDA}rH4LIZTIlD@tl zy3zb+ATbr;lxlmEh*p8Lrd)pmN0dI5T!}y=SbahiWWk zYmk5`E8YMkoR*R!@es38#1qOf$A)f0l)>>0dHZU=j(LBvwW$rNMo?ygwnAoM4#Ng# zK_ZHbw<)2ot90w46?wA{58Z!w%Qkpoi0z%3x6P|21g+iqCfq~Iy^VDDnV0iDMWh;o zoJ+rmd73_e?n4WNMyY=$Pzpt7C&Vi z+LZM6zRyMp>Bg$lN?S8yow#4e?7j3UXb_dpUQa?qYPRjg`Cex_c?_jc|Ni7!Y0{^{ z`&EuYkI1*q3VN^?k7b_<>iA)Q4$*WheSrBYY`)2hba@br)lji z_GD!fgUKPADe1(=Tmq*dN!m&#S_{iIHJa)iAHI?r^a}Q;&E|s&vNG$FwaZUF=?eIC?T>bRf~q2}Sa?_I6)OOM&2gG7LtJC#Nc~fvv19sQ)A4eJp!^Rl9I}Vbs%i~Et+Di6?+4DgT4x{YTif6sg3Zs#e1VU@ zcv6|=n)C8iFR3;oG{uR#b-!_%;F1)H)A*PQpgzbiUzI5ev;yL`a}isfi!Z4u>y6lLke%KTlbrTTO&v05y)a;sfDc`ZsE6) z*(xe1sj21B>QUJ2%qtofSVoiJ5-SRVrI#U6Bx{n0?&~ONs#&G>+smk=qEJ+AijPLHn?=yiuhA-;>3epF zQ|Oxn%)pc19qXJp(N>RXy5SKI^8v!CGD)x~!arTQypFR$ebL@cf|lgfdL;gUqZI(! z9>+Lh^A=%uW&ky`OnQu>Dr0bi#*F z>Al~eN1eGO0y=o=>q?M&;{%#T=4^IYdZZP-5ClFUn=6_Y$#*GYunbdVU`R|E-8cUZ zCqFWnJjv>GyA5(|A=c$eUty+7NJ8=zaQZZ^7`uV)EO>y;P2d|16#XV0eWX`O0$j1y z@doLdTWs5Y5JEu84nGhXOm1-70+oIuNB*UY zEC?KoQSExOW6)-0YYl8$s_j|H^DsxOW1ea+WRwj4M*&yv&+Njp9UN=3{fs?)NK-d; z0wv#UHAz^0s<@ls9;3aomhoRN$(#4s{L!?MO_TW{rxu#%0mEpirjdD-+M2!d`v=us zG@|>>l}2M=Y#xUti}fC#A-)!l7gFSnGr(56XO(Ezi7gdNi91@>rOL8I`S&Ot?Z~~0 zfF!2&F(UnZe_>xEQk2&W;HVFApqtRxGuGe~XSxG}dCpGs>ffW)3aC#DaQbW5u@T!LJ9+E$C@{M0$8s5TYZFw9<8Nu6Dp?!O|!t(f1TzL+Ks?m zQwi{GzyuDhqB2jnxA+}!E(cynQKhNsF8*!@=g(2`T9FRKE7d_T54mWyf$g<|Z$|6X zgw+ejXNh@f8jkbnP063P@f-u4t#NUFkfLX;M2M}H-PGHt5~fz6m0jS zjE%q$*$TPtbLxcc&C+>48hQBn70pzZjD%lj}|8Y#4W%gB~PcI4@YGW@DRT?ZaVnkyvtYQ8>&ExpVeg zSKLVpzjClAnNWx?Y_v*EC+tr~V$_77b#4UVAtslMt{TtPo=`Jzy31*35!W2%WYCuX zwq>lwFa%?(7ul{r0zwGBXil`0jhsZSMmNgpK^TUas+;N^F_w-bcT0GQDFBK5tTG1S z`+ls)=Nt5h>Wo%qrLH6qE0sbd3EM(1ZgCa!lp*dGO zF9@>AT02183Q9Bv# z3^%P>bq0QUwOSS$m}?io7rCtEA}3!{&w+$ovTakwbv*7**u$(&U5HniB`b97m5uh%SL!Hy$Uh0>;fcLXOAm&FUwu+ur6%L%i)T zBN3H8D^w{_VVI;VEG3Z^8yx(e92mklte`v8z^MUFFyBtD#>;8d2Fk}rQqiT- zk>kJb5kWP#05CRmM;GolZ#-n*&@aibpkYLi5+Wow+O9Tbw@4OQtbDq+;yg8W5wG8C zujyJ?F&)3qH!&$G(oITFe zHAn$0Q34edT4(j!Cc34>xPsFxJ*voLY^;?R|7xAz>o7|%jSzH~FG2NXS%19&c!hFN zBQ#;RnyX<%V7c_-KBxbML{OQ)=^Z4P@efpu=mhOd{z`EB59fPz+5mC3`MCMiv;ua1 zW#b(?Mn!}i|o!v7oXz|P=z$1MCTU$D#^`Nl~cIDup&E1 zz+|+^X=o6IFnlD_*U!kyxo~NTPRb0zWyTd z?qwtIW{idyeZRpS>uBOd*j-8a3 zTJSGJ?CcyCz0 zlDOxuO%yu&FBD1p+S%5l$lhf`L-3NabCz<->)09K_Aov^D$%fQ+aEOu5zBh3%dlUTJ zfrA6~(XCml{Nm~g^HjysvfSc28Af3!!?4vbKA|^J#v7BElvFjX$7!Jpq?__@X>tBe zXcEuB3qX?35#%Tuzr81MqD>EdBs>3+a8@AmH`xE~y zuf<x@%AN140#~IPtg4#+6uzS`6@&9*Zy7!BG4HUyp#a4|D`Pc?!o^0c_R2r zc?Zl6FZz$q0gzX+7lRrjn$i3J=jL9bFYP!aygP)i!t?!A8i8vah8M!Rqzh-WKR#IC z`GLni7G)(v5`C5b_+MRfbbR1y>groZT=$zdK`}PB0~if1myLFgjv+G#X_>?}DyWIv z;ENV@Ik~z054X1ZYu*r*7DrffHMUKg+uKqZF{=`y1X6(B2|*Jp zO9K>hx+KV!z#(_1eCQ>9y^MH4Nn{r7H@n?ZJ(~#VA>I7q#DS4;1w&Sw9}Vz0eN$;R zH*V}H#R-b~9sv+xq0d3DOJx5S03#Tc^*aT{^A((S?BhL>)EK6X8VMa2bbD#JpnS_y zkFba0v7o? z%>oknc8BUvN`i^lrKOcMVrcC@EcLH4trOV(^ag6{VCCUT2DA?N#dRT&(YNXJz$Og6 z$jteaq?AJnimS{0IwwQGpPVEiSb57+o}GgOB8F86xCns-_;0f{Odu5bDe;f3SelIe zic8Pua(~yobKUD~hxi(bihqH^i6kY5&#v(D3N~AK!O%oOM&G9cI7ZHre=Ipp(yoDo znN$=K5;7;dm}4aSkG2|_O<9rj`dASS@b25eQQH~)4a4}rkf>f%CNL$?ASXy1;;Z{U z`}W>(+Bae=kyoFhmv9m<&|jMEcE{<6>ZAjHQ^G|z$@z=qrIQ0Jvk4WIgoAM@ba8Jl z{unQ~mkfk4faH}gj+byeuekUivFkCXHL6qO%QuV6b58rb;5Wcgq@`@&QhSj_ROhD0 zNV(fQy~BIHyk7U=k%DpK&t%}|9t_*2ae}!`lSObvaGruMqIm`iUqkN|QUI=!aK5k$ zv$?)@n{qV3zPTyIgzuQ8et?Fj$;)Z`>izlehxHOAd|9}jw4_w@=Azl&{_$9BoclNw zXp+ZksXR&O;-Kr7EZAIcw-u66cTjzJ-Q`U~)sz5Z*#*2g%&pkH!c~o}s`CpsWmxzv zP!l1Y6;SP%WxA@#wG6~uip^RX7p^TnE8M4y?ptr_!8pPH*g{5rdofc|SZK{hVj_`W zB^85^UhM(pp?c-ykf>Jt(4~|NvJK)}Eqz#gUsFN^iW>ONi^zTY#sUX_csz?!G*`gD z?Khsx$C)?mh)92;-2bN~n@;d09ucGk&wqUk!wZI#%XvUR zZfl{Q8?M`=rEqyjR_^XRMby-iY2w?SA9mmq-lcoes`MZL{Hr%8nS>e-DmEm4AWex% z;CKry@~xTcsUmCF_qk=QT3ViulDG3SZ4}C78ex-W^RbFU6u<;`rQ}2XTudkaJ|8Vk1@dH&%PAPBYMRYI}R=`O+-9) zvy>NRI)ZTUym;SF;p9Ev|3CqM1J^~fYRbMJZjUoH5Dj`*y!RApy)RAJe8Y2HRRN_( z10z5fnZQE)0jtUNI{l$r+e3)^VzBM4S&QW}pO;KUcDDQo(5@J73ka8vE{wo4MB?m* zjolyY6M+|n!0PDJgS?!g{qj&jWhMWo5^ZwqPbD$qNsJI+RniX;D+yQOh_}brtvr4P z!-W!1U~uoKp{35@v>$|TfmyL=i@iM=PjlLRD;`fLx#i{3jZICT_v-<9q_chrcvx$3 zlK#Na%R}%&BbOSOvjwfo*Xxz;V`;rL7nQKEeqR*NELsXkx7el=q635!lnCQ^=_*cwz$oywj^ zZqBAUaz2)QJ-y4_?w)i27tTS~`A_y;Ws6xW{O?)f1LNZ2yG|Ci0sPpkNT+FTU1H z{6oRMCjwcUIeA#d?@M?(h3oNx2g}IMaOn&jFe{FYMZcYReiWT+G0lfZfsQe zdR6PT9>I-?&hWT6`V{&w{d`W3`Tl*Rq`ISDV9C?1_%i{9?Lcm8E1$!m2Lr2=nn{t{ zbJGe_nW5+P^)rFzdBf;nx##B3vkvWtZ%hDGA4&VE;-NWjLcl~4yY6H$YUOr`Cz8={ zbS^7#CGq~`>8dwu7eSXC8`;5qyPVs!%cd57g>_W=VeMoP801K6STW~ubjh;m=y`&a z+muh9gP2u4>b5_szu|wmtzovc_EQ+*99JBCw5?Dy9G8Q3l>6N72S-nFX)*RC&W63D zH=30hwPvG0>tk3syO_Y!+354+VMd#k?DBcc^RO9Eit-%OkPn2Mv6#gg=N^{=qU?tg zo?AWV_0wMd= zgsdZ+zMIs3NNWE5!0{QJ(+DIAbAabD;hE*bekDI`U{xgSL{EvTz7UiDV^$XbhKT-w zQn?lex>_18y0>bS3Ll@>L+5B44{Phu8>phur>T7KtlB8Bjpt!Q9`n?S)GYc7^~_`A zjK>3MxOvu%D$mQkK%fWhrS(`6-_SMZ?qzU0v3i{uVtPpFO0}`i!=$Tc=W+Nxhtt#T zEnyaY#wfX#z3t~vhw#%zvM93Ui6vq##B0Wdyt+DR_IZxOGLm{Q-4?5-4#+Rqo>!hv z0xu2DKaU%dNJ%Mo?@!JiY)_yMIy3=IF7o1{3Ha&%j{*9ZPxpU(!Uy*5qvm*Z40n~b zoCXf{^XkWkX3lfFc3gO4td;90?MRGbmPE@7zV`3%N5~@2vy8i4HP$~Pt;~9_%=4C? z<|@ql>|qf#p>tR{b1c->#-^8P@gH)vmWa z&ucbOT=Ycu{RvN+1qbdg+!yTkJ`5_A#biADX-B6d zlc9OtRaY}rh4H*leL(ox?7opIp0sv`(_MV zV-RfSX=8k8$=v_0&GYUVcHHrW8(bGimr#GgpMDh|*FSRt?ZncV`T5FaEA_^#?OTIA z+r!wy2Cl*Z4?UJo@u{57Vt03UL)XGQSL)t2zUBe!R8RACAD}-!j^KXjr0;$5=kuBi zLdypyD*SzepK+sYv${$cgLM|hvJ(G%N&ZYe`!zhTH53EjH;r6e)R@@RW@=gEalfS^ zdnA?9d74OgQkC)NO##N0Bp)r*og~^m&h~n$d))qPqh*PO(`XIH;Uar}!q;XrU!|+H z>(>Q#HszBA-2yId+Wd7L0zMs=)9w$q!;N!y{j7g+0kr7}Sa2dneGp|&*HyKBmUPNg>OY#kf=Rmk`0BHAbh;hibrCK#M5*wBDFr2}F zn7X2uvyc9X;p!1-XHJy*#j}cZrO|4ZG{KL%fc7emDa}_2L6(g z5{a0`G1%hPyPEQpMu3a)eJ?Gmcd1%KXY3K?p`iohGmzOMZncTmY_?Mc!WaozC4=}X zli91YEw9_`^#kqa?H?|NN#<}-vjFy#u)O)Mk>rI{%q&FoU65Uj=}ZY?_!ry(nIB(I ziR4?K+d*G=3w|Jc8c8dTo-iEoj}3Ex$|`riM}jxyo@VPgPlivCNQIj&fyqG_9vno= zxpg~E8-?WAGv4dSrcrNXsvmxF!ur4$qe4{0{v>(93(P|oX;M*@{o;Uyojl|yEBk$O zYnX0f252o^S@SH@ZZi)4g?MyufZYY#u0fknqTZy$xrFzU?3B{acwF4s;yQ62%L!jB z$;vFFm8KKiT!!wfh>uq?pTMwD$*Dd(I7o<%?P_}*?vE2ifT-I zN=Zdc9hrJ)#)wnaukC*KF$N-4~RM zEsW{iv9Pck^jGSJ4EP6mFJ(kqijUPOde_F)|efrE-_z4`_iQfmdn|&2u^vo9PwE!oQ zA>&IN^~+Nd(p~r?Qfwps$#a6h1X{nj&&as(Aqbw)@*Fh#c~sn(CnRP#5(|tGf;dRS zhI>22(lj;2EQD@w5UF))xO_bU<`sh>c{JBFs<46GS%J$@D=H~jO0m#n>BKI?G5zS4 z!Q(k*7r<%;4AYUbf8y`6I^Q0KgY7k2l9vYf>%#t_o3)>N>cB^|0}azXr&sF@oPC*or3w0^b`JJ7@l3O=v&wHJcd;A(C@C#vTyMsIyurA=v3%4j*)9Rh z_!laalaFbB>Pg{4-Wf?_SwMC!Rjp!w zdHYXhTc={vxRf#E>I_djs@iP2&van>>aGu&^{-q3W=&C;piU8EO397;2isZ`BQ5;< zFm0sZE;{P2ulV=7!rsC^c2?2*AMcg5VS{^qJ!yXAd}nsD$m&T*AhS@II$egabx{pF z7XMLu+stESVIyfnp@EDz?V+K=v{vu_lWM&cpNMS^xFGBp5AZr*%Ib=cIs)dxA0=~R zmX74*%wPN0OE{uEMw*)zzycPOltf-0Ob;Ya6_e1=APAB@?clr1apFZR#VKpKOA@&8 ziw!)VRAt1EdamJb-i2W^)K$uH-$fy%*f4oq*39_0|FGWujdJSJ(s8%oc-%LfQYVqp zRzU7h@{nYf50#(;>kollWOZB zXbU_~rr7N{~mt(IAzMbb9J;(to^vYv!BoN+(!Al zNikI;Zg?5jb}PbGQX)5px_&CI?WCk{U|?b?%?-2JYuo0eMVMyZXL+*xXfhBh>T;SVnNAl+ zHVlLuH8=2WYkLRNrRoNX3i-fmyl*?|q1tkj&-1}I+P9Z3f9A(vLisL}2%PKlBlCVz ze#SWACw$k7FMe$@SCxT-WPwn%!uYp=0TXpk^pj*^YWSj>kjYbW5 zu2rXsm8CDsSDwh*Bx^;Cx3z_AG_BSZe7h{J8Ddc5M;x z2z2QeJwH7@7vMi%?1-$_g=y`cX%}onRELJKk<)fx&@xb44dFd4E4uWQIBG>~*Qr|8 z-gO{(mU#UB`7C#Tg)al}i7wlMc&$l$T&3QfX?rSuWZ7dQ1x2+Yk0b~g&Jgulp>y3& zWm%XVB;>D3NO7)Ol&Dx7(lZ$)cRNsDVJ95KGD|35nq0UzT+Z!SuzB;^Z8kc1(SDOG zt*2qN;oqllo~1qRQW3}AnL-Gu4AQ1?6sHiaQos?2h14)`48|nQeI_L#F+xeQO*?l? zpw(1py0Eh^Ur%rJc=WfmZ(reRbW|=hF5Dl8D^}xtOZNp`NkFaV!(pRpWnW*POd5y1 zoZ^&XvSGF3owD{**4+UaG&$ChO^>bz&(mqdR%<<8h@fc&gZur$3LY&-fDJ~lJY&sH z#SnE}zClA$vN=hElx2KMVawvsf;`u#Ok6wfAEdJZd13+$>{ct*8N%W0(pZ}Z=0L^u z4olXnJBD#OKr`b1eBEEu{{Q!NX4uP(!_utq{xGKf=Z$4pdrbfD8~n{r z=idWK_W4Im&9wbnS?vI)@stq z)OtBDk#8(c-5*Gu*IS}U4wvfvnQ}|GqqHVcuUbBk>-WdZSTJ2L^@#P$p|kqeAMkoS z@AdK0*Hu}~PF*>jEm@+!r$cG*X#Uz9)yW_7uftH*`@49#xs%-;Tfdbp3# zXq^TP4NXzZCR!}MEtVuHkEFQE*OzSB)J$J`O@%QvA~{*jzOF5{`*(Bvl#Nz8v zTrZ-{t4{Fa>mJ~VF0$1)j;7aI)uZ5U1p1mV(-x{-B6EpE_+s4_D`J|9W3LZLx~&wX z)WGZqm$b;H6wvlPj1-aBtI|6y`39k39Mh<|p?#~$cg46VF5&nEK1Iu=SYw$`hKjZz zM9R0Of~LK(bxk>y)|6WWbZ&ynlJe(fP*Zl3e87l7-Qw5tTjY89d}#s0fCH!bs(R^s^J=`4NB6qVAqq{@YJ3`bESP#?cb3)PT|7RmJ% zqfKomlU!Qp+oYgJNoH_My04v&7M*VN`t|gn^TB*|I4Fr0EWs>>TvJiB4=XB*@AJBe zf%ewlx>mStft{-O>@OxQ88nq8T&jT{E{KrpDU@qNAGb&|K_%CnrpX~oLMHoR8!1@) z-$m>1C6I^=#A{Idc}lyeOtY~-@uc;gHTUU{wa6~4%iLI36E(TRU`5?D+wdPhS0?%~ z_NR)_FYgBw?3HT}PQ(Y}xih(M`|~FYWQp}2nJV052|x^`3xAyk=0PdZ@K?$OeNa(V z&9zu*C^ng|lgHdMmYtP9GH zQ*`l3Mh%d0jc86YgK`_Z9xd?;8VtF;M5R^YnVBdhaj{y7Gh|N5{V4zNNm11@Zx*^_%=_ zDzVa3>uK>T#jzS_3PAf%tgYc6S(Q&Ptb%&X??#I&+wQ&P>MX#ah@`Zmd!&^_WAci9 zwcHi?4f4c{-enL)t`gvwe(z|q*PPO7$ubFsRh){yEXw;GOB{7Fy1%rbc+2A{nd^DN z7Dw>qF?4NFhSR{v3hN^{Ll@&x=|Gbj)FgMd5Miu-Gp3)OEO++S7DlvQshFf>&>$Sy zKz-ut`bYk0!F@?q(ZdSIn~Dyf(WLb{1oUt@3Mcb36e*CnzoULlf1Ap?|L$2yF@r`S zuI<^c$mN+Piu=aJkx^awWVVDd{oYILez1)tgq75*o&@AOz(7>ls4QEU31xyGfS9u` z4Lj4BpzBv(;FFu1Q*9+$@A_Kt1YV~=X)yhjNB<(OIQ-wM(Fr_7^KCb+V(w=jKVZR$ z^m_`9w^PSGMXV@dwz*4^enmQ$mZ{4*lWtRtD^|WMxkkIbE!jdsHA(}QJGkHxLYicc zAhIf?z=0bg%VJiXnsRB%*~|&7-4XhC=ivWLQeT3ndgl~zaVT8gOfQBPUrRjH z%d{HGL-BJyZx@pg3FNmJdR-UXe`(NmWZn$7?om%2k2}eU)+=es%b@(n_!J13@q|$x zPe}iWQKsw`vFAGc;+#EOx3?+yH8Zru!933+50moQNxxP5@L&AZ_m9%eS_?p+xgHWD zNd5<@0VlFmbW;WQ-(^K?9F zW-OQS7%V0td9ErvmWUJ%`Dj%sj>Q3-uIg^G@Kn5g*jREG$`F%0WxJf)N6ZI>ZY#+_ncfdPDtH74Ef@@@%<99o5COAodsB{EY`S2rJb zP=OswByUe<+?Gax6Zw<;uZ(NMEC&DC1snHZnm2hy+ye?dXyAEt{;(#hm#+KAr z#;DlS%X^Ip)=w{w`j*5D3e>x3SQ4W}l*P>noMDg9&`E48{KN!Du7NCkqNhtwy zUF|*hT#{2`(Wl($s-~q0WVMQ-qc^+7$`Bq%`?Ym-`44#L@Ie2wt65D12IMk|nzQ(q znv@HVzfihY0BWFg7ka&^K-~@E~D^xP-i-;fU)@SCtjKdx&u%1tkrQVy<3t za?w3*3)D&%Bg`XY{qfq}p8S5eJ!0gC^idxwUow^1@g3{{1;7PnrYOKR%^4{vgJ}!* zP0uZ9Zieam5SJKPP)>hQmHmwCS?v#$Zc5gM)XYN8T(1?@=Fazf!qf+DznN9=Tlac^ z6RcBFbgXE$AR_%7NnrH8x78>V& zL@0V~6N7+uU8dAcrA0F+Qau}$`tXil7MDSk9?vh?J9E-@)u<~fl^zEZK*bY0vpe+3 zEH&CqB2s0!5AB|Oy7L95RVXG;^W`=ilC1UEC**2Os(=ld|gY?qi1<;^JGH))*uudbZ(InR1j4PsqrA_e12oxp)oPyMGo~~1T zcp$h7b;^ZVBzfJCpbDvu0!&Io%d2!P6dzMrEZ1_+%dlqK%DEjYWaZ+sNMTmD28i)u z3WxNV;Djnxzb~|EvY+h5bFwY6{HNpR)j+E!esltOE~B}Vv5rdf++7;4=40dIv$$CK zoOsX4ttv-SCV%lgJ}7@0LnT&Wm`jL_PgKn?zA8ZBZo^*R8^D=q=jL)h3!K8=RTjw5 z^t^)H-=Pg8lAP$NbJ5+C$1o8Q&enKqGz7FzgPbW%LXqw=ND~K z^7&HgOocdorxy6ydrm^E^1$4!>*OuBl_urytBcU?^Q9dGAdtl-6qYt}3~6^vt2wf) z(;zA|aTgL}5cW^r#(uub|UmW_q|ln@QHPf4VuY=Dz#rFm*;pML{EvdB&l+I5_?qcRWjYwWk!- z{%D&f?F$senfB#P0+luRm@_it?7u;W_K)dD!!Pu`N@M9T6V5M56V4kL#>y2 zMTH8jT)yr&c9(NORAk1;mBG~PcK+a zqc1)mHf8=02`D|7&yB(QRqhx#sj-9wy~^ouG_D%b1F+XlVg(N|)PjH(bfNU1S}>h5 z2)!l~VuSx7j6ue#{RXy!MN~FjfGGZTYiOm=p zwbFMK6U*Hp<DUwOy~2z#_<59^ z$ZK$2Cd;<+`;(HY>ReZ@`O~3k*E!z(f{$&!6*X(dTXl7}z2K`)v2)oeV(!Z7mk~3XSl&S+z9syA}0~YVJQ5 z=X&oDwCpI2@_uYR0>oGE#{w!+vJjXTG=Tns0rrr|RcrY8bg40w9QYJdjzHZnVQed>IO7$0WKZDMW6iZdR*h`rAqA1^~73qI6egCRo z{v)j=kH+e>8|dP?WnzE)AgHK#bc(vOxZ}zGZu0TzLEBw&l$4yb_{rV17)qI1R?k)# zLS(pm4Y{E3vcQ~xKg3j4mX&UvH(6x#bWHEoWVkVGU-y)BYnyfFX6?=Xa!cRmy!l;g z8i(CqTKY&Ma$GKURmu=)<%$a75N`yGjp*1l{sj3=@+$|N0PDWrwoS>;Fa#{Pr;`E% z+|W;O$4;v+<}DBSD(KPtCH8<5e@F(gX^Sw}*sSIAZ*mk@Eh~&L!TUa|W6PD+>?_

8hacM6t!uxv&ukAs`xUjwi1SGn?e4EIi}V5z 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%0A`i`Jt3)*0qWQ+1tn`YYk*;_LA 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 literal 0 HcmV?d00001 From 824a75101ff4f0ad7b1b9574ef05ff17001c6d2a Mon Sep 17 00:00:00 2001 From: ProperSAMA <997794945@qq.com> Date: Thu, 13 Mar 2025 10:25:41 +0800 Subject: [PATCH 151/162] =?UTF-8?q?fix(NAS=E9=83=A8=E7=BD=B2=E6=8C=87?= =?UTF-8?q?=E5=8D=97):=20=E4=BF=AE=E5=A4=8D=E6=88=AA=E5=9B=BE=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E6=98=BE=E7=A4=BA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/synology_deploy.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/synology_deploy.md b/docs/synology_deploy.md index 1f13503f3..23e24e704 100644 --- a/docs/synology_deploy.md +++ b/docs/synology_deploy.md @@ -16,7 +16,7 @@ docker-compose.yml: https://github.com/SengokuCola/MaiMBot/blob/main/docker-compose.yml 下载后打开,将 `services-mongodb-image` 修改为 `mongo:4.4.24`。这是因为最新的 MongoDB 强制要求 AVX 指令集,而群晖似乎不支持这个指令集 -![](/Users/propersama/Desktop/synology_docker-compose.png) +![](https://raw.githubusercontent.com/ProperSAMA/MaiMBot/refs/heads/debug/docs/synology_docker-compose.png) bot_config.toml: https://github.com/SengokuCola/MaiMBot/blob/main/template/bot_config_template.toml 下载后,重命名为 `bot_config.toml` @@ -25,13 +25,13 @@ bot_config.toml: https://github.com/SengokuCola/MaiMBot/blob/main/template/bot_c .env.prod: https://github.com/SengokuCola/MaiMBot/blob/main/template.env 下载后,重命名为 `.env.prod` 按下图修改 mongodb 设置,使用 `MONGODB_URI` -![](/Users/propersama/Desktop/synology_.env.prod.png) +![](https://raw.githubusercontent.com/ProperSAMA/MaiMBot/refs/heads/debug/docs/synology_.env.prod.png) 把 `bot_config.toml` 和 `.env.prod` 放入之前创建的 `MaiMBot`文件夹 #### 如何下载? -点这里!![](/Users/propersama/Desktop/synology_how_to_download.png) +点这里!![](https://raw.githubusercontent.com/ProperSAMA/MaiMBot/refs/heads/debug/docs/synology_how_to_download.png) ### 创建项目 @@ -44,7 +44,7 @@ bot_config.toml: https://github.com/SengokuCola/MaiMBot/blob/main/template/bot_c 图例: -![](/Users/propersama/Desktop/synology_create_project.png) +![](https://raw.githubusercontent.com/ProperSAMA/MaiMBot/refs/heads/debug/docs/synology_create_project.png) 一路点下一步,等待项目创建完成 From 554380415b2be20f8f296ff0589a9502b47ab284 Mon Sep 17 00:00:00 2001 From: MuWinds Date: Thu, 13 Mar 2025 10:55:44 +0800 Subject: [PATCH 152/162] =?UTF-8?q?Fix:=E6=8E=A8=E7=90=86gui=E6=8A=A5Impor?= =?UTF-8?q?tError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/reasoning_gui.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/gui/reasoning_gui.py b/src/gui/reasoning_gui.py index e79f8f91f..c577ba3ae 100644 --- a/src/gui/reasoning_gui.py +++ b/src/gui/reasoning_gui.py @@ -7,7 +7,7 @@ from datetime import datetime from typing import Dict, List from loguru import logger from typing import Optional -from ..common.database import db + import customtkinter as ctk from dotenv import load_dotenv @@ -16,6 +16,8 @@ from dotenv import load_dotenv current_dir = os.path.dirname(os.path.abspath(__file__)) # 获取项目根目录 root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) +sys.path.insert(0, root_dir) +from src.common.database import db # 加载环境变量 if os.path.exists(os.path.join(root_dir, '.env.dev')): From 48b1953e7e62f046aa352f24827c952807f6dcca Mon Sep 17 00:00:00 2001 From: HYY Date: Thu, 13 Mar 2025 12:00:34 +0800 Subject: [PATCH 153/162] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E6=8E=A5=E6=94=B6At=E5=85=A8=E4=BD=93=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/cq_code.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index bc40cff80..049419f1c 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -86,9 +86,12 @@ class CQCode: else: self.translated_segments = Seg(type="text", data="[图片]") elif self.type == "at": - user_nickname = get_user_nickname(self.params.get("qq", "")) - self.translated_segments = Seg( - type="text", data=f"[@{user_nickname or '某人'}]" + if self.params.get("qq") == "all": + self.translated_segments = Seg(type="text", data="@[全体成员]") + else: + user_nickname = get_user_nickname(self.params.get("qq", "")) + self.translated_segments = Seg( + type="text", data=f"[@{user_nickname or '某人'}]" ) elif self.type == "reply": reply_segments = self.translate_reply() From 2e562b0971ee0395b39ab54bf63b34599a77818d Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Thu, 13 Mar 2025 12:56:53 +0800 Subject: [PATCH 154/162] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9B=B8?= =?UTF-8?q?=E5=90=8C=E5=9B=BE=E7=89=87=E4=B8=8D=E5=90=8C=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E7=9A=84=E9=87=8D=E5=A4=8Dhash=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D@=E5=85=A8=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/utils_image.py | 72 ++++++++++++++------------------- src/plugins/chat/utils_user.py | 12 ++++-- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index cc3a6ca3d..dd6d7d4d1 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -44,18 +44,23 @@ class ImageManager: """确保images集合存在并创建索引""" if "images" not in db.list_collection_names(): db.create_collection("images") - # 创建索引 - db.images.create_index([("hash", 1)], unique=True) - db.images.create_index([("url", 1)]) - db.images.create_index([("path", 1)]) + + # 删除旧索引 + db.images.drop_indexes() + # 创建新的复合索引 + db.images.create_index([("hash", 1), ("type", 1)], unique=True) + db.images.create_index([("url", 1)]) + db.images.create_index([("path", 1)]) def _ensure_description_collection(self): """确保image_descriptions集合存在并创建索引""" if "image_descriptions" not in db.list_collection_names(): db.create_collection("image_descriptions") - # 创建索引 - db.image_descriptions.create_index([("hash", 1)], unique=True) - db.image_descriptions.create_index([("type", 1)]) + + # 删除旧索引 + db.image_descriptions.drop_indexes() + # 创建新的复合索引 + db.image_descriptions.create_index([("hash", 1), ("type", 1)], unique=True) def _get_description_from_db(self, image_hash: str, description_type: str) -> Optional[str]: """从数据库获取图片描述 @@ -78,36 +83,21 @@ class ImageManager: description: 描述文本 description_type: 描述类型 ('emoji' 或 'image') """ - db.image_descriptions.update_one( - {"hash": image_hash, "type": description_type}, - {"$set": {"description": description, "timestamp": int(time.time())}}, - upsert=True, - ) - - async def get_image_by_url(self, url: str) -> Optional[str]: - """根据URL获取图像路径(带查重) - Args: - url: 图像URL - Returns: - str: 本地文件路径,不存在返回None - """ try: - # 先查找是否已存在 - existing = db.images.find_one({"url": url}) - if existing: - return existing["path"] - - # 下载图像 - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - if resp.status == 200: - image_bytes = await resp.read() - return await self.save_image(image_bytes, url=url) - return None - + db.image_descriptions.update_one( + {"hash": image_hash, "type": description_type}, + { + "$set": { + "description": description, + "timestamp": int(time.time()), + "hash": image_hash, # 确保hash字段存在 + "type": description_type, # 确保type字段存在 + } + }, + upsert=True, + ) except Exception as e: - logger.error(f"获取图像失败: {str(e)}") - return None + logger.error(f"保存描述到数据库失败: {str(e)}") async def get_emoji_description(self, image_base64: str) -> str: """获取表情包描述,带查重和保存功能""" @@ -129,7 +119,7 @@ class ImageManager: cached_description = self._get_description_from_db(image_hash, "emoji") if cached_description: - logger.warning(f"虽然生成了描述,但找到缓存表情包描述: {cached_description}") + logger.warning(f"虽然生成了描述,但是找到缓存表情包描述: {cached_description}") return f"[表情包:{cached_description}]" # 根据配置决定是否保存图片 @@ -170,7 +160,6 @@ class ImageManager: async def get_image_description(self, image_base64: str) -> str: """获取普通图片描述,带查重和保存功能""" try: - print("处理图片中") # 计算图片哈希 image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() @@ -179,7 +168,7 @@ class ImageManager: # 查询缓存的描述 cached_description = self._get_description_from_db(image_hash, "image") if cached_description: - print("图片描述缓存中") + logger.info(f"图片描述缓存中 {cached_description}") return f"[图片:{cached_description}]" # 调用AI获取描述 @@ -187,12 +176,13 @@ class ImageManager: "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" ) description, _ = await self._llm.generate_response_for_image(prompt, image_base64, image_format) - cached_description = self._get_description_from_db(image_hash, "emoji") + + cached_description = self._get_description_from_db(image_hash, "image") if cached_description: - logger.info(f"缓存图片描述: {cached_description}") + logger.warning(f"虽然生成了描述,但是找到缓存图片描述 {cached_description}") return f"[图片:{cached_description}]" - print(f"描述是{description}") + logger.info(f"描述是{description}") if description is None: logger.warning("AI未能生成图片描述") diff --git a/src/plugins/chat/utils_user.py b/src/plugins/chat/utils_user.py index 489eb7a1d..70aee5c06 100644 --- a/src/plugins/chat/utils_user.py +++ b/src/plugins/chat/utils_user.py @@ -3,16 +3,20 @@ from .relationship_manager import relationship_manager def get_user_nickname(user_id: int) -> str: + if user_id == "all": + return "全体成员" if int(user_id) == int(global_config.BOT_QQ): return global_config.BOT_NICKNAME -# print(user_id) + # print(user_id) return relationship_manager.get_name(user_id) + def get_user_cardname(user_id: int) -> str: if int(user_id) == int(global_config.BOT_QQ): return global_config.BOT_NICKNAME -# print(user_id) - return '' + # print(user_id) + return "" + def get_groupname(group_id: int) -> str: - return f"群{group_id}" \ No newline at end of file + return f"群{group_id}" From c83fbc1529f56ddc07e474d7e99c3ae657cf3de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E7=8C=AB?= Date: Thu, 13 Mar 2025 14:14:56 +0900 Subject: [PATCH 155/162] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20.pre-commit-config?= =?UTF-8?q?.yaml=20=EF=BC=88=E8=BF=99=E5=B9=B6=E4=B8=8D=E4=BC=9A=E5=90=AF?= =?UTF-8?q?=E7=94=A8hook=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..8a04e2d84 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.9.10 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format From 2caeb59f2875e6be64abd6e420fad1527c1b1a8c Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Thu, 13 Mar 2025 13:20:32 +0800 Subject: [PATCH 156/162] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E5=AE=89=E8=A3=85=20NapCat=E3=80=81MongoDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run.sh | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 110 insertions(+), 15 deletions(-) diff --git a/run.sh b/run.sh index 43f35c578..c3f6969b6 100644 --- a/run.sh +++ b/run.sh @@ -5,7 +5,10 @@ # 请小心使用任何一键脚本! # 如无法访问GitHub请修改此处镜像地址 -GITHUB_REPO="https://github.com/SengokuCola/MaiMBot.git" + +LANG=C.UTF-8 + +GITHUB_REPO="https://ghfast.top/https://github.com/SengokuCola/MaiMBot.git" # 颜色输出 GREEN="\e[32m" @@ -13,7 +16,7 @@ RED="\e[31m" RESET="\e[0m" # 需要的基本软件包 -REQUIRED_PACKAGES=("git" "sudo" "python3" "python3-venv" "python3-pip") +REQUIRED_PACKAGES=("git" "sudo" "python3" "python3-venv" "curl" "gnupg" "python3-pip") # 默认项目目录 DEFAULT_INSTALL_DIR="/opt/maimbot" @@ -21,6 +24,9 @@ DEFAULT_INSTALL_DIR="/opt/maimbot" # 服务名称 SERVICE_NAME="maimbot" +IS_INSTALL_MONGODB=false +IS_INSTALL_NAPCAT=false + # 1/6: 检测是否安装 whiptail if ! command -v whiptail &>/dev/null; then echo -e "${RED}[1/6] whiptail 未安装,正在安装...${RESET}" @@ -38,10 +44,26 @@ get_os_info() { echo "$OS_INFO" } +# 检查系统 check_system() { - OS_NAME=$(get_os_info) - whiptail --title "⚙️ [2/6] 检查系统" --yesno "本脚本仅支持Debian 12。\n当前系统为 $OS_NAME\n是否继续?" 10 60 || exit 1 + # 检查是否为 root 用户 + if [[ "$(id -u)" -ne 0 ]]; then + whiptail --title "🚫 权限不足" --msgbox "请使用 root 用户运行此脚本!\n执行方式: sudo bash $0" 10 60 + exit 1 + fi + + if [[ -f /etc/os-release ]]; then + source /etc/os-release + if [[ "$ID" != "debian" || "$VERSION_ID" != "12" ]]; then + whiptail --title "🚫 不支持的系统" --msgbox "此脚本仅支持 Debian 12 (Bookworm)!\n当前系统: $PRETTY_NAME\n安装已终止。" 10 60 + exit 1 + fi + else + whiptail --title "⚠️ 无法检测系统" --msgbox "无法识别系统版本,安装已终止。" 10 60 + exit 1 + fi } + # 3/6: 询问用户是否安装缺失的软件包 install_packages() { missing_packages=() @@ -52,11 +74,11 @@ install_packages() { done if [[ ${#missing_packages[@]} -gt 0 ]]; then - whiptail --title "📦 [3/6] 软件包检查" --yesno "检测到以下软件包缺失(MongoDB除外):\n${missing_packages[*]}\n\n是否要自动安装?" 12 60 + whiptail --title "📦 [3/6] 软件包检查" --yesno "检测到以下必须的依赖项目缺失:\n${missing_packages[*]}\n\n是否要自动安装?" 12 60 if [[ $? -eq 0 ]]; then - return + return 0 else - whiptail --title "⚠️ 注意" --yesno "某些必要的软件包未安装,可能会影响运行!\n是否继续?" 10 60 || exit 1 + whiptail --title "⚠️ 注意" --yesno "某些必要的依赖项未安装,可能会影响运行!\n是否继续?" 10 60 || exit 1 fi fi } @@ -105,21 +127,31 @@ confirm_install() { if [[ ${#missing_packages[@]} -gt 0 ]]; then confirm_message+="📦 安装缺失的依赖项: ${missing_packages[*]}\n" else - confirm_message+="✅ 所有依赖项已安装,无需额外安装\n" + confirm_message+="✅ 所有依赖项已安装\n" fi - confirm_message+="📂 安装目录: $INSTALL_DIR\n" + confirm_message+="📂 安装麦麦Bot到: $INSTALL_DIR\n" confirm_message+="🔀 分支: $BRANCH\n" - if dpkg -s mongodb-org &>/dev/null; then + if [[ "$MONGODB_INSTALLED" == "true" ]]; then confirm_message+="✅ MongoDB 已安装\n" else - confirm_message+="⚠️ MongoDB 可能未安装(请参阅官方文档安装)\n" + if [[ "$IS_INSTALL_MONGODB" == "true" ]]; then + confirm_message+="📦 安装 MongoDB\n" + fi fi - confirm_message+="🛠️ 添加 Maimbot 作为系统服务 ($SERVICE_NAME.service)\n" + if [[ "$NAPCAT_INSTALLED" == "true" ]]; then + confirm_message+="✅ NapCat 已安装\n" + else + if [[ "$IS_INSTALL_NAPCAT" == "true" ]]; then + confirm_message+="📦 安装 NapCat\n" + fi + fi - confirm_message+="\n\n注意:本脚本使用GitHub,如无法访问请手动修改仓库地址。" + confirm_message+="🛠️ 添加麦麦Bot作为系统服务 ($SERVICE_NAME.service)\n" + + confitm_message+="\n\n注意:本脚本默认使用ghfast.top为GitHub进行加速,如不想使用请手动修改脚本开头的GITHUB_REPO变量。" whiptail --title "🔧 安装确认" --yesno "$confirm_message\n\n是否继续安装?" 15 60 if [[ $? -ne 0 ]]; then whiptail --title "🚫 取消安装" --msgbox "安装已取消。" 10 60 @@ -127,21 +159,83 @@ confirm_install() { fi } +check_mongodb() { + if command -v mongod &>/dev/null; then + MONGO_INSTALLED=true + else + MONGO_INSTALLED=false + fi +} + +# 安装 MongoDB +install_mongodb() { + if [[ "$MONGO_INSTALLED" == "true" ]]; then + return 0 + fi + + whiptail --title "📦 [3/6] 软件包检查" --yesno "检测到未安装MongoDB,是否安装?\n如果您想使用远程数据库,请跳过此步。" 10 60 + if [[ $? -ne 0 ]]; then + return 1 + fi + IS_INSTALL_MONGODB=true +} + +check_napcat() { + if command -v napcat &>/dev/null; then + NAPCAT_INSTALLED=true + else + NAPCAT_INSTALLED=false + fi +} + +install_napcat() { + if [[ "$NAPCAT_INSTALLED" == "true" ]]; then + return 0 + fi + + whiptail --title "📦 [3/6] 软件包检查" --yesno "检测到未安装NapCat,是否安装?\n如果您想使用远程NapCat,请跳过此步。" 10 60 + if [[ $? -ne 0 ]]; then + return 1 + fi + IS_INSTALL_NAPCAT=true +} + # 运行安装步骤 check_system +check_mongodb +check_napcat install_packages +install_mongodb +install_napcat check_python choose_branch choose_install_dir confirm_install # 开始安装 -whiptail --title "🚀 开始安装" --msgbox "所有环境检查完毕,即将开始安装 Maimbot!" 10 60 +whiptail --title "🚀 开始安装" --msgbox "所有环境检查完毕,即将开始安装麦麦Bot!" 10 60 echo -e "${GREEN}安装依赖项...${RESET}" apt update && apt install -y "${missing_packages[@]}" + +if [[ "$IS_INSTALL_MONGODB" == "true" ]]; then + echo -e "${GREEN}安装 MongoDB...${RESET}" + curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor + echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] http://repo.mongodb.org/apt/debian bookworm/mongodb-org/8.0 main" | sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.list + apt-get update + apt-get install -y mongodb-org + + systemctl enable mongod + systemctl start mongod +fi + +if [[ "$IS_INSTALL_NAPCAT" == "true" ]]; then + echo -e "${GREEN}安装 NapCat...${RESET}" + curl -o napcat.sh https://nclatest.znin.net/NapNeko/NapCat-Installer/main/script/install.sh && bash napcat.sh +fi + echo -e "${GREEN}创建 Python 虚拟环境...${RESET}" mkdir -p "$INSTALL_DIR" cd "$INSTALL_DIR" || exit @@ -158,6 +252,7 @@ echo -e "${GREEN}安装 Python 依赖...${RESET}" pip install -r requirements.txt echo -e "${GREEN}设置服务...${RESET}" + # 设置 Maimbot 服务 cat < Date: Thu, 13 Mar 2025 13:25:26 +0800 Subject: [PATCH 157/162] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92fcdc318..a7394c7cf 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ - 📦 **Windows 一键傻瓜式部署**:请运行项目根目录中的 `run.bat`,部署完成后请参照后续配置指南进行配置 -- 📦 Linux 自动部署(实验) :请在项目根目录中运行 `bash 1key_install_linux.sh`并按照提示安装,部署完成后请参照后续配置指南进行配置 +- 📦 Linux 自动部署(实验) :请下载并运行项目根目录中的`run.sh`并按照提示安装,部署完成后请参照后续配置指南进行配置 - [📦 Windows 手动部署指南 ](docs/manual_deploy_windows.md) From 3e6290ad8c55e2155436c07c4c0aa4e45158961e Mon Sep 17 00:00:00 2001 From: Klu5ure Date: Thu, 13 Mar 2025 13:48:41 +0800 Subject: [PATCH 158/162] =?UTF-8?q?=E4=BD=BF=E7=94=A8os.path.join=E4=BB=A3?= =?UTF-8?q?=E6=9B=BF=E7=A1=AC=E7=BC=96=E7=A0=81=E8=B7=AF=E5=BE=84=E5=88=86?= =?UTF-8?q?=E9=9A=94=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 76437f8f2..e3342d1a7 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -25,7 +25,7 @@ image_manager = ImageManager() class EmojiManager: _instance = None - EMOJI_DIR = "data/emoji" # 表情包存储目录 + EMOJI_DIR = os.path.join("data", "emoji") # 表情包存储目录 def __new__(cls): if cls._instance is None: @@ -211,7 +211,7 @@ class EmojiManager: async def scan_new_emojis(self): """扫描新的表情包""" try: - emoji_dir = "data/emoji" + emoji_dir = self.EMOJI_DIR os.makedirs(emoji_dir, exist_ok=True) # 获取所有支持的图片文件 From c4d1df3d4812c640db2fed17e6f190e00fea21a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Thu, 13 Mar 2025 15:05:32 +0900 Subject: [PATCH 159/162] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0.gitignore?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0JetBrains=E7=9B=B8=E5=85=B3=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=92=8C=E7=9B=AE=E5=BD=95=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?.env=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3579444dc..b4c7154de 100644 --- a/.gitignore +++ b/.gitignore @@ -190,7 +190,6 @@ cython_debug/ # PyPI configuration file .pypirc -.env # jieba jieba.cache @@ -199,4 +198,9 @@ jieba.cache !.vscode/settings.json # direnv -/.direnv \ No newline at end of file +/.direnv + +# JetBrains +.idea +*.iml +*.ipr From c6738cd5b8a2dd58c5a3ebecd1a68d1f8a7ee815 Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Thu, 13 Mar 2025 14:48:35 +0800 Subject: [PATCH 160/162] =?UTF-8?q?fix:=20=E6=92=A4=E9=94=80at=20all?= =?UTF-8?q?=E7=9A=84=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/utils_user.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plugins/chat/utils_user.py b/src/plugins/chat/utils_user.py index 70aee5c06..90c93eeb2 100644 --- a/src/plugins/chat/utils_user.py +++ b/src/plugins/chat/utils_user.py @@ -3,8 +3,6 @@ from .relationship_manager import relationship_manager def get_user_nickname(user_id: int) -> str: - if user_id == "all": - return "全体成员" if int(user_id) == int(global_config.BOT_QQ): return global_config.BOT_NICKNAME # print(user_id) From 024bd1e649cff74b31e2738944c238261dcf1b41 Mon Sep 17 00:00:00 2001 From: UnCLAS-Prommer Date: Thu, 13 Mar 2025 15:51:39 +0800 Subject: [PATCH 161/162] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=97=A5=E7=A8=8B?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/__init__.py | 18 ++++++++---- src/plugins/schedule/schedule_generator.py | 33 ++++++++++++++-------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index d7a7bd7e4..da925bf38 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -4,7 +4,7 @@ import os from loguru import logger from nonebot import get_driver, on_message, require -from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment,MessageEvent +from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment, MessageEvent from nonebot.typing import T_State from ..moods.moods import MoodManager # 导入情绪管理器 @@ -99,15 +99,14 @@ async def _(bot: Bot, event: MessageEvent, state: T_State): @scheduler.scheduled_job("interval", seconds=global_config.build_memory_interval, id="build_memory") async def build_memory_task(): """每build_memory_interval秒执行一次记忆构建""" - logger.debug( - "[记忆构建]" - "------------------------------------开始构建记忆--------------------------------------") + logger.debug("[记忆构建]------------------------------------开始构建记忆--------------------------------------") start_time = time.time() await hippocampus.operation_build_memory(chat_size=20) end_time = time.time() logger.success( f"[记忆构建]--------------------------记忆构建完成:耗时: {end_time - start_time:.2f} " - "秒-------------------------------------------") + "秒-------------------------------------------" + ) @scheduler.scheduled_job("interval", seconds=global_config.forget_memory_interval, id="forget_memory") @@ -131,3 +130,12 @@ async def print_mood_task(): """每30秒打印一次情绪状态""" mood_manager = MoodManager.get_instance() mood_manager.print_mood_status() + + +@scheduler.scheduled_job("interval", seconds=30, id="generate_schedule") +async def generate_schedule_task(): + """每2小时尝试生成一次日程""" + logger.debug("尝试生成日程") + await bot_schedule.initialize() + if not bot_schedule.enable_output: + bot_schedule.print_schedule() diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index 5f62d6aca..2f96f3531 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -14,7 +14,10 @@ from ..models.utils_model import LLM_request driver = get_driver() config = driver.config + class ScheduleGenerator: + enable_output: bool = True + def __init__(self): # 根据global_config.llm_normal这一字典配置指定模型 # self.llm_scheduler = LLMModel(model = global_config.llm_normal,temperature=0.9) @@ -32,14 +35,16 @@ class ScheduleGenerator: yesterday = datetime.datetime.now() - datetime.timedelta(days=1) self.today_schedule_text, self.today_schedule = await self.generate_daily_schedule(target_date=today) - self.tomorrow_schedule_text, self.tomorrow_schedule = await self.generate_daily_schedule(target_date=tomorrow, - read_only=True) + self.tomorrow_schedule_text, self.tomorrow_schedule = await self.generate_daily_schedule( + target_date=tomorrow, read_only=True + ) self.yesterday_schedule_text, self.yesterday_schedule = await self.generate_daily_schedule( - target_date=yesterday, read_only=True) - - async def generate_daily_schedule(self, target_date: datetime.datetime = None, read_only: bool = False) -> Dict[ - str, str]: + target_date=yesterday, read_only=True + ) + async def generate_daily_schedule( + self, target_date: datetime.datetime = None, read_only: bool = False + ) -> Dict[str, str]: date_str = target_date.strftime("%Y-%m-%d") weekday = target_date.strftime("%A") @@ -47,28 +52,33 @@ class ScheduleGenerator: existing_schedule = db.schedule.find_one({"date": date_str}) if existing_schedule: - logger.debug(f"{date_str}的日程已存在:") + if self.enable_output: + logger.debug(f"{date_str}的日程已存在:") schedule_text = existing_schedule["schedule"] # print(self.schedule_text) elif not read_only: logger.debug(f"{date_str}的日程不存在,准备生成新的日程。") - prompt = f"""我是{global_config.BOT_NICKNAME},{global_config.PROMPT_SCHEDULE_GEN},请为我生成{date_str}({weekday})的日程安排,包括:""" + \ - """ + prompt = ( + f"""我是{global_config.BOT_NICKNAME},{global_config.PROMPT_SCHEDULE_GEN},请为我生成{date_str}({weekday})的日程安排,包括:""" + + """ 1. 早上的学习和工作安排 2. 下午的活动和任务 3. 晚上的计划和休息时间 请按照时间顺序列出具体时间点和对应的活动,用一个时间点而不是时间段来表示时间,用JSON格式返回日程表,仅返回内容,不要返回注释,不要添加任何markdown或代码块样式,时间采用24小时制,格式为{"时间": "活动","时间": "活动",...}。""" + ) try: schedule_text, _ = await self.llm_scheduler.generate_response(prompt) db.schedule.insert_one({"date": date_str, "schedule": schedule_text}) + self.enable_output = True except Exception as e: logger.error(f"生成日程失败: {str(e)}") schedule_text = "生成日程时出错了" # print(self.schedule_text) else: - logger.debug(f"{date_str}的日程不存在。") + if self.enable_output: + logger.debug(f"{date_str}的日程不存在。") schedule_text = "忘了" return schedule_text, None @@ -95,7 +105,7 @@ class ScheduleGenerator: # 找到最接近当前时间的任务 closest_time = None - min_diff = float('inf') + min_diff = float("inf") # 检查今天的日程 if not self.today_schedule: @@ -148,6 +158,7 @@ class ScheduleGenerator: for time_str, activity in self.today_schedule.items(): logger.info(f"时间[{time_str}]: 活动[{activity}]") logger.info("==================") + self.enable_output = False # def main(): From a7284cfa8b102e230f2f60b745aeecba016da5a3 Mon Sep 17 00:00:00 2001 From: UnCLAS-Prommer Date: Thu, 13 Mar 2025 16:02:38 +0800 Subject: [PATCH 162/162] Fix Minor bug --- src/plugins/chat/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index 5dc84f1d1..6dde80d24 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -141,7 +141,7 @@ async def print_mood_task(): mood_manager.print_mood_status() -@scheduler.scheduled_job("interval", seconds=30, id="generate_schedule") +@scheduler.scheduled_job("interval", seconds=7200, id="generate_schedule") async def generate_schedule_task(): """每2小时尝试生成一次日程""" logger.debug("尝试生成日程")

whm?T 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 literal 0 HcmV?d00001 diff --git a/docs/MONGO_DB_2.png b/docs/MONGO_DB_2.png new file mode 100644 index 0000000000000000000000000000000000000000..e59cc8793ed0fb531030177ecf8808bced1e7825 GIT binary patch literal 31358 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 literal 0 HcmV?d00001 diff --git a/docs/fast_q_a.md b/docs/fast_q_a.md new file mode 100644 index 000000000..095cd90c1 --- /dev/null +++ b/docs/fast_q_a.md @@ -0,0 +1,109 @@ +## 快速更新Q&A❓ + +
+ +- 这个文件用来记录一些常见的新手问题。 + +
+ +### Api相关问题 + +
+ +
+ +- 为什么显示:"缺失必要的API KEY" ❓ + +
+ + + + + +--- + +>
+> 你需要在 [Silicon Flow Api](https://cloud.siliconflow.cn/account/ak) 网站上注册一个账号,然后点击这个链接打开API KEY获取页面。点击 "新建API密钥" 按钮新建一个给MaiMBot使用的API KEY。不要忘了点击复制。 +> +> 之后打开MaiMBot在你电脑上的文件根目录,使用记事本或者其他文本编辑器打开 [.env.prod](../.env.prod) 这个文件。把你刚才复制的API KEY填入到 "SILICONFLOW_KEY=" 这个等号的右边。 +> +>在默认情况下,MaiMBot使用的默认Api都是硅基流动的。 +>
+ +
+ +
+ + +- 我想使用硅基流动之外的Api网站,我应该怎么做 ❓ + +--- + +>
+>你需要使用记事本或者其他文本编辑器打开config目录下的 [bot_config.toml](../config/bot_config.toml) ,然后修改其中的 "provider = " 字段。同时不要忘记模仿 [.env.prod](../.env.prod) 文件的写法添加 Api Key 和 Base URL。 +> +>举个例子,如果你写了 " provider = \"ABC\" ",那你需要相应的在 [.env.prod](../.env.prod) 文件里添加形如 +> " ABC_BASE_URL = https://api.abc.com/v1 " 和 " ABC_KEY = sk-1145141919810 " 的字段。 +> +>**如果你对AI没有较深的了解,修改识图模型和嵌入模型的provider字段可能会产生bug,因为你从Api网站调用了一个并不存在的模型** +> +>这个时候,你需要把字段的值改回 "provider = \"SILICONFLOW\" " 以此解决bug。 +
+ +
+ +
+ +### MongoDB相关问题 + +
+ +- 我应该怎么清空bot内存储的表情包 ❓ + +--- + +>
+>打开你的MongoDB Compass软件,你会在左上角看到这样的一个界面: +>
+> +> +>点击 "CONNECT" 之后,点击展开 MegBot 标签栏 +> +> +> +>点进 "emoji" 再点击 "DELETE" 删掉所有条目,如图所示 +> +> +> +>你可以用类似的方式手动清空MaiMBot的所有服务器数据。 +> +>MaiMBot的所有图片均储存在 [data](../data) 文件夹内,按类型分为 [emoji](../data/emoji) 和 [image](../data/image) +> +>在删除服务器数据时不要忘记清空这些图片。 +>
+ +
+ +- 为什么我连接不上MongoDB服务器 ❓ + +--- + + +>
+> +>这个问题比较复杂,但是你可以按照下面的步骤检查,看看具体是什么问题 +> +>
+> +> 1. 检查有没有把 mongod.exe 所在的目录添加到 path。 具体可参照 +> +>
+> +> [CSDN-windows10设置环境变量Path详细步骤](https://blog.csdn.net/flame_007/article/details/106401215) +> +>
+> +> **需要往path里填入的是 exe 所在的完整目录!不带 exe 本体** +> +> 2. 待完成 +> \ No newline at end of file From 86ca4aeebe2d73f80d263d793838afffdaeacf44 Mon Sep 17 00:00:00 2001 From: Twds_0x13 Date: Wed, 12 Mar 2025 02:21:44 +0800 Subject: [PATCH 103/162] =?UTF-8?q?=E6=95=99=E7=A8=8B=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/fast_q_a.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/fast_q_a.md b/docs/fast_q_a.md index 095cd90c1..c8d398084 100644 --- a/docs/fast_q_a.md +++ b/docs/fast_q_a.md @@ -6,6 +6,14 @@
+### 完整安装教程 + +
+ +[MaiMbot简易配置教程](https://www.bilibili.com/video/BV1zsQ5YCEE6) + +
+ ### Api相关问题
From 524014bd9077f3fe93665ae5005baf551e39c5d3 Mon Sep 17 00:00:00 2001 From: Twds_0x13 Date: Wed, 12 Mar 2025 02:35:45 +0800 Subject: [PATCH 104/162] =?UTF-8?q?=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/fast_q_a.md | 52 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/fast_q_a.md b/docs/fast_q_a.md index c8d398084..3b995e24a 100644 --- a/docs/fast_q_a.md +++ b/docs/fast_q_a.md @@ -30,12 +30,20 @@ --- +
+ >
-> 你需要在 [Silicon Flow Api](https://cloud.siliconflow.cn/account/ak) 网站上注册一个账号,然后点击这个链接打开API KEY获取页面。点击 "新建API密钥" 按钮新建一个给MaiMBot使用的API KEY。不要忘了点击复制。 > -> 之后打开MaiMBot在你电脑上的文件根目录,使用记事本或者其他文本编辑器打开 [.env.prod](../.env.prod) 这个文件。把你刚才复制的API KEY填入到 "SILICONFLOW_KEY=" 这个等号的右边。 +>你需要在 [Silicon Flow Api](https://cloud.siliconflow.cn/account/ak) +>网站上注册一个账号,然后点击这个链接打开API KEY获取页面。 +> +>点击 "新建API密钥" 按钮新建一个给MaiMBot使用的API KEY。不要忘了点击复制。 +> +>之后打开MaiMBot在你电脑上的文件根目录,使用记事本或者其他文本编辑器打开 [.env.prod](../.env.prod) +>这个文件。把你刚才复制的API KEY填入到 "SILICONFLOW_KEY=" 这个等号的右边。 > >在默认情况下,MaiMBot使用的默认Api都是硅基流动的。 +> >

@@ -47,18 +55,23 @@ --- +
+ >
->你需要使用记事本或者其他文本编辑器打开config目录下的 [bot_config.toml](../config/bot_config.toml) ,然后修改其中的 "provider = " 字段。同时不要忘记模仿 [.env.prod](../.env.prod) 文件的写法添加 Api Key 和 Base URL。 > ->举个例子,如果你写了 " provider = \"ABC\" ",那你需要相应的在 [.env.prod](../.env.prod) 文件里添加形如 -> " ABC_BASE_URL = https://api.abc.com/v1 " 和 " ABC_KEY = sk-1145141919810 " 的字段。 +>你需要使用记事本或者其他文本编辑器打开config目录下的 [bot_config.toml](../config/bot_config.toml) +>然后修改其中的 "provider = " 字段。同时不要忘记模仿 [.env.prod](../.env.prod) +>文件的写法添加 Api Key 和 Base URL。 +> +>举个例子,如果你写了 " provider = \"ABC\" ",那你需要相应的在 [.env.prod](../.env.prod) +>文件里添加形如 " ABC_BASE_URL = https://api.abc.com/v1 " 和 " ABC_KEY = sk-1145141919810 " 的字段。 > >**如果你对AI没有较深的了解,修改识图模型和嵌入模型的provider字段可能会产生bug,因为你从Api网站调用了一个并不存在的模型** > >这个时候,你需要把字段的值改回 "provider = \"SILICONFLOW\" " 以此解决bug。 -
+> +>
-

@@ -70,24 +83,40 @@ --- +
+ >
+> >打开你的MongoDB Compass软件,你会在左上角看到这样的一个界面: +> >
+> > > +>
+> >点击 "CONNECT" 之后,点击展开 MegBot 标签栏 > +>
+> > > +>
+> >点进 "emoji" 再点击 "DELETE" 删掉所有条目,如图所示 > +>
+> > > +>
+> >你可以用类似的方式手动清空MaiMBot的所有服务器数据。 > >MaiMBot的所有图片均储存在 [data](../data) 文件夹内,按类型分为 [emoji](../data/emoji) 和 [image](../data/image) > >在删除服务器数据时不要忘记清空这些图片。 +> >

@@ -107,11 +136,14 @@ > >
> -> [CSDN-windows10设置环境变量Path详细步骤](https://blog.csdn.net/flame_007/article/details/106401215) +>  [CSDN-windows10设置环境变量Path详细步骤](https://blog.csdn.net/flame_007/article/details/106401215) > >
> -> **需要往path里填入的是 exe 所在的完整目录!不带 exe 本体** +>  **需要往path里填入的是 exe 所在的完整目录!不带 exe 本体** +> +>
> > 2. 待完成 -> \ No newline at end of file +> +>
\ No newline at end of file From 90d169f740230ef637015677f5161d2047d9d5b6 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Wed, 12 Mar 2025 07:44:57 +0800 Subject: [PATCH 105/162] =?UTF-8?q?=E5=B0=86=E4=BB=B7=E6=A0=BCprint?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=AF=B9=E5=BA=94=E7=9A=84logger=E8=BE=93?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 4 ++-- src/plugins/utils/typo_generator.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index f335a2ba3..f179e8ef3 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -235,10 +235,10 @@ class ChatBot: is_head=not mark_head, is_emoji=False, ) - print(f"bot_message: {bot_message}") + logger.debug(f"bot_message: {bot_message}") if not mark_head: mark_head = True - print(f"添加消息到message_set: {bot_message}") + logger.debug(f"添加消息到message_set: {bot_message}") message_set.add_message(bot_message) # message_set 可以直接加入 message_manager diff --git a/src/plugins/utils/typo_generator.py b/src/plugins/utils/typo_generator.py index aa72c387f..f99a7ab20 100644 --- a/src/plugins/utils/typo_generator.py +++ b/src/plugins/utils/typo_generator.py @@ -13,6 +13,8 @@ from pathlib import Path import jieba from pypinyin import Style, pinyin +from loguru import logger + class ChineseTypoGenerator: def __init__(self, @@ -38,7 +40,9 @@ class ChineseTypoGenerator: self.max_freq_diff = max_freq_diff # 加载数据 - print("正在加载汉字数据库,请稍候...") + # print("正在加载汉字数据库,请稍候...") + logger.info("正在加载汉字数据库,请稍候...") + self.pinyin_dict = self._create_pinyin_dict() self.char_frequency = self._load_or_create_char_frequency() From 126c9af70e8f3bc6ae3e19bf8a29a90c4c4d1155 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Wed, 12 Mar 2025 07:58:27 +0800 Subject: [PATCH 106/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=81=AB=E5=B1=B1?= =?UTF-8?q?=E9=83=A8=E5=88=86=E6=B5=81=E5=BC=8F=E8=BE=93=E5=87=BA=E6=B2=A1?= =?UTF-8?q?=E6=9C=89finish=5Freason=E5=AF=BC=E8=87=B4=E7=9A=84=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/models/utils_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index 75b46f611..5335e3d65 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -235,7 +235,7 @@ class LLM_request: delta_content = "" accumulated_content += delta_content # 检测流式输出文本是否结束 - finish_reason = chunk["choices"][0]["finish_reason"] + finish_reason = chunk["choices"][0].get("finish_reason") if finish_reason == "stop": usage = chunk.get("usage", None) if usage: From 27be42b8f0f683da455c19ce07ee36ea0630e75c Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Wed, 12 Mar 2025 08:25:55 +0800 Subject: [PATCH 107/162] =?UTF-8?q?=E4=BF=AE=E6=AD=A3willing=5Fmanager?= =?UTF-8?q?=E5=86=85=E5=9B=A0=E4=B8=BA=E4=BB=A3=E7=A0=81=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E5=86=97=E4=BD=99,=E5=8E=BB?= =?UTF-8?q?=E9=99=A4=E5=87=A0=E4=B8=AAprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/willing_manager.py | 80 ++++++++++++++--------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index f34afb746..773d40c6e 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -5,101 +5,98 @@ from typing import Dict from .config import global_config from .chat_stream import ChatStream +from loguru import logger + class WillingManager: def __init__(self): - self.chat_reply_willing: Dict[str, float] = {} # 存储每个聊天流的回复意愿 self.chat_reply_willing: Dict[str, float] = {} # 存储每个聊天流的回复意愿 self._decay_task = None self._started = False - + async def _decay_reply_willing(self): """定期衰减回复意愿""" while True: await asyncio.sleep(5) for chat_id in self.chat_reply_willing: self.chat_reply_willing[chat_id] = max(0, self.chat_reply_willing[chat_id] * 0.6) - for chat_id in self.chat_reply_willing: - self.chat_reply_willing[chat_id] = max(0, self.chat_reply_willing[chat_id] * 0.6) - - def get_willing(self,chat_stream:ChatStream) -> float: + + def get_willing(self, chat_stream: ChatStream) -> float: """获取指定聊天流的回复意愿""" stream = chat_stream if stream: return self.chat_reply_willing.get(stream.stream_id, 0) return 0 - + def set_willing(self, chat_id: str, willing: float): """设置指定聊天流的回复意愿""" self.chat_reply_willing[chat_id] = willing - def set_willing(self, chat_id: str, willing: float): - """设置指定聊天流的回复意愿""" - self.chat_reply_willing[chat_id] = willing - - async def change_reply_willing_received(self, - chat_stream:ChatStream, - topic: str = None, - is_mentioned_bot: bool = False, - config = None, - is_emoji: bool = False, - interested_rate: float = 0) -> float: + + async def change_reply_willing_received( + self, + chat_stream: ChatStream, + topic: str = None, + is_mentioned_bot: bool = False, + config=None, + is_emoji: bool = False, + interested_rate: float = 0, + ) -> float: """改变指定聊天流的回复意愿并返回回复概率""" # 获取或创建聊天流 stream = chat_stream chat_id = stream.stream_id - + current_willing = self.chat_reply_willing.get(chat_id, 0) - - # print(f"初始意愿: {current_willing}") + if is_mentioned_bot and current_willing < 1.0: current_willing += 0.9 - print(f"被提及, 当前意愿: {current_willing}") + logger.debug(f"被提及, 当前意愿: {current_willing}") elif is_mentioned_bot: current_willing += 0.05 - print(f"被重复提及, 当前意愿: {current_willing}") - + logger.debug(f"被重复提及, 当前意愿: {current_willing}") + if is_emoji: current_willing *= 0.1 - print(f"表情包, 当前意愿: {current_willing}") - - print(f"放大系数_interested_rate: {global_config.response_interested_rate_amplifier}") - interested_rate *= global_config.response_interested_rate_amplifier #放大回复兴趣度 + logger.debug(f"表情包, 当前意愿: {current_willing}") + + logger.debug(f"放大系数_interested_rate: {global_config.response_interested_rate_amplifier}") + interested_rate *= global_config.response_interested_rate_amplifier # 放大回复兴趣度 if interested_rate > 0.4: # print(f"兴趣度: {interested_rate}, 当前意愿: {current_willing}") - current_willing += interested_rate-0.4 - - current_willing *= global_config.response_willing_amplifier #放大回复意愿 + current_willing += interested_rate - 0.4 + + current_willing *= global_config.response_willing_amplifier # 放大回复意愿 # print(f"放大系数_willing: {global_config.response_willing_amplifier}, 当前意愿: {current_willing}") - + reply_probability = max((current_willing - 0.45) * 2, 0) - + # 检查群组权限(如果是群聊) - if chat_stream.group_info: + if chat_stream.group_info: if chat_stream.group_info.group_id in config.talk_frequency_down_groups: reply_probability = reply_probability / global_config.down_frequency_rate reply_probability = min(reply_probability, 1) if reply_probability < 0: reply_probability = 0 - + self.chat_reply_willing[chat_id] = min(current_willing, 3.0) return reply_probability - - def change_reply_willing_sent(self, chat_stream:ChatStream): + + def change_reply_willing_sent(self, chat_stream: ChatStream): """开始思考后降低聊天流的回复意愿""" stream = chat_stream if stream: current_willing = self.chat_reply_willing.get(stream.stream_id, 0) self.chat_reply_willing[stream.stream_id] = max(0, current_willing - 2) - - def change_reply_willing_after_sent(self,chat_stream:ChatStream): + + def change_reply_willing_after_sent(self, chat_stream: ChatStream): """发送消息后提高聊天流的回复意愿""" stream = chat_stream if stream: current_willing = self.chat_reply_willing.get(stream.stream_id, 0) if current_willing < 1: self.chat_reply_willing[stream.stream_id] = min(1, current_willing + 0.2) - + async def ensure_started(self): """确保衰减任务已启动""" if not self._started: @@ -107,5 +104,6 @@ class WillingManager: self._decay_task = asyncio.create_task(self._decay_reply_willing()) self._started = True + # 创建全局实例 -willing_manager = WillingManager() \ No newline at end of file +willing_manager = WillingManager() From e9f3ec89d52156e292999b16451e10000af98305 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Wed, 12 Mar 2025 08:28:00 +0800 Subject: [PATCH 108/162] =?UTF-8?q?=E7=BB=99cq=5Fcode=E5=8A=A0=E4=B8=AAimp?= =?UTF-8?q?ort=20os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/cq_code.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins/chat/cq_code.py b/src/plugins/chat/cq_code.py index 0a8a71df3..bc40cff80 100644 --- a/src/plugins/chat/cq_code.py +++ b/src/plugins/chat/cq_code.py @@ -4,6 +4,8 @@ import time from dataclasses import dataclass from typing import Dict, List, Optional, Union +import os + import requests # 解析各种CQ码 From 5040e7111a9d523d07a393b4f92780ff632bfe29 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Wed, 12 Mar 2025 08:28:41 +0800 Subject: [PATCH 109/162] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E8=8E=AB=E5=90=8D=E5=85=B6=E5=A6=99=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hort --pretty=format-ad -s | 141 ------------------------------------ 1 file changed, 141 deletions(-) delete mode 100644 hort --pretty=format-ad -s diff --git a/hort --pretty=format-ad -s b/hort --pretty=format-ad -s deleted file mode 100644 index faeacdd5f..000000000 --- a/hort --pretty=format-ad -s +++ /dev/null @@ -1,141 +0,0 @@ -cbb569e - Create 如果你更新了版本,点我.txt -a91ef7b - 自动升级配置文件脚本 -ed18f2e - 新增了知识库一键启动漂亮脚本 -80ed568 - fix: 删除print调试代码 -c681a82 - 修复小名无效问题 -e54038f - fix: 从 nixpkgs 增加 numpy 依赖,以避免出现 libc++.so 找不到的问题 -26782c9 - fix: 修复 ENVIRONMENT 变量在同一终端下不能被覆盖的问题 -8c34637 - 提高健壮性 -2688a96 - close SengokuCola/MaiMBot#225 让麦麦可以正确读取分享卡片 -cd16e68 - 修复表情包发送时的缺失参数 -b362c35 - feat: 更新 flake.nix ,采用 venv 的方式生成环境,nixos用户也可以本机运行项目了 -3c8c897 - 屏蔽一个臃肿的debug信息 -9d0152a - 修复了合并过程中造成的代码重复 -956135c - 添加一些注释 -a412741 - 将print变为logger.debug -3180426 - 修复了没有改掉的typo字段 -aea3bff - 添加私聊过滤开关,更新config,增加约束 -cda6281 - chore: update emoji_manager.py -baed856 - 修正了私聊屏蔽词输出 -66a0f18 - 修复了私聊时产生reply消息的bug -3bf5cd6 - feat: 新增运行时重载配置文件;新增根据不同环境(dev;prod)显示不同级别的log -33cd83b - 添加私聊功能 -aa41f0d - fix: 放反了 -ef8691c - fix: 修改message继承逻辑,修复回复消息无法识别 -7d017be - fix:模型降级 -e1019ad - fix: 修复变量拼写错误并优化代码可读性 -c24bb70 - fix: 流式输出模式增加结束判断与token用量记录 -60a9376 - 添加logger的debug输出开关,默认为不开启 -bfa9a3c - fix: 添加群信息获取的错误处理 (#173) -4cc5c8e - 修正.env.prod和.env.dev的生成 -dea14c1 - fix: 模型降级目前只对硅基流动的V3和R1生效 -b6edbea - fix: 图片保存路径不正确 -01a6fa8 - fix: 删除神秘test -20f009d - 修复systemctl强制停止maimbot的问题 -af962c2 - 修复了情绪管理器没有正确导入导致发布出消息 -0586700 - 按照Sourcery提供的建议修改systemctl管理指南 -e48b32a - 在手动部署教程中增加使用systemctl管理 -5760412 - fix: 小修 -1c9b0cc - fix: 修复部分cq码解析错误,merge -b6867b9 - fix: 统一使用os.getenv获取数据库连接信息,避免从config对象获取不存在的值时出现KeyError -5e069f7 - 修复记忆保存时无时间信息的bug -73a3e41 - 修复记忆更新bug -52c93ba - refactor: use Base64 for emoji CQ codes -67f6d7c - fix: 保证能运行的小修改 -c32c4fb - refactor: 修改配置文件的版本号 -a54ca8c - Merge remote-tracking branch 'upstream/debug' into feat_regix -8cbf9bb - feat: 史上最好的消息流重构和图片管理 -9e41c4f - feat: 修改 bot_config 0.0.5 版本的变更日志 -eede406 - fix: 修复nonebot无法加载项目的问题 -00e02ed - fix: 0.0.5 版本的增加分层控制项 -0f99d6a - Update docs/docker_deploy.md -c789074 - feat: 增加ruff依赖 -ff65ab8 - feat: 修改默认的ruff配置文件,同时消除config的所有不符合规范的地方 -bf97013 - feat: 精简日志,禁用Uvicorn/NoneBot默认日志;启动方式改为显示加载uvicorn,以便优雅shutdown -d9a2863 - 优化Docker部署文档更新容器部分 -efcf00f - Docker部署文档追加更新部分 -a63ce96 - fix: 更新情感判断模型配置(使配置文件里的 llm_emotion_judge 生效) -1294c88 - feat: 增加标准化格式化设置 -2e8cd47 - fix: 避免可能出现的日程解析错误 -043a724 - 修一下文档跳转,小美化( -e4b8865 - 支持别名,可以用不同名称召唤机器人 -7b35ddd - ruff 哥又有新点子 -7899e67 - feat: 重构完成开始测试debug -354d6d0 - 记忆系统优化 -6cef8fd - 修复时区,删去napcat用不到的端口 -cd96644 - 添加使用说明 -84495f8 - fix -204744c - 修改配置名与修改过滤对象为raw_message -a03b490 - Update README.md -2b2b342 - feat: 增加 ruff 依赖 -72a6749 - fix: 修复docker部署时区指定问题 -ee579bc - Update README.md -1b611ec - resolve SengokuCola/MaiMBot#167 根据正则表达式过滤消息 -6e2ea82 - refractor: 几乎写完了,进入测试阶段 -2ffdfef - More -e680405 - fix: typo 'discription' -68b3f57 - Minor Doc Update -312f065 - Create linux_deploy_guide_for_beginners.md -ed505a4 - fix: 使用动态路径替换硬编码的项目路径 -8ff7bb6 - docs: 更新文档,修正格式并添加必要的换行符 -6e36a56 - feat: 增加 MONGODB_URI 的配置项,并将所有env文件的注释单独放在一行(python的dotenv有时无法正确处理行内注释) -4baa6c6 - feat: 实现MongoDB URI方式连接,并统一数据库连接代码。 -8a32d18 - feat: 优化willing_manager逻辑,增加回复保底概率 -c9f1244 - docs: 改进README.md文档格式和排版 -e1b484a - docs: 添加CLAUDE.md开发指南文件(用于Claude Code) -a43f949 - fix: remove duplicate message(CR comments) -fddb641 - fix: 修复错误的空值检测逻辑 -8b7876c - fix: 修复没有上传tag的问题 -6b4130e - feat: 增加stable-dev分支的打包 -052e67b - refactor: 日志打印优化(终于改完了,爽了 -a7f9d05 - 修复记忆整理传入格式问题 -536bb1d - fix: 更新情感判断模型配置 -8d99592 - fix: logger初始化顺序 -052802c - refactor: logger promotion -8661d94 - doc: README.md - telegram version information -5746afa - refactor: logger in src\plugins\chat\bot.py -288dbb6 - refactor: logger in src\plugins\chat\__init__.py -8428a06 - fix: memory logger optimization (CR comment) -665c459 - 改进了可视化脚本 -6c35704 - fix: 调用了错误的函数 -3223153 - feat: 一键脚本新增记忆可视化 -3149dd3 - fix: mongodb.zip 无法解压 fix:更换执行命令的方法 fix:当 db 不存在时自动创建 feat: 一键安装完成后启动麦麦 -089d6a6 - feat: 针对硅基流动的Pro模型添加了自动降级功能 -c4b0917 - 一个记忆可视化小脚本 -6a71ea4 - 修复了记忆时间bug,config添加了记忆屏蔽关键词 -1b5344f - fix: 优化bot初始化的日志&格式 -41aa974 - fix: 优化chat/config.py的日志&格式 -980cde7 - fix: 优化scheduler_generator日志&格式 -31a5514 - fix: 调整全局logger加载顺序 -8baef07 - feat: 添加全局logger初始化设置 -5566f17 - refractor: 几乎写完了,进入测试阶段 -6a66933 - feat: 添加开发环境.env.dev初始化 -411ff1a - feat: 安装 MongoDB Compass -0de9eba - feat: 增加实时更新贡献者列表的功能 -f327f45 - fix: 优化src/plugins/chat/__init__.py的import -826daa5 - fix: 当虚拟环境存在时跳过创建 -f54de42 - fix: time.tzset 仅在类 Unix 系统可用 -47c4990 - fix: 修复docker部署场景下时间错误的问题 -e23a371 - docs: 添加 compose 注释 -1002822 - docs: 标注 Python 最低版本 -564350d - feat: 校验 Python 版本 -4cc4482 - docs: 添加傻瓜式脚本 -757173a - 带麦麦看了心理医生,让她没那么容易陷入负面情绪 -39bb99c - 将错别字生成提取到配置,一句一个错别字太烦了! -fe36847 - feat: 超大型重构 -e304dd7 - Update README.md -b7cfe6d - feat: 发布第 0.0.2 版本配置模板 -ca929d5 - 补充Docker部署文档 -1e97120 - 补充Docker部署文档 -25f7052 - fix: 修复兼容性选项和目前第一个版本之间的版本间隙 0.0.0 版,并将所有的直接退出修改为抛出异常 -c5bdc4f - 防ipv6炸,虽然小概率事件 -d86610d - fix: 修复不能加载环境变量的问题 -2306ebf - feat: 因为判断临界版本范围比较麻烦,增加 notice 字段,删除原本的判断逻辑(存在故障) -dd09576 - fix: 修复 TypeError: BotConfig.convert_to_specifierset() takes 1 positional argument but 2 were given -18f839b - fix: 修复 missing 1 required positional argument: 'INNER_VERSION' -6adb5ed - 调整一些细节,docker部署时可选数据库账密 -07f48e9 - fix: 利用filter来过滤环境变量,避免直接删除key造成的 RuntimeError: dictionary changed size during iteration -5856074 - fix: 修复无法进行基础设置的问题 -32aa032 - feat: 发布 0.0.1 版本的配置文件 -edc07ac - feat: 重构配置加载器,增加配置文件版本控制和程序兼容能力 -0f492ed - fix: 修复 BASE_URL/KEY 组合检查中被 GPG_KEY 干扰的问题 \ No newline at end of file From 184059915656e59e01e2b4e2c03cb9204b4320a7 Mon Sep 17 00:00:00 2001 From: HYY1116 Date: Wed, 12 Mar 2025 09:53:01 +0800 Subject: [PATCH 110/162] =?UTF-8?q?fix:=20=E5=B0=9D=E8=AF=95=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=89=80=E6=9C=89=E5=9B=BE=E7=89=87=E9=83=BD=E8=A2=AB?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=B8=BAjpg=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BB=A5=E6=AD=A3=E7=A1=AE=E7=9A=84=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E8=AF=B7=E6=B1=82=E8=AF=86=E5=9B=BEapi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/utils_image.py | 19 ++++++++++++------- src/plugins/models/utils_model.py | 15 +++++++++------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 8f09a21a0..2f3a8a14b 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -4,6 +4,8 @@ import time import aiohttp import hashlib from typing import Optional, Union +from PIL import Image +import io from loguru import logger from nonebot import get_driver @@ -119,6 +121,7 @@ class ImageManager: # 计算哈希值 image_hash = hashlib.md5(image_bytes).hexdigest() + img_format = Image.open(io.BytesIO(image_bytes)).format() # 查重 existing = self.db.images.find_one({'hash': image_hash}) @@ -127,7 +130,7 @@ class ImageManager: # 生成文件名和路径 timestamp = int(time.time()) - filename = f"{timestamp}_{image_hash[:8]}.jpg" + filename = f"{timestamp}_{image_hash[:8]}.{img_format}" file_path = os.path.join(self.IMAGE_DIR, filename) # 保存文件 @@ -238,7 +241,8 @@ class ImageManager: # 计算图片哈希 image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() - + image_format = Image.open(io.BytesIO(image_bytes)).format + # 查询缓存的描述 cached_description = self._get_description_from_db(image_hash, 'emoji') if cached_description: @@ -247,13 +251,13 @@ class ImageManager: # 调用AI获取描述 prompt = "这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感" - description, _ = await self._llm.generate_response_for_image(prompt, image_base64) + description, _ = await self._llm.generate_response_for_image(prompt, image_base64, image_format) # 根据配置决定是否保存图片 if global_config.EMOJI_SAVE: # 生成文件名和路径 timestamp = int(time.time()) - filename = f"{timestamp}_{image_hash[:8]}.jpg" + filename = f"{timestamp}_{image_hash[:8]}.{image_format}" file_path = os.path.join(self.IMAGE_DIR, 'emoji',filename) try: @@ -292,7 +296,8 @@ class ImageManager: # 计算图片哈希 image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() - + image_format = Image.open(io.BytesIO(image_bytes)).format + # 查询缓存的描述 cached_description = self._get_description_from_db(image_hash, 'image') if cached_description: @@ -300,7 +305,7 @@ class ImageManager: # 调用AI获取描述 prompt = "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" - description, _ = await self._llm.generate_response_for_image(prompt, image_base64) + description, _ = await self._llm.generate_response_for_image(prompt, image_base64, image_format) if description is None: logger.warning("AI未能生成图片描述") @@ -310,7 +315,7 @@ class ImageManager: if global_config.EMOJI_SAVE: # 生成文件名和路径 timestamp = int(time.time()) - filename = f"{timestamp}_{image_hash[:8]}.jpg" + filename = f"{timestamp}_{image_hash[:8]}.{image_format}" file_path = os.path.join(self.IMAGE_DIR,'image', filename) try: diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index 75b46f611..8d6e6191b 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -104,6 +104,7 @@ class LLM_request: endpoint: str, prompt: str = None, image_base64: str = None, + image_format: str = None, payload: dict = None, retry_policy: dict = None, response_handler: callable = None, @@ -115,6 +116,7 @@ class LLM_request: endpoint: API端点路径 (如 "chat/completions") prompt: prompt文本 image_base64: 图片的base64编码 + image_format: 图片格式 payload: 请求体数据 retry_policy: 自定义重试策略 response_handler: 自定义响应处理器 @@ -151,7 +153,7 @@ class LLM_request: # 构建请求体 if image_base64: - payload = await self._build_payload(prompt, image_base64) + payload = await self._build_payload(prompt, image_base64, image_format) elif payload is None: payload = await self._build_payload(prompt) @@ -172,7 +174,7 @@ class LLM_request: if response.status == 413: logger.warning("请求体过大,尝试压缩...") image_base64 = compress_base64_image_by_scale(image_base64) - payload = await self._build_payload(prompt, image_base64) + payload = await self._build_payload(prompt, image_base64, image_format) elif response.status in [500, 503]: logger.error(f"错误码: {response.status} - {error_code_mapping.get(response.status)}") raise RuntimeError("服务器负载过高,模型恢复失败QAQ") @@ -294,7 +296,7 @@ class LLM_request: new_params["max_completion_tokens"] = new_params.pop("max_tokens") return new_params - async def _build_payload(self, prompt: str, image_base64: str = None) -> dict: + async def _build_payload(self, prompt: str, image_base64: str = None, image_format: str = None) -> dict: """构建请求体""" # 复制一份参数,避免直接修改 self.params params_copy = await self._transform_parameters(self.params) @@ -306,7 +308,7 @@ class LLM_request: "role": "user", "content": [ {"type": "text", "text": prompt}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}} + {"type": "image_url", "image_url": {"url": f"data:image/{image_format.lower()};base64,{image_base64}"}} ] } ], @@ -391,13 +393,14 @@ class LLM_request: ) return content, reasoning_content - async def generate_response_for_image(self, prompt: str, image_base64: str) -> Tuple[str, str]: + async def generate_response_for_image(self, prompt: str, image_base64: str, image_format: str) -> Tuple[str, str, str]: """根据输入的提示和图片生成模型的异步响应""" content, reasoning_content = await self._execute_request( endpoint="/chat/completions", prompt=prompt, - image_base64=image_base64 + image_base64=image_base64, + image_format=image_format ) return content, reasoning_content From d106260d93e5972e07d2635b5e444ee23221a282 Mon Sep 17 00:00:00 2001 From: HYY1116 Date: Wed, 12 Mar 2025 09:58:10 +0800 Subject: [PATCH 111/162] =?UTF-8?q?=E5=B0=8F=E4=BF=AE=E8=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/utils_image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 2f3a8a14b..ca294356a 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -121,7 +121,7 @@ class ImageManager: # 计算哈希值 image_hash = hashlib.md5(image_bytes).hexdigest() - img_format = Image.open(io.BytesIO(image_bytes)).format() + image_format = Image.open(io.BytesIO(image_bytes)).format # 查重 existing = self.db.images.find_one({'hash': image_hash}) @@ -130,7 +130,7 @@ class ImageManager: # 生成文件名和路径 timestamp = int(time.time()) - filename = f"{timestamp}_{image_hash[:8]}.{img_format}" + filename = f"{timestamp}_{image_hash[:8]}.{image_format}" file_path = os.path.join(self.IMAGE_DIR, filename) # 保存文件 From 25ecfcecc030bd1306764854a0b511c2355af0c0 Mon Sep 17 00:00:00 2001 From: HYY Date: Wed, 12 Mar 2025 10:42:48 +0800 Subject: [PATCH 112/162] =?UTF-8?q?=E4=BF=AE=E8=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index f15251077..0460eca40 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -6,6 +6,8 @@ import random import time import traceback from typing import Optional, Tuple +from PIL import Image +import io from loguru import logger from nonebot import get_driver @@ -192,11 +194,11 @@ class EmojiManager: logger.error(f"获取标签失败: {str(e)}") return None - async def _check_emoji(self, image_base64: str) -> str: + async def _check_emoji(self, image_base64: str, image_format: str) -> str: try: prompt = f'这是一个表情包,请回答这个表情包是否满足\"{global_config.EMOJI_CHECK_PROMPT}\"的要求,是则回答是,否则回答否,不要出现任何其他内容' - content, _ = await self.vlm.generate_response_for_image(prompt, image_base64) + content, _ = await self.vlm.generate_response_for_image(prompt, image_base64, image_format) logger.debug(f"输出描述: {content}") return content @@ -237,7 +239,7 @@ class EmojiManager: image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() - + image_format = Image.open(io.BytesIO(image_bytes)).format # 检查是否已经注册过 existing_emoji = self.db['emoji'].find_one({'filename': filename}) description = None @@ -278,7 +280,7 @@ class EmojiManager: if global_config.EMOJI_CHECK: - check = await self._check_emoji(image_base64) + check = await self._check_emoji(image_base64, image_format) if '是' not in check: os.remove(image_path) logger.info(f"描述: {description}") From 26ed7f54d362a5ca656264de2180a353d6406538 Mon Sep 17 00:00:00 2001 From: HYY Date: Wed, 12 Mar 2025 10:59:34 +0800 Subject: [PATCH 113/162] =?UTF-8?q?=E9=81=B5=E5=BE=AAs=E6=8C=87=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 2 +- src/plugins/chat/utils_image.py | 6 +++--- src/plugins/models/utils_model.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 0460eca40..8ff394039 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -239,7 +239,7 @@ class EmojiManager: image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() - image_format = Image.open(io.BytesIO(image_bytes)).format + image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 检查是否已经注册过 existing_emoji = self.db['emoji'].find_one({'filename': filename}) description = None diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index ca294356a..91cdcba08 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -121,7 +121,7 @@ class ImageManager: # 计算哈希值 image_hash = hashlib.md5(image_bytes).hexdigest() - image_format = Image.open(io.BytesIO(image_bytes)).format + image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 查重 existing = self.db.images.find_one({'hash': image_hash}) @@ -241,7 +241,7 @@ class ImageManager: # 计算图片哈希 image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() - image_format = Image.open(io.BytesIO(image_bytes)).format + image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 查询缓存的描述 cached_description = self._get_description_from_db(image_hash, 'emoji') @@ -296,7 +296,7 @@ class ImageManager: # 计算图片哈希 image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() - image_format = Image.open(io.BytesIO(image_bytes)).format + image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 查询缓存的描述 cached_description = self._get_description_from_db(image_hash, 'image') diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index 8d6e6191b..3a6243870 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -393,7 +393,7 @@ class LLM_request: ) return content, reasoning_content - async def generate_response_for_image(self, prompt: str, image_base64: str, image_format: str) -> Tuple[str, str, str]: + async def generate_response_for_image(self, prompt: str, image_base64: str, image_format: str) -> Tuple[str, str]: """根据输入的提示和图片生成模型的异步响应""" content, reasoning_content = await self._execute_request( From aecb4ffa351220c42abf971e02768eb7ca77e168 Mon Sep 17 00:00:00 2001 From: Nestor Qin Date: Tue, 11 Mar 2025 20:04:52 -0700 Subject: [PATCH 114/162] Fix logger double loading --- bot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot.py b/bot.py index 36d621a6e..a96c8aca2 100644 --- a/bot.py +++ b/bot.py @@ -194,7 +194,6 @@ def raw_main(): time.tzset() easter_egg() - load_logger() init_config() init_env() load_env() From 8676682e97615b8c0a9a321f1430969402778e72 Mon Sep 17 00:00:00 2001 From: Nestor Qin Date: Tue, 11 Mar 2025 20:07:02 -0700 Subject: [PATCH 115/162] Sink logs to logs file --- .gitignore | 1 + bot.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/.gitignore b/.gitignore index 6e1be60b4..3579444dc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ data1/ mongodb/ NapCat.Framework.Windows.Once/ log/ +logs/ /test /src/test message_queue_content.txt diff --git a/bot.py b/bot.py index a96c8aca2..09d081be0 100644 --- a/bot.py +++ b/bot.py @@ -17,6 +17,19 @@ env_mask = {key: os.getenv(key) for key in os.environ} uvicorn_server = None +# 配置日志 +log_path = os.path.join(os.getcwd(), "logs") +if not os.path.exists(log_path): + os.makedirs(log_path) + +# 添加文件日志,启用rotation和retention +logger.add( + os.path.join(log_path, "maimbot_{time:YYYY-MM-DD}.log"), + rotation="00:00", # 每天0点创建新文件 + retention="30 days", # 保留30天的日志 + level="INFO", + encoding="utf-8" +) def easter_egg(): # 彩蛋 From 3a614506420f30186c55a3cab62ebadba0cae18e Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 12 Mar 2025 15:32:25 +0800 Subject: [PATCH 116/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E6=97=A0=E6=B3=95=E8=AF=BB=E5=8F=96=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E5=92=8C=E7=9F=A5=E8=AF=86=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=8C=E4=BB=A5=E5=8F=8A=E5=9B=BE=E7=89=87=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 6 ++-- src/plugins/chat/utils.py | 8 ++--- src/plugins/memory_system/memory.py | 20 +++++------ .../memory_system/memory_manual_build.py | 30 ++++++++-------- src/plugins/memory_system/memory_test1.py | 34 +++++++++---------- src/plugins/zhishi/knowledge_library.py | 10 +++--- 6 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index f15251077..9532db4f0 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -246,7 +246,7 @@ class EmojiManager: # 即使表情包已存在,也检查是否需要同步到images集合 description = existing_emoji.get('discription') # 检查是否在images集合中存在 - existing_image = image_manager.db.db.images.find_one({'hash': image_hash}) + existing_image = image_manager.db.images.find_one({'hash': image_hash}) if not existing_image: # 同步到images集合 image_doc = { @@ -256,7 +256,7 @@ class EmojiManager: 'description': description, 'timestamp': int(time.time()) } - image_manager.db.db.images.update_one( + image_manager.db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True @@ -318,7 +318,7 @@ class EmojiManager: 'description': description, 'timestamp': int(time.time()) } - image_manager.db.db.images.update_one( + image_manager.db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index cf3e59f73..0d1afd055 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -88,13 +88,13 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): list: 消息记录列表,每个记录包含时间和文本信息 """ chat_records = [] - closest_record = db.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) + closest_record = db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) if closest_record: closest_time = closest_record['time'] chat_id = closest_record['chat_id'] # 获取chat_id # 获取该时间戳之后的length条消息,保持相同的chat_id - chat_records = list(db.db.messages.find( + chat_records = list(db.messages.find( { "time": {"$gt": closest_time}, "chat_id": chat_id # 添加chat_id过滤 @@ -128,7 +128,7 @@ async def get_recent_group_messages(db, chat_id:str, limit: int = 12) -> list: """ # 从数据库获取最近消息 - recent_messages = list(db.db.messages.find( + recent_messages = list(db.messages.find( {"chat_id": chat_id}, ).sort("time", -1).limit(limit)) @@ -162,7 +162,7 @@ async def get_recent_group_messages(db, chat_id:str, limit: int = 12) -> list: def get_recent_group_detailed_plain_text(db, chat_stream_id: int, limit: int = 12, combine=False): - recent_messages = list(db.db.messages.find( + recent_messages = list(db.messages.find( {"chat_id": chat_stream_id}, { "time": 1, # 返回时间字段 diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 48fc19261..d9e867e63 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -349,7 +349,7 @@ class Hippocampus: def sync_memory_to_db(self): """检查并同步内存中的图结构与数据库""" # 获取数据库中所有节点和内存中所有节点 - db_nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) + db_nodes = list(self.memory_graph.db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) # 转换数据库节点为字典格式,方便查找 @@ -377,7 +377,7 @@ class Hippocampus: 'created_time': created_time, 'last_modified': last_modified } - self.memory_graph.db.db.graph_data.nodes.insert_one(node_data) + self.memory_graph.db.graph_data.nodes.insert_one(node_data) else: # 获取数据库中节点的特征值 db_node = db_nodes_dict[concept] @@ -385,7 +385,7 @@ class Hippocampus: # 如果特征值不同,则更新节点 if db_hash != memory_hash: - self.memory_graph.db.db.graph_data.nodes.update_one( + self.memory_graph.db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'memory_items': memory_items, @@ -396,7 +396,7 @@ class Hippocampus: ) # 处理边的信息 - db_edges = list(self.memory_graph.db.db.graph_data.edges.find()) + db_edges = list(self.memory_graph.db.graph_data.edges.find()) memory_edges = list(self.memory_graph.G.edges(data=True)) # 创建边的哈希值字典 @@ -428,11 +428,11 @@ class Hippocampus: 'created_time': created_time, 'last_modified': last_modified } - self.memory_graph.db.db.graph_data.edges.insert_one(edge_data) + self.memory_graph.db.graph_data.edges.insert_one(edge_data) else: # 检查边的特征值是否变化 if db_edge_dict[edge_key]['hash'] != edge_hash: - self.memory_graph.db.db.graph_data.edges.update_one( + self.memory_graph.db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': { 'hash': edge_hash, @@ -451,7 +451,7 @@ class Hippocampus: self.memory_graph.G.clear() # 从数据库加载所有节点 - nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) + nodes = list(self.memory_graph.db.graph_data.nodes.find()) for node in nodes: concept = node['concept'] memory_items = node.get('memory_items', []) @@ -468,7 +468,7 @@ class Hippocampus: if 'last_modified' not in node: update_data['last_modified'] = current_time - self.memory_graph.db.db.graph_data.nodes.update_one( + self.memory_graph.db.graph_data.nodes.update_one( {'concept': concept}, {'$set': update_data} ) @@ -485,7 +485,7 @@ class Hippocampus: last_modified=last_modified) # 从数据库加载所有边 - edges = list(self.memory_graph.db.db.graph_data.edges.find()) + edges = list(self.memory_graph.db.graph_data.edges.find()) for edge in edges: source = edge['source'] target = edge['target'] @@ -501,7 +501,7 @@ class Hippocampus: if 'last_modified' not in edge: update_data['last_modified'] = current_time - self.memory_graph.db.db.graph_data.edges.update_one( + self.memory_graph.db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': update_data} ) diff --git a/src/plugins/memory_system/memory_manual_build.py b/src/plugins/memory_system/memory_manual_build.py index 736a50e97..adf972a06 100644 --- a/src/plugins/memory_system/memory_manual_build.py +++ b/src/plugins/memory_system/memory_manual_build.py @@ -56,13 +56,13 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): list: 消息记录字典列表,每个字典包含消息内容和时间信息 """ chat_records = [] - closest_record = db.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) + closest_record = db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) if closest_record and closest_record.get('memorized', 0) < 4: closest_time = closest_record['time'] group_id = closest_record['group_id'] # 获取该时间戳之后的length条消息,且groupid相同 - records = list(db.db.messages.find( + records = list(db.messages.find( {"time": {"$gt": closest_time}, "group_id": group_id} ).sort('time', 1).limit(length)) @@ -74,7 +74,7 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): return '' # 更新memorized值 - db.db.messages.update_one( + db.messages.update_one( {"_id": record["_id"]}, {"$set": {"memorized": current_memorized + 1}} ) @@ -323,7 +323,7 @@ class Hippocampus: self.memory_graph.G.clear() # 从数据库加载所有节点 - nodes = self.memory_graph.db.db.graph_data.nodes.find() + nodes = self.memory_graph.db.graph_data.nodes.find() for node in nodes: concept = node['concept'] memory_items = node.get('memory_items', []) @@ -334,7 +334,7 @@ class Hippocampus: self.memory_graph.G.add_node(concept, memory_items=memory_items) # 从数据库加载所有边 - edges = self.memory_graph.db.db.graph_data.edges.find() + edges = self.memory_graph.db.graph_data.edges.find() for edge in edges: source = edge['source'] target = edge['target'] @@ -371,7 +371,7 @@ class Hippocampus: 使用特征值(哈希值)快速判断是否需要更新 """ # 获取数据库中所有节点和内存中所有节点 - db_nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) + db_nodes = list(self.memory_graph.db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) # 转换数据库节点为字典格式,方便查找 @@ -394,7 +394,7 @@ class Hippocampus: 'memory_items': memory_items, 'hash': memory_hash } - self.memory_graph.db.db.graph_data.nodes.insert_one(node_data) + self.memory_graph.db.graph_data.nodes.insert_one(node_data) else: # 获取数据库中节点的特征值 db_node = db_nodes_dict[concept] @@ -403,7 +403,7 @@ class Hippocampus: # 如果特征值不同,则更新节点 if db_hash != memory_hash: # logger.info(f"更新节点内容: {concept}") - self.memory_graph.db.db.graph_data.nodes.update_one( + self.memory_graph.db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'memory_items': memory_items, @@ -416,10 +416,10 @@ class Hippocampus: for db_node in db_nodes: if db_node['concept'] not in memory_concepts: # logger.info(f"删除多余节点: {db_node['concept']}") - self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) + self.memory_graph.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) # 处理边的信息 - db_edges = list(self.memory_graph.db.db.graph_data.edges.find()) + db_edges = list(self.memory_graph.db.graph_data.edges.find()) memory_edges = list(self.memory_graph.G.edges()) # 创建边的哈希值字典 @@ -445,12 +445,12 @@ class Hippocampus: 'num': 1, 'hash': edge_hash } - self.memory_graph.db.db.graph_data.edges.insert_one(edge_data) + self.memory_graph.db.graph_data.edges.insert_one(edge_data) else: # 检查边的特征值是否变化 if db_edge_dict[edge_key]['hash'] != edge_hash: logger.info(f"更新边: {source} - {target}") - self.memory_graph.db.db.graph_data.edges.update_one( + self.memory_graph.db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': {'hash': edge_hash}} ) @@ -461,7 +461,7 @@ class Hippocampus: if edge_key not in memory_edge_set: source, target = edge_key logger.info(f"删除多余边: {source} - {target}") - self.memory_graph.db.db.graph_data.edges.delete_one({ + self.memory_graph.db.graph_data.edges.delete_one({ 'source': source, 'target': target }) @@ -487,9 +487,9 @@ class Hippocampus: topic: 要删除的节点概念 """ # 删除节点 - self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': topic}) + self.memory_graph.db.graph_data.nodes.delete_one({'concept': topic}) # 删除所有涉及该节点的边 - self.memory_graph.db.db.graph_data.edges.delete_many({ + self.memory_graph.db.graph_data.edges.delete_many({ '$or': [ {'source': topic}, {'target': topic} diff --git a/src/plugins/memory_system/memory_test1.py b/src/plugins/memory_system/memory_test1.py index 72accc2b3..f86c8ea3d 100644 --- a/src/plugins/memory_system/memory_test1.py +++ b/src/plugins/memory_system/memory_test1.py @@ -115,13 +115,13 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): list: 消息记录字典列表,每个字典包含消息内容和时间信息 """ chat_records = [] - closest_record = db.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) + closest_record = db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) if closest_record and closest_record.get('memorized', 0) < 4: closest_time = closest_record['time'] group_id = closest_record['group_id'] # 获取该时间戳之后的length条消息,且groupid相同 - records = list(db.db.messages.find( + records = list(db.messages.find( {"time": {"$gt": closest_time}, "group_id": group_id} ).sort('time', 1).limit(length)) @@ -133,7 +133,7 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): return '' # 更新memorized值 - db.db.messages.update_one( + db.messages.update_one( {"_id": record["_id"]}, {"$set": {"memorized": current_memorized + 1}} ) @@ -163,7 +163,7 @@ class Memory_cortex: default_time = datetime.datetime.now().timestamp() # 从数据库加载所有节点 - nodes = self.memory_graph.db.db.graph_data.nodes.find() + nodes = self.memory_graph.db.graph_data.nodes.find() for node in nodes: concept = node['concept'] memory_items = node.get('memory_items', []) @@ -180,7 +180,7 @@ class Memory_cortex: created_time = default_time last_modified = default_time # 更新数据库中的节点 - self.memory_graph.db.db.graph_data.nodes.update_one( + self.memory_graph.db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'created_time': created_time, @@ -196,7 +196,7 @@ class Memory_cortex: last_modified=last_modified) # 从数据库加载所有边 - edges = self.memory_graph.db.db.graph_data.edges.find() + edges = self.memory_graph.db.graph_data.edges.find() for edge in edges: source = edge['source'] target = edge['target'] @@ -212,7 +212,7 @@ class Memory_cortex: created_time = default_time last_modified = default_time # 更新数据库中的边 - self.memory_graph.db.db.graph_data.edges.update_one( + self.memory_graph.db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': { 'created_time': created_time, @@ -256,7 +256,7 @@ class Memory_cortex: current_time = datetime.datetime.now().timestamp() # 获取数据库中所有节点和内存中所有节点 - db_nodes = list(self.memory_graph.db.db.graph_data.nodes.find()) + db_nodes = list(self.memory_graph.db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) # 转换数据库节点为字典格式,方便查找 @@ -280,7 +280,7 @@ class Memory_cortex: 'created_time': data.get('created_time', current_time), 'last_modified': data.get('last_modified', current_time) } - self.memory_graph.db.db.graph_data.nodes.insert_one(node_data) + self.memory_graph.db.graph_data.nodes.insert_one(node_data) else: # 获取数据库中节点的特征值 db_node = db_nodes_dict[concept] @@ -288,7 +288,7 @@ class Memory_cortex: # 如果特征值不同,则更新节点 if db_hash != memory_hash: - self.memory_graph.db.db.graph_data.nodes.update_one( + self.memory_graph.db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'memory_items': memory_items, @@ -301,10 +301,10 @@ class Memory_cortex: memory_concepts = set(node[0] for node in memory_nodes) for db_node in db_nodes: if db_node['concept'] not in memory_concepts: - self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) + self.memory_graph.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) # 处理边的信息 - db_edges = list(self.memory_graph.db.db.graph_data.edges.find()) + db_edges = list(self.memory_graph.db.graph_data.edges.find()) memory_edges = list(self.memory_graph.G.edges(data=True)) # 创建边的哈希值字典 @@ -332,11 +332,11 @@ class Memory_cortex: 'created_time': data.get('created_time', current_time), 'last_modified': data.get('last_modified', current_time) } - self.memory_graph.db.db.graph_data.edges.insert_one(edge_data) + self.memory_graph.db.graph_data.edges.insert_one(edge_data) else: # 检查边的特征值是否变化 if db_edge_dict[edge_key]['hash'] != edge_hash: - self.memory_graph.db.db.graph_data.edges.update_one( + self.memory_graph.db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': { 'hash': edge_hash, @@ -350,7 +350,7 @@ class Memory_cortex: for edge_key in db_edge_dict: if edge_key not in memory_edge_set: source, target = edge_key - self.memory_graph.db.db.graph_data.edges.delete_one({ + self.memory_graph.db.graph_data.edges.delete_one({ 'source': source, 'target': target }) @@ -365,9 +365,9 @@ class Memory_cortex: topic: 要删除的节点概念 """ # 删除节点 - self.memory_graph.db.db.graph_data.nodes.delete_one({'concept': topic}) + self.memory_graph.db.graph_data.nodes.delete_one({'concept': topic}) # 删除所有涉及该节点的边 - self.memory_graph.db.db.graph_data.edges.delete_many({ + self.memory_graph.db.graph_data.edges.delete_many({ '$or': [ {'source': topic}, {'target': topic} diff --git a/src/plugins/zhishi/knowledge_library.py b/src/plugins/zhishi/knowledge_library.py index 2411e3112..ad309814b 100644 --- a/src/plugins/zhishi/knowledge_library.py +++ b/src/plugins/zhishi/knowledge_library.py @@ -176,7 +176,7 @@ class KnowledgeLibrary: try: current_hash = self.calculate_file_hash(file_path) - processed_record = self.db.db.processed_files.find_one({"file_path": file_path}) + processed_record = self.db.processed_files.find_one({"file_path": file_path}) if processed_record: if processed_record.get("hash") == current_hash: @@ -197,14 +197,14 @@ class KnowledgeLibrary: "split_length": knowledge_length, "created_at": datetime.now() } - self.db.db.knowledges.insert_one(knowledge) + self.db.knowledges.insert_one(knowledge) result["chunks_processed"] += 1 split_by = processed_record.get("split_by", []) if processed_record else [] if knowledge_length not in split_by: split_by.append(knowledge_length) - self.db.db.processed_files.update_one( + self.db.knowledges.processed_files.update_one( {"file_path": file_path}, { "$set": { @@ -322,7 +322,7 @@ class KnowledgeLibrary: {"$project": {"content": 1, "similarity": 1, "file_path": 1}} ] - results = list(self.db.db.knowledges.aggregate(pipeline)) + results = list(self.db.knowledges.aggregate(pipeline)) return results # 创建单例实例 @@ -346,7 +346,7 @@ if __name__ == "__main__": elif choice == '2': confirm = input("确定要删除所有知识吗?这个操作不可撤销!(y/n): ").strip().lower() if confirm == 'y': - knowledge_library.db.db.knowledges.delete_many({}) + knowledge_library.db.knowledges.delete_many({}) console.print("[green]已清空所有知识![/green]") continue elif choice == '1': From 23c9211d9371d43b02afd1e3cc694d7c6b341c3f Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 12 Mar 2025 15:45:24 +0800 Subject: [PATCH 117/162] Update utils_image.py --- src/plugins/chat/utils_image.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 8f09a21a0..fb2428870 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -289,6 +289,7 @@ class ImageManager: async def get_image_description(self, image_base64: str) -> str: """获取普通图片描述,带查重和保存功能""" try: + print("处理图片中") # 计算图片哈希 image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() @@ -296,12 +297,15 @@ class ImageManager: # 查询缓存的描述 cached_description = self._get_description_from_db(image_hash, 'image') if cached_description: + print("图片描述缓存中") return f"[图片:{cached_description}]" # 调用AI获取描述 prompt = "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" description, _ = await self._llm.generate_response_for_image(prompt, image_base64) + print(f"描述是{description}") + if description is None: logger.warning("AI未能生成图片描述") return "[图片]" From 50c468e5196ff757be2b38e98334868f8d3645f6 Mon Sep 17 00:00:00 2001 From: Cindy-Master <2606440373@qq.com> Date: Wed, 12 Mar 2025 16:56:12 +0800 Subject: [PATCH 118/162] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E5=87=8F=E5=B0=91?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E7=BE=A4=E5=86=85=E7=9A=84=E5=90=8C=E4=B9=89?= =?UTF-8?q?=E8=AF=8D=E6=B7=B7=E6=B7=86=20=E4=BB=A5=E5=8F=8A=E5=85=81?= =?UTF-8?q?=E8=AE=B8=E7=94=A8=E6=88=B7=E8=AE=BE=E7=BD=AE=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E7=BE=A4=E7=BB=84=E8=AE=B0=E5=BF=86=E7=A7=81=E6=9C=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户设置指定群组记忆私有会导致记忆的语料严重减少 未设置群组记忆私有的记忆会开放对于所有群聊 因此要不就修改其他记忆配置 增强学习/记忆能力 或者增加单一群组的信息流数量 或者增加公开记忆的信息流数量 记忆检索时会按以下优先级返回记忆: 当前群组的记忆(如果群聊属于某个群组) 当前群聊的记忆(如果不属于任何群组) 公共记忆(无群组/群聊标识的记忆) 其他非私有群组的记忆 5. 跨群记忆连接 同一群组内的主题使用较强连接(相似度×10) 跨群组的相似主题使用较弱连接(相似度×5) 相同群组/群聊的相似主题会获得20%的相似度加成 --- src/plugins/chat/config.py | 12 ++ src/plugins/chat/prompt_builder.py | 10 +- src/plugins/memory_system/memory.py | 186 +++++++++++++++++++++++++--- template/bot_config_template.toml | 13 +- 4 files changed, 203 insertions(+), 18 deletions(-) diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 88cb31ed5..891c4e939 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -104,6 +104,12 @@ class BotConfig: memory_ban_words: list = field( default_factory=lambda: ["表情包", "图片", "回复", "聊天记录"] ) # 添加新的配置项默认值 + + # 是否优先使用当前群组的记忆 + memory_group_priority: bool = True # 默认开启群组记忆优先 + + # 群组记忆私有化 + memory_private_groups: dict = field(default_factory=dict) # 群组私有记忆配置 @staticmethod def get_config_dir() -> str: @@ -304,6 +310,12 @@ class BotConfig: config.memory_forget_time = memory_config.get("memory_forget_time", config.memory_forget_time) config.memory_forget_percentage = memory_config.get("memory_forget_percentage", config.memory_forget_percentage) config.memory_compress_rate = memory_config.get("memory_compress_rate", config.memory_compress_rate) + # 添加对memory_group_priority配置项的加载 + config.memory_group_priority = memory_config.get("memory_group_priority", config.memory_group_priority) + + if config.INNER_VERSION in SpecifierSet(">=0.0.9"): + # 添加群组记忆私有化配置项的加载 + config.memory_private_groups = memory_config.get("memory_private_groups", {}) def mood(parent: dict): mood_config = parent["mood"] diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index c89bf3e07..ac31234c8 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -91,12 +91,20 @@ class PromptBuilder: memory_prompt = '' start_time = time.time() + # 获取群组ID + group_id = None + if stream_id: + chat_stream = chat_manager.get_stream(stream_id) + if chat_stream and chat_stream.group_info: + group_id = chat_stream.group_info.group_id + # 调用 hippocampus 的 get_relevant_memories 方法 relevant_memories = await hippocampus.get_relevant_memories( text=message_txt, max_topics=5, similarity_threshold=0.4, - max_memory_num=5 + max_memory_num=5, + group_id=group_id # 传递群组ID ) if relevant_memories: diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index d9e867e63..a3a0d068a 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -4,6 +4,7 @@ import math import random import time import os +from typing import Optional import jieba import networkx as nx @@ -209,15 +210,31 @@ class Hippocampus: return chat_samples - async def memory_compress(self, messages: list, compress_rate=0.1): + async def memory_compress(self, messages: list, compress_rate=0.1, group_id=None): """压缩消息记录为记忆 + Args: + messages: 消息记录列表 + compress_rate: 压缩率 + group_id: 群组ID,用于标记记忆来源 + Returns: tuple: (压缩记忆集合, 相似主题字典) """ + from ..chat.config import global_config # 导入配置 + if not messages: return set(), {} + # 确定记忆所属的群组 + memory_group_tag = None + if group_id is not None: + # 查找群聊所属的群组 + for group_name, group_ids in global_config.memory_private_groups.items(): + if str(group_id) in group_ids: + memory_group_tag = f"[群组:{group_name}]" + break + # 合并消息文本,同时保留时间信息 input_text = "" time_info = "" @@ -267,7 +284,17 @@ class Hippocampus: for topic, task in tasks: response = await task if response: - compressed_memory.add((topic, response[0])) + memory_content = response[0] + + # 添加标记 + # 优先使用群组标记 + if memory_group_tag: + memory_content = f"{memory_group_tag}{memory_content}" + # 如果没有群组标记但有群组ID,添加简单的群组ID标记 + elif group_id is not None: + memory_content = f"[群组:{group_id}]{memory_content}" + + compressed_memory.add((topic, memory_content)) # 为每个话题查找相似的已存在主题 existing_topics = list(self.memory_graph.G.nodes()) similar_topics = [] @@ -316,7 +343,17 @@ class Hippocampus: logger.debug(f"进度: [{bar}] {progress:.1f}% ({i}/{len(memory_samples)})") compress_rate = global_config.memory_compress_rate - compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate) + + # 尝试从消息中提取群组ID + group_id = None + if messages and len(messages) > 0: + first_msg = messages[0] + if 'group_id' in first_msg: + group_id = first_msg['group_id'] + logger.info(f"检测到消息来自群组: {group_id}") + + # 传递群组ID到memory_compress + compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate, group_id) logger.info(f"压缩后记忆数量: {len(compressed_memory)},似曾相识的话题: {len(similar_topics_dict)}") current_time = datetime.datetime.now().timestamp() @@ -841,8 +878,21 @@ class Hippocampus: return activation async def get_relevant_memories(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.4, - max_memory_num: int = 5) -> list: - """根据输入文本获取相关的记忆内容""" + max_memory_num: int = 5, group_id: Optional[int] = None) -> list: + """根据输入文本获取相关的记忆内容 + + Args: + text: 输入文本 + max_topics: 最大主题数 + similarity_threshold: 相似度阈值 + max_memory_num: 最大记忆数量 + group_id: 群组ID,用于优先匹配当前群组的记忆 + + Returns: + list: 相关记忆列表 + """ + from ..chat.config import global_config # 导入配置 + # 识别主题 identified_topics = await self._identify_topics(text) @@ -855,30 +905,134 @@ class Hippocampus: # 获取最相关的主题 relevant_topics = self._get_top_topics(all_similar_topics, max_topics) - + + # 确定记忆所属的群组 + current_group_name = None + if group_id is not None: + # 查找群聊所属的群组 + for group_name, group_ids in global_config.memory_private_groups.items(): + if str(group_id) in group_ids: + current_group_name = group_name + break + + has_private_groups = len(global_config.memory_private_groups) > 0 + # 获取相关记忆内容 relevant_memories = [] + group_related_memories = [] # 当前群聊的记忆 + group_definition_memories = [] # 当前群组的记忆 + public_memories = [] # 公共记忆 + for topic, score in relevant_topics: # 获取该主题的记忆内容 first_layer, _ = self.memory_graph.get_related_item(topic, depth=1) if first_layer: # 如果记忆条数超过限制,随机选择指定数量的记忆 - if len(first_layer) > max_memory_num / 2: - first_layer = random.sample(first_layer, max_memory_num // 2) + if len(first_layer) > max_memory_num: + first_layer = random.sample(first_layer, max_memory_num) + # 为每条记忆添加来源主题和相似度信息 for memory in first_layer: - relevant_memories.append({ + memory_info = { 'topic': topic, 'similarity': score, 'content': memory - }) + } + + memory_text = str(memory) + + # 分类处理记忆 + if has_private_groups and group_id is not None: + # 如果配置了私有群组且当前在群聊中 + if current_group_name: + # 当前群聊属于某个群组 + if f"[群组:{current_group_name}]" in memory_text: + # 当前群组的记忆 + group_definition_memories.append(memory_info) + elif not any(f"[群组:" in memory_text for _ in range(1)): + # 公共记忆 + public_memories.append(memory_info) + else: + # 当前群聊不属于任何群组 + if f"[群组:{group_id}]" in memory_text: + # 当前群聊的特定记忆 + group_related_memories.append(memory_info) + elif not any(f"[群组:" in memory_text for _ in range(1)): + # 公共记忆 + public_memories.append(memory_info) + elif global_config.memory_group_priority and group_id is not None: + # 如果只启用了群组记忆优先 + if f"[群组:{group_id}]" in memory_text: + # 当前群聊的记忆,放入群组相关记忆列表 + group_related_memories.append(memory_info) + else: + # 其他记忆,放入公共记忆列表 + public_memories.append(memory_info) + else: + # 如果没有特殊配置,所有记忆都放入相关记忆列表 + relevant_memories.append(memory_info) - # 如果记忆数量超过5个,随机选择5个 - # 按相似度排序 - relevant_memories.sort(key=lambda x: x['similarity'], reverse=True) - - if len(relevant_memories) > max_memory_num: - relevant_memories = random.sample(relevant_memories, max_memory_num) + # 根据配置决定如何组合记忆 + if has_private_groups and group_id is not None: + # 配置了私有群组且当前在群聊中 + if current_group_name: + # 当前群聊属于某个群组 + # 优先使用当前群组的记忆,如果不足再使用公共记忆 + if len(group_definition_memories) >= max_memory_num: + # 如果群组记忆足够,只使用群组记忆 + group_definition_memories.sort(key=lambda x: x['similarity'], reverse=True) + relevant_memories = group_definition_memories[:max_memory_num] + else: + # 如果群组记忆不足,添加公共记忆 + group_definition_memories.sort(key=lambda x: x['similarity'], reverse=True) + public_memories.sort(key=lambda x: x['similarity'], reverse=True) + + relevant_memories = group_definition_memories.copy() + remaining_count = max_memory_num - len(relevant_memories) + if remaining_count > 0 and public_memories: + selected_other = public_memories[:remaining_count] + relevant_memories.extend(selected_other) + else: + # 当前群聊不属于任何群组 + # 优先使用当前群聊的记忆,然后使用公共记忆 + if len(group_related_memories) >= max_memory_num: + # 如果当前群聊记忆足够,只使用当前群聊记忆 + group_related_memories.sort(key=lambda x: x['similarity'], reverse=True) + relevant_memories = group_related_memories[:max_memory_num] + else: + # 如果当前群聊记忆不足,添加公共记忆 + group_related_memories.sort(key=lambda x: x['similarity'], reverse=True) + public_memories.sort(key=lambda x: x['similarity'], reverse=True) + + relevant_memories = group_related_memories.copy() + remaining_count = max_memory_num - len(relevant_memories) + if remaining_count > 0 and public_memories: + selected_other = public_memories[:remaining_count] + relevant_memories.extend(selected_other) + elif global_config.memory_group_priority and group_id is not None: + # 如果只启用了群组记忆优先 + # 按相似度排序 + group_related_memories.sort(key=lambda x: x['similarity'], reverse=True) + public_memories.sort(key=lambda x: x['similarity'], reverse=True) + + # 优先使用群组相关记忆,如果不足再使用其他记忆 + if len(group_related_memories) >= max_memory_num: + # 如果群组相关记忆足够,只使用群组相关记忆 + relevant_memories = group_related_memories[:max_memory_num] + else: + # 使用所有群组相关记忆 + relevant_memories = group_related_memories.copy() + # 如果群组相关记忆不足,添加其他记忆 + remaining_count = max_memory_num - len(relevant_memories) + if remaining_count > 0 and public_memories: + # 从其他记忆中选择剩余需要的数量 + selected_other = public_memories[:remaining_count] + relevant_memories.extend(selected_other) + else: + # 如果没有特殊配置,按相似度排序 + relevant_memories.sort(key=lambda x: x['similarity'], reverse=True) + if len(relevant_memories) > max_memory_num: + relevant_memories = relevant_memories[:max_memory_num] return relevant_memories diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 089be69b0..49f3a1919 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "0.0.8" +version = "0.0.9" #如果你想要修改配置文件,请在修改后将version的值进行变更 #如果新增项目,请在BotConfig类下新增相应的变量 @@ -72,6 +72,17 @@ forget_memory_interval = 600 # 记忆遗忘间隔 单位秒 间隔越低,麦 memory_forget_time = 24 #多长时间后的记忆会被遗忘 单位小时 memory_forget_percentage = 0.01 # 记忆遗忘比例 控制记忆遗忘程度 越大遗忘越多 建议保持默认 +memory_group_priority = true # 是否优先使用当前群组的记忆,开启后将优先使用当前群组的记忆内容,避免不同群组讨论相同话题时的记忆混淆 + +# 群组私有记忆配置 - 同一群组内的群聊共享记忆,但不与其他群组共享 +# 格式为 { 群组名称 = [群聊ID列表] } +# 未配置在任何群组中的群聊记忆可以与所有群聊共享(群组内群数量过少 聊天记录过少的情况下 建议修改其他记忆参数 加强回复概率等) +# 例如: +# memory_private_groups = { +# "游戏群组" = ["123456", "234567"], +# "工作群组" = ["345678", "456789"] +# } +memory_private_groups = { } memory_ban_words = [ #不希望记忆的词 # "403","张三" From 72754a0f2f374f1604489332ddd3bedfb35a4977 Mon Sep 17 00:00:00 2001 From: UnCLAS-Prommer Date: Wed, 12 Mar 2025 17:13:37 +0800 Subject: [PATCH 119/162] No More Typo Please --- src/plugins/chat/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index f179e8ef3..2a0324845 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -135,7 +135,7 @@ class ChatBot: for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.raw_message): logger.info( - f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{message.user_nickname}:{message.raw_message}" + f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{user_info.user_nickname}:{message.raw_message}" ) logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return From 6c5f56b23dc927a88690db72cc7393e415b9b764 Mon Sep 17 00:00:00 2001 From: UnCLAS-Prommer Date: Wed, 12 Mar 2025 17:18:39 +0800 Subject: [PATCH 120/162] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=88=91=E4=BF=AEtyp?= =?UTF-8?q?o=E4=BA=A7=E7=94=9F=E7=9A=84typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 2a0324845..1db38477c 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -135,7 +135,7 @@ class ChatBot: for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.raw_message): logger.info( - f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{user_info.user_nickname}:{message.raw_message}" + f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{userinfo.user_nickname}:{message.raw_message}" ) logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return From 38e38cf4a96e1242e8ff9cc4d46acd5dfe46a44e Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 12 Mar 2025 17:25:23 +0800 Subject: [PATCH 121/162] =?UTF-8?q?Revert=20"=E5=B0=9D=E8=AF=95=E5=87=8F?= =?UTF-8?q?=E5=B0=91=E4=B8=8D=E5=90=8C=E7=BE=A4=E5=86=85=E7=9A=84=E5=90=8C?= =?UTF-8?q?=E4=B9=89=E8=AF=8D=E6=B7=B7=E6=B7=86=20=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E5=85=81=E8=AE=B8=E7=94=A8=E6=88=B7=E8=AE=BE=E7=BD=AE=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E7=BE=A4=E7=BB=84=E8=AE=B0=E5=BF=86=E7=A7=81=E6=9C=89?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 50c468e5196ff757be2b38e98334868f8d3645f6. --- src/plugins/chat/config.py | 12 -- src/plugins/chat/prompt_builder.py | 10 +- src/plugins/memory_system/memory.py | 186 +++------------------------- template/bot_config_template.toml | 13 +- 4 files changed, 18 insertions(+), 203 deletions(-) diff --git a/src/plugins/chat/config.py b/src/plugins/chat/config.py index 891c4e939..88cb31ed5 100644 --- a/src/plugins/chat/config.py +++ b/src/plugins/chat/config.py @@ -104,12 +104,6 @@ class BotConfig: memory_ban_words: list = field( default_factory=lambda: ["表情包", "图片", "回复", "聊天记录"] ) # 添加新的配置项默认值 - - # 是否优先使用当前群组的记忆 - memory_group_priority: bool = True # 默认开启群组记忆优先 - - # 群组记忆私有化 - memory_private_groups: dict = field(default_factory=dict) # 群组私有记忆配置 @staticmethod def get_config_dir() -> str: @@ -310,12 +304,6 @@ class BotConfig: config.memory_forget_time = memory_config.get("memory_forget_time", config.memory_forget_time) config.memory_forget_percentage = memory_config.get("memory_forget_percentage", config.memory_forget_percentage) config.memory_compress_rate = memory_config.get("memory_compress_rate", config.memory_compress_rate) - # 添加对memory_group_priority配置项的加载 - config.memory_group_priority = memory_config.get("memory_group_priority", config.memory_group_priority) - - if config.INNER_VERSION in SpecifierSet(">=0.0.9"): - # 添加群组记忆私有化配置项的加载 - config.memory_private_groups = memory_config.get("memory_private_groups", {}) def mood(parent: dict): mood_config = parent["mood"] diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index ac31234c8..c89bf3e07 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -91,20 +91,12 @@ class PromptBuilder: memory_prompt = '' start_time = time.time() - # 获取群组ID - group_id = None - if stream_id: - chat_stream = chat_manager.get_stream(stream_id) - if chat_stream and chat_stream.group_info: - group_id = chat_stream.group_info.group_id - # 调用 hippocampus 的 get_relevant_memories 方法 relevant_memories = await hippocampus.get_relevant_memories( text=message_txt, max_topics=5, similarity_threshold=0.4, - max_memory_num=5, - group_id=group_id # 传递群组ID + max_memory_num=5 ) if relevant_memories: diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index a3a0d068a..d9e867e63 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -4,7 +4,6 @@ import math import random import time import os -from typing import Optional import jieba import networkx as nx @@ -210,31 +209,15 @@ class Hippocampus: return chat_samples - async def memory_compress(self, messages: list, compress_rate=0.1, group_id=None): + async def memory_compress(self, messages: list, compress_rate=0.1): """压缩消息记录为记忆 - Args: - messages: 消息记录列表 - compress_rate: 压缩率 - group_id: 群组ID,用于标记记忆来源 - Returns: tuple: (压缩记忆集合, 相似主题字典) """ - from ..chat.config import global_config # 导入配置 - if not messages: return set(), {} - # 确定记忆所属的群组 - memory_group_tag = None - if group_id is not None: - # 查找群聊所属的群组 - for group_name, group_ids in global_config.memory_private_groups.items(): - if str(group_id) in group_ids: - memory_group_tag = f"[群组:{group_name}]" - break - # 合并消息文本,同时保留时间信息 input_text = "" time_info = "" @@ -284,17 +267,7 @@ class Hippocampus: for topic, task in tasks: response = await task if response: - memory_content = response[0] - - # 添加标记 - # 优先使用群组标记 - if memory_group_tag: - memory_content = f"{memory_group_tag}{memory_content}" - # 如果没有群组标记但有群组ID,添加简单的群组ID标记 - elif group_id is not None: - memory_content = f"[群组:{group_id}]{memory_content}" - - compressed_memory.add((topic, memory_content)) + compressed_memory.add((topic, response[0])) # 为每个话题查找相似的已存在主题 existing_topics = list(self.memory_graph.G.nodes()) similar_topics = [] @@ -343,17 +316,7 @@ class Hippocampus: logger.debug(f"进度: [{bar}] {progress:.1f}% ({i}/{len(memory_samples)})") compress_rate = global_config.memory_compress_rate - - # 尝试从消息中提取群组ID - group_id = None - if messages and len(messages) > 0: - first_msg = messages[0] - if 'group_id' in first_msg: - group_id = first_msg['group_id'] - logger.info(f"检测到消息来自群组: {group_id}") - - # 传递群组ID到memory_compress - compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate, group_id) + compressed_memory, similar_topics_dict = await self.memory_compress(messages, compress_rate) logger.info(f"压缩后记忆数量: {len(compressed_memory)},似曾相识的话题: {len(similar_topics_dict)}") current_time = datetime.datetime.now().timestamp() @@ -878,21 +841,8 @@ class Hippocampus: return activation async def get_relevant_memories(self, text: str, max_topics: int = 5, similarity_threshold: float = 0.4, - max_memory_num: int = 5, group_id: Optional[int] = None) -> list: - """根据输入文本获取相关的记忆内容 - - Args: - text: 输入文本 - max_topics: 最大主题数 - similarity_threshold: 相似度阈值 - max_memory_num: 最大记忆数量 - group_id: 群组ID,用于优先匹配当前群组的记忆 - - Returns: - list: 相关记忆列表 - """ - from ..chat.config import global_config # 导入配置 - + max_memory_num: int = 5) -> list: + """根据输入文本获取相关的记忆内容""" # 识别主题 identified_topics = await self._identify_topics(text) @@ -905,134 +855,30 @@ class Hippocampus: # 获取最相关的主题 relevant_topics = self._get_top_topics(all_similar_topics, max_topics) - - # 确定记忆所属的群组 - current_group_name = None - if group_id is not None: - # 查找群聊所属的群组 - for group_name, group_ids in global_config.memory_private_groups.items(): - if str(group_id) in group_ids: - current_group_name = group_name - break - - has_private_groups = len(global_config.memory_private_groups) > 0 - + # 获取相关记忆内容 relevant_memories = [] - group_related_memories = [] # 当前群聊的记忆 - group_definition_memories = [] # 当前群组的记忆 - public_memories = [] # 公共记忆 - for topic, score in relevant_topics: # 获取该主题的记忆内容 first_layer, _ = self.memory_graph.get_related_item(topic, depth=1) if first_layer: # 如果记忆条数超过限制,随机选择指定数量的记忆 - if len(first_layer) > max_memory_num: - first_layer = random.sample(first_layer, max_memory_num) - + if len(first_layer) > max_memory_num / 2: + first_layer = random.sample(first_layer, max_memory_num // 2) # 为每条记忆添加来源主题和相似度信息 for memory in first_layer: - memory_info = { + relevant_memories.append({ 'topic': topic, 'similarity': score, 'content': memory - } - - memory_text = str(memory) - - # 分类处理记忆 - if has_private_groups and group_id is not None: - # 如果配置了私有群组且当前在群聊中 - if current_group_name: - # 当前群聊属于某个群组 - if f"[群组:{current_group_name}]" in memory_text: - # 当前群组的记忆 - group_definition_memories.append(memory_info) - elif not any(f"[群组:" in memory_text for _ in range(1)): - # 公共记忆 - public_memories.append(memory_info) - else: - # 当前群聊不属于任何群组 - if f"[群组:{group_id}]" in memory_text: - # 当前群聊的特定记忆 - group_related_memories.append(memory_info) - elif not any(f"[群组:" in memory_text for _ in range(1)): - # 公共记忆 - public_memories.append(memory_info) - elif global_config.memory_group_priority and group_id is not None: - # 如果只启用了群组记忆优先 - if f"[群组:{group_id}]" in memory_text: - # 当前群聊的记忆,放入群组相关记忆列表 - group_related_memories.append(memory_info) - else: - # 其他记忆,放入公共记忆列表 - public_memories.append(memory_info) - else: - # 如果没有特殊配置,所有记忆都放入相关记忆列表 - relevant_memories.append(memory_info) + }) - # 根据配置决定如何组合记忆 - if has_private_groups and group_id is not None: - # 配置了私有群组且当前在群聊中 - if current_group_name: - # 当前群聊属于某个群组 - # 优先使用当前群组的记忆,如果不足再使用公共记忆 - if len(group_definition_memories) >= max_memory_num: - # 如果群组记忆足够,只使用群组记忆 - group_definition_memories.sort(key=lambda x: x['similarity'], reverse=True) - relevant_memories = group_definition_memories[:max_memory_num] - else: - # 如果群组记忆不足,添加公共记忆 - group_definition_memories.sort(key=lambda x: x['similarity'], reverse=True) - public_memories.sort(key=lambda x: x['similarity'], reverse=True) - - relevant_memories = group_definition_memories.copy() - remaining_count = max_memory_num - len(relevant_memories) - if remaining_count > 0 and public_memories: - selected_other = public_memories[:remaining_count] - relevant_memories.extend(selected_other) - else: - # 当前群聊不属于任何群组 - # 优先使用当前群聊的记忆,然后使用公共记忆 - if len(group_related_memories) >= max_memory_num: - # 如果当前群聊记忆足够,只使用当前群聊记忆 - group_related_memories.sort(key=lambda x: x['similarity'], reverse=True) - relevant_memories = group_related_memories[:max_memory_num] - else: - # 如果当前群聊记忆不足,添加公共记忆 - group_related_memories.sort(key=lambda x: x['similarity'], reverse=True) - public_memories.sort(key=lambda x: x['similarity'], reverse=True) - - relevant_memories = group_related_memories.copy() - remaining_count = max_memory_num - len(relevant_memories) - if remaining_count > 0 and public_memories: - selected_other = public_memories[:remaining_count] - relevant_memories.extend(selected_other) - elif global_config.memory_group_priority and group_id is not None: - # 如果只启用了群组记忆优先 - # 按相似度排序 - group_related_memories.sort(key=lambda x: x['similarity'], reverse=True) - public_memories.sort(key=lambda x: x['similarity'], reverse=True) - - # 优先使用群组相关记忆,如果不足再使用其他记忆 - if len(group_related_memories) >= max_memory_num: - # 如果群组相关记忆足够,只使用群组相关记忆 - relevant_memories = group_related_memories[:max_memory_num] - else: - # 使用所有群组相关记忆 - relevant_memories = group_related_memories.copy() - # 如果群组相关记忆不足,添加其他记忆 - remaining_count = max_memory_num - len(relevant_memories) - if remaining_count > 0 and public_memories: - # 从其他记忆中选择剩余需要的数量 - selected_other = public_memories[:remaining_count] - relevant_memories.extend(selected_other) - else: - # 如果没有特殊配置,按相似度排序 - relevant_memories.sort(key=lambda x: x['similarity'], reverse=True) - if len(relevant_memories) > max_memory_num: - relevant_memories = relevant_memories[:max_memory_num] + # 如果记忆数量超过5个,随机选择5个 + # 按相似度排序 + relevant_memories.sort(key=lambda x: x['similarity'], reverse=True) + + if len(relevant_memories) > max_memory_num: + relevant_memories = random.sample(relevant_memories, max_memory_num) return relevant_memories diff --git a/template/bot_config_template.toml b/template/bot_config_template.toml index 49f3a1919..089be69b0 100644 --- a/template/bot_config_template.toml +++ b/template/bot_config_template.toml @@ -1,5 +1,5 @@ [inner] -version = "0.0.9" +version = "0.0.8" #如果你想要修改配置文件,请在修改后将version的值进行变更 #如果新增项目,请在BotConfig类下新增相应的变量 @@ -72,17 +72,6 @@ forget_memory_interval = 600 # 记忆遗忘间隔 单位秒 间隔越低,麦 memory_forget_time = 24 #多长时间后的记忆会被遗忘 单位小时 memory_forget_percentage = 0.01 # 记忆遗忘比例 控制记忆遗忘程度 越大遗忘越多 建议保持默认 -memory_group_priority = true # 是否优先使用当前群组的记忆,开启后将优先使用当前群组的记忆内容,避免不同群组讨论相同话题时的记忆混淆 - -# 群组私有记忆配置 - 同一群组内的群聊共享记忆,但不与其他群组共享 -# 格式为 { 群组名称 = [群聊ID列表] } -# 未配置在任何群组中的群聊记忆可以与所有群聊共享(群组内群数量过少 聊天记录过少的情况下 建议修改其他记忆参数 加强回复概率等) -# 例如: -# memory_private_groups = { -# "游戏群组" = ["123456", "234567"], -# "工作群组" = ["345678", "456789"] -# } -memory_private_groups = { } memory_ban_words = [ #不希望记忆的词 # "403","张三" From 144475d2f4231374ac31c7e24207840f75dc0770 Mon Sep 17 00:00:00 2001 From: Pocketfans Date: Wed, 12 Mar 2025 17:57:50 +0800 Subject: [PATCH 122/162] =?UTF-8?q?=E8=AE=A9=E5=9B=9E=E5=A4=8D=E6=84=8F?= =?UTF-8?q?=E6=84=BF=E6=9B=B4=E5=8A=A0=E6=8B=9F=E4=BA=BA=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E9=AB=98=E4=BD=8E=E5=9B=9E=E5=A4=8D=E6=84=8F=E6=84=BF?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=EF=BC=8C=E4=B8=94=E5=A2=9E=E5=8A=A0=E4=BA=86?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E7=AE=80=E5=8D=95=E7=9A=84=E8=BF=BD=E9=97=AE?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 10 +- src/plugins/chat/willing_manager.py | 256 ++++++++++++++++++++++------ 2 files changed, 209 insertions(+), 57 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 1db38477c..5002cb162 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -135,7 +135,7 @@ class ChatBot: for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.raw_message): logger.info( - f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{userinfo.user_nickname}:{message.raw_message}" + f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{message.user_nickname}:{message.raw_message}" ) logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return @@ -159,6 +159,7 @@ class ChatBot: config=global_config, is_emoji=message.is_emoji, interested_rate=interested_rate, + sender_id=str(message.message_info.user_info.user_id), ) current_willing = willing_manager.get_willing(chat_stream=chat) @@ -189,6 +190,9 @@ class ChatBot: willing_manager.change_reply_willing_sent(chat) response, raw_content = await self.gpt.generate_response(message) + else: + # 决定不回复时,也更新回复意愿 + willing_manager.change_reply_willing_not_sent(chat) # print(f"response: {response}") if response: @@ -235,10 +239,10 @@ class ChatBot: is_head=not mark_head, is_emoji=False, ) - logger.debug(f"bot_message: {bot_message}") + print(f"bot_message: {bot_message}") if not mark_head: mark_head = True - logger.debug(f"添加消息到message_set: {bot_message}") + print(f"添加消息到message_set: {bot_message}") message_set.add_message(bot_message) # message_set 可以直接加入 message_manager diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 773d40c6e..81a4fe62a 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -1,109 +1,257 @@ import asyncio +import random +import time from typing import Dict from .config import global_config from .chat_stream import ChatStream -from loguru import logger - class WillingManager: def __init__(self): self.chat_reply_willing: Dict[str, float] = {} # 存储每个聊天流的回复意愿 + self.chat_high_willing_mode: Dict[str, bool] = {} # 存储每个聊天流是否处于高回复意愿期 + self.chat_msg_count: Dict[str, int] = {} # 存储每个聊天流接收到的消息数量 + self.chat_last_mode_change: Dict[str, float] = {} # 存储每个聊天流上次模式切换的时间 + self.chat_high_willing_duration: Dict[str, int] = {} # 高意愿期持续时间(秒) + self.chat_low_willing_duration: Dict[str, int] = {} # 低意愿期持续时间(秒) + self.chat_last_reply_time: Dict[str, float] = {} # 存储每个聊天流上次回复的时间 + self.chat_last_sender_id: Dict[str, str] = {} # 存储每个聊天流上次回复的用户ID + self.chat_conversation_context: Dict[str, bool] = {} # 标记是否处于对话上下文中 self._decay_task = None + self._mode_switch_task = None self._started = False - + async def _decay_reply_willing(self): """定期衰减回复意愿""" while True: await asyncio.sleep(5) - for chat_id in self.chat_reply_willing: - self.chat_reply_willing[chat_id] = max(0, self.chat_reply_willing[chat_id] * 0.6) - + for chat_id in list(self.chat_reply_willing.keys()): + is_high_mode = self.chat_high_willing_mode.get(chat_id, False) + if is_high_mode: + # 高回复意愿期内轻微衰减 + self.chat_reply_willing[chat_id] = max(0.5, self.chat_reply_willing[chat_id] * 0.95) + else: + # 低回复意愿期内正常衰减 + self.chat_reply_willing[chat_id] = max(0, self.chat_reply_willing[chat_id] * 0.8) + + async def _mode_switch_check(self): + """定期检查是否需要切换回复意愿模式""" + while True: + current_time = time.time() + await asyncio.sleep(10) # 每10秒检查一次 + + for chat_id in list(self.chat_high_willing_mode.keys()): + last_change_time = self.chat_last_mode_change.get(chat_id, 0) + is_high_mode = self.chat_high_willing_mode.get(chat_id, False) + + # 获取当前模式的持续时间 + duration = 0 + if is_high_mode: + duration = self.chat_high_willing_duration.get(chat_id, 180) # 默认3分钟 + else: + duration = self.chat_low_willing_duration.get(chat_id, random.randint(300, 1200)) # 默认5-20分钟 + + # 检查是否需要切换模式 + if current_time - last_change_time > duration: + self._switch_willing_mode(chat_id) + elif not is_high_mode and random.random() < 0.1: + # 低回复意愿期有10%概率随机切换到高回复期 + self._switch_willing_mode(chat_id) + + # 检查对话上下文状态是否需要重置 + last_reply_time = self.chat_last_reply_time.get(chat_id, 0) + if current_time - last_reply_time > 300: # 5分钟无交互,重置对话上下文 + self.chat_conversation_context[chat_id] = False + + def _switch_willing_mode(self, chat_id: str): + """切换聊天流的回复意愿模式""" + is_high_mode = self.chat_high_willing_mode.get(chat_id, False) + + if is_high_mode: + # 从高回复期切换到低回复期 + self.chat_high_willing_mode[chat_id] = False + self.chat_reply_willing[chat_id] = 0.1 # 设置为最低回复意愿 + self.chat_low_willing_duration[chat_id] = random.randint(600, 1200) # 10-20分钟 + print(f"聊天流 {chat_id} 切换到低回复意愿期,持续 {self.chat_low_willing_duration[chat_id]} 秒") + else: + # 从低回复期切换到高回复期 + self.chat_high_willing_mode[chat_id] = True + self.chat_reply_willing[chat_id] = 1.0 # 设置为较高回复意愿 + self.chat_high_willing_duration[chat_id] = random.randint(180, 240) # 3-4分钟 + print(f"聊天流 {chat_id} 切换到高回复意愿期,持续 {self.chat_high_willing_duration[chat_id]} 秒") + + self.chat_last_mode_change[chat_id] = time.time() + self.chat_msg_count[chat_id] = 0 # 重置消息计数 + def get_willing(self, chat_stream: ChatStream) -> float: """获取指定聊天流的回复意愿""" stream = chat_stream if stream: return self.chat_reply_willing.get(stream.stream_id, 0) return 0 - + def set_willing(self, chat_id: str, willing: float): """设置指定聊天流的回复意愿""" self.chat_reply_willing[chat_id] = willing - - async def change_reply_willing_received( - self, - chat_stream: ChatStream, - topic: str = None, - is_mentioned_bot: bool = False, - config=None, - is_emoji: bool = False, - interested_rate: float = 0, - ) -> float: + + def _ensure_chat_initialized(self, chat_id: str): + """确保聊天流的所有数据已初始化""" + if chat_id not in self.chat_reply_willing: + self.chat_reply_willing[chat_id] = 0.1 + + if chat_id not in self.chat_high_willing_mode: + self.chat_high_willing_mode[chat_id] = False + self.chat_last_mode_change[chat_id] = time.time() + self.chat_low_willing_duration[chat_id] = random.randint(300, 1200) # 5-20分钟 + + if chat_id not in self.chat_msg_count: + self.chat_msg_count[chat_id] = 0 + + if chat_id not in self.chat_conversation_context: + self.chat_conversation_context[chat_id] = False + + async def change_reply_willing_received(self, + chat_stream: ChatStream, + topic: str = None, + is_mentioned_bot: bool = False, + config = None, + is_emoji: bool = False, + interested_rate: float = 0, + sender_id: str = None) -> float: """改变指定聊天流的回复意愿并返回回复概率""" # 获取或创建聊天流 stream = chat_stream chat_id = stream.stream_id - + current_time = time.time() + + self._ensure_chat_initialized(chat_id) + + # 增加消息计数 + self.chat_msg_count[chat_id] = self.chat_msg_count.get(chat_id, 0) + 1 + current_willing = self.chat_reply_willing.get(chat_id, 0) - - if is_mentioned_bot and current_willing < 1.0: - current_willing += 0.9 - logger.debug(f"被提及, 当前意愿: {current_willing}") - elif is_mentioned_bot: - current_willing += 0.05 - logger.debug(f"被重复提及, 当前意愿: {current_willing}") - + is_high_mode = self.chat_high_willing_mode.get(chat_id, False) + msg_count = self.chat_msg_count.get(chat_id, 0) + in_conversation_context = self.chat_conversation_context.get(chat_id, False) + + # 检查是否是对话上下文中的追问 + last_reply_time = self.chat_last_reply_time.get(chat_id, 0) + last_sender = self.chat_last_sender_id.get(chat_id, "") + is_follow_up_question = False + + # 如果是同一个人在短时间内(2分钟内)发送消息,且消息数量较少(<=5条),视为追问 + if sender_id and sender_id == last_sender and current_time - last_reply_time < 120 and msg_count <= 5: + is_follow_up_question = True + in_conversation_context = True + self.chat_conversation_context[chat_id] = True + print(f"检测到追问 (同一用户), 提高回复意愿") + current_willing += 0.3 + + # 特殊情况处理 + if is_mentioned_bot: + current_willing += 0.3 + in_conversation_context = True + self.chat_conversation_context[chat_id] = True + print(f"被提及, 当前意愿: {current_willing}") + if is_emoji: current_willing *= 0.1 - logger.debug(f"表情包, 当前意愿: {current_willing}") - - logger.debug(f"放大系数_interested_rate: {global_config.response_interested_rate_amplifier}") - interested_rate *= global_config.response_interested_rate_amplifier # 放大回复兴趣度 - if interested_rate > 0.4: - # print(f"兴趣度: {interested_rate}, 当前意愿: {current_willing}") - current_willing += interested_rate - 0.4 - - current_willing *= global_config.response_willing_amplifier # 放大回复意愿 - # print(f"放大系数_willing: {global_config.response_willing_amplifier}, 当前意愿: {current_willing}") - - reply_probability = max((current_willing - 0.45) * 2, 0) - + print(f"表情包, 当前意愿: {current_willing}") + + # 根据话题兴趣度适当调整 + if interested_rate > 0.5: + current_willing += (interested_rate - 0.5) * 0.5 + + # 根据当前模式计算回复概率 + base_probability = 0.0 + + if in_conversation_context: + # 在对话上下文中,提高基础回复概率 + base_probability = 0.75 if is_high_mode else 0.5 + print(f"处于对话上下文中,基础回复概率: {base_probability}") + elif is_high_mode: + # 高回复周期:4-8句话有65%的概率会回复一次 + base_probability = 0.65 if 4 <= msg_count <= 8 else 0.3 + else: + # 低回复周期:需要最少15句才有50%的概率会回一句 + base_probability = 0.5 if msg_count >= 15 else 0.05 * min(msg_count, 10) + + # 考虑回复意愿的影响 + reply_probability = base_probability * current_willing + # 检查群组权限(如果是群聊) - if chat_stream.group_info: + if chat_stream.group_info and config: if chat_stream.group_info.group_id in config.talk_frequency_down_groups: reply_probability = reply_probability / global_config.down_frequency_rate reply_probability = min(reply_probability, 1) if reply_probability < 0: reply_probability = 0 - + + # 记录当前发送者ID以便后续追踪 + if sender_id: + self.chat_last_sender_id[chat_id] = sender_id + self.chat_reply_willing[chat_id] = min(current_willing, 3.0) return reply_probability - + def change_reply_willing_sent(self, chat_stream: ChatStream): """开始思考后降低聊天流的回复意愿""" stream = chat_stream if stream: - current_willing = self.chat_reply_willing.get(stream.stream_id, 0) - self.chat_reply_willing[stream.stream_id] = max(0, current_willing - 2) - - def change_reply_willing_after_sent(self, chat_stream: ChatStream): - """发送消息后提高聊天流的回复意愿""" + chat_id = stream.stream_id + self._ensure_chat_initialized(chat_id) + is_high_mode = self.chat_high_willing_mode.get(chat_id, False) + current_willing = self.chat_reply_willing.get(chat_id, 0) + + # 回复后减少回复意愿 + self.chat_reply_willing[chat_id] = max(0, current_willing - 0.3) + + # 标记为对话上下文中 + self.chat_conversation_context[chat_id] = True + + # 记录最后回复时间 + self.chat_last_reply_time[chat_id] = time.time() + + # 重置消息计数 + self.chat_msg_count[chat_id] = 0 + + def change_reply_willing_not_sent(self, chat_stream: ChatStream): + """决定不回复后提高聊天流的回复意愿""" stream = chat_stream if stream: - current_willing = self.chat_reply_willing.get(stream.stream_id, 0) - if current_willing < 1: - self.chat_reply_willing[stream.stream_id] = min(1, current_willing + 0.2) - + chat_id = stream.stream_id + self._ensure_chat_initialized(chat_id) + is_high_mode = self.chat_high_willing_mode.get(chat_id, False) + current_willing = self.chat_reply_willing.get(chat_id, 0) + in_conversation_context = self.chat_conversation_context.get(chat_id, False) + + # 根据当前模式调整不回复后的意愿增加 + if is_high_mode: + willing_increase = 0.1 + elif in_conversation_context: + # 在对话上下文中但决定不回复,小幅增加回复意愿 + willing_increase = 0.15 + else: + willing_increase = random.uniform(0.05, 0.1) + + self.chat_reply_willing[chat_id] = min(2.0, current_willing + willing_increase) + + def change_reply_willing_after_sent(self, chat_stream: ChatStream): + """发送消息后提高聊天流的回复意愿""" + # 由于已经在sent中处理,这个方法保留但不再需要额外调整 + pass + async def ensure_started(self): - """确保衰减任务已启动""" + """确保所有任务已启动""" if not self._started: if self._decay_task is None: self._decay_task = asyncio.create_task(self._decay_reply_willing()) + if self._mode_switch_task is None: + self._mode_switch_task = asyncio.create_task(self._mode_switch_check()) self._started = True - # 创建全局实例 -willing_manager = WillingManager() +willing_manager = WillingManager() \ No newline at end of file From e50c73932b0e0ae08d4cff1c4ba5477180dac6f9 Mon Sep 17 00:00:00 2001 From: Pocketfans Date: Wed, 12 Mar 2025 18:27:04 +0800 Subject: [PATCH 123/162] =?UTF-8?q?=E9=99=8D=E4=BD=8E=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E7=9A=84=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/willing_manager.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 81a4fe62a..9754848c4 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -151,7 +151,7 @@ class WillingManager: # 特殊情况处理 if is_mentioned_bot: - current_willing += 0.3 + current_willing += 0.5 in_conversation_context = True self.chat_conversation_context[chat_id] = True print(f"被提及, 当前意愿: {current_willing}") @@ -168,15 +168,15 @@ class WillingManager: base_probability = 0.0 if in_conversation_context: - # 在对话上下文中,提高基础回复概率 - base_probability = 0.75 if is_high_mode else 0.5 + # 在对话上下文中,降低基础回复概率 + base_probability = 0.5 if is_high_mode else 0.25 print(f"处于对话上下文中,基础回复概率: {base_probability}") elif is_high_mode: - # 高回复周期:4-8句话有65%的概率会回复一次 - base_probability = 0.65 if 4 <= msg_count <= 8 else 0.3 + # 高回复周期:4-8句话有50%的概率会回复一次 + base_probability = 0.50 if 4 <= msg_count <= 8 else 0.2 else: - # 低回复周期:需要最少15句才有50%的概率会回一句 - base_probability = 0.5 if msg_count >= 15 else 0.05 * min(msg_count, 10) + # 低回复周期:需要最少15句才有30%的概率会回一句 + base_probability = 0.30 if msg_count >= 15 else 0.03 * min(msg_count, 10) # 考虑回复意愿的影响 reply_probability = base_probability * current_willing @@ -186,7 +186,8 @@ class WillingManager: if chat_stream.group_info.group_id in config.talk_frequency_down_groups: reply_probability = reply_probability / global_config.down_frequency_rate - reply_probability = min(reply_probability, 1) + # 限制最大回复概率 + reply_probability = min(reply_probability, 0.75) # 设置最大回复概率为75% if reply_probability < 0: reply_probability = 0 From 49082267bb5427d0f2affbd15b6942785b94c591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E7=8C=AB?= Date: Wed, 12 Mar 2025 22:27:05 +0900 Subject: [PATCH 124/162] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E6=A8=A1=E5=9D=97=E5=AE=9E=E7=8E=B0=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用Global Object Pattern设计模式 - 实现数据库连接的延迟初始化 - 添加类型注解支持IDE类型推导 - 确保环境变量在bot.py加载后再连接数据库 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/common/database.py | 118 +++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 69 deletions(-) diff --git a/src/common/database.py b/src/common/database.py index c6cead225..ca73dc468 100644 --- a/src/common/database.py +++ b/src/common/database.py @@ -1,73 +1,53 @@ -from typing import Optional +import os +from typing import cast from pymongo import MongoClient -from pymongo.database import Database as MongoDatabase +from pymongo.database import Database -class Database: - _instance: Optional["Database"] = None - - def __init__( - self, - host: str, - port: int, - db_name: str, - username: Optional[str] = None, - password: Optional[str] = None, - auth_source: Optional[str] = None, - uri: Optional[str] = None, - ): - if uri and uri.startswith("mongodb://"): - # 优先使用URI连接 - self.client = MongoClient(uri) - elif username and password: - # 如果有用户名和密码,使用认证连接 - self.client = MongoClient( - host, port, username=username, password=password, authSource=auth_source - ) - else: - # 否则使用无认证连接 - self.client = MongoClient(host, port) - self.db: MongoDatabase = self.client[db_name] - - @classmethod - def initialize( - cls, - host: str, - port: int, - db_name: str, - username: Optional[str] = None, - password: Optional[str] = None, - auth_source: Optional[str] = None, - uri: Optional[str] = None, - ) -> MongoDatabase: - if cls._instance is None: - cls._instance = cls( - host, port, db_name, username, password, auth_source, uri - ) - return cls._instance.db - - @classmethod - def get_instance(cls) -> MongoDatabase: - if cls._instance is None: - raise RuntimeError("Database not initialized") - return cls._instance.db +_client = None +_db = None - #测试用 - - def get_random_group_messages(self, group_id: str, limit: int = 5): - # 先随机获取一条消息 - random_message = list(self.db.messages.aggregate([ - {"$match": {"group_id": group_id}}, - {"$sample": {"size": 1}} - ]))[0] - - # 获取该消息之后的消息 - subsequent_messages = list(self.db.messages.find({ - "group_id": group_id, - "time": {"$gt": random_message["time"]} - }).sort("time", 1).limit(limit)) - - # 将随机消息和后续消息合并 - messages = [random_message] + subsequent_messages - - return messages \ No newline at end of file +def __create_database_instance(): + uri = os.getenv("MONGODB_URI") + host = os.getenv("MONGODB_HOST", "127.0.0.1") + port = int(os.getenv("MONGODB_PORT", "27017")) + db_name = os.getenv("DATABASE_NAME", "MegBot") + username = os.getenv("MONGODB_USERNAME") + password = os.getenv("MONGODB_PASSWORD") + auth_source = os.getenv("MONGODB_AUTH_SOURCE") + + if uri and uri.startswith("mongodb://"): + # 优先使用URI连接 + return MongoClient(uri) + + if username and password: + # 如果有用户名和密码,使用认证连接 + return MongoClient( + host, port, username=username, password=password, authSource=auth_source + ) + + # 否则使用无认证连接 + return MongoClient(host, port) + + +def get_db(): + """获取数据库连接实例,延迟初始化。""" + global _client, _db + if _client is None: + _client = __create_database_instance() + _db = _client[os.getenv("DATABASE_NAME", "MegBot")] + return _db + + +class DBWrapper: + """数据库代理类,保持接口兼容性同时实现懒加载。""" + + def __getattr__(self, name): + return getattr(get_db(), name) + + def __getitem__(self, key): + return get_db()[key] + + +# 全局数据库访问点 +db: Database = DBWrapper() From 8be087dcad80bf592e62369f566a677a9884d3bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E7=8C=AB?= Date: Wed, 12 Mar 2025 22:27:59 +0900 Subject: [PATCH 125/162] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E8=AE=BF=E9=97=AE=EF=BC=8C=E6=9B=BF=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=9A=84=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 15 ---- src/gui/reasoning_gui.py | 39 +-------- src/plugins/chat/__init__.py | 1 - src/plugins/chat/chat_stream.py | 17 ++-- src/plugins/chat/emoji_manager.py | 39 +++++---- src/plugins/chat/llm_generator.py | 6 +- src/plugins/chat/prompt_builder.py | 9 +-- src/plugins/chat/relationship_manager.py | 5 +- src/plugins/chat/storage.py | 7 +- src/plugins/chat/utils.py | 9 +-- src/plugins/chat/utils_image.py | 40 +++++----- src/plugins/memory_system/draw_memory.py | 30 +++---- src/plugins/memory_system/memory.py | 29 ++++--- .../memory_system/memory_manual_build.py | 46 ++++------- src/plugins/memory_system/memory_test1.py | 79 +++++-------------- src/plugins/models/utils_model.py | 13 ++- src/plugins/schedule/schedule_generator.py | 9 +-- src/plugins/utils/statistic.py | 5 +- src/plugins/zhishi/knowledge_library.py | 24 ++---- 19 files changed, 138 insertions(+), 284 deletions(-) diff --git a/bot.py b/bot.py index 19ad80025..48517fe24 100644 --- a/bot.py +++ b/bot.py @@ -12,8 +12,6 @@ from loguru import logger from nonebot.adapters.onebot.v11 import Adapter import platform -from src.common.database import Database - # 获取没有加载env时的环境变量 env_mask = {key: os.getenv(key) for key in os.environ} @@ -111,18 +109,6 @@ def load_env(): logger.error(f"ENVIRONMENT 配置错误,请检查 .env 文件中的 ENVIRONMENT 变量及对应 .env.{env} 是否存在") RuntimeError(f"ENVIRONMENT 配置错误,请检查 .env 文件中的 ENVIRONMENT 变量及对应 .env.{env} 是否存在") -def init_database(): - Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), - ) - - def load_logger(): logger.remove() # 移除默认配置 if os.getenv("ENVIRONMENT") == "dev": @@ -223,7 +209,6 @@ def raw_main(): init_config() init_env() load_env() - init_database() # 加载完成环境后初始化database load_logger() env_config = {key: os.getenv(key) for key in os.environ} diff --git a/src/gui/reasoning_gui.py b/src/gui/reasoning_gui.py index 84b95adaf..e79f8f91f 100644 --- a/src/gui/reasoning_gui.py +++ b/src/gui/reasoning_gui.py @@ -7,7 +7,7 @@ from datetime import datetime from typing import Dict, List from loguru import logger from typing import Optional -from ..common.database import Database +from ..common.database import db import customtkinter as ctk from dotenv import load_dotenv @@ -44,28 +44,6 @@ class ReasoningGUI: self.root.geometry('800x600') self.root.protocol("WM_DELETE_WINDOW", self._on_closing) - # 初始化数据库连接 - try: - self.db = Database.get_instance() - logger.success("数据库连接成功") - except RuntimeError: - logger.warning("数据库未初始化,正在尝试初始化...") - try: - Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), - ) - self.db = Database.get_instance() - logger.success("数据库初始化成功") - except Exception: - logger.exception("数据库初始化失败") - sys.exit(1) - # 存储群组数据 self.group_data: Dict[str, List[dict]] = {} @@ -264,11 +242,11 @@ class ReasoningGUI: logger.debug(f"查询条件: {query}") # 先获取一条记录检查时间格式 - sample = self.db.reasoning_logs.find_one() + sample = db.reasoning_logs.find_one() if sample: logger.debug(f"样本记录时间格式: {type(sample['time'])} 值: {sample['time']}") - cursor = self.db.reasoning_logs.find(query).sort("time", -1) + cursor = db.reasoning_logs.find(query).sort("time", -1) new_data = {} total_count = 0 @@ -333,17 +311,6 @@ class ReasoningGUI: def main(): - """主函数""" - Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), - ) - app = ReasoningGUI() app.run() diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index 1c6bf3f35..d7a7bd7e4 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -7,7 +7,6 @@ from nonebot import get_driver, on_message, require from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment,MessageEvent from nonebot.typing import T_State -from ...common.database import Database from ..moods.moods import MoodManager # 导入情绪管理器 from ..schedule.schedule_generator import bot_schedule from ..utils.statistic import LLMStatistics diff --git a/src/plugins/chat/chat_stream.py b/src/plugins/chat/chat_stream.py index 3ccd03f81..60b0af493 100644 --- a/src/plugins/chat/chat_stream.py +++ b/src/plugins/chat/chat_stream.py @@ -6,7 +6,7 @@ from typing import Dict, Optional from loguru import logger -from ...common.database import Database +from ...common.database import db from .message_base import GroupInfo, UserInfo @@ -83,7 +83,6 @@ class ChatManager: def __init__(self): if not self._initialized: self.streams: Dict[str, ChatStream] = {} # stream_id -> ChatStream - self.db = Database.get_instance() self._ensure_collection() self._initialized = True # 在事件循环中启动初始化 @@ -111,11 +110,11 @@ class ChatManager: def _ensure_collection(self): """确保数据库集合存在并创建索引""" - if "chat_streams" not in self.db.list_collection_names(): - self.db.create_collection("chat_streams") + if "chat_streams" not in db.list_collection_names(): + db.create_collection("chat_streams") # 创建索引 - self.db.chat_streams.create_index([("stream_id", 1)], unique=True) - self.db.chat_streams.create_index( + db.chat_streams.create_index([("stream_id", 1)], unique=True) + db.chat_streams.create_index( [("platform", 1), ("user_info.user_id", 1), ("group_info.group_id", 1)] ) @@ -168,7 +167,7 @@ class ChatManager: return stream # 检查数据库中是否存在 - data = self.db.chat_streams.find_one({"stream_id": stream_id}) + data = db.chat_streams.find_one({"stream_id": stream_id}) if data: stream = ChatStream.from_dict(data) # 更新用户信息和群组信息 @@ -204,7 +203,7 @@ class ChatManager: async def _save_stream(self, stream: ChatStream): """保存聊天流到数据库""" if not stream.saved: - self.db.chat_streams.update_one( + db.chat_streams.update_one( {"stream_id": stream.stream_id}, {"$set": stream.to_dict()}, upsert=True ) stream.saved = True @@ -216,7 +215,7 @@ class ChatManager: async def load_all_streams(self): """从数据库加载所有聊天流""" - all_streams = self.db.chat_streams.find({}) + all_streams = db.chat_streams.find({}) for data in all_streams: stream = ChatStream.from_dict(data) self.streams[stream.stream_id] = stream diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 1c8a07699..822eda009 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -12,7 +12,7 @@ import io from loguru import logger from nonebot import get_driver -from ...common.database import Database +from ...common.database import db from ..chat.config import global_config from ..chat.utils import get_embedding from ..chat.utils_image import ImageManager, image_path_to_base64 @@ -30,12 +30,10 @@ class EmojiManager: def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) - cls._instance.db = None cls._instance._initialized = False return cls._instance def __init__(self): - self.db = Database.get_instance() self._scan_task = None self.vlm = LLM_request(model=global_config.vlm, temperature=0.3, max_tokens=1000) self.llm_emotion_judge = LLM_request(model=global_config.llm_emotion_judge, max_tokens=60, @@ -50,7 +48,6 @@ class EmojiManager: """初始化数据库连接和表情目录""" if not self._initialized: try: - self.db = Database.get_instance() self._ensure_emoji_collection() self._ensure_emoji_dir() self._initialized = True @@ -78,16 +75,16 @@ class EmojiManager: 没有索引的话,数据库每次查询都需要扫描全部数据,建立索引后可以大大提高查询效率。 """ - if 'emoji' not in self.db.list_collection_names(): - self.db.create_collection('emoji') - self.db.emoji.create_index([('embedding', '2dsphere')]) - self.db.emoji.create_index([('filename', 1)], unique=True) + if 'emoji' not in db.list_collection_names(): + db.create_collection('emoji') + db.emoji.create_index([('embedding', '2dsphere')]) + db.emoji.create_index([('filename', 1)], unique=True) def record_usage(self, emoji_id: str): """记录表情使用次数""" try: self._ensure_db() - self.db.emoji.update_one( + db.emoji.update_one( {'_id': emoji_id}, {'$inc': {'usage_count': 1}} ) @@ -121,7 +118,7 @@ class EmojiManager: try: # 获取所有表情包 - all_emojis = list(self.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'description': 1})) + all_emojis = list(db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'description': 1})) if not all_emojis: logger.warning("数据库中没有任何表情包") @@ -159,7 +156,7 @@ class EmojiManager: if selected_emoji and 'path' in selected_emoji: # 更新使用次数 - self.db.emoji.update_one( + db.emoji.update_one( {'_id': selected_emoji['_id']}, {'$inc': {'usage_count': 1}} ) @@ -241,14 +238,14 @@ class EmojiManager: image_hash = hashlib.md5(image_bytes).hexdigest() image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 检查是否已经注册过 - existing_emoji = self.db['emoji'].find_one({'filename': filename}) + existing_emoji = db['emoji'].find_one({'filename': filename}) description = None if existing_emoji: # 即使表情包已存在,也检查是否需要同步到images集合 description = existing_emoji.get('discription') # 检查是否在images集合中存在 - existing_image = image_manager.db.images.find_one({'hash': image_hash}) + existing_image = db.images.find_one({'hash': image_hash}) if not existing_image: # 同步到images集合 image_doc = { @@ -258,7 +255,7 @@ class EmojiManager: 'description': description, 'timestamp': int(time.time()) } - image_manager.db.images.update_one( + db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True @@ -307,7 +304,7 @@ class EmojiManager: } # 保存到emoji数据库 - self.db['emoji'].insert_one(emoji_record) + db['emoji'].insert_one(emoji_record) logger.success(f"注册新表情包: {filename}") logger.info(f"描述: {description}") @@ -320,7 +317,7 @@ class EmojiManager: 'description': description, 'timestamp': int(time.time()) } - image_manager.db.images.update_one( + db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True @@ -348,7 +345,7 @@ class EmojiManager: try: self._ensure_db() # 获取所有表情包记录 - all_emojis = list(self.db.emoji.find()) + all_emojis = list(db.emoji.find()) removed_count = 0 total_count = len(all_emojis) @@ -356,13 +353,13 @@ class EmojiManager: try: if 'path' not in emoji: logger.warning(f"发现无效记录(缺少path字段),ID: {emoji.get('_id', 'unknown')}") - self.db.emoji.delete_one({'_id': emoji['_id']}) + db.emoji.delete_one({'_id': emoji['_id']}) removed_count += 1 continue if 'embedding' not in emoji: logger.warning(f"发现过时记录(缺少embedding字段),ID: {emoji.get('_id', 'unknown')}") - self.db.emoji.delete_one({'_id': emoji['_id']}) + db.emoji.delete_one({'_id': emoji['_id']}) removed_count += 1 continue @@ -370,7 +367,7 @@ class EmojiManager: if not os.path.exists(emoji['path']): logger.warning(f"表情包文件已被删除: {emoji['path']}") # 从数据库中删除记录 - result = self.db.emoji.delete_one({'_id': emoji['_id']}) + result = db.emoji.delete_one({'_id': emoji['_id']}) if result.deleted_count > 0: logger.debug(f"成功删除数据库记录: {emoji['_id']}") removed_count += 1 @@ -381,7 +378,7 @@ class EmojiManager: continue # 验证清理结果 - remaining_count = self.db.emoji.count_documents({}) + remaining_count = db.emoji.count_documents({}) if removed_count > 0: logger.success(f"已清理 {removed_count} 个失效的表情包记录") logger.info(f"清理前总数: {total_count} | 清理后总数: {remaining_count}") diff --git a/src/plugins/chat/llm_generator.py b/src/plugins/chat/llm_generator.py index 84e1937b0..2e0c0eb1f 100644 --- a/src/plugins/chat/llm_generator.py +++ b/src/plugins/chat/llm_generator.py @@ -5,7 +5,7 @@ from typing import List, Optional, Tuple, Union from nonebot import get_driver from loguru import logger -from ...common.database import Database +from ...common.database import db from ..models.utils_model import LLM_request from .config import global_config from .message import MessageRecv, MessageThinking, Message @@ -34,7 +34,6 @@ class ResponseGenerator: self.model_v25 = LLM_request( model=global_config.llm_normal_minor, temperature=0.7, max_tokens=1000 ) - self.db = Database.get_instance() self.current_model_type = "r1" # 默认使用 R1 async def generate_response( @@ -154,7 +153,7 @@ class ResponseGenerator: reasoning_content: str, ): """保存对话记录到数据库""" - self.db.reasoning_logs.insert_one( + db.reasoning_logs.insert_one( { "time": time.time(), "chat_id": message.chat_stream.stream_id, @@ -211,7 +210,6 @@ class ResponseGenerator: class InitiativeMessageGenerate: def __init__(self): - self.db = Database.get_instance() self.model_r1 = LLM_request(model=global_config.llm_reasoning, temperature=0.7) self.model_v3 = LLM_request(model=global_config.llm_normal, temperature=0.7) self.model_r1_distill = LLM_request( diff --git a/src/plugins/chat/prompt_builder.py b/src/plugins/chat/prompt_builder.py index c89bf3e07..a41ed51e2 100644 --- a/src/plugins/chat/prompt_builder.py +++ b/src/plugins/chat/prompt_builder.py @@ -3,7 +3,7 @@ import time from typing import Optional from loguru import logger -from ...common.database import Database +from ...common.database import db from ..memory_system.memory import hippocampus, memory_graph from ..moods.moods import MoodManager from ..schedule.schedule_generator import bot_schedule @@ -16,7 +16,6 @@ class PromptBuilder: def __init__(self): self.prompt_built = '' self.activate_messages = '' - self.db = Database.get_instance() @@ -76,7 +75,7 @@ class PromptBuilder: chat_in_group=True chat_talking_prompt = '' if stream_id: - chat_talking_prompt = get_recent_group_detailed_plain_text(self.db, stream_id, limit=global_config.MAX_CONTEXT_SIZE,combine = True) + chat_talking_prompt = get_recent_group_detailed_plain_text(stream_id, limit=global_config.MAX_CONTEXT_SIZE,combine = True) chat_stream=chat_manager.get_stream(stream_id) if chat_stream.group_info: chat_talking_prompt = f"以下是群里正在聊天的内容:\n{chat_talking_prompt}" @@ -199,7 +198,7 @@ class PromptBuilder: chat_talking_prompt = '' if group_id: - chat_talking_prompt = get_recent_group_detailed_plain_text(self.db, group_id, + chat_talking_prompt = get_recent_group_detailed_plain_text(group_id, limit=global_config.MAX_CONTEXT_SIZE, combine=True) @@ -311,7 +310,7 @@ class PromptBuilder: {"$project": {"content": 1, "similarity": 1}} ] - results = list(self.db.knowledges.aggregate(pipeline)) + results = list(db.knowledges.aggregate(pipeline)) # print(f"\033[1;34m[调试]\033[0m获取知识库内容结果: {results}") if not results: diff --git a/src/plugins/chat/relationship_manager.py b/src/plugins/chat/relationship_manager.py index fbd8cec59..d604e6734 100644 --- a/src/plugins/chat/relationship_manager.py +++ b/src/plugins/chat/relationship_manager.py @@ -2,7 +2,7 @@ import asyncio from typing import Optional from loguru import logger -from ...common.database import Database +from ...common.database import db from .message_base import UserInfo from .chat_stream import ChatStream @@ -167,14 +167,12 @@ class RelationshipManager: async def load_all_relationships(self): """加载所有关系对象""" - db = Database.get_instance() all_relationships = db.relationships.find({}) for data in all_relationships: await self.load_relationship(data) async def _start_relationship_manager(self): """每5分钟自动保存一次关系数据""" - db = Database.get_instance() # 获取所有关系记录 all_relationships = db.relationships.find({}) # 依次加载每条记录 @@ -205,7 +203,6 @@ class RelationshipManager: age = relationship.age saved = relationship.saved - db = Database.get_instance() db.relationships.update_one( {'user_id': user_id, 'platform': platform}, {'$set': { diff --git a/src/plugins/chat/storage.py b/src/plugins/chat/storage.py index ec155bbe9..ad6662f2b 100644 --- a/src/plugins/chat/storage.py +++ b/src/plugins/chat/storage.py @@ -1,15 +1,12 @@ from typing import Optional, Union -from ...common.database import Database +from ...common.database import db from .message import MessageSending, MessageRecv from .chat_stream import ChatStream from loguru import logger class MessageStorage: - def __init__(self): - self.db = Database.get_instance() - async def store_message(self, message: Union[MessageSending, MessageRecv],chat_stream:ChatStream, topic: Optional[str] = None) -> None: """存储消息到数据库""" try: @@ -23,7 +20,7 @@ class MessageStorage: "detailed_plain_text": message.detailed_plain_text, "topic": topic, } - self.db.messages.insert_one(message_data) + db.messages.insert_one(message_data) except Exception: logger.exception("存储消息失败") diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index 0d1afd055..bf834a380 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -16,6 +16,7 @@ from .message import MessageRecv,Message from .message_base import UserInfo from .chat_stream import ChatStream from ..moods.moods import MoodManager +from ...common.database import db driver = get_driver() config = driver.config @@ -76,11 +77,10 @@ def calculate_information_content(text): return entropy -def get_cloest_chat_from_db(db, length: int, timestamp: str): +def get_cloest_chat_from_db(length: int, timestamp: str): """从数据库中获取最接近指定时间戳的聊天记录 Args: - db: 数据库实例 length: 要获取的消息数量 timestamp: 时间戳 @@ -115,11 +115,10 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): return [] -async def get_recent_group_messages(db, chat_id:str, limit: int = 12) -> list: +async def get_recent_group_messages(chat_id:str, limit: int = 12) -> list: """从数据库获取群组最近的消息记录 Args: - db: Database实例 group_id: 群组ID limit: 获取消息数量,默认12条 @@ -161,7 +160,7 @@ async def get_recent_group_messages(db, chat_id:str, limit: int = 12) -> list: return message_objects -def get_recent_group_detailed_plain_text(db, chat_stream_id: int, limit: int = 12, combine=False): +def get_recent_group_detailed_plain_text(chat_stream_id: int, limit: int = 12, combine=False): recent_messages = list(db.messages.find( {"chat_id": chat_stream_id}, { diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 94014b5b4..2154280de 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -10,7 +10,7 @@ import io from loguru import logger from nonebot import get_driver -from ...common.database import Database +from ...common.database import db from ..chat.config import global_config from ..models.utils_model import LLM_request driver = get_driver() @@ -23,13 +23,11 @@ class ImageManager: def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) - cls._instance.db = None cls._instance._initialized = False return cls._instance def __init__(self): if not self._initialized: - self.db = Database.get_instance() self._ensure_image_collection() self._ensure_description_collection() self._ensure_image_dir() @@ -42,20 +40,20 @@ class ImageManager: def _ensure_image_collection(self): """确保images集合存在并创建索引""" - if 'images' not in self.db.list_collection_names(): - self.db.create_collection('images') + if 'images' not in db.list_collection_names(): + db.create_collection('images') # 创建索引 - self.db.images.create_index([('hash', 1)], unique=True) - self.db.images.create_index([('url', 1)]) - self.db.images.create_index([('path', 1)]) + db.images.create_index([('hash', 1)], unique=True) + db.images.create_index([('url', 1)]) + db.images.create_index([('path', 1)]) def _ensure_description_collection(self): """确保image_descriptions集合存在并创建索引""" - if 'image_descriptions' not in self.db.list_collection_names(): - self.db.create_collection('image_descriptions') + if 'image_descriptions' not in db.list_collection_names(): + db.create_collection('image_descriptions') # 创建索引 - self.db.image_descriptions.create_index([('hash', 1)], unique=True) - self.db.image_descriptions.create_index([('type', 1)]) + db.image_descriptions.create_index([('hash', 1)], unique=True) + db.image_descriptions.create_index([('type', 1)]) def _get_description_from_db(self, image_hash: str, description_type: str) -> Optional[str]: """从数据库获取图片描述 @@ -67,7 +65,7 @@ class ImageManager: Returns: Optional[str]: 描述文本,如果不存在则返回None """ - result= self.db.image_descriptions.find_one({ + result= db.image_descriptions.find_one({ 'hash': image_hash, 'type': description_type }) @@ -81,7 +79,7 @@ class ImageManager: description: 描述文本 description_type: 描述类型 ('emoji' 或 'image') """ - self.db.image_descriptions.update_one( + db.image_descriptions.update_one( {'hash': image_hash, 'type': description_type}, { '$set': { @@ -124,7 +122,7 @@ class ImageManager: image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 查重 - existing = self.db.images.find_one({'hash': image_hash}) + existing = db.images.find_one({'hash': image_hash}) if existing: return existing['path'] @@ -145,7 +143,7 @@ class ImageManager: 'description': description, 'timestamp': timestamp } - self.db.images.insert_one(image_doc) + db.images.insert_one(image_doc) return file_path @@ -162,7 +160,7 @@ class ImageManager: """ try: # 先查找是否已存在 - existing = self.db.images.find_one({'url': url}) + existing = db.images.find_one({'url': url}) if existing: return existing['path'] @@ -206,7 +204,7 @@ class ImageManager: Returns: bool: 是否存在 """ - return self.db.images.find_one({'url': url}) is not None + return db.images.find_one({'url': url}) is not None def check_hash_exists(self, image_data: Union[str, bytes], is_base64: bool = False) -> bool: """检查图像是否已存在 @@ -229,7 +227,7 @@ class ImageManager: return False image_hash = hashlib.md5(image_bytes).hexdigest() - return self.db.images.find_one({'hash': image_hash}) is not None + return db.images.find_one({'hash': image_hash}) is not None except Exception as e: logger.error(f"检查哈希失败: {str(e)}") @@ -273,7 +271,7 @@ class ImageManager: 'description': description, 'timestamp': timestamp } - self.db.images.update_one( + db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True @@ -335,7 +333,7 @@ class ImageManager: 'description': description, 'timestamp': timestamp } - self.db.images.update_one( + db.images.update_one( {'hash': image_hash}, {'$set': image_doc}, upsert=True diff --git a/src/plugins/memory_system/draw_memory.py b/src/plugins/memory_system/draw_memory.py index d6ba8f3b2..df699f459 100644 --- a/src/plugins/memory_system/draw_memory.py +++ b/src/plugins/memory_system/draw_memory.py @@ -13,7 +13,7 @@ from loguru import logger root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) sys.path.append(root_path) -from src.common.database import Database # 使用正确的导入语法 +from src.common.database import db # 使用正确的导入语法 # 加载.env.dev文件 env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), '.env.dev') @@ -23,7 +23,6 @@ load_dotenv(env_path) class Memory_graph: def __init__(self): self.G = nx.Graph() # 使用 networkx 的图结构 - self.db = Database.get_instance() def connect_dot(self, concept1, concept2): self.G.add_edge(concept1, concept2) @@ -96,7 +95,7 @@ class Memory_graph: dot_data = { "concept": node } - self.db.store_memory_dots.insert_one(dot_data) + db.store_memory_dots.insert_one(dot_data) @property def dots(self): @@ -106,7 +105,7 @@ class Memory_graph: def get_random_chat_from_db(self, length: int, timestamp: str): # 从数据库中根据时间戳获取离其最近的聊天记录 chat_text = '' - closest_record = self.db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) # 调试输出 + closest_record = db.messages.find_one({"time": {"$lte": timestamp}}, sort=[('time', -1)]) # 调试输出 logger.info( f"距离time最近的消息时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(closest_record['time'])))}") @@ -115,7 +114,7 @@ class Memory_graph: group_id = closest_record['group_id'] # 获取groupid # 获取该时间戳之后的length条消息,且groupid相同 chat_record = list( - self.db.messages.find({"time": {"$gt": closest_time}, "group_id": group_id}).sort('time', 1).limit( + db.messages.find({"time": {"$gt": closest_time}, "group_id": group_id}).sort('time', 1).limit( length)) for record in chat_record: time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(record['time']))) @@ -130,50 +129,39 @@ class Memory_graph: def save_graph_to_db(self): # 清空现有的图数据 - self.db.graph_data.delete_many({}) + db.graph_data.delete_many({}) # 保存节点 for node in self.G.nodes(data=True): node_data = { 'concept': node[0], 'memory_items': node[1].get('memory_items', []) # 默认为空列表 } - self.db.graph_data.nodes.insert_one(node_data) + db.graph_data.nodes.insert_one(node_data) # 保存边 for edge in self.G.edges(): edge_data = { 'source': edge[0], 'target': edge[1] } - self.db.graph_data.edges.insert_one(edge_data) + db.graph_data.edges.insert_one(edge_data) def load_graph_from_db(self): # 清空当前图 self.G.clear() # 加载节点 - nodes = self.db.graph_data.nodes.find() + nodes = db.graph_data.nodes.find() for node in nodes: memory_items = node.get('memory_items', []) if not isinstance(memory_items, list): memory_items = [memory_items] if memory_items else [] self.G.add_node(node['concept'], memory_items=memory_items) # 加载边 - edges = self.db.graph_data.edges.find() + edges = db.graph_data.edges.find() for edge in edges: self.G.add_edge(edge['source'], edge['target']) def main(): - # 初始化数据库 - Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), - ) - memory_graph = Memory_graph() memory_graph.load_graph_from_db() diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index d9e867e63..4a8249504 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -10,7 +10,7 @@ import networkx as nx from loguru import logger from nonebot import get_driver -from ...common.database import Database # 使用正确的导入语法 +from ...common.database import db # 使用正确的导入语法 from ..chat.config import global_config from ..chat.utils import ( calculate_information_content, @@ -23,7 +23,6 @@ from ..models.utils_model import LLM_request class Memory_graph: def __init__(self): self.G = nx.Graph() # 使用 networkx 的图结构 - self.db = Database.get_instance() def connect_dot(self, concept1, concept2): # 避免自连接 @@ -191,19 +190,19 @@ class Hippocampus: # 短期:1h 中期:4h 长期:24h for _ in range(time_frequency.get('near')): random_time = current_timestamp - random.randint(1, 3600) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('mid')): random_time = current_timestamp - random.randint(3600, 3600 * 4) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('far')): random_time = current_timestamp - random.randint(3600 * 4, 3600 * 24) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) @@ -349,7 +348,7 @@ class Hippocampus: def sync_memory_to_db(self): """检查并同步内存中的图结构与数据库""" # 获取数据库中所有节点和内存中所有节点 - db_nodes = list(self.memory_graph.db.graph_data.nodes.find()) + db_nodes = list(db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) # 转换数据库节点为字典格式,方便查找 @@ -377,7 +376,7 @@ class Hippocampus: 'created_time': created_time, 'last_modified': last_modified } - self.memory_graph.db.graph_data.nodes.insert_one(node_data) + db.graph_data.nodes.insert_one(node_data) else: # 获取数据库中节点的特征值 db_node = db_nodes_dict[concept] @@ -385,7 +384,7 @@ class Hippocampus: # 如果特征值不同,则更新节点 if db_hash != memory_hash: - self.memory_graph.db.graph_data.nodes.update_one( + db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'memory_items': memory_items, @@ -396,7 +395,7 @@ class Hippocampus: ) # 处理边的信息 - db_edges = list(self.memory_graph.db.graph_data.edges.find()) + db_edges = list(db.graph_data.edges.find()) memory_edges = list(self.memory_graph.G.edges(data=True)) # 创建边的哈希值字典 @@ -428,11 +427,11 @@ class Hippocampus: 'created_time': created_time, 'last_modified': last_modified } - self.memory_graph.db.graph_data.edges.insert_one(edge_data) + db.graph_data.edges.insert_one(edge_data) else: # 检查边的特征值是否变化 if db_edge_dict[edge_key]['hash'] != edge_hash: - self.memory_graph.db.graph_data.edges.update_one( + db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': { 'hash': edge_hash, @@ -451,7 +450,7 @@ class Hippocampus: self.memory_graph.G.clear() # 从数据库加载所有节点 - nodes = list(self.memory_graph.db.graph_data.nodes.find()) + nodes = list(db.graph_data.nodes.find()) for node in nodes: concept = node['concept'] memory_items = node.get('memory_items', []) @@ -468,7 +467,7 @@ class Hippocampus: if 'last_modified' not in node: update_data['last_modified'] = current_time - self.memory_graph.db.graph_data.nodes.update_one( + db.graph_data.nodes.update_one( {'concept': concept}, {'$set': update_data} ) @@ -485,7 +484,7 @@ class Hippocampus: last_modified=last_modified) # 从数据库加载所有边 - edges = list(self.memory_graph.db.graph_data.edges.find()) + edges = list(db.graph_data.edges.find()) for edge in edges: source = edge['source'] target = edge['target'] @@ -501,7 +500,7 @@ class Hippocampus: if 'last_modified' not in edge: update_data['last_modified'] = current_time - self.memory_graph.db.graph_data.edges.update_one( + db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': update_data} ) diff --git a/src/plugins/memory_system/memory_manual_build.py b/src/plugins/memory_system/memory_manual_build.py index adf972a06..e0d64b55f 100644 --- a/src/plugins/memory_system/memory_manual_build.py +++ b/src/plugins/memory_system/memory_manual_build.py @@ -19,7 +19,7 @@ import jieba root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) sys.path.append(root_path) -from src.common.database import Database +from src.common.database import db from src.plugins.memory_system.offline_llm import LLMModel # 获取当前文件的目录 @@ -49,7 +49,7 @@ def calculate_information_content(text): return entropy -def get_cloest_chat_from_db(db, length: int, timestamp: str): +def get_cloest_chat_from_db(length: int, timestamp: str): """从数据库中获取最接近指定时间戳的聊天记录,并记录读取次数 Returns: @@ -91,7 +91,6 @@ def get_cloest_chat_from_db(db, length: int, timestamp: str): class Memory_graph: def __init__(self): self.G = nx.Graph() # 使用 networkx 的图结构 - self.db = Database.get_instance() def connect_dot(self, concept1, concept2): # 如果边已存在,增加 strength @@ -186,19 +185,19 @@ class Hippocampus: # 短期:1h 中期:4h 长期:24h for _ in range(time_frequency.get('near')): random_time = current_timestamp - random.randint(1, 3600*4) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('mid')): random_time = current_timestamp - random.randint(3600*4, 3600*24) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('far')): random_time = current_timestamp - random.randint(3600*24, 3600*24*7) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) @@ -323,7 +322,7 @@ class Hippocampus: self.memory_graph.G.clear() # 从数据库加载所有节点 - nodes = self.memory_graph.db.graph_data.nodes.find() + nodes = db.graph_data.nodes.find() for node in nodes: concept = node['concept'] memory_items = node.get('memory_items', []) @@ -334,7 +333,7 @@ class Hippocampus: self.memory_graph.G.add_node(concept, memory_items=memory_items) # 从数据库加载所有边 - edges = self.memory_graph.db.graph_data.edges.find() + edges = db.graph_data.edges.find() for edge in edges: source = edge['source'] target = edge['target'] @@ -371,7 +370,7 @@ class Hippocampus: 使用特征值(哈希值)快速判断是否需要更新 """ # 获取数据库中所有节点和内存中所有节点 - db_nodes = list(self.memory_graph.db.graph_data.nodes.find()) + db_nodes = list(db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) # 转换数据库节点为字典格式,方便查找 @@ -394,7 +393,7 @@ class Hippocampus: 'memory_items': memory_items, 'hash': memory_hash } - self.memory_graph.db.graph_data.nodes.insert_one(node_data) + db.graph_data.nodes.insert_one(node_data) else: # 获取数据库中节点的特征值 db_node = db_nodes_dict[concept] @@ -403,7 +402,7 @@ class Hippocampus: # 如果特征值不同,则更新节点 if db_hash != memory_hash: # logger.info(f"更新节点内容: {concept}") - self.memory_graph.db.graph_data.nodes.update_one( + db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'memory_items': memory_items, @@ -416,10 +415,10 @@ class Hippocampus: for db_node in db_nodes: if db_node['concept'] not in memory_concepts: # logger.info(f"删除多余节点: {db_node['concept']}") - self.memory_graph.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) + db.graph_data.nodes.delete_one({'concept': db_node['concept']}) # 处理边的信息 - db_edges = list(self.memory_graph.db.graph_data.edges.find()) + db_edges = list(db.graph_data.edges.find()) memory_edges = list(self.memory_graph.G.edges()) # 创建边的哈希值字典 @@ -445,12 +444,12 @@ class Hippocampus: 'num': 1, 'hash': edge_hash } - self.memory_graph.db.graph_data.edges.insert_one(edge_data) + db.graph_data.edges.insert_one(edge_data) else: # 检查边的特征值是否变化 if db_edge_dict[edge_key]['hash'] != edge_hash: logger.info(f"更新边: {source} - {target}") - self.memory_graph.db.graph_data.edges.update_one( + db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': {'hash': edge_hash}} ) @@ -461,7 +460,7 @@ class Hippocampus: if edge_key not in memory_edge_set: source, target = edge_key logger.info(f"删除多余边: {source} - {target}") - self.memory_graph.db.graph_data.edges.delete_one({ + db.graph_data.edges.delete_one({ 'source': source, 'target': target }) @@ -487,9 +486,9 @@ class Hippocampus: topic: 要删除的节点概念 """ # 删除节点 - self.memory_graph.db.graph_data.nodes.delete_one({'concept': topic}) + db.graph_data.nodes.delete_one({'concept': topic}) # 删除所有涉及该节点的边 - self.memory_graph.db.graph_data.edges.delete_many({ + db.graph_data.edges.delete_many({ '$or': [ {'source': topic}, {'target': topic} @@ -902,17 +901,6 @@ def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = Fal plt.show() async def main(): - # 初始化数据库 - logger.info("正在初始化数据库连接...") - Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), - ) start_time = time.time() test_pare = {'do_build_memory':False,'do_forget_topic':False,'do_visualize_graph':True,'do_query':False,'do_merge_memory':False} diff --git a/src/plugins/memory_system/memory_test1.py b/src/plugins/memory_system/memory_test1.py index f86c8ea3d..2253d0032 100644 --- a/src/plugins/memory_system/memory_test1.py +++ b/src/plugins/memory_system/memory_test1.py @@ -38,7 +38,7 @@ import jieba # from chat.config import global_config sys.path.append("C:/GitHub/MaiMBot") # 添加项目根目录到 Python 路径 -from src.common.database import Database +from src.common.database import db from src.plugins.memory_system.offline_llm import LLMModel # 获取当前文件的目录 @@ -56,45 +56,6 @@ else: logger.warning(f"未找到环境变量文件: {env_path}") logger.info("将使用默认配置") -class Database: - _instance = None - db = None - - @classmethod - def get_instance(cls): - if cls._instance is None: - cls._instance = cls() - return cls._instance - - def __init__(self): - if not Database.db: - Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), - ) - - @classmethod - def initialize(cls, host, port, db_name, username=None, password=None, auth_source="admin"): - try: - if username and password: - uri = f"mongodb://{username}:{password}@{host}:{port}/{db_name}?authSource={auth_source}" - else: - uri = f"mongodb://{host}:{port}" - - client = pymongo.MongoClient(uri) - cls.db = client[db_name] - # 测试连接 - client.server_info() - logger.success("MongoDB连接成功!") - - except Exception as e: - logger.error(f"初始化MongoDB失败: {str(e)}") - raise def calculate_information_content(text): """计算文本的信息量(熵)""" @@ -108,7 +69,7 @@ def calculate_information_content(text): return entropy -def get_cloest_chat_from_db(db, length: int, timestamp: str): +def get_cloest_chat_from_db(length: int, timestamp: str): """从数据库中获取最接近指定时间戳的聊天记录,并记录读取次数 Returns: @@ -163,7 +124,7 @@ class Memory_cortex: default_time = datetime.datetime.now().timestamp() # 从数据库加载所有节点 - nodes = self.memory_graph.db.graph_data.nodes.find() + nodes = db.graph_data.nodes.find() for node in nodes: concept = node['concept'] memory_items = node.get('memory_items', []) @@ -180,7 +141,7 @@ class Memory_cortex: created_time = default_time last_modified = default_time # 更新数据库中的节点 - self.memory_graph.db.graph_data.nodes.update_one( + db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'created_time': created_time, @@ -196,7 +157,7 @@ class Memory_cortex: last_modified=last_modified) # 从数据库加载所有边 - edges = self.memory_graph.db.graph_data.edges.find() + edges = db.graph_data.edges.find() for edge in edges: source = edge['source'] target = edge['target'] @@ -212,7 +173,7 @@ class Memory_cortex: created_time = default_time last_modified = default_time # 更新数据库中的边 - self.memory_graph.db.graph_data.edges.update_one( + db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': { 'created_time': created_time, @@ -256,7 +217,7 @@ class Memory_cortex: current_time = datetime.datetime.now().timestamp() # 获取数据库中所有节点和内存中所有节点 - db_nodes = list(self.memory_graph.db.graph_data.nodes.find()) + db_nodes = list(db.graph_data.nodes.find()) memory_nodes = list(self.memory_graph.G.nodes(data=True)) # 转换数据库节点为字典格式,方便查找 @@ -280,7 +241,7 @@ class Memory_cortex: 'created_time': data.get('created_time', current_time), 'last_modified': data.get('last_modified', current_time) } - self.memory_graph.db.graph_data.nodes.insert_one(node_data) + db.graph_data.nodes.insert_one(node_data) else: # 获取数据库中节点的特征值 db_node = db_nodes_dict[concept] @@ -288,7 +249,7 @@ class Memory_cortex: # 如果特征值不同,则更新节点 if db_hash != memory_hash: - self.memory_graph.db.graph_data.nodes.update_one( + db.graph_data.nodes.update_one( {'concept': concept}, {'$set': { 'memory_items': memory_items, @@ -301,10 +262,10 @@ class Memory_cortex: memory_concepts = set(node[0] for node in memory_nodes) for db_node in db_nodes: if db_node['concept'] not in memory_concepts: - self.memory_graph.db.graph_data.nodes.delete_one({'concept': db_node['concept']}) + db.graph_data.nodes.delete_one({'concept': db_node['concept']}) # 处理边的信息 - db_edges = list(self.memory_graph.db.graph_data.edges.find()) + db_edges = list(db.graph_data.edges.find()) memory_edges = list(self.memory_graph.G.edges(data=True)) # 创建边的哈希值字典 @@ -332,11 +293,11 @@ class Memory_cortex: 'created_time': data.get('created_time', current_time), 'last_modified': data.get('last_modified', current_time) } - self.memory_graph.db.graph_data.edges.insert_one(edge_data) + db.graph_data.edges.insert_one(edge_data) else: # 检查边的特征值是否变化 if db_edge_dict[edge_key]['hash'] != edge_hash: - self.memory_graph.db.graph_data.edges.update_one( + db.graph_data.edges.update_one( {'source': source, 'target': target}, {'$set': { 'hash': edge_hash, @@ -350,7 +311,7 @@ class Memory_cortex: for edge_key in db_edge_dict: if edge_key not in memory_edge_set: source, target = edge_key - self.memory_graph.db.graph_data.edges.delete_one({ + db.graph_data.edges.delete_one({ 'source': source, 'target': target }) @@ -365,9 +326,9 @@ class Memory_cortex: topic: 要删除的节点概念 """ # 删除节点 - self.memory_graph.db.graph_data.nodes.delete_one({'concept': topic}) + db.graph_data.nodes.delete_one({'concept': topic}) # 删除所有涉及该节点的边 - self.memory_graph.db.graph_data.edges.delete_many({ + db.graph_data.edges.delete_many({ '$or': [ {'source': topic}, {'target': topic} @@ -377,7 +338,6 @@ class Memory_cortex: class Memory_graph: def __init__(self): self.G = nx.Graph() # 使用 networkx 的图结构 - self.db = Database.get_instance() def connect_dot(self, concept1, concept2): # 避免自连接 @@ -492,19 +452,19 @@ class Hippocampus: # 短期:1h 中期:4h 长期:24h for _ in range(time_frequency.get('near')): random_time = current_timestamp - random.randint(1, 3600*4) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('mid')): random_time = current_timestamp - random.randint(3600*4, 3600*24) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('far')): random_time = current_timestamp - random.randint(3600*24, 3600*24*7) - messages = get_cloest_chat_from_db(db=self.memory_graph.db, length=chat_size, timestamp=random_time) + messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) @@ -1134,7 +1094,6 @@ def visualize_graph_lite(memory_graph: Memory_graph, color_by_memory: bool = Fal async def main(): # 初始化数据库 logger.info("正在初始化数据库连接...") - db = Database.get_instance() start_time = time.time() test_pare = {'do_build_memory':True,'do_forget_topic':False,'do_visualize_graph':True,'do_query':False,'do_merge_memory':False} diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index aa07bb55d..afe4baeb5 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -10,7 +10,7 @@ from nonebot import get_driver import base64 from PIL import Image import io -from ...common.database import Database +from ...common.database import db from ..chat.config import global_config driver = get_driver() @@ -34,17 +34,16 @@ class LLM_request: self.pri_out = model.get("pri_out", 0) # 获取数据库实例 - self.db = Database.get_instance() self._init_database() def _init_database(self): """初始化数据库集合""" try: # 创建llm_usage集合的索引 - self.db.llm_usage.create_index([("timestamp", 1)]) - self.db.llm_usage.create_index([("model_name", 1)]) - self.db.llm_usage.create_index([("user_id", 1)]) - self.db.llm_usage.create_index([("request_type", 1)]) + db.llm_usage.create_index([("timestamp", 1)]) + db.llm_usage.create_index([("model_name", 1)]) + db.llm_usage.create_index([("user_id", 1)]) + db.llm_usage.create_index([("request_type", 1)]) except Exception: logger.error("创建数据库索引失败") @@ -73,7 +72,7 @@ class LLM_request: "status": "success", "timestamp": datetime.now() } - self.db.llm_usage.insert_one(usage_data) + db.llm_usage.insert_one(usage_data) logger.info( f"Token使用情况 - 模型: {self.model_name}, " f"用户: {user_id}, 类型: {request_type}, " diff --git a/src/plugins/schedule/schedule_generator.py b/src/plugins/schedule/schedule_generator.py index bde593890..5f62d6aca 100644 --- a/src/plugins/schedule/schedule_generator.py +++ b/src/plugins/schedule/schedule_generator.py @@ -8,7 +8,7 @@ from nonebot import get_driver from src.plugins.chat.config import global_config -from ...common.database import Database # 使用正确的导入语法 +from ...common.database import db # 使用正确的导入语法 from ..models.utils_model import LLM_request driver = get_driver() @@ -19,7 +19,6 @@ class ScheduleGenerator: # 根据global_config.llm_normal这一字典配置指定模型 # self.llm_scheduler = LLMModel(model = global_config.llm_normal,temperature=0.9) self.llm_scheduler = LLM_request(model=global_config.llm_normal, temperature=0.9) - self.db = Database.get_instance() self.today_schedule_text = "" self.today_schedule = {} self.tomorrow_schedule_text = "" @@ -46,7 +45,7 @@ class ScheduleGenerator: schedule_text = str - existing_schedule = self.db.schedule.find_one({"date": date_str}) + existing_schedule = db.schedule.find_one({"date": date_str}) if existing_schedule: logger.debug(f"{date_str}的日程已存在:") schedule_text = existing_schedule["schedule"] @@ -63,7 +62,7 @@ class ScheduleGenerator: try: schedule_text, _ = await self.llm_scheduler.generate_response(prompt) - self.db.schedule.insert_one({"date": date_str, "schedule": schedule_text}) + db.schedule.insert_one({"date": date_str, "schedule": schedule_text}) except Exception as e: logger.error(f"生成日程失败: {str(e)}") schedule_text = "生成日程时出错了" @@ -143,7 +142,7 @@ class ScheduleGenerator: """打印完整的日程安排""" if not self._parse_schedule(self.today_schedule_text): logger.warning("今日日程有误,将在下次运行时重新生成") - self.db.schedule.delete_one({"date": datetime.datetime.now().strftime("%Y-%m-%d")}) + db.schedule.delete_one({"date": datetime.datetime.now().strftime("%Y-%m-%d")}) else: logger.info("=== 今日日程安排 ===") for time_str, activity in self.today_schedule.items(): diff --git a/src/plugins/utils/statistic.py b/src/plugins/utils/statistic.py index 4629f0e0b..e812bce4b 100644 --- a/src/plugins/utils/statistic.py +++ b/src/plugins/utils/statistic.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from typing import Any, Dict from loguru import logger -from ...common.database import Database +from ...common.database import db class LLMStatistics: @@ -15,7 +15,6 @@ class LLMStatistics: Args: output_file: 统计结果输出文件路径 """ - self.db = Database.get_instance() self.output_file = output_file self.running = False self.stats_thread = None @@ -53,7 +52,7 @@ class LLMStatistics: "costs_by_model": defaultdict(float) } - cursor = self.db.llm_usage.find({ + cursor = db.llm_usage.find({ "timestamp": {"$gte": start_time} }) diff --git a/src/plugins/zhishi/knowledge_library.py b/src/plugins/zhishi/knowledge_library.py index ad309814b..a049394fe 100644 --- a/src/plugins/zhishi/knowledge_library.py +++ b/src/plugins/zhishi/knowledge_library.py @@ -14,7 +14,7 @@ root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) sys.path.append(root_path) # 现在可以导入src模块 -from src.common.database import Database +from src.common.database import db # 加载根目录下的env.edv文件 env_path = os.path.join(root_path, ".env.prod") @@ -24,18 +24,6 @@ load_dotenv(env_path) class KnowledgeLibrary: def __init__(self): - # 初始化数据库连接 - if Database._instance is None: - Database.initialize( - uri=os.getenv("MONGODB_URI"), - host=os.getenv("MONGODB_HOST", "127.0.0.1"), - port=int(os.getenv("MONGODB_PORT", "27017")), - db_name=os.getenv("DATABASE_NAME", "MegBot"), - username=os.getenv("MONGODB_USERNAME"), - password=os.getenv("MONGODB_PASSWORD"), - auth_source=os.getenv("MONGODB_AUTH_SOURCE"), - ) - self.db = Database.get_instance() self.raw_info_dir = "data/raw_info" self._ensure_dirs() self.api_key = os.getenv("SILICONFLOW_KEY") @@ -176,7 +164,7 @@ class KnowledgeLibrary: try: current_hash = self.calculate_file_hash(file_path) - processed_record = self.db.processed_files.find_one({"file_path": file_path}) + processed_record = db.processed_files.find_one({"file_path": file_path}) if processed_record: if processed_record.get("hash") == current_hash: @@ -197,14 +185,14 @@ class KnowledgeLibrary: "split_length": knowledge_length, "created_at": datetime.now() } - self.db.knowledges.insert_one(knowledge) + db.knowledges.insert_one(knowledge) result["chunks_processed"] += 1 split_by = processed_record.get("split_by", []) if processed_record else [] if knowledge_length not in split_by: split_by.append(knowledge_length) - self.db.knowledges.processed_files.update_one( + db.knowledges.processed_files.update_one( {"file_path": file_path}, { "$set": { @@ -322,7 +310,7 @@ class KnowledgeLibrary: {"$project": {"content": 1, "similarity": 1, "file_path": 1}} ] - results = list(self.db.knowledges.aggregate(pipeline)) + results = list(db.knowledges.aggregate(pipeline)) return results # 创建单例实例 @@ -346,7 +334,7 @@ if __name__ == "__main__": elif choice == '2': confirm = input("确定要删除所有知识吗?这个操作不可撤销!(y/n): ").strip().lower() if confirm == 'y': - knowledge_library.db.knowledges.delete_many({}) + db.knowledges.delete_many({}) console.print("[green]已清空所有知识![/green]") continue elif choice == '1': From 588aecd0f39ea14254a6a5a1e436675318719b4b Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Wed, 12 Mar 2025 21:30:38 +0800 Subject: [PATCH 126/162] =?UTF-8?q?fix:=20=E4=B8=BA=E6=B2=A1=E6=9C=89hash?= =?UTF-8?q?=E7=9A=84=E8=A1=A8=E6=83=85=E5=8C=85=E6=B7=BB=E5=8A=A0hash?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8Dset=20reply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 172 ++++++++++++------------- src/plugins/chat/message.py | 83 +++++------- src/plugins/chat/utils_image.py | 206 ++++++++++++++---------------- 3 files changed, 208 insertions(+), 253 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 1c8a07699..a30940ec7 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -38,9 +38,9 @@ class EmojiManager: self.db = Database.get_instance() self._scan_task = None self.vlm = LLM_request(model=global_config.vlm, temperature=0.3, max_tokens=1000) - self.llm_emotion_judge = LLM_request(model=global_config.llm_emotion_judge, max_tokens=60, - temperature=0.8) # 更高的温度,更少的token(后续可以根据情绪来调整温度) - + self.llm_emotion_judge = LLM_request( + model=global_config.llm_emotion_judge, max_tokens=60, temperature=0.8 + ) # 更高的温度,更少的token(后续可以根据情绪来调整温度) def _ensure_emoji_dir(self): """确保表情存储目录存在""" @@ -68,42 +68,39 @@ class EmojiManager: def _ensure_emoji_collection(self): """确保emoji集合存在并创建索引 - + 这个函数用于确保MongoDB数据库中存在emoji集合,并创建必要的索引。 - + 索引的作用是加快数据库查询速度: - embedding字段的2dsphere索引: 用于加速向量相似度搜索,帮助快速找到相似的表情包 - tags字段的普通索引: 加快按标签搜索表情包的速度 - filename字段的唯一索引: 确保文件名不重复,同时加快按文件名查找的速度 - + 没有索引的话,数据库每次查询都需要扫描全部数据,建立索引后可以大大提高查询效率。 """ - if 'emoji' not in self.db.list_collection_names(): - self.db.create_collection('emoji') - self.db.emoji.create_index([('embedding', '2dsphere')]) - self.db.emoji.create_index([('filename', 1)], unique=True) + if "emoji" not in self.db.list_collection_names(): + self.db.create_collection("emoji") + self.db.emoji.create_index([("embedding", "2dsphere")]) + self.db.emoji.create_index([("filename", 1)], unique=True) def record_usage(self, emoji_id: str): """记录表情使用次数""" try: self._ensure_db() - self.db.emoji.update_one( - {'_id': emoji_id}, - {'$inc': {'usage_count': 1}} - ) + self.db.emoji.update_one({"_id": emoji_id}, {"$inc": {"usage_count": 1}}) except Exception as e: logger.error(f"记录表情使用失败: {str(e)}") - - async def get_emoji_for_text(self, text: str) -> Optional[Tuple[str,str]]: + + async def get_emoji_for_text(self, text: str) -> Optional[Tuple[str, str]]: """根据文本内容获取相关表情包 Args: text: 输入文本 Returns: Optional[str]: 表情包文件路径,如果没有找到则返回None - - + + 可不可以通过 配置文件中的指令 来自定义使用表情包的逻辑? - 我觉得可行 + 我觉得可行 """ try: @@ -121,7 +118,7 @@ class EmojiManager: try: # 获取所有表情包 - all_emojis = list(self.db.emoji.find({}, {'_id': 1, 'path': 1, 'embedding': 1, 'description': 1})) + all_emojis = list(self.db.emoji.find({}, {"_id": 1, "path": 1, "embedding": 1, "description": 1})) if not all_emojis: logger.warning("数据库中没有任何表情包") @@ -140,34 +137,31 @@ class EmojiManager: # 计算所有表情包与输入文本的相似度 emoji_similarities = [ - (emoji, cosine_similarity(text_embedding, emoji.get('embedding', []))) - for emoji in all_emojis + (emoji, cosine_similarity(text_embedding, emoji.get("embedding", []))) for emoji in all_emojis ] # 按相似度降序排序 emoji_similarities.sort(key=lambda x: x[1], reverse=True) # 获取前3个最相似的表情包 - top_10_emojis = emoji_similarities[:10 if len(emoji_similarities) > 10 else len(emoji_similarities)] - + top_10_emojis = emoji_similarities[: 10 if len(emoji_similarities) > 10 else len(emoji_similarities)] + if not top_10_emojis: logger.warning("未找到匹配的表情包") return None # 从前3个中随机选择一个 selected_emoji, similarity = random.choice(top_10_emojis) - - if selected_emoji and 'path' in selected_emoji: + + if selected_emoji and "path" in selected_emoji: # 更新使用次数 - self.db.emoji.update_one( - {'_id': selected_emoji['_id']}, - {'$inc': {'usage_count': 1}} - ) + self.db.emoji.update_one({"_id": selected_emoji["_id"]}, {"$inc": {"usage_count": 1}}) logger.success( - f"找到匹配的表情包: {selected_emoji.get('description', '无描述')} (相似度: {similarity:.4f})") + f"找到匹配的表情包: {selected_emoji.get('description', '无描述')} (相似度: {similarity:.4f})" + ) # 稍微改一下文本描述,不然容易产生幻觉,描述已经包含 表情包 了 - return selected_emoji['path'], "[ %s ]" % selected_emoji.get('description', '无描述') + return selected_emoji["path"], "[ %s ]" % selected_emoji.get("description", "无描述") except Exception as search_error: logger.error(f"搜索表情包失败: {str(search_error)}") @@ -179,7 +173,6 @@ class EmojiManager: logger.error(f"获取表情包失败: {str(e)}") return None - async def _get_emoji_discription(self, image_base64: str) -> str: """获取表情包的标签,使用image_manager的描述生成功能""" @@ -187,16 +180,16 @@ class EmojiManager: # 使用image_manager获取描述,去掉前后的方括号和"表情包:"前缀 description = await image_manager.get_emoji_description(image_base64) # 去掉[表情包:xxx]的格式,只保留描述内容 - description = description.strip('[]').replace('表情包:', '') + description = description.strip("[]").replace("表情包:", "") return description - + except Exception as e: logger.error(f"获取标签失败: {str(e)}") return None async def _check_emoji(self, image_base64: str, image_format: str) -> str: try: - prompt = f'这是一个表情包,请回答这个表情包是否满足\"{global_config.EMOJI_CHECK_PROMPT}\"的要求,是则回答是,否则回答否,不要出现任何其他内容' + prompt = f'这是一个表情包,请回答这个表情包是否满足"{global_config.EMOJI_CHECK_PROMPT}"的要求,是则回答是,否则回答否,不要出现任何其他内容' content, _ = await self.vlm.generate_response_for_image(prompt, image_base64, image_format) logger.debug(f"输出描述: {content}") @@ -208,9 +201,9 @@ class EmojiManager: async def _get_kimoji_for_text(self, text: str): try: - prompt = f'这是{global_config.BOT_NICKNAME}将要发送的消息内容:\n{text}\n若要为其配上表情包,请你输出这个表情包应该表达怎样的情感,应该给人什么样的感觉,不要太简洁也不要太长,注意不要输出任何对消息内容的分析内容,只输出\"一种什么样的感觉\"中间的形容词部分。' + prompt = f'这是{global_config.BOT_NICKNAME}将要发送的消息内容:\n{text}\n若要为其配上表情包,请你输出这个表情包应该表达怎样的情感,应该给人什么样的感觉,不要太简洁也不要太长,注意不要输出任何对消息内容的分析内容,只输出"一种什么样的感觉"中间的形容词部分。' - content, _ = await self.llm_emotion_judge.generate_response_async(prompt,temperature=1.5) + content, _ = await self.llm_emotion_judge.generate_response_async(prompt, temperature=1.5) logger.info(f"输出描述: {content}") return content @@ -225,63 +218,58 @@ class EmojiManager: os.makedirs(emoji_dir, exist_ok=True) # 获取所有支持的图片文件 - files_to_process = [f for f in os.listdir(emoji_dir) if - f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif'))] + files_to_process = [ + f for f in os.listdir(emoji_dir) if f.lower().endswith((".jpg", ".jpeg", ".png", ".gif")) + ] for filename in files_to_process: image_path = os.path.join(emoji_dir, filename) - + # 获取图片的base64编码和哈希值 image_base64 = image_path_to_base64(image_path) if image_base64 is None: os.remove(image_path) continue - + image_bytes = base64.b64decode(image_base64) image_hash = hashlib.md5(image_bytes).hexdigest() image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 检查是否已经注册过 - existing_emoji = self.db['emoji'].find_one({'filename': filename}) + existing_emoji = self.db["emoji"].find_one({"filename": filename}) description = None - + if existing_emoji: # 即使表情包已存在,也检查是否需要同步到images集合 - description = existing_emoji.get('discription') + description = existing_emoji.get("discription") # 检查是否在images集合中存在 - existing_image = image_manager.db.images.find_one({'hash': image_hash}) + existing_image = image_manager.db.images.find_one({"hash": image_hash}) if not existing_image: # 同步到images集合 image_doc = { - 'hash': image_hash, - 'path': image_path, - 'type': 'emoji', - 'description': description, - 'timestamp': int(time.time()) + "hash": image_hash, + "path": image_path, + "type": "emoji", + "description": description, + "timestamp": int(time.time()), } - image_manager.db.images.update_one( - {'hash': image_hash}, - {'$set': image_doc}, - upsert=True - ) + image_manager.db.images.update_one({"hash": image_hash}, {"$set": image_doc}, upsert=True) # 保存描述到image_descriptions集合 - image_manager._save_description_to_db(image_hash, description, 'emoji') + image_manager._save_description_to_db(image_hash, description, "emoji") logger.success(f"同步已存在的表情包到images集合: {filename}") continue - + # 检查是否在images集合中已有描述 - existing_description = image_manager._get_description_from_db(image_hash, 'emoji') - + existing_description = image_manager._get_description_from_db(image_hash, "emoji") + if existing_description: description = existing_description else: # 获取表情包的描述 description = await self._get_emoji_discription(image_base64) - - if global_config.EMOJI_CHECK: check = await self._check_emoji(image_base64, image_format) - if '是' not in check: + if "是" not in check: os.remove(image_path) logger.info(f"描述: {description}") @@ -289,44 +277,39 @@ class EmojiManager: logger.info(f"其不满足过滤规则,被剔除 {check}") continue logger.info(f"check通过 {check}") - + if description is not None: embedding = await get_embedding(description) - + if description is not None: embedding = await get_embedding(description) # 准备数据库记录 emoji_record = { - 'filename': filename, - 'path': image_path, - 'embedding': embedding, - 'discription': description, - 'hash': image_hash, - 'timestamp': int(time.time()) + "filename": filename, + "path": image_path, + "embedding": embedding, + "discription": description, + "hash": image_hash, + "timestamp": int(time.time()), } - + # 保存到emoji数据库 - self.db['emoji'].insert_one(emoji_record) + self.db["emoji"].insert_one(emoji_record) logger.success(f"注册新表情包: {filename}") logger.info(f"描述: {description}") - # 保存到images数据库 image_doc = { - 'hash': image_hash, - 'path': image_path, - 'type': 'emoji', - 'description': description, - 'timestamp': int(time.time()) + "hash": image_hash, + "path": image_path, + "type": "emoji", + "description": description, + "timestamp": int(time.time()), } - image_manager.db.images.update_one( - {'hash': image_hash}, - {'$set': image_doc}, - upsert=True - ) + image_manager.db.images.update_one({"hash": image_hash}, {"$set": image_doc}, upsert=True) # 保存描述到image_descriptions集合 - image_manager._save_description_to_db(image_hash, description, 'emoji') + image_manager._save_description_to_db(image_hash, description, "emoji") logger.success(f"同步保存到images集合: {filename}") else: logger.warning(f"跳过表情包: {filename}") @@ -354,23 +337,28 @@ class EmojiManager: for emoji in all_emojis: try: - if 'path' not in emoji: + if "path" not in emoji: logger.warning(f"发现无效记录(缺少path字段),ID: {emoji.get('_id', 'unknown')}") - self.db.emoji.delete_one({'_id': emoji['_id']}) + self.db.emoji.delete_one({"_id": emoji["_id"]}) removed_count += 1 continue - if 'embedding' not in emoji: + if "embedding" not in emoji: logger.warning(f"发现过时记录(缺少embedding字段),ID: {emoji.get('_id', 'unknown')}") - self.db.emoji.delete_one({'_id': emoji['_id']}) + self.db.emoji.delete_one({"_id": emoji["_id"]}) removed_count += 1 continue + if "hash" not in emoji: + logger.warning(f"发现缺失记录(缺少hash字段),ID: {emoji.get('_id', 'unknown')}") + hash = hashlib.md5(open(emoji["path"], "rb").read()).hexdigest() + self.db.emoji.update_one({"_id": emoji["_id"]}, {"$set": {"hash": hash}}) + # 检查文件是否存在 - if not os.path.exists(emoji['path']): + if not os.path.exists(emoji["path"]): logger.warning(f"表情包文件已被删除: {emoji['path']}") # 从数据库中删除记录 - result = self.db.emoji.delete_one({'_id': emoji['_id']}) + result = self.db.emoji.delete_one({"_id": emoji["_id"]}) if result.deleted_count > 0: logger.debug(f"成功删除数据库记录: {emoji['_id']}") removed_count += 1 @@ -401,5 +389,3 @@ class EmojiManager: # 创建全局单例 emoji_manager = EmojiManager() - - diff --git a/src/plugins/chat/message.py b/src/plugins/chat/message.py index 626e7cf4e..96308c50b 100644 --- a/src/plugins/chat/message.py +++ b/src/plugins/chat/message.py @@ -23,8 +23,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @dataclass class Message(MessageBase): - chat_stream: ChatStream=None - reply: Optional['Message'] = None + chat_stream: ChatStream = None + reply: Optional["Message"] = None detailed_plain_text: str = "" processed_plain_text: str = "" @@ -35,7 +35,7 @@ class Message(MessageBase): chat_stream: ChatStream, user_info: UserInfo, message_segment: Optional[Seg] = None, - reply: Optional['MessageRecv'] = None, + reply: Optional["MessageRecv"] = None, detailed_plain_text: str = "", processed_plain_text: str = "", ): @@ -45,21 +45,17 @@ class Message(MessageBase): message_id=message_id, time=time, group_info=chat_stream.group_info, - user_info=user_info + user_info=user_info, ) # 调用父类初始化 - super().__init__( - message_info=message_info, - message_segment=message_segment, - raw_message=None - ) + super().__init__(message_info=message_info, message_segment=message_segment, raw_message=None) self.chat_stream = chat_stream # 文本处理相关属性 self.processed_plain_text = processed_plain_text self.detailed_plain_text = detailed_plain_text - + # 回复消息 self.reply = reply @@ -74,41 +70,38 @@ class MessageRecv(Message): Args: message_dict: MessageCQ序列化后的字典 """ - self.message_info = BaseMessageInfo.from_dict(message_dict.get('message_info', {})) + self.message_info = BaseMessageInfo.from_dict(message_dict.get("message_info", {})) - message_segment = message_dict.get('message_segment', {}) + message_segment = message_dict.get("message_segment", {}) - if message_segment.get('data','') == '[json]': + if message_segment.get("data", "") == "[json]": # 提取json消息中的展示信息 - pattern = r'\[CQ:json,data=(?P.+?)\]' - match = re.search(pattern, message_dict.get('raw_message','')) - raw_json = html.unescape(match.group('json_data')) + pattern = r"\[CQ:json,data=(?P.+?)\]" + match = re.search(pattern, message_dict.get("raw_message", "")) + raw_json = html.unescape(match.group("json_data")) try: json_message = json.loads(raw_json) except json.JSONDecodeError: json_message = {} - message_segment['data'] = json_message.get('prompt','') + message_segment["data"] = json_message.get("prompt", "") + + self.message_segment = Seg.from_dict(message_dict.get("message_segment", {})) + self.raw_message = message_dict.get("raw_message") - self.message_segment = Seg.from_dict(message_dict.get('message_segment', {})) - self.raw_message = message_dict.get('raw_message') - # 处理消息内容 self.processed_plain_text = "" # 初始化为空字符串 - self.detailed_plain_text = "" # 初始化为空字符串 - self.is_emoji=False - - - def update_chat_stream(self,chat_stream:ChatStream): - self.chat_stream=chat_stream - + self.detailed_plain_text = "" # 初始化为空字符串 + self.is_emoji = False + + def update_chat_stream(self, chat_stream: ChatStream): + self.chat_stream = chat_stream + async def process(self) -> None: """处理消息内容,生成纯文本和详细文本 这个方法必须在创建实例后显式调用,因为它包含异步操作。 """ - self.processed_plain_text = await self._process_message_segments( - self.message_segment - ) + self.processed_plain_text = await self._process_message_segments(self.message_segment) self.detailed_plain_text = self._generate_detailed_text() async def _process_message_segments(self, segment: Seg) -> str: @@ -157,16 +150,12 @@ class MessageRecv(Message): else: return f"[{seg.type}:{str(seg.data)}]" except Exception as e: - logger.error( - f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}" - ) + logger.error(f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}") return f"[处理失败的{seg.type}消息]" def _generate_detailed_text(self) -> str: """生成详细文本,包含时间和用户信息""" - time_str = time.strftime( - "%m-%d %H:%M:%S", time.localtime(self.message_info.time) - ) + time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(self.message_info.time)) user_info = self.message_info.user_info name = ( f"{user_info.user_nickname}(ta的昵称:{user_info.user_cardname},ta的id:{user_info.user_id})" @@ -174,7 +163,7 @@ class MessageRecv(Message): else f"{user_info.user_nickname}(ta的id:{user_info.user_id})" ) return f"[{time_str}] {name}: {self.processed_plain_text}\n" - + @dataclass class MessageProcessBase(Message): @@ -257,16 +246,12 @@ class MessageProcessBase(Message): else: return f"[{seg.type}:{str(seg.data)}]" except Exception as e: - logger.error( - f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}" - ) + logger.error(f"处理消息段失败: {str(e)}, 类型: {seg.type}, 数据: {seg.data}") return f"[处理失败的{seg.type}消息]" def _generate_detailed_text(self) -> str: """生成详细文本,包含时间和用户信息""" - time_str = time.strftime( - "%m-%d %H:%M:%S", time.localtime(self.message_info.time) - ) + time_str = time.strftime("%m-%d %H:%M:%S", time.localtime(self.message_info.time)) user_info = self.message_info.user_info name = ( f"{user_info.user_nickname}(ta的昵称:{user_info.user_cardname},ta的id:{user_info.user_id})" @@ -330,10 +315,11 @@ class MessageSending(MessageProcessBase): self.is_head = is_head self.is_emoji = is_emoji - def set_reply(self, reply: Optional["MessageRecv"]) -> None: + def set_reply(self, reply: Optional["MessageRecv"] = None) -> None: """设置回复消息""" if reply: self.reply = reply + if self.reply: self.reply_to_message_id = self.reply.message_info.message_id self.message_segment = Seg( type="seglist", @@ -346,9 +332,7 @@ class MessageSending(MessageProcessBase): async def process(self) -> None: """处理消息内容,生成纯文本和详细文本""" if self.message_segment: - self.processed_plain_text = await self._process_message_segments( - self.message_segment - ) + self.processed_plain_text = await self._process_message_segments(self.message_segment) self.detailed_plain_text = self._generate_detailed_text() @classmethod @@ -377,10 +361,7 @@ class MessageSending(MessageProcessBase): def is_private_message(self) -> bool: """判断是否为私聊消息""" - return ( - self.message_info.group_info is None - or self.message_info.group_info.group_id is None - ) + return self.message_info.group_info is None or self.message_info.group_info.group_id is None @dataclass diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 94014b5b4..14658f4f1 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -13,20 +13,22 @@ from nonebot import get_driver from ...common.database import Database from ..chat.config import global_config from ..models.utils_model import LLM_request + driver = get_driver() config = driver.config + class ImageManager: _instance = None IMAGE_DIR = "data" # 图像存储根目录 - + def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.db = None cls._instance._initialized = False return cls._instance - + def __init__(self): if not self._initialized: self.db = Database.get_instance() @@ -35,68 +37,62 @@ class ImageManager: self._ensure_image_dir() self._initialized = True self._llm = LLM_request(model=global_config.vlm, temperature=0.4, max_tokens=300) - + def _ensure_image_dir(self): """确保图像存储目录存在""" os.makedirs(self.IMAGE_DIR, exist_ok=True) - + def _ensure_image_collection(self): """确保images集合存在并创建索引""" - if 'images' not in self.db.list_collection_names(): - self.db.create_collection('images') + if "images" not in self.db.list_collection_names(): + self.db.create_collection("images") # 创建索引 - self.db.images.create_index([('hash', 1)], unique=True) - self.db.images.create_index([('url', 1)]) - self.db.images.create_index([('path', 1)]) + self.db.images.create_index([("hash", 1)], unique=True) + self.db.images.create_index([("url", 1)]) + self.db.images.create_index([("path", 1)]) def _ensure_description_collection(self): """确保image_descriptions集合存在并创建索引""" - if 'image_descriptions' not in self.db.list_collection_names(): - self.db.create_collection('image_descriptions') + if "image_descriptions" not in self.db.list_collection_names(): + self.db.create_collection("image_descriptions") # 创建索引 - self.db.image_descriptions.create_index([('hash', 1)], unique=True) - self.db.image_descriptions.create_index([('type', 1)]) + self.db.image_descriptions.create_index([("hash", 1)], unique=True) + self.db.image_descriptions.create_index([("type", 1)]) def _get_description_from_db(self, image_hash: str, description_type: str) -> Optional[str]: """从数据库获取图片描述 - + Args: image_hash: 图片哈希值 description_type: 描述类型 ('emoji' 或 'image') - + Returns: Optional[str]: 描述文本,如果不存在则返回None """ - result= self.db.image_descriptions.find_one({ - 'hash': image_hash, - 'type': description_type - }) - return result['description'] if result else None + if image_hash is None: + return + result = self.db.image_descriptions.find_one({"hash": image_hash, "type": description_type}) + return result["description"] if result else None def _save_description_to_db(self, image_hash: str, description: str, description_type: str) -> None: """保存图片描述到数据库 - + Args: image_hash: 图片哈希值 description: 描述文本 description_type: 描述类型 ('emoji' 或 'image') """ + if image_hash is None: + return self.db.image_descriptions.update_one( - {'hash': image_hash, 'type': description_type}, - { - '$set': { - 'description': description, - 'timestamp': int(time.time()) - } - }, - upsert=True + {"hash": image_hash, "type": description_type}, + {"$set": {"description": description, "timestamp": int(time.time())}}, + upsert=True, ) - async def save_image(self, - image_data: Union[str, bytes], - url: str = None, - description: str = None, - is_base64: bool = False) -> Optional[str]: + async def save_image( + self, image_data: Union[str, bytes], url: str = None, description: str = None, is_base64: bool = False + ) -> Optional[str]: """保存图像 Args: image_data: 图像数据(base64字符串或字节) @@ -118,41 +114,41 @@ class ImageManager: image_bytes = image_data else: return None - + # 计算哈希值 image_hash = hashlib.md5(image_bytes).hexdigest() image_format = Image.open(io.BytesIO(image_bytes)).format.lower() - + # 查重 - existing = self.db.images.find_one({'hash': image_hash}) + existing = self.db.images.find_one({"hash": image_hash}) if existing: - return existing['path'] - + return existing["path"] + # 生成文件名和路径 timestamp = int(time.time()) filename = f"{timestamp}_{image_hash[:8]}.{image_format}" file_path = os.path.join(self.IMAGE_DIR, filename) - + # 保存文件 with open(file_path, "wb") as f: f.write(image_bytes) - + # 保存到数据库 image_doc = { - 'hash': image_hash, - 'path': file_path, - 'url': url, - 'description': description, - 'timestamp': timestamp + "hash": image_hash, + "path": file_path, + "url": url, + "description": description, + "timestamp": timestamp, } self.db.images.insert_one(image_doc) - + return file_path - + except Exception as e: logger.error(f"保存图像失败: {str(e)}") return None - + async def get_image_by_url(self, url: str) -> Optional[str]: """根据URL获取图像路径(带查重) Args: @@ -162,10 +158,10 @@ class ImageManager: """ try: # 先查找是否已存在 - existing = self.db.images.find_one({'url': url}) + existing = self.db.images.find_one({"url": url}) if existing: - return existing['path'] - + return existing["path"] + # 下载图像 async with aiohttp.ClientSession() as session: async with session.get(url) as resp: @@ -173,11 +169,11 @@ class ImageManager: image_bytes = await resp.read() return await self.save_image(image_bytes, url=url) return None - + except Exception as e: logger.error(f"获取图像失败: {str(e)}") return None - + async def get_base64_by_url(self, url: str) -> Optional[str]: """根据URL获取base64(带查重) Args: @@ -189,16 +185,15 @@ class ImageManager: image_path = await self.get_image_by_url(url) if not image_path: return None - - with open(image_path, 'rb') as f: + + with open(image_path, "rb") as f: image_bytes = f.read() - return base64.b64encode(image_bytes).decode('utf-8') - + return base64.b64encode(image_bytes).decode("utf-8") + except Exception as e: logger.error(f"获取base64失败: {str(e)}") return None - - + def check_url_exists(self, url: str) -> bool: """检查URL是否已存在 Args: @@ -206,8 +201,8 @@ class ImageManager: Returns: bool: 是否存在 """ - return self.db.images.find_one({'url': url}) is not None - + return self.db.images.find_one({"url": url}) is not None + def check_hash_exists(self, image_data: Union[str, bytes], is_base64: bool = False) -> bool: """检查图像是否已存在 Args: @@ -227,14 +222,14 @@ class ImageManager: image_bytes = image_data else: return False - + image_hash = hashlib.md5(image_bytes).hexdigest() - return self.db.images.find_one({'hash': image_hash}) is not None - + return self.db.images.find_one({"hash": image_hash}) is not None + except Exception as e: logger.error(f"检查哈希失败: {str(e)}") return False - + async def get_emoji_description(self, image_base64: str) -> str: """获取表情包描述,带查重和保存功能""" try: @@ -244,7 +239,7 @@ class ImageManager: image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 查询缓存的描述 - cached_description = self._get_description_from_db(image_hash, 'emoji') + cached_description = self._get_description_from_db(image_hash, "emoji") if cached_description: logger.info(f"缓存表情包描述: {cached_description}") return f"[表情包:{cached_description}]" @@ -252,39 +247,35 @@ class ImageManager: # 调用AI获取描述 prompt = "这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感" description, _ = await self._llm.generate_response_for_image(prompt, image_base64, image_format) - + # 根据配置决定是否保存图片 if global_config.EMOJI_SAVE: # 生成文件名和路径 timestamp = int(time.time()) filename = f"{timestamp}_{image_hash[:8]}.{image_format}" - file_path = os.path.join(self.IMAGE_DIR, 'emoji',filename) - + file_path = os.path.join(self.IMAGE_DIR, "emoji", filename) + try: # 保存文件 with open(file_path, "wb") as f: f.write(image_bytes) - + # 保存到数据库 image_doc = { - 'hash': image_hash, - 'path': file_path, - 'type': 'emoji', - 'description': description, - 'timestamp': timestamp + "hash": image_hash, + "path": file_path, + "type": "emoji", + "description": description, + "timestamp": timestamp, } - self.db.images.update_one( - {'hash': image_hash}, - {'$set': image_doc}, - upsert=True - ) + self.db.images.update_one({"hash": image_hash}, {"$set": image_doc}, upsert=True) logger.success(f"保存表情包: {file_path}") except Exception as e: logger.error(f"保存表情包文件失败: {str(e)}") - + # 保存描述到数据库 - self._save_description_to_db(image_hash, description, 'emoji') - + self._save_description_to_db(image_hash, description, "emoji") + return f"[表情包:{description}]" except Exception as e: logger.error(f"获取表情包描述失败: {str(e)}") @@ -300,60 +291,57 @@ class ImageManager: image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 查询缓存的描述 - cached_description = self._get_description_from_db(image_hash, 'image') + cached_description = self._get_description_from_db(image_hash, "image") if cached_description: print("图片描述缓存中") return f"[图片:{cached_description}]" # 调用AI获取描述 - prompt = "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" + prompt = ( + "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" + ) description, _ = await self._llm.generate_response_for_image(prompt, image_base64, image_format) - + print(f"描述是{description}") - + if description is None: logger.warning("AI未能生成图片描述") return "[图片]" - + # 根据配置决定是否保存图片 if global_config.EMOJI_SAVE: # 生成文件名和路径 timestamp = int(time.time()) filename = f"{timestamp}_{image_hash[:8]}.{image_format}" - file_path = os.path.join(self.IMAGE_DIR,'image', filename) - + file_path = os.path.join(self.IMAGE_DIR, "image", filename) + try: # 保存文件 with open(file_path, "wb") as f: f.write(image_bytes) - + # 保存到数据库 image_doc = { - 'hash': image_hash, - 'path': file_path, - 'type': 'image', - 'description': description, - 'timestamp': timestamp + "hash": image_hash, + "path": file_path, + "type": "image", + "description": description, + "timestamp": timestamp, } - self.db.images.update_one( - {'hash': image_hash}, - {'$set': image_doc}, - upsert=True - ) + self.db.images.update_one({"hash": image_hash}, {"$set": image_doc}, upsert=True) logger.success(f"保存图片: {file_path}") except Exception as e: logger.error(f"保存图片文件失败: {str(e)}") - + # 保存描述到数据库 - self._save_description_to_db(image_hash, description, 'image') - + self._save_description_to_db(image_hash, description, "image") + return f"[图片:{description}]" except Exception as e: logger.error(f"获取图片描述失败: {str(e)}") return "[图片]" - # 创建全局单例 image_manager = ImageManager() @@ -366,9 +354,9 @@ def image_path_to_base64(image_path: str) -> str: str: base64编码的图片数据 """ try: - with open(image_path, 'rb') as f: + with open(image_path, "rb") as f: image_data = f.read() - return base64.b64encode(image_data).decode('utf-8') + return base64.b64encode(image_data).decode("utf-8") except Exception as e: logger.error(f"读取图片失败: {image_path}, 错误: {str(e)}") - return None \ No newline at end of file + return None From ce12ba655d7b13569b5b30100d0421d59f276d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E7=8C=AB?= Date: Wed, 12 Mar 2025 22:39:18 +0900 Subject: [PATCH 127/162] typo --- src/plugins/chat/utils.py | 2 +- src/plugins/memory_system/memory.py | 8 ++++---- src/plugins/memory_system/memory_manual_build.py | 8 ++++---- src/plugins/memory_system/memory_test1.py | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/plugins/chat/utils.py b/src/plugins/chat/utils.py index bf834a380..f28d0e192 100644 --- a/src/plugins/chat/utils.py +++ b/src/plugins/chat/utils.py @@ -77,7 +77,7 @@ def calculate_information_content(text): return entropy -def get_cloest_chat_from_db(length: int, timestamp: str): +def get_closest_chat_from_db(length: int, timestamp: str): """从数据库中获取最接近指定时间戳的聊天记录 Args: diff --git a/src/plugins/memory_system/memory.py b/src/plugins/memory_system/memory.py index 4a8249504..f87f037d5 100644 --- a/src/plugins/memory_system/memory.py +++ b/src/plugins/memory_system/memory.py @@ -15,7 +15,7 @@ from ..chat.config import global_config from ..chat.utils import ( calculate_information_content, cosine_similarity, - get_cloest_chat_from_db, + get_closest_chat_from_db, text_to_vector, ) from ..models.utils_model import LLM_request @@ -190,19 +190,19 @@ class Hippocampus: # 短期:1h 中期:4h 长期:24h for _ in range(time_frequency.get('near')): random_time = current_timestamp - random.randint(1, 3600) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('mid')): random_time = current_timestamp - random.randint(3600, 3600 * 4) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('far')): random_time = current_timestamp - random.randint(3600 * 4, 3600 * 24) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) diff --git a/src/plugins/memory_system/memory_manual_build.py b/src/plugins/memory_system/memory_manual_build.py index e0d64b55f..2d16998e0 100644 --- a/src/plugins/memory_system/memory_manual_build.py +++ b/src/plugins/memory_system/memory_manual_build.py @@ -49,7 +49,7 @@ def calculate_information_content(text): return entropy -def get_cloest_chat_from_db(length: int, timestamp: str): +def get_closest_chat_from_db(length: int, timestamp: str): """从数据库中获取最接近指定时间戳的聊天记录,并记录读取次数 Returns: @@ -185,19 +185,19 @@ class Hippocampus: # 短期:1h 中期:4h 长期:24h for _ in range(time_frequency.get('near')): random_time = current_timestamp - random.randint(1, 3600*4) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('mid')): random_time = current_timestamp - random.randint(3600*4, 3600*24) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('far')): random_time = current_timestamp - random.randint(3600*24, 3600*24*7) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) diff --git a/src/plugins/memory_system/memory_test1.py b/src/plugins/memory_system/memory_test1.py index 2253d0032..245eb9b26 100644 --- a/src/plugins/memory_system/memory_test1.py +++ b/src/plugins/memory_system/memory_test1.py @@ -69,7 +69,7 @@ def calculate_information_content(text): return entropy -def get_cloest_chat_from_db(length: int, timestamp: str): +def get_closest_chat_from_db(length: int, timestamp: str): """从数据库中获取最接近指定时间戳的聊天记录,并记录读取次数 Returns: @@ -452,19 +452,19 @@ class Hippocampus: # 短期:1h 中期:4h 长期:24h for _ in range(time_frequency.get('near')): random_time = current_timestamp - random.randint(1, 3600*4) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('mid')): random_time = current_timestamp - random.randint(3600*4, 3600*24) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) for _ in range(time_frequency.get('far')): random_time = current_timestamp - random.randint(3600*24, 3600*24*7) - messages = get_cloest_chat_from_db(length=chat_size, timestamp=random_time) + messages = get_closest_chat_from_db(length=chat_size, timestamp=random_time) if messages: chat_samples.append(messages) From 3b1fc70e26f73d7ef584aa14957ab28ee4111c02 Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Wed, 12 Mar 2025 21:51:16 +0800 Subject: [PATCH 128/162] =?UTF-8?q?fix:=20=E8=B7=AF=E5=BE=84=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=80=A7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/utils_image.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index 0dfc17111..f4969d3e9 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -247,6 +247,8 @@ class ImageManager: # 生成文件名和路径 timestamp = int(time.time()) filename = f"{timestamp}_{image_hash[:8]}.{image_format}" + if not os.path.exists(os.path.join(self.IMAGE_DIR, "emoji")): + os.makedirs(os.path.join(self.IMAGE_DIR, "emoji")) file_path = os.path.join(self.IMAGE_DIR, "emoji", filename) try: @@ -307,6 +309,8 @@ class ImageManager: # 生成文件名和路径 timestamp = int(time.time()) filename = f"{timestamp}_{image_hash[:8]}.{image_format}" + if not os.path.exists(os.path.join(self.IMAGE_DIR, "image")): + os.makedirs(os.path.join(self.IMAGE_DIR, "image")) file_path = os.path.join(self.IMAGE_DIR, "image", filename) try: From 8ee53fb312a793ab6f1fc4e7950a7d015eb900b7 Mon Sep 17 00:00:00 2001 From: HYY Date: Wed, 12 Mar 2025 22:01:05 +0800 Subject: [PATCH 129/162] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E5=B0=8Fbug=EF=BC=8C=E5=B9=B6=E4=B8=94=E6=8A=8Aprint?= =?UTF-8?q?=E6=94=B9=E4=B8=BAlogger=EF=BC=8C=E5=87=8F=E5=B0=91review?= =?UTF-8?q?=E8=A1=80=E5=8E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 6 +++--- src/plugins/chat/willing_manager.py | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 5002cb162..53266fccf 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -135,7 +135,7 @@ class ChatBot: for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.raw_message): logger.info( - f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{message.user_nickname}:{message.raw_message}" + f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{userinfo.user_nickname}:{message.raw_message}" ) logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return @@ -239,10 +239,10 @@ class ChatBot: is_head=not mark_head, is_emoji=False, ) - print(f"bot_message: {bot_message}") + logger.debug(f"bot_message: {bot_message}") if not mark_head: mark_head = True - print(f"添加消息到message_set: {bot_message}") + logger.debug(f"添加消息到message_set: {bot_message}") message_set.add_message(bot_message) # message_set 可以直接加入 message_manager diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 9754848c4..345461703 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -2,6 +2,7 @@ import asyncio import random import time from typing import Dict +from loguru import logger from .config import global_config @@ -27,7 +28,7 @@ class WillingManager: """定期衰减回复意愿""" while True: await asyncio.sleep(5) - for chat_id in list(self.chat_reply_willing.keys()): + for chat_id in self.chat_reply_willing: is_high_mode = self.chat_high_willing_mode.get(chat_id, False) if is_high_mode: # 高回复意愿期内轻微衰减 @@ -42,7 +43,7 @@ class WillingManager: current_time = time.time() await asyncio.sleep(10) # 每10秒检查一次 - for chat_id in list(self.chat_high_willing_mode.keys()): + for chat_id in self.chat_high_willing_mode: last_change_time = self.chat_last_mode_change.get(chat_id, 0) is_high_mode = self.chat_high_willing_mode.get(chat_id, False) @@ -146,7 +147,7 @@ class WillingManager: is_follow_up_question = True in_conversation_context = True self.chat_conversation_context[chat_id] = True - print(f"检测到追问 (同一用户), 提高回复意愿") + logger.debug(f"检测到追问 (同一用户), 提高回复意愿") current_willing += 0.3 # 特殊情况处理 @@ -154,11 +155,11 @@ class WillingManager: current_willing += 0.5 in_conversation_context = True self.chat_conversation_context[chat_id] = True - print(f"被提及, 当前意愿: {current_willing}") + logger.debug(f"被提及, 当前意愿: {current_willing}") if is_emoji: current_willing *= 0.1 - print(f"表情包, 当前意愿: {current_willing}") + logger.debug(f"表情包, 当前意愿: {current_willing}") # 根据话题兴趣度适当调整 if interested_rate > 0.5: @@ -170,7 +171,7 @@ class WillingManager: if in_conversation_context: # 在对话上下文中,降低基础回复概率 base_probability = 0.5 if is_high_mode else 0.25 - print(f"处于对话上下文中,基础回复概率: {base_probability}") + logger.debug(f"处于对话上下文中,基础回复概率: {base_probability}") elif is_high_mode: # 高回复周期:4-8句话有50%的概率会回复一次 base_probability = 0.50 if 4 <= msg_count <= 8 else 0.2 From 62810c7bc72c69bf1d97190b559a67ac7ccb1777 Mon Sep 17 00:00:00 2001 From: HYY Date: Wed, 12 Mar 2025 22:01:35 +0800 Subject: [PATCH 130/162] =?UTF-8?q?chore:logger=E6=9B=BF=E6=8D=A2print?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/willing_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/chat/willing_manager.py b/src/plugins/chat/willing_manager.py index 345461703..6df27f3a4 100644 --- a/src/plugins/chat/willing_manager.py +++ b/src/plugins/chat/willing_manager.py @@ -75,13 +75,13 @@ class WillingManager: self.chat_high_willing_mode[chat_id] = False self.chat_reply_willing[chat_id] = 0.1 # 设置为最低回复意愿 self.chat_low_willing_duration[chat_id] = random.randint(600, 1200) # 10-20分钟 - print(f"聊天流 {chat_id} 切换到低回复意愿期,持续 {self.chat_low_willing_duration[chat_id]} 秒") + logger.debug(f"聊天流 {chat_id} 切换到低回复意愿期,持续 {self.chat_low_willing_duration[chat_id]} 秒") else: # 从低回复期切换到高回复期 self.chat_high_willing_mode[chat_id] = True self.chat_reply_willing[chat_id] = 1.0 # 设置为较高回复意愿 self.chat_high_willing_duration[chat_id] = random.randint(180, 240) # 3-4分钟 - print(f"聊天流 {chat_id} 切换到高回复意愿期,持续 {self.chat_high_willing_duration[chat_id]} 秒") + logger.debug(f"聊天流 {chat_id} 切换到高回复意愿期,持续 {self.chat_high_willing_duration[chat_id]} 秒") self.chat_last_mode_change[chat_id] = time.time() self.chat_msg_count[chat_id] = 0 # 重置消息计数 From f62e79523a97bbd11986339ca379fbaac2aee88e Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Wed, 12 Mar 2025 22:38:40 +0800 Subject: [PATCH 131/162] =?UTF-8?q?feat:=20Linux=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 1key_install_linux.sh | 183 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 1key_install_linux.sh diff --git a/1key_install_linux.sh b/1key_install_linux.sh new file mode 100644 index 000000000..dd9903716 --- /dev/null +++ b/1key_install_linux.sh @@ -0,0 +1,183 @@ +#!/bin/bash + +# Maimbot 一键安装脚本 by Cookie987 +# 适用于Debian系 +# 请小心使用任何一键脚本! + +# 如无法访问GitHub请修改此处镜像地址 +GITHUB_REPO="https://ghfast.top/https://github.com/SengokuCola/MaiMBot.git" + +# 颜色输出 +GREEN="\e[32m" +RED="\e[31m" +RESET="\e[0m" + +# 需要的基本软件包 +REQUIRED_PACKAGES=("git" "sudo" "python3" "python3-venv" "python3-pip") + +# 默认项目目录 +DEFAULT_INSTALL_DIR="/opt/maimbot" + +# 服务名称 +SERVICE_NAME="maimbot" + +# 1/6: 检测是否安装 whiptail +if ! command -v whiptail &>/dev/null; then + echo -e "${RED}[1/6] whiptail 未安装,正在安装...${RESET}" + apt update && apt install -y whiptail +fi + +get_os_info() { + if command -v lsb_release &>/dev/null; then + OS_INFO=$(lsb_release -d | cut -f2) + elif [[ -f /etc/os-release ]]; then + OS_INFO=$(grep "^PRETTY_NAME=" /etc/os-release | cut -d '"' -f2) + else + OS_INFO="Unknown OS" + fi + echo "$OS_INFO" +} + +check_system() { + OS_NAME=$(get_os_info) + whiptail --title "⚙️ [2/6] 检查系统" --yesno "本脚本仅支持Debian 12。\n当前系统为 $OS_NAME\n是否继续?" 10 60 || exit 1 +} +# 3/6: 询问用户是否安装缺失的软件包 +install_packages() { + missing_packages=() + for package in "${REQUIRED_PACKAGES[@]}"; do + if ! dpkg -s "$package" &>/dev/null; then + missing_packages+=("$package") + fi + done + + if [[ ${#missing_packages[@]} -gt 0 ]]; then + whiptail --title "📦 [3/6] 软件包检查" --yesno "检测到以下软件包缺失(MongoDB除外):\n${missing_packages[*]}\n\n是否要自动安装?" 12 60 + if [[ $? -eq 0 ]]; then + break + else + whiptail --title "⚠️ 注意" --yesno "某些必要的软件包未安装,可能会影响运行!\n是否继续?" 10 60 || exit 1 + fi + fi +} + +# 4/6: Python 版本检查 +check_python() { + PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + + python3 -c "import sys; exit(0) if sys.version_info >= (3,9) else exit(1)" + if [[ $? -ne 0 ]]; then + whiptail --title "⚠️ [4/6] Python 版本过低" --msgbox "检测到 Python 版本为 $PYTHON_VERSION,需要 3.9 或以上!\n请升级 Python 后重新运行本脚本。" 10 60 + exit 1 + fi +} + +# 5/6: 选择分支 +choose_branch() { + BRANCH=$(whiptail --title "🔀 [5/6] 选择 Maimbot 分支" --menu "请选择要安装的 Maimbot 分支:" 15 60 2 \ + "main" "稳定版本(推荐)" \ + "debug" "开发版本(可能不稳定)" 3>&1 1>&2 2>&3) + + if [[ -z "$BRANCH" ]]; then + BRANCH="main" + whiptail --title "🔀 默认选择" --msgbox "未选择分支,默认安装稳定版本(main)" 10 60 + fi +} + +# 6/6: 选择安装路径 +choose_install_dir() { + INSTALL_DIR=$(whiptail --title "📂 [6/6] 选择安装路径" --inputbox "请输入 Maimbot 的安装目录:" 10 60 "$DEFAULT_INSTALL_DIR" 3>&1 1>&2 2>&3) + + if [[ -z "$INSTALL_DIR" ]]; then + whiptail --title "⚠️ 取消输入" --yesno "未输入安装路径,是否退出安装?" 10 60 + if [[ $? -ne 0 ]]; then + INSTALL_DIR="$DEFAULT_INSTALL_DIR" + else + exit 1 + fi + fi +} + +# 显示确认界面 +confirm_install() { + local confirm_message="请确认以下更改:\n\n" + + if [[ ${#missing_packages[@]} -gt 0 ]]; then + confirm_message+="📦 安装缺失的依赖项: ${missing_packages[*]}\n" + else + confirm_message+="✅ 所有依赖项已安装,无需额外安装\n" + fi + + confirm_message+="📂 安装目录: $INSTALL_DIR\n" + confirm_message+="🔀 分支: $BRANCH\n" + + if dpkg -s mongodb-org &>/dev/null; then + confirm_message+="✅ MongoDB 已安装\n" + else + confirm_message+="⚠️ MongoDB 可能未安装(请参阅官方文档安装)\n" + fi + + confirm_message+="🛠️ 添加 Maimbot 作为系统服务 ($SERVICE_NAME.service)\n" + + confitm_message+="\n\n注意:本脚本使用GitHub,如无法访问请手动修改仓库地址。" + whiptail --title "🔧 安装确认" --yesno "$confirm_message\n\n是否继续安装?" 15 60 + if [[ $? -ne 0 ]]; then + whiptail --title "🚫 取消安装" --msgbox "安装已取消。" 10 60 + exit 1 + fi +} + +# 运行安装步骤 +check_system +install_packages +check_python +choose_branch +choose_install_dir +confirm_install + +# 开始安装 +whiptail --title "🚀 开始安装" --msgbox "所有环境检查完毕,即将开始安装 Maimbot!" 10 60 + +echo -e "${GREEN}安装依赖项...${RESET}" + +apt update && apt install -y "${missing_packages[@]}" + +echo -e "${GREEN}创建 Python 虚拟环境...${RESET}" +mkdir -p "$INSTALL_DIR" +cd "$INSTALL_DIR" || exit +python3 -m venv venv +source venv/bin/activate + +echo -e "${GREEN}克隆仓库...${RESET}" +# 安装 Maimbot +mkdir -p "$INSTALL_DIR/repo" +cd "$INSTALL_DIR/repo" || exit 1 +git clone -b "$BRANCH" $GITHUB_REPO . + +echo -e "${GREEN}安装 Python 依赖...${RESET}" +pip install -r requirements.txt + +echo -e "${GREEN}设置服务...${RESET}" +# 设置 Maimbot 服务 +cat < Date: Wed, 12 Mar 2025 22:40:22 +0800 Subject: [PATCH 132/162] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c14ac646e..964c69e22 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ - 📦 **Windows 一键傻瓜式部署**:请运行项目根目录中的 `run.bat`,部署完成后请参照后续配置指南进行配置 +- [📦 Linux 自动部署(实验) ](请在项目根目录中运行 `bash 1key_install_linux.sh`并安装提示安装,部署完成后请参照后续配置指南进行配置) - [📦 Windows 手动部署指南 ](docs/manual_deploy_windows.md) From 466ce4c45d331cd297f007e877969fbc4cfb27cc Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Wed, 12 Mar 2025 22:41:15 +0800 Subject: [PATCH 133/162] =?UTF-8?q?feat:=20Linux=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 964c69e22..92fcdc318 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ - 📦 **Windows 一键傻瓜式部署**:请运行项目根目录中的 `run.bat`,部署完成后请参照后续配置指南进行配置 -- [📦 Linux 自动部署(实验) ](请在项目根目录中运行 `bash 1key_install_linux.sh`并安装提示安装,部署完成后请参照后续配置指南进行配置) +- 📦 Linux 自动部署(实验) :请在项目根目录中运行 `bash 1key_install_linux.sh`并按照提示安装,部署完成后请参照后续配置指南进行配置 - [📦 Windows 手动部署指南 ](docs/manual_deploy_windows.md) From 1db47d24680c7f8b4e38faa854d7d4beb744d3fb Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Wed, 12 Mar 2025 22:42:22 +0800 Subject: [PATCH 134/162] =?UTF-8?q?=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/manual_deploy_linux.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/manual_deploy_linux.md b/docs/manual_deploy_linux.md index b19f3d6a7..a5c91d6e2 100644 --- a/docs/manual_deploy_linux.md +++ b/docs/manual_deploy_linux.md @@ -121,6 +121,7 @@ sudo nano /etc/systemd/system/maimbot.service 输入以下内容: ``:你的maimbot目录 + ``:你的venv环境(就是上文创建环境后,执行的代码`source maimbot/bin/activate`中source后面的路径的绝对路径) ```ini From 5399aca0d3f503d4e542846506830d369ad8a0e6 Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Wed, 12 Mar 2025 22:46:00 +0800 Subject: [PATCH 135/162] Update and rename 1key_install_linux.sh to run.sh --- 1key_install_linux.sh => run.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename 1key_install_linux.sh => run.sh (97%) diff --git a/1key_install_linux.sh b/run.sh similarity index 97% rename from 1key_install_linux.sh rename to run.sh index dd9903716..c616edcfb 100644 --- a/1key_install_linux.sh +++ b/run.sh @@ -5,7 +5,7 @@ # 请小心使用任何一键脚本! # 如无法访问GitHub请修改此处镜像地址 -GITHUB_REPO="https://ghfast.top/https://github.com/SengokuCola/MaiMBot.git" +GITHUB_REPO="https://github.com/SengokuCola/MaiMBot.git" # 颜色输出 GREEN="\e[32m" @@ -119,7 +119,7 @@ confirm_install() { confirm_message+="🛠️ 添加 Maimbot 作为系统服务 ($SERVICE_NAME.service)\n" - confitm_message+="\n\n注意:本脚本使用GitHub,如无法访问请手动修改仓库地址。" + confirm_message+="\n\n注意:本脚本使用GitHub,如无法访问请手动修改仓库地址。" whiptail --title "🔧 安装确认" --yesno "$confirm_message\n\n是否继续安装?" 15 60 if [[ $? -ne 0 ]]; then whiptail --title "🚫 取消安装" --msgbox "安装已取消。" 10 60 From 5bb8b69fa0b302aabcd38f007e989ade487b57e7 Mon Sep 17 00:00:00 2001 From: Cookie987 Date: Wed, 12 Mar 2025 22:55:11 +0800 Subject: [PATCH 136/162] =?UTF-8?q?break=E6=94=B9=E4=B8=BAreturn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run.sh b/run.sh index c616edcfb..43f35c578 100644 --- a/run.sh +++ b/run.sh @@ -54,7 +54,7 @@ install_packages() { if [[ ${#missing_packages[@]} -gt 0 ]]; then whiptail --title "📦 [3/6] 软件包检查" --yesno "检测到以下软件包缺失(MongoDB除外):\n${missing_packages[*]}\n\n是否要自动安装?" 12 60 if [[ $? -eq 0 ]]; then - break + return else whiptail --title "⚠️ 注意" --yesno "某些必要的软件包未安装,可能会影响运行!\n是否继续?" 10 60 || exit 1 fi From acb59e5d8db52817e654a788ccf4960ee7e60ff4 Mon Sep 17 00:00:00 2001 From: KawaiiYusora Date: Wed, 12 Mar 2025 23:07:28 +0800 Subject: [PATCH 137/162] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1hash=E5=AD=97=E6=AE=B5=E7=9A=84=E8=AE=B0=E5=BD=95=20?= =?UTF-8?q?=E6=8A=9B=E5=87=BAFile=20not=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 0933644ec..2fa2a268d 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -346,11 +346,6 @@ class EmojiManager: removed_count += 1 continue - if "hash" not in emoji: - logger.warning(f"发现缺失记录(缺少hash字段),ID: {emoji.get('_id', 'unknown')}") - hash = hashlib.md5(open(emoji["path"], "rb").read()).hexdigest() - db.emoji.update_one({"_id": emoji["_id"]}, {"$set": {"hash": hash}}) - # 检查文件是否存在 if not os.path.exists(emoji["path"]): logger.warning(f"表情包文件已被删除: {emoji['path']}") @@ -361,6 +356,12 @@ class EmojiManager: removed_count += 1 else: logger.error(f"删除数据库记录失败: {emoji['_id']}") + continue + + if "hash" not in emoji: + logger.warning(f"发现缺失记录(缺少hash字段),ID: {emoji.get('_id', 'unknown')}") + hash = hashlib.md5(open(emoji["path"], "rb").read()).hexdigest() + db.emoji.update_one({"_id": emoji["_id"]}, {"$set": {"hash": hash}}) except Exception as item_error: logger.error(f"处理表情包记录时出错: {str(item_error)}") continue From 24a3cf3b663bc1d0a2f3dbbb9db3bd6115ed96b6 Mon Sep 17 00:00:00 2001 From: HYY Date: Wed, 12 Mar 2025 23:09:53 +0800 Subject: [PATCH 138/162] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=E4=B8=80=E4=B8=AAhash=20unset=E5=8F=AF=E8=83=BD=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=BC=82=E5=B8=B8=E7=9A=84=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 0933644ec..aae24ac53 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -346,11 +346,6 @@ class EmojiManager: removed_count += 1 continue - if "hash" not in emoji: - logger.warning(f"发现缺失记录(缺少hash字段),ID: {emoji.get('_id', 'unknown')}") - hash = hashlib.md5(open(emoji["path"], "rb").read()).hexdigest() - db.emoji.update_one({"_id": emoji["_id"]}, {"$set": {"hash": hash}}) - # 检查文件是否存在 if not os.path.exists(emoji["path"]): logger.warning(f"表情包文件已被删除: {emoji['path']}") @@ -361,6 +356,12 @@ class EmojiManager: removed_count += 1 else: logger.error(f"删除数据库记录失败: {emoji['_id']}") + + if "hash" not in emoji: + logger.warning(f"发现缺失记录(缺少hash字段),ID: {emoji.get('_id', 'unknown')}") + hash = hashlib.md5(open(emoji["path"], "rb").read()).hexdigest() + db.emoji.update_one({"_id": emoji["_id"]}, {"$set": {"hash": hash}}) + except Exception as item_error: logger.error(f"处理表情包记录时出错: {str(item_error)}") continue From eafc3dea7188b05500e5fc7eb735285f90fbe812 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Wed, 12 Mar 2025 23:16:29 +0800 Subject: [PATCH 139/162] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E4=BA=86message?= =?UTF-8?q?=E5=8F=98=E5=8A=A8=E5=AF=BC=E8=87=B4=E7=9A=84=E7=A7=81=E8=81=8A?= =?UTF-8?q?error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 1db38477c..ec2f3a0b4 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -126,7 +126,7 @@ class ChatBot: for word in global_config.ban_words: if word in message.processed_plain_text: logger.info( - f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{userinfo.user_nickname}:{message.processed_plain_text}" + f"[{chat.group_info.group_name if chat.group_info else '私聊'}]{userinfo.user_nickname}:{message.processed_plain_text}" ) logger.info(f"[过滤词识别]消息中含有{word},filtered") return @@ -135,7 +135,7 @@ class ChatBot: for pattern in global_config.ban_msgs_regex: if re.search(pattern, message.raw_message): logger.info( - f"[{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{userinfo.user_nickname}:{message.raw_message}" + f"[{chat.group_info.group_name if chat.group_info else '私聊'}]{userinfo.user_nickname}:{message.raw_message}" ) logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") return @@ -143,7 +143,7 @@ class ChatBot: current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) # topic=await topic_identifier.identify_topic_llm(message.processed_plain_text) - + topic = "" interested_rate = await hippocampus.memory_activate_value(message.processed_plain_text) / 100 logger.debug(f"对{message.processed_plain_text}的激活度:{interested_rate}") @@ -163,7 +163,7 @@ class ChatBot: current_willing = willing_manager.get_willing(chat_stream=chat) logger.info( - f"[{current_time}][{chat.group_info.group_name if chat.group_info.group_id else '私聊'}]{chat.user_info.user_nickname}:" + f"[{current_time}][{chat.group_info.group_name if chat.group_info else '私聊'}]{chat.user_info.user_nickname}:" f"{message.processed_plain_text}[回复意愿:{current_willing:.2f}][概率:{reply_probability * 100:.1f}%]" ) From 2d7da8cd70aa6a6f7622482bd9cf791b91ff962f Mon Sep 17 00:00:00 2001 From: tcmofashi Date: Thu, 13 Mar 2025 00:46:50 +0800 Subject: [PATCH 140/162] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A1=A8?= =?UTF-8?q?=E6=83=85=E5=8C=85=E9=87=8D=E5=A4=8D=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/emoji_manager.py | 2 +- src/plugins/chat/utils_image.py | 124 +++--------------------------- 2 files changed, 10 insertions(+), 116 deletions(-) diff --git a/src/plugins/chat/emoji_manager.py b/src/plugins/chat/emoji_manager.py index 657b17c67..76437f8f2 100644 --- a/src/plugins/chat/emoji_manager.py +++ b/src/plugins/chat/emoji_manager.py @@ -232,7 +232,7 @@ class EmojiManager: image_hash = hashlib.md5(image_bytes).hexdigest() image_format = Image.open(io.BytesIO(image_bytes)).format.lower() # 检查是否已经注册过 - existing_emoji = db["emoji"].find_one({"filename": filename}) + existing_emoji = db["emoji"].find_one({"hash": image_hash}) description = None if existing_emoji: diff --git a/src/plugins/chat/utils_image.py b/src/plugins/chat/utils_image.py index f4969d3e9..cc3a6ca3d 100644 --- a/src/plugins/chat/utils_image.py +++ b/src/plugins/chat/utils_image.py @@ -84,65 +84,6 @@ class ImageManager: upsert=True, ) - async def save_image( - self, image_data: Union[str, bytes], url: str = None, description: str = None, is_base64: bool = False - ) -> Optional[str]: - """保存图像 - Args: - image_data: 图像数据(base64字符串或字节) - url: 图像URL - description: 图像描述 - is_base64: image_data是否为base64格式 - Returns: - str: 保存后的文件路径,失败返回None - """ - try: - # 转换为字节格式 - if is_base64: - if isinstance(image_data, str): - image_bytes = base64.b64decode(image_data) - else: - return None - else: - if isinstance(image_data, bytes): - image_bytes = image_data - else: - return None - - # 计算哈希值 - image_hash = hashlib.md5(image_bytes).hexdigest() - image_format = Image.open(io.BytesIO(image_bytes)).format.lower() - - # 查重 - existing = db.images.find_one({"hash": image_hash}) - if existing: - return existing["path"] - - # 生成文件名和路径 - timestamp = int(time.time()) - filename = f"{timestamp}_{image_hash[:8]}.{image_format}" - file_path = os.path.join(self.IMAGE_DIR, filename) - - # 保存文件 - with open(file_path, "wb") as f: - f.write(image_bytes) - - # 保存到数据库 - image_doc = { - "hash": image_hash, - "path": file_path, - "url": url, - "description": description, - "timestamp": timestamp, - } - db.images.insert_one(image_doc) - - return file_path - - except Exception as e: - logger.error(f"保存图像失败: {str(e)}") - return None - async def get_image_by_url(self, url: str) -> Optional[str]: """根据URL获取图像路径(带查重) Args: @@ -168,62 +109,6 @@ class ImageManager: logger.error(f"获取图像失败: {str(e)}") return None - async def get_base64_by_url(self, url: str) -> Optional[str]: - """根据URL获取base64(带查重) - Args: - url: 图像URL - Returns: - str: base64字符串,失败返回None - """ - try: - image_path = await self.get_image_by_url(url) - if not image_path: - return None - - with open(image_path, "rb") as f: - image_bytes = f.read() - return base64.b64encode(image_bytes).decode("utf-8") - - except Exception as e: - logger.error(f"获取base64失败: {str(e)}") - return None - - def check_url_exists(self, url: str) -> bool: - """检查URL是否已存在 - Args: - url: 图像URL - Returns: - bool: 是否存在 - """ - return db.images.find_one({"url": url}) is not None - - def check_hash_exists(self, image_data: Union[str, bytes], is_base64: bool = False) -> bool: - """检查图像是否已存在 - Args: - image_data: 图像数据(base64或字节) - is_base64: 是否为base64格式 - Returns: - bool: 是否存在 - """ - try: - if is_base64: - if isinstance(image_data, str): - image_bytes = base64.b64decode(image_data) - else: - return False - else: - if isinstance(image_data, bytes): - image_bytes = image_data - else: - return False - - image_hash = hashlib.md5(image_bytes).hexdigest() - return db.images.find_one({"hash": image_hash}) is not None - - except Exception as e: - logger.error(f"检查哈希失败: {str(e)}") - return False - async def get_emoji_description(self, image_base64: str) -> str: """获取表情包描述,带查重和保存功能""" try: @@ -242,6 +127,11 @@ class ImageManager: prompt = "这是一个表情包,使用中文简洁的描述一下表情包的内容和表情包所表达的情感" description, _ = await self._llm.generate_response_for_image(prompt, image_base64, image_format) + cached_description = self._get_description_from_db(image_hash, "emoji") + if cached_description: + logger.warning(f"虽然生成了描述,但找到缓存表情包描述: {cached_description}") + return f"[表情包:{cached_description}]" + # 根据配置决定是否保存图片 if global_config.EMOJI_SAVE: # 生成文件名和路径 @@ -297,6 +187,10 @@ class ImageManager: "请用中文描述这张图片的内容。如果有文字,请把文字都描述出来。并尝试猜测这个图片的含义。最多200个字。" ) description, _ = await self._llm.generate_response_for_image(prompt, image_base64, image_format) + cached_description = self._get_description_from_db(image_hash, "emoji") + if cached_description: + logger.info(f"缓存图片描述: {cached_description}") + return f"[图片:{cached_description}]" print(f"描述是{description}") From d6e267e2976c4dc0bf9fe8fdbbc603ab10418cf7 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 13 Mar 2025 01:19:03 +0800 Subject: [PATCH 141/162] =?UTF-8?q?=E5=A5=BD=E7=8E=A9=E7=9A=84=E5=B0=8F?= =?UTF-8?q?=E4=B8=9C=E8=A5=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/avatars/SengokuCola.jpg | Bin 0 -> 19939 bytes docs/avatars/default.png | Bin 0 -> 36490 bytes docs/avatars/run.bat | 1 + 3 files changed, 1 insertion(+) create mode 100644 docs/avatars/SengokuCola.jpg create mode 100644 docs/avatars/default.png create mode 100644 docs/avatars/run.bat diff --git a/docs/avatars/SengokuCola.jpg b/docs/avatars/SengokuCola.jpg new file mode 100644 index 0000000000000000000000000000000000000000..deebf5ed577140007b32e6de05eb37367580607c GIT binary patch literal 19939 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 z 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%0A0tmoW^FC_o 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 Date: Thu, 13 Mar 2025 02:22:50 +0900 Subject: [PATCH 142/162] add ruff action to debug branch --- .github/workflows/ruff.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/workflows/ruff.yml diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 000000000..4adeffd74 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,8 @@ +name: Ruff +on: [ push, pull_request ] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 From 4ffffb3b606fbe60ab508c4e6c4b444f41ab5d0a Mon Sep 17 00:00:00 2001 From: ChangingSelf Date: Thu, 13 Mar 2025 01:38:53 +0800 Subject: [PATCH 143/162] =?UTF-8?q?close=20SengokuCola/MaiMBot#246=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=88=B3=E4=B8=80=E6=88=B3=E5=9B=9E=E5=BA=94?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/__init__.py | 20 +++++++---- src/plugins/chat/bot.py | 68 +++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index d7a7bd7e4..26b3d36da 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -3,8 +3,9 @@ import time import os from loguru import logger -from nonebot import get_driver, on_message, require -from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment,MessageEvent +from nonebot import get_driver, on_message, on_notice, require +from nonebot.rule import to_me +from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment, MessageEvent, NoticeEvent from nonebot.typing import T_State from ..moods.moods import MoodManager # 导入情绪管理器 @@ -39,6 +40,8 @@ logger.debug(f"正在唤醒{global_config.BOT_NICKNAME}......") chat_bot = ChatBot() # 注册消息处理器 msg_in = on_message(priority=5) +# 注册和bot相关的通知处理器 +notice_matcher = on_notice(priority=1) # 创建定时任务 scheduler = require("nonebot_plugin_apscheduler").scheduler @@ -95,19 +98,24 @@ async def _(bot: Bot, event: MessageEvent, state: T_State): await chat_bot.handle_message(event, bot) +@notice_matcher.handle() +async def _(bot: Bot, event: NoticeEvent, state: T_State): + logger.debug(f"收到通知:{event}") + await chat_bot.handle_notice(event, bot) + + # 添加build_memory定时任务 @scheduler.scheduled_job("interval", seconds=global_config.build_memory_interval, id="build_memory") async def build_memory_task(): """每build_memory_interval秒执行一次记忆构建""" - logger.debug( - "[记忆构建]" - "------------------------------------开始构建记忆--------------------------------------") + logger.debug("[记忆构建]------------------------------------开始构建记忆--------------------------------------") start_time = time.time() await hippocampus.operation_build_memory(chat_size=20) end_time = time.time() logger.success( f"[记忆构建]--------------------------记忆构建完成:耗时: {end_time - start_time:.2f} " - "秒-------------------------------------------") + "秒-------------------------------------------" + ) @scheduler.scheduled_job("interval", seconds=global_config.forget_memory_interval, id="forget_memory") diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 1db38477c..ffce56963 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -7,6 +7,8 @@ from nonebot.adapters.onebot.v11 import ( GroupMessageEvent, MessageEvent, PrivateMessageEvent, + NoticeEvent, + PokeNotifyEvent, ) from ..memory_system.memory import hippocampus @@ -25,6 +27,7 @@ from .relationship_manager import relationship_manager from .storage import MessageStorage from .utils import calculate_typing_time, is_mentioned_bot_in_message from .utils_image import image_path_to_base64 +from .utils_user import get_user_nickname, get_user_cardname, get_groupname from .willing_manager import willing_manager # 导入意愿管理器 from .message_base import UserInfo, GroupInfo, Seg @@ -46,6 +49,69 @@ class ChatBot: if not self._started: self._started = True + async def handle_notice(self, event: NoticeEvent, bot: Bot) -> None: + """处理收到的通知""" + # 戳一戳通知 + if isinstance(event, PokeNotifyEvent): + # 用户屏蔽,不区分私聊/群聊 + if event.user_id in global_config.ban_user_id: + return + reply_poke_probability = 1 # 回复戳一戳的概率 + + if random() < reply_poke_probability: + user_info = UserInfo( + user_id=event.user_id, + user_nickname=get_user_nickname(event.user_id) or None, + user_cardname=get_user_cardname(event.user_id) or None, + platform="qq", + ) + group_info = GroupInfo(group_id=event.group_id, group_name=None, platform="qq") + message_cq = MessageRecvCQ( + message_id=None, + user_info=user_info, + raw_message=str("[戳了戳]你"), + group_info=group_info, + reply_message=None, + platform="qq", + ) + message_json = message_cq.to_dict() + + # 进入maimbot + message = MessageRecv(message_json) + groupinfo = message.message_info.group_info + userinfo = message.message_info.user_info + messageinfo = message.message_info + + chat = await chat_manager.get_or_create_stream( + platform=messageinfo.platform, user_info=userinfo, group_info=groupinfo + ) + message.update_chat_stream(chat) + await message.process() + + bot_user_info = UserInfo( + user_id=global_config.BOT_QQ, + user_nickname=global_config.BOT_NICKNAME, + platform=messageinfo.platform, + ) + + response, raw_content = await self.gpt.generate_response(message) + + if response: + for msg in response: + message_segment = Seg(type="text", data=msg) + + bot_message = MessageSending( + message_id=None, + chat_stream=chat, + bot_user_info=bot_user_info, + sender_info=userinfo, + message_segment=message_segment, + reply=None, + is_head=False, + is_emoji=False, + ) + message_manager.add_message(bot_message) + async def handle_message(self, event: MessageEvent, bot: Bot) -> None: """处理收到的消息""" @@ -143,7 +209,7 @@ class ChatBot: current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(messageinfo.time)) # topic=await topic_identifier.identify_topic_llm(message.processed_plain_text) - + topic = "" interested_rate = await hippocampus.memory_activate_value(message.processed_plain_text) / 100 logger.debug(f"对{message.processed_plain_text}的激活度:{interested_rate}") From fe7ef7d7315bb02fa4f885e782ca107837f3b264 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Thu, 13 Mar 2025 02:51:53 +0800 Subject: [PATCH 144/162] =?UTF-8?q?chore:=20=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E4=BC=98=E5=8C=96=EF=BC=88=E6=8A=A5=E9=94=99=E4=BF=A1?= =?UTF-8?q?=E6=81=AF&log=EF=BC=89+=20ruff=E7=9A=84=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 96 ++++++++++++++++--------------- src/plugins/models/utils_model.py | 2 +- template.env | 2 +- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/bot.py b/bot.py index 48517fe24..b78cd0e03 100644 --- a/bot.py +++ b/bot.py @@ -17,19 +17,6 @@ env_mask = {key: os.getenv(key) for key in os.environ} uvicorn_server = None -# 配置日志 -log_path = os.path.join(os.getcwd(), "logs") -if not os.path.exists(log_path): - os.makedirs(log_path) - -# 添加文件日志,启用rotation和retention -logger.add( - os.path.join(log_path, "maimbot_{time:YYYY-MM-DD}.log"), - rotation="00:00", # 每天0点创建新文件 - retention="30 days", # 保留30天的日志 - level="INFO", - encoding="utf-8" -) def easter_egg(): # 彩蛋 @@ -76,7 +63,7 @@ def init_env(): # 首先加载基础环境变量.env if os.path.exists(".env"): - load_dotenv(".env",override=True) + load_dotenv(".env", override=True) logger.success("成功加载基础环境变量配置") @@ -90,10 +77,7 @@ def load_env(): logger.success("加载开发环境变量配置") load_dotenv(".env.dev", override=True) # override=True 允许覆盖已存在的环境变量 - fn_map = { - "prod": prod, - "dev": dev - } + fn_map = {"prod": prod, "dev": dev} env = os.getenv("ENVIRONMENT") logger.info(f"[load_env] 当前的 ENVIRONMENT 变量值:{env}") @@ -109,28 +93,53 @@ def load_env(): logger.error(f"ENVIRONMENT 配置错误,请检查 .env 文件中的 ENVIRONMENT 变量及对应 .env.{env} 是否存在") RuntimeError(f"ENVIRONMENT 配置错误,请检查 .env 文件中的 ENVIRONMENT 变量及对应 .env.{env} 是否存在") -def load_logger(): - logger.remove() # 移除默认配置 - if os.getenv("ENVIRONMENT") == "dev": - logger.add( - sys.stderr, - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <7} | {name:.<8}:{function:.<8}:{line: >4} - {message}", - colorize=True, - level=os.getenv("LOG_LEVEL", "DEBUG"), # 根据环境设置日志级别,默认为DEBUG - ) - else: - logger.add( - sys.stderr, - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <7} | {name:.<8}:{function:.<8}:{line: >4} - {message}", - colorize=True, - level=os.getenv("LOG_LEVEL", "INFO"), # 根据环境设置日志级别,默认为INFO - filter=lambda record: "nonebot" not in record["name"] - ) +def load_logger(): + logger.remove() + + # 配置日志基础路径 + log_path = os.path.join(os.getcwd(), "logs") + if not os.path.exists(log_path): + os.makedirs(log_path) + + current_env = os.getenv("ENV", "dev") + + # 公共配置参数 + log_level = os.getenv( + "LOG_LEVEL", + "INFO" if current_env == "prod" else "DEBUG" + ) + log_filter = lambda record: ( + ("nonebot" not in record["name"] or record["level"].no >= logger.level("ERROR").no + ) if current_env == "prod" else True + ) + log_format = ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} " + "| {level: <7} " + "| {name:.<8}:{function:.<8}:{line: >4} " + "- {message}" + ) + + # 日志文件储存至/logs + logger.add( + os.path.join(log_path, "maimbot_{time:YYYY-MM-DD}.log"), + rotation="00:00", + retention="30 days", + format=log_format, + colorize=False, + level=log_level, + filter=log_filter, + encoding="utf-8" + ) + + # 终端输出 + logger.add( + sys.stderr, + format=log_format, + colorize=True, + level=log_level, + filter=log_filter + ) def scan_provider(env_config: dict): @@ -160,10 +169,7 @@ def scan_provider(env_config: dict): # 检查每个 provider 是否同时存在 url 和 key for provider_name, config in provider.items(): if config["url"] is None or config["key"] is None: - logger.error( - f"provider 内容:{config}\n" - f"env_config 内容:{env_config}" - ) + logger.error(f"provider 内容:{config}\nenv_config 内容:{env_config}") raise ValueError(f"请检查 '{provider_name}' 提供商配置是否丢失 BASE_URL 或 KEY 环境变量") @@ -192,7 +198,7 @@ async def uvicorn_main(): reload=os.getenv("ENVIRONMENT") == "dev", timeout_graceful_shutdown=5, log_config=None, - access_log=False + access_log=False, ) server = uvicorn.Server(config) uvicorn_server = server @@ -202,7 +208,7 @@ async def uvicorn_main(): def raw_main(): # 利用 TZ 环境变量设定程序工作的时区 # 仅保证行为一致,不依赖 localtime(),实际对生产环境几乎没有作用 - if platform.system().lower() != 'windows': + if platform.system().lower() != "windows": time.tzset() easter_egg() diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index afe4baeb5..0f5bb335c 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -132,7 +132,7 @@ class LLM_request: # 常见Error Code Mapping error_code_mapping = { 400: "参数不正确", - 401: "API key 错误,认证失败", + 401: "API key 错误,认证失败,请检查/config/bot_config.toml和.env.prod中的配置是否正确哦~", 402: "账号余额不足", 403: "需要实名,或余额不足", 404: "Not Found", diff --git a/template.env b/template.env index d2a763112..322776ce7 100644 --- a/template.env +++ b/template.env @@ -23,7 +23,7 @@ CHAT_ANY_WHERE_BASE_URL=https://api.chatanywhere.tech/v1 SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1/ DEEP_SEEK_BASE_URL=https://api.deepseek.com/v1 -#定义你要用的api的base_url +#定义你要用的api的key(需要去对应网站申请哦) DEEP_SEEK_KEY= CHAT_ANY_WHERE_KEY= SILICONFLOW_KEY= From 11e8b2fa5f00defaeb1ce2a770fb8afbd7b0d19b Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Thu, 13 Mar 2025 03:18:49 +0800 Subject: [PATCH 145/162] =?UTF-8?q?chore:=20ruff=E7=9A=84=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 20 ++++++-------------- src/common/database.py | 4 +--- src/plugins/chat/__init__.py | 9 ++++----- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/bot.py b/bot.py index b78cd0e03..b5d210cc3 100644 --- a/bot.py +++ b/bot.py @@ -105,13 +105,11 @@ def load_logger(): current_env = os.getenv("ENV", "dev") # 公共配置参数 - log_level = os.getenv( - "LOG_LEVEL", - "INFO" if current_env == "prod" else "DEBUG" - ) + log_level = os.getenv("LOG_LEVEL", "INFO" if current_env == "prod" else "DEBUG") log_filter = lambda record: ( - ("nonebot" not in record["name"] or record["level"].no >= logger.level("ERROR").no - ) if current_env == "prod" else True + ("nonebot" not in record["name"] or record["level"].no >= logger.level("ERROR").no) + if current_env == "prod" + else True ) log_format = ( "{time:YYYY-MM-DD HH:mm:ss.SSS} " @@ -129,17 +127,11 @@ def load_logger(): colorize=False, level=log_level, filter=log_filter, - encoding="utf-8" + encoding="utf-8", ) # 终端输出 - logger.add( - sys.stderr, - format=log_format, - colorize=True, - level=log_level, - filter=log_filter - ) + logger.add(sys.stderr, format=log_format, colorize=True, level=log_level, filter=log_filter) def scan_provider(env_config: dict): diff --git a/src/common/database.py b/src/common/database.py index ca73dc468..cd149e526 100644 --- a/src/common/database.py +++ b/src/common/database.py @@ -22,9 +22,7 @@ def __create_database_instance(): if username and password: # 如果有用户名和密码,使用认证连接 - return MongoClient( - host, port, username=username, password=password, authSource=auth_source - ) + return MongoClient(host, port, username=username, password=password, authSource=auth_source) # 否则使用无认证连接 return MongoClient(host, port) diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index d7a7bd7e4..c4281d186 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -4,7 +4,7 @@ import os from loguru import logger from nonebot import get_driver, on_message, require -from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment,MessageEvent +from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageSegment, MessageEvent from nonebot.typing import T_State from ..moods.moods import MoodManager # 导入情绪管理器 @@ -99,15 +99,14 @@ async def _(bot: Bot, event: MessageEvent, state: T_State): @scheduler.scheduled_job("interval", seconds=global_config.build_memory_interval, id="build_memory") async def build_memory_task(): """每build_memory_interval秒执行一次记忆构建""" - logger.debug( - "[记忆构建]" - "------------------------------------开始构建记忆--------------------------------------") + logger.debug("[记忆构建]------------------------------------开始构建记忆--------------------------------------") start_time = time.time() await hippocampus.operation_build_memory(chat_size=20) end_time = time.time() logger.success( f"[记忆构建]--------------------------记忆构建完成:耗时: {end_time - start_time:.2f} " - "秒-------------------------------------------") + "秒-------------------------------------------" + ) @scheduler.scheduled_job("interval", seconds=global_config.forget_memory_interval, id="forget_memory") From 3b5523d7e2e1a27f65a648952fdc9648c511f046 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Thu, 13 Mar 2025 03:21:54 +0800 Subject: [PATCH 146/162] =?UTF-8?q?=E6=9A=82=E6=97=B6=E5=85=B3=E6=8E=89ruf?= =?UTF-8?q?f=E7=9A=84checkout@v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ruff.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 4adeffd74..812dac070 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -5,4 +5,3 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: astral-sh/ruff-action@v3 From 2c74a5dfb7a8d63a5d44be1f20eedecb5c59dc33 Mon Sep 17 00:00:00 2001 From: AL76 <735756072@qq.com> Date: Thu, 13 Mar 2025 03:35:54 +0800 Subject: [PATCH 147/162] =?UTF-8?q?=E6=81=A2=E5=A4=8Druff=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ruff.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 812dac070..0d1e50c5a 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -5,3 +5,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 \ No newline at end of file From a2c29efb41d82b9313ee07a347181c522528ecb6 Mon Sep 17 00:00:00 2001 From: Pliosauroidea Date: Thu, 13 Mar 2025 09:27:56 +0800 Subject: [PATCH 148/162] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84envieonment=E8=AF=BB=E5=8F=96=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84debug=E8=BE=93=E5=87=BA=E6=8C=81=E7=BB=AD=E5=BC=80?= =?UTF-8?q?=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot.py b/bot.py index b5d210cc3..a3a844a15 100644 --- a/bot.py +++ b/bot.py @@ -102,7 +102,7 @@ def load_logger(): if not os.path.exists(log_path): os.makedirs(log_path) - current_env = os.getenv("ENV", "dev") + current_env = os.getenv("ENVIRONMENT", "dev") # 公共配置参数 log_level = os.getenv("LOG_LEVEL", "INFO" if current_env == "prod" else "DEBUG") From f1e38e8b1320318cea73caa2be3329617efbc3f1 Mon Sep 17 00:00:00 2001 From: Cindy-Master <2606440373@qq.com> Date: Thu, 13 Mar 2025 09:32:33 +0800 Subject: [PATCH 149/162] =?UTF-8?q?fix:=20ban=5Fuser=5Fid=E5=9C=A8?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E7=9A=84=E6=83=85=E5=86=B5=E4=B8=8B=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E8=A2=AB=E7=BB=95=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/chat/bot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/chat/bot.py b/src/plugins/chat/bot.py index 44931c75e..b90b3d0f3 100644 --- a/src/plugins/chat/bot.py +++ b/src/plugins/chat/bot.py @@ -120,7 +120,10 @@ class ChatBot: # 用户屏蔽,不区分私聊/群聊 if event.user_id in global_config.ban_user_id: return - + + if event.reply and hasattr(event.reply, 'sender') and hasattr(event.reply.sender, 'user_id') and event.reply.sender.user_id in global_config.ban_user_id: + logger.debug(f"跳过处理回复来自被ban用户 {event.reply.sender.user_id} 的消息") + return # 处理私聊消息 if isinstance(event, PrivateMessageEvent): if not global_config.enable_friend_chat: # 私聊过滤 From fc6f12442246b7ebb2acc5d4024480ae7fdab248 Mon Sep 17 00:00:00 2001 From: ProperSAMA <997794945@qq.com> Date: Thu, 13 Mar 2025 10:20:54 +0800 Subject: [PATCH 150/162] =?UTF-8?q?docs:=20=E7=BC=96=E5=86=99=E7=BE=A4?= =?UTF-8?q?=E6=99=96NAS=E9=83=A8=E7=BD=B2=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/synology_.env.prod.png | Bin 0 -> 109678 bytes docs/synology_create_project.png | Bin 0 -> 213225 bytes docs/synology_deploy.md | 67 ++++++++++++++++++++++++++++++ docs/synology_docker-compose.png | Bin 0 -> 173543 bytes docs/synology_how_to_download.png | Bin 0 -> 136527 bytes 5 files changed, 67 insertions(+) create mode 100644 docs/synology_.env.prod.png create mode 100644 docs/synology_create_project.png create mode 100644 docs/synology_deploy.md create mode 100644 docs/synology_docker-compose.png create mode 100644 docs/synology_how_to_download.png diff --git a/docs/synology_.env.prod.png b/docs/synology_.env.prod.png new file mode 100644 index 0000000000000000000000000000000000000000..0bdcacdf3b75439d2a93fe27a3d7d633e0003143 GIT binary patch literal 109678 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
CbdWV3qS;@C z<~8MJ4^-0`qBc@W&5zz&`wayy8R7Z`9wG`0`EN*H(*iP=mOOfsWNpzN07S#G$xKX6u^a^y=;)k>HedsGB*WG zp0OBEDBA8EO0_I3cJ~D~kZzGT{T;r#01ryXyar~bk%Fo_DpedV@|hWQ)1WJwanM-H zTzXhk4D1i?j?VB*>~M{%U61!c8e+9rZP$e~x^b)Qs;c}qK`N6_5s5C&+@Zj9S%8+u zS;z^+alDdy2F9V6jE9 zg`_2mYTb*X5d~qn8iVzP!dbb&f&qK}L6Lag+bs&wleLe{+qf1+A z!3Ar>seWN`nF1THlP4ozvJO%=t8f}fW0q4(gbA~5ZBl7t2mtYx6Ix2z<9v+ zd7Mpo>1NWMOuC;1=EgV<>06Lt`lemF+v`X#@Hmxyc44oYb=_ZR&Z|n=7fPkJkn+r@R8^gYKzfd5x9hm#Y4&ZG?V*JYs{1SWh+Gg3*k^CEDYRsmSv7mL?QErEQCpUs3!yucvMeaOuT_?A zm<@8sycaP@a|BITLnTPh5|rQuNHM^-h*e+!jlRjt zt71`@n#+bBLu(k_otU|54&F&tlkfO=di}+9U#klYd2cX#R0pt1v=KHE8K^DCxciqR z!cu;g$vzNw9*|Od65`aWOR6%!S1JwFk}2lgthHIaIg5<3 z+>>I!H6DEzHf_Bzg`Y1PUmXe%_ro`67ZMPjCHMIPmPv zfJk#9e)y|BT~`2q-315m&m-?n75U=A1oIfqS4h{cMW#lt^5cZ|v>j~^ZJ~wf+vE^m z)~{bU7OgNh#57qM7@@&6e?8-TW+1fc4lrmsM6e)0CTsub z|4d&KGV9C;HPFBM9UsJ|_hwGCEGKLzj*g%s3sun}jm#C>XX2H~J7Z&8eGWV%SuT&A{PmMpG;Qx<9#t#w-KBiu#PWkH> zuUx}fd8CkVcz=UYf#x0r3v; z%%u)^)KiC6m2rQjCf{u7mV@&BQw9pQlk(R1y^?BUB80p8Af;u-RjQmy zoTZ1CkvCR1doNdOR%O9c&$7{|V{bkIORjTey2k$&%HU+$h<`1c82nqQ>_0)Cnu0o) zReO2jpF^Um?Yg&yT*&peYQT$wMRerbGa&ERe4z221o=szaIwE;3$rzz4b#w-M1~9j zAO1pgbM5)T(%P)=(?mRy&$=BKI}@6AxTKo3&3E8R2U|;pY!<(wO5BwN|UvEk{dp z3M_TsV4)BFRMM0u-miZSdW9E+ctJ_TYjs;SE^k&A5htU@S7fXuk16}a3wXE@p?WFTz;Q4UIXCUq(PdSpDIMdww;Yzg;o-t8Y*9dU#0j#nh+@`@#hzc XZQ;|DfVVy{(2tz7vQ(Lb@z?(cYrZR~ literal 0 HcmV?d00001 diff --git a/docs/synology_create_project.png b/docs/synology_create_project.png new file mode 100644 index 0000000000000000000000000000000000000000..f716d4605ff091a2ab7d6d63cfebccd7f4d4ff30 GIT binary patch literal 213225 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