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.
Files changed (30) hide show
  1. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/PKG-INFO +18 -1
  2. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/README.md +17 -0
  3. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/__init__.py +219 -10
  4. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/config.py +8 -0
  5. nonebot_plugin_aigf_master-1.4.0/nonebot_plugin_aigf_master/image_gen_client.py +42 -0
  6. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/meme_store.py +12 -0
  7. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/processor.py +199 -5
  8. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/prompt_builder.py +7 -3
  9. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master.egg-info/PKG-INFO +18 -1
  10. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master.egg-info/SOURCES.txt +1 -0
  11. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/pyproject.toml +1 -1
  12. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/LICENSE +0 -0
  13. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/api_hooks.py +0 -0
  14. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/command_learner.py +0 -0
  15. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/context_bus.py +0 -0
  16. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/image_handler.py +0 -0
  17. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/llm_client.py +0 -0
  18. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/memory_store.py +0 -0
  19. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/models.py +0 -0
  20. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/peer_client.py +0 -0
  21. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/plugin_discovery.py +0 -0
  22. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/plugin_invoker.py +0 -0
  23. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/preset_store.py +0 -0
  24. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/response_parser.py +0 -0
  25. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/search_client.py +0 -0
  26. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master/vlm_client.py +0 -0
  27. {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
  28. {nonebot_plugin_aigf_master-1.2.0 → nonebot_plugin_aigf_master-1.4.0}/nonebot_plugin_aigf_master.egg-info/requires.txt +0 -0
  29. {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
  30. {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.2.0
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
- reply_msg_id = seg.data.get("message_id")
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 = _extract_reply_text(original["message"])
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
- except Exception:
197
- pass
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 _extract_reply_text(message_content) -> str:
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
- text = msg.extract_plain_text().strip()
223
- return text[:50] if text else "(非文字消息)"
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 .command_learner import CommandLearner
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]) -> tuple[str | None, bool]:
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.2.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nonebot-plugin-aigf-master"
7
- version = "1.2.0"
7
+ version = "1.4.0"
8
8
  description = "群聊特化LLM聊天机器人(增强版),具有记忆、表情包、跨插件调用和跨bot通信能力"
9
9
  readme = "README.md"
10
10
  license = "MIT"