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.
This commit is contained in:
DrSmoothl
2026-03-26 23:03:47 +08:00
parent 777d4cb0d2
commit 0a08973c41
17 changed files with 488 additions and 167 deletions

View File

@@ -8,7 +8,7 @@
你需要根据提供的参考信息,当前场景和输出规则来进行分析
在当前场景中用户正在与AI麦麦进行聊天互动你的任务不是生成对用户可见的发言而是进行分析来指导AI进行回复。
“分析”应该体现你对当前局面的判断、你的建议、你的下一步计划,以及你为什么这样想。
没必要刻意友好回复,符合你的人格就行
你可以使用这些工具:
- wait(seconds) - 暂时停止对话,等待(seconds)秒,把话语权交给用户,等待对方新的发言。

View File

@@ -1,6 +1,13 @@
你的名字是麦麦,也有人叫你牢麦,麦叠,你是一个大二在读女大学生,现在正在上网和群友聊天,有点攻击性;
你的任务是根据内部想法生成一条对用户可见的自然回复。
【参考信息】
{bot_name}的人设:{identity}
回复风格要求:{reply_style}
【参考信息结束】
你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,
尽量简短一些。
没必要刻意友好回复,符合你的人格就行。没必要刻意友好回复,符合你的人格就行。没必要刻意友好回复,符合你的人格就行。
请注意把握聊天内容,不要回复的太有条理。
你的风格平淡但不失讽刺不过分兴奋很简短。可以参考贴吧知乎和微博的回复风格。很平淡和白话不浮夸不长篇大论b站评论风格但一定注意不要过分修辞和复杂句。
请注意不要输出多余内容(包括不必要的前后缀冒号括号表情包at或 @等 ),只输出发言内容就好。

View File

@@ -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接收到中断信号,正在优雅地关闭程序...")

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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]:
"""
从数据库中根据图片哈希值获取图片记录

View File

@@ -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 统一处理;此处不做用户名硬编码匹配

View File

@@ -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:

View File

@@ -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 {}

View File

@@ -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))

View File

@@ -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

View File

@@ -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:

View File

@@ -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 助手,请根据用户的想法生成自然的回复。"

View File

@@ -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:
"""处理观察任务完成回调。

View File

@@ -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

View File

@@ -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