From 59ecef8782c81f5c51dffdb40722ea0232c569d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=80=E6=B4=9B=E8=8A=99?= <202204100317@stu.zafu.edu.cn> Date: Sun, 7 Jun 2026 05:14:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E4=B8=BB=E5=8A=A8=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=94=AF=E6=8C=81=E9=85=8D=E5=9B=BE=EF=BC=88=E4=B8=89?= =?UTF-8?q?=E6=80=81=E5=BC=80=E5=85=B3=EF=BC=8C=E4=B8=8D=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E7=89=B9=E5=AE=9A=E7=94=9F=E5=9B=BE=E6=8F=92=E4=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 issue #73:主动消息可根据文本内容生成配图,提供「不生图 / Bot 自行 判断 / 直接生图」三种模式,群聊与私聊各自独立配置。 实现要点: - provider 无关:通过工具循环 Agent + get_full_tool_set 暴露的全部已注册 LLM 工具,由主模型自行判断并调用任意已安装的生图插件,不绑定 aiimg 等 特定插件;未安装任何生图插件时自动降级为纯文本。 - 图片回流自管:新增「捕获型」合成事件(继承 AstrMessageEvent,重写 send 拦截图片到缓冲区),使生图插件产出的图片不直接发往平台,而是交还本插件, 统一走既有分段 / 装饰钩子 / 平台历史持久化的发送流程。 - 不改动 AstrBot 源码:仅继承公开基类、调用 context 公开 API。 - 失败静默降级:生图任何环节出错只记录日志并回退纯文本,绝不把错误信息塞进 发送给用户的消息内容。 新增 core/image_generator.py;_conf_schema.json 为 friend/group 各增 image_settings; message_sender 在文本发送后接入配图发送;main.py 注册 ImageMixin。 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- _conf_schema.json | 56 ++++++++++++ core/image_generator.py | 195 ++++++++++++++++++++++++++++++++++++++++ core/message_sender.py | 13 +++ main.py | 2 + 4 files changed, 266 insertions(+) create mode 100644 core/image_generator.py diff --git a/_conf_schema.json b/_conf_schema.json index 98acd6e..a13caff 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -158,6 +158,34 @@ } } }, + "image_settings": { + "description": "🖼️ 私聊主动消息配图设置", + "type": "object", + "items": { + "mode": { + "description": "🎨 配图模式", + "type": "string", + "options": [ + "off", + "auto", + "always" + ], + "default": "off", + "labels": [ + "不生图", + "Bot 自行判断", + "直接生图" + ], + "hint": "off=只发文本;auto=让模型判断这条消息是否适合配图并自行调用可用的生图工具;always=尽量为每条主动消息配图。依赖你已安装的任意生图插件(向主 LLM 注册了工具),未安装则自动只发文本。" + }, + "extra_prompt": { + "description": "📝 配图引导补充(可选)", + "type": "string", + "default": "", + "hint": "附加在配图决策提示后的额外说明,例如画风偏好。留空则仅依据消息内容自行判断。" + } + } + }, "segmented_reply_settings": { "description": "🔪 分段回复设置", "type": "object", @@ -435,6 +463,34 @@ } } }, + "image_settings": { + "description": "🖼️ 群聊主动消息配图设置", + "type": "object", + "items": { + "mode": { + "description": "🎨 配图模式", + "type": "string", + "options": [ + "off", + "auto", + "always" + ], + "default": "off", + "labels": [ + "不生图", + "Bot 自行判断", + "直接生图" + ], + "hint": "off=只发文本;auto=让模型判断这条消息是否适合配图并自行调用可用的生图工具;always=尽量为每条主动消息配图。依赖你已安装的任意生图插件(向主 LLM 注册了工具),未安装则自动只发文本。" + }, + "extra_prompt": { + "description": "📝 配图引导补充(可选)", + "type": "string", + "default": "", + "hint": "附加在配图决策提示后的额外说明,例如画风偏好。留空则仅依据消息内容自行判断。" + } + } + }, "segmented_reply_settings": { "description": "🔪 分段回复设置", "type": "object", diff --git a/core/image_generator.py b/core/image_generator.py new file mode 100644 index 0000000..692d969 --- /dev/null +++ b/core/image_generator.py @@ -0,0 +1,195 @@ +"""主动消息配图能力。 + +通过 AstrBot 的工具循环 Agent,让主 LLM 在生成主动消息后自行判断/调用 +任意已安装的生图插件(向主 LLM 注册了工具的插件)来生成配图。生成的图片 +不会由生图插件直接发往平台,而是被一个“捕获型”合成事件拦截、收集后交还给 +本插件,统一走主动消息既有的发送流程(分段、装饰钩子、平台历史持久化)。 + +设计要点: +- provider 无关:不绑定任何特定生图插件,依赖 ``get_full_tool_set`` 暴露的 + 全部已注册工具,由模型自行选择。 +- 不改动 AstrBot 源码:仅继承公开基类、调用公开 API。 +- 失败静默降级:任何环节出错都只记录日志、回退为纯文本,绝不把错误信息塞进 + 发送给用户的消息内容。 +""" + +from __future__ import annotations + +import time +import uuid +from typing import Any + +from astrbot.api import logger + +try: + from astrbot.core.message.components import Image, Plain + from astrbot.core.message.message_event_result import MessageChain + from astrbot.core.platform.astr_message_event import AstrMessageEvent + from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember + from astrbot.core.platform.message_session import MessageSession + from astrbot.core.platform.message_type import MessageType + from astrbot.core.platform.platform_metadata import PlatformMetadata + + _IMAGE_AGENT_AVAILABLE = True +except ImportError as _e: # pragma: no cover - 取决于宿主版本 + _IMAGE_AGENT_AVAILABLE = False + logger.warning(f"[主动消息] 当前 AstrBot 版本不支持配图 Agent 所需组件喵: {_e}") + + +# 占位符,便于在依赖缺失时仍能定义类型注解。 +_BaseEvent = AstrMessageEvent if _IMAGE_AGENT_AVAILABLE else object + + +class _ImageCaptureEvent(_BaseEvent): # type: ignore[misc, valid-type] + """捕获型合成事件。 + + 参考 AstrBot 官方的 ``CronMessageEvent`` 构造一个不绑定真实平台连接的合成 + 事件,供工具循环 Agent 使用。与之不同的是:本事件重写 ``send`` ,把生图 + 插件试图发送的图片 **拦截到内部缓冲区** ,而不是真正发往平台——从而让本 + 插件能拿回图片、自行决定如何发送。 + """ + + def __init__( + self, + *, + context: Any, + session: "MessageSession", + message: str, + message_type: "MessageType", + ) -> None: + platform_meta = PlatformMetadata( + name="proactive_image", + description="ProactiveChat 配图合成事件", + id=session.platform_id, + ) + + msg_obj = AstrBotMessage() + msg_obj.type = message_type + msg_obj.self_id = "astrbot" + msg_obj.session_id = session.session_id + msg_obj.message_id = uuid.uuid4().hex + msg_obj.sender = MessageMember(user_id=session.session_id, nickname="主动消息") + msg_obj.message = [Plain(message)] + msg_obj.message_str = message + msg_obj.raw_message = message + msg_obj.timestamp = int(time.time()) + + super().__init__(message, msg_obj, platform_meta, session.session_id) + + # 使用原始会话,保证工具内部读取 unified_msg_origin 等信息时一致。 + self.session = session + self.context_obj = context + self.is_at_or_wake_command = True + self.is_wake = True + + # 收集被拦截的图片组件,供 Agent 结束后取用。 + self.captured_images: list["Image"] = [] + + async def send(self, message: "MessageChain") -> None: + """拦截发送:仅收集图片组件,不真正发往平台。""" + try: + if message and getattr(message, "chain", None): + for comp in message.chain: + if isinstance(comp, Image): + self.captured_images.append(comp) + except Exception as e: # noqa: BLE001 - 拦截阶段不应影响主流程 + logger.debug(f"[主动消息] 捕获配图组件时出现异常喵: {e!r}") + # 故意不调用 super().send(),避免触发真实平台发送与统计。 + + async def send_streaming(self, generator, use_fallback: bool = False) -> None: + async for chain in generator: + await self.send(chain) + + +# 引导模型决定是否配图的系统提示。 +_AUTO_SYSTEM_PROMPT = ( + "你刚刚生成了一条要主动发给用户的消息。现在请判断这条消息是否适合配一张图片。\n" + "- 如果适合(例如描述了某个画面、场景、物品、表情、心情且配图能增强表达)," + "请调用一个可用的生图工具来生成与消息内容相符的图片。\n" + "- 如果不适合(例如纯粹的问候、提问、抽象表达),则不要调用任何工具,直接结束。\n" + "不要输出多余的解释,只在需要时调用生图工具。" +) +_ALWAYS_SYSTEM_PROMPT = ( + "你刚刚生成了一条要主动发给用户的消息。请调用一个可用的生图工具,生成一张" + "与这条消息内容相符的配图。请直接调用工具,不要输出多余解释。" +) + + +class ImageMixin: + """为主动消息提供“配图”能力的 Mixin。""" + + async def _maybe_generate_proactive_images( + self, session_id: str, text: str, session_config: dict + ) -> list: + """根据配置决定并生成主动消息的配图。 + + 返回一个图片组件列表(可能为空)。任何失败都会被吞掉并返回空列表, + 以保证主动消息至少能以纯文本形式发出。 + """ + image_conf = (session_config or {}).get("image_settings", {}) + mode = str(image_conf.get("mode", "off") or "off").strip().lower() + if mode not in ("auto", "always"): + return [] + + if not _IMAGE_AGENT_AVAILABLE: + logger.warning( + "[主动消息] 当前 AstrBot 版本不支持配图所需的合成事件组件,跳过配图喵。" + ) + return [] + + try: + return await self._run_image_agent(session_id, text, image_conf, mode) + except Exception as e: # noqa: BLE001 - 配图失败不可影响文本发送 + logger.warning(f"[主动消息] 生成主动消息配图失败,将仅发送文本喵: {e!r}") + return [] + + async def _run_image_agent( + self, session_id: str, text: str, image_conf: dict, mode: str + ) -> list: + """用工具循环 Agent 驱动任意已注册的生图工具,收集其产出的图片。""" + context = self.context + + # 解析会话来源,构造合成事件所需的 MessageSession。 + session = MessageSession.from_str(session_id) + + # 拿到当前会话使用的对话 provider;没有则无法驱动 Agent。 + provider_id = await context.get_current_chat_provider_id(session_id) + if not provider_id: + logger.info("[主动消息] 未找到可用对话 provider,跳过配图喵。") + return [] + + # 汇集全部已注册的 LLM 工具(包含用户安装的任意生图插件的工具)。 + tool_manager = context.get_llm_tool_manager() + tool_set = tool_manager.get_full_tool_set() + if tool_set is None or tool_set.empty(): + logger.info("[主动消息] 未发现任何可用的 LLM 工具(如生图插件),跳过配图喵。") + return [] + + capture_event = _ImageCaptureEvent( + context=context, + session=session, + message=text, + message_type=session.message_type, + ) + + base_prompt = _ALWAYS_SYSTEM_PROMPT if mode == "always" else _AUTO_SYSTEM_PROMPT + extra = str(image_conf.get("extra_prompt", "") or "").strip() + system_prompt = f"{base_prompt}\n\n{extra}" if extra else base_prompt + + user_prompt = f"这是要发送的主动消息内容:\n{text}" + + await context.tool_loop_agent( + event=capture_event, + chat_provider_id=provider_id, + prompt=user_prompt, + tools=tool_set, + system_prompt=system_prompt, + max_steps=6, + ) + + images = list(capture_event.captured_images) + if images: + logger.info(f"[主动消息] 配图 Agent 共捕获 {len(images)} 张图片喵。") + else: + logger.info("[主动消息] 配图 Agent 未产出图片(模型判断无需配图或工具未生图)喵。") + return images diff --git a/core/message_sender.py b/core/message_sender.py index f0b87a6..53c193a 100644 --- a/core/message_sender.py +++ b/core/message_sender.py @@ -489,6 +489,19 @@ async def _send_proactive_message(self, session_id: str, text: str) -> None: ) ) + # 配图:根据会话配置决定是否生成并发送图片。 + # 图片由本插件统一发送,走与文本相同的装饰钩子与平台历史持久化流程。 + try: + images = await self._maybe_generate_proactive_images( + session_id, text, session_config + ) + for image in images: + await self._send_chain_with_hooks(session_id, [image]) + await asyncio.sleep(0.3) + except Exception as e: + # 配图属增强能力,任何异常都不应影响已发送的文本主流程。 + logger.warning(f"[主动消息] 发送主动消息配图时出现异常喵: {e!r}") + # Bot 在群聊发言后需要重置沉默计时 if "group" in session_id.lower(): await self._reset_group_silence_timer(session_id) diff --git a/main.py b/main.py index c6102e9..f457e8b 100644 --- a/main.py +++ b/main.py @@ -18,6 +18,7 @@ from .core.chat_flow import ProactiveCoreMixin from .core.data_storage import StorageMixin from .core.llm_adapter import LlmMixin +from .core.image_generator import ImageMixin from .core.message_events import EventsMixin from .core.message_sender import SenderMixin from .core.notification_center import NotificationCenter @@ -38,6 +39,7 @@ class ProactiveChatPlugin( SchedulerMixin, # 定时任务、自动触发与沉默计时 LlmMixin, # 上下文准备与 LLM 调用封装 SenderMixin, # 主动消息发送与装饰钩子 + ImageMixin, # 主动消息配图(工具循环 Agent 驱动任意生图插件) EventsMixin, # 私聊/群聊事件监听处理 LifecycleMixin, # initialize/terminate 生命周期管理 ProactiveCoreMixin, # 主动消息主流程编排 From 7e4ffc7fd737e4f2f9dfdb3018735afe8eac3d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=80=E6=B4=9B=E8=8A=99?= Date: Sun, 7 Jun 2026 21:55:48 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E9=85=8D=E5=9B=BE=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=EF=BC=88=E5=85=88=E5=9B=BE=E5=90=8E=E6=96=87=20/=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB=E7=94=9F=E5=9B=BE=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=B9=B6=E7=BC=93=E5=AD=98=20/=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E7=94=B1=E6=8F=92=E4=BB=B6=E7=AB=AF=E7=94=9F=E6=88=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 issue #73 配图功能基础上: 1. 先图后文,修复文本不等图。 2. 关键词双线自动识别生图工具(可 extra 补 / exclude 排)+ 预选缓存 + 3 次退避。 3. 配图提示词由插件端先生成画面描述,再交给 Agent 据此调用生图工具。 Generated with Claude Code via Happy Co-Authored-By: Claude Co-Authored-By: Happy --- _conf_schema.json | 26 +++- core/image_generator.py | 260 +++++++++++++++++++++++++++++++++++---- core/message_sender.py | 27 ++-- core/plugin_lifecycle.py | 16 +++ main.py | 5 + 5 files changed, 297 insertions(+), 37 deletions(-) diff --git a/_conf_schema.json b/_conf_schema.json index a13caff..0b3d756 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -183,6 +183,18 @@ "type": "string", "default": "", "hint": "附加在配图决策提示后的额外说明,例如画风偏好。留空则仅依据消息内容自行判断。" + }, + "extra_tools": { + "description": "➕ 额外生图工具名(可选)", + "type": "list", + "default": [], + "hint": "默认会按关键词(draw/image/生图/绘图 等,匹配工具名与描述)自动识别生图工具。如果你的生图插件工具名不含这些关键词(例如 nano_banana、flux),在这里补上它的 LLM 工具名,每行一个。" + }, + "exclude_tools": { + "description": "➖ 排除的工具名(可选)", + "type": "list", + "default": [], + "hint": "如果自动识别误把某个非生图工具当成了生图工具(例如 read_image_file 这类),在这里填它的名字排除掉,每行一个。" } } }, @@ -488,6 +500,18 @@ "type": "string", "default": "", "hint": "附加在配图决策提示后的额外说明,例如画风偏好。留空则仅依据消息内容自行判断。" + }, + "extra_tools": { + "description": "➕ 额外生图工具名(可选)", + "type": "list", + "default": [], + "hint": "默认会按关键词(draw/image/生图/绘图 等,匹配工具名与描述)自动识别生图工具。如果你的生图插件工具名不含这些关键词(例如 nano_banana、flux),在这里补上它的 LLM 工具名,每行一个。" + }, + "exclude_tools": { + "description": "➖ 排除的工具名(可选)", + "type": "list", + "default": [], + "hint": "如果自动识别误把某个非生图工具当成了生图工具(例如 read_image_file 这类),在这里填它的名字排除掉,每行一个。" } } }, @@ -667,4 +691,4 @@ } } } -} +} \ No newline at end of file diff --git a/core/image_generator.py b/core/image_generator.py index 692d969..e116361 100644 --- a/core/image_generator.py +++ b/core/image_generator.py @@ -22,6 +22,7 @@ from astrbot.api import logger try: + from astrbot.core.agent.tool import ToolSet from astrbot.core.message.components import Image, Plain from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.astr_message_event import AstrMessageEvent @@ -101,20 +102,6 @@ async def send_streaming(self, generator, use_fallback: bool = False) -> None: await self.send(chain) -# 引导模型决定是否配图的系统提示。 -_AUTO_SYSTEM_PROMPT = ( - "你刚刚生成了一条要主动发给用户的消息。现在请判断这条消息是否适合配一张图片。\n" - "- 如果适合(例如描述了某个画面、场景、物品、表情、心情且配图能增强表达)," - "请调用一个可用的生图工具来生成与消息内容相符的图片。\n" - "- 如果不适合(例如纯粹的问候、提问、抽象表达),则不要调用任何工具,直接结束。\n" - "不要输出多余的解释,只在需要时调用生图工具。" -) -_ALWAYS_SYSTEM_PROMPT = ( - "你刚刚生成了一条要主动发给用户的消息。请调用一个可用的生图工具,生成一张" - "与这条消息内容相符的配图。请直接调用工具,不要输出多余解释。" -) - - class ImageMixin: """为主动消息提供“配图”能力的 Mixin。""" @@ -143,6 +130,204 @@ async def _maybe_generate_proactive_images( logger.warning(f"[主动消息] 生成主动消息配图失败,将仅发送文本喵: {e!r}") return [] + @staticmethod + def _parse_name_list(raw) -> list[str]: + """把配置值解析为名字列表。 + + 兼容 list 与逗号/换行分隔的字符串两种写法,去重并保持顺序。 + """ + names: list[str] = [] + if isinstance(raw, str): + for piece in raw.replace("\n", ",").split(","): + piece = piece.strip() + if piece: + names.append(piece) + elif isinstance(raw, (list, tuple)): + for item in raw: + piece = str(item).strip() + if piece: + names.append(piece) + seen: set[str] = set() + result: list[str] = [] + for n in names: + if n not in seen: + seen.add(n) + result.append(n) + return result + + def _select_image_tools(self, tool_manager, image_conf: dict): + """挑选交给 Agent 的生图工具。 + + 规则:按关键词(工具名 + 描述)自动识别生图工具;再叠加用户配置的 + extra_tools(强制补充,即使不含关键词)与 exclude_tools(强制排除)。 + 返回一个 ToolSet(可能为空)。 + """ + extra = set(self._parse_name_list((image_conf or {}).get("extra_tools", []))) + exclude = set(self._parse_name_list((image_conf or {}).get("exclude_tools", []))) + + try: + all_tools = list(getattr(tool_manager, "func_list", []) or []) + except Exception: # noqa: BLE001 + all_tools = [] + + tool_set = ToolSet() + for tool in all_tools: + name = getattr(tool, "name", "") or "" + if name in exclude: + continue + desc = getattr(tool, "description", "") or "" + hit_keyword = self._looks_like_image_tool(name, desc) + if hit_keyword or name in extra: + tool_set.add_tool(tool) + + # extra 里指向但上面没收进来的(理论上已收,双保险按名字补一次) + for name in extra: + if tool_set.get_tool(name) is None and name not in exclude: + t = tool_manager.get_func(name) if hasattr(tool_manager, "get_func") else None + if t is not None: + tool_set.add_tool(t) + return tool_set + + # 生图相关关键词(小写)。命中工具名或描述即视为候选生图工具。 + # 视频类(video)刻意不收,避免把视频生成工具当配图。 + _IMAGE_KEYWORDS = ( + "draw", "image", "paint", "pic", "photo", "illustr", "t2i", "text2image", + "text-to-image", "stable", "diffusion", "midjourney", "dalle", "dall-e", + "flux", "comfyui", "render", + "生图", "绘图", "绘画", "画图", "配图", "图片", "作画", "出图", "画一", "生成图", + ) + # 强生图信号:命中负向词后,只要工具名或描述里出现这些词,仍判定为生图工具。 + _IMAGE_STRONG = ( + "draw", "paint", "t2i", "text2image", "text-to-image", + "generate image", "generate an image", "image generation", + "生图", "绘图", "绘画", "绘制", "画图", "作画", "配图", + "生成图片", "生成一张", "画一张", "画一幅", + ) + # 明显非生图但可能含 image 字样的工具,关键词命中后再排除一层。 + _IMAGE_NEGATIVE = ( + "read", "ocr", "recogn", "识别", "video", "视频", "understand", "analy", "描述图", + ) + + @classmethod + def _looks_like_image_tool(cls, name: str, description: str) -> bool: + """按关键词判断一个工具是否像“文生图”工具。 + + 名字与描述双线匹配:任一命中生图关键词即为候选;命中负向词时, + 只要名字或描述里仍出现强生图信号,依然保留。 + """ + haystack = f"{name} {description}".lower() + if not any(kw in haystack for kw in cls._IMAGE_KEYWORDS): + return False + # 命中负向词时,要求名字或描述里带强生图信号才保留,否则排除。 + if any(neg in haystack for neg in cls._IMAGE_NEGATIVE): + return any(s in haystack for s in cls._IMAGE_STRONG) + return True + + # 选不到生图工具的最大累计次数,超过则本次运行内永久回退为纯文本。 + _IMAGE_TOOLS_MAX_ATTEMPTS = 3 + + def _ensure_image_tool_names(self, tool_manager, image_conf: dict) -> list[str]: + """返回已选定并缓存的生图工具名;未选定则尝试选一次。 + + - 一旦成功选定就缓存,后续直接复用,不再扫描。 + - 累计选不到达到上限后永久回退(本次运行内不再尝试)。 + """ + if getattr(self, "_image_tools_disabled", False): + return [] + if getattr(self, "_image_tools_selected", False): + return list(self._image_tools_cache) + + tool_set = self._select_image_tools(tool_manager, image_conf) + names = [t.name for t in tool_set.tools] if tool_set else [] + if names: + self._image_tools_cache = names + self._image_tools_selected = True + logger.info("[主动消息] 已找到可用的生图工具喵。") + return list(names) + + # 没选到:累计计数,达到上限则永久回退。 + self._image_tools_attempts = getattr(self, "_image_tools_attempts", 0) + 1 + if self._image_tools_attempts >= self._IMAGE_TOOLS_MAX_ATTEMPTS: + self._image_tools_disabled = True + logger.info( + "[主动消息] 多次未找到可用生图工具喵,已回退为不生图模式(本次运行内不再尝试)。" + ) + else: + logger.info("[主动消息] 暂未找到可用的生图工具喵,稍后重试。") + return [] + + async def prewarm_image_tools(self) -> None: + """插件加载/空闲时预选一次生图工具,避免发送时才扫描。 + + 加载阶段生图插件可能尚未就绪,选不到属正常,不计入失败次数。 + """ + if not _IMAGE_AGENT_AVAILABLE: + return + if getattr(self, "_image_tools_selected", False) or getattr( + self, "_image_tools_disabled", False + ): + return + try: + tool_manager = self.context.get_llm_tool_manager() + except Exception: # noqa: BLE001 + return + # 预热用空配置即可(extra/exclude 在真正发送时按会话配置再算); + # 这里只为尽早把“有没有生图工具”探明并缓存。 + tool_set = self._select_image_tools(tool_manager, {}) + names = [t.name for t in tool_set.tools] if tool_set else [] + if names: + self._image_tools_cache = names + self._image_tools_selected = True + logger.info("[主动消息] 已找到可用的生图工具喵。") + # 预热选不到不记失败、不打 warning,留给发送时按需重试。 + + # auto 模式下,模型判断无需配图时回复的固定标记。 + _NO_IMAGE_TOKEN = "NO_IMAGE" + + async def _generate_image_prompt( + self, provider_id: str, text: str, image_conf: dict, mode: str + ) -> str: + """由插件端调用 LLM,把主动消息文本转成一段“画面描述”(生图提示词)。 + + - always 模式:总是生成一段画面描述。 + - auto 模式:先让模型判断是否适合配图,不适合则返回空字符串。 + 失败一律返回空字符串(跳过配图,不影响文本)。 + """ + extra = str((image_conf or {}).get("extra_prompt", "") or "").strip() + if mode == "always": + system_prompt = ( + "你是配图提示词助手。请根据给定的消息内容,写出一段适合用来生图的" + "画面描述(中文或英文均可),聚焦可视画面:主体、场景、氛围、风格。" + "只输出画面描述本身,不要解释、不要加引号。" + ) + else: + system_prompt = ( + "你是配图提示词助手。请判断给定的消息是否适合配一张图片:\n" + f"- 不适合(如纯问候、提问、抽象表达):只回复 {self._NO_IMAGE_TOKEN}。\n" + "- 适合:写出一段适合用来生图的画面描述(聚焦主体、场景、氛围、风格)," + "只输出画面描述本身,不要解释、不要加引号。" + ) + if extra: + system_prompt = f"{system_prompt}\n补充要求:{extra}" + + try: + resp = await self.context.llm_generate( + chat_provider_id=provider_id, + prompt=f"消息内容:\n{text}", + system_prompt=system_prompt, + ) + except Exception as e: # noqa: BLE001 + logger.warning(f"[主动消息] 生成配图提示词失败喵: {e!r}") + return "" + + prompt = (getattr(resp, "completion_text", "") or "").strip() + if not prompt: + return "" + # auto 模式下命中“无需配图”标记则跳过。 + if mode != "always" and self._NO_IMAGE_TOKEN in prompt.upper(): + return "" + return prompt + async def _run_image_agent( self, session_id: str, text: str, image_conf: dict, mode: str ) -> list: @@ -158,11 +343,21 @@ async def _run_image_agent( logger.info("[主动消息] 未找到可用对话 provider,跳过配图喵。") return [] - # 汇集全部已注册的 LLM 工具(包含用户安装的任意生图插件的工具)。 + # 取已选定(并缓存)的生图工具名;未选定则尝试选一次。 tool_manager = context.get_llm_tool_manager() - tool_set = tool_manager.get_full_tool_set() - if tool_set is None or tool_set.empty(): - logger.info("[主动消息] 未发现任何可用的 LLM 工具(如生图插件),跳过配图喵。") + tool_names = self._ensure_image_tool_names(tool_manager, image_conf) + if not tool_names: + # 已选不到(或已永久回退),_ensure_image_tool_names 内已记日志。 + return [] + + # 用缓存的工具名重建本次调用的 ToolSet。 + tool_set = ToolSet() + for name in tool_names: + tool = tool_manager.get_func(name) + if tool is not None: + tool_set.add_tool(tool) + if tool_set.empty(): + logger.info("[主动消息] 已选定的生图工具当前不可用,跳过配图喵。") return [] capture_event = _ImageCaptureEvent( @@ -172,11 +367,30 @@ async def _run_image_agent( message_type=session.message_type, ) - base_prompt = _ALWAYS_SYSTEM_PROMPT if mode == "always" else _AUTO_SYSTEM_PROMPT - extra = str(image_conf.get("extra_prompt", "") or "").strip() - system_prompt = f"{base_prompt}\n\n{extra}" if extra else base_prompt + # 第一步:由插件端先生成“画面描述”(生图提示词)。 + # auto 模式下若模型判断这条消息不适合配图,会返回空,此时直接跳过。 + image_prompt = await self._generate_image_prompt( + provider_id, text, image_conf, mode + ) + if not image_prompt: + logger.info("[主动消息] 未生成配图提示词(判断无需配图),跳过配图喵。") + return [] + + capture_event = _ImageCaptureEvent( + context=context, + session=session, + message=text, + message_type=session.message_type, + ) - user_prompt = f"这是要发送的主动消息内容:\n{text}" + # 第二步:把插件生成好的画面描述作为明确指令交给 Agent,让它据此调用生图 + # 工具——描述由插件掌控,要求模型原样使用、不要改写或自行编造。 + system_prompt = ( + "你的唯一任务是调用一个可用的生图工具,为下面给出的画面描述生成图片。\n" + "请把画面描述原样作为生图工具的绘画提示词,不要改写、缩写或自行发挥;\n" + "调用工具即可,不要输出多余文字。" + ) + user_prompt = f"画面描述:\n{image_prompt}" await context.tool_loop_agent( event=capture_event, @@ -191,5 +405,5 @@ async def _run_image_agent( if images: logger.info(f"[主动消息] 配图 Agent 共捕获 {len(images)} 张图片喵。") else: - logger.info("[主动消息] 配图 Agent 未产出图片(模型判断无需配图或工具未生图)喵。") + logger.info("[主动消息] 配图 Agent 未产出图片(工具未生图或调用失败)喵。") return images diff --git a/core/message_sender.py b/core/message_sender.py index 53c193a..a720013 100644 --- a/core/message_sender.py +++ b/core/message_sender.py @@ -416,6 +416,20 @@ async def _send_proactive_message(self, session_id: str, text: str) -> None: # 是否继续发送文本:未发出 TTS 或配置要求始终发文本 should_send_text = not is_tts_sent or tts_conf.get("always_send_text", True) + # 配图:在发送文本之前先生成并发送图片(先图后文)。 + # 生图是同步等待的,因此文本会等图片就绪后再发,不会出现“文本先到、图掉队”。 + # 图片由本插件统一发送,走与文本相同的装饰钩子与平台历史持久化流程。 + try: + images = await self._maybe_generate_proactive_images( + session_id, text, session_config + ) + for image in images: + await self._send_chain_with_hooks(session_id, [image]) + await asyncio.sleep(0.3) + except Exception as e: + # 配图属增强能力,任何异常都不应影响后续文本发送。 + logger.warning(f"[主动消息] 发送主动消息配图时出现异常喵: {e!r}") + if should_send_text: enable_seg = seg_conf.get("enable", False) threshold = seg_conf.get("words_count_threshold", 150) @@ -489,19 +503,6 @@ async def _send_proactive_message(self, session_id: str, text: str) -> None: ) ) - # 配图:根据会话配置决定是否生成并发送图片。 - # 图片由本插件统一发送,走与文本相同的装饰钩子与平台历史持久化流程。 - try: - images = await self._maybe_generate_proactive_images( - session_id, text, session_config - ) - for image in images: - await self._send_chain_with_hooks(session_id, [image]) - await asyncio.sleep(0.3) - except Exception as e: - # 配图属增强能力,任何异常都不应影响已发送的文本主流程。 - logger.warning(f"[主动消息] 发送主动消息配图时出现异常喵: {e!r}") - # Bot 在群聊发言后需要重置沉默计时 if "group" in session_id.lower(): await self._reset_group_silence_timer(session_id) diff --git a/core/plugin_lifecycle.py b/core/plugin_lifecycle.py index cf3c28b..c03a918 100644 --- a/core/plugin_lifecycle.py +++ b/core/plugin_lifecycle.py @@ -149,6 +149,22 @@ async def initialize(self) -> None: ) ) + # 预热配图工具:延迟少许,等其它生图插件也加载完成后再探测一次, + # 避免主动消息发送时才扫描工具。选不到不影响主流程。 + prewarm = getattr(self, "prewarm_image_tools", None) + if callable(prewarm): + async def _deferred_prewarm_image_tools() -> None: + try: + await asyncio.sleep(5) + await prewarm() + except Exception as e: # noqa: BLE001 + logger.debug(f"[主动消息] 预热配图工具时出现异常喵: {e!r}") + + try: + asyncio.create_task(_deferred_prewarm_image_tools()) + except Exception as e: # noqa: BLE001 + logger.debug(f"[主动消息] 启动配图工具预热任务失败喵: {e!r}") + async def terminate(self) -> None: """插件被卸载或停用时调用的清理函数。""" logger.info("[主动消息] 收到插件终止指令,开始清理资源喵。") diff --git a/main.py b/main.py index f457e8b..dfcaa63 100644 --- a/main.py +++ b/main.py @@ -100,6 +100,11 @@ def __init__(self, context: star.Context, config: AstrBotConfig) -> None: str, dict ] = {} # 临时态(如群聊最后用户发言时间) self.last_message_times: dict[str, float] = {} # 会话最近消息时间,用于触发判断 + # 配图工具选择缓存:避免每次发送都扫描全部工具。 + self._image_tools_cache: list[str] = [] # 已选定的生图工具名 + self._image_tools_selected: bool = False # 是否已成功选定 + self._image_tools_attempts: int = 0 # 累计选不到的次数 + self._image_tools_disabled: bool = False # 连续 3 次选不到后永久回退 self.auto_trigger_timers: dict[ str, asyncio.TimerHandle ] = {} # 自动触发计时器句柄 From 13c72e6cf8efed7ebd5114d07102c6c223ee1a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=80=E6=B4=9B=E8=8A=99?= Date: Sun, 7 Jun 2026 22:10:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?style:=20=E6=8C=89=20ruff=20format=20?= =?UTF-8?q?=E6=95=B4=E7=90=86=E6=A0=BC=E5=BC=8F=E5=B9=B6=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=BF=87=E6=97=B6=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with Claude Code via Happy Co-Authored-By: Claude Co-Authored-By: Happy --- core/image_generator.py | 89 ++++++++++++++++++++++++++++++++-------- core/plugin_lifecycle.py | 1 + 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/core/image_generator.py b/core/image_generator.py index e116361..5271328 100644 --- a/core/image_generator.py +++ b/core/image_generator.py @@ -1,14 +1,15 @@ """主动消息配图能力。 -通过 AstrBot 的工具循环 Agent,让主 LLM 在生成主动消息后自行判断/调用 -任意已安装的生图插件(向主 LLM 注册了工具的插件)来生成配图。生成的图片 -不会由生图插件直接发往平台,而是被一个“捕获型”合成事件拦截、收集后交还给 -本插件,统一走主动消息既有的发送流程(分段、装饰钩子、平台历史持久化)。 +通过 AstrBot 的工具循环 Agent,让主 LLM 调用已安装的生图插件(向主 LLM +注册了工具的插件)来生成配图。生成的图片不会由生图插件直接发往平台,而是被一个 +“捕获型”合成事件拦截、收集后交还给本插件,统一走主动消息既有的发送流程 +(分段、装饰钩子、平台历史持久化)。 设计要点: -- provider 无关:不绑定任何特定生图插件,依赖 ``get_full_tool_set`` 暴露的 - 全部已注册工具,由模型自行选择。 -- 不改动 AstrBot 源码:仅继承公开基类、调用公开 API。 +- provider 无关:不绑定任何特定生图插件,按关键词(工具名 + 描述)自动识别生图 + 工具,并支持用户用 extra_tools 补充、exclude_tools 排除。 +- 提示词由插件端生成:先由插件调用 LLM 生成画面描述,再交给 Agent 据此调用生图 + 工具,而非让模型在工具循环里自行编写提示词。 - 失败静默降级:任何环节出错都只记录日志、回退为纯文本,绝不把错误信息塞进 发送给用户的消息内容。 """ @@ -163,7 +164,9 @@ def _select_image_tools(self, tool_manager, image_conf: dict): 返回一个 ToolSet(可能为空)。 """ extra = set(self._parse_name_list((image_conf or {}).get("extra_tools", []))) - exclude = set(self._parse_name_list((image_conf or {}).get("exclude_tools", []))) + exclude = set( + self._parse_name_list((image_conf or {}).get("exclude_tools", [])) + ) try: all_tools = list(getattr(tool_manager, "func_list", []) or []) @@ -183,7 +186,11 @@ def _select_image_tools(self, tool_manager, image_conf: dict): # extra 里指向但上面没收进来的(理论上已收,双保险按名字补一次) for name in extra: if tool_set.get_tool(name) is None and name not in exclude: - t = tool_manager.get_func(name) if hasattr(tool_manager, "get_func") else None + t = ( + tool_manager.get_func(name) + if hasattr(tool_manager, "get_func") + else None + ) if t is not None: tool_set.add_tool(t) return tool_set @@ -191,21 +198,67 @@ def _select_image_tools(self, tool_manager, image_conf: dict): # 生图相关关键词(小写)。命中工具名或描述即视为候选生图工具。 # 视频类(video)刻意不收,避免把视频生成工具当配图。 _IMAGE_KEYWORDS = ( - "draw", "image", "paint", "pic", "photo", "illustr", "t2i", "text2image", - "text-to-image", "stable", "diffusion", "midjourney", "dalle", "dall-e", - "flux", "comfyui", "render", - "生图", "绘图", "绘画", "画图", "配图", "图片", "作画", "出图", "画一", "生成图", + "draw", + "image", + "paint", + "pic", + "photo", + "illustr", + "t2i", + "text2image", + "text-to-image", + "stable", + "diffusion", + "midjourney", + "dalle", + "dall-e", + "flux", + "comfyui", + "render", + "生图", + "绘图", + "绘画", + "画图", + "配图", + "图片", + "作画", + "出图", + "画一", + "生成图", ) # 强生图信号:命中负向词后,只要工具名或描述里出现这些词,仍判定为生图工具。 _IMAGE_STRONG = ( - "draw", "paint", "t2i", "text2image", "text-to-image", - "generate image", "generate an image", "image generation", - "生图", "绘图", "绘画", "绘制", "画图", "作画", "配图", - "生成图片", "生成一张", "画一张", "画一幅", + "draw", + "paint", + "t2i", + "text2image", + "text-to-image", + "generate image", + "generate an image", + "image generation", + "生图", + "绘图", + "绘画", + "绘制", + "画图", + "作画", + "配图", + "生成图片", + "生成一张", + "画一张", + "画一幅", ) # 明显非生图但可能含 image 字样的工具,关键词命中后再排除一层。 _IMAGE_NEGATIVE = ( - "read", "ocr", "recogn", "识别", "video", "视频", "understand", "analy", "描述图", + "read", + "ocr", + "recogn", + "识别", + "video", + "视频", + "understand", + "analy", + "描述图", ) @classmethod diff --git a/core/plugin_lifecycle.py b/core/plugin_lifecycle.py index c03a918..a0ee582 100644 --- a/core/plugin_lifecycle.py +++ b/core/plugin_lifecycle.py @@ -153,6 +153,7 @@ async def initialize(self) -> None: # 避免主动消息发送时才扫描工具。选不到不影响主流程。 prewarm = getattr(self, "prewarm_image_tools", None) if callable(prewarm): + async def _deferred_prewarm_image_tools() -> None: try: await asyncio.sleep(5) From 059b14c8c3223d482d651c9afd0e1d1bfe016cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=80=E6=B4=9B=E8=8A=99?= Date: Sun, 7 Jun 2026 22:21:18 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E6=8C=89=20review=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E9=85=8D=E5=9B=BE=E6=A8=A1=E5=9D=97=E7=9A=84=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with Claude Code via Happy Co-Authored-By: Claude Co-Authored-By: Happy --- core/image_generator.py | 30 ++++++++++++++++++------------ core/plugin_lifecycle.py | 6 +++++- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/core/image_generator.py b/core/image_generator.py index 5271328..c420ec6 100644 --- a/core/image_generator.py +++ b/core/image_generator.py @@ -25,7 +25,6 @@ try: from astrbot.core.agent.tool import ToolSet from astrbot.core.message.components import Image, Plain - from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember from astrbot.core.platform.message_session import MessageSession @@ -87,11 +86,22 @@ def __init__( # 收集被拦截的图片组件,供 Agent 结束后取用。 self.captured_images: list["Image"] = [] - async def send(self, message: "MessageChain") -> None: - """拦截发送:仅收集图片组件,不真正发往平台。""" + async def send(self, message: Any) -> None: + """拦截发送:仅收集图片组件,不真正发往平台。 + + 兼容多种传入形式:MessageChain(含 .chain)、组件列表/元组、单个组件—— + 因为第三方生图插件调用 ``event.send`` 的写法不完全一致。 + """ try: - if message and getattr(message, "chain", None): - for comp in message.chain: + if message is not None: + chain = getattr(message, "chain", None) + if chain is not None: + comps = chain + elif isinstance(message, (list, tuple)): + comps = message + else: + comps = [message] + for comp in comps: if isinstance(comp, Image): self.captured_images.append(comp) except Exception as e: # noqa: BLE001 - 拦截阶段不应影响主流程 @@ -398,6 +408,9 @@ async def _run_image_agent( # 取已选定(并缓存)的生图工具名;未选定则尝试选一次。 tool_manager = context.get_llm_tool_manager() + if not tool_manager: + logger.info("[主动消息] 未找到 LLM 工具管理器,跳过配图喵。") + return [] tool_names = self._ensure_image_tool_names(tool_manager, image_conf) if not tool_names: # 已选不到(或已永久回退),_ensure_image_tool_names 内已记日志。 @@ -413,13 +426,6 @@ async def _run_image_agent( logger.info("[主动消息] 已选定的生图工具当前不可用,跳过配图喵。") return [] - capture_event = _ImageCaptureEvent( - context=context, - session=session, - message=text, - message_type=session.message_type, - ) - # 第一步:由插件端先生成“画面描述”(生图提示词)。 # auto 模式下若模型判断这条消息不适合配图,会返回空,此时直接跳过。 image_prompt = await self._generate_image_prompt( diff --git a/core/plugin_lifecycle.py b/core/plugin_lifecycle.py index a0ee582..a48a0cf 100644 --- a/core/plugin_lifecycle.py +++ b/core/plugin_lifecycle.py @@ -162,7 +162,11 @@ async def _deferred_prewarm_image_tools() -> None: logger.debug(f"[主动消息] 预热配图工具时出现异常喵: {e!r}") try: - asyncio.create_task(_deferred_prewarm_image_tools()) + # 用 _track_task 登记引用,避免后台任务在 sleep 期间被 GC 回收。 + track = getattr(self, "_track_task", None) + task = asyncio.create_task(_deferred_prewarm_image_tools()) + if callable(track): + track(task) except Exception as e: # noqa: BLE001 logger.debug(f"[主动消息] 启动配图工具预热任务失败喵: {e!r}")