This commit is contained in:
Kawakaze 2025-11-07 08:36:30 +00:00 committed by GitHub
commit 5e1c8797b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 715 additions and 261 deletions

View file

@ -183,6 +183,8 @@ LLMCHAT__MCP_SERVERS同样为一个dictkey为服务器名称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同样为一个dictkey为服务器名称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同样为一个dictkey为服务器名称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)

View file

@ -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
@ -38,12 +38,22 @@ import nonebot_plugin_localstore as store
require("nonebot_plugin_apscheduler")
from nonebot_plugin_apscheduler import scheduler
require("nonebot_plugin_tortoise_orm")
# 必须在 require 之后导入模型,才能正确注册到 Tortoise ORM
from . import models # noqa: F401
require("nonebot_plugin_tortoise_orm")
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 migrate_from_json_to_db
__plugin_meta__ = PluginMetadata(
name="llmchat",
description="支持多API预设、MCP协议、联网搜索、视觉模型、Nano Banana生图模型的AI群聊插件",
@ -375,13 +385,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]来引用。",
"- 如果有多条消息,你应该优先回复提到你的,一段时间之前的就不要回复了,也可以直接选择不回复。",
"- 如果你选择完全不回复,你只需要直接输出一个<botbr>。",
"- 如果你需要思考的话,你应该尽量少思考,以节省时间。",
]
if is_group:
system_lines += [
"- 你有at群成员的能力只需要在某条消息中插入[CQ:at,qq=QQ号]"
"也就是CQ码。at发送者是非必要的你可以根据你自己的想法at某个人。",
]
system_lines += [
"下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。",
default_prompt,
]
@ -438,7 +456,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(
@ -470,27 +488,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",
@ -568,6 +571,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()
@ -577,22 +602,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}")
@ -600,16 +636,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("修改成功")
@ -617,16 +660,25 @@ 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]
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)
group_states[group_id].past_events.clear()
group_states[group_id].history.clear()
state.past_events.clear()
state.history.clear()
await reset_handler.finish("记忆已清空")
@ -640,32 +692,39 @@ 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}")
return
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(
@ -673,198 +732,82 @@ 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():
"""保存群组状态到文件"""
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("插件启动初始化")
# 首先进行数据迁移(如果存在 JSON 文件)
logger.info("检查是否需要进行 JSON 数据迁移...")
await migrate_from_json_to_db(Config(llmchat=plugin_config))
await load_state()
# 每5分钟保存状态
scheduler.add_job(save_state, "interval", minutes=5)
# 每30分钟保存一次状态作为备份已开启实时保存
scheduler.add_job(save_state, "interval", minutes=30)
logger.info("插件初始化完成(已启用实时保存功能)")
@driver.on_shutdown

View file

@ -0,0 +1,269 @@
"""
数据库操作层
处理聊天历史和状态的持久化
"""
import json
from collections import deque
from datetime import datetime
from typing import Optional
from nonebot import logger
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) -> Optional[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) -> Optional[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}")

View file

@ -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'):

View file

@ -0,0 +1,162 @@
"""
数据迁移脚本
将聊天数据从 JSON 文件迁移到数据库
"""
import asyncio
import json
import os
from collections import deque
from datetime import datetime
from nonebot import logger
from .config import 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
total_migrated_groups = 0
total_migrated_users = 0
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} 个群组的状态")
total_migrated_groups = 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} 个用户的私聊状态")
total_migrated_users = migrated_users
except Exception as e:
logger.error(f"迁移私聊状态失败: {e}")
# 迁移成功后,重命名 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}")
def rename_json_files_to_migrated():
"""将已迁移的 JSON 文件重命名为 .migrated"""
if not data_file:
return
if os.path.exists(data_file):
migrated_file = f"{data_file}.migrated"
try:
os.rename(data_file, migrated_file)
logger.info(f"已将群组状态文件重命名为: {migrated_file}")
except Exception as e:
logger.warning(f"重命名文件失败: {e}")
if private_data_file and os.path.exists(private_data_file):
migrated_file = f"{private_data_file}.migrated"
try:
os.rename(private_data_file, migrated_file)
logger.info(f"已将私聊状态文件重命名为: {migrated_file}")
except Exception as e:
logger.warning(f"重命名文件失败: {e}")

View file

@ -0,0 +1,100 @@
"""
Tortoise ORM 模型定义
用于存储聊天历史和群组/私聊状态
"""
import json
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):
"""群组聊天状态"""
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"
table_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"
table_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"
table_description = "聊天消息历史表"
@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"
table_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)