nonebot-plugin-aigf-master 1.2.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/PKG-INFO +18 -1
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/README.md +17 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/__init__.py +219 -10
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/config.py +8 -0
- nonebot_plugin_aigf_master-1.4.0/nonebot_plugin_aigf_master/image_gen_client.py +42 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/meme_store.py +12 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/processor.py +199 -5
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/prompt_builder.py +7 -3
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master.egg-info/PKG-INFO +18 -1
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master.egg-info/SOURCES.txt +1 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/pyproject.toml +1 -1
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/LICENSE +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/api_hooks.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/command_learner.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/context_bus.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/image_handler.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/llm_client.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/memory_store.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/models.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/peer_client.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/plugin_discovery.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/plugin_invoker.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/preset_store.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/response_parser.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/search_client.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/vlm_client.py +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master.egg-info/dependency_links.txt +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master.egg-info/requires.txt +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master.egg-info/top_level.txt +0 -0
- {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-aigf-master
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: 群聊特化LLM聊天机器人(增强版),具有记忆、表情包、跨插件调用和跨bot通信能力
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: <4.0,>=3.10
|
|
@@ -35,6 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
## 📖 介绍
|
|
36
36
|
|
|
37
37
|
一个基于 NoneBot2 的群聊 AI 助手插件,能够自主收集和发送表情包、联网搜索知识、对群内信息进行记忆,还能够感知同实例内其它插件的输出、通过 LLM 调用其它插件、自主学习群内命令用法,并能通过子插件与其它 bot 通信获取消息和远程调用命令。
|
|
38
|
+
> 本项目有AI高度参与,若有做得不够好及需要改进的地方,欢迎在issue提出意见。
|
|
38
39
|
|
|
39
40
|
### 核心能力
|
|
40
41
|
|
|
@@ -45,6 +46,7 @@ Dynamic: license-file
|
|
|
45
46
|
- 🖼️ **表情包功能**:AI 自主决定发表情包;自动从群聊中收藏
|
|
46
47
|
- 🔍 **联网搜索**:支持 Tavily / Bocha / Bing,自动搜索不懂的梗和网络用语
|
|
47
48
|
- 🧠 **四层记忆系统**:短期记忆、长期记忆、群友信息、文化记忆,LLM 自主管理
|
|
49
|
+
- 🎨 **AI 生图**:LLM可调用生图模型生成图片发到群里,尺寸由 LLM 决定(程序限最大尺寸),支持参考图改绘(回复图片消息或从聊天记录选图)
|
|
48
50
|
|
|
49
51
|
### 基础能力
|
|
50
52
|
|
|
@@ -52,6 +54,8 @@ Dynamic: license-file
|
|
|
52
54
|
- 💬 **口语化回复**:像平时聊天一样自然,回复长度有变化,允许敷衍
|
|
53
55
|
- 📨 **消息合并**:同一用户连续消息自动合并,避免碎片化上下文
|
|
54
56
|
- ⏱️ **不完整消息检测**:纯 @ 消息或连续发送时自动延长等待时间
|
|
57
|
+
- 🕐 **消息时间标注**:可选为 LLM 看到的消息加上 `[MM-DD HH:MM]` 时间前缀(`AIGFM_SHOW_MESSAGE_TIME`)
|
|
58
|
+
- 📄 **完整消息类型**:合并聊天记录、小程序/卡片、XML 消息均可解析与捕获
|
|
55
59
|
- 🔒 **强制 JSON 输出**:确保 LLM 始终返回正确格式
|
|
56
60
|
- ⚡ **轻量高效**:单次 LLM 调用完成对话 + 记忆管理 + 命令学习
|
|
57
61
|
|
|
@@ -111,6 +115,15 @@ AIGFM_VLM_API_KEY="" # VLM API Key(为空时使用 chat 的
|
|
|
111
115
|
### 可选
|
|
112
116
|
|
|
113
117
|
```env
|
|
118
|
+
# --- AI 生图 ---
|
|
119
|
+
AIGFM_IMAGE_GEN_ENABLED=false # 是否启用 AI 生图(默认 false)
|
|
120
|
+
AIGFM_IMAGE_GEN_MODEL="..." # 生图模型名称(豆包/Ark 示例: doubao-seedream-5-0-lite-260128)
|
|
121
|
+
AIGFM_IMAGE_GEN_BASE_URL="https://..." # 生图 API 地址(OpenAI 兼容 images API;豆包示例 https://ark.cn-beijing.volces.com/api/v3)
|
|
122
|
+
AIGFM_IMAGE_GEN_API_KEY="" # 生图 API Key(为空时使用 chat 的 key)
|
|
123
|
+
AIGFM_IMAGE_GEN_MAX_SIZE="1024x1024" # 生成最大尺寸限制(宽x高,LLM 可指定比例/尺寸,程序自动缩放到不超过该上限)
|
|
124
|
+
AIGFM_IMAGE_GEN_MIN_SIZE="" # 生成最小尺寸(宽x高,为空不限制;用于满足服务最低像素要求,如豆包 Seedream 需 "1920x1920" 即 3686400 像素,非空时按比例放大到不小于该面积)
|
|
125
|
+
AIGFM_IMAGE_GEN_WATERMARK=false # 生图水印(参考豆包类服务,仅参考图时传入,默认 false)
|
|
126
|
+
|
|
114
127
|
# --- 基础 ---
|
|
115
128
|
AIGFM_MEME_ENABLED=true # 是否启用表情包功能(默认 true)
|
|
116
129
|
AIGFM_MEME_MAX_COUNT=200 # 自动收集的表情包最大数量(默认 200)
|
|
@@ -122,6 +135,7 @@ AIGFM_BATCH_TIMEOUT=30.0 # 距最后一条消息多少秒后触
|
|
|
122
135
|
AIGFM_INCOMPLETE_TIMEOUT=40.0 # 消息可能不完整时的等待时间/秒(默认 40.0;替代默认 30s 生效,非叠加)
|
|
123
136
|
AIGFM_RECENT_MESSAGES=20 # prompt 中包含的最近历史消息条数(默认 20,最大 50)
|
|
124
137
|
AIGFM_MERGE_WINDOW=5.0 # 消息合并时间窗口/秒(默认 5.0)
|
|
138
|
+
AIGFM_SHOW_MESSAGE_TIME=false # LLM 看到的消息是否带 [MM-DD HH:MM] 时间前缀(默认 false)
|
|
125
139
|
|
|
126
140
|
# --- LLM ---
|
|
127
141
|
AIGFM_LLM_JSON_MODE=true # 是否强制 LLM 输出 JSON(默认 true)
|
|
@@ -222,6 +236,9 @@ LLM 在聊天记录中看到:[插件名]: 插件的输出
|
|
|
222
236
|
|------|---------|
|
|
223
237
|
| 文本 | 直接以 `[来源]: '内容'` 进入上下文 |
|
|
224
238
|
| 图片 | 先经 VLM 转成文字描述,再以 `[图片] 描述` 进入上下文(`AIGFM_CAPTURE_IMAGES=false` 时不捕获) |
|
|
239
|
+
| 合并聊天记录 | 以 `[收到一条合并聊天记录]` 进入上下文 |
|
|
240
|
+
| 小程序/卡片 | 以 `[小程序/卡片: 标题, 描述]` 进入上下文 |
|
|
241
|
+
| XML | 以 `[收到一条XML消息]` 进入上下文 |
|
|
225
242
|
|
|
226
243
|
图片的来源字段依次支持:
|
|
227
244
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
## 📖 介绍
|
|
16
16
|
|
|
17
17
|
一个基于 NoneBot2 的群聊 AI 助手插件,能够自主收集和发送表情包、联网搜索知识、对群内信息进行记忆,还能够感知同实例内其它插件的输出、通过 LLM 调用其它插件、自主学习群内命令用法,并能通过子插件与其它 bot 通信获取消息和远程调用命令。
|
|
18
|
+
> 本项目有AI高度参与,若有做得不够好及需要改进的地方,欢迎在issue提出意见。
|
|
18
19
|
|
|
19
20
|
### 核心能力
|
|
20
21
|
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
- 🖼️ **表情包功能**:AI 自主决定发表情包;自动从群聊中收藏
|
|
26
27
|
- 🔍 **联网搜索**:支持 Tavily / Bocha / Bing,自动搜索不懂的梗和网络用语
|
|
27
28
|
- 🧠 **四层记忆系统**:短期记忆、长期记忆、群友信息、文化记忆,LLM 自主管理
|
|
29
|
+
- 🎨 **AI 生图**:LLM可调用生图模型生成图片发到群里,尺寸由 LLM 决定(程序限最大尺寸),支持参考图改绘(回复图片消息或从聊天记录选图)
|
|
28
30
|
|
|
29
31
|
### 基础能力
|
|
30
32
|
|
|
@@ -32,6 +34,8 @@
|
|
|
32
34
|
- 💬 **口语化回复**:像平时聊天一样自然,回复长度有变化,允许敷衍
|
|
33
35
|
- 📨 **消息合并**:同一用户连续消息自动合并,避免碎片化上下文
|
|
34
36
|
- ⏱️ **不完整消息检测**:纯 @ 消息或连续发送时自动延长等待时间
|
|
37
|
+
- 🕐 **消息时间标注**:可选为 LLM 看到的消息加上 `[MM-DD HH:MM]` 时间前缀(`AIGFM_SHOW_MESSAGE_TIME`)
|
|
38
|
+
- 📄 **完整消息类型**:合并聊天记录、小程序/卡片、XML 消息均可解析与捕获
|
|
35
39
|
- 🔒 **强制 JSON 输出**:确保 LLM 始终返回正确格式
|
|
36
40
|
- ⚡ **轻量高效**:单次 LLM 调用完成对话 + 记忆管理 + 命令学习
|
|
37
41
|
|
|
@@ -91,6 +95,15 @@ AIGFM_VLM_API_KEY="" # VLM API Key(为空时使用 chat 的
|
|
|
91
95
|
### 可选
|
|
92
96
|
|
|
93
97
|
```env
|
|
98
|
+
# --- AI 生图 ---
|
|
99
|
+
AIGFM_IMAGE_GEN_ENABLED=false # 是否启用 AI 生图(默认 false)
|
|
100
|
+
AIGFM_IMAGE_GEN_MODEL="..." # 生图模型名称(豆包/Ark 示例: doubao-seedream-5-0-lite-260128)
|
|
101
|
+
AIGFM_IMAGE_GEN_BASE_URL="https://..." # 生图 API 地址(OpenAI 兼容 images API;豆包示例 https://ark.cn-beijing.volces.com/api/v3)
|
|
102
|
+
AIGFM_IMAGE_GEN_API_KEY="" # 生图 API Key(为空时使用 chat 的 key)
|
|
103
|
+
AIGFM_IMAGE_GEN_MAX_SIZE="1024x1024" # 生成最大尺寸限制(宽x高,LLM 可指定比例/尺寸,程序自动缩放到不超过该上限)
|
|
104
|
+
AIGFM_IMAGE_GEN_MIN_SIZE="" # 生成最小尺寸(宽x高,为空不限制;用于满足服务最低像素要求,如豆包 Seedream 需 "1920x1920" 即 3686400 像素,非空时按比例放大到不小于该面积)
|
|
105
|
+
AIGFM_IMAGE_GEN_WATERMARK=false # 生图水印(参考豆包类服务,仅参考图时传入,默认 false)
|
|
106
|
+
|
|
94
107
|
# --- 基础 ---
|
|
95
108
|
AIGFM_MEME_ENABLED=true # 是否启用表情包功能(默认 true)
|
|
96
109
|
AIGFM_MEME_MAX_COUNT=200 # 自动收集的表情包最大数量(默认 200)
|
|
@@ -102,6 +115,7 @@ AIGFM_BATCH_TIMEOUT=30.0 # 距最后一条消息多少秒后触
|
|
|
102
115
|
AIGFM_INCOMPLETE_TIMEOUT=40.0 # 消息可能不完整时的等待时间/秒(默认 40.0;替代默认 30s 生效,非叠加)
|
|
103
116
|
AIGFM_RECENT_MESSAGES=20 # prompt 中包含的最近历史消息条数(默认 20,最大 50)
|
|
104
117
|
AIGFM_MERGE_WINDOW=5.0 # 消息合并时间窗口/秒(默认 5.0)
|
|
118
|
+
AIGFM_SHOW_MESSAGE_TIME=false # LLM 看到的消息是否带 [MM-DD HH:MM] 时间前缀(默认 false)
|
|
105
119
|
|
|
106
120
|
# --- LLM ---
|
|
107
121
|
AIGFM_LLM_JSON_MODE=true # 是否强制 LLM 输出 JSON(默认 true)
|
|
@@ -202,6 +216,9 @@ LLM 在聊天记录中看到:[插件名]: 插件的输出
|
|
|
202
216
|
|------|---------|
|
|
203
217
|
| 文本 | 直接以 `[来源]: '内容'` 进入上下文 |
|
|
204
218
|
| 图片 | 先经 VLM 转成文字描述,再以 `[图片] 描述` 进入上下文(`AIGFM_CAPTURE_IMAGES=false` 时不捕获) |
|
|
219
|
+
| 合并聊天记录 | 以 `[收到一条合并聊天记录]` 进入上下文 |
|
|
220
|
+
| 小程序/卡片 | 以 `[小程序/卡片: 标题, 描述]` 进入上下文 |
|
|
221
|
+
| XML | 以 `[收到一条XML消息]` 进入上下文 |
|
|
205
222
|
|
|
206
223
|
图片的来源字段依次支持:
|
|
207
224
|
|
|
@@ -8,10 +8,12 @@ from datetime import datetime
|
|
|
8
8
|
|
|
9
9
|
import anyio
|
|
10
10
|
import httpx
|
|
11
|
-
from nonebot import get_driver, logger, on_command, on_message, require
|
|
11
|
+
from nonebot import get_driver, logger, on_command, on_message, on_notice, require
|
|
12
12
|
from nonebot.adapters import Event, Message
|
|
13
13
|
from nonebot.adapters.onebot.v11 import (
|
|
14
14
|
Bot, GroupMessageEvent, MessageSegment, Message as OneBotMessage,
|
|
15
|
+
GroupBanNoticeEvent, GroupDecreaseNoticeEvent, GroupIncreaseNoticeEvent,
|
|
16
|
+
GroupRecallNoticeEvent, PokeNotifyEvent,
|
|
15
17
|
)
|
|
16
18
|
from nonebot.params import CommandArg
|
|
17
19
|
from nonebot.permission import SUPERUSER
|
|
@@ -26,6 +28,7 @@ from .context_bus import ContextBus
|
|
|
26
28
|
from .command_learner import CommandLearner
|
|
27
29
|
from .llm_client import LLMClient
|
|
28
30
|
from .image_handler import ImageHandler
|
|
31
|
+
from .image_gen_client import ImageGenClient
|
|
29
32
|
from .meme_store import MemeStore
|
|
30
33
|
from .memory_store import MemoryStore
|
|
31
34
|
from .models import ChatMessage
|
|
@@ -60,6 +63,12 @@ _learner = CommandLearner(
|
|
|
60
63
|
min_confidence=plugin_config.aigfm_learn_min_confidence,
|
|
61
64
|
)
|
|
62
65
|
_image_handler = ImageHandler(_cache_dir)
|
|
66
|
+
_image_gen = ImageGenClient(
|
|
67
|
+
plugin_config.aigfm_image_gen_api_key or plugin_config.aigfm_llm_api_key,
|
|
68
|
+
plugin_config.aigfm_image_gen_model,
|
|
69
|
+
plugin_config.aigfm_image_gen_base_url,
|
|
70
|
+
_proxy,
|
|
71
|
+
) if plugin_config.aigfm_image_gen_enabled else None
|
|
63
72
|
_search = create_search_client(
|
|
64
73
|
plugin_config.aigfm_search_api, plugin_config.aigfm_search_api_key,
|
|
65
74
|
plugin_config.aigfm_openwebsearch_url,
|
|
@@ -78,6 +87,7 @@ def _get_processor(group_id: int) -> MessageProcessor:
|
|
|
78
87
|
context_bus=_bus, invoker=_invoker, learner=_learner,
|
|
79
88
|
search=_search, config=plugin_config, peer_client=_peer_client,
|
|
80
89
|
peer_scanned_commands=_peer_scanned_commands,
|
|
90
|
+
image_gen=_image_gen, image_handler=_image_handler,
|
|
81
91
|
)
|
|
82
92
|
return _processors[group_id]
|
|
83
93
|
|
|
@@ -186,15 +196,19 @@ async def _parse_message(bot: Bot, event: GroupMessageEvent, message: Message, b
|
|
|
186
196
|
elif seg.type == "reply":
|
|
187
197
|
has_non_at = True
|
|
188
198
|
try:
|
|
189
|
-
|
|
199
|
+
# OneBot v11 的 reply 段键名在不同实现/构造方式下可能是 message_id 或 id
|
|
200
|
+
reply_msg_id = seg.data.get("message_id") or seg.data.get("id")
|
|
190
201
|
if reply_msg_id:
|
|
191
202
|
original = await bot.get_msg(message_id=int(reply_msg_id))
|
|
192
203
|
if original and "message" in original:
|
|
193
|
-
replied_text =
|
|
204
|
+
replied_text = await _extract_reply_content(bot, original["message"], event.group_id)
|
|
194
205
|
replied_sender = original.get("sender", {}).get("nickname", "未知")
|
|
195
206
|
content += f"[回复 {replied_sender} 的消息: \"{replied_text}\"] "
|
|
196
|
-
|
|
197
|
-
|
|
207
|
+
else:
|
|
208
|
+
content += "[回复: (无法获取被回复消息)] "
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.warning(f"回复消息处理失败: {e}")
|
|
211
|
+
content += "[回复: (被回复内容获取失败)] "
|
|
198
212
|
elif seg.type == "forward":
|
|
199
213
|
has_non_at = True
|
|
200
214
|
content += "[收到一条合并聊天记录] "
|
|
@@ -204,11 +218,86 @@ async def _parse_message(bot: Bot, event: GroupMessageEvent, message: Message, b
|
|
|
204
218
|
elif seg.type == "xml":
|
|
205
219
|
has_non_at = True
|
|
206
220
|
content += "[收到一条XML消息] "
|
|
221
|
+
elif seg.type == "face":
|
|
222
|
+
# 部分客户端会带表情名(text),缺失时用 id 兜底
|
|
223
|
+
has_non_at = True
|
|
224
|
+
face_name = seg.data.get("text") or seg.data.get("id")
|
|
225
|
+
content += f"[QQ表情 {face_name}] " if face_name else "[QQ表情] "
|
|
226
|
+
elif seg.type == "record":
|
|
227
|
+
has_non_at = True
|
|
228
|
+
content += "[收到一条语音消息] "
|
|
229
|
+
elif seg.type == "video":
|
|
230
|
+
has_non_at = True
|
|
231
|
+
content += "[收到一条视频消息] "
|
|
232
|
+
elif seg.type == "file":
|
|
233
|
+
has_non_at = True
|
|
234
|
+
# go-cqhttp/NapCat 字段名略有差异:file_name/name、file_size/size
|
|
235
|
+
file_name = seg.data.get("file_name") or seg.data.get("name")
|
|
236
|
+
file_size = seg.data.get("file_size") or seg.data.get("size")
|
|
237
|
+
size_str = ""
|
|
238
|
+
if file_size:
|
|
239
|
+
try:
|
|
240
|
+
size_bytes = int(file_size)
|
|
241
|
+
if size_bytes >= 1024 * 1024:
|
|
242
|
+
size_str = f"({size_bytes / 1024 / 1024:.1f}MB)"
|
|
243
|
+
elif size_bytes >= 1024:
|
|
244
|
+
size_str = f"({size_bytes / 1024:.1f}KB)"
|
|
245
|
+
else:
|
|
246
|
+
size_str = f"({size_bytes}B)"
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
pass
|
|
249
|
+
content += f"[群文件: {file_name}{size_str}] " if file_name else "[群文件] "
|
|
250
|
+
elif seg.type == "share":
|
|
251
|
+
has_non_at = True
|
|
252
|
+
share_title = seg.data.get("title", "")
|
|
253
|
+
share_url = seg.data.get("url", "")
|
|
254
|
+
share_content = seg.data.get("content")
|
|
255
|
+
parts = []
|
|
256
|
+
if share_title:
|
|
257
|
+
parts.append(share_title)
|
|
258
|
+
if share_url:
|
|
259
|
+
parts.append(share_url)
|
|
260
|
+
if share_content:
|
|
261
|
+
parts.append(share_content)
|
|
262
|
+
content += f"[分享链接: {' | '.join(parts)}] " if parts else "[分享链接] "
|
|
263
|
+
elif seg.type == "contact":
|
|
264
|
+
has_non_at = True
|
|
265
|
+
contact_type = "群" if seg.data.get("type") == "group" else "好友"
|
|
266
|
+
contact_id = seg.data.get("id", "")
|
|
267
|
+
content += f"[推荐{contact_type}: {contact_id}] " if contact_id else f"[推荐{contact_type}] "
|
|
268
|
+
elif seg.type == "location":
|
|
269
|
+
has_non_at = True
|
|
270
|
+
loc_title = seg.data.get("title", "")
|
|
271
|
+
lat, lon = seg.data.get("lat", ""), seg.data.get("lon", "")
|
|
272
|
+
parts = [loc_title] if loc_title else []
|
|
273
|
+
if lat and lon:
|
|
274
|
+
parts.append(f"{lat},{lon}")
|
|
275
|
+
if seg.data.get("content"):
|
|
276
|
+
parts.append(seg.data["content"])
|
|
277
|
+
content += f"[位置: {' | '.join(parts)}] " if parts else "[位置] "
|
|
278
|
+
elif seg.type == "music":
|
|
279
|
+
has_non_at = True
|
|
280
|
+
music_title = seg.data.get("title")
|
|
281
|
+
music_id = seg.data.get("id")
|
|
282
|
+
content += f"[音乐分享: {music_title}] " if music_title else f"[音乐分享 {music_id}] "
|
|
283
|
+
elif seg.type == "dice":
|
|
284
|
+
has_non_at = True
|
|
285
|
+
dice_result = seg.data.get("result")
|
|
286
|
+
content += f"[骰子 {dice_result}] " if dice_result else "[骰子] "
|
|
287
|
+
elif seg.type in ("rps", "shake", "anonymous", "node"):
|
|
288
|
+
has_non_at = True
|
|
289
|
+
label = {"rps": "猜拳", "shake": "窗口抖动", "anonymous": "匿名消息", "node": "合并聊天记录节点"}[seg.type]
|
|
290
|
+
content += f"[{label}] "
|
|
207
291
|
|
|
208
292
|
return content.strip(), not has_non_at
|
|
209
293
|
|
|
210
294
|
|
|
211
|
-
def
|
|
295
|
+
async def _extract_reply_content(bot: Bot, message_content, group_id: int) -> str:
|
|
296
|
+
"""把被回复消息渲染为「原文 + 图片 id + VLM 描述」(完整输出,不截断)
|
|
297
|
+
|
|
298
|
+
图片段与普通图片消息同链路:下载 → 缓存(save_to_cache,保证 cache_id 进入本群缓存)→ VLM 描述。
|
|
299
|
+
这样 LLM 从回复段能看到图片 id 与内容,可作为生图参考图(reference_cache_id)。
|
|
300
|
+
"""
|
|
212
301
|
try:
|
|
213
302
|
if isinstance(message_content, str):
|
|
214
303
|
msg = OneBotMessage(message_content)
|
|
@@ -218,12 +307,45 @@ def _extract_reply_text(message_content) -> str:
|
|
|
218
307
|
if isinstance(seg, dict) and "type" in seg:
|
|
219
308
|
msg.append(MessageSegment(type=seg["type"], data=seg.get("data", {})))
|
|
220
309
|
else:
|
|
221
|
-
return str(message_content)[:50]
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
except Exception:
|
|
310
|
+
return str(message_content)[:50] # 非标准结构兜底
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.warning(f"被回复消息解析失败: {e}")
|
|
225
313
|
return "(无法获取)"
|
|
226
314
|
|
|
315
|
+
parts = []
|
|
316
|
+
for seg in msg:
|
|
317
|
+
if seg.type == "text":
|
|
318
|
+
text = seg.data.get("text", "")
|
|
319
|
+
if text.strip():
|
|
320
|
+
parts.append(text.strip())
|
|
321
|
+
elif seg.type in ("image", "emoji"):
|
|
322
|
+
_on_image_start(group_id)
|
|
323
|
+
try:
|
|
324
|
+
img = _extract_image_data(seg.data)
|
|
325
|
+
is_sticker = seg.data.get("sub_type") == 1
|
|
326
|
+
image_bytes = await _load_image_bytes(img)
|
|
327
|
+
image_base64 = base64.b64encode(image_bytes).decode()
|
|
328
|
+
if plugin_config.aigfm_image_mode == "llm":
|
|
329
|
+
cache_id = await _memes.save_to_cache(group_id, image_bytes, "", "")
|
|
330
|
+
parts.append(f"[发送了一张图片, id: {cache_id}]")
|
|
331
|
+
else:
|
|
332
|
+
desc = await _image_handler.describe(image_base64, is_sticker)
|
|
333
|
+
if desc:
|
|
334
|
+
cache_id = await _memes.save_to_cache(group_id, image_bytes, desc.description, desc.emotion)
|
|
335
|
+
if is_sticker:
|
|
336
|
+
parts.append(f"[发送了一张可能是表情包的图片, id: {cache_id}] [情感:{desc.emotion}] [内容:{desc.description}]")
|
|
337
|
+
else:
|
|
338
|
+
parts.append(f"[发送了一张图片, id: {cache_id}] [内容:{desc.description}]")
|
|
339
|
+
else:
|
|
340
|
+
parts.append("[发送了一张图片](识图失败)")
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.error(f"被回复图片处理错误: {e}")
|
|
343
|
+
parts.append("[图片加载失败]")
|
|
344
|
+
finally:
|
|
345
|
+
_on_image_done(group_id)
|
|
346
|
+
text = " ".join(parts)
|
|
347
|
+
return text if text else "(非文字消息)"
|
|
348
|
+
|
|
227
349
|
|
|
228
350
|
# ========== 批量消息处理 ==========
|
|
229
351
|
|
|
@@ -266,6 +388,22 @@ def _add_peer_message(group_id: int, source: str, content: str, reset_timer: boo
|
|
|
266
388
|
_group_last_time[group_id] = asyncio.get_event_loop().time()
|
|
267
389
|
|
|
268
390
|
|
|
391
|
+
def _add_notice_message(group_id: int, content: str):
|
|
392
|
+
"""将群系统通知(戳一戳/禁言/进出群/消息撤回)加入消息缓冲区
|
|
393
|
+
|
|
394
|
+
user_id 留空 → 渲染为 `[系统]: '内容'`,LLM 可区分系统通知与群友发言
|
|
395
|
+
"""
|
|
396
|
+
if group_id not in _group_chunks:
|
|
397
|
+
_group_chunks[group_id] = []
|
|
398
|
+
_group_chunks[group_id].append(ChatMessage(
|
|
399
|
+
time=datetime.now(),
|
|
400
|
+
user_name="系统",
|
|
401
|
+
content=content,
|
|
402
|
+
user_id="",
|
|
403
|
+
))
|
|
404
|
+
_group_last_time[group_id] = asyncio.get_event_loop().time()
|
|
405
|
+
|
|
406
|
+
|
|
269
407
|
async def _describe_peer_image(group_id: int, source: str, image_url: str = "", image_base64: str = ""):
|
|
270
408
|
"""在 Bot A 侧对其它 bot 推送的图片做 VLM 描述后加入消息缓冲区"""
|
|
271
409
|
_on_image_start(group_id)
|
|
@@ -410,6 +548,55 @@ def _is_group_msg(event: Event) -> bool:
|
|
|
410
548
|
return isinstance(event, GroupMessageEvent)
|
|
411
549
|
|
|
412
550
|
|
|
551
|
+
_NOTICE_TYPES = (
|
|
552
|
+
PokeNotifyEvent, GroupBanNoticeEvent, GroupIncreaseNoticeEvent,
|
|
553
|
+
GroupDecreaseNoticeEvent, GroupRecallNoticeEvent,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _is_group_notice(event: Event) -> bool:
|
|
558
|
+
"""只收群内系统通知;PokeNotifyEvent 的 group_id 可能为空(私聊戳一戳),过滤掉"""
|
|
559
|
+
if not isinstance(event, _NOTICE_TYPES):
|
|
560
|
+
return False
|
|
561
|
+
return bool(getattr(event, "group_id", None))
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
async def _resolve_notice_text(bot: Bot, event: Event) -> str | None:
|
|
565
|
+
"""把群 notice 事件渲染成一行文本;昵称查询失败回落 QQ 号"""
|
|
566
|
+
group_id = int(getattr(event, "group_id"))
|
|
567
|
+
|
|
568
|
+
async def _name(user_id: int) -> str:
|
|
569
|
+
try:
|
|
570
|
+
info = await bot.get_group_member_info(group_id=group_id, user_id=user_id)
|
|
571
|
+
return info.get("nickname") or str(user_id)
|
|
572
|
+
except Exception:
|
|
573
|
+
return str(user_id)
|
|
574
|
+
|
|
575
|
+
if isinstance(event, PokeNotifyEvent):
|
|
576
|
+
return f"{await _name(event.user_id)} 戳了戳 {await _name(event.target_id)}"
|
|
577
|
+
if isinstance(event, GroupBanNoticeEvent):
|
|
578
|
+
if event.sub_type == "self_ban":
|
|
579
|
+
return f"我被禁言 {event.duration} 分钟"
|
|
580
|
+
if event.sub_type == "self_lift_ban":
|
|
581
|
+
return "我被解除禁言"
|
|
582
|
+
if event.sub_type == "lift_ban":
|
|
583
|
+
return f"{await _name(event.operator_id)} 解除了 {await _name(event.user_id)} 的禁言"
|
|
584
|
+
return f"{await _name(event.operator_id)} 将 {await _name(event.user_id)} 禁言 {event.duration} 分钟"
|
|
585
|
+
if isinstance(event, GroupIncreaseNoticeEvent):
|
|
586
|
+
return f"{await _name(event.user_id)} 加入了群聊"
|
|
587
|
+
if isinstance(event, GroupDecreaseNoticeEvent):
|
|
588
|
+
if event.sub_type == "kick_me":
|
|
589
|
+
return "我被移出了群聊"
|
|
590
|
+
if event.sub_type == "kick":
|
|
591
|
+
return f"{await _name(event.user_id)} 被 {await _name(event.operator_id)} 移出了群聊"
|
|
592
|
+
return f"{await _name(event.user_id)} 退出了群聊"
|
|
593
|
+
if isinstance(event, GroupRecallNoticeEvent):
|
|
594
|
+
if event.user_id == event.operator_id:
|
|
595
|
+
return f"{await _name(event.user_id)} 撤回了一条消息"
|
|
596
|
+
return f"{await _name(event.operator_id)} 撤回了 {await _name(event.user_id)} 的一条消息"
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
|
|
413
600
|
# 这里新增/改名的管理命令必须同步 processor.SELF_COMMANDS:
|
|
414
601
|
# synthetic 事件携带真实触发用户的 user_id,少了同步会让 LLM 有机会代为执行这些命令
|
|
415
602
|
status_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
@@ -425,6 +612,7 @@ set_preset_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
|
425
612
|
reload_meme_cmd = on_command(rule=_is_group_msg, permission=SUPERUSER,
|
|
426
613
|
cmd="reload_meme", aliases={"重载表情包"}, priority=0, block=True)
|
|
427
614
|
auto_chat = on_message(rule=_is_group_msg, priority=1, block=False)
|
|
615
|
+
group_notice = on_notice(rule=_is_group_notice, priority=1, block=False)
|
|
428
616
|
|
|
429
617
|
|
|
430
618
|
@status_cmd.handle()
|
|
@@ -554,6 +742,27 @@ async def handle_auto_chat(bot: Bot, event: GroupMessageEvent):
|
|
|
554
742
|
pass
|
|
555
743
|
|
|
556
744
|
|
|
745
|
+
@group_notice.handle()
|
|
746
|
+
async def handle_group_notice(bot: Bot, event: Event):
|
|
747
|
+
"""群系统通知(戳一戳/禁言/进出群/消息撤回)→ 渲染后入缓冲区
|
|
748
|
+
|
|
749
|
+
notice 事件没有用户消息上下文:不更新 processor._current_user_id(避免污染工具调用身份),
|
|
750
|
+
批处理触发依赖该群最近一次真实用户消息(与 peer 推送的等待行为一致)
|
|
751
|
+
"""
|
|
752
|
+
group_id = int(getattr(event, "group_id"))
|
|
753
|
+
if group_id not in plugin_config.aigfm_enabled_groups:
|
|
754
|
+
return
|
|
755
|
+
try:
|
|
756
|
+
text = await _resolve_notice_text(bot, event)
|
|
757
|
+
except Exception as e:
|
|
758
|
+
logger.error(f"[通知] 事件渲染失败: {e}")
|
|
759
|
+
return
|
|
760
|
+
if not text:
|
|
761
|
+
return
|
|
762
|
+
logger.info(f"[通知] 群{group_id}: {text}")
|
|
763
|
+
_add_notice_message(group_id, text)
|
|
764
|
+
|
|
765
|
+
|
|
557
766
|
# ========== 启动 ==========
|
|
558
767
|
|
|
559
768
|
@get_driver().on_startup
|
|
@@ -21,6 +21,14 @@ class PluginConfig(BaseModel):
|
|
|
21
21
|
aigfm_vlm_model: str = Field("", description="VLM 模型名称")
|
|
22
22
|
aigfm_vlm_base_url: str = Field("", description="VLM API 地址")
|
|
23
23
|
aigfm_vlm_api_key: str = Field("", description="VLM API Key(为空时使用 llm_api_key)")
|
|
24
|
+
# AI 生图(OpenAI 兼容 images API)
|
|
25
|
+
aigfm_image_gen_enabled: bool = Field(False, description="是否启用 AI 生图")
|
|
26
|
+
aigfm_image_gen_model: str = Field("", description="生图模型名称")
|
|
27
|
+
aigfm_image_gen_base_url: str = Field("", description="生图 API 地址")
|
|
28
|
+
aigfm_image_gen_api_key: str = Field("", description="生图 API Key(为空时使用 llm_api_key)")
|
|
29
|
+
aigfm_image_gen_max_size: str = Field("1024x1024", description="生图最大尺寸限制(宽x高),LLM 可指定比例/尺寸,程序自动缩放到不超过该上限")
|
|
30
|
+
aigfm_image_gen_min_size: str = Field("", description="生图最小尺寸(宽x高),用于满足服务的最低像素要求(如豆包 Seedream 需 ≥ 1920x1920 即 3686400 像素);为空不限制,非空时按比例放大到不小于该面积(最小优先于最大)")
|
|
31
|
+
aigfm_image_gen_watermark: bool = Field(False, description="生图水印(参考豆包类服务,仅参考图路径传入 watermark 参数)")
|
|
24
32
|
|
|
25
33
|
# 群聊配置
|
|
26
34
|
aigfm_enabled_groups: list[int] = Field(default_factory=list, description="启用的群号列表")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""AI 生图客户端(OpenAI 兼容 images API)"""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from openai import AsyncOpenAI
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ImageGenClient:
|
|
8
|
+
"""AI 生图客户端:调用 OpenAI 兼容的 images API(POST /v1/images/generations)"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, api_key: str, model: str, base_url: str,
|
|
11
|
+
proxy: str | None = None, timeout: int = 120):
|
|
12
|
+
http_client = httpx.AsyncClient(proxy=proxy) if proxy else None
|
|
13
|
+
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, http_client=http_client)
|
|
14
|
+
self._model = model
|
|
15
|
+
self._timeout = timeout
|
|
16
|
+
|
|
17
|
+
async def generate(self, prompt: str, size: str = "1024x1024",
|
|
18
|
+
reference_images: list[str] | None = None,
|
|
19
|
+
watermark: bool = False) -> dict | None:
|
|
20
|
+
"""生成图片
|
|
21
|
+
|
|
22
|
+
reference_images: 可选参考图列表(data URI:data:image/<小写格式>;base64,<b64>,或可访问 url)。
|
|
23
|
+
有参考图时经 extra_body.image 传给豆包类服务做图生图/改绘;无参考图走普通文生图。
|
|
24
|
+
|
|
25
|
+
返回 {"b64": "..."}(base64 图片数据)或 {"url": "..."}(网络地址);
|
|
26
|
+
无结果返回 None。异常由调用方捕获处理。
|
|
27
|
+
"""
|
|
28
|
+
kwargs: dict = dict(
|
|
29
|
+
model=self._model, prompt=prompt, n=1, size=size,
|
|
30
|
+
response_format="b64_json", timeout=self._timeout,
|
|
31
|
+
)
|
|
32
|
+
if reference_images:
|
|
33
|
+
kwargs["extra_body"] = {"image": reference_images, "watermark": watermark}
|
|
34
|
+
response = await self._client.images.generate(**kwargs)
|
|
35
|
+
if not response.data:
|
|
36
|
+
return None
|
|
37
|
+
data = response.data[0]
|
|
38
|
+
if getattr(data, "b64_json", None):
|
|
39
|
+
return {"b64": data.b64_json}
|
|
40
|
+
if getattr(data, "url", None):
|
|
41
|
+
return {"url": data.url}
|
|
42
|
+
return None
|
|
@@ -127,6 +127,18 @@ class MemeStore:
|
|
|
127
127
|
def get_cache_info(self, group_id: int, cache_id: str) -> dict | None:
|
|
128
128
|
return self._cache_index.get(group_id, {}).get(cache_id)
|
|
129
129
|
|
|
130
|
+
def get_cache_file(self, cache_id: str) -> str | None:
|
|
131
|
+
"""按 cache_id 从磁盘兜底查找缓存图片文件路径(clear_cache 只清索引不清文件)
|
|
132
|
+
|
|
133
|
+
返回首个存在的文件路径:sticker_cache/{cache_id}.{ext};找不到返回 None。
|
|
134
|
+
"""
|
|
135
|
+
if not cache_id:
|
|
136
|
+
return None
|
|
137
|
+
for p in self._cache_dir.glob(f"{cache_id}.*"):
|
|
138
|
+
if p.is_file():
|
|
139
|
+
return str(p)
|
|
140
|
+
return None
|
|
141
|
+
|
|
130
142
|
def clear_cache(self, group_id: int):
|
|
131
143
|
self._cache_index.pop(group_id, None)
|
|
132
144
|
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
"""消息处理编排器"""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
3
5
|
import random
|
|
4
6
|
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
5
8
|
|
|
9
|
+
import anyio
|
|
10
|
+
import httpx
|
|
6
11
|
from nonebot import get_driver, logger
|
|
12
|
+
from nonebot.adapters.onebot.v11 import Message, MessageSegment
|
|
7
13
|
|
|
14
|
+
from .command_learner import CommandLearner
|
|
8
15
|
from .config import PluginConfig
|
|
9
16
|
from .context_bus import ContextBus
|
|
10
|
-
from .
|
|
17
|
+
from .image_gen_client import ImageGenClient
|
|
18
|
+
from .image_handler import ImageHandler
|
|
11
19
|
from .llm_client import LLMClient
|
|
12
20
|
from .meme_store import MemeStore
|
|
13
21
|
from .memory_store import MemoryStore
|
|
@@ -34,6 +42,72 @@ def _command_head(command: str) -> str:
|
|
|
34
42
|
return command.split(None, 1)[0] if command else command
|
|
35
43
|
|
|
36
44
|
|
|
45
|
+
def _normalize_size(size_desc: str, max_size: str, min_size: str = "") -> str:
|
|
46
|
+
"""把 LLM 的尺寸描述(方向词或 WxH)缩放到不超过 max_size 的偶数尺寸
|
|
47
|
+
|
|
48
|
+
支持:square/方形/1:1、portrait/竖版/竖屏、landscape/横版/横屏、
|
|
49
|
+
16:9/宽幅/banner、4:3,或直接写 宽x高(如 768x1024)。
|
|
50
|
+
结果按比例缩放到不超过 max_size,并对齐到偶数像素。
|
|
51
|
+
min_size 非空时:若结果面积小于 min_size 面积(宽x高),按比例放大到不小于该面积(最小优先于最大,
|
|
52
|
+
服务于有最低像素要求的生图服务,如豆包 Seedream 需 ≥ 1920x1920 即 3686400 像素)。
|
|
53
|
+
"""
|
|
54
|
+
max_w, max_h = 1024, 1024
|
|
55
|
+
try:
|
|
56
|
+
mw, mh = max_size.lower().split("x")
|
|
57
|
+
max_w, max_h = int(mw), int(mh)
|
|
58
|
+
except (ValueError, AttributeError):
|
|
59
|
+
pass
|
|
60
|
+
max_w = max(max_w, 64)
|
|
61
|
+
max_h = max(max_h, 64)
|
|
62
|
+
|
|
63
|
+
min_pixels = 0
|
|
64
|
+
if min_size:
|
|
65
|
+
try:
|
|
66
|
+
min_w, min_h = min_size.lower().split("x")
|
|
67
|
+
min_pixels = int(min_w) * int(min_h)
|
|
68
|
+
except (ValueError, AttributeError):
|
|
69
|
+
min_pixels = 0
|
|
70
|
+
|
|
71
|
+
ratio = 1.0 # 宽/高
|
|
72
|
+
if size_desc:
|
|
73
|
+
s = size_desc.strip().lower()
|
|
74
|
+
if "x" in s:
|
|
75
|
+
try:
|
|
76
|
+
w, h = s.split("x")
|
|
77
|
+
ratio = int(w) / int(h)
|
|
78
|
+
except (ValueError, ZeroDivisionError):
|
|
79
|
+
ratio = 1.0
|
|
80
|
+
elif s in ("portrait", "竖版", "竖屏", "2:3"):
|
|
81
|
+
ratio = 2 / 3
|
|
82
|
+
elif s in ("landscape", "横版", "横屏", "3:2"):
|
|
83
|
+
ratio = 3 / 2
|
|
84
|
+
elif s in ("16:9", "宽幅", "banner"):
|
|
85
|
+
ratio = 16 / 9
|
|
86
|
+
elif s in ("4:3",):
|
|
87
|
+
ratio = 4 / 3
|
|
88
|
+
# square/方形/1:1 及其它未知描述 → ratio = 1.0
|
|
89
|
+
|
|
90
|
+
if ratio >= 1.0:
|
|
91
|
+
w, h = max_w, round(max_w / ratio)
|
|
92
|
+
else:
|
|
93
|
+
w, h = round(max_h * ratio), max_h
|
|
94
|
+
w -= w % 2
|
|
95
|
+
h -= h % 2
|
|
96
|
+
w = max(w, 64)
|
|
97
|
+
h = max(h, 64)
|
|
98
|
+
if min_pixels and w * h < min_pixels:
|
|
99
|
+
scale = (min_pixels / (w * h)) ** 0.5
|
|
100
|
+
w, h = round(w * scale), round(h * scale)
|
|
101
|
+
w -= w % 2
|
|
102
|
+
h -= h % 2
|
|
103
|
+
while w * h < min_pixels: # 偶数取整后仍可能略低于下限,循环补边
|
|
104
|
+
if w >= h:
|
|
105
|
+
w += 2
|
|
106
|
+
else:
|
|
107
|
+
h += 2
|
|
108
|
+
return f"{w}x{h}"
|
|
109
|
+
|
|
110
|
+
|
|
37
111
|
# 本插件自身的管理命令不允许被 LLM 代为执行:synthetic 事件携带真实触发用户的 user_id,
|
|
38
112
|
# 若该用户是 superuser,`/status`、`/reset` 等会被真的执行
|
|
39
113
|
SELF_PLUGIN_NAMES = {"nonebot_plugin_aigf_master", "nonebot-plugin-aigf-master"}
|
|
@@ -65,6 +139,8 @@ class MessageProcessor:
|
|
|
65
139
|
config: PluginConfig,
|
|
66
140
|
peer_client: PeerClient | None = None,
|
|
67
141
|
peer_scanned_commands: dict[str, list[dict]] | None = None,
|
|
142
|
+
image_gen: ImageGenClient | None = None,
|
|
143
|
+
image_handler: ImageHandler | None = None,
|
|
68
144
|
):
|
|
69
145
|
self.group_id = group_id
|
|
70
146
|
self.llm = llm
|
|
@@ -78,6 +154,9 @@ class MessageProcessor:
|
|
|
78
154
|
self.config = config
|
|
79
155
|
self.peer_client = peer_client
|
|
80
156
|
self.peer_scanned_commands = peer_scanned_commands if peer_scanned_commands is not None else {}
|
|
157
|
+
self.image_gen = image_gen
|
|
158
|
+
self.image_handler = image_handler
|
|
159
|
+
self._image_gen_tasks: set[asyncio.Task] = set()
|
|
81
160
|
self.bot_name = "小助手"
|
|
82
161
|
self.bot_role = "一个友好的群聊助手"
|
|
83
162
|
self.current_preset = ""
|
|
@@ -211,7 +290,7 @@ class MessageProcessor:
|
|
|
211
290
|
|
|
212
291
|
used_tool = False
|
|
213
292
|
if (self.config.aigfm_search_enabled and self.search):
|
|
214
|
-
response_str, used_tool = await self._call_llm_with_tools(prompt, sticker_images)
|
|
293
|
+
response_str, used_tool = await self._call_llm_with_tools(prompt, sticker_images, plugin_commands, peer_commands)
|
|
215
294
|
else:
|
|
216
295
|
response_str = await self.llm.chat(
|
|
217
296
|
prompt, self.config.aigfm_llm_model,
|
|
@@ -291,7 +370,13 @@ class MessageProcessor:
|
|
|
291
370
|
|
|
292
371
|
return reply_segments
|
|
293
372
|
|
|
294
|
-
async def _call_llm_with_tools(self, prompt: str, sticker_images: list[str]
|
|
373
|
+
async def _call_llm_with_tools(self, prompt: str, sticker_images: list[str],
|
|
374
|
+
plugin_commands: list | None = None,
|
|
375
|
+
peer_commands: list | None = None) -> tuple[str | None, bool]:
|
|
376
|
+
# 命令清单为空时不注入命令调用工具:模型看不到工具就不会填「无/none」占位值,
|
|
377
|
+
# 命令学习走 JSON 输出字段,不受影响;学到第一条命令后清单非空,工具自动出现
|
|
378
|
+
plugin_commands = plugin_commands or []
|
|
379
|
+
peer_commands = peer_commands or []
|
|
295
380
|
tools = []
|
|
296
381
|
if self.config.aigfm_search_enabled:
|
|
297
382
|
tools.append({
|
|
@@ -301,7 +386,7 @@ class MessageProcessor:
|
|
|
301
386
|
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
|
|
302
387
|
},
|
|
303
388
|
})
|
|
304
|
-
if self.config.aigfm_invoke_enabled:
|
|
389
|
+
if self.config.aigfm_invoke_enabled and plugin_commands:
|
|
305
390
|
tools.append({
|
|
306
391
|
"type": "function", "function": {
|
|
307
392
|
"name": "invoke_plugin",
|
|
@@ -313,7 +398,7 @@ class MessageProcessor:
|
|
|
313
398
|
}, "required": ["command"]},
|
|
314
399
|
},
|
|
315
400
|
})
|
|
316
|
-
if self.config.aigfm_invoke_enabled and self.peer_client:
|
|
401
|
+
if self.config.aigfm_invoke_enabled and self.peer_client and peer_commands:
|
|
317
402
|
peer_names = "、".join(self.peer_client.names())
|
|
318
403
|
tools.append({
|
|
319
404
|
"type": "function", "function": {
|
|
@@ -327,6 +412,18 @@ class MessageProcessor:
|
|
|
327
412
|
}, "required": ["bot", "command"]},
|
|
328
413
|
},
|
|
329
414
|
})
|
|
415
|
+
if self.config.aigfm_image_gen_enabled and self.image_gen:
|
|
416
|
+
tools.append({
|
|
417
|
+
"type": "function", "function": {
|
|
418
|
+
"name": "generate_image",
|
|
419
|
+
"description": "使用 AI 生图模型根据描述生成图片并自动发到群里(仅当用户明确要求生成/画图时才调用)",
|
|
420
|
+
"parameters": {"type": "object", "properties": {
|
|
421
|
+
"prompt": {"type": "string", "description": "详细的图片描述(画面内容、风格、色调、比例等),越具体生成效果越好"},
|
|
422
|
+
"size": {"type": "string", "description": "可选尺寸/比例:square/方形、portrait/竖版、landscape/横版、16:9/宽幅,或直接写宽x高(如 768x1024);默认方形,程序会自动限制在配置的最大尺寸内"},
|
|
423
|
+
"reference_cache_id": {"type": "string", "description": "可选参考图 id:聊天记录中出现的图片 id(如 [发送了一张图片, id: xxx] 或 [回复 xxx 的消息: \"[发送了一张图片, id: xxx]...\"] 中的 xxx);用户要求\"参考这张图/照着改/换成某种风格\"时填,不要编造不存在的 id,不需要时省略"}
|
|
424
|
+
}, "required": ["prompt"]},
|
|
425
|
+
},
|
|
426
|
+
})
|
|
330
427
|
|
|
331
428
|
async def handler(name: str, args: dict) -> str:
|
|
332
429
|
if name == "search_internet" and self.search:
|
|
@@ -386,6 +483,23 @@ class MessageProcessor:
|
|
|
386
483
|
content=f"已调用命令「{command}」({peer_name},user_id={uid})",
|
|
387
484
|
))
|
|
388
485
|
return result
|
|
486
|
+
elif name == "generate_image":
|
|
487
|
+
prompt = args.get("prompt", "").strip()
|
|
488
|
+
if not prompt:
|
|
489
|
+
return "缺少图片描述,请提供要生成的画面内容"
|
|
490
|
+
size_desc = args.get("size", "").strip()
|
|
491
|
+
reference_id = args.get("reference_cache_id", "").strip()
|
|
492
|
+
uid = args.get("user_id", self._current_user_id)
|
|
493
|
+
logger.info(f"[生图] 开始: prompt={prompt[:60]}, size={size_desc or '默认方形'}, reference={reference_id or '无'}, user_id={uid}")
|
|
494
|
+
# 防重复:立即写自述,后台完成后另写一条结果自述
|
|
495
|
+
self.recent_messages.append(ChatMessage(
|
|
496
|
+
time=datetime.now(), user_name=self.bot_name,
|
|
497
|
+
content=f"已开始生成图片「{prompt}」",
|
|
498
|
+
))
|
|
499
|
+
task = asyncio.create_task(self._generate_image_bg(prompt, size_desc, uid, reference_id))
|
|
500
|
+
self._image_gen_tasks.add(task)
|
|
501
|
+
task.add_done_callback(self._image_gen_tasks.discard)
|
|
502
|
+
return "图片生成任务已启动,完成后会自动发到群里;生成需要一点时间,期间请不要重复提交相同的生图请求"
|
|
389
503
|
return f"未知工具: {name}"
|
|
390
504
|
|
|
391
505
|
return await self.llm.chat_with_tools(
|
|
@@ -394,6 +508,86 @@ class MessageProcessor:
|
|
|
394
508
|
first_call_json=self.config.aigfm_llm_tools_json_strict,
|
|
395
509
|
)
|
|
396
510
|
|
|
511
|
+
async def _generate_image_bg(self, prompt: str, size_desc: str, uid: int, reference_id: str = ""):
|
|
512
|
+
"""后台生图任务:生成 → 发送到群 → VLM 描述 → 写自述(LLM 下一次感知)"""
|
|
513
|
+
try:
|
|
514
|
+
if not (self._bot and self.image_gen):
|
|
515
|
+
self.recent_messages.append(ChatMessage(
|
|
516
|
+
time=datetime.now(), user_name=self.bot_name,
|
|
517
|
+
content="图片生成失败:生图功能未就绪",
|
|
518
|
+
))
|
|
519
|
+
return
|
|
520
|
+
size = _normalize_size(size_desc, self.config.aigfm_image_gen_max_size,
|
|
521
|
+
self.config.aigfm_image_gen_min_size)
|
|
522
|
+
# 参考图:先查本群缓存索引,未命中则按 id 从磁盘兜底(clear_cache 只清索引不清文件)
|
|
523
|
+
reference_images: list[str] | None = None
|
|
524
|
+
reference_note = ""
|
|
525
|
+
if reference_id:
|
|
526
|
+
ref_path = None
|
|
527
|
+
info = self.memes.get_cache_info(int(self.group_id), reference_id)
|
|
528
|
+
if info and info.get("path") and Path(info["path"]).is_file():
|
|
529
|
+
ref_path = info["path"]
|
|
530
|
+
else:
|
|
531
|
+
disk_path = self.memes.get_cache_file(reference_id)
|
|
532
|
+
if disk_path:
|
|
533
|
+
ref_path = disk_path
|
|
534
|
+
if ref_path:
|
|
535
|
+
async with await anyio.open_file(ref_path, "rb") as f:
|
|
536
|
+
ref_bytes = await f.read()
|
|
537
|
+
ext = Path(ref_path).suffix.lstrip(".").lower() or "png"
|
|
538
|
+
reference_images = [f"data:image/{ext};base64,{base64.b64encode(ref_bytes).decode()}"]
|
|
539
|
+
else:
|
|
540
|
+
logger.warning(f"[生图] 参考图不可用: {reference_id},改为普通生成")
|
|
541
|
+
reference_note = "(参考图不可用,已改为普通生成)"
|
|
542
|
+
result = await self.image_gen.generate(
|
|
543
|
+
prompt, size,
|
|
544
|
+
reference_images=reference_images,
|
|
545
|
+
watermark=self.config.aigfm_image_gen_watermark,
|
|
546
|
+
)
|
|
547
|
+
if not result:
|
|
548
|
+
self.recent_messages.append(ChatMessage(
|
|
549
|
+
time=datetime.now(), user_name=self.bot_name,
|
|
550
|
+
content=f"图片生成失败:服务未返回图片(「{prompt}」)",
|
|
551
|
+
))
|
|
552
|
+
return
|
|
553
|
+
# 取图片数据(仅返回 url 时下载)
|
|
554
|
+
b64 = result.get("b64")
|
|
555
|
+
if not b64 and result.get("url"):
|
|
556
|
+
async with httpx.AsyncClient() as client:
|
|
557
|
+
resp = await client.get(result["url"], timeout=30)
|
|
558
|
+
resp.raise_for_status()
|
|
559
|
+
b64 = base64.b64encode(resp.content).decode()
|
|
560
|
+
if not b64:
|
|
561
|
+
self.recent_messages.append(ChatMessage(
|
|
562
|
+
time=datetime.now(), user_name=self.bot_name,
|
|
563
|
+
content="图片生成失败:无法获取图片数据",
|
|
564
|
+
))
|
|
565
|
+
return
|
|
566
|
+
await self._bot.send_msg(
|
|
567
|
+
message_type="group", group_id=int(self.group_id),
|
|
568
|
+
message=Message(MessageSegment.image(f"base64://{b64}")),
|
|
569
|
+
)
|
|
570
|
+
# VLM 描述(本插件自己发的图不会被钩子捕获,需主动描述让 LLM 感知结果)
|
|
571
|
+
desc_text = ""
|
|
572
|
+
if self.image_handler:
|
|
573
|
+
try:
|
|
574
|
+
info = await self.image_handler.describe(b64, is_sticker=False)
|
|
575
|
+
if info and info.description:
|
|
576
|
+
desc_text = f",内容描述: {info.description}"
|
|
577
|
+
except Exception:
|
|
578
|
+
desc_text = ""
|
|
579
|
+
logger.success(f"[生图] 已生成并发送: {prompt[:60]} ({size})")
|
|
580
|
+
self.recent_messages.append(ChatMessage(
|
|
581
|
+
time=datetime.now(), user_name=self.bot_name,
|
|
582
|
+
content=f"图片已生成:「{prompt}」({size}){desc_text}{reference_note}",
|
|
583
|
+
))
|
|
584
|
+
except Exception as e:
|
|
585
|
+
logger.error(f"[生图] 失败: {e}")
|
|
586
|
+
self.recent_messages.append(ChatMessage(
|
|
587
|
+
time=datetime.now(), user_name=self.bot_name,
|
|
588
|
+
content=f"图片生成失败:{e}",
|
|
589
|
+
))
|
|
590
|
+
|
|
397
591
|
async def _save_memes(self, save_meme: list, cached_stickers: list[dict]):
|
|
398
592
|
current_ids = {s["id"] for s in cached_stickers}
|
|
399
593
|
for item in save_meme if isinstance(save_meme, list) else [save_meme]:
|
|
@@ -274,11 +274,12 @@ def build_prompt(
|
|
|
274
274
|
```json
|
|
275
275
|
{short_term_str}
|
|
276
276
|
```
|
|
277
|
-
|
|
278
|
-
- modify
|
|
279
|
-
- delete
|
|
277
|
+
这是你对**近期对话**的临时记忆,需要频繁维护:对话有新进展→修改;话题结束或被取代→删除;重要且长期有效的信息→**提升到长期记忆**(群友的个人信息写入群友信息,群规/重大事件写入长期记忆),写入后从短期记忆删除。
|
|
278
|
+
- modify:对话有新进展时直接更新已有条目(优先使用,如约定的时间/地点变了就改写原条目)
|
|
279
|
+
- delete:已结束的话题、已解决的问题、被新对话取代的旧要点(积极使用)
|
|
280
280
|
- add:仅当列表中没有相关内容时才添加
|
|
281
281
|
index 对应上面数组的下标(从 0 开始)。
|
|
282
|
+
短期记忆是临时的:保留当前话题的要点即可(建议不超过 8 条),超过时删除最旧或最不相关的,让列表跟随对话流动。
|
|
282
283
|
值得记:本群近期的话题、群友的临时约定(如"明天一起开黑")、本次对话中值得留存的要点。
|
|
283
284
|
|
|
284
285
|
### 长期记忆
|
|
@@ -411,6 +412,9 @@ index 对应上面数组的下标(从 0 开始)。
|
|
|
411
412
|
- 不要对图片内容发表评论,除非群友@了你
|
|
412
413
|
- 合并聊天记录([收到一条合并聊天记录])→ 不回复,通常不是发给你的
|
|
413
414
|
- 小程序/卡片链接([小程序/卡片: ...])→ 不回复,通常不是发给你的
|
|
415
|
+
- 语音/视频/文件(内容不可见)→ 不主动回复,确需回应时简短说明「听不到/看不到内容」
|
|
416
|
+
- QQ表情/链接分享/位置/音乐(内容可识别)→ 通常不是发给你的,视情况正常应对
|
|
417
|
+
- `[系统]` 开头的通知(戳一戳/禁言/进出群/消息撤回)→ 一般静默不回复;新人进群可以发欢迎词;被 @ 或话题与你相关才回应;被戳时可以根据你的社交能量适当回应
|
|
414
418
|
|
|
415
419
|
## 其他规则
|
|
416
420
|
- 不要编造表情包 id,只能使用上面列出的
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-aigf-master
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: 群聊特化LLM聊天机器人(增强版),具有记忆、表情包、跨插件调用和跨bot通信能力
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: <4.0,>=3.10
|
|
@@ -35,6 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
## 📖 介绍
|
|
36
36
|
|
|
37
37
|
一个基于 NoneBot2 的群聊 AI 助手插件,能够自主收集和发送表情包、联网搜索知识、对群内信息进行记忆,还能够感知同实例内其它插件的输出、通过 LLM 调用其它插件、自主学习群内命令用法,并能通过子插件与其它 bot 通信获取消息和远程调用命令。
|
|
38
|
+
> 本项目有AI高度参与,若有做得不够好及需要改进的地方,欢迎在issue提出意见。
|
|
38
39
|
|
|
39
40
|
### 核心能力
|
|
40
41
|
|
|
@@ -45,6 +46,7 @@ Dynamic: license-file
|
|
|
45
46
|
- 🖼️ **表情包功能**:AI 自主决定发表情包;自动从群聊中收藏
|
|
46
47
|
- 🔍 **联网搜索**:支持 Tavily / Bocha / Bing,自动搜索不懂的梗和网络用语
|
|
47
48
|
- 🧠 **四层记忆系统**:短期记忆、长期记忆、群友信息、文化记忆,LLM 自主管理
|
|
49
|
+
- 🎨 **AI 生图**:LLM可调用生图模型生成图片发到群里,尺寸由 LLM 决定(程序限最大尺寸),支持参考图改绘(回复图片消息或从聊天记录选图)
|
|
48
50
|
|
|
49
51
|
### 基础能力
|
|
50
52
|
|
|
@@ -52,6 +54,8 @@ Dynamic: license-file
|
|
|
52
54
|
- 💬 **口语化回复**:像平时聊天一样自然,回复长度有变化,允许敷衍
|
|
53
55
|
- 📨 **消息合并**:同一用户连续消息自动合并,避免碎片化上下文
|
|
54
56
|
- ⏱️ **不完整消息检测**:纯 @ 消息或连续发送时自动延长等待时间
|
|
57
|
+
- 🕐 **消息时间标注**:可选为 LLM 看到的消息加上 `[MM-DD HH:MM]` 时间前缀(`AIGFM_SHOW_MESSAGE_TIME`)
|
|
58
|
+
- 📄 **完整消息类型**:合并聊天记录、小程序/卡片、XML 消息均可解析与捕获
|
|
55
59
|
- 🔒 **强制 JSON 输出**:确保 LLM 始终返回正确格式
|
|
56
60
|
- ⚡ **轻量高效**:单次 LLM 调用完成对话 + 记忆管理 + 命令学习
|
|
57
61
|
|
|
@@ -111,6 +115,15 @@ AIGFM_VLM_API_KEY="" # VLM API Key(为空时使用 chat 的
|
|
|
111
115
|
### 可选
|
|
112
116
|
|
|
113
117
|
```env
|
|
118
|
+
# --- AI 生图 ---
|
|
119
|
+
AIGFM_IMAGE_GEN_ENABLED=false # 是否启用 AI 生图(默认 false)
|
|
120
|
+
AIGFM_IMAGE_GEN_MODEL="..." # 生图模型名称(豆包/Ark 示例: doubao-seedream-5-0-lite-260128)
|
|
121
|
+
AIGFM_IMAGE_GEN_BASE_URL="https://..." # 生图 API 地址(OpenAI 兼容 images API;豆包示例 https://ark.cn-beijing.volces.com/api/v3)
|
|
122
|
+
AIGFM_IMAGE_GEN_API_KEY="" # 生图 API Key(为空时使用 chat 的 key)
|
|
123
|
+
AIGFM_IMAGE_GEN_MAX_SIZE="1024x1024" # 生成最大尺寸限制(宽x高,LLM 可指定比例/尺寸,程序自动缩放到不超过该上限)
|
|
124
|
+
AIGFM_IMAGE_GEN_MIN_SIZE="" # 生成最小尺寸(宽x高,为空不限制;用于满足服务最低像素要求,如豆包 Seedream 需 "1920x1920" 即 3686400 像素,非空时按比例放大到不小于该面积)
|
|
125
|
+
AIGFM_IMAGE_GEN_WATERMARK=false # 生图水印(参考豆包类服务,仅参考图时传入,默认 false)
|
|
126
|
+
|
|
114
127
|
# --- 基础 ---
|
|
115
128
|
AIGFM_MEME_ENABLED=true # 是否启用表情包功能(默认 true)
|
|
116
129
|
AIGFM_MEME_MAX_COUNT=200 # 自动收集的表情包最大数量(默认 200)
|
|
@@ -122,6 +135,7 @@ AIGFM_BATCH_TIMEOUT=30.0 # 距最后一条消息多少秒后触
|
|
|
122
135
|
AIGFM_INCOMPLETE_TIMEOUT=40.0 # 消息可能不完整时的等待时间/秒(默认 40.0;替代默认 30s 生效,非叠加)
|
|
123
136
|
AIGFM_RECENT_MESSAGES=20 # prompt 中包含的最近历史消息条数(默认 20,最大 50)
|
|
124
137
|
AIGFM_MERGE_WINDOW=5.0 # 消息合并时间窗口/秒(默认 5.0)
|
|
138
|
+
AIGFM_SHOW_MESSAGE_TIME=false # LLM 看到的消息是否带 [MM-DD HH:MM] 时间前缀(默认 false)
|
|
125
139
|
|
|
126
140
|
# --- LLM ---
|
|
127
141
|
AIGFM_LLM_JSON_MODE=true # 是否强制 LLM 输出 JSON(默认 true)
|
|
@@ -222,6 +236,9 @@ LLM 在聊天记录中看到:[插件名]: 插件的输出
|
|
|
222
236
|
|------|---------|
|
|
223
237
|
| 文本 | 直接以 `[来源]: '内容'` 进入上下文 |
|
|
224
238
|
| 图片 | 先经 VLM 转成文字描述,再以 `[图片] 描述` 进入上下文(`AIGFM_CAPTURE_IMAGES=false` 时不捕获) |
|
|
239
|
+
| 合并聊天记录 | 以 `[收到一条合并聊天记录]` 进入上下文 |
|
|
240
|
+
| 小程序/卡片 | 以 `[小程序/卡片: 标题, 描述]` 进入上下文 |
|
|
241
|
+
| XML | 以 `[收到一条XML消息]` 进入上下文 |
|
|
225
242
|
|
|
226
243
|
图片的来源字段依次支持:
|
|
227
244
|
|
|
@@ -6,6 +6,7 @@ nonebot_plugin_aigf_master/api_hooks.py
|
|
|
6
6
|
nonebot_plugin_aigf_master/command_learner.py
|
|
7
7
|
nonebot_plugin_aigf_master/config.py
|
|
8
8
|
nonebot_plugin_aigf_master/context_bus.py
|
|
9
|
+
nonebot_plugin_aigf_master/image_gen_client.py
|
|
9
10
|
nonebot_plugin_aigf_master/image_handler.py
|
|
10
11
|
nonebot_plugin_aigf_master/llm_client.py
|
|
11
12
|
nonebot_plugin_aigf_master/meme_store.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|