diff --git a/src/plugins/built_in/maizone_refactored/plugin.py b/src/plugins/built_in/maizone_refactored/plugin.py index 8f6481a55..6bc5ecd11 100644 --- a/src/plugins/built_in/maizone_refactored/plugin.py +++ b/src/plugins/built_in/maizone_refactored/plugin.py @@ -42,6 +42,7 @@ class MaiZoneRefactoredPlugin(BasePlugin): "plugin": {"enable": ConfigField(type=bool, default=True, description="是否启用插件")}, "models": { "text_model": ConfigField(type=str, default="maizone", description="生成文本的模型名称"), + "vision_model": ConfigField(type=str, default="YISHAN-gemini-2.5-flash", description="识别图片的模型名称"), "siliconflow_apikey": ConfigField(type=str, default="", description="硅基流动AI生图API密钥"), }, "send": { diff --git a/src/plugins/built_in/maizone_refactored/services/content_service.py b/src/plugins/built_in/maizone_refactored/services/content_service.py index 6b7900cc3..7a98a7cdc 100644 --- a/src/plugins/built_in/maizone_refactored/services/content_service.py +++ b/src/plugins/built_in/maizone_refactored/services/content_service.py @@ -6,8 +6,19 @@ from typing import Callable, Optional import datetime +import base64 +import aiohttp from src.common.logger import get_logger -from src.plugin_system.apis import llm_api, config_api +import base64 +import aiohttp +import imghdr +import asyncio +from src.common.logger import get_logger +from src.plugin_system.apis import llm_api, config_api, generator_api, person_api +from src.chat.message_receive.chat_stream import get_chat_manager +from maim_message import UserInfo +from src.llm_models.utils_model import LLMRequest +from src.config.api_ada_configs import TaskConfig # 导入旧的工具函数,我们稍后会考虑是否也需要重构它 from ..utils.history_utils import get_send_history @@ -97,110 +108,181 @@ class ContentService: logger.error(f"生成说说内容时发生异常: {e}") return "" - async def generate_comment(self, content: str, target_name: str, rt_con: str = "") -> str: + async def generate_comment(self, content: str, target_name: str, rt_con: str = "", images: list = []) -> str: """ 针对一条具体的说说内容生成评论。 - - :param content: 好友的说说内容。 - :param target_name: 好友的昵称。 - :param rt_con: 如果是转发的说说,这里是原说说内容。 - :return: 生成的评论内容,如果失败则返回空字符串。 """ - try: - # 获取模型配置 - models = llm_api.get_available_models() - text_model = str(self.get_config("models.text_model", "replyer_1")) - model_config = models.get(text_model) + for i in range(3): # 重试3次 + try: + chat_manager = get_chat_manager() + bot_platform = config_api.get_global_config('bot.platform') + bot_qq = str(config_api.get_global_config('bot.qq_account')) + bot_nickname = config_api.get_global_config('bot.nickname') + + bot_user_info = UserInfo( + platform=bot_platform, + user_id=bot_qq, + user_nickname=bot_nickname + ) - if not model_config: - logger.error("未配置LLM模型") - return "" + chat_stream = await chat_manager.get_or_create_stream( + platform=bot_platform, + user_info=bot_user_info + ) - # 获取机器人信息 - bot_personality = config_api.get_global_config("personality.personality_core", "一个机器人") - bot_expression = config_api.get_global_config("expression.expression_style", "内容积极向上") + if not chat_stream: + logger.error(f"无法为QQ号 {bot_qq} 创建聊天流") + return "" - # 构建提示词 - if not rt_con: - prompt = f""" - 你是'{bot_personality}',你正在浏览你好友'{target_name}'的QQ空间, - 你看到了你的好友'{target_name}'qq空间上内容是'{content}'的说说,你想要发表你的一条评论, - {bot_expression},回复的平淡一些,简短一些,说中文, - 不要刻意突出自身学科背景,不要浮夸,不要夸张修辞,不要输出多余内容(包括前后缀,冒号和引号,括号(),表情包,at或 @等 )。只输出回复内容 - """ - else: - prompt = f""" - 你是'{bot_personality}',你正在浏览你好友'{target_name}'的QQ空间, - 你看到了你的好友'{target_name}'在qq空间上转发了一条内容为'{rt_con}'的说说,你的好友的评论为'{content}' - 你想要发表你的一条评论,{bot_expression},回复的平淡一些,简短一些,说中文, - 不要刻意突出自身学科背景,不要浮夸,不要夸张修辞,不要输出多余内容(包括前后缀,冒号和引号,括号(),表情包,at或 @等 )。只输出回复内容 - """ + image_descriptions = [] + if images: + for image_url in images: + description = await self._describe_image(image_url) + if description: + image_descriptions.append(description) + + extra_info = "正在评论QQ空间的好友说说。" + if image_descriptions: + extra_info += "说说中包含的图片内容如下:\n" + "\n".join(image_descriptions) - logger.info(f"正在为'{target_name}'的说说生成评论: {content[:20]}...") + reply_to = f"{target_name}:{content}" + if rt_con: + reply_to += f"\n[转发内容]: {rt_con}" - # 调用LLM生成评论 - success, comment, _, _ = await llm_api.generate_with_model( - prompt=prompt, - model_config=model_config, - request_type="comment.generate", - temperature=0.3, - max_tokens=100 - ) + success, reply_set, _ = await generator_api.generate_reply( + chat_stream=chat_stream, + reply_to=reply_to, + extra_info=extra_info, + request_type="maizone.comment" + ) - if success: - logger.info(f"成功生成评论内容:'{comment}'") - return comment - else: - logger.error("生成评论内容失败") - return "" - - except Exception as e: - logger.error(f"生成评论内容时发生异常: {e}") - return "" + if success and reply_set: + comment = "".join([content for type, content in reply_set if type == 'text']) + logger.info(f"成功生成评论内容:'{comment}'") + return comment + else: + # 如果生成失败,则进行重试 + if i < 2: + logger.warning(f"生成评论失败,将在5秒后重试 (尝试 {i+1}/3)") + await asyncio.sleep(5) + continue + else: + logger.error("使用 generator_api 生成评论失败") + return "" + except Exception as e: + if i < 2: + logger.warning(f"生成评论时发生异常,将在5秒后重试 (尝试 {i+1}/3): {e}") + await asyncio.sleep(5) + continue + else: + logger.error(f"生成评论时发生异常: {e}") + return "" + return "" async def generate_comment_reply(self, story_content: str, comment_content: str, commenter_name: str) -> str: """ 针对自己说说的评论,生成回复。 - - :param story_content: 原始说说内容。 - :param comment_content: 好友的评论内容。 - :param commenter_name: 评论者的昵称。 - :return: 生成的回复内容。 """ - try: - models = llm_api.get_available_models() - text_model = str(self.get_config("models.text_model", "replyer_1")) - model_config = models.get(text_model) - if not model_config: - return "" + for i in range(3): # 重试3次 + try: + chat_manager = get_chat_manager() + bot_platform = config_api.get_global_config('bot.platform') + bot_qq = str(config_api.get_global_config('bot.qq_account')) + bot_nickname = config_api.get_global_config('bot.nickname') - bot_personality = config_api.get_global_config("personality.personality_core", "一个机器人") - bot_expression = config_api.get_global_config("expression.expression_style", "内容积极向上") + bot_user_info = UserInfo( + platform=bot_platform, + user_id=bot_qq, + user_nickname=bot_nickname + ) - prompt = f""" - 你是'{bot_personality}',你的好友'{commenter_name}'评论了你QQ空间上的一条内容为“{story_content}”说说, - 你的好友对该说说的评论为:“{comment_content}”,你想要对此评论进行回复 - {bot_expression},回复的平淡一些,简短一些,说中文, - 不要刻意突出自身学科背景,不要浮夸,不要夸张修辞,不要输出多余内容(包括前后缀,冒号和引号,括号(),表情包,at或 @等 )。只输出回复内容 - """ - - success, reply, _, _ = await llm_api.generate_with_model( - prompt=prompt, - model_config=model_config, - request_type="comment.reply.generate", - temperature=0.3, - max_tokens=100 - ) + chat_stream = await chat_manager.get_or_create_stream( + platform=bot_platform, + user_info=bot_user_info + ) - if success: - logger.info(f"成功为'{commenter_name}'的评论生成回复: '{reply}'") - return reply - else: - logger.error("生成评论回复失败") - return "" - except Exception as e: - logger.error(f"生成评论回复时发生异常: {e}") - return "" + if not chat_stream: + logger.error(f"无法为QQ号 {bot_qq} 创建聊天流") + return "" + + reply_to = f"{commenter_name}:{comment_content}" + extra_info = f"正在回复我的QQ空间说说“{story_content}”下的评论。" + + success, reply_set, _ = await generator_api.generate_reply( + chat_stream=chat_stream, + reply_to=reply_to, + extra_info=extra_info, + request_type="maizone.comment_reply" + ) + + if success and reply_set: + reply = "".join([content for type, content in reply_set if type == 'text']) + logger.info(f"成功为'{commenter_name}'的评论生成回复: '{reply}'") + return reply + else: + if i < 2: + logger.warning(f"生成评论回复失败,将在5秒后重试 (尝试 {i+1}/3)") + await asyncio.sleep(5) + continue + else: + logger.error("使用 generator_api 生成评论回复失败") + return "" + except Exception as e: + if i < 2: + logger.warning(f"生成评论回复时发生异常,将在5秒后重试 (尝试 {i+1}/3): {e}") + await asyncio.sleep(5) + continue + else: + logger.error(f"生成评论回复时发生异常: {e}") + return "" + return "" + + async def _describe_image(self, image_url: str) -> Optional[str]: + """ + 使用LLM识别图片内容。 + """ + for i in range(3): # 重试3次 + try: + async with aiohttp.ClientSession() as session: + async with session.get(image_url, timeout=30) as resp: + if resp.status != 200: + logger.error(f"下载图片失败: {image_url}, status: {resp.status}") + await asyncio.sleep(2) + continue + image_bytes = await resp.read() + + image_format = imghdr.what(None, image_bytes) + if not image_format: + logger.error(f"无法识别图片格式: {image_url}") + return None + + image_base64 = base64.b64encode(image_bytes).decode("utf-8") + + vision_model_name = self.get_config("models.vision_model", "vision") + if not vision_model_name: + logger.error("未在插件配置中指定视觉模型") + return None + + vision_model_config = TaskConfig( + model_list=[vision_model_name], + temperature=0.3, + max_tokens=1500 + ) + + llm_request = LLMRequest(model_set=vision_model_config, request_type="maizone.image_describe") + + prompt = config_api.get_global_config("custom_prompt.image_prompt", "请描述这张图片") + + description, _ = await llm_request.generate_response_for_image( + prompt=prompt, + image_base64=image_base64, + image_format=image_format, + ) + return description + except Exception as e: + logger.error(f"识别图片时发生异常 (尝试 {i+1}/3): {e}") + await asyncio.sleep(2) + return None async def generate_story_from_activity(self, activity: str) -> str: """ diff --git a/src/plugins/built_in/maizone_refactored/services/qzone_service.py b/src/plugins/built_in/maizone_refactored/services/qzone_service.py index ca7b39879..aa3eb666a 100644 --- a/src/plugins/built_in/maizone_refactored/services/qzone_service.py +++ b/src/plugins/built_in/maizone_refactored/services/qzone_service.py @@ -163,7 +163,7 @@ class QZoneService: continue # 区分是自己的说说还是他人的说说 - if target_qq == qq_account: + if str(target_qq) == str(qq_account): if self.get_config("monitor.enable_auto_reply", False): await self._reply_to_own_feed_comments(feed, api_client) else: @@ -244,12 +244,20 @@ class QZoneService: if not comments: return - # 筛选出未被自己回复过的主评论 - my_comment_tids = { - c["parent_tid"] for c in comments if c.get("parent_tid") and c.get("qq_account") == qq_account + # 筛选出未被自己回复过的评论 + if not comments: + return + + # 找到所有我已经回复过的评论的ID + replied_to_tids = { + c['parent_tid'] for c in comments + if c.get('parent_tid') and str(c.get('qq_account')) == str(qq_account) } + + # 找出所有非我发出且我未回复过的评论 comments_to_reply = [ - c for c in comments if not c.get("parent_tid") and c.get("comment_tid") not in my_comment_tids + c for c in comments + if str(c.get('qq_account')) != str(qq_account) and c.get('comment_tid') not in replied_to_tids ] if not comments_to_reply: @@ -275,9 +283,10 @@ class QZoneService: content = feed.get("content", "") fid = feed.get("tid", "") rt_con = feed.get("rt_con", "") + images = feed.get("images", []) if random.random() <= self.get_config("read.comment_possibility", 0.3): - comment_text = await self.content_service.generate_comment(content, target_name, rt_con) + comment_text = await self.content_service.generate_comment(content, target_name, rt_con, images) if comment_text: await api_client["comment"](target_qq, fid, comment_text) @@ -655,6 +664,8 @@ class QZoneService: c.get("name") == my_name for c in msg.get("commentlist", []) if isinstance(c, dict) ) if not is_commented: + images = [pic['url1'] for pic in msg.get('pictotal', []) if 'url1' in pic] + feeds_list.append( { "tid": msg.get("tid", ""), @@ -665,6 +676,7 @@ class QZoneService: "rt_con": msg.get("rt_con", {}).get("content", "") if isinstance(msg.get("rt_con"), dict) else "", + "images": images } ) return feeds_list @@ -815,10 +827,45 @@ class QZoneService: text_div = soup.find('div', class_='f-info') text = text_div.get_text(strip=True) if text_div else "" + images = [img['src'] for img in soup.find_all('img') if 'src' in img.attrs and 'user-avatar' not in img.get('class', [])] + + comments = [] + comment_divs = soup.find_all('div', class_='f-single-comment') + for comment_div in comment_divs: + # --- 处理主评论 --- + author_a = comment_div.find('a', class_='f-nick') + content_span = comment_div.find('span', class_='f-re-con') + + if author_a and content_span: + comments.append({ + 'qq_account': str(comment_div.get('data-uin', '')), + 'nickname': author_a.get_text(strip=True), + 'content': content_span.get_text(strip=True), + 'comment_tid': comment_div.get('data-tid', ''), + 'parent_tid': None # 主评论没有父ID + }) + + # --- 处理这条主评论下的所有回复 --- + reply_divs = comment_div.find_all('div', class_='f-single-re') + for reply_div in reply_divs: + reply_author_a = reply_div.find('a', class_='f-nick') + reply_content_span = reply_div.find('span', class_='f-re-con') + + if reply_author_a and reply_content_span: + comments.append({ + 'qq_account': str(reply_div.get('data-uin', '')), + 'nickname': reply_author_a.get_text(strip=True), + 'content': reply_content_span.get_text(strip=True).lstrip(': '), # 移除回复内容前多余的冒号和空格 + 'comment_tid': reply_div.get('data-tid', ''), + 'parent_tid': reply_div.get('data-parent-tid', comment_div.get('data-tid', '')) # 如果没有父ID,则将父ID设为主评论ID + }) + feeds_list.append({ 'target_qq': target_qq, 'tid': tid, 'content': text, + 'images': images, + 'comments': comments }) logger.info(f"监控任务发现 {len(feeds_list)} 条未处理的新说说。") return feeds_list