From 0a08973c41393c001eec713b6b6ea40b03ea714f Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Thu, 26 Mar 2026 23:03:47 +0800 Subject: [PATCH] feat: Enhance emoji and image management with asynchronous background processing - Added support for scheduling background tasks to build emoji and image descriptions when not found in cache. - Improved error handling and logging for emoji and image processing. - Updated `SessionMessage` processing to allow for optional heavy media analysis and voice transcription. - Refactored logging messages for better clarity and consistency across various modules. - Introduced a new function to build outbound log previews for messages, enhancing logging capabilities. --- prompts/zh-CN/maidairy_chat.prompt | 2 +- prompts/zh-CN/maidairy_replyer.prompt | 9 +- scripts/info_extraction.py | 2 +- scripts/lpmm_manager.py | 8 +- src/chat/emoji_system/emoji_manager.py | 193 +++++++++++++----- src/chat/heart_flow/heartflow_manager.py | 45 ++-- src/chat/image_system/image_manager.py | 64 +++++- src/chat/message_receive/bot.py | 8 +- src/chat/message_receive/message.py | 203 ++++++++++++++++--- src/common/i18n/manager.py | 36 +--- src/common/prompt_i18n.py | 2 +- src/llm_models/model_client/gemini_client.py | 10 +- src/llm_models/model_client/openai_client.py | 6 +- src/maisaka/llm_service.py | 7 +- src/plugin_runtime/host/hook_dispatcher.py | 26 +-- src/plugin_runtime/runner/log_handler.py | 2 +- src/services/send_service.py | 32 ++- 17 files changed, 488 insertions(+), 167 deletions(-) diff --git a/prompts/zh-CN/maidairy_chat.prompt b/prompts/zh-CN/maidairy_chat.prompt index a59e2af0..f0cff929 100644 --- a/prompts/zh-CN/maidairy_chat.prompt +++ b/prompts/zh-CN/maidairy_chat.prompt @@ -8,7 +8,7 @@ 你需要根据提供的参考信息,当前场景和输出规则来进行分析 在当前场景中,用户正在与AI麦麦进行聊天互动,你的任务不是生成对用户可见的发言,而是进行分析来指导AI进行回复。 “分析”应该体现你对当前局面的判断、你的建议、你的下一步计划,以及你为什么这样想。 - +没必要刻意友好回复,符合你的人格就行 你可以使用这些工具: - wait(seconds) - 暂时停止对话,等待(seconds)秒,把话语权交给用户,等待对方新的发言。 diff --git a/prompts/zh-CN/maidairy_replyer.prompt b/prompts/zh-CN/maidairy_replyer.prompt index f06f086f..2884afd9 100644 --- a/prompts/zh-CN/maidairy_replyer.prompt +++ b/prompts/zh-CN/maidairy_replyer.prompt @@ -1,6 +1,13 @@ -你的名字是麦麦,也有人叫你牢麦,麦叠,你是一个大二在读女大学生,现在正在上网和群友聊天,有点攻击性; +你的任务是根据内部想法生成一条对用户可见的自然回复。 + +【参考信息】 +{bot_name}的人设:{identity} +回复风格要求:{reply_style} +【参考信息结束】 + 你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复, 尽量简短一些。 +没必要刻意友好回复,符合你的人格就行。没必要刻意友好回复,符合你的人格就行。没必要刻意友好回复,符合你的人格就行。 请注意把握聊天内容,不要回复的太有条理。 你的风格平淡但不失讽刺,不过分兴奋,很简短。可以参考贴吧,知乎和微博的回复风格。很平淡和白话,不浮夸不长篇大论,b站评论风格,但一定注意不要过分修辞和复杂句。 请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,表情包,at或 @等 ),只输出发言内容就好。 diff --git a/scripts/info_extraction.py b/scripts/info_extraction.py index bd52536e..ab9d295b 100644 --- a/scripts/info_extraction.py +++ b/scripts/info_extraction.py @@ -189,7 +189,7 @@ def _run(non_interactive: bool = False) -> None: # sourcery skip: comprehension elif doc_item: with open_ie_doc_lock: open_ie_doc.append(doc_item) - logger.info('已处理"%s"', doc_item.get("passage", "")) + logger.info(f'已处理"{doc_item.get("passage", "")}"') progress.update(task, advance=1) except KeyboardInterrupt: logger.info("\n接收到中断信号,正在优雅地关闭程序...") diff --git a/scripts/lpmm_manager.py b/scripts/lpmm_manager.py index 2f935c51..868d4b14 100644 --- a/scripts/lpmm_manager.py +++ b/scripts/lpmm_manager.py @@ -110,7 +110,7 @@ def run_action(action: str, extra_args: Optional[List[str]] = None) -> None: 这里不重复解析子参数,而是直接调用各脚本的 main(), 让子脚本保留原有的交互/参数行为。 """ - logger.info("开始执行操作: %s", action) + logger.info(f"开始执行操作: {action}") extra_args = extra_args or [] @@ -162,14 +162,14 @@ def run_action(action: str, extra_args: Optional[List[str]] = None) -> None: _warn_if_lpmm_disabled() _with_overridden_argv(extra_args, refresh_lpmm_knowledge_main) else: - logger.error("未知操作: %s", action) + logger.error(f"未知操作: {action}") except KeyboardInterrupt: logger.info("用户中断当前操作(Ctrl+C)") except SystemExit: # 子脚本里大量使用 sys.exit,直接透传即可 raise except Exception as exc: # pragma: no cover - 防御性兜底 - logger.error("执行操作 %s 时发生未捕获异常: %s", action, exc) + logger.error(f"执行操作 {action} 时发生未捕获异常: {exc}") raise @@ -442,7 +442,7 @@ def _run_embedding_helper() -> None: try: test_path.rename(archive_path) except Exception as exc: # pragma: no cover - 防御性兜底 - logger.error("归档 embedding_model_test.json 失败: %s", exc) + logger.error(f"归档 embedding_model_test.json 失败: {exc}") print("[ERROR] 归档 embedding_model_test.json 失败,请检查文件权限与路径。错误详情已写入日志。") return diff --git a/src/chat/emoji_system/emoji_manager.py b/src/chat/emoji_system/emoji_manager.py index 780049d2..594f33b4 100644 --- a/src/chat/emoji_system/emoji_manager.py +++ b/src/chat/emoji_system/emoji_manager.py @@ -1,8 +1,9 @@ from datetime import datetime from pathlib import Path +from typing import Dict, List, Optional, Tuple + from rich.traceback import install from sqlmodel import select -from typing import Optional, Tuple, List import asyncio import hashlib @@ -51,11 +52,13 @@ class EmojiManager: """ def __init__(self) -> None: + """初始化表情包管理器。""" _ensure_directories() self._emoji_num: int = 0 - self.emojis: list[MaiEmoji] = [] + self.emojis: List[MaiEmoji] = [] self._maintenance_wakeup_event: asyncio.Event = asyncio.Event() + self._pending_description_tasks: Dict[str, asyncio.Task[None]] = {} self._reload_callback_registered: bool = False config_manager.register_reload_callback(self.reload_runtime_config) @@ -78,7 +81,11 @@ class EmojiManager: logger.info("[关闭] Emoji 模块已注销配置热重载回调") async def get_emoji_description( - self, *, emoji_bytes: Optional[bytes] = None, emoji_hash: Optional[str] = None + self, + *, + emoji_bytes: Optional[bytes] = None, + emoji_hash: Optional[str] = None, + wait_for_build: bool = True, ) -> Optional[Tuple[str, List[str]]]: """ 根据表情包哈希获取表情包描述和情感列表的封装方法 @@ -86,6 +93,7 @@ class EmojiManager: Args: emoji_bytes (Optional[bytes]): 表情包的字节数据,如果提供了字节数据但数据库中没有找到对应记录,则会尝试构建表情包描述 emoji_hash (Optional[str]): 表情包的哈希值,如果提供了哈希值则优先使用哈希值查找表情包描述 + wait_for_build (bool): 未命中缓存时是否同步等待描述构建完成 Returns: return (Optional[Tuple[str, List[str]]]): 如果找到对应的表情包,则返回包含描述和情感标签的元组;若没找到,则尝试构建表情包描述并返回,如果构建失败则返回 None Raises: @@ -113,27 +121,88 @@ class EmojiManager: # 如果提供了字节数据但数据库中没有找到,尝试构建 if not emoji_bytes: return None + if not wait_for_build: + self._schedule_description_build(emoji_hash, emoji_bytes) + return None # 找不到尝试构建 + return await self._build_and_cache_emoji_description(emoji_hash, emoji_bytes) + + def _schedule_description_build(self, emoji_hash: str, emoji_bytes: bytes) -> None: + """调度表情包描述后台构建任务。 + + Args: + emoji_hash: 表情包哈希值。 + emoji_bytes: 表情包字节数据。 + """ + if emoji_hash in self._pending_description_tasks: + return + + task = asyncio.create_task(self._build_description_in_background(emoji_hash, emoji_bytes)) + self._pending_description_tasks[emoji_hash] = task + task.add_done_callback(lambda finished_task: self._finalize_description_build(emoji_hash, finished_task)) + + async def _build_description_in_background(self, emoji_hash: str, emoji_bytes: bytes) -> None: + """在后台构建并缓存表情包描述。 + + Args: + emoji_hash: 表情包哈希值。 + emoji_bytes: 表情包字节数据。 + """ + try: + logger.info(f"表情包描述后台构建已开始,哈希值: {emoji_hash}") + await self._build_and_cache_emoji_description(emoji_hash, emoji_bytes) + logger.info(f"表情包描述后台构建完成,哈希值: {emoji_hash}") + except Exception as exc: + logger.warning(f"表情包描述后台构建失败,哈希值: {emoji_hash},错误: {exc}") + + def _finalize_description_build(self, emoji_hash: str, task: asyncio.Task[None]) -> None: + """回收表情包描述后台构建任务。 + + Args: + emoji_hash: 表情包哈希值。 + task: 已完成的后台任务。 + """ + self._pending_description_tasks.pop(emoji_hash, None) + try: + task.result() + except Exception as exc: + logger.debug(f"表情包描述后台任务结束时捕获异常,哈希值: {emoji_hash},错误: {exc}") + + async def _build_and_cache_emoji_description( + self, + emoji_hash: str, + emoji_bytes: bytes, + ) -> Optional[Tuple[str, List[str]]]: + """构建并缓存表情包描述与情感标签。 + + Args: + emoji_hash: 表情包哈希值。 + emoji_bytes: 表情包字节数据。 + + Returns: + Optional[Tuple[str, List[str]]]: 构建成功时返回描述和情感标签,否则返回 ``None``。 + """ logger.info(f"未找到哈希值为 {emoji_hash} 的表情包与其描述,尝试构建描述") full_path = EMOJI_DIR / f"{emoji_hash}.png" try: full_path.write_bytes(emoji_bytes) new_emoji = MaiEmoji(full_path=full_path, image_bytes=emoji_bytes) await new_emoji.calculate_hash_format() - except Exception as e: - logger.error(f"缓存表情包文件时出错: {e}") - raise e + except Exception as exc: + logger.error(f"缓存表情包文件时出错: {exc}") + raise exc + success_desc, new_emoji = await self.build_emoji_description(new_emoji) if not success_desc: logger.error("构建表情包描述失败") return None + success_emotion, new_emoji = await self.build_emoji_emotion(new_emoji) if not success_emotion: logger.error("构建表情包情感标签失败") return None - # 缓存结果到数据库 with get_db_session() as session: try: image_record = new_emoji.to_db_instance() @@ -142,8 +211,8 @@ class EmojiManager: image_record.register_time = datetime.now() image_record.no_file_flag = True session.add(image_record) - except Exception as e: - logger.error(f"缓存表情包描述时出错: {e}") + except Exception as exc: + logger.error(f"缓存表情包描述时出错: {exc}") return new_emoji.description, new_emoji.emotion or [] def load_emojis_from_db(self) -> None: @@ -520,45 +589,56 @@ class EmojiManager: image_bytes = target_emoji.image_bytes or await asyncio.to_thread( target_emoji.read_image_bytes, target_emoji.full_path ) + image_base64 = ImageUtils.image_bytes_to_base64(image_bytes) + try: + if image_format == "gif": + try: + image_bytes = await asyncio.to_thread(ImageUtils.gif_2_static_image, image_bytes) + except Exception as e: + logger.error(f"[构建描述] 转换 GIF 图片时出错: {e}") + return False, target_emoji + prompt: str = "这是一个动态图表情包,每一张图代表了动态图的某一帧,黑色背景代表透明,简短描述一下表情包表达的情感和内容,从互联网梗、meme的角度去分析,精简回答" + image_base64 = ImageUtils.image_bytes_to_base64(image_bytes) + description_result = await emoji_manager_vlm.generate_response_for_image( + prompt, + image_base64, + "jpg", + options=LLMImageOptions(temperature=0.5), + ) + description = description_result.response + else: + prompt: str = "这是一个表情包,请详细描述一下表情包所表达的情感和内容,简短描述细节,从互联网梗、meme的角度去分析,精简回答" + description_result = await emoji_manager_vlm.generate_response_for_image( + prompt, + image_base64, + image_format, + options=LLMImageOptions(temperature=0.5), + ) + description = description_result.response + except Exception as e: + logger.error(f"[构建描述] 调用视觉模型生成表情包描述时出错: {e}") + return False, target_emoji - if image_format == "gif": - try: - image_bytes = await asyncio.to_thread(ImageUtils.gif_2_static_image, image_bytes) - except Exception as e: - logger.error(f"[构建描述] 转换 GIF 图片时出错: {e}") - return False, target_emoji - prompt: str = "这是一个动态图表情包,每一张图代表了动态图的某一帧,黑色背景代表透明,简短描述一下表情包表达的情感和内容,从互联网梗、meme的角度去分析,精简回答" - image_base64 = ImageUtils.image_bytes_to_base64(image_bytes) - description_result = await emoji_manager_vlm.generate_response_for_image( - prompt, - image_base64, - "jpg", - options=LLMImageOptions(temperature=0.5), - ) - description = description_result.response - else: - prompt: str = "这是一个表情包,请详细描述一下表情包所表达的情感和内容,简短描述细节,从互联网梗、meme的角度去分析,精简回答" - image_base64 = ImageUtils.image_bytes_to_base64(image_bytes) - description_result = await emoji_manager_vlm.generate_response_for_image( - prompt, - image_base64, - image_format, - options=LLMImageOptions(temperature=0.5), - ) - description = description_result.response + if not description: + logger.warning(f"[构建描述] 视觉模型返回空描述,跳过注册: {target_emoji.file_name}") + return False, target_emoji # 表情包审查 if global_config.emoji.content_filtration: - filtration_prompt_template = prompt_manager.get_prompt("emoji_content_filtration") - filtration_prompt_template.add_context("demand", global_config.emoji.filtration_prompt) - filtration_prompt = await prompt_manager.render_prompt(filtration_prompt_template) - filtration_result = await emoji_manager_vlm.generate_response_for_image( - filtration_prompt, - image_base64, - image_format, - options=LLMImageOptions(temperature=0.3), - ) - llm_response = filtration_result.response + try: + filtration_prompt_template = prompt_manager.get_prompt("emoji_content_filtration") + filtration_prompt_template.add_context("demand", global_config.emoji.filtration_prompt) + filtration_prompt = await prompt_manager.render_prompt(filtration_prompt_template) + filtration_result = await emoji_manager_vlm.generate_response_for_image( + filtration_prompt, + image_base64, + image_format, + options=LLMImageOptions(temperature=0.3), + ) + llm_response = filtration_result.response + except Exception as e: + logger.error(f"[表情包审查] 调用视觉模型审查表情包时出错: {e}") + return False, target_emoji if "否" in llm_response: logger.warning(f"[表情包审查] 表情包内容不符合要求,拒绝注册: {target_emoji.file_name}") return False, target_emoji @@ -584,11 +664,19 @@ class EmojiManager: emotion_prompt_template.add_context("description", target_emoji.description) emotion_prompt = await prompt_manager.render_prompt(emotion_prompt_template) # 调用LLM生成情感标签 - emotion_generation_result = await emoji_manager_emotion_judge_llm.generate_response( - emotion_prompt, - options=LLMGenerationOptions(temperature=0.3, max_tokens=200), - ) - emotion_result = emotion_generation_result.response + try: + emotion_generation_result = await emoji_manager_emotion_judge_llm.generate_response( + emotion_prompt, + options=LLMGenerationOptions(temperature=0.3, max_tokens=200), + ) + emotion_result = emotion_generation_result.response + except Exception as e: + logger.error(f"[构建情感标签] 调用模型生成情感标签时出错: {e}") + return False, target_emoji + + if not emotion_result: + logger.warning(f"[构建情感标签] 情感标签结果为空,跳过注册: {target_emoji.file_name}") + return False, target_emoji # 解析情感标签结果 emotions = [e.strip() for e in emotion_result.replace(",", ",").split(",") if e.strip()] @@ -670,7 +758,12 @@ class EmojiManager: for emoji_file in EMOJI_DIR.iterdir(): if not emoji_file.is_file(): continue - if await self.register_emoji_by_filename(emoji_file): + try: + register_success = await self.register_emoji_by_filename(emoji_file) + except Exception as e: + logger.error(f"[定期维护] 注册表情包 {emoji_file.name} 时发生未处理异常: {e}") + register_success = False + if register_success: break # 每次只注册一个表情包 try: emoji_file.unlink() diff --git a/src/chat/heart_flow/heartflow_manager.py b/src/chat/heart_flow/heartflow_manager.py index 13075f51..0b83d819 100644 --- a/src/chat/heart_flow/heartflow_manager.py +++ b/src/chat/heart_flow/heartflow_manager.py @@ -2,39 +2,39 @@ from typing import Dict import traceback -from src.chat.heart_flow.heartFC_chat import HeartFChatting from src.chat.message_receive.chat_manager import chat_manager from src.common.logger import get_logger -from src.config.config import global_config from src.maisaka.runtime import MaisakaHeartFlowChatting -# from src.chat.brain_chat.brain_chat import BrainChatting logger = get_logger("heartflow") -# TODO: 恢复PFC,现在暂时禁用 class HeartflowManager: - """主心流协调器,负责初始化并协调聊天,控制聊天属性""" + """主心流协调器。 - def __init__(self): - # self.heartflow_chat_list: Dict[str, HeartFChatting | BrainChatting] = {} - self.heartflow_chat_list: Dict[str, HeartFChatting | MaisakaHeartFlowChatting] = {} + 当前群聊统一使用 Maisaka runtime 作为消息核心循环实现。 + """ - async def get_or_create_heartflow_chat(self, session_id: str): # -> Optional[HeartFChatting | BrainChatting]: - """获取或创建一个新的HeartFChatting实例""" + def __init__(self) -> None: + """初始化心流聊天实例缓存。""" + self.heartflow_chat_list: Dict[str, MaisakaHeartFlowChatting] = {} + + async def get_or_create_heartflow_chat(self, session_id: str) -> MaisakaHeartFlowChatting: + """获取或创建群聊心流实例。 + + Args: + session_id: 聊天会话 ID。 + + Returns: + MaisakaHeartFlowChatting: 当前会话绑定的 Maisaka runtime。 + """ try: if chat := self.heartflow_chat_list.get(session_id): return chat chat_session = chat_manager.get_session_by_session_id(session_id) if not chat_session: raise ValueError(f"未找到 session_id={session_id} 的聊天流") - # new_chat = ( - # HeartFChatting(session_id=session_id) if chat_session.group_id else BrainChatting(session_id=session_id) - # ) - if global_config.maisaka.take_over_hfc: - new_chat = MaisakaHeartFlowChatting(session_id=session_id) - else: - new_chat = HeartFChatting(session_id=session_id) + new_chat = MaisakaHeartFlowChatting(session_id=session_id) await new_chat.start() self.heartflow_chat_list[session_id] = new_chat return new_chat @@ -43,10 +43,15 @@ class HeartflowManager: traceback.print_exc() raise e - def adjust_talk_frequency(self, session_id: str, frequency: float): - """调整指定聊天流的说话频率""" + def adjust_talk_frequency(self, session_id: str, frequency: float) -> None: + """调整指定聊天流的说话频率。 + + Args: + session_id: 聊天会话 ID。 + frequency: 目标频率系数。 + """ chat = self.heartflow_chat_list.get(session_id) - if chat and hasattr(chat, "adjust_talk_frequency"): + if chat: chat.adjust_talk_frequency(frequency) logger.info(f"已调整聊天 {session_id} 的说话频率为 {frequency}") else: diff --git a/src/chat/image_system/image_manager.py b/src/chat/image_system/image_manager.py index 5edbf134..492886d4 100644 --- a/src/chat/image_system/image_manager.py +++ b/src/chat/image_system/image_manager.py @@ -1,9 +1,11 @@ from datetime import datetime from pathlib import Path +from typing import Dict, Optional + from rich.traceback import install from sqlmodel import select -from typing import Optional +import asyncio import base64 import hashlib @@ -24,7 +26,8 @@ IMAGE_DIR = DATA_DIR / "images" logger = get_logger("image") -def _ensure_image_dir_exists(): +def _ensure_image_dir_exists() -> None: + """确保图片缓存目录存在。""" IMAGE_DIR.mkdir(parents=True, exist_ok=True) @@ -32,13 +35,21 @@ vlm = LLMServiceClient(task_name="vlm", request_type="image") class ImageManager: - def __init__(self): + """图片描述管理器。""" + + def __init__(self) -> None: + """初始化图片管理器。""" _ensure_image_dir_exists() + self._pending_description_tasks: Dict[str, asyncio.Task[None]] = {} logger.info("图片管理器初始化完成") async def get_image_description( - self, *, image_hash: Optional[str] = None, image_bytes: Optional[bytes] = None + self, + *, + image_hash: Optional[str] = None, + image_bytes: Optional[bytes] = None, + wait_for_build: bool = True, ) -> str: """ 获取图片描述的封装方法 @@ -50,6 +61,7 @@ class ImageManager: Args: image_hash (Optional[str]): 图片的哈希值,如果提供则优先使用该 image_bytes (Optional[bytes]): 图片的字节数据,如果提供则在数据库中找不到哈希值时使用该数据生成描述 + wait_for_build (bool): 未命中缓存时是否同步等待描述构建完成 Returns: return (str): 图片描述,如果发生错误或无法生成描述则返回空字符串 Raises: @@ -74,6 +86,9 @@ class ImageManager: if not image_bytes: logger.warning("图片哈希值未找到,且未提供图片字节数据,返回无描述") return "" + if not wait_for_build: + self._schedule_description_build(hash_str, image_bytes) + return "" logger.info(f"图片描述未找到,哈希值: {hash_str},准备生成新描述") try: image = await self.save_image_and_process(image_bytes) @@ -82,6 +97,47 @@ class ImageManager: logger.error(f"生成图片描述时发生错误: {e}") return "" + def _schedule_description_build(self, image_hash: str, image_bytes: bytes) -> None: + """调度图片描述后台构建任务。 + + Args: + image_hash: 图片哈希值。 + image_bytes: 图片字节数据。 + """ + if image_hash in self._pending_description_tasks: + return + + task = asyncio.create_task(self._build_description_in_background(image_hash, image_bytes)) + self._pending_description_tasks[image_hash] = task + task.add_done_callback(lambda finished_task: self._finalize_description_build(image_hash, finished_task)) + + async def _build_description_in_background(self, image_hash: str, image_bytes: bytes) -> None: + """在后台构建并缓存图片描述。 + + Args: + image_hash: 图片哈希值。 + image_bytes: 图片字节数据。 + """ + try: + logger.info(f"图片描述后台构建已开始,哈希值: {image_hash}") + await self.save_image_and_process(image_bytes) + logger.info(f"图片描述后台构建完成,哈希值: {image_hash}") + except Exception as exc: + logger.warning(f"图片描述后台构建失败,哈希值: {image_hash},错误: {exc}") + + def _finalize_description_build(self, image_hash: str, task: asyncio.Task[None]) -> None: + """回收图片描述后台构建任务。 + + Args: + image_hash: 图片哈希值。 + task: 已完成的后台任务。 + """ + self._pending_description_tasks.pop(image_hash, None) + try: + task.result() + except Exception as exc: + logger.debug(f"图片描述后台任务结束时捕获异常,哈希值: {image_hash},错误: {exc}") + def get_image_from_db(self, image_hash: str) -> Optional[MaiImage]: """ 从数据库中根据图片哈希值获取图片记录 diff --git a/src/chat/message_receive/bot.py b/src/chat/message_receive/bot.py index 2d8fdaa6..1d2abb3b 100644 --- a/src/chat/message_receive/bot.py +++ b/src/chat/message_receive/bot.py @@ -303,9 +303,13 @@ class ChatBot: # pass # 处理消息内容,识别表情包等二进制数据并转化为文本描述 - if global_config.maisaka.take_over_hfc and global_config.maisaka.direct_image_input: + if group_info is not None and global_config.maisaka.direct_image_input: message.maisaka_original_raw_message = deepcopy(message.raw_message) # type: ignore[attr-defined] - await message.process() + # 入站主链优先保证消息尽快入队,避免图片、表情包、语音分析阻塞适配器超时。 + await message.process( + enable_heavy_media_analysis=False, + enable_voice_transcription=False, + ) # 平台层的 @ 检测由底层 is_mentioned_bot_in_message 统一处理;此处不做用户名硬编码匹配 diff --git a/src/chat/message_receive/message.py b/src/chat/message_receive/message.py index be2ef026..3cf5fdf5 100644 --- a/src/chat/message_receive/message.py +++ b/src/chat/message_receive/message.py @@ -1,7 +1,8 @@ from asyncio import Task +from typing import Dict, List, Sequence, Tuple + from rich.traceback import install from sqlmodel import select -from typing import List, Dict, Tuple, Sequence import asyncio @@ -27,14 +28,36 @@ logger = get_logger("chat_message") class MsgIDMapping: - def __init__(self): - self.mapping: Dict[str, Tuple[str | Task, UserInfo]] = {} + """回复消息内容缓存。""" + + def __init__(self) -> None: + """初始化消息 ID 到内容的映射缓存。""" + self.mapping: Dict[str, Tuple[str | Task[str], UserInfo]] = {} class SessionMessage(MaiMessage): - async def process(self): - """处理消息内容,识别消息内容并转化为文本(会修改消息组件属性)""" - tasks = [self.process_single_component(component, MsgIDMapping()) for component in self.raw_message.components] + async def process( + self, + *, + enable_heavy_media_analysis: bool = True, + enable_voice_transcription: bool = True, + ) -> None: + """处理消息内容并转化为纯文本。 + + Args: + enable_heavy_media_analysis: 是否同步执行图片与表情包描述生成。 + enable_voice_transcription: 是否同步执行语音转写。 + """ + id_content_map = MsgIDMapping() + tasks = [ + self.process_single_component( + component, + id_content_map, + enable_heavy_media_analysis=enable_heavy_media_analysis, + enable_voice_transcription=enable_voice_transcription, + ) + for component in self.raw_message.components + ] results = await asyncio.gather(*tasks, return_exceptions=True) processed_texts: List[str] = [] for result in results: @@ -45,50 +68,116 @@ class SessionMessage(MaiMessage): self.processed_plain_text = " ".join(processed_texts) async def process_single_component( - self, component: StandardMessageComponents, id_content_map: MsgIDMapping, recursion_depth: int = 0 + self, + component: StandardMessageComponents, + id_content_map: MsgIDMapping, + recursion_depth: int = 0, + *, + enable_heavy_media_analysis: bool = True, + enable_voice_transcription: bool = True, ) -> str: - """按照类型处理单个消息组件,返回处理后的文本内容(会修改消息组件属性)""" + """按类型处理单个消息组件。 + + Args: + component: 待处理的消息组件。 + id_content_map: 回复消息解析缓存。 + recursion_depth: 当前递归深度。 + enable_heavy_media_analysis: 是否同步执行图片与表情包描述生成。 + enable_voice_transcription: 是否同步执行语音转写。 + + Returns: + str: 组件对应的文本表示。 + """ if isinstance(component, TextComponent): return component.text elif isinstance(component, ImageComponent): - return await self.process_image_component(component) + return await self.process_image_component( + component, + enable_heavy_media_analysis=enable_heavy_media_analysis, + ) elif isinstance(component, EmojiComponent): - return await self.process_emoji_component(component) + return await self.process_emoji_component( + component, + enable_heavy_media_analysis=enable_heavy_media_analysis, + ) elif isinstance(component, AtComponent): return await self.process_at_component(component) elif isinstance(component, VoiceComponent): - return await self.process_voice_component(component) + return await self.process_voice_component( + component, + enable_voice_transcription=enable_voice_transcription, + ) elif isinstance(component, ReplyComponent): return await self.process_reply_component(component, id_content_map) elif isinstance(component, ForwardNodeComponent): - return await self.process_forward_component(component, id_content_map, recursion_depth=recursion_depth + 1) + return await self.process_forward_component( + component, + id_content_map, + recursion_depth=recursion_depth + 1, + enable_heavy_media_analysis=enable_heavy_media_analysis, + enable_voice_transcription=enable_voice_transcription, + ) else: raise NotImplementedError(f"暂时不支持的消息组件类型: {type(component)}") - async def process_image_component(self, component: ImageComponent) -> str: + async def process_image_component( + self, + component: ImageComponent, + *, + enable_heavy_media_analysis: bool = True, + ) -> str: + """处理图片组件。 + + Args: + component: 图片组件。 + enable_heavy_media_analysis: 是否同步执行图片描述生成。 + + Returns: + str: 图片组件对应的文本表示。 + """ if component.content: # 先检查是否处理过 return component.content from src.chat.image_system.image_manager import image_manager # 获取描述 try: - desc = await image_manager.get_image_description(image_bytes=component.binary_data) + desc = await image_manager.get_image_description( + image_bytes=component.binary_data, + wait_for_build=enable_heavy_media_analysis, + ) except Exception: desc = None # 失败置空 - content = f"[图片:{desc}]" if desc else "[一张图片,网卡了加载不出来]" + content = f"[图片:{desc}]" if desc else "[图片]" component.content = content component.binary_data = b"" # 处理完就丢掉二进制数据,节省内存 return content - async def process_emoji_component(self, component: EmojiComponent) -> str: + async def process_emoji_component( + self, + component: EmojiComponent, + *, + enable_heavy_media_analysis: bool = True, + ) -> str: + """处理表情包组件。 + + Args: + component: 表情包组件。 + enable_heavy_media_analysis: 是否同步执行表情包描述生成。 + + Returns: + str: 表情包组件对应的文本表示。 + """ if component.content: # 先检查是否处理过 return component.content from src.chat.emoji_system.emoji_manager import emoji_manager # 获取表情包描述 try: - tuple_content = await emoji_manager.get_emoji_description(emoji_bytes=component.binary_data) + tuple_content = await emoji_manager.get_emoji_description( + emoji_bytes=component.binary_data, + wait_for_build=enable_heavy_media_analysis, + ) except Exception: tuple_content = None # 失败置空 @@ -96,7 +185,7 @@ class SessionMessage(MaiMessage): desc, _ = tuple_content content = f"[表情包: {desc}]" else: - content = "[一个表情,网卡了加载不出来]" + content = "[表情包]" component.content = content component.binary_data = b"" # 处理完就丢掉二进制数据,节省内存 return content @@ -124,9 +213,26 @@ class SessionMessage(MaiMessage): else: # 最后使用用户ID return f"@{component.target_user_id}" - async def process_voice_component(self, component: VoiceComponent) -> str: + async def process_voice_component( + self, + component: VoiceComponent, + *, + enable_voice_transcription: bool = True, + ) -> str: + """处理语音组件。 + + Args: + component: 语音组件。 + enable_voice_transcription: 是否同步执行语音转写。 + + Returns: + str: 语音组件对应的文本表示。 + """ if component.content: # 先检查是否处理过 return component.content + if not enable_voice_transcription: + component.content = "[语音消息]" + return component.content from src.common.utils.utils_voice import get_voice_text text = await get_voice_text(component.binary_data) @@ -169,13 +275,37 @@ class SessionMessage(MaiMessage): return "[回复了一条消息,但原消息已无法访问]" async def process_forward_component( - self, component: ForwardNodeComponent, id_content_map: MsgIDMapping, recursion_depth: int = 0 + self, + component: ForwardNodeComponent, + id_content_map: MsgIDMapping, + recursion_depth: int = 0, + *, + enable_heavy_media_analysis: bool = True, + enable_voice_transcription: bool = True, ) -> str: + """处理合并转发组件。 + + Args: + component: 合并转发组件。 + id_content_map: 回复消息解析缓存。 + recursion_depth: 当前递归深度。 + enable_heavy_media_analysis: 是否同步执行图片与表情包描述生成。 + enable_voice_transcription: 是否同步执行语音转写。 + + Returns: + str: 合并转发组件对应的文本表示。 + """ task_list: List[Task] = [] node_user_info_list: List[UserInfo] = [] for node in component.forward_components: task = asyncio.create_task( - self._process_multiple_components(node.content, id_content_map, recursion_depth + 1) + self._process_multiple_components( + node.content, + id_content_map, + recursion_depth + 1, + enable_heavy_media_analysis=enable_heavy_media_analysis, + enable_voice_transcription=enable_voice_transcription, + ) ) node_user_info = UserInfo(node.user_id or "未知用户", node.user_nickname, node.user_cardname) # 传入ID缓存映射,方便Reply组件获取并等待处理结果 @@ -196,9 +326,36 @@ class SessionMessage(MaiMessage): return "【合并转发消息: \n" + "\n".join(forward_texts) + "\n】" async def _process_multiple_components( - self, components: Sequence[StandardMessageComponents], id_content_map: MsgIDMapping, recursion_depth: int = 0 + self, + components: Sequence[StandardMessageComponents], + id_content_map: MsgIDMapping, + recursion_depth: int = 0, + *, + enable_heavy_media_analysis: bool = True, + enable_voice_transcription: bool = True, ) -> str: - tasks = [self.process_single_component(component, id_content_map, recursion_depth) for component in components] + """并行处理多个消息组件。 + + Args: + components: 待处理的组件序列。 + id_content_map: 回复消息解析缓存。 + recursion_depth: 当前递归深度。 + enable_heavy_media_analysis: 是否同步执行图片与表情包描述生成。 + enable_voice_transcription: 是否同步执行语音转写。 + + Returns: + str: 多个组件拼接后的文本表示。 + """ + tasks = [ + self.process_single_component( + component, + id_content_map, + recursion_depth, + enable_heavy_media_analysis=enable_heavy_media_analysis, + enable_voice_transcription=enable_voice_transcription, + ) + for component in components + ] results = await asyncio.gather(*tasks, return_exceptions=True) # 并行处理多个组件 processed_texts: List[str] = [] for result in results: diff --git a/src/common/i18n/manager.py b/src/common/i18n/manager.py index 4d6a1cc8..938d7ef6 100644 --- a/src/common/i18n/manager.py +++ b/src/common/i18n/manager.py @@ -46,9 +46,7 @@ class I18nManager: self._log_once( ("invalid_env_locale", "env", env_locale), logging.WARNING, - "检测到非法 MAIBOT_LOCALE=%s,已回退到默认 locale %s", - env_locale, - self._default_locale, + f"检测到非法 MAIBOT_LOCALE={env_locale},已回退到默认 locale {self._default_locale}", ) return self._default_locale @@ -84,15 +82,14 @@ class I18nManager: self._log_once( ("non_plural_key", translation_locale, key), logging.WARNING, - "翻译 key '%s' 不是 plural 节点,已回退到普通 t()", - key, + f"翻译 key '{key}' 不是 plural 节点,已回退到普通 t()", ) return self.t(key, locale=translation_locale, count=count, **kwargs) try: plural_category = select_plural_category(translation_locale, count) except Exception as exc: - logger.warning("为 key '%s' 选择 plural category 失败: %s,已回退到 other", key, exc) + logger.warning(f"为 key '{key}' 选择 plural category 失败: {exc},已回退到 other") plural_category = "other" template = translation_value.get(plural_category) or translation_value.get("other") @@ -100,8 +97,7 @@ class I18nManager: self._log_once( ("plural_missing_template", translation_locale, key), logging.WARNING, - "翻译 key '%s' 缺少 plural 模板,已回退到 key 本身", - key, + f"翻译 key '{key}' 缺少 plural 模板,已回退到 key 本身", ) return key @@ -125,8 +121,7 @@ class I18nManager: self._log_once( ("plural_missing_other", translation_locale, key), logging.WARNING, - "翻译 key '%s' 缺少 other plural category,已回退到 key 本身", - key, + f"翻译 key '{key}' 缺少 other plural category,已回退到 key 本身", ) return template @@ -134,7 +129,7 @@ class I18nManager: try: return format_template(template, **kwargs) except Exception as exc: - logger.error("翻译 key '%s' 格式化失败: %s", key, exc) + logger.error(f"翻译 key '{key}' 格式化失败: {exc}") return template def _get_translation_value(self, key: str, locale: str | None) -> tuple[TranslationValue | None, str]: @@ -149,20 +144,15 @@ class I18nManager: self._log_once( ("missing_key_fallback", target_locale, key), logging.WARNING, - "翻译 key '%s' 在 locale '%s' 中缺失,已回退到默认 locale '%s'", - key, - target_locale, - self._default_locale, + f"翻译 key '{key}' 在 locale '{target_locale}' 中缺失," + f"已回退到默认 locale '{self._default_locale}'", ) return default_catalog[key], self._default_locale self._log_once( ("missing_key", target_locale, key), logging.WARNING, - "翻译 key '%s' 缺失,locale='%s',默认 locale='%s'", - key, - target_locale, - self._default_locale, + f"翻译 key '{key}' 缺失,locale='{target_locale}',默认 locale='{self._default_locale}'", ) return None, target_locale @@ -177,9 +167,7 @@ class I18nManager: self._log_once( ("invalid_locale", "explicit", locale), logging.WARNING, - "检测到非法 locale='%s',已回退到当前默认 locale %s", - locale, - current_locale, + f"检测到非法 locale='{locale}',已回退到当前默认 locale {current_locale}", ) return current_locale @@ -195,9 +183,7 @@ class I18nManager: self._log_once( ("load_failed", normalized_locale, exc.__class__.__name__), logging.WARNING, - "加载 locale '%s' 失败: %s", - normalized_locale, - exc, + f"加载 locale '{normalized_locale}' 失败: {exc}", ) return {} diff --git a/src/common/prompt_i18n.py b/src/common/prompt_i18n.py index 46b6d70b..358833d1 100644 --- a/src/common/prompt_i18n.py +++ b/src/common/prompt_i18n.py @@ -170,7 +170,7 @@ def _format_prompt_template(name: str, template: str, **kwargs: object) -> str: error = KeyError(t("prompt.missing_placeholder", name=name, placeholder=missing_placeholder)) if is_strict_prompt_i18n_mode(): raise error from exc - logger.error("%s", error) + logger.error(f"{error}") return template except Exception as exc: logger.error(t("prompt.format_failed", name=name, error=exc)) diff --git a/src/llm_models/model_client/gemini_client.py b/src/llm_models/model_client/gemini_client.py index 17cedb45..0cad4fb4 100644 --- a/src/llm_models/model_client/gemini_client.py +++ b/src/llm_models/model_client/gemini_client.py @@ -627,7 +627,7 @@ class GeminiClient(AdapterClient[AsyncIterator[GenerateContentResponse], Generat try: thinking_budget = int(extra_params["thinking_budget"]) except (TypeError, ValueError): - logger.warning("无效的 thinking_budget=%s,已回退为自动模式", extra_params["thinking_budget"]) + logger.warning(f"无效的 thinking_budget={extra_params['thinking_budget']},已回退为自动模式") limits: Dict[str, int | bool] | None = None if model_id in THINKING_BUDGET_LIMITS: @@ -646,21 +646,21 @@ class GeminiClient(AdapterClient[AsyncIterator[GenerateContentResponse], Generat return THINKING_BUDGET_DISABLED if limits: minimum_value = int(limits["min"]) - logger.warning("模型 %s 不支持禁用思考预算,已回退为最小值 %s", model_id, minimum_value) + logger.warning(f"模型 {model_id} 不支持禁用思考预算,已回退为最小值 {minimum_value}") return minimum_value return THINKING_BUDGET_AUTO if limits is None: - logger.warning("模型 %s 未配置思考预算范围,已回退为自动模式", model_id) + logger.warning(f"模型 {model_id} 未配置思考预算范围,已回退为自动模式") return THINKING_BUDGET_AUTO minimum_value = int(limits["min"]) maximum_value = int(limits["max"]) if thinking_budget < minimum_value: - logger.warning("模型 %s 的 thinking_budget=%s 过小,已调整为 %s", model_id, thinking_budget, minimum_value) + logger.warning(f"模型 {model_id} 的 thinking_budget={thinking_budget} 过小,已调整为 {minimum_value}") return minimum_value if thinking_budget > maximum_value: - logger.warning("模型 %s 的 thinking_budget=%s 过大,已调整为 %s", model_id, thinking_budget, maximum_value) + logger.warning(f"模型 {model_id} 的 thinking_budget={thinking_budget} 过大,已调整为 {maximum_value}") return maximum_value return thinking_budget diff --git a/src/llm_models/model_client/openai_client.py b/src/llm_models/model_client/openai_client.py index 47f75263..44e085eb 100644 --- a/src/llm_models/model_client/openai_client.py +++ b/src/llm_models/model_client/openai_client.py @@ -103,7 +103,7 @@ def _normalize_reasoning_parse_mode(parse_mode: str | ReasoningParseMode) -> Rea try: return ReasoningParseMode(parse_mode) except ValueError: - logger.warning("未识别的推理解析模式 %s,已回退为 auto", parse_mode) + logger.warning(f"未识别的推理解析模式 {parse_mode},已回退为 auto") return ReasoningParseMode.AUTO @@ -121,7 +121,7 @@ def _normalize_tool_argument_parse_mode(parse_mode: str | ToolArgumentParseMode) try: return ToolArgumentParseMode(parse_mode) except ValueError: - logger.warning("未识别的工具参数解析模式 %s,已回退为 auto", parse_mode) + logger.warning(f"未识别的工具参数解析模式 {parse_mode},已回退为 auto") return ToolArgumentParseMode.AUTO @@ -425,7 +425,7 @@ def _log_length_truncation(finish_reason: str | None, model_name: str | None) -> model_name: 上游返回的模型标识。 """ if finish_reason == "length": - logger.info("模型%s因为超过最大 max_token 限制,可能仅输出部分内容,可视情况调整", model_name or "") + logger.info(f"模型{model_name or ''}因为超过最大 max_token 限制,可能仅输出部分内容,可视情况调整") def _coerce_openai_argument(value: Any) -> Any | Omit: diff --git a/src/maisaka/llm_service.py b/src/maisaka/llm_service.py index 5ab2b3ad..aca4b348 100644 --- a/src/maisaka/llm_service.py +++ b/src/maisaka/llm_service.py @@ -752,7 +752,12 @@ class MaiSakaLLMService: # 获取回复提示词 try: - system_prompt = load_prompt("maidairy_replyer") + system_prompt = load_prompt( + "maidairy_replyer", + bot_name=global_config.bot.nickname, + identity=self._personality_prompt, + reply_style=global_config.personality.reply_style, + ) except Exception: system_prompt = "你是一个友好的 AI 助手,请根据用户的想法生成自然的回复。" diff --git a/src/plugin_runtime/host/hook_dispatcher.py b/src/plugin_runtime/host/hook_dispatcher.py index 0406c8f6..f2979f29 100644 --- a/src/plugin_runtime/host/hook_dispatcher.py +++ b/src/plugin_runtime/host/hook_dispatcher.py @@ -578,19 +578,16 @@ class HookDispatcher: return if not hook_spec.allow_abort: logger.warning( - "Hook %s 禁止 abort,已将 %s 的错误策略按 skip 处理", - dispatch_result.hook_name, - target.entry.full_name, + f"Hook {dispatch_result.hook_name} 禁止 abort," + f"已将 {target.entry.full_name} 的错误策略按 skip 处理" ) return dispatch_result.aborted = True dispatch_result.stopped_by = target.entry.full_name logger.warning( - "HookHandler %s 因错误策略 abort 中止了 Hook %s: %s", - target.entry.full_name, - dispatch_result.hook_name, - error_message, + f"HookHandler {target.entry.full_name} 因错误策略 abort " + f"中止了 Hook {dispatch_result.hook_name}: {error_message}" ) def _schedule_observe_handler( @@ -610,7 +607,7 @@ class HookDispatcher: """ if not hook_spec.allow_observe: - logger.warning("Hook %s 不允许 observe 处理器,已跳过 %s", hook_name, target.entry.full_name) + logger.warning(f"Hook {hook_name} 不允许 observe 处理器,已跳过 {target.entry.full_name}") return task = asyncio.create_task( @@ -649,20 +646,15 @@ class HookDispatcher: if not execution_result.success: logger.warning( - "观察型 HookHandler %s 执行失败: %s", - target.entry.full_name, - execution_result.error_message or "未知错误", + f"观察型 HookHandler {target.entry.full_name} 执行失败: " + f"{execution_result.error_message or '未知错误'}" ) return if execution_result.modified_kwargs is not None: - logger.warning( - "观察型 HookHandler %s 返回了 modified_kwargs,已忽略", target.entry.full_name - ) + logger.warning(f"观察型 HookHandler {target.entry.full_name} 返回了 modified_kwargs,已忽略") if execution_result.action == "abort": - logger.warning( - "观察型 HookHandler %s 请求 abort,已忽略", target.entry.full_name - ) + logger.warning(f"观察型 HookHandler {target.entry.full_name} 请求 abort,已忽略") def _handle_background_task_done(self, task: asyncio.Task[Any]) -> None: """处理观察任务完成回调。 diff --git a/src/plugin_runtime/runner/log_handler.py b/src/plugin_runtime/runner/log_handler.py index 6f42940f..03f2db4d 100644 --- a/src/plugin_runtime/runner/log_handler.py +++ b/src/plugin_runtime/runner/log_handler.py @@ -164,7 +164,7 @@ class RunnerIPCLogHandler(logging.Handler): return f"{event_text} {' '.join(extras)}".strip() return event_text - # format() 会处理 %s 参数替换和 exc_info 文本拼接。 + # format() 会处理占位参数替换和 exc_info 文本拼接。 return self.format(record) @staticmethod diff --git a/src/services/send_service.py b/src/services/send_service.py index 134fb15e..d7f17563 100644 --- a/src/services/send_service.py +++ b/src/services/send_service.py @@ -281,6 +281,26 @@ def _build_processed_plain_text(message: SessionMessage) -> str: return " ".join(part for part in processed_parts if part) +def _build_outbound_log_preview(message: SessionMessage, max_length: int = 160) -> str: + """构造出站消息的日志预览文本。 + + Args: + message: 待发送的内部消息对象。 + max_length: 预览文本最大长度。 + + Returns: + str: 适用于日志展示的消息摘要。 + """ + preview_text = (message.processed_plain_text or message.display_message or "").strip() + if not preview_text: + preview_text = f"[{_describe_message_sequence(message.raw_message)}]" + + normalized_preview = " ".join(preview_text.split()) + if len(normalized_preview) <= max_length: + return normalized_preview + return f"{normalized_preview[:max_length]}..." + + def _build_outbound_session_message( message_sequence: MessageSequence, stream_id: str, @@ -424,11 +444,7 @@ def _log_platform_io_failures(delivery_batch: DeliveryBatch) -> None: f"driver={receipt.driver_id} status={receipt.status} error={receipt.error}" for receipt in delivery_batch.failed_receipts ) or "未命中任何发送路由" - logger.warning( - "[SendService] Platform IO 发送失败: platform=%s %s", - delivery_batch.route_key.platform, - failed_details, - ) + logger.warning(f"[SendService] Platform IO 发送失败: platform={delivery_batch.route_key.platform} {failed_details}") async def _send_via_platform_io( @@ -493,9 +509,9 @@ async def _send_via_platform_io( for receipt in delivery_batch.sent_receipts ] logger.info( - "[SendService] 已通过 Platform IO 将消息发往平台 '%s' (drivers: %s)", - route_key.platform, - ", ".join(successful_driver_ids), + f"[SendService] 已通过 Platform IO 将消息发往平台 '{route_key.platform}' " + f"(drivers: {', '.join(successful_driver_ids)}) " + f"message={_build_outbound_log_preview(message)}" ) return True