From fa49841011eac53fb353d959f6013251a029fa66 Mon Sep 17 00:00:00 2001 From: KawakazeNotFound Date: Fri, 7 Nov 2025 15:35:46 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot_plugin_llmchat/__init__.py | 158 +++++++++------- nonebot_plugin_llmchat/db_manager.py | 270 +++++++++++++++++++++++++++ nonebot_plugin_llmchat/migration.py | 152 +++++++++++++++ nonebot_plugin_llmchat/models.py | 102 ++++++++++ 4 files changed, 610 insertions(+), 72 deletions(-) create mode 100644 nonebot_plugin_llmchat/db_manager.py create mode 100644 nonebot_plugin_llmchat/migration.py create mode 100644 nonebot_plugin_llmchat/models.py diff --git a/nonebot_plugin_llmchat/__init__.py b/nonebot_plugin_llmchat/__init__.py index 0ab0736..1e0afb2 100755 --- a/nonebot_plugin_llmchat/__init__.py +++ b/nonebot_plugin_llmchat/__init__.py @@ -38,12 +38,19 @@ import nonebot_plugin_localstore as store require("nonebot_plugin_apscheduler") from nonebot_plugin_apscheduler import scheduler +require("nonebot_plugin_tortoise_orm") +from nonebot_plugin_tortoise_orm import init_orm_plugin + if TYPE_CHECKING: from openai.types.chat import ( ChatCompletionContentPartParam, ChatCompletionMessageParam, ) +from .db_manager import DatabaseManager +from .models import ChatHistory, ChatMessage, GroupChatState, PrivateChatState +from .migration import backup_json_files, migrate_from_json_to_db + __plugin_meta__ = PluginMetadata( name="llmchat", description="支持多API预设、MCP协议、联网搜索、视觉模型、Nano Banana(生图模型)的AI群聊插件", @@ -625,8 +632,13 @@ reset_handler = on_command( async def handle_reset(event: GroupMessageEvent, args: Message = CommandArg()): group_id = event.group_id + # 清空内存状态 group_states[group_id].past_events.clear() group_states[group_id].history.clear() + + # 清空数据库记录 + await DatabaseManager.clear_group_history(group_id) + await reset_handler.finish("记忆已清空") @@ -744,8 +756,13 @@ async def handle_private_reset(event: PrivateMessageEvent, args: Message = Comma user_id = event.user_id + # 清空内存状态 private_chat_states[user_id].past_events.clear() private_chat_states[user_id].history.clear() + + # 清空数据库记录 + await DatabaseManager.clear_private_history(user_id) + await private_reset_handler.finish("记忆已清空") @@ -775,96 +792,93 @@ async def handle_private_think(event: PrivateMessageEvent, args: Message = Comma # region 持久化与定时任务 -# 获取插件数据目录 -data_dir = store.get_plugin_data_dir() -# 获取插件数据文件 -data_file = store.get_plugin_data_file("llmchat_state.json") -private_data_file = store.get_plugin_data_file("llmchat_private_state.json") - async def save_state(): - """保存群组状态到文件""" - logger.info(f"开始保存群组状态到文件:{data_file}") - data = { - gid: { - "preset": state.preset_name, - "history": list(state.history), - "last_active": state.last_active, - "group_prompt": state.group_prompt, - "output_reasoning_content": state.output_reasoning_content, - "random_trigger_prob": state.random_trigger_prob, - } - for gid, state in group_states.items() - } - - os.makedirs(os.path.dirname(data_file), exist_ok=True) - async with aiofiles.open(data_file, "w", encoding="utf8") as f: - await f.write(json.dumps(data, ensure_ascii=False)) - + """保存所有群组和私聊状态到数据库""" + logger.info("开始保存所有状态到数据库") + + # 保存群组状态 + for gid, state in group_states.items(): + await DatabaseManager.save_group_state( + group_id=gid, + preset_name=state.preset_name, + history=state.history, + group_prompt=state.group_prompt, + output_reasoning_content=state.output_reasoning_content, + random_trigger_prob=state.random_trigger_prob, + ) + # 保存私聊状态 if plugin_config.enable_private_chat: - logger.info(f"开始保存私聊状态到文件:{private_data_file}") - private_data = { - uid: { - "preset": state.preset_name, - "history": list(state.history), - "last_active": state.last_active, - "group_prompt": state.group_prompt, - "output_reasoning_content": state.output_reasoning_content, - } - for uid, state in private_chat_states.items() - } - - os.makedirs(os.path.dirname(private_data_file), exist_ok=True) - async with aiofiles.open(private_data_file, "w", encoding="utf8") as f: - await f.write(json.dumps(private_data, ensure_ascii=False)) + for uid, state in private_chat_states.items(): + await DatabaseManager.save_private_state( + user_id=uid, + preset_name=state.preset_name, + history=state.history, + user_prompt=state.group_prompt, # 注意:这里应该是 group_prompt 但是在 PrivateChatState 中叫 group_prompt + output_reasoning_content=state.output_reasoning_content, + ) + + logger.info("所有状态保存完成") async def load_state(): - """从文件加载群组状态""" - logger.info(f"从文件加载群组状态:{data_file}") - if not os.path.exists(data_file): - return - - async with aiofiles.open(data_file, encoding="utf8") as f: - data = json.loads(await f.read()) - for gid, state_data in data.items(): - state = GroupState() - state.preset_name = state_data["preset"] - state.history = deque( - state_data["history"], maxlen=plugin_config.history_size * 2 - ) - state.last_active = state_data["last_active"] - state.group_prompt = state_data["group_prompt"] - state.output_reasoning_content = state_data["output_reasoning_content"] - state.random_trigger_prob = state_data.get("random_trigger_prob", plugin_config.random_trigger_prob) - group_states[int(gid)] = state - + """从数据库加载所有状态""" + logger.info("从数据库加载所有状态") + + history_maxlen = plugin_config.history_size * 2 + + # 加载群组状态 + group_data = await DatabaseManager.load_all_group_states(history_maxlen) + for gid, state_data in group_data.items(): + state = GroupState() + state.preset_name = state_data["preset_name"] + state.history = state_data["history"] + state.group_prompt = state_data["group_prompt"] + state.output_reasoning_content = state_data["output_reasoning_content"] + state.random_trigger_prob = state_data["random_trigger_prob"] + state.last_active = state_data["last_active"] + group_states[gid] = state + # 加载私聊状态 if plugin_config.enable_private_chat: - logger.info(f"从文件加载私聊状态:{private_data_file}") - if os.path.exists(private_data_file): - async with aiofiles.open(private_data_file, encoding="utf8") as f: - private_data = json.loads(await f.read()) - for uid, state_data in private_data.items(): - state = PrivateChatState() - state.preset_name = state_data["preset"] - state.history = deque( - state_data["history"], maxlen=plugin_config.history_size * 2 - ) - state.last_active = state_data["last_active"] - state.group_prompt = state_data["group_prompt"] - state.output_reasoning_content = state_data["output_reasoning_content"] - private_chat_states[int(uid)] = state + private_data = await DatabaseManager.load_all_private_states(history_maxlen) + for uid, state_data in private_data.items(): + state = PrivateChatState() + state.preset_name = state_data["preset_name"] + state.history = state_data["history"] + state.group_prompt = state_data["user_prompt"] # 注意:从 user_prompt 恢复到 group_prompt + state.output_reasoning_content = state_data["output_reasoning_content"] + state.last_active = state_data["last_active"] + private_chat_states[uid] = state + + logger.info(f"已加载 {len(group_states)} 个群组和 {len(private_chat_states)} 个私聊的状态") # 注册生命周期事件 @driver.on_startup async def init_plugin(): logger.info("插件启动初始化") + # 初始化 Tortoise ORM 的模型 + await init_orm_plugin() + # 创建表(如果不存在) + try: + # Tortoise ORM 会自动创建表,这里只是尝试检查 + logger.info("数据库表初始化中...") + except Exception as e: + logger.warning(f"初始化数据库时出现警告: {e}") + + # 执行迁移(从 JSON 到数据库) + try: + await migrate_from_json_to_db(plugin_config) + except Exception as e: + logger.warning(f"执行迁移时出现错误: {e}") + await load_state() # 每5分钟保存状态 scheduler.add_job(save_state, "interval", minutes=5) + + logger.info("插件初始化完成") @driver.on_shutdown diff --git a/nonebot_plugin_llmchat/db_manager.py b/nonebot_plugin_llmchat/db_manager.py new file mode 100644 index 0000000..af042b7 --- /dev/null +++ b/nonebot_plugin_llmchat/db_manager.py @@ -0,0 +1,270 @@ +""" +数据库操作层 +处理聊天历史和状态的持久化 +""" +import json +from collections import deque +from datetime import datetime +from typing import Optional + +from nonebot import logger +from tortoise.exceptions import DoesNotExist + +from .models import ChatHistory, ChatMessage, GroupChatState, PrivateChatState + + +class DatabaseManager: + """数据库管理器""" + + @staticmethod + async def save_group_state( + group_id: int, + preset_name: str, + history: deque, + group_prompt: Optional[str], + output_reasoning_content: bool, + random_trigger_prob: float, + ): + """保存群组状态和历史到数据库""" + try: + # 保存或更新群组状态 + state, _ = await GroupChatState.get_or_create( + group_id=group_id, + defaults={ + "preset_name": preset_name, + "group_prompt": group_prompt, + "output_reasoning_content": output_reasoning_content, + "random_trigger_prob": random_trigger_prob, + }, + ) + if _: # 如果是新创建的 + logger.debug(f"创建群组状态记录: {group_id}") + else: + # 更新现有记录 + state.preset_name = preset_name + state.group_prompt = group_prompt + state.output_reasoning_content = output_reasoning_content + state.random_trigger_prob = random_trigger_prob + await state.save() + logger.debug(f"更新群组状态记录: {group_id}") + + # 保存历史快照 + messages_list = list(history) + history_record, _ = await ChatHistory.get_or_create( + group_id=group_id, + is_private=False, + defaults={"messages_json": ChatHistory.serialize_messages(messages_list)}, + ) + if not _: + history_record.messages_json = ChatHistory.serialize_messages(messages_list) + await history_record.save() + + logger.debug(f"已保存群组 {group_id} 的历史记录({len(messages_list)} 条消息)") + + except Exception as e: + logger.error(f"保存群组状态失败 群号: {group_id}, 错误: {e}") + + @staticmethod + async def save_private_state( + user_id: int, + preset_name: str, + history: deque, + user_prompt: Optional[str], + output_reasoning_content: bool, + ): + """保存私聊状态和历史到数据库""" + try: + # 保存或更新私聊状态 + state, _ = await PrivateChatState.get_or_create( + user_id=user_id, + defaults={ + "preset_name": preset_name, + "user_prompt": user_prompt, + "output_reasoning_content": output_reasoning_content, + }, + ) + if _: # 如果是新创建的 + logger.debug(f"创建私聊状态记录: {user_id}") + else: + # 更新现有记录 + state.preset_name = preset_name + state.user_prompt = user_prompt + state.output_reasoning_content = output_reasoning_content + await state.save() + logger.debug(f"更新私聊状态记录: {user_id}") + + # 保存历史快照 + messages_list = list(history) + history_record, _ = await ChatHistory.get_or_create( + user_id=user_id, + is_private=True, + defaults={"messages_json": ChatHistory.serialize_messages(messages_list)}, + ) + if not _: + history_record.messages_json = ChatHistory.serialize_messages(messages_list) + await history_record.save() + + logger.debug(f"已保存用户 {user_id} 的历史记录({len(messages_list)} 条消息)") + + except Exception as e: + logger.error(f"保存私聊状态失败 用户: {user_id}, 错误: {e}") + + @staticmethod + async def load_group_state(group_id: int, history_maxlen: int) -> dict: + """从数据库加载群组状态""" + try: + state = await GroupChatState.get_or_none(group_id=group_id) + if not state: + logger.debug(f"未找到群组 {group_id} 的状态记录,返回默认值") + return None + + # 加载历史 + history_record = await ChatHistory.get_or_none( + group_id=group_id, is_private=False + ) + history = deque( + ChatHistory.deserialize_messages(history_record.messages_json) + if history_record + else [], + maxlen=history_maxlen, + ) + + logger.debug(f"已加载群组 {group_id} 的状态({len(history)} 条历史)") + + return { + "preset_name": state.preset_name, + "history": history, + "group_prompt": state.group_prompt, + "output_reasoning_content": state.output_reasoning_content, + "random_trigger_prob": state.random_trigger_prob, + "last_active": state.last_active.timestamp(), + } + + except Exception as e: + logger.error(f"加载群组状态失败 群号: {group_id}, 错误: {e}") + return None + + @staticmethod + async def load_private_state(user_id: int, history_maxlen: int) -> dict: + """从数据库加载私聊状态""" + try: + state = await PrivateChatState.get_or_none(user_id=user_id) + if not state: + logger.debug(f"未找到用户 {user_id} 的状态记录,返回默认值") + return None + + # 加载历史 + history_record = await ChatHistory.get_or_none( + user_id=user_id, is_private=True + ) + history = deque( + ChatHistory.deserialize_messages(history_record.messages_json) + if history_record + else [], + maxlen=history_maxlen, + ) + + logger.debug(f"已加载用户 {user_id} 的状态({len(history)} 条历史)") + + return { + "preset_name": state.preset_name, + "history": history, + "user_prompt": state.user_prompt, + "output_reasoning_content": state.output_reasoning_content, + "last_active": state.last_active.timestamp(), + } + + except Exception as e: + logger.error(f"加载私聊状态失败 用户: {user_id}, 错误: {e}") + return None + + @staticmethod + async def load_all_group_states(history_maxlen: int) -> dict: + """加载所有群组状态""" + try: + states = await GroupChatState.all() + result = {} + + for state in states: + history_record = await ChatHistory.get_or_none( + group_id=state.group_id, is_private=False + ) + history = deque( + ChatHistory.deserialize_messages(history_record.messages_json) + if history_record + else [], + maxlen=history_maxlen, + ) + + result[state.group_id] = { + "preset_name": state.preset_name, + "history": history, + "group_prompt": state.group_prompt, + "output_reasoning_content": state.output_reasoning_content, + "random_trigger_prob": state.random_trigger_prob, + "last_active": state.last_active.timestamp(), + } + + logger.info(f"已加载 {len(result)} 个群组的状态") + return result + + except Exception as e: + logger.error(f"加载所有群组状态失败, 错误: {e}") + return {} + + @staticmethod + async def load_all_private_states(history_maxlen: int) -> dict: + """加载所有私聊状态""" + try: + states = await PrivateChatState.all() + result = {} + + for state in states: + history_record = await ChatHistory.get_or_none( + user_id=state.user_id, is_private=True + ) + history = deque( + ChatHistory.deserialize_messages(history_record.messages_json) + if history_record + else [], + maxlen=history_maxlen, + ) + + result[state.user_id] = { + "preset_name": state.preset_name, + "history": history, + "user_prompt": state.user_prompt, + "output_reasoning_content": state.output_reasoning_content, + "last_active": state.last_active.timestamp(), + } + + logger.info(f"已加载 {len(result)} 个用户的私聊状态") + return result + + except Exception as e: + logger.error(f"加载所有私聊状态失败, 错误: {e}") + return {} + + @staticmethod + async def clear_group_history(group_id: int): + """清空群组历史""" + try: + await ChatHistory.filter(group_id=group_id, is_private=False).delete() + state = await GroupChatState.get_or_none(group_id=group_id) + if state: + await state.delete() + logger.info(f"已清空群组 {group_id} 的历史记录") + except Exception as e: + logger.error(f"清空群组历史失败 群号: {group_id}, 错误: {e}") + + @staticmethod + async def clear_private_history(user_id: int): + """清空私聊历史""" + try: + await ChatHistory.filter(user_id=user_id, is_private=True).delete() + state = await PrivateChatState.get_or_none(user_id=user_id) + if state: + await state.delete() + logger.info(f"已清空用户 {user_id} 的历史记录") + except Exception as e: + logger.error(f"清空私聊历史失败 用户: {user_id}, 错误: {e}") diff --git a/nonebot_plugin_llmchat/migration.py b/nonebot_plugin_llmchat/migration.py new file mode 100644 index 0000000..507d0ca --- /dev/null +++ b/nonebot_plugin_llmchat/migration.py @@ -0,0 +1,152 @@ +""" +数据迁移脚本 +将聊天数据从 JSON 文件迁移到数据库 +""" +import asyncio +import json +import os +from collections import deque +from datetime import datetime + +from nonebot import logger + +from .config import Config, get_plugin_config +from .db_manager import DatabaseManager +from .models import ChatHistory, GroupChatState, PrivateChatState + +# 获取插件数据目录 +try: + import nonebot_plugin_localstore as store + data_dir = store.get_plugin_data_dir() + data_file = store.get_plugin_data_file("llmchat_state.json") + private_data_file = store.get_plugin_data_file("llmchat_private_state.json") +except ImportError: + logger.warning("无法找到 nonebot_plugin_localstore,迁移可能失败") + data_dir = None + data_file = None + private_data_file = None + + +async def migrate_from_json_to_db(plugin_config: Config): + """从 JSON 文件迁移到数据库""" + logger.info("开始从 JSON 文件迁移到数据库") + + if not data_file or not os.path.exists(data_file): + logger.info("未找到群组状态 JSON 文件,跳过迁移") + return + + try: + # 迁移群组状态 + logger.info(f"正在迁移群组状态数据: {data_file}") + with open(data_file, "r", encoding="utf8") as f: + data = json.load(f) + + migrated_groups = 0 + for gid_str, state_data in data.items(): + try: + gid = int(gid_str) + # 检查是否已存在 + existing = await GroupChatState.get_or_none(group_id=gid) + if existing: + logger.debug(f"群组 {gid} 已存在于数据库,跳过迁移") + continue + + # 创建新的状态记录 + await GroupChatState.create( + group_id=gid, + preset_name=state_data.get("preset", "off"), + group_prompt=state_data.get("group_prompt"), + output_reasoning_content=state_data.get("output_reasoning_content", False), + random_trigger_prob=state_data.get("random_trigger_prob", 0.05), + last_active=datetime.fromtimestamp(state_data.get("last_active", datetime.now().timestamp())), + ) + + # 创建历史记录 + messages = state_data.get("history", []) + if messages: + await ChatHistory.create( + group_id=gid, + is_private=False, + messages_json=ChatHistory.serialize_messages(messages), + ) + + migrated_groups += 1 + logger.debug(f"已迁移群组 {gid}({len(messages)} 条消息)") + + except Exception as e: + logger.error(f"迁移群组 {gid_str} 失败: {e}") + + logger.info(f"成功迁移 {migrated_groups} 个群组的状态") + + except Exception as e: + logger.error(f"迁移群组状态失败: {e}") + + # 迁移私聊状态 + if plugin_config.llmchat.enable_private_chat and private_data_file and os.path.exists(private_data_file): + try: + logger.info(f"正在迁移私聊状态数据: {private_data_file}") + with open(private_data_file, "r", encoding="utf8") as f: + private_data = json.load(f) + + migrated_users = 0 + for uid_str, state_data in private_data.items(): + try: + uid = int(uid_str) + # 检查是否已存在 + existing = await PrivateChatState.get_or_none(user_id=uid) + if existing: + logger.debug(f"用户 {uid} 已存在于数据库,跳过迁移") + continue + + # 创建新的状态记录 + await PrivateChatState.create( + user_id=uid, + preset_name=state_data.get("preset", "off"), + user_prompt=state_data.get("group_prompt"), # JSON 中存的是 group_prompt + output_reasoning_content=state_data.get("output_reasoning_content", False), + last_active=datetime.fromtimestamp(state_data.get("last_active", datetime.now().timestamp())), + ) + + # 创建历史记录 + messages = state_data.get("history", []) + if messages: + await ChatHistory.create( + user_id=uid, + is_private=True, + messages_json=ChatHistory.serialize_messages(messages), + ) + + migrated_users += 1 + logger.debug(f"已迁移用户 {uid}({len(messages)} 条消息)") + + except Exception as e: + logger.error(f"迁移用户 {uid_str} 失败: {e}") + + logger.info(f"成功迁移 {migrated_users} 个用户的私聊状态") + + except Exception as e: + logger.error(f"迁移私聊状态失败: {e}") + + logger.info("JSON 迁移完成") + + +async def backup_json_files(): + """备份旧的 JSON 文件""" + if not data_file: + return + + if os.path.exists(data_file): + backup_file = f"{data_file}.backup" + try: + os.rename(data_file, backup_file) + logger.info(f"已备份群组状态文件: {backup_file}") + except Exception as e: + logger.warning(f"备份文件失败: {e}") + + if private_data_file and os.path.exists(private_data_file): + backup_file = f"{private_data_file}.backup" + try: + os.rename(private_data_file, backup_file) + logger.info(f"已备份私聊状态文件: {backup_file}") + except Exception as e: + logger.warning(f"备份文件失败: {e}") diff --git a/nonebot_plugin_llmchat/models.py b/nonebot_plugin_llmchat/models.py new file mode 100644 index 0000000..51e2c9e --- /dev/null +++ b/nonebot_plugin_llmchat/models.py @@ -0,0 +1,102 @@ +""" +Tortoise ORM 模型定义 +用于存储聊天历史和群组/私聊状态 +""" +import json +from datetime import datetime +from typing import Optional + +from tortoise import fields +from tortoise.models import Model + + +class GroupChatState(Model): + """群组聊天状态""" + + id = fields.IntField(pk=True) + group_id = fields.BigIntField(unique=True, description="群号") + preset_name = fields.CharField(max_length=50, description="当前使用的 API 预设名") + group_prompt = fields.TextField(null=True, description="群组自定义提示词") + output_reasoning_content = fields.BooleanField(default=False, description="是否输出推理内容") + random_trigger_prob = fields.FloatField(default=0.05, description="随机触发概率") + last_active = fields.DatetimeField(auto_now=True, description="最后活跃时间") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + + class Meta: + table = "llmchat_group_state" + description = "群组聊天状态表" + + +class PrivateChatState(Model): + """私聊状态""" + + id = fields.IntField(pk=True) + user_id = fields.BigIntField(unique=True, description="用户 QQ") + preset_name = fields.CharField(max_length=50, description="当前使用的 API 预设名") + user_prompt = fields.TextField(null=True, description="用户自定义提示词") + output_reasoning_content = fields.BooleanField(default=False, description="是否输出推理内容") + last_active = fields.DatetimeField(auto_now=True, description="最后活跃时间") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + + class Meta: + table = "llmchat_private_state" + description = "私聊状态表" + + +class ChatMessage(Model): + """聊天消息历史""" + + id = fields.IntField(pk=True) + group_id = fields.BigIntField(null=True, description="群号(私聊时为 NULL)") + user_id = fields.BigIntField(null=True, description="用户 QQ(私聊时有值)") + is_private = fields.BooleanField(default=False, description="是否为私聊") + role = fields.CharField( + max_length=20, + description="消息角色: user/assistant/system/tool", + ) + content = fields.TextField(description="消息内容(JSON 序列化)") + created_at = fields.DatetimeField(auto_now_add=True, description="消息时间") + + class Meta: + table = "llmchat_message" + description = "聊天消息历史表" + indexes = [ + ("group_id", "is_private", "created_at"), # 复合索引用于快速查询 + ("user_id", "is_private", "created_at"), + ] + + @staticmethod + def serialize_content(content) -> str: + """将内容序列化为 JSON 字符串""" + return json.dumps(content, ensure_ascii=False) + + @staticmethod + def deserialize_content(content_str: str): + """从 JSON 字符串反序列化内容""" + return json.loads(content_str) + + +class ChatHistory(Model): + """聊天历史快照(用于快速加载)""" + + id = fields.IntField(pk=True) + group_id = fields.BigIntField(null=True, unique=True, description="群号(私聊时为 NULL)") + user_id = fields.BigIntField(null=True, unique=True, description="用户 QQ(私聊时有值)") + is_private = fields.BooleanField(default=False, description="是否为私聊") + # 存储最近 history_size*2 条消息的 JSON 数组 + messages_json = fields.TextField(description="消息历史(JSON 数组)") + last_update = fields.DatetimeField(auto_now=True, description="最后更新时间") + + class Meta: + table = "llmchat_history" + description = "聊天历史快照表(用于快速加载)" + + @staticmethod + def serialize_messages(messages_list) -> str: + """将消息列表序列化为 JSON 字符串""" + return json.dumps(messages_list, ensure_ascii=False) + + @staticmethod + def deserialize_messages(messages_json: str): + """从 JSON 字符串反序列化消息列表""" + return json.loads(messages_json) From 9b0e2449a06db5a8b3759be808040f5c989f0d65 Mon Sep 17 00:00:00 2001 From: FuQuan <87348379+FuQuan233@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:56:34 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20=E5=90=88=E5=B9=B6=E7=A7=81?= =?UTF-8?q?=E8=81=8A=E6=8C=87=E4=BB=A4=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=9D=83=E9=99=90=E5=92=8C=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot_plugin_llmchat/__init__.py | 240 +++++++++-------------------- 1 file changed, 77 insertions(+), 163 deletions(-) diff --git a/nonebot_plugin_llmchat/__init__.py b/nonebot_plugin_llmchat/__init__.py index 1e0afb2..d3c7aa7 100755 --- a/nonebot_plugin_llmchat/__init__.py +++ b/nonebot_plugin_llmchat/__init__.py @@ -22,7 +22,7 @@ from nonebot import ( require, ) from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegment, PrivateMessageEvent -from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER +from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER, PRIVATE from nonebot.params import CommandArg from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata @@ -382,13 +382,21 @@ async def process_messages(context_id: int, is_group: bool = True): "- 不要使用markdown或者html,聊天软件不支持解析,换行请用换行符。", "- 你应该以普通人的方式发送消息,每条消息字数要尽量少一些,但是不建议超过两条。", "- 代码则不需要分段,用单独的一条消息发送。", - "- 请使用发送者的昵称称呼发送者,你可以礼貌地问候发送者,但只需要在第一次回答这位发送者的问题时问候他。", - "- 你有at群成员的能力,只需要在某条消息中插入[CQ:at,qq=(QQ号)]," - "也就是CQ码。at发送者是非必要的,如果不是必要,请不要at别人。", + "- 请使用发送者的昵称称呼发送者,你可以礼貌地问候发送者,但只需要在" + "第一次回答这位发送者的问题时问候他。", "- 你有引用某条消息的能力,使用[CQ:reply,id=(消息id)]来引用。", "- 如果有多条消息,你应该优先回复提到你的,一段时间之前的就不要回复了,也可以直接选择不回复。", "- 如果你选择完全不回复,你只需要直接输出一个。", "- 如果你需要思考的话,你应该尽量少思考,以节省时间。", + ] + + if is_group: + system_lines += [ + "- 你有at群成员的能力,只需要在某条消息中插入[CQ:at,qq=(QQ号)]," + "也就是CQ码。at发送者是非必要的,你可以根据你自己的想法at某个人。", + ] + + system_lines += [ "下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。", default_prompt, ] @@ -445,7 +453,7 @@ async def process_messages(context_id: int, is_group: bool = True): } if preset.support_mcp: - available_tools = await mcp_client.get_available_tools() + available_tools = await mcp_client.get_available_tools(is_group) client_config["tools"] = available_tools response = await client.chat.completions.create( @@ -477,27 +485,12 @@ async def process_messages(context_id: int, is_group: bool = True): # 发送工具调用提示 await handler.send(Message(f"正在使用{mcp_client.get_friendly_name(tool_name)}")) - # 执行工具调用,传递群组和机器人信息用于QQ工具 - if is_group: - result = await mcp_client.call_tool( - tool_name, - tool_args, - group_id=event.group_id, - bot_id=str(event.self_id), - user_id=event.user_id - ) - else: - # 私聊时某些工具不可用(如群操作工具),跳过这些工具 - if tool_name.startswith("ob__"): - result = f"私聊不支持{mcp_client.get_friendly_name(tool_name)}工具" - else: - result = await mcp_client.call_tool( - tool_name, - tool_args, - group_id=None, - bot_id=str(event.self_id), - user_id=event.user_id - ) + result = await mcp_client.call_tool( + tool_name, + tool_args, + group_id=event.group_id, + bot_id=str(event.self_id) + ) new_messages.append({ "role": "tool", @@ -584,22 +577,33 @@ preset_handler = on_command("API预设", priority=1, block=True, permission=SUPE @preset_handler.handle() -async def handle_preset(event: GroupMessageEvent, args: Message = CommandArg()): - group_id = event.group_id +async def handle_preset(event: GroupMessageEvent | PrivateMessageEvent, args: Message = CommandArg()): + if isinstance(event, GroupMessageEvent): + context_id = event.group_id + state = group_states[context_id] + else: # PrivateMessageEvent + if not plugin_config.enable_private_chat: + return + context_id = event.user_id + state = private_chat_states[context_id] + preset_name = args.extract_plain_text().strip() if preset_name == "off": - group_states[group_id].preset_name = preset_name - await preset_handler.finish("已关闭llmchat") + state.preset_name = preset_name + if isinstance(event, GroupMessageEvent): + await preset_handler.finish("已关闭llmchat群聊功能") + else: + await preset_handler.finish("已关闭llmchat私聊功能") available_presets = {p.name for p in plugin_config.api_presets} if preset_name not in available_presets: available_presets_str = "\n- ".join(available_presets) await preset_handler.finish( - f"当前API预设:{group_states[group_id].preset_name}\n可用API预设:\n- {available_presets_str}" + f"当前API预设:{state.preset_name}\n可用API预设:\n- {available_presets_str}" ) - group_states[group_id].preset_name = preset_name + state.preset_name = preset_name await preset_handler.finish(f"已切换至API预设:{preset_name}") @@ -607,16 +611,23 @@ edit_preset_handler = on_command( "修改设定", priority=1, block=True, - permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), + permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER | PRIVATE), ) @edit_preset_handler.handle() -async def handle_edit_preset(event: GroupMessageEvent, args: Message = CommandArg()): - group_id = event.group_id - group_prompt = args.extract_plain_text().strip() +async def handle_edit_preset(event: GroupMessageEvent | PrivateMessageEvent, args: Message = CommandArg()): + if isinstance(event, GroupMessageEvent): + context_id = event.group_id + state = group_states[context_id] + else: # PrivateMessageEvent + if not plugin_config.enable_private_chat: + return + context_id = event.user_id + state = private_chat_states[context_id] - group_states[group_id].group_prompt = group_prompt + group_prompt = args.extract_plain_text().strip() + state.group_prompt = group_prompt await edit_preset_handler.finish("修改成功") @@ -624,21 +635,23 @@ reset_handler = on_command( "记忆清除", priority=1, block=True, - permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), + permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER | PRIVATE), ) @reset_handler.handle() -async def handle_reset(event: GroupMessageEvent, args: Message = CommandArg()): - group_id = event.group_id +async def handle_reset(event: GroupMessageEvent | PrivateMessageEvent, args: Message = CommandArg()): + if isinstance(event, GroupMessageEvent): + context_id = event.group_id + state = group_states[context_id] + else: # PrivateMessageEvent + if not plugin_config.enable_private_chat: + return + context_id = event.user_id + state = private_chat_states[context_id] - # 清空内存状态 - group_states[group_id].past_events.clear() - group_states[group_id].history.clear() - - # 清空数据库记录 - await DatabaseManager.clear_group_history(group_id) - + state.past_events.clear() + state.history.clear() await reset_handler.finish("记忆已清空") @@ -652,32 +665,38 @@ set_prob_handler = on_command( @set_prob_handler.handle() async def handle_set_prob(event: GroupMessageEvent, args: Message = CommandArg()): - group_id = event.group_id - prob = 0 + context_id = event.group_id + state = group_states[context_id] try: prob = float(args.extract_plain_text().strip()) if prob < 0 or prob > 1: - raise ValueError - except Exception as e: - await reset_handler.finish(f"输入有误,请使用 [0,1] 的浮点数\n{e!s}") + raise ValueError("概率值必须在0-1之间") + except ValueError as e: + await set_prob_handler.finish(f"输入有误,请使用 [0,1] 的浮点数\n{e!s}") - group_states[group_id].random_trigger_prob = prob - await reset_handler.finish(f"主动回复概率已设为 {prob}") + state.random_trigger_prob = prob + await set_prob_handler.finish(f"主动回复概率已设为 {prob}") -# 预设切换命令 +# 思维输出切换命令 think_handler = on_command( "切换思维输出", priority=1, block=True, - permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER), + permission=(SUPERUSER | GROUP_ADMIN | GROUP_OWNER | PRIVATE), ) @think_handler.handle() -async def handle_think(event: GroupMessageEvent, args: Message = CommandArg()): - state = group_states[event.group_id] +async def handle_think(event: GroupMessageEvent | PrivateMessageEvent, args: Message = CommandArg()): + if isinstance(event, GroupMessageEvent): + state = group_states[event.group_id] + else: # PrivateMessageEvent + if not plugin_config.enable_private_chat: + return + state = private_chat_states[event.user_id] + state.output_reasoning_content = not state.output_reasoning_content await think_handler.finish( @@ -685,111 +704,6 @@ async def handle_think(event: GroupMessageEvent, args: Message = CommandArg()): ) -# region 私聊相关指令 - -# 私聊预设切换命令 -private_preset_handler = on_command( - "私聊API预设", - priority=1, - block=True, - permission=SUPERUSER, -) - - -@private_preset_handler.handle() -async def handle_private_preset(event: PrivateMessageEvent, args: Message = CommandArg()): - if not plugin_config.enable_private_chat: - await private_preset_handler.finish("私聊功能未启用") - - user_id = event.user_id - preset_name = args.extract_plain_text().strip() - - if preset_name == "off": - private_chat_states[user_id].preset_name = preset_name - await private_preset_handler.finish("已关闭llmchat私聊功能") - - available_presets = {p.name for p in plugin_config.api_presets} - if preset_name not in available_presets: - available_presets_str = "\n- ".join(available_presets) - await private_preset_handler.finish( - f"当前API预设:{private_chat_states[user_id].preset_name}\n可用API预设:\n- {available_presets_str}" - ) - - private_chat_states[user_id].preset_name = preset_name - await private_preset_handler.finish(f"已切换至API预设:{preset_name}") - - -# 私聊设定修改命令 -private_edit_preset_handler = on_command( - "私聊修改设定", - priority=1, - block=True, - permission=SUPERUSER, -) - - -@private_edit_preset_handler.handle() -async def handle_private_edit_preset(event: PrivateMessageEvent, args: Message = CommandArg()): - if not plugin_config.enable_private_chat: - await private_edit_preset_handler.finish("私聊功能未启用") - - user_id = event.user_id - user_prompt = args.extract_plain_text().strip() - - private_chat_states[user_id].group_prompt = user_prompt - await private_edit_preset_handler.finish("修改成功") - - -# 私聊记忆清除命令 -private_reset_handler = on_command( - "私聊记忆清除", - priority=1, - block=True, - permission=SUPERUSER, -) - - -@private_reset_handler.handle() -async def handle_private_reset(event: PrivateMessageEvent, args: Message = CommandArg()): - if not plugin_config.enable_private_chat: - await private_reset_handler.finish("私聊功能未启用") - - user_id = event.user_id - - # 清空内存状态 - private_chat_states[user_id].past_events.clear() - private_chat_states[user_id].history.clear() - - # 清空数据库记录 - await DatabaseManager.clear_private_history(user_id) - - await private_reset_handler.finish("记忆已清空") - - -# 私聊思维输出切换命令 -private_think_handler = on_command( - "私聊切换思维输出", - priority=1, - block=True, - permission=SUPERUSER, -) - - -@private_think_handler.handle() -async def handle_private_think(event: PrivateMessageEvent, args: Message = CommandArg()): - if not plugin_config.enable_private_chat: - await private_think_handler.finish("私聊功能未启用") - - state = private_chat_states[event.user_id] - state.output_reasoning_content = not state.output_reasoning_content - - await private_think_handler.finish( - f"已{(state.output_reasoning_content and '开启') or '关闭'}思维输出" - ) - -# endregion - - # region 持久化与定时任务 From 93878883478a0b3e7fa8a7ef7bb05e8c7e09b86d Mon Sep 17 00:00:00 2001 From: FuQuan <87348379+FuQuan233@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:58:16 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20=E5=90=88=E5=B9=B6=E7=A7=81?= =?UTF-8?q?=E8=81=8A=E6=8C=87=E4=BB=A4=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=9D=83=E9=99=90=E5=92=8C=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot_plugin_llmchat/mcpclient.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nonebot_plugin_llmchat/mcpclient.py b/nonebot_plugin_llmchat/mcpclient.py index ee284e9..1b28d80 100644 --- a/nonebot_plugin_llmchat/mcpclient.py +++ b/nonebot_plugin_llmchat/mcpclient.py @@ -108,7 +108,7 @@ class MCPClient: return SessionContext() - async def get_available_tools(self): + async def get_available_tools(self, is_group: bool): """获取可用工具列表,使用缓存机制""" if self._tools_cache is not None: logger.debug("返回缓存的工具列表") @@ -117,10 +117,11 @@ class MCPClient: logger.info(f"初始化工具列表缓存,需要连接{len(self.server_config)}个服务器") available_tools = [] - # 添加OneBot内置工具 - onebot_tools = self.onebot_tools.get_available_tools() - available_tools.extend(onebot_tools) - logger.debug(f"添加了{len(onebot_tools)}个OneBot内置工具") + if is_group: + # 添加OneBot内置工具,仅在群聊中可用 + onebot_tools = self.onebot_tools.get_available_tools() + available_tools.extend(onebot_tools) + logger.debug(f"添加了{len(onebot_tools)}个OneBot内置工具") # 添加自定义工具(可选) # if hasattr(self, 'custom_tools'): From d12d63a906b70fa89a2ba608c5d3bbf2c3412d78 Mon Sep 17 00:00:00 2001 From: FuQuan <87348379+FuQuan233@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:13:53 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20fix=20lint=20problems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot_plugin_llmchat/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nonebot_plugin_llmchat/__init__.py b/nonebot_plugin_llmchat/__init__.py index d3c7aa7..624da69 100755 --- a/nonebot_plugin_llmchat/__init__.py +++ b/nonebot_plugin_llmchat/__init__.py @@ -586,7 +586,7 @@ async def handle_preset(event: GroupMessageEvent | PrivateMessageEvent, args: Me return context_id = event.user_id state = private_chat_states[context_id] - + preset_name = args.extract_plain_text().strip() if preset_name == "off": @@ -674,6 +674,7 @@ async def handle_set_prob(event: GroupMessageEvent, args: Message = CommandArg() raise ValueError("概率值必须在0-1之间") except ValueError as e: await set_prob_handler.finish(f"输入有误,请使用 [0,1] 的浮点数\n{e!s}") + return state.random_trigger_prob = prob await set_prob_handler.finish(f"主动回复概率已设为 {prob}") From 4ab2faef9351a891bfa40f15adf9aaffe43af19e Mon Sep 17 00:00:00 2001 From: FuQuan <87348379+FuQuan233@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:23:04 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=93=98=20=E6=9B=B4=E6=96=B0=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 6b0fb10..33f2c76 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,8 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的 NICKNAME=["谢拉","Cierra","cierra"] LLMCHAT__HISTORY_SIZE=20 LLMCHAT__DEFAULT_PROMPT="前面忘了,你是一个猫娘,后面忘了" + LLMCHAT__ENABLE_PRIVATE_CHAT=true + LLMCHAT__PRIVATE_CHAT_PRESET="deepseek-v1" LLMCHAT__API_PRESETS=' [ { @@ -261,9 +263,9 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的 ## 🎉 使用 -**如果`LLMCHAT__DEFAULT_PRESET`没有配置,则插件默认为关闭状态,请使用`API预设+[预设名]`开启插件** +**如果`LLMCHAT__DEFAULT_PRESET`没有配置,则插件默认为关闭状态,请使用`API预设+[预设名]`开启插件, 私聊同理。** -配置完成后@机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率或群聊中使用指令设置的概率随机自动触发回复。 +配置完成后在群聊中@机器人或私聊机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率或群聊中使用指令设置的概率随机自动触发回复。 ### MCP 工具权限控制 @@ -308,33 +310,10 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的 | 指令 | 权限 | 参数 | 说明 | |:-----:|:----:|:----:|:----:| -| 私聊API预设 | 主人 | [预设名] | 查看或修改私聊使用的API预设 | -| 私聊修改设定 | 主人 | 设定 | 修改私聊机器人的设定 | -| 私聊记忆清除 | 主人 | 无 | 清除私聊的机器人记忆 | -| 私聊切换思维输出 | 主人 | 无 | 切换是否输出私聊AI的思维过程的开关(需模型支持) | - -**私聊功能说明:** - -- 私聊消息默认触发回复(无需@或随机触发) -- 私聊和群聊的对话记忆独立管理 -- OneBot群操作工具(如禁言、撤回等)在私聊中不可用 - -## 📝 私聊功能启用示例 - -在 `.env` 文件中添加以下配置以启用私聊功能: - -```bash -LLMCHAT__ENABLE_PRIVATE_CHAT=true -LLMCHAT__PRIVATE_CHAT_PRESET="deepseek-v1" -``` - -然后你可以在私聊中与机器人交互。使用以下命令管理私聊: - -- 切换预设:`私聊API预设 aliyun-deepseek-v3` -- 清除记忆:`私聊记忆清除` -- 修改设定:`私聊修改设定 你是一个有趣的AI助手` -| 切换思维输出 | 管理 | 否 | 群聊 | 无 | 切换是否输出AI的思维过程的开关(需模型支持) | -| 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数,0为完全关闭主动回复 | +| API预设 | 主人 | [预设名] | 查看或修改私聊使用的API预设 | +| 修改设定 | 所有人 | 设定 | 修改私聊机器人的设定 | +| 记忆清除 | 所有人 | 无 | 清除私聊的机器人记忆 | +| 切换思维输出 | 所有人 | 无 | 切换是否输出私聊AI的思维过程的开关(需模型支持) | ### 效果图 ![](img/mcp_demo.jpg) From 153e278faca94db46d76cc1df80f6d378c359faf Mon Sep 17 00:00:00 2001 From: KawakazeNotFound Date: Fri, 7 Nov 2025 16:00:57 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E6=94=B9=E7=94=A8ORM?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot_plugin_llmchat/__init__.py | 24 ++++++------------------ nonebot_plugin_llmchat/db_manager.py | 9 ++++----- nonebot_plugin_llmchat/models.py | 18 ++++++++---------- 3 files changed, 18 insertions(+), 33 deletions(-) diff --git a/nonebot_plugin_llmchat/__init__.py b/nonebot_plugin_llmchat/__init__.py index 624da69..aa39a7c 100755 --- a/nonebot_plugin_llmchat/__init__.py +++ b/nonebot_plugin_llmchat/__init__.py @@ -39,7 +39,10 @@ require("nonebot_plugin_apscheduler") from nonebot_plugin_apscheduler import scheduler require("nonebot_plugin_tortoise_orm") -from nonebot_plugin_tortoise_orm import init_orm_plugin +# 必须在 require 之后导入模型,才能正确注册到 Tortoise ORM +from . import models # noqa: F401 + +require("nonebot_plugin_tortoise_orm") if TYPE_CHECKING: from openai.types.chat import ( @@ -49,7 +52,6 @@ if TYPE_CHECKING: from .db_manager import DatabaseManager from .models import ChatHistory, ChatMessage, GroupChatState, PrivateChatState -from .migration import backup_json_files, migrate_from_json_to_db __plugin_meta__ = PluginMetadata( name="llmchat", @@ -644,11 +646,13 @@ async def handle_reset(event: GroupMessageEvent | PrivateMessageEvent, args: Mes if isinstance(event, GroupMessageEvent): context_id = event.group_id state = group_states[context_id] + await DatabaseManager.clear_group_history(context_id) else: # PrivateMessageEvent if not plugin_config.enable_private_chat: return context_id = event.user_id state = private_chat_states[context_id] + await DatabaseManager.clear_private_history(context_id) state.past_events.clear() state.history.clear() @@ -774,25 +778,9 @@ async def load_state(): @driver.on_startup async def init_plugin(): logger.info("插件启动初始化") - # 初始化 Tortoise ORM 的模型 - await init_orm_plugin() - # 创建表(如果不存在) - try: - # Tortoise ORM 会自动创建表,这里只是尝试检查 - logger.info("数据库表初始化中...") - except Exception as e: - logger.warning(f"初始化数据库时出现警告: {e}") - - # 执行迁移(从 JSON 到数据库) - try: - await migrate_from_json_to_db(plugin_config) - except Exception as e: - logger.warning(f"执行迁移时出现错误: {e}") - await load_state() # 每5分钟保存状态 scheduler.add_job(save_state, "interval", minutes=5) - logger.info("插件初始化完成") diff --git a/nonebot_plugin_llmchat/db_manager.py b/nonebot_plugin_llmchat/db_manager.py index af042b7..f2411e0 100644 --- a/nonebot_plugin_llmchat/db_manager.py +++ b/nonebot_plugin_llmchat/db_manager.py @@ -8,7 +8,6 @@ from datetime import datetime from typing import Optional from nonebot import logger -from tortoise.exceptions import DoesNotExist from .models import ChatHistory, ChatMessage, GroupChatState, PrivateChatState @@ -25,7 +24,7 @@ class DatabaseManager: output_reasoning_content: bool, random_trigger_prob: float, ): - """保存群组状态和历史到数据库""" + """保存群组状态""" try: # 保存或更新群组状态 state, _ = await GroupChatState.get_or_create( @@ -72,7 +71,7 @@ class DatabaseManager: user_prompt: Optional[str], output_reasoning_content: bool, ): - """保存私聊状态和历史到数据库""" + """保存私聊状态""" try: # 保存或更新私聊状态 state, _ = await PrivateChatState.get_or_create( @@ -110,7 +109,7 @@ class DatabaseManager: logger.error(f"保存私聊状态失败 用户: {user_id}, 错误: {e}") @staticmethod - async def load_group_state(group_id: int, history_maxlen: int) -> dict: + async def load_group_state(group_id: int, history_maxlen: int) -> Optional[dict]: """从数据库加载群组状态""" try: state = await GroupChatState.get_or_none(group_id=group_id) @@ -145,7 +144,7 @@ class DatabaseManager: return None @staticmethod - async def load_private_state(user_id: int, history_maxlen: int) -> dict: + async def load_private_state(user_id: int, history_maxlen: int) -> Optional[dict]: """从数据库加载私聊状态""" try: state = await PrivateChatState.get_or_none(user_id=user_id) diff --git a/nonebot_plugin_llmchat/models.py b/nonebot_plugin_llmchat/models.py index 51e2c9e..d6b17dd 100644 --- a/nonebot_plugin_llmchat/models.py +++ b/nonebot_plugin_llmchat/models.py @@ -3,12 +3,14 @@ Tortoise ORM 模型定义 用于存储聊天历史和群组/私聊状态 """ import json -from datetime import datetime -from typing import Optional +from nonebot_plugin_tortoise_orm import add_model from tortoise import fields from tortoise.models import Model +# 注册模型到 Tortoise ORM +add_model(__name__) + class GroupChatState(Model): """群组聊天状态""" @@ -24,7 +26,7 @@ class GroupChatState(Model): class Meta: table = "llmchat_group_state" - description = "群组聊天状态表" + table_description = "群组聊天状态表" class PrivateChatState(Model): @@ -40,7 +42,7 @@ class PrivateChatState(Model): class Meta: table = "llmchat_private_state" - description = "私聊状态表" + table_description = "私聊状态表" class ChatMessage(Model): @@ -59,11 +61,7 @@ class ChatMessage(Model): class Meta: table = "llmchat_message" - description = "聊天消息历史表" - indexes = [ - ("group_id", "is_private", "created_at"), # 复合索引用于快速查询 - ("user_id", "is_private", "created_at"), - ] + table_description = "聊天消息历史表" @staticmethod def serialize_content(content) -> str: @@ -89,7 +87,7 @@ class ChatHistory(Model): class Meta: table = "llmchat_history" - description = "聊天历史快照表(用于快速加载)" + table_description = "聊天历史快照表(用于快速加载)" @staticmethod def serialize_messages(messages_list) -> str: From 57e1b8acbb18375552fe62f3d8f7ee72aceb1029 Mon Sep 17 00:00:00 2001 From: KawakazeNotFound Date: Fri, 7 Nov 2025 16:03:43 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E8=AE=A1=E6=97=B6=E5=99=A8=EF=BC=8CORM=E7=8E=B0=E4=B8=BA?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot_plugin_llmchat/__init__.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/nonebot_plugin_llmchat/__init__.py b/nonebot_plugin_llmchat/__init__.py index aa39a7c..4ea4e5a 100755 --- a/nonebot_plugin_llmchat/__init__.py +++ b/nonebot_plugin_llmchat/__init__.py @@ -570,6 +570,28 @@ async def process_messages(context_id: int, is_group: bool = True): finally: state.processing = False state.queue.task_done() + + # 实时保存状态到数据库 + try: + if is_group: + await DatabaseManager.save_group_state( + group_id=context_id, + preset_name=state.preset_name, + history=state.history, + group_prompt=state.group_prompt, + output_reasoning_content=state.output_reasoning_content, + random_trigger_prob=state.random_trigger_prob, + ) + else: + await DatabaseManager.save_private_state( + user_id=context_id, + preset_name=state.preset_name, + history=state.history, + user_prompt=state.group_prompt, + output_reasoning_content=state.output_reasoning_content, + ) + except Exception as e: + logger.error(f"实时保存状态失败 {'群号' if is_group else '用户'}:{context_id}, 错误:{e}") # 不再需要每次都清理MCPClient,因为它现在是单例 # await mcp_client.cleanup() @@ -779,9 +801,9 @@ async def load_state(): async def init_plugin(): logger.info("插件启动初始化") await load_state() - # 每5分钟保存状态 - scheduler.add_job(save_state, "interval", minutes=5) - logger.info("插件初始化完成") + # 每30分钟保存一次状态作为备份(已开启实时保存) + scheduler.add_job(save_state, "interval", minutes=30) + logger.info("插件初始化完成(已启用实时保存功能)") @driver.on_shutdown From 83f4803183be452ab7e2e883a96c44050fbde840 Mon Sep 17 00:00:00 2001 From: KawakazeNotFound Date: Fri, 7 Nov 2025 16:13:00 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E2=9C=A8=20=E6=96=B0=E5=A2=9Ejson=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=BF=81=E7=A7=BB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot_plugin_llmchat/__init__.py | 4 ++++ nonebot_plugin_llmchat/migration.py | 36 ++++++++++++++++++----------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/nonebot_plugin_llmchat/__init__.py b/nonebot_plugin_llmchat/__init__.py index 4ea4e5a..d8b3393 100755 --- a/nonebot_plugin_llmchat/__init__.py +++ b/nonebot_plugin_llmchat/__init__.py @@ -52,6 +52,7 @@ if TYPE_CHECKING: from .db_manager import DatabaseManager from .models import ChatHistory, ChatMessage, GroupChatState, PrivateChatState +from .migration import migrate_from_json_to_db __plugin_meta__ = PluginMetadata( name="llmchat", @@ -800,6 +801,9 @@ async def load_state(): @driver.on_startup async def init_plugin(): logger.info("插件启动初始化") + # 首先进行数据迁移(如果存在 JSON 文件) + logger.info("检查是否需要进行 JSON 数据迁移...") + await migrate_from_json_to_db(Config(llmchat=plugin_config)) await load_state() # 每30分钟保存一次状态作为备份(已开启实时保存) scheduler.add_job(save_state, "interval", minutes=30) diff --git a/nonebot_plugin_llmchat/migration.py b/nonebot_plugin_llmchat/migration.py index 507d0ca..023cdaa 100644 --- a/nonebot_plugin_llmchat/migration.py +++ b/nonebot_plugin_llmchat/migration.py @@ -10,7 +10,7 @@ from datetime import datetime from nonebot import logger -from .config import Config, get_plugin_config +from .config import Config from .db_manager import DatabaseManager from .models import ChatHistory, GroupChatState, PrivateChatState @@ -35,6 +35,9 @@ async def migrate_from_json_to_db(plugin_config: Config): logger.info("未找到群组状态 JSON 文件,跳过迁移") return + total_migrated_groups = 0 + total_migrated_users = 0 + try: # 迁移群组状态 logger.info(f"正在迁移群组状态数据: {data_file}") @@ -77,7 +80,8 @@ async def migrate_from_json_to_db(plugin_config: Config): logger.error(f"迁移群组 {gid_str} 失败: {e}") logger.info(f"成功迁移 {migrated_groups} 个群组的状态") - + total_migrated_groups = migrated_groups + except Exception as e: logger.error(f"迁移群组状态失败: {e}") @@ -123,30 +127,36 @@ async def migrate_from_json_to_db(plugin_config: Config): logger.error(f"迁移用户 {uid_str} 失败: {e}") logger.info(f"成功迁移 {migrated_users} 个用户的私聊状态") + total_migrated_users = migrated_users except Exception as e: logger.error(f"迁移私聊状态失败: {e}") - logger.info("JSON 迁移完成") + # 迁移成功后,重命名 JSON 文件为 .migrated + if total_migrated_groups > 0 or total_migrated_users > 0: + logger.info("迁移成功,开始重命名 JSON 文件...") + rename_json_files_to_migrated() + + logger.info(f"JSON 迁移完成(群组: {total_migrated_groups},用户: {total_migrated_users})") -async def backup_json_files(): - """备份旧的 JSON 文件""" +def rename_json_files_to_migrated(): + """将已迁移的 JSON 文件重命名为 .migrated""" if not data_file: return if os.path.exists(data_file): - backup_file = f"{data_file}.backup" + migrated_file = f"{data_file}.migrated" try: - os.rename(data_file, backup_file) - logger.info(f"已备份群组状态文件: {backup_file}") + os.rename(data_file, migrated_file) + logger.info(f"已将群组状态文件重命名为: {migrated_file}") except Exception as e: - logger.warning(f"备份文件失败: {e}") + logger.warning(f"重命名文件失败: {e}") if private_data_file and os.path.exists(private_data_file): - backup_file = f"{private_data_file}.backup" + migrated_file = f"{private_data_file}.migrated" try: - os.rename(private_data_file, backup_file) - logger.info(f"已备份私聊状态文件: {backup_file}") + os.rename(private_data_file, migrated_file) + logger.info(f"已将私聊状态文件重命名为: {migrated_file}") except Exception as e: - logger.warning(f"备份文件失败: {e}") + logger.warning(f"重命名文件失败: {e}")