From 36a47fa5e20ca5a8e7e08f69a7f4f797a8a7ebf9 Mon Sep 17 00:00:00 2001 From: XokoukioX <3282076201@qq.com> Date: Thu, 6 Nov 2025 00:31:17 +0800 Subject: [PATCH] =?UTF-8?q?WIP::=E7=A7=81=E8=81=8A=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PRIVATE_CHAT_CHANGES.md | 183 ++++++++++++++++ README.md | 46 +++- nonebot_plugin_llmchat/__init__.py | 336 ++++++++++++++++++++++++----- nonebot_plugin_llmchat/config.py | 2 + 4 files changed, 510 insertions(+), 57 deletions(-) create mode 100644 PRIVATE_CHAT_CHANGES.md diff --git a/PRIVATE_CHAT_CHANGES.md b/PRIVATE_CHAT_CHANGES.md new file mode 100644 index 0000000..701ed86 --- /dev/null +++ b/PRIVATE_CHAT_CHANGES.md @@ -0,0 +1,183 @@ +# 私聊功能实现总结 + +## 📝 概览 + +已成功为 nonebot-plugin-llmchat 项目添加了完整的私聊功能支持。用户现在可以在私聊中与机器人进行对话,同时保持群聊功能完全不变。 + +--- + +## 🔧 主要改动 + +### 1. **config.py** - 配置模块 + +#### 新增配置项: +- `LLMCHAT__ENABLE_PRIVATE_CHAT` (bool, 默认值: False) + - 是否启用私聊功能 + +- `LLMCHAT__PRIVATE_CHAT_PRESET` (str, 默认值: "off") + - 私聊默认使用的预设名称 + +### 2. **__init__.py** - 主程序模块 + +#### 新增导入: +```python +from typing import Union +from nonebot.adapters.onebot.v11 import PrivateMessageEvent +``` + +#### 新增数据结构: + +**PrivateChatState 类** +- 用于管理每个用户的私聊状态 +- 结构与 GroupState 类似,但针对单个用户独立管理 +- 包含:preset_name、history、queue、processing 等属性 + +**private_chat_states 字典** +- 类型:`dict[int, PrivateChatState]` +- 按用户ID存储私聊状态 + +#### 修改的函数: + +1. **format_message()** + - 参数改为:`event: Union[GroupMessageEvent, PrivateMessageEvent]` + - 支持两种消息事件类型的格式化 + +2. **is_triggered()** + - 参数改为:`event: Union[GroupMessageEvent, PrivateMessageEvent]` + - 新增私聊事件检测逻辑 + - 私聊消息在启用且预设不为"off"时自动触发 + +3. **get_preset()** + - 新增参数:`is_group: bool = True` + - 支持从群组或私聊状态获取预设配置 + +4. **process_messages()** + - 新增参数:`context_id: int, is_group: bool = True` + - 支持处理群组和私聊消息 + - 私聊时跳过OneBot群操作工具(ob__开头的工具) + +5. **handle_message()** + - 参数改为:`event: Union[GroupMessageEvent, PrivateMessageEvent]` + - 支持路由到不同的处理逻辑 + +6. **save_state()** / **load_state()** + - 新增私聊状态的持久化 + - 私聊状态保存到单独的文件:`llmchat_private_state.json` + +#### 新增命令处理器(私聊相关): + +所有私聊命令需要主人权限,且仅在启用私聊功能时可用: + +1. **私聊API预设** + - 查看或修改私聊使用的API预设 + - 用法:`私聊API预设 [预设名]` + +2. **私聊修改设定** + - 修改私聊机器人的性格设定 + - 用法:`私聊修改设定 [新设定]` + +3. **私聊记忆清除** + - 清除私聊的对话历史记录 + - 用法:`私聊记忆清除` + +4. **私聊切换思维输出** + - 切换是否输出AI的思维过程 + - 用法:`私聊切换思维输出` + +### 3. **README.md** - 文档更新 + +#### 更新的章节: + +1. **项目介绍** + - 更新标题为"群聊&私聊的AI对话插件" + - 添加"群聊和私聊支持"功能说明 + +2. **配置表格** + - 添加两个新配置项的说明 + +3. **使用指南** + - 将原"指令表"改名为"群聊指令表" + - 新增"私聊指令表" + - 添加"私聊功能启用示例"部分 + +--- + +## 🚀 使用指南 + +### 启用私聊功能 + +在 `.env` 文件中添加: + +```bash +# 启用私聊功能 +LLMCHAT__ENABLE_PRIVATE_CHAT=true + +# 设置私聊默认预设 +LLMCHAT__PRIVATE_CHAT_PRESET="deepseek-v1" +``` + +### 私聊命令示例 + +``` +# 主人私聊机器人 + +私聊API预设 # 查看当前预设 +私聊API预设 aliyun-deepseek-v3 # 切换预设 + +私聊修改设定 你是一个有趣的AI # 修改性格设定 + +私聊记忆清除 # 清除对话记忆 + +私聊切换思维输出 # 开关思维过程输出 +``` + +--- + +## 🔑 关键特性 + +✅ **独立管理** - 群聊和私聊拥有完全独立的对话记忆和配置 + +✅ **灵活控制** - 可单独启用/禁用私聊功能,无需影响群聊 + +✅ **自动触发** - 私聊消息自动触发回复,无需@机器人 + +✅ **权限隔离** - 私聊命令仅主人可用 + +✅ **工具适配** - 私聊时自动跳过不适用的群操作工具 + +✅ **状态持久化** - 私聊状态独立保存和恢复 + +--- + +## 📊 文件对比 + +| 文件 | 变更类型 | 主要改动 | +|------|--------|--------| +| config.py | 修改 | 新增2个配置项 | +| __init__.py | 修改 | 新增私聊类、处理器、命令 | +| README.md | 修改 | 更新文档说明 | + +--- + +## ⚠️ 注意事项 + +1. **默认禁用** - 私聊功能默认为禁用状态,需要在配置文件中显式启用 + +2. **群操作工具** - OneBot群操作工具(禁言、撤回等)在私聊中不可用 + +3. **状态文件** - 私聊状态存储在 `llmchat_private_state.json` 文件中 + +4. **权限限制** - 所有私聊命令都需要主人权限 + +5. **独立预设** - 私聊和群聊可以使用不同的API预设 + +--- + +## ✨ 后续改进建议 + +- [ ] 支持多用户私聊会话管理面板 +- [ ] 添加私聊消息转发到管理员功能 +- [ ] 实现私聊速率限制 +- [ ] 添加私聊用户黑名单 +- [ ] 支持私聊消息加密存储 + diff --git a/README.md b/README.md index 9239110..4e645e1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # nonebot-plugin-llmchat -_✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型的AI群聊插件 ✨_ +_✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型、群聊&私聊的AI对话插件 ✨_ @@ -48,6 +48,12 @@ _✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型 - 支持处理回复消息 - 群聊消息顺序处理,防止消息错乱 +1. **群聊和私聊支持** + - 支持群聊场景(默认启用) + - 支持私聊场景(可选启用) + - 分别管理群聊和私聊的对话记忆 + - 灵活的权限配置 + 1. **分群聊上下文记忆管理** - 分群聊保留对话历史记录(可配置保留条数) - 自动合并未处理消息,降低API用量 @@ -120,6 +126,8 @@ _✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型 | LLMCHAT__BLACKLIST_USER_IDS | 否 | [] | 黑名单用户ID列表,机器人将不会处理黑名单用户的消息 | | LLMCHAT__IGNORE_PREFIXES | 否 | [] | 需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理 | | LLMCHAT__MCP_SERVERS | 否 | {} | MCP服务器配置,具体见下表 | +| LLMCHAT__ENABLE_PRIVATE_CHAT | 否 | False | 是否启用私聊功能 | +| LLMCHAT__PRIVATE_CHAT_PRESET | 否 | off | 私聊默认使用的预设名称 | ### 内置OneBot工具 @@ -241,7 +249,7 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的 配置完成后@机器人即可手动触发回复,另外在机器人收到群聊消息时会根据`LLMCHAT__RANDOM_TRIGGER_PROB`配置的概率或群聊中使用指令设置的概率随机自动触发回复。 -### 指令表 +### 群聊指令表 以下指令均仅对发送的群聊生效,不同群聊配置不互通。 @@ -253,6 +261,40 @@ LLMCHAT__MCP_SERVERS同样为一个dict,key为服务器名称,value配置的 | 切换思维输出 | 管理 | 否 | 群聊 | 无 | 切换是否输出AI的思维过程的开关(需模型支持) | | 设置主动回复概率 | 管理 | 否 | 群聊 | 主动回复概率 | 主动回复概率需为 [0, 1] 的浮点数,0为完全关闭主动回复 | +### 私聊指令表 + +以下指令仅在启用私聊功能(`LLMCHAT__ENABLE_PRIVATE_CHAT=true`)后可用,这些指令均只对发送者的私聊生效。 + +| 指令 | 权限 | 参数 | 说明 | +|:-----:|:----:|:----:|:----:| +| 私聊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为完全关闭主动回复 | + ### 效果图 ![](img/mcp_demo.jpg) ![](img/demo.png) diff --git a/nonebot_plugin_llmchat/__init__.py b/nonebot_plugin_llmchat/__init__.py index acfa403..caac2a8 100755 --- a/nonebot_plugin_llmchat/__init__.py +++ b/nonebot_plugin_llmchat/__init__.py @@ -8,7 +8,7 @@ import random import re import ssl import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union import aiofiles import httpx @@ -21,7 +21,7 @@ from nonebot import ( on_message, require, ) -from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegment +from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegment, PrivateMessageEvent from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER from nonebot.params import CommandArg from nonebot.permission import SUPERUSER @@ -86,16 +86,36 @@ class GroupState: self.last_active = time.time() self.past_events = deque(maxlen=plugin_config.past_events_size) self.group_prompt: str | None = None + self.user_prompt: str | None = None self.output_reasoning_content = False self.random_trigger_prob = plugin_config.random_trigger_prob +# 初始化私聊状态 +class PrivateChatState: + def __init__(self): + self.preset_name = plugin_config.private_chat_preset + self.history = deque(maxlen=plugin_config.history_size * 2) + self.queue = asyncio.Queue() + self.processing = False + self.last_active = time.time() + self.past_events = deque(maxlen=plugin_config.past_events_size) + self.group_prompt: str | None = None + self.user_prompt: str | None = None + self.output_reasoning_content = False + + group_states: dict[int, GroupState] = defaultdict(GroupState) +private_chat_states: dict[int, PrivateChatState] = defaultdict(PrivateChatState) # 获取当前预设配置 -def get_preset(group_id: int) -> PresetConfig: - state = group_states[group_id] +def get_preset(context_id: int, is_group: bool = True) -> PresetConfig: + if is_group: + state = group_states[context_id] + else: + state = private_chat_states[context_id] + for preset in plugin_config.api_presets: if preset.name == state.preset_name: return preset @@ -103,12 +123,12 @@ def get_preset(group_id: int) -> PresetConfig: # 消息格式转换 -def format_message(event: GroupMessageEvent) -> str: +def format_message(event: Union[GroupMessageEvent, PrivateMessageEvent]) -> str: text_message = "" - if event.reply is not None: + if isinstance(event, GroupMessageEvent) and event.reply is not None: text_message += f"[回复 {event.reply.sender.nickname} 的消息 {event.reply.message.extract_plain_text()}]\n" - if event.is_tome(): + if isinstance(event, GroupMessageEvent) and event.is_tome(): text_message += f"@{next(iter(driver.config.nickname))} " for msgseg in event.get_message(): @@ -123,13 +143,22 @@ def format_message(event: GroupMessageEvent) -> str: elif msgseg.type == "text": text_message += msgseg.data.get("text", "") - message = { - "SenderNickname": str(event.sender.card or event.sender.nickname), - "SenderUserId": str(event.user_id), - "Message": text_message, - "MessageID": event.message_id, - "SendTime": datetime.fromtimestamp(event.time).isoformat(), - } + if isinstance(event, GroupMessageEvent): + message = { + "SenderNickname": str(event.sender.card or event.sender.nickname), + "SenderUserId": str(event.user_id), + "Message": text_message, + "MessageID": event.message_id, + "SendTime": datetime.fromtimestamp(event.time).isoformat(), + } + else: # PrivateMessageEvent + message = { + "SenderNickname": str(event.sender.nickname), + "SenderUserId": str(event.user_id), + "Message": text_message, + "MessageID": event.message_id, + "SendTime": datetime.fromtimestamp(event.time).isoformat(), + } return json.dumps(message, ensure_ascii=False) @@ -157,32 +186,60 @@ def build_reasoning_forward_nodes(self_id: str, reasoning_content: str): return nodes -async def is_triggered(event: GroupMessageEvent) -> bool: +async def is_triggered(event: Union[GroupMessageEvent, PrivateMessageEvent]) -> bool: """扩展后的消息处理规则""" - state = group_states[event.group_id] + if isinstance(event, GroupMessageEvent): + state = group_states[event.group_id] - if state.preset_name == "off": - return False - - # 黑名单用户 - if event.user_id in plugin_config.blacklist_user_ids: - return False - - # 忽略特定前缀的消息 - msg_text = event.get_plaintext().strip() - for prefix in plugin_config.ignore_prefixes: - if msg_text.startswith(prefix): + if state.preset_name == "off": return False - state.past_events.append(event) + # 黑名单用户 + if event.user_id in plugin_config.blacklist_user_ids: + return False - # 原有@触发条件 - if event.is_tome(): - return True + # 忽略特定前缀的消息 + msg_text = event.get_plaintext().strip() + for prefix in plugin_config.ignore_prefixes: + if msg_text.startswith(prefix): + return False - # 随机触发条件 - if random.random() < state.random_trigger_prob: + state.past_events.append(event) + + # 原有@触发条件 + if event.is_tome(): + return True + + # 随机触发条件 + if random.random() < state.random_trigger_prob: + return True + + return False + + elif isinstance(event, PrivateMessageEvent): + # 检查私聊功能是否启用 + if not plugin_config.enable_private_chat: + return False + + state = private_chat_states[event.user_id] + + if state.preset_name == "off": + return False + + # 黑名单用户 + if event.user_id in plugin_config.blacklist_user_ids: + return False + + # 忽略特定前缀的消息 + msg_text = event.get_plaintext().strip() + for prefix in plugin_config.ignore_prefixes: + if msg_text.startswith(prefix): + return False + + state.past_events.append(event) + + # 私聊默认触发 return True return False @@ -197,18 +254,27 @@ handler = on_message( @handler.handle() -async def handle_message(event: GroupMessageEvent): - group_id = event.group_id - logger.debug( - f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}" - ) - - state = group_states[group_id] +async def handle_message(event: Union[GroupMessageEvent, PrivateMessageEvent]): + if isinstance(event, GroupMessageEvent): + group_id = event.group_id + logger.debug( + f"收到群聊消息 群号:{group_id} 用户:{event.user_id} 内容:{event.get_plaintext()}" + ) + state = group_states[group_id] + context_id = group_id + else: # PrivateMessageEvent + user_id = event.user_id + logger.debug( + f"收到私聊消息 用户:{user_id} 内容:{event.get_plaintext()}" + ) + state = private_chat_states[user_id] + context_id = user_id await state.queue.put(event) if not state.processing: state.processing = True - task = asyncio.create_task(process_messages(group_id)) + is_group = isinstance(event, GroupMessageEvent) + task = asyncio.create_task(process_messages(context_id, is_group)) task.add_done_callback(tasks.discard) tasks.add(task) @@ -253,9 +319,16 @@ async def send_split_messages(message_handler, content: str): logger.debug(f"发送消息分段 内容:{segment[:50]}...") # 只记录前50个字符避免日志过大 await message_handler.send(Message(segment)) -async def process_messages(group_id: int): - state = group_states[group_id] - preset = get_preset(group_id) +async def process_messages(context_id: int, is_group: bool = True): + if is_group: + group_id = context_id + state = group_states[group_id] + else: + user_id = context_id + state = private_chat_states[user_id] + group_id = None + + preset = get_preset(context_id, is_group) # 初始化OpenAI客户端 if preset.proxy != "": @@ -273,16 +346,21 @@ async def process_messages(group_id: int): ) logger.info( - f"开始处理群聊消息 群号:{group_id} 当前队列长度:{state.queue.qsize()}" + f"开始处理{'群聊' if is_group else '私聊'}消息 {'群号' if is_group else '用户'}:{context_id} 当前队列长度:{state.queue.qsize()}" ) while not state.queue.empty(): event = await state.queue.get() - logger.debug(f"从队列获取消息 群号:{group_id} 消息ID:{event.message_id}") + if is_group: + logger.debug(f"从队列获取消息 群号:{context_id} 消息ID:{event.message_id}") + group_id = context_id + else: + logger.debug(f"从队列获取消息 用户:{context_id} 消息ID:{event.message_id}") + group_id = None past_events_snapshot = [] mcp_client = MCPClient.get_instance(plugin_config.mcp_servers) try: systemPrompt = f""" -我想要你帮我在群聊中闲聊,大家一般叫你{"、".join(list(driver.config.nickname))},我将会在后面的信息中告诉你每条群聊信息的发送者和发送时间,你可以直接称呼发送者为他对应的昵称。 +我想要你帮我在{"群聊" if is_group else "私聊"}中闲聊,大家一般叫你{"、".join(list(driver.config.nickname))},我将会在后面的信息中告诉你每条{"群聊" if is_group else "私聊"}信息的发送者和发送时间,你可以直接称呼发送者为他对应的昵称。 你的回复需要遵守以下几点规则: - 你可以使用多条消息回复,每两条消息之间使用分隔,前后不需要包含额外的换行和空格。 - 除外,消息中不应该包含其他类似的标记。 @@ -296,7 +374,7 @@ async def process_messages(group_id: int): - 如果你选择完全不回复,你只需要直接输出一个。 - 如果你需要思考的话,你应该思考尽量少,以节省时间。 下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。 -{state.group_prompt or plugin_config.default_prompt} +{(state.group_prompt if is_group else state.user_prompt) or plugin_config.default_prompt} """ if preset.support_mcp: systemPrompt += "你也可以使用一些工具,下面是关于这些工具的额外说明:\n" @@ -382,12 +460,24 @@ async def process_messages(group_id: int): await handler.send(Message(f"正在使用{mcp_client.get_friendly_name(tool_name)}")) # 执行工具调用,传递群组和机器人信息用于QQ工具 - result = await mcp_client.call_tool( - tool_name, - tool_args, - group_id=event.group_id, - bot_id=str(event.self_id) - ) + if is_group: + result = await mcp_client.call_tool( + tool_name, + tool_args, + group_id=event.group_id, + bot_id=str(event.self_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) + ) new_messages.append({ "role": "tool", @@ -452,7 +542,7 @@ async def process_messages(group_id: int): await handler.send(image_msg) except Exception as e: - logger.opt(exception=e).error(f"API请求失败 群号:{group_id}") + logger.opt(exception=e).error(f"API请求失败 {'群号' if is_group else '用户'}:{context_id}") # 如果在处理过程中出现异常,恢复未处理的消息到state中 state.past_events.extendleft(reversed(past_events_snapshot)) await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}")) @@ -564,12 +654,113 @@ 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 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 持久化与定时任务 # 获取插件数据目录 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(): @@ -590,6 +781,24 @@ async def save_state(): 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)) + + # 保存私聊状态 + 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)) async def load_state(): @@ -611,6 +820,23 @@ async def load_state(): 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 + + # 加载私聊状态 + 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 # 注册生命周期事件 diff --git a/nonebot_plugin_llmchat/config.py b/nonebot_plugin_llmchat/config.py index ed88dd2..6ecebf1 100755 --- a/nonebot_plugin_llmchat/config.py +++ b/nonebot_plugin_llmchat/config.py @@ -49,6 +49,8 @@ class ScopedConfig(BaseModel): default_factory=list, description="需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理" ) + enable_private_chat: bool = Field(False, description="是否启用私聊功能") + private_chat_preset: str = Field("off", description="私聊默认使用的预设名称") class Config(BaseModel):