From 6e6aa0b13a2b2d2d9dff237ffba6d75083aa0236 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 3 Apr 2026 13:43:49 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E7=94=9F=E6=88=90=E5=99=A8?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E5=A4=9A=E6=B6=88=E6=81=AF=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/replyer/maisaka_generator_multi.py | 453 ++++++++++++++++++++ src/chat/replyer/maisaka_replyer_factory.py | 21 + src/chat/replyer/replyer_manager.py | 16 +- src/cli/maisaka_cli.py | 4 +- src/config/config.py | 2 +- src/config/config_utils.py | 9 +- src/config/official_configs.py | 9 + src/maisaka/reasoning_engine.py | 5 +- 8 files changed, 505 insertions(+), 14 deletions(-) create mode 100644 src/chat/replyer/maisaka_generator_multi.py create mode 100644 src/chat/replyer/maisaka_replyer_factory.py diff --git a/src/chat/replyer/maisaka_generator_multi.py b/src/chat/replyer/maisaka_generator_multi.py new file mode 100644 index 00000000..dd705cd7 --- /dev/null +++ b/src/chat/replyer/maisaka_generator_multi.py @@ -0,0 +1,453 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional, Tuple + +import random +import time + +from sqlmodel import select + +from src.chat.message_receive.chat_manager import BotChatSession +from src.common.database.database import get_db_session +from src.common.database.database_model import Expression +from src.common.data_models.reply_generation_data_models import ( + GenerationMetrics, + LLMCompletionResult, + ReplyGenerationResult, +) +from src.common.logger import get_logger +from src.common.prompt_i18n import load_prompt +from src.config.config import global_config +from src.core.types import ActionInfo +from src.services.llm_service import LLMServiceClient + +from src.chat.message_receive.message import SessionMessage +from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType +from src.maisaka.context_messages import AssistantMessage, LLMContextMessage, ReferenceMessage, SessionBackedMessage, ToolResultMessage +from src.maisaka.message_adapter import parse_speaker_content + +logger = get_logger("replyer") + + +@dataclass +class MaisakaReplyContext: + """Maisaka replyer 使用的回复上下文。""" + + expression_habits: str = "" + selected_expression_ids: List[int] = field(default_factory=list) + + +@dataclass +class _ExpressionRecord: + """表达方式的轻量记录。""" + + expression_id: Optional[int] + situation: str + style: str + + +class MaisakaReplyGenerator: + """生成 Maisaka 的最终可见回复。""" + + def __init__( + self, + chat_stream: Optional[BotChatSession] = None, + request_type: str = "maisaka_replyer", + ) -> None: + self.chat_stream = chat_stream + self.request_type = request_type + self.express_model = LLMServiceClient( + task_name="replyer", + request_type=request_type, + ) + self._personality_prompt = self._build_personality_prompt() + + def _build_personality_prompt(self) -> str: + """构建 replyer 使用的人设描述。""" + try: + bot_name = global_config.bot.nickname + alias_names = global_config.bot.alias_names + bot_aliases = f",也有人叫你{','.join(alias_names)}" if alias_names else "" + + prompt_personality = global_config.personality.personality + if ( + hasattr(global_config.personality, "states") + and global_config.personality.states + and hasattr(global_config.personality, "state_probability") + and global_config.personality.state_probability > 0 + and random.random() < global_config.personality.state_probability + ): + prompt_personality = random.choice(global_config.personality.states) + + return f"你的名字是{bot_name}{bot_aliases},你{prompt_personality};" + except Exception as exc: + logger.warning(f"构建 Maisaka 人设提示词失败: {exc}") + return "你的名字是麦麦,你是一个活泼可爱的 AI 助手。" + + @staticmethod + def _normalize_content(content: str, limit: int = 500) -> str: + normalized = " ".join((content or "").split()) + if len(normalized) > limit: + return normalized[:limit] + "..." + return normalized + + @staticmethod + def _extract_visible_assistant_reply(message: AssistantMessage) -> str: + del message + return "" + + def _extract_guided_bot_reply(self, message: SessionBackedMessage) -> str: + speaker_name, body = parse_speaker_content(message.processed_plain_text.strip()) + bot_nickname = global_config.bot.nickname.strip() or "Bot" + if speaker_name == bot_nickname: + return self._normalize_content(body.strip()) + return "" + + @staticmethod + def _split_user_message_segments(raw_content: str) -> List[tuple[Optional[str], str]]: + """按说话人拆分用户消息。""" + segments: List[tuple[Optional[str], str]] = [] + current_speaker: Optional[str] = None + current_lines: List[str] = [] + + for raw_line in raw_content.splitlines(): + speaker_name, content_body = parse_speaker_content(raw_line) + if speaker_name is not None: + if current_lines: + segments.append((current_speaker, "\n".join(current_lines))) + current_speaker = speaker_name + current_lines = [content_body] + continue + + current_lines.append(raw_line) + + if current_lines: + segments.append((current_speaker, "\n".join(current_lines))) + + return segments + + def _build_system_prompt( + self, + reply_reason: str, + expression_habits: str = "", + ) -> str: + """构建 Maisaka replyer 使用的系统提示词。""" + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + try: + system_prompt = load_prompt( + "maisaka_replyer", + bot_name=global_config.bot.nickname, + time_block=f"当前时间:{current_time}", + identity=self._personality_prompt, + reply_style=global_config.personality.reply_style, + ) + except Exception: + system_prompt = "你是一个友好的 AI 助手,请根据聊天记录自然回复。" + + extra_sections: List[str] = [] + if expression_habits.strip(): + extra_sections.append(expression_habits.strip()) + if reply_reason.strip(): + extra_sections.append(f"【回复信息参考】\n{reply_reason}") + + if not extra_sections: + return system_prompt + return f"{system_prompt}\n\n" + "\n\n".join(extra_sections) + + def _build_reply_instruction(self) -> str: + """构建追加在上下文末尾的回复指令。""" + return "请基于以上逐条对话消息,自然地继续回复。直接输出你要说的话,不要额外解释。" + + def _build_history_messages(self, chat_history: List[LLMContextMessage]) -> List[Message]: + """将 replyer 上下文拆成多条 LLM 消息。""" + bot_nickname = global_config.bot.nickname.strip() or "Bot" + default_user_name = global_config.maisaka.user_name.strip() or "User" + messages: List[Message] = [] + + for message in chat_history: + if isinstance(message, (ReferenceMessage, ToolResultMessage)): + continue + + if isinstance(message, SessionBackedMessage): + guided_reply = self._extract_guided_bot_reply(message) + if guided_reply: + messages.append( + MessageBuilder().set_role(RoleType.Assistant).add_text_content(guided_reply).build() + ) + continue + + for speaker_name, content_body in self._split_user_message_segments(message.processed_plain_text): + content = self._normalize_content(content_body) + if not content: + continue + + visible_speaker = speaker_name or default_user_name + if visible_speaker == bot_nickname: + messages.append( + MessageBuilder().set_role(RoleType.Assistant).add_text_content(content).build() + ) + continue + + user_content = f"[{visible_speaker}]{content}" + messages.append(MessageBuilder().set_role(RoleType.User).add_text_content(user_content).build()) + continue + + if isinstance(message, AssistantMessage): + visible_reply = self._extract_visible_assistant_reply(message) + if visible_reply: + messages.append( + MessageBuilder().set_role(RoleType.Assistant).add_text_content(visible_reply).build() + ) + + return messages + + def _build_request_messages( + self, + chat_history: List[LLMContextMessage], + reply_reason: str, + expression_habits: str = "", + ) -> List[Message]: + """构建发给大模型的消息列表。""" + messages: List[Message] = [] + system_prompt = self._build_system_prompt( + reply_reason=reply_reason, + expression_habits=expression_habits, + ) + instruction = self._build_reply_instruction() + + messages.append(MessageBuilder().set_role(RoleType.System).add_text_content(system_prompt).build()) + messages.extend(self._build_history_messages(chat_history)) + messages.append(MessageBuilder().set_role(RoleType.User).add_text_content(instruction).build()) + return messages + + @staticmethod + def _build_request_prompt_preview(messages: List[Message]) -> str: + """将消息列表转为便于调试的文本预览。""" + preview_lines: List[str] = [] + for message in messages: + role_name = message.role.value.capitalize() + preview_lines.append(f"{role_name}: {message.get_text_content()}") + return "\n\n".join(preview_lines) + + def _resolve_session_id(self, stream_id: Optional[str]) -> str: + """解析当前回复使用的会话 ID。""" + if stream_id: + return stream_id + if self.chat_stream is not None: + return self.chat_stream.session_id + return "" + + async def _build_reply_context( + self, + chat_history: List[LLMContextMessage], + reply_message: Optional[SessionMessage], + reply_reason: str, + stream_id: Optional[str], + ) -> MaisakaReplyContext: + """在 replyer 内部构建表达习惯和黑话解释。""" + session_id = self._resolve_session_id(stream_id) + if not session_id: + logger.warning("构建 Maisaka 回复上下文失败:缺少会话标识") + return MaisakaReplyContext() + + expression_habits, selected_expression_ids = self._build_expression_habits( + session_id=session_id, + chat_history=chat_history, + reply_message=reply_message, + reply_reason=reply_reason, + ) + return MaisakaReplyContext( + expression_habits=expression_habits, + selected_expression_ids=selected_expression_ids, + ) + + def _build_expression_habits( + self, + session_id: str, + chat_history: List[LLMContextMessage], + reply_message: Optional[SessionMessage], + reply_reason: str, + ) -> tuple[str, List[int]]: + """查询并格式化适合当前会话的表达习惯。""" + del chat_history + del reply_message + del reply_reason + + expression_records = self._load_expression_records(session_id) + if not expression_records: + return "", [] + + lines: List[str] = [] + selected_ids: List[int] = [] + for expression in expression_records: + if expression.expression_id is not None: + selected_ids.append(expression.expression_id) + lines.append(f"- 当{expression.situation}时,可以自然地用{expression.style}这种表达习惯。") + + block = "【表达习惯参考】\n" + "\n".join(lines) + logger.info( + f"已构建 Maisaka 表达习惯: 会话标识={session_id} " + f"数量={len(selected_ids)} 表达编号={selected_ids!r}" + ) + return block, selected_ids + + def _load_expression_records(self, session_id: str) -> List[_ExpressionRecord]: + """提取表达方式静态数据,避免 detached ORM 对象。""" + with get_db_session(auto_commit=False) as session: + query = select(Expression).where(Expression.rejected.is_(False)) # type: ignore[attr-defined] + if global_config.expression.expression_checked_only: + query = query.where(Expression.checked.is_(True)) # type: ignore[attr-defined] + + query = query.where( + (Expression.session_id == session_id) | (Expression.session_id.is_(None)) # type: ignore[attr-defined] + ).order_by(Expression.count.desc(), Expression.last_active_time.desc()) # type: ignore[attr-defined] + + expressions = session.exec(query.limit(5)).all() + return [ + _ExpressionRecord( + expression_id=expression.id, + situation=expression.situation, + style=expression.style, + ) + for expression in expressions + ] + + async def generate_reply_with_context( + self, + extra_info: str = "", + reply_reason: str = "", + available_actions: Optional[Dict[str, ActionInfo]] = None, + chosen_actions: Optional[List[object]] = None, + from_plugin: bool = True, + stream_id: Optional[str] = None, + reply_message: Optional[SessionMessage] = None, + reply_time_point: Optional[float] = None, + think_level: int = 1, + unknown_words: Optional[List[str]] = None, + log_reply: bool = True, + chat_history: Optional[List[LLMContextMessage]] = None, + expression_habits: str = "", + selected_expression_ids: Optional[List[int]] = None, + ) -> Tuple[bool, ReplyGenerationResult]: + """结合上下文生成 Maisaka 的最终可见回复。""" + del available_actions + del chosen_actions + del extra_info + del from_plugin + del log_reply + del reply_time_point + del think_level + del unknown_words + + result = ReplyGenerationResult() + if chat_history is None: + result.error_message = "聊天历史为空" + return False, result + + logger.info( + f"Maisaka 回复器开始生成: 会话流标识={stream_id} 回复原因={reply_reason!r} " + f"历史消息数={len(chat_history)} 目标消息编号=" + f"{reply_message.message_id if reply_message else None}" + ) + + filtered_history = [ + message + for message in chat_history + if not isinstance(message, (ReferenceMessage, ToolResultMessage)) + ] + + logger.debug(f"Maisaka 回复器过滤后历史消息数={len(filtered_history)}") + + # Validate that express_model is properly initialized + if self.express_model is None: + logger.error("Maisaka 回复器的回复模型未初始化") + result.error_message = "回复模型尚未初始化" + return False, result + + try: + reply_context = await self._build_reply_context( + chat_history=filtered_history, + reply_message=reply_message, + reply_reason=reply_reason or "", + stream_id=stream_id, + ) + except Exception as exc: + import traceback + logger.error(f"Maisaka 回复器构建回复上下文失败: {exc}\n{traceback.format_exc()}") + result.error_message = f"构建回复上下文失败: {exc}" + return False, result + + merged_expression_habits = expression_habits.strip() or reply_context.expression_habits + result.selected_expression_ids = ( + list(selected_expression_ids) + if selected_expression_ids is not None + else list(reply_context.selected_expression_ids) + ) + + logger.info( + f"Maisaka 回复上下文构建完成: 会话流标识={stream_id} " + f"已选表达编号={result.selected_expression_ids!r}" + ) + + try: + request_messages = self._build_request_messages( + chat_history=filtered_history, + reply_reason=reply_reason or "", + expression_habits=merged_expression_habits, + ) + except Exception as exc: + import traceback + logger.error(f"Maisaka 回复器构建提示词失败: {exc}\n{traceback.format_exc()}") + result.error_message = f"构建提示词失败: {exc}" + return False, result + + prompt_preview = self._build_request_prompt_preview(request_messages) + + def message_factory(_client: object) -> List[Message]: + return request_messages + + result.completion.request_prompt = prompt_preview + + if global_config.debug.show_replyer_prompt: + logger.info(f"\nMaisaka 回复器提示词:\n{prompt_preview}\n") + + started_at = time.perf_counter() + try: + generation_result = await self.express_model.generate_response_with_messages(message_factory=message_factory) + except Exception as exc: + logger.exception("Maisaka 回复器调用失败") + result.error_message = str(exc) + result.metrics = GenerationMetrics( + overall_ms=round((time.perf_counter() - started_at) * 1000, 2), + ) + return False, result + + response_text = (generation_result.response or "").strip() + result.success = bool(response_text) + result.completion = LLMCompletionResult( + request_prompt=prompt_preview, + response_text=response_text, + reasoning_text=generation_result.reasoning or "", + model_name=generation_result.model_name or "", + tool_calls=generation_result.tool_calls or [], + ) + result.metrics = GenerationMetrics( + overall_ms=round((time.perf_counter() - started_at) * 1000, 2), + ) + + if global_config.debug.show_replyer_reasoning and result.completion.reasoning_text: + logger.info(f"Maisaka 回复器思考内容:\n{result.completion.reasoning_text}") + + if not result.success: + result.error_message = "回复器返回了空内容" + logger.warning("Maisaka 回复器返回了空内容") + return False, result + + logger.info( + f"Maisaka 回复器生成成功: 回复文本={response_text!r} " + f"总耗时毫秒={result.metrics.overall_ms} " + f"已选表达编号={result.selected_expression_ids!r}" + ) + result.text_fragments = [response_text] + return True, result diff --git a/src/chat/replyer/maisaka_replyer_factory.py b/src/chat/replyer/maisaka_replyer_factory.py new file mode 100644 index 00000000..e4a8cd39 --- /dev/null +++ b/src/chat/replyer/maisaka_replyer_factory.py @@ -0,0 +1,21 @@ +from typing import Type + +from src.config.config import global_config + + +def get_maisaka_replyer_class() -> Type[object]: + """根据配置返回 Maisaka replyer 类。""" + generator_type = global_config.maisaka.replyer_generator_type + if generator_type == "multi": + from .maisaka_generator_multi import MaisakaReplyGenerator + + return MaisakaReplyGenerator + + from .maisaka_generator import MaisakaReplyGenerator + + return MaisakaReplyGenerator + + +def get_maisaka_replyer_generator_type() -> str: + """返回当前配置的 Maisaka replyer 生成器类型。""" + return global_config.maisaka.replyer_generator_type diff --git a/src/chat/replyer/replyer_manager.py b/src/chat/replyer/replyer_manager.py index 6ba9ce02..58b4041b 100644 --- a/src/chat/replyer/replyer_manager.py +++ b/src/chat/replyer/replyer_manager.py @@ -1,11 +1,14 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from src.chat.message_receive.chat_manager import BotChatSession, chat_manager as _chat_manager +from src.chat.replyer.maisaka_replyer_factory import ( + get_maisaka_replyer_class, + get_maisaka_replyer_generator_type, +) from src.common.logger import get_logger if TYPE_CHECKING: from src.chat.replyer.group_generator import DefaultReplyer - from src.chat.replyer.maisaka_generator import MaisakaReplyGenerator from src.chat.replyer.private_generator import PrivateReplyer logger = get_logger("ReplyerManager") @@ -23,14 +26,15 @@ class ReplyerManager: chat_id: Optional[str] = None, request_type: str = "replyer", replyer_type: str = "default", - ) -> Optional["DefaultReplyer | MaisakaReplyGenerator | PrivateReplyer"]: + ) -> Optional["DefaultReplyer | PrivateReplyer | Any"]: """按会话和 replyer 类型获取实例。""" stream_id = chat_stream.session_id if chat_stream else chat_id if not stream_id: logger.warning("[ReplyerManager] 缺少 stream_id,无法获取 replyer") return None - cache_key = f"{replyer_type}:{stream_id}" + generator_type = get_maisaka_replyer_generator_type() if replyer_type == "maisaka" else "" + cache_key = f"{replyer_type}:{generator_type}:{stream_id}" if cache_key in self._repliers: logger.info(f"[ReplyerManager] 命中缓存 replyer: cache_key={cache_key}") return self._repliers[cache_key] @@ -47,10 +51,10 @@ class ReplyerManager: try: if replyer_type == "maisaka": - logger.info("[ReplyerManager] importing MaisakaReplyGenerator") - from src.chat.replyer.maisaka_generator import MaisakaReplyGenerator + logger.info(f"[ReplyerManager] 选择 MaisakaReplyGenerator: generator_type={generator_type}") + maisaka_replyer_class = get_maisaka_replyer_class() - replyer = MaisakaReplyGenerator( + replyer = maisaka_replyer_class( chat_stream=target_stream, request_type=request_type, ) diff --git a/src/cli/maisaka_cli.py b/src/cli/maisaka_cli.py index 3aec362c..fb333b07 100644 --- a/src/cli/maisaka_cli.py +++ b/src/cli/maisaka_cli.py @@ -17,7 +17,7 @@ from rich.text import Text from src.know_u.knowledge import KnowledgeLearner, retrieve_relevant_knowledge from src.know_u.knowledge_store import get_knowledge_store from src.chat.message_receive.message import SessionMessage -from src.chat.replyer.maisaka_generator import MaisakaReplyGenerator +from src.chat.replyer.maisaka_replyer_factory import get_maisaka_replyer_class from src.config.config import config_manager, global_config from src.mcp_module import MCPManager from src.mcp_module.host_llm_bridge import MCPHostLLMBridge @@ -46,7 +46,7 @@ class BufferCLI: def __init__(self) -> None: self._chat_loop_service: Optional[MaisakaChatLoopService] = None - self._reply_generator = MaisakaReplyGenerator() + self._reply_generator = get_maisaka_replyer_class()() self._reader = InputReader() self._chat_history: Optional[list[LLMContextMessage]] = None self._knowledge_store = get_knowledge_store() diff --git a/src/config/config.py b/src/config/config.py index 601ae5b1..9894117b 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -56,7 +56,7 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config" BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute() MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute() MMC_VERSION: str = "1.0.0" -CONFIG_VERSION: str = "8.2.0" +CONFIG_VERSION: str = "8.2.1" MODEL_CONFIG_VERSION: str = "1.13.1" logger = get_logger("config") diff --git a/src/config/config_utils.py b/src/config/config_utils.py index f2e73291..75238f61 100644 --- a/src/config/config_utils.py +++ b/src/config/config_utils.py @@ -30,7 +30,14 @@ def recursive_parse_item_to_table( if value is None: continue if isinstance(value, ConfigBase): - config_table.add(config_item_name, recursive_parse_item_to_table(value, override_repr=override_repr)) + config_table.add( + config_item_name, + recursive_parse_item_to_table( + value, + is_inline_table=is_inline_table, + override_repr=override_repr, + ), + ) else: config_table.add( config_item_name, convert_field(config_item_name, config_item_info, value, override_repr=override_repr) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 9c800996..bc300184 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1528,6 +1528,15 @@ class MaiSakaConfig(ConfigBase): ) """是否将新接收的用户发言合并为单个用户消息""" + replyer_generator_type: Literal["legacy", "multi"] = Field( + default="legacy", + json_schema_extra={ + "x-widget": "select", + "x-icon": "git-branch", + }, + ) + """Maisaka replyer 生成器类型:legacy(旧版单 prompt)/ multi(多消息版)""" + max_internal_rounds: int = Field( default=6, ge=1, diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index fd8fb06f..a79a2f90 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -2,7 +2,7 @@ from base64 import b64decode from datetime import datetime -from typing import TYPE_CHECKING, Any, Optional, cast +from typing import TYPE_CHECKING, Any, Optional import asyncio import difflib @@ -1385,9 +1385,6 @@ class MaisakaReasoningEngine: "Maisaka 回复生成器当前不可用。", ) - from src.chat.replyer.maisaka_generator import MaisakaReplyGenerator - - replyer = cast(MaisakaReplyGenerator, replyer) logger.info(f"{self._runtime.log_prefix} 已成功获取 Maisaka 回复生成器") logger.info(f"{self._runtime.log_prefix} 正在调用回复生成接口: 目标消息编号={target_message_id}")