diff --git a/_conf_schema.json b/_conf_schema.json index 98acd6e..0b3d756 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -158,6 +158,46 @@ } } }, + "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": "附加在配图决策提示后的额外说明,例如画风偏好。留空则仅依据消息内容自行判断。" + }, + "extra_tools": { + "description": "➕ 额外生图工具名(可选)", + "type": "list", + "default": [], + "hint": "默认会按关键词(draw/image/生图/绘图 等,匹配工具名与描述)自动识别生图工具。如果你的生图插件工具名不含这些关键词(例如 nano_banana、flux),在这里补上它的 LLM 工具名,每行一个。" + }, + "exclude_tools": { + "description": "➖ 排除的工具名(可选)", + "type": "list", + "default": [], + "hint": "如果自动识别误把某个非生图工具当成了生图工具(例如 read_image_file 这类),在这里填它的名字排除掉,每行一个。" + } + } + }, "segmented_reply_settings": { "description": "🔪 分段回复设置", "type": "object", @@ -435,6 +475,46 @@ } } }, + "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": "附加在配图决策提示后的额外说明,例如画风偏好。留空则仅依据消息内容自行判断。" + }, + "extra_tools": { + "description": "➕ 额外生图工具名(可选)", + "type": "list", + "default": [], + "hint": "默认会按关键词(draw/image/生图/绘图 等,匹配工具名与描述)自动识别生图工具。如果你的生图插件工具名不含这些关键词(例如 nano_banana、flux),在这里补上它的 LLM 工具名,每行一个。" + }, + "exclude_tools": { + "description": "➖ 排除的工具名(可选)", + "type": "list", + "default": [], + "hint": "如果自动识别误把某个非生图工具当成了生图工具(例如 read_image_file 这类),在这里填它的名字排除掉,每行一个。" + } + } + }, "segmented_reply_settings": { "description": "🔪 分段回复设置", "type": "object", @@ -611,4 +691,4 @@ } } } -} +} \ No newline at end of file diff --git a/core/image_generator.py b/core/image_generator.py new file mode 100644 index 0000000..c420ec6 --- /dev/null +++ b/core/image_generator.py @@ -0,0 +1,468 @@ +"""主动消息配图能力。 + +通过 AstrBot 的工具循环 Agent,让主 LLM 调用已安装的生图插件(向主 LLM +注册了工具的插件)来生成配图。生成的图片不会由生图插件直接发往平台,而是被一个 +“捕获型”合成事件拦截、收集后交还给本插件,统一走主动消息既有的发送流程 +(分段、装饰钩子、平台历史持久化)。 + +设计要点: +- provider 无关:不绑定任何特定生图插件,按关键词(工具名 + 描述)自动识别生图 + 工具,并支持用户用 extra_tools 补充、exclude_tools 排除。 +- 提示词由插件端生成:先由插件调用 LLM 生成画面描述,再交给 Agent 据此调用生图 + 工具,而非让模型在工具循环里自行编写提示词。 +- 失败静默降级:任何环节出错都只记录日志、回退为纯文本,绝不把错误信息塞进 + 发送给用户的消息内容。 +""" + +from __future__ import annotations + +import time +import uuid +from typing import Any + +from astrbot.api import logger + +try: + from astrbot.core.agent.tool import ToolSet + from astrbot.core.message.components import Image, Plain + 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: Any) -> None: + """拦截发送:仅收集图片组件,不真正发往平台。 + + 兼容多种传入形式:MessageChain(含 .chain)、组件列表/元组、单个组件—— + 因为第三方生图插件调用 ``event.send`` 的写法不完全一致。 + """ + try: + 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 - 拦截阶段不应影响主流程 + 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) + + +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 [] + + @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: + """用工具循环 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 [] + + # 取已选定(并缓存)的生图工具名;未选定则尝试选一次。 + 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 内已记日志。 + 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 [] + + # 第一步:由插件端先生成“画面描述”(生图提示词)。 + # 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, + ) + + # 第二步:把插件生成好的画面描述作为明确指令交给 Agent,让它据此调用生图 + # 工具——描述由插件掌控,要求模型原样使用、不要改写或自行编造。 + system_prompt = ( + "你的唯一任务是调用一个可用的生图工具,为下面给出的画面描述生成图片。\n" + "请把画面描述原样作为生图工具的绘画提示词,不要改写、缩写或自行发挥;\n" + "调用工具即可,不要输出多余文字。" + ) + user_prompt = f"画面描述:\n{image_prompt}" + + 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..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) diff --git a/core/plugin_lifecycle.py b/core/plugin_lifecycle.py index cf3c28b..a48a0cf 100644 --- a/core/plugin_lifecycle.py +++ b/core/plugin_lifecycle.py @@ -149,6 +149,27 @@ 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: + # 用 _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}") + async def terminate(self) -> None: """插件被卸载或停用时调用的清理函数。""" logger.info("[主动消息] 收到插件终止指令,开始清理资源喵。") diff --git a/main.py b/main.py index c6102e9..dfcaa63 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, # 主动消息主流程编排 @@ -98,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 ] = {} # 自动触发计时器句柄