From c0230fc31369f8aa5b3e9c92bb337d5bec6d37e9 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sat, 11 Apr 2026 16:41:00 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E7=BB=9F=E4=B8=80replyer=E5=9C=A8?= =?UTF-8?q?=E6=98=AF=E5=90=A6=E5=A4=9A=E6=A8=A1=E6=80=81=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E8=A1=A8=E7=8E=B0=EF=BC=8C=E6=8F=90=E9=AB=98=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E5=92=8C=E9=80=9A=E7=94=A8=E6=80=A7=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=A8=A1=E5=9E=8Bvisual=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pytests/test_maisaka_monitor_protocol.py | 133 +- pytests/test_prompt_message_roundtrip.py | 18 + src/chat/replyer/group_generator.py | 1176 ----------------- src/chat/replyer/maisaka_generator.py | 530 +------- ...tor_multi.py => maisaka_generator_base.py} | 47 +- src/chat/replyer/maisaka_replyer_factory.py | 21 - src/chat/replyer/replyer_manager.py | 29 +- src/config/config.py | 2 +- src/config/model_configs.py | 11 +- src/llm_models/request_snapshot.py | 2 + src/main.py | 2 +- src/maisaka/chat_loop_service.py | 2 +- src/maisaka/display/__init__.py | 33 + src/maisaka/{ => display}/display_utils.py | 20 +- .../{ => display}/prompt_cli_renderer.py | 30 +- .../{ => display}/prompt_preview_logger.py | 1 + .../{ => display}/stage_status_board.py | 0 .../{ => display}/stage_status_viewer.py | 2 +- src/maisaka/runtime.py | 6 +- src/services/llm_service.py | 60 +- 20 files changed, 323 insertions(+), 1802 deletions(-) create mode 100644 pytests/test_prompt_message_roundtrip.py delete mode 100644 src/chat/replyer/group_generator.py rename src/chat/replyer/{maisaka_generator_multi.py => maisaka_generator_base.py} (95%) delete mode 100644 src/chat/replyer/maisaka_replyer_factory.py create mode 100644 src/maisaka/display/__init__.py rename src/maisaka/{ => display}/display_utils.py (76%) rename src/maisaka/{ => display}/prompt_cli_renderer.py (97%) rename src/maisaka/{ => display}/prompt_preview_logger.py (99%) rename src/maisaka/{ => display}/stage_status_board.py (100%) rename src/maisaka/{ => display}/stage_status_viewer.py (100%) diff --git a/pytests/test_maisaka_monitor_protocol.py b/pytests/test_maisaka_monitor_protocol.py index 131aa774..31cc4f09 100644 --- a/pytests/test_maisaka_monitor_protocol.py +++ b/pytests/test_maisaka_monitor_protocol.py @@ -5,8 +5,7 @@ import pytest from rich.panel import Panel from rich.text import Text -from src.chat.replyer import maisaka_generator as legacy_replyer_module -from src.chat.replyer import maisaka_generator_multi as multimodal_replyer_module +from src.chat.replyer import maisaka_generator as replyer_module from src.common.data_models.reply_generation_data_models import ( GenerationMetrics, LLMCompletionResult, @@ -37,8 +36,8 @@ class _FakeLegacyLLMServiceClient: del args del kwargs - async def generate_response(self, prompt: str) -> _FakeLLMResult: - assert prompt + async def generate_response_with_messages(self, *, message_factory: Callable[[object], list[Any]]) -> _FakeLLMResult: + assert message_factory(object()) return _FakeLLMResult() @@ -54,13 +53,21 @@ class _FakeMultimodalLLMServiceClient: @pytest.mark.asyncio async def test_legacy_and_multimodal_replyer_monitor_detail_have_same_shape(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(legacy_replyer_module, "LLMServiceClient", _FakeLegacyLLMServiceClient) - monkeypatch.setattr(multimodal_replyer_module, "LLMServiceClient", _FakeMultimodalLLMServiceClient) - monkeypatch.setattr(legacy_replyer_module, "load_prompt", lambda *args, **kwargs: "legacy prompt") - monkeypatch.setattr(multimodal_replyer_module, "load_prompt", lambda *args, **kwargs: "multi prompt") + monkeypatch.setattr(replyer_module, "LLMServiceClient", _FakeLegacyLLMServiceClient) + monkeypatch.setattr(replyer_module, "load_prompt", lambda *args, **kwargs: "legacy prompt") - legacy_generator = legacy_replyer_module.MaisakaReplyGenerator(chat_stream=None, request_type="test_legacy") - multimodal_generator = multimodal_replyer_module.MaisakaReplyGenerator(chat_stream=None, request_type="test_multi") + legacy_generator = replyer_module.MaisakaReplyGenerator( + chat_stream=None, + request_type="test_legacy", + enable_visual_message=False, + ) + multimodal_generator = replyer_module.MaisakaReplyGenerator( + chat_stream=None, + request_type="test_multi", + llm_client_cls=_FakeMultimodalLLMServiceClient, + load_prompt_func=lambda *args, **kwargs: "multi prompt", + enable_visual_message=True, + ) legacy_success, legacy_result = await legacy_generator.generate_reply_with_context( stream_id="session-legacy", @@ -84,6 +91,40 @@ async def test_legacy_and_multimodal_replyer_monitor_detail_have_same_shape(monk assert legacy_result.monitor_detail["metrics"]["total_tokens"] == 19 +def test_legacy_replyer_builds_message_sequence_like_multimodal() -> None: + legacy_generator = replyer_module.MaisakaReplyGenerator( + chat_stream=None, + request_type="test_legacy", + enable_visual_message=False, + ) + legacy_prompt_loader = replyer_module.load_prompt + replyer_module.load_prompt = lambda *args, **kwargs: "legacy prompt" + + try: + session_message = replyer_module.SessionBackedMessage( + raw_message=SimpleNamespace(), + visible_text="[Alice]你好\n[Bob]在吗", + timestamp=replyer_module.datetime.now(), + source_kind="user", + ) + request_messages = legacy_generator._build_request_messages( + chat_history=[session_message], + reply_message=None, + reply_reason="测试原因", + stream_id="session-legacy", + ) + finally: + replyer_module.load_prompt = legacy_prompt_loader + + assert len(request_messages) == 4 + assert request_messages[0].role.value == "system" + assert request_messages[1].role.value == "user" + assert request_messages[1].get_text_content() == "[Alice]你好" + assert request_messages[2].role.value == "user" + assert request_messages[2].get_text_content() == "[Bob]在吗" + assert request_messages[3].role.value == "user" + + @pytest.mark.asyncio async def test_reply_tool_puts_monitor_detail_into_metadata(monkeypatch: pytest.MonkeyPatch) -> None: fake_monitor_detail = { @@ -324,7 +365,7 @@ def test_reasoning_engine_build_tool_monitor_result_keeps_non_reply_tool_without def test_runtime_build_tool_detail_panels_renders_reply_monitor_detail() -> None: runtime = object.__new__(MaisakaHeartFlowChatting) runtime.session_id = "session-1" - panels = runtime._build_tool_detail_panels( + panels = runtime._build_tool_detail_cards( [ { "tool_call_id": "call-reply-1", @@ -348,7 +389,8 @@ def test_runtime_build_tool_detail_panels_renders_reply_monitor_detail() -> None }, }, } - ] + ], + stage_title="工具调用", ) assert len(panels) == 1 @@ -387,7 +429,7 @@ def test_runtime_build_tool_detail_panels_uses_prompt_access_panel(monkeypatch: _fake_build_text_access_panel, ) - panels = runtime._build_tool_detail_panels( + panels = runtime._build_tool_detail_cards( [ { "tool_call_id": "call-reply-2", @@ -401,7 +443,8 @@ def test_runtime_build_tool_detail_panels_uses_prompt_access_panel(monkeypatch: "output_text": "reply output", }, } - ] + ], + stage_title="工具调用", ) assert len(panels) == 1 @@ -425,7 +468,7 @@ def test_runtime_build_tool_detail_panels_uses_emotion_prompt_access_panel(monke _fake_build_text_access_panel, ) - panels = runtime._build_tool_detail_panels( + panels = runtime._build_tool_detail_cards( [ { "tool_call_id": "call-emoji-1", @@ -439,7 +482,8 @@ def test_runtime_build_tool_detail_panels_uses_emotion_prompt_access_panel(monke "output_text": '{"emoji_index": 1}', }, } - ] + ], + stage_title="工具调用", ) assert len(panels) == 1 @@ -448,6 +492,63 @@ def test_runtime_build_tool_detail_panels_uses_emotion_prompt_access_panel(monke assert captured["kwargs"]["request_kind"] == "emotion" +def test_runtime_build_tool_detail_cards_uses_structured_prompt_messages_with_images( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = object.__new__(MaisakaHeartFlowChatting) + runtime.session_id = "session-image" + captured: dict[str, Any] = {} + + def _fake_build_prompt_access_panel(messages: list[Any], **kwargs: Any) -> str: + captured["messages"] = messages + captured["kwargs"] = kwargs + return "IMAGE_PROMPT_LINK" + + def _fake_build_text_access_panel(content: str, **kwargs: Any) -> str: + captured["text_content"] = content + captured["text_kwargs"] = kwargs + return "TEXT_PROMPT_LINK" + + monkeypatch.setattr( + "src.maisaka.runtime.PromptCLIVisualizer.build_prompt_access_panel", + _fake_build_prompt_access_panel, + ) + monkeypatch.setattr( + "src.maisaka.runtime.PromptCLIVisualizer.build_text_access_panel", + _fake_build_text_access_panel, + ) + + panels = runtime._build_tool_detail_cards( + [ + { + "tool_call_id": "call-reply-image-1", + "tool_name": "reply", + "tool_args": {"msg_id": "m3"}, + "success": True, + "duration_ms": 22.0, + "summary": "- reply [成功]: 已回复", + "detail": { + "prompt_text": "reply prompt image", + "request_messages": [ + { + "role": "user", + "content": ["前缀文本", ["png", "ZmFrZQ=="]], + } + ], + "output_text": "reply output", + }, + } + ], + stage_title="工具调用", + ) + + assert len(panels) == 1 + assert "messages" in captured + assert "text_content" not in captured + assert captured["kwargs"]["chat_id"] == "session-image" + assert captured["kwargs"]["request_kind"] == "replyer" + + def test_runtime_render_context_usage_panel_merges_timing_and_planner(monkeypatch: pytest.MonkeyPatch) -> None: runtime = object.__new__(MaisakaHeartFlowChatting) runtime.session_id = "session-merged" diff --git a/pytests/test_prompt_message_roundtrip.py b/pytests/test_prompt_message_roundtrip.py new file mode 100644 index 00000000..01878585 --- /dev/null +++ b/pytests/test_prompt_message_roundtrip.py @@ -0,0 +1,18 @@ +from src.llm_models.payload_content.message import MessageBuilder, RoleType +from src.plugin_runtime.hook_payloads import deserialize_prompt_messages, serialize_prompt_messages + + +def test_prompt_messages_roundtrip_preserves_image_parts() -> None: + messages = [ + MessageBuilder().set_role(RoleType.User).add_text_content("你好").add_image_content("png", "ZmFrZQ==").build(), + ] + + serialized_messages = serialize_prompt_messages(messages) + restored_messages = deserialize_prompt_messages(serialized_messages) + + assert len(restored_messages) == 1 + assert restored_messages[0].role == RoleType.User + assert restored_messages[0].get_text_content() == "你好" + assert len(restored_messages[0].parts) == 2 + assert restored_messages[0].parts[1].image_format == "png" + assert restored_messages[0].parts[1].image_base64 == "ZmFrZQ==" diff --git a/src/chat/replyer/group_generator.py b/src/chat/replyer/group_generator.py deleted file mode 100644 index a3ba4817..00000000 --- a/src/chat/replyer/group_generator.py +++ /dev/null @@ -1,1176 +0,0 @@ -import traceback -import time -import asyncio -import importlib -import random -import re - -from typing import List, Optional, Dict, Any, Tuple -from datetime import datetime -from src.common.logger import get_logger -from src.common.data_models.planned_action_data_models import PlannedAction -from src.common.data_models.llm_data_model import LLMGenerationDataModel -from src.config.config import global_config -from src.services.llm_service import LLMServiceClient -from maim_message import BaseMessageInfo, MessageBase, Seg, UserInfo as MaimUserInfo - -from src.common.data_models.mai_message_data_model import MaiMessage -from src.common.utils.utils_session import SessionUtils -from src.chat.message_receive.message import SessionMessage -from src.chat.message_receive.chat_manager import BotChatSession -from src.chat.utils.timer_calculator import Timer # <--- Import Timer -from src.chat.utils.utils import get_bot_account, get_chat_type_and_target_info, is_bot_self -from src.prompt.prompt_manager import prompt_manager -from src.services.message_service import ( - build_readable_messages, - get_messages_before_time_in_chat, - replace_user_references, - translate_pid_to_description, -) -# from src.memory_system.memory_activator import MemoryActivator -from src.person_info.person_info import Person -from src.core.types import ActionInfo, EventType -from src.services import llm_service as llm_api - -from src.memory_system.memory_retrieval import init_memory_retrieval_sys, build_memory_retrieval_prompt -from src.learners.jargon_explainer_old import explain_jargon_in_context -from src.chat.utils.common_utils import TempMethodsExpression - -init_memory_retrieval_sys() - - -logger = get_logger("replyer") - - -class DefaultReplyer: - def __init__( - self, - chat_stream: BotChatSession, - request_type: str = "replyer", - ): - """初始化群聊回复器。 - - Args: - chat_stream: 当前绑定的聊天会话。 - request_type: LLM 请求类型标识。 - """ - self.express_model = LLMServiceClient( - task_name="replyer", request_type=request_type - ) - self.chat_stream = chat_stream - self.is_group_chat, self.chat_target_info = get_chat_type_and_target_info(self.chat_stream.session_id) - - async def generate_reply_with_context( - self, - extra_info: str = "", - reply_reason: str = "", - available_actions: Optional[Dict[str, ActionInfo]] = None, - chosen_actions: Optional[List[PlannedAction]] = None, - from_plugin: bool = True, - stream_id: Optional[str] = None, - reply_message: Optional[SessionMessage] = None, - reply_time_point: float = time.time(), - think_level: int = 1, - unknown_words: Optional[List[str]] = None, - log_reply: bool = True, - ) -> Tuple[bool, LLMGenerationDataModel]: - # sourcery skip: merge-nested-ifs - """ - 回复器 (Replier): 负责生成回复文本的核心逻辑。 - - Args: - reply_to: 回复对象,格式为 "发送者:消息内容" - extra_info: 额外信息,用于补充上下文 - reply_reason: 回复原因 - available_actions: 可用的动作信息字典 - chosen_actions: 已选动作 - from_plugin: 是否来自插件 - - Returns: - Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: (是否成功, 生成的回复, 使用的prompt) - """ - - overall_start = time.perf_counter() - prompt_duration_ms: Optional[float] = None - llm_duration_ms: Optional[float] = None - prompt = None - selected_expressions: Optional[List[int]] = None - llm_response = LLMGenerationDataModel() - if available_actions is None: - available_actions = {} - try: - # 3. 构建 Prompt - timing_logs = [] - almost_zero_str = "" - prompt_start = time.perf_counter() - with Timer("构建Prompt", {}): # 内部计时器,可选保留 - prompt, selected_expressions, timing_logs, almost_zero_str = await self.build_prompt_reply_context( - extra_info=extra_info, - available_actions=available_actions, - chosen_actions=chosen_actions, - reply_message=reply_message, - reply_reason=reply_reason, - reply_time_point=reply_time_point, - think_level=think_level, - unknown_words=unknown_words, - ) - prompt_duration_ms = (time.perf_counter() - prompt_start) * 1000 - llm_response.prompt = prompt - llm_response.selected_expressions = selected_expressions - llm_response.timing = { - "prompt_ms": round(prompt_duration_ms or 0.0, 2), - "overall_ms": None, # 占位,稍后写入 - } - llm_response.timing_logs = timing_logs - llm_response.timing["timing_logs"] = timing_logs - - if not prompt: - logger.warning("构建prompt失败,跳过回复生成") - llm_response.timing["overall_ms"] = round((time.perf_counter() - overall_start) * 1000, 2) - llm_response.timing["almost_zero"] = almost_zero_str - llm_response.timing["timing_logs"] = timing_logs - return False, llm_response - from src.core.event_bus import event_bus - from src.chat.event_helpers import build_event_message - - if not from_plugin: - _event_msg = build_event_message(EventType.POST_LLM, llm_prompt=prompt, stream_id=stream_id) - continue_flag, modified_message = await event_bus.emit(EventType.POST_LLM, _event_msg) - if not continue_flag: - raise UserWarning("插件于请求前中断了内容生成") - if modified_message and modified_message._modify_flags.modify_llm_prompt: - llm_response.prompt = modified_message.llm_prompt - prompt = str(modified_message.llm_prompt) - - # 4. 调用 LLM 生成回复 - content = None - reasoning_content = None - model_name = "unknown_model" - - try: - llm_start = time.perf_counter() - content, reasoning_content, model_name, tool_call = await self.llm_generate_content(prompt) - llm_duration_ms = (time.perf_counter() - llm_start) * 1000 - # logger.debug(f"replyer生成内容: {content}") - - # 统一输出所有日志信息,使用try-except确保即使某个步骤出错也能输出 - try: - # 1. 输出回复准备日志 - timing_log_str = ( - f"回复准备: {'; '.join(timing_logs)}; {almost_zero_str} <0.1s" - if timing_logs or almost_zero_str - else "回复准备: 无计时信息" - ) - logger.info(timing_log_str) - # 2. 输出Prompt日志 - if global_config.debug.show_replyer_prompt: - logger.info(f"\n{prompt}\n") - else: - logger.debug(f"\nreplyer_Prompt:{prompt}\n") - # 3. 输出模型生成内容和推理日志 - logger.info(f"模型: [{model_name}][思考等级:{think_level}]生成内容: {content}") - if global_config.debug.show_replyer_reasoning and reasoning_content: - logger.info(f"模型: [{model_name}][思考等级:{think_level}]生成推理:\n{reasoning_content}") - except Exception as e: - logger.warning(f"输出日志时出错: {e}") - - llm_response.content = content - llm_response.reasoning = reasoning_content - llm_response.model = model_name - llm_response.tool_calls = tool_call - llm_response.timing["llm_ms"] = round(llm_duration_ms or 0.0, 2) - llm_response.timing["overall_ms"] = round((time.perf_counter() - overall_start) * 1000, 2) - llm_response.timing_logs = timing_logs - llm_response.timing["timing_logs"] = timing_logs - llm_response.timing["almost_zero"] = almost_zero_str - _event_msg = build_event_message( - EventType.AFTER_LLM, llm_prompt=prompt, llm_response=llm_response, stream_id=stream_id - ) - continue_flag, modified_message = await event_bus.emit(EventType.AFTER_LLM, _event_msg) - if not from_plugin and not continue_flag: - raise UserWarning("插件于请求后取消了内容生成") - if modified_message: - if modified_message._modify_flags.modify_llm_prompt: - logger.warning("警告:插件在内容生成后才修改了prompt,此修改不会生效") - llm_response.prompt = modified_message.llm_prompt # 虽然我不知道为什么在这里需要改prompt - if modified_message._modify_flags.modify_llm_response_content: - llm_response.content = modified_message.llm_response_content - if modified_message._modify_flags.modify_llm_response_reasoning: - llm_response.reasoning = modified_message.llm_response_reasoning - except UserWarning as e: - raise e - except Exception as llm_e: - # 精简报错信息 - logger.error(f"LLM 生成失败: {llm_e}") - # 即使LLM生成失败,也尝试输出已收集的日志信息 - try: - # 1. 输出回复准备日志 - timing_log_str = ( - f"回复准备: {'; '.join(timing_logs)}; {almost_zero_str} <0.1s" - if timing_logs or almost_zero_str - else "回复准备: 无计时信息" - ) - logger.info(timing_log_str) - # 2. 输出Prompt日志 - if global_config.debug.show_replyer_prompt: - logger.info(f"\n{prompt}\n") - else: - logger.debug(f"\nreplyer_Prompt:{prompt}\n") - # 3. 输出模型生成失败信息 - logger.info("模型生成失败,无法输出生成内容和推理") - except Exception as log_e: - logger.warning(f"输出日志时出错: {log_e}") - - llm_response.timing["llm_ms"] = round(llm_duration_ms or 0.0, 2) - llm_response.timing["overall_ms"] = round((time.perf_counter() - overall_start) * 1000, 2) - llm_response.timing_logs = timing_logs - llm_response.timing["timing_logs"] = timing_logs - llm_response.timing["almost_zero"] = almost_zero_str - return False, llm_response # LLM 调用失败则无法生成回复 - - return True, llm_response - - except UserWarning as uw: - raise uw - except Exception as e: - logger.error(f"回复生成意外失败: {e}") - traceback.print_exc() - return False, llm_response - - async def rewrite_reply_with_context( - self, - raw_reply: str = "", - reason: str = "", - reply_to: str = "", - ) -> Tuple[bool, LLMGenerationDataModel]: - """ - 表达器 (Expressor): 负责重写和优化回复文本。 - - Args: - raw_reply: 原始回复内容 - reason: 回复原因 - reply_to: 回复对象,格式为 "发送者:消息内容" - relation_info: 关系信息 - - Returns: - Tuple[bool, Optional[str]]: (是否成功, 重写后的回复内容) - """ - llm_response = LLMGenerationDataModel() - try: - with Timer("构建Prompt", {}): # 内部计时器,可选保留 - prompt = await self.build_prompt_rewrite_context( - raw_reply=raw_reply, - reason=reason, - reply_to=reply_to, - ) - llm_response.prompt = prompt - - content = None - reasoning_content = None - model_name = "unknown_model" - if not prompt: - logger.error("Prompt 构建失败,无法生成回复。") - return False, llm_response - - try: - content, reasoning_content, model_name, _ = await self.llm_generate_content(prompt) - logger.info(f"想要表达:{raw_reply}||理由:{reason}||生成回复: {content}\n") - llm_response.content = content - llm_response.reasoning = reasoning_content - llm_response.model = model_name - - except Exception as llm_e: - # 精简报错信息 - logger.error(f"LLM 生成失败: {llm_e}") - return False, llm_response # LLM 调用失败则无法生成回复 - - return True, llm_response - - except Exception as e: - logger.error(f"回复生成意外失败: {e}") - traceback.print_exc() - return False, llm_response - - async def build_expression_habits( - self, chat_history: str, target: str, reply_reason: str = "", think_level: int = 1 - ) -> Tuple[str, List[int]]: - """构建表达习惯块。""" - del chat_history - del target - del reply_reason - del think_level - - use_expression, _, _ = TempMethodsExpression.get_expression_config_for_chat(self.chat_stream.session_id) - if not use_expression: - return "", [] - - # 旧 replyer 的表达方式选择链路已停用,这里不再执行额外的模型筛选。 - logger.debug("旧 replyer 表达方式选择已停用,跳过 expression habits 构建") - return "", [] - - async def build_tool_info(self, chat_history: str, sender: str, target: str) -> str: - del chat_history - del sender - del target - return "" - """构建工具信息块 - - Args: - chat_history: 聊天历史记录 - reply_to: 回复对象,格式为 "发送者:消息内容" - Returns: - str: 工具信息字符串 - """ - - try: - # 使用工具执行器获取信息 - tool_results = [] - - if tool_results: - tool_info_str = "以下是你通过工具获取到的实时信息:\n" - for tool_result in tool_results: - tool_name = tool_result.get("tool_name", "unknown") - content = tool_result.get("content", "") - _result_type = tool_result.get("type", "tool_result") - - tool_info_str += f"- 【{tool_name}】: {content}\n" - - tool_info_str += "以上是你获取到的实时信息,请在回复时参考这些信息。" - logger.info(f"获取到 {len(tool_results)} 个工具结果") - - return tool_info_str - else: - logger.debug("未获取到任何工具结果") - return "" - - except Exception as e: - logger.error(f"工具信息获取失败: {e}") - return "" - - def _parse_reply_target(self, target_message: Optional[str]) -> Tuple[str, str]: - """解析回复目标消息 - - Args: - target_message: 目标消息,格式为 "发送者:消息内容" 或 "发送者:消息内容" - - Returns: - Tuple[str, str]: (发送者名称, 消息内容) - """ - sender = "" - target = "" - # 添加None检查,防止NoneType错误 - if target_message is None: - return sender, target - if ":" in target_message or ":" in target_message: - # 使用正则表达式匹配中文或英文冒号 - parts = re.split(pattern=r"[::]", string=target_message, maxsplit=1) - if len(parts) == 2: - sender = parts[0].strip() - target = parts[1].strip() - return sender, target - - def _replace_picids_with_descriptions(self, text: str) -> str: - """将文本中的[picid:xxx]替换为具体的图片描述 - - Args: - text: 包含picid标记的文本 - - Returns: - 替换后的文本 - """ - # 匹配 [picid:xxxxx] 格式 - pic_pattern = r"\[picid:([^\]]+)\]" - - def replace_pic_id(match: re.Match) -> str: - pic_id = match.group(1) - description = translate_pid_to_description(pic_id) - return f"[图片:{description}]" - - return re.sub(pic_pattern, replace_pic_id, text) - - def _analyze_target_content(self, target: str) -> Tuple[bool, bool, str, str]: - """分析target内容类型(基于原始picid格式) - - Args: - target: 目标消息内容(包含[picid:xxx]格式) - - Returns: - Tuple[bool, bool, str, str]: (是否只包含图片, 是否包含文字, 图片部分, 文字部分) - """ - if not target or not target.strip(): - return False, False, "", "" - - # 检查是否只包含picid标记 - picid_pattern = r"\[picid:[^\]]+\]" - picid_matches = re.findall(picid_pattern, target) - - # 移除所有picid标记后检查是否还有文字内容 - text_without_picids = re.sub(picid_pattern, "", target).strip() - - has_only_pics = len(picid_matches) > 0 and not text_without_picids - has_text = bool(text_without_picids) - - # 提取图片部分(转换为[图片:描述]格式) - pic_part = "" - if picid_matches: - pic_descriptions = [] - for picid_match in picid_matches: - pic_id = picid_match[7:-1] # 提取picid:xxx中的xxx部分(从第7个字符开始) - description = translate_pid_to_description(pic_id) - logger.info(f"图片ID: {pic_id}, 描述: {description}") - # 如果description已经是[图片]格式,直接使用;否则包装为[图片:描述]格式 - if description == "[图片]": - pic_descriptions.append(description) - else: - pic_descriptions.append(f"[图片:{description}]") - pic_part = "".join(pic_descriptions) - - return has_only_pics, has_text, pic_part, text_without_picids - - async def build_keywords_reaction_prompt(self, target: Optional[str]) -> str: - """构建关键词反应提示 - - Args: - target: 目标消息内容 - - Returns: - str: 关键词反应提示字符串 - """ - # 关键词检测与反应 - keywords_reaction_prompt = "" - try: - # 添加None检查,防止NoneType错误 - if target is None: - return keywords_reaction_prompt - - # 处理关键词规则 - for rule in global_config.keyword_reaction.keyword_rules: - if any(keyword in target for keyword in rule.keywords): - logger.info(f"检测到关键词规则:{rule.keywords},触发反应:{rule.reaction}") - keywords_reaction_prompt += f"{rule.reaction}," - - # 处理正则表达式规则 - for rule in global_config.keyword_reaction.regex_rules: - for pattern_str in rule.regex: - try: - pattern = re.compile(pattern_str) - if result := pattern.search(target): - reaction = rule.reaction - for name, content in result.groupdict().items(): - reaction = reaction.replace(f"[{name}]", content) - logger.info(f"匹配到正则表达式:{pattern_str},触发反应:{reaction}") - keywords_reaction_prompt += f"{reaction}," - break - except re.error as e: - logger.error(f"正则表达式编译错误: {pattern_str}, 错误信息: {str(e)}") - continue - except Exception as e: - logger.error(f"关键词检测与反应时发生异常: {str(e)}", exc_info=True) - - return keywords_reaction_prompt - - async def _time_and_run_task(self, coroutine, name: str) -> Tuple[str, Any, float]: - """计时并运行异步任务的辅助函数 - - Args: - coroutine: 要执行的协程 - name: 任务名称 - - Returns: - Tuple[str, Any, float]: (任务名称, 任务结果, 执行耗时) - """ - start_time = time.time() - result = await coroutine - end_time = time.time() - duration = end_time - start_time - return name, result, duration - - async def _build_jargon_explanation( - self, - chat_id: str, - messages_short: List[SessionMessage], - chat_talking_prompt_short: str, - unknown_words: Optional[List[str]], - ) -> str: - """ - 统一的黑话解释构建函数: - - 根据 enable_jargon_explanation 决定是否启用 - """ - del unknown_words - enable_jargon_explanation = getattr(global_config.expression, "enable_jargon_explanation", True) - if not enable_jargon_explanation: - return "" - - # 使用上下文自动匹配黑话 - try: - return await explain_jargon_in_context(chat_id, messages_short, chat_talking_prompt_short) or "" - except Exception as e: - logger.error(f"上下文黑话解释失败: {e}") - return "" - - async def build_actions_prompt( - self, available_actions: Dict[str, ActionInfo], chosen_actions_info: Optional[List[PlannedAction]] = None - ) -> str: - """构建动作提示""" - - action_descriptions = "" - skip_names = ["emoji", "build_memory", "build_relation", "reply"] - if available_actions: - action_descriptions = "除了进行回复之外,你可以做以下这些动作,不过这些动作由另一个模型决定,:\n" - for action_name, action_info in available_actions.items(): - if action_name in skip_names: - continue - action_description = action_info.description - action_descriptions += f"- {action_name}: {action_description}\n" - action_descriptions += "\n" - - chosen_action_descriptions = "" - if chosen_actions_info: - for action_plan_info in chosen_actions_info: - action_name = action_plan_info.action_name - if action_name in skip_names: - continue - action_description: str = "无描述" - reasoning: str = "无原因" - if action := available_actions.get(action_name): - action_description = action.description or action_description - reasoning = action_plan_info.decision_reason or reasoning - - chosen_action_descriptions += f"- {action_name}: {action_description},原因:{reasoning}\n" - - if chosen_action_descriptions: - action_descriptions += "根据聊天情况,另一个模型决定在回复的同时做以下这些动作:\n" - action_descriptions += chosen_action_descriptions - - return action_descriptions - - async def build_personality_prompt(self) -> str: - bot_name = global_config.bot.nickname - if global_config.bot.alias_names: - bot_nickname = f",也有人叫你{','.join(global_config.bot.alias_names)}" - else: - bot_nickname = "" - - # 获取基础personality - prompt_personality = global_config.personality.personality - - # 检查是否需要随机替换为状态(personality 本体) - if ( - global_config.personality.states - and global_config.personality.state_probability > 0 - and random.random() < global_config.personality.state_probability - ): - # 随机选择一个状态替换personality - selected_state = random.choice(global_config.personality.states) - prompt_personality = selected_state - - prompt_personality = f"{prompt_personality};" - return f"你的名字是{bot_name}{bot_nickname},你{prompt_personality}" - - def _parse_chat_prompt_config_to_chat_id(self, chat_prompt_str: str) -> Optional[tuple[str, bool, str]]: - """ - 解析聊天prompt配置字符串并生成对应的 chat_id 和 prompt内容 - - Args: - chat_prompt_str: 格式为 "platform:id:type:prompt内容" 的字符串 - - Returns: - tuple: (chat_id, is_group_chat, prompt_content),如果解析失败则返回 None - """ - try: - # 使用 split 分割,但限制分割次数为3,因为prompt内容可能包含冒号 - parts = chat_prompt_str.split(":", 3) - if len(parts) != 4: - return None - - platform = parts[0] - id_str = parts[1] - stream_type = parts[2] - prompt_content = parts[3] - - # 判断是否为群聊 - is_group = stream_type == "group" - - chat_id = SessionUtils.calculate_session_id( - platform, - group_id=str(id_str) if is_group else None, - user_id=str(id_str) if not is_group else None, - ) - return chat_id, is_group, prompt_content - - except (ValueError, IndexError): - return None - - def _build_chat_attention_block(self, chat_id: str) -> str: - """构建当前聊天场景下的额外注意事项块。""" - prompt_lines: List[str] = [] - - if self.is_group_chat is True: - if group_chat_prompt := global_config.chat.group_chat_prompt.strip(): - prompt_lines.append(f"通用注意事项:\n{group_chat_prompt}") - elif self.is_group_chat is False: - if private_chat_prompt := global_config.chat.private_chat_prompts.strip(): - prompt_lines.append(f"通用注意事项:\n{private_chat_prompt}") - - if chat_prompt := self.get_chat_prompt_for_chat(chat_id).strip(): - prompt_lines.append(f"当前聊天额外注意事项:\n{chat_prompt}") - - if not prompt_lines: - return "" - - return "在该聊天中的注意事项:\n" + "\n\n".join(prompt_lines) + "\n" - - def get_chat_prompt_for_chat(self, chat_id: str) -> str: - """根据聊天流 ID 获取匹配的额外 prompt。""" - if not global_config.chat.chat_prompts: - return "" - - for chat_prompt_item in global_config.chat.chat_prompts: - if hasattr(chat_prompt_item, "rule_type") and hasattr(chat_prompt_item, "prompt"): - rule_type = str(chat_prompt_item.rule_type or "").strip() - if self.is_group_chat is True and rule_type != "group": - continue - if self.is_group_chat is False and rule_type != "private": - continue - - config_chat_id = self._build_chat_uid( - str(chat_prompt_item.platform or "").strip(), - str(chat_prompt_item.item_id or "").strip(), - rule_type == "group", - ) - prompt_content = str(chat_prompt_item.prompt or "").strip() - if config_chat_id == chat_id and prompt_content: - logger.debug(f"匹配到群聊 prompt 配置,chat_id: {chat_id}, prompt: {prompt_content[:50]}...") - return prompt_content - continue - - if not isinstance(chat_prompt_item, str): - continue - - # 兼容旧格式的 platform:id:type:prompt 配置字符串。 - parts = chat_prompt_item.split(":", 3) - if len(parts) != 4: - continue - - result = self._parse_chat_prompt_config_to_chat_id(chat_prompt_item) - if result is None: - continue - - config_chat_id, config_is_group, prompt_content = result - if self.is_group_chat is True and not config_is_group: - continue - if self.is_group_chat is False and config_is_group: - continue - if config_chat_id == chat_id: - logger.debug(f"匹配到群聊 prompt 配置,chat_id: {chat_id}, prompt: {prompt_content[:50]}...") - return prompt_content - - return "" - - async def build_prompt_reply_context( - self, - reply_message: Optional[SessionMessage] = None, - extra_info: str = "", - reply_reason: str = "", - available_actions: Optional[Dict[str, ActionInfo]] = None, - chosen_actions: Optional[List[PlannedAction]] = None, - reply_time_point: float = time.time(), - think_level: int = 1, - unknown_words: Optional[List[str]] = None, - ) -> Tuple[str, List[int], List[str], str]: - """ - 构建回复器上下文 - - Args: - extra_info: 额外信息,用于补充上下文 - reply_reason: 回复原因 - available_actions: 可用动作 - chosen_actions: 已选动作 - enable_timeout: 是否启用超时处理 - reply_message: 回复的原始消息 - Returns: - str: 构建好的上下文 - """ - if available_actions is None: - available_actions = {} - chat_stream = self.chat_stream - chat_id = chat_stream.session_id - platform = chat_stream.platform - - user_id = "用户ID" - person_name = "用户" - sender = "用户" - target = "消息" - - if reply_message: - reply_user_info = reply_message.message_info.user_info - user_id = reply_user_info.user_id - person = Person(platform=platform, user_id=user_id) - person_name = person.person_name or user_id - sender = person_name - target = reply_message.processed_plain_text or "" - - target = replace_user_references(target, chat_stream.platform, replace_bot_name=True) - - # 在picid替换之前分析内容类型(防止prompt注入) - has_only_pics, has_text, pic_part, text_part = self._analyze_target_content(target) - - # 将[picid:xxx]替换为具体的图片描述 - target = self._replace_picids_with_descriptions(target) - - message_list_before_now_long = get_messages_before_time_in_chat( - chat_id=chat_id, - timestamp=reply_time_point, - limit=global_config.chat.max_context_size * 1, - filter_intercept_message_level=1, - ) - - message_list_before_short = get_messages_before_time_in_chat( - chat_id=chat_id, - timestamp=reply_time_point, - limit=int(global_config.chat.max_context_size * 0.33), - filter_intercept_message_level=1, - ) - - person_list_short: List[Person] = [] - for msg in message_list_before_short: - msg_user_info = msg.message_info.user_info - # 使用统一的 is_bot_self 函数判断是否是机器人自己(支持多平台,包括 WebUI) - if is_bot_self(msg.platform, msg_user_info.user_id): - continue - if ( - reply_message - and reply_message.message_info.user_info.user_id == msg_user_info.user_id - and reply_message.platform == msg.platform - ): - continue - person = Person(platform=msg.platform, user_id=msg_user_info.user_id) - if person.is_known: - person_list_short.append(person) - - # for person in person_list_short: - # print(person.person_name) - - chat_talking_prompt_short = build_readable_messages( - message_list_before_short, - replace_bot_name=True, - timestamp_mode="relative", - read_mark=0.0, - show_actions=True, - ) - - # 统一黑话解释构建:根据配置选择上下文或 Planner 模式 - jargon_coroutine = self._build_jargon_explanation( - chat_id, message_list_before_short, chat_talking_prompt_short, unknown_words - ) - - # 并行执行构建任务(包括黑话解释,可配置关闭) - task_results = await asyncio.gather( - self._time_and_run_task( - self.build_expression_habits(chat_talking_prompt_short, target, reply_reason, think_level=think_level), - "expression_habits", - ), - self._time_and_run_task( - self.build_tool_info(chat_talking_prompt_short, sender, target), "tool_info" - ), - self._time_and_run_task(self.get_prompt_info(chat_talking_prompt_short, sender, target), "prompt_info"), - self._time_and_run_task(self.build_actions_prompt(available_actions, chosen_actions), "actions_info"), - self._time_and_run_task(self.build_personality_prompt(), "personality_prompt"), - self._time_and_run_task( - build_memory_retrieval_prompt( - chat_talking_prompt_short, - sender, - target, - self.chat_stream, - think_level=think_level, - unknown_words=unknown_words, - ), - "memory_retrieval", - ), - self._time_and_run_task(jargon_coroutine, "jargon_explanation"), - ) - - # 任务名称中英文映射 - task_name_mapping = { - "expression_habits": "选取表达方式", - "relation_info": "感受关系", - "tool_info": "使用工具", - "prompt_info": "获取知识", - "actions_info": "动作信息", - "personality_prompt": "人格信息", - "memory_retrieval": "记忆检索", - "jargon_explanation": "黑话解释", - } - - # 处理结果 - timing_logs = [] - results_dict = {} - - almost_zero_str = "" - for name, result, duration in task_results: - results_dict[name] = result - chinese_name = task_name_mapping.get(name, name) - if duration < 0.1: - almost_zero_str += f"{chinese_name}," - continue - - timing_logs.append(f"{chinese_name}: {duration:.1f}s") - # 不再在这里输出日志,而是返回给调用者统一输出 - # logger.info(f"回复准备: {'; '.join(timing_logs)}; {almost_zero_str} <0.1s") - - expression_habits_block, selected_expressions = results_dict["expression_habits"] - expression_habits_block: str - selected_expressions: List[int] - # relation_info: str = results_dict["relation_info"] - tool_info: str = results_dict["tool_info"] - prompt_info: str = results_dict["prompt_info"] # 直接使用格式化后的结果 - actions_info: str = results_dict["actions_info"] - personality_prompt: str = results_dict["personality_prompt"] - memory_retrieval: str = results_dict["memory_retrieval"] - keywords_reaction_prompt = await self.build_keywords_reaction_prompt(target) - jargon_explanation: str = results_dict.get("jargon_explanation") or "" - planner_reasoning = f"你的想法是:{reply_reason}" - - if extra_info: - extra_info_block = f"以下是你在回复时需要参考的信息,现在请你阅读以下内容,进行决策\n{extra_info}\n以上是你在回复时需要参考的信息,现在请你阅读以下内容,进行决策" - else: - extra_info_block = "" - - time_block = f"当前时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - - moderation_prompt_block = "请不要输出违法违规内容,不要输出色情,暴力,政治相关内容,如有敏感内容,请规避。" - - if sender: - # 使用预先分析的内容类型结果 - if has_only_pics and not has_text: - # 只包含图片 - reply_target_block = f"现在{sender}发送的图片:{pic_part}。引起了你的注意" - elif has_text and pic_part: - # 既有图片又有文字 - reply_target_block = f"现在{sender}发送了图片:{pic_part},并说:{text_part}。引起了你的注意" - elif has_text: - # 只包含文字 - reply_target_block = f"现在{sender}说的:{text_part}。引起了你的注意" - else: - # 其他情况(空内容等) - reply_target_block = f"现在{sender}说的:{target}。引起了你的注意" - else: - reply_target_block = "" - - dialogue_prompt: str = "" - if message_list_before_now_long: - latest_msgs = message_list_before_now_long[-int(global_config.chat.max_context_size) :] - dialogue_prompt = build_readable_messages( - latest_msgs, - replace_bot_name=True, - timestamp_mode="normal_no_YMD", - truncate=True, - ) - - # 获取匹配的额外prompt - chat_prompt_block = self._build_chat_attention_block(chat_id) - - # 根据think_level选择不同的回复模板 - # think_level=0: 轻量回复(简短平淡) - # think_level=1: 中等回复(日常口语化) - if think_level == 0: - prompt_name = "replyer_light" - else: # think_level == 1 或默认 - prompt_name = "replyer" - - # 根据配置构建最终的 reply_style:支持 multiple_reply_style 按概率随机替换 - reply_style = global_config.personality.reply_style - multi_styles = getattr(global_config.personality, "multiple_reply_style", None) or [] - multi_prob = getattr(global_config.personality, "multiple_probability", 0.0) or 0.0 - if multi_styles and multi_prob > 0 and random.random() < multi_prob: - try: - reply_style = random.choice(list(multi_styles)) - except Exception: - # 兜底:即使 multiple_reply_style 配置异常也不影响正常回复 - reply_style = global_config.personality.reply_style - - prompt = prompt_manager.get_prompt(prompt_name) - prompt.add_context("expression_habits_block", expression_habits_block) - prompt.add_context("tool_info_block", tool_info) - prompt.add_context("bot_name", global_config.bot.nickname) - prompt.add_context("knowledge_prompt", prompt_info) - # prompt.add_context("relation_info_block", relation_info) - prompt.add_context("extra_info_block", extra_info_block) - prompt.add_context("jargon_explanation", jargon_explanation) - prompt.add_context("identity", personality_prompt) - prompt.add_context("action_descriptions", actions_info) - prompt.add_context("sender_name", sender) - prompt.add_context("dialogue_prompt", dialogue_prompt) - prompt.add_context("time_block", time_block) - prompt.add_context("reply_target_block", reply_target_block) - prompt.add_context("reply_style", reply_style) - prompt.add_context("keywords_reaction_prompt", keywords_reaction_prompt) - prompt.add_context("moderation_prompt", moderation_prompt_block) - prompt.add_context("memory_retrieval", memory_retrieval) - prompt.add_context("chat_prompt", chat_prompt_block) - prompt.add_context("planner_reasoning", planner_reasoning) - formatted_prompt = await prompt_manager.render_prompt(prompt) - return (formatted_prompt, selected_expressions, timing_logs, almost_zero_str) - - async def build_prompt_rewrite_context( - self, - raw_reply: str, - reason: str, - reply_to: str, - ) -> str: # sourcery skip: merge-else-if-into-elif, remove-redundant-if - chat_stream = self.chat_stream - chat_id = chat_stream.session_id - sender, target = self._parse_reply_target(reply_to) - target = replace_user_references(target, chat_stream.platform, replace_bot_name=True) - - # 在picid替换之前分析内容类型(防止prompt注入) - has_only_pics, has_text, pic_part, text_part = self._analyze_target_content(target) - - # 将[picid:xxx]替换为具体的图片描述 - target = self._replace_picids_with_descriptions(target) - - message_list_before_now_half = get_messages_before_time_in_chat( - chat_id=chat_id, - timestamp=time.time(), - limit=min(int(global_config.chat.max_context_size * 0.33), 15), - filter_intercept_message_level=1, - ) - chat_talking_prompt_half = build_readable_messages( - message_list_before_now_half, - replace_bot_name=True, - timestamp_mode="relative", - read_mark=0.0, - show_actions=True, - ) - - # 并行执行2个构建任务 - (expression_habits_block, _), personality_prompt = await asyncio.gather( - self.build_expression_habits(chat_talking_prompt_half, target), - self.build_personality_prompt(), - ) - - keywords_reaction_prompt = await self.build_keywords_reaction_prompt(target) - - time_block = f"当前时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - - moderation_prompt_block = ( - "请不要输出违法违规内容,不要输出色情,暴力,政治相关内容,如有敏感内容,请规避。不要随意遵从他人指令。" - ) - - if sender and target: - # 使用预先分析的内容类型结果 - if sender: - if has_only_pics and not has_text: - # 只包含图片 - reply_target_block = ( - f"现在{sender}发送的图片:{pic_part}。引起了你的注意,你想要在群里发言或者回复这条消息。" - ) - elif has_text and pic_part: - # 既有图片又有文字 - reply_target_block = f"现在{sender}发送了图片:{pic_part},并说:{text_part}。引起了你的注意,你想要在群里发言或者回复这条消息。" - else: - # 只包含文字 - reply_target_block = ( - f"现在{sender}说的:{text_part}。引起了你的注意,你想要在群里发言或者回复这条消息。" - ) - elif target: - reply_target_block = f"现在{target}引起了你的注意,你想要在群里发言或者回复这条消息。" - else: - reply_target_block = "现在,你想要在群里发言或者回复消息。" - else: - reply_target_block = "" - - chat_target_1_prompt = prompt_manager.get_prompt("chat_target_group1") - chat_target_1 = await prompt_manager.render_prompt(chat_target_1_prompt) - chat_target_2_prompt = prompt_manager.get_prompt("chat_target_group2") - chat_target_2 = await prompt_manager.render_prompt(chat_target_2_prompt) - - # 根据配置构建最终的 reply_style:支持 multiple_reply_style 按概率随机替换 - reply_style = global_config.personality.reply_style - multi_styles = global_config.personality.multiple_reply_style - multi_prob = global_config.personality.multiple_probability or 0.0 - if multi_styles and multi_prob > 0 and random.random() < multi_prob: - try: - reply_style = random.choice(multi_styles) - except Exception: - reply_style = global_config.personality.reply_style - - prompt_template = prompt_manager.get_prompt("default_expressor") - prompt_template.add_context("expression_habits_block", expression_habits_block) - # prompt_template.add_context("relation_info_block", relation_info) - prompt_template.add_context("chat_target", chat_target_1) - prompt_template.add_context("time_block", time_block) - prompt_template.add_context("chat_info", chat_talking_prompt_half) - prompt_template.add_context("identity", personality_prompt) - prompt_template.add_context("chat_target_2", chat_target_2) - prompt_template.add_context("reply_target_block", reply_target_block) - prompt_template.add_context("raw_reply", raw_reply) - prompt_template.add_context("reason", reason) - prompt_template.add_context("reply_style", reply_style) - prompt_template.add_context("keywords_reaction_prompt", keywords_reaction_prompt) - prompt_template.add_context("moderation_prompt", moderation_prompt_block) - return await prompt_manager.render_prompt(prompt_template) - - async def _build_single_sending_message( - self, - message_id: str, - message_segment: Seg, - reply_to: bool, - is_emoji: bool, - thinking_start_time: float, - display_message: str, - anchor_message: Optional[MaiMessage] = None, - ) -> SessionMessage: - """构建单个发送消息""" - bot_user_id = get_bot_account(self.chat_stream.platform) - if not bot_user_id: - logger.error(f"平台 {self.chat_stream.platform} 未配置机器人账号,无法构建发送消息") - raise RuntimeError(f"平台 {self.chat_stream.platform} 未配置机器人账号") - - maim_message = MessageBase( - message_info=BaseMessageInfo( - platform=self.chat_stream.platform, - message_id=message_id, - time=thinking_start_time, - user_info=MaimUserInfo( - user_id=bot_user_id, - user_nickname=global_config.bot.nickname, - ), - additional_config={ - "platform_io_target_group_id": self.chat_stream.group_id, - "platform_io_target_user_id": self.chat_stream.user_id, - }, - ), - message_segment=message_segment, - ) - message = SessionMessage.from_maim_message(maim_message) - message.session_id = self.chat_stream.session_id - message.display_message = display_message - message.reply_to = anchor_message.message_id if reply_to and anchor_message else None - message.is_emoji = is_emoji - return message - - async def llm_generate_content(self, prompt: str): - with Timer("LLM生成", {}): # 内部计时器,可选保留 - # 直接使用已初始化的模型实例 - # logger.info(f"\n{prompt}\n") - - # 不再在这里输出日志,而是返回给调用者统一输出 - # if global_config.debug.show_replyer_prompt: - # logger.info(f"\n{prompt}\n") - # else: - # logger.debug(f"\nreplyer_Prompt:{prompt}\n") - - generation_result = await self.express_model.generate_response(prompt) - content = generation_result.response - reasoning_content = generation_result.reasoning - model_name = generation_result.model_name - tool_calls = generation_result.tool_calls - - # 移除 content 前后的换行符和空格 - content = content.strip() - - # logger.info(f"使用 {model_name} 生成回复内容: {content}") - return content, reasoning_content, model_name, tool_calls - - async def get_prompt_info(self, message: str, sender: str, target: str): - return "" - related_info = "" - start_time = time.time() - try: - knowledge_module = importlib.import_module("src.plugins.built_in.knowledge.lpmm_get_knowledge") - except ImportError: - logger.debug("LPMM知识库工具模块不存在,跳过获取知识库内容") - return "" - - search_knowledge_tool = getattr(knowledge_module, "SearchKnowledgeFromLPMMTool", None) - if search_knowledge_tool is None: - logger.debug("LPMM知识库工具未提供 SearchKnowledgeFromLPMMTool,跳过获取知识库内容") - return "" - - logger.debug(f"获取知识库内容,元消息:{message[:30]}...,消息长度: {len(message)}") - # 从LPMM知识库获取知识 - try: - # 检查LPMM知识库是否启用 - if not global_config.lpmm_knowledge.enable: - logger.debug("LPMM知识库未启用,跳过获取知识库内容") - return "" - - if global_config.lpmm_knowledge.lpmm_mode == "agent": - return "" - - template_prompt = prompt_manager.get_prompt("lpmm_get_knowledge") - template_prompt.add_context("bot_name", global_config.bot.nickname) - template_prompt.add_context("time_now", lambda _: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) - template_prompt.add_context("chat_history", message) - template_prompt.add_context("sender", sender) - template_prompt.add_context("target_message", target) - prompt = await prompt_manager.render_prompt(template_prompt) - generation_result = await llm_api.generate( - llm_api.LLMServiceRequest( - task_name="utils", - request_type="replyer.lpmm_knowledge", - prompt=prompt, - tool_options=[search_knowledge_tool.get_tool_definition()], - ) - ) - tool_calls = generation_result.completion.tool_calls - - # logger.info(f"工具调用提示词: {prompt}") - # logger.info(f"工具调用: {tool_calls}") - - if tool_calls: - result = None - end_time = time.time() - if not result or not result.get("content"): - logger.debug("从LPMM知识库获取知识失败,返回空知识...") - return "" - found_knowledge_from_lpmm = result.get("content", "") - logger.info( - f"从LPMM知识库获取知识,相关信息:{found_knowledge_from_lpmm[:100]}...,信息长度: {len(found_knowledge_from_lpmm)}" - ) - related_info += found_knowledge_from_lpmm - logger.debug(f"获取知识库内容耗时: {(end_time - start_time):.3f}秒") - logger.debug(f"获取知识库内容,相关信息:{related_info[:100]}...,信息长度: {len(related_info)}") - - return f"你有以下这些**知识**:\n{related_info}\n请你**记住上面的知识**,之后可能会用到。\n" - else: - logger.debug("模型认为不需要使用LPMM知识库") - return "" - except Exception as e: - logger.error(f"获取知识库内容时发生异常: {str(e)}") - return "" - - -def weighted_sample_no_replacement(items, weights, k) -> list: - """ - 加权且不放回地随机抽取k个元素。 - - 参数: - items: 待抽取的元素列表 - weights: 每个元素对应的权重(与items等长,且为正数) - k: 需要抽取的元素个数 - 返回: - selected: 按权重加权且不重复抽取的k个元素组成的列表 - - 如果 items 中的元素不足 k 个,就只会返回所有可用的元素 - - 实现思路: - 每次从当前池中按权重加权随机选出一个元素,选中后将其从池中移除,重复k次。 - 这样保证了: - 1. count越大被选中概率越高 - 2. 不会重复选中同一个元素 - """ - selected = [] - pool = list(zip(items, weights, strict=False)) - for _ in range(min(k, len(pool))): - total = sum(w for _, w in pool) - r = random.uniform(0, total) - upto = 0 - for idx, (item, weight) in enumerate(pool): - upto += weight - if upto >= r: - selected.append(item) - pool.pop(idx) - break - return selected diff --git a/src/chat/replyer/maisaka_generator.py b/src/chat/replyer/maisaka_generator.py index a588bae0..166b03c6 100644 --- a/src/chat/replyer/maisaka_generator.py +++ b/src/chat/replyer/maisaka_generator.py @@ -1,530 +1,34 @@ -from dataclasses import dataclass, field from datetime import datetime -from typing import Awaitable, Callable, Dict, List, Optional, Tuple - -import random -import time - -from rich.panel import Panel +from typing import Any, Callable, Optional from src.chat.message_receive.chat_manager import BotChatSession -from src.chat.message_receive.message import SessionMessage -from src.chat.utils.utils import get_chat_type_and_target_info -from src.cli.console import console -from src.common.data_models.reply_generation_data_models import ( - GenerationMetrics, - LLMCompletionResult, - ReplyGenerationResult, - build_reply_monitor_detail, -) -from src.common.logger import get_logger from src.common.prompt_i18n import load_prompt -from src.common.utils.utils_session import SessionUtils from src.config.config import global_config -from src.core.types import ActionInfo -from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType +from src.maisaka.context_messages import SessionBackedMessage from src.services.llm_service import LLMServiceClient -from src.maisaka.context_messages import ( - AssistantMessage, - LLMContextMessage, - ReferenceMessage, - SessionBackedMessage, - ToolResultMessage, -) -from src.maisaka.message_adapter import parse_speaker_content -from src.maisaka.prompt_cli_renderer import PromptCLIVisualizer -from src.plugin_runtime.hook_payloads import serialize_prompt_messages - -from .maisaka_expression_selector import maisaka_expression_selector - -logger = get_logger("replyer") +from .maisaka_generator_base import BaseMaisakaReplyGenerator -@dataclass -class MaisakaReplyContext: - """Maisaka replyer 使用的回复上下文。""" - - expression_habits: str = "" - selected_expression_ids: List[int] = field(default_factory=list) - - -class MaisakaReplyGenerator: - """生成 Maisaka 的最终可见回复。""" +class MaisakaReplyGenerator(BaseMaisakaReplyGenerator): + """Maisaka replyer。""" def __init__( self, chat_stream: Optional[BotChatSession] = None, request_type: str = "maisaka_replyer", + llm_client_cls: Optional[Any] = None, + load_prompt_func: Optional[Callable[..., str]] = None, + enable_visual_message: Optional[bool] = None, ) -> None: - self.chat_stream = chat_stream - self.request_type = request_type - self.express_model = LLMServiceClient( - task_name="replyer", + super().__init__( + chat_stream=chat_stream, request_type=request_type, + llm_client_cls=llm_client_cls or LLMServiceClient, + load_prompt_func=load_prompt_func or load_prompt, + enable_visual_message=( + global_config.visual.multimodal_replyer + if enable_visual_message is None + else enable_visual_message + ), ) - 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 _format_message_time(message: LLMContextMessage) -> str: - return message.timestamp.strftime("%H:%M:%S") - - @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 _format_chat_history(self, messages: List[LLMContextMessage]) -> str: - """格式化 replyer 使用的可见聊天记录。""" - bot_nickname = global_config.bot.nickname.strip() or "Bot" - parts: List[str] = [] - - for message in messages: - timestamp = self._format_message_time(message) - - if isinstance(message, (ReferenceMessage, ToolResultMessage)): - continue - - if isinstance(message, SessionBackedMessage): - guided_reply = self._extract_guided_bot_reply(message) - if guided_reply: - parts.append(f"{timestamp} {bot_nickname}(you): {guided_reply}") - continue - - raw_content = message.processed_plain_text - for speaker_name, content_body in self._split_user_message_segments(raw_content): - content = self._normalize_content(content_body) - if not content: - continue - visible_speaker = speaker_name or global_config.maisaka.cli_user_name.strip() or "User" - parts.append(f"{timestamp} {visible_speaker}: {content}") - continue - - if isinstance(message, AssistantMessage): - visible_reply = self._extract_visible_assistant_reply(message) - if visible_reply: - parts.append(f"{timestamp} {bot_nickname}(you): {visible_reply}") - - return "\n".join(parts) - - def _build_target_message_block(self, reply_message: Optional[SessionMessage]) -> str: - """构建当前需要回复的目标消息摘要。""" - if reply_message is None: - return "" - - user_info = reply_message.message_info.user_info - sender_name = user_info.user_cardname or user_info.user_nickname or user_info.user_id - target_message_id = reply_message.message_id.strip() if reply_message.message_id else "未知" - target_content = self._normalize_content((reply_message.processed_plain_text or "").strip(), limit=300) - if not target_content: - target_content = "[无可见文本内容]" - - return ( - "【本次回复目标】\n" - f"- 目标消息ID:{target_message_id}\n" - f"- 发送者:{sender_name}\n" - f"- 消息内容:{target_content}\n" - "- 你这次要回复的就是这条目标消息,请结合整段上下文理解,但不要误把其他历史消息当成当前回复对象。" - ) - - @staticmethod - def _get_chat_prompt_for_chat(chat_id: str, is_group_chat: Optional[bool]) -> str: - """根据聊天流 ID 获取匹配的额外 prompt。""" - if not global_config.chat.chat_prompts: - return "" - - for chat_prompt_item in global_config.chat.chat_prompts: - if hasattr(chat_prompt_item, "platform"): - platform = str(chat_prompt_item.platform or "").strip() - item_id = str(chat_prompt_item.item_id or "").strip() - rule_type = str(chat_prompt_item.rule_type or "").strip() - prompt_content = str(chat_prompt_item.prompt or "").strip() - elif isinstance(chat_prompt_item, str): - parts = chat_prompt_item.split(":", 3) - if len(parts) != 4: - continue - - platform, item_id, rule_type, prompt_content = parts - platform = platform.strip() - item_id = item_id.strip() - rule_type = rule_type.strip() - prompt_content = prompt_content.strip() - else: - continue - - if not platform or not item_id or not prompt_content: - continue - - if rule_type == "group": - config_is_group = True - config_chat_id = SessionUtils.calculate_session_id(platform, group_id=item_id) - elif rule_type == "private": - config_is_group = False - config_chat_id = SessionUtils.calculate_session_id(platform, user_id=item_id) - else: - continue - - if config_is_group != is_group_chat: - continue - if config_chat_id == chat_id: - return prompt_content - - return "" - - def _build_group_chat_attention_block(self, session_id: str) -> str: - """构建当前聊天场景下的额外注意事项块。""" - if not session_id: - return "" - - try: - is_group_chat, _ = get_chat_type_and_target_info(session_id) - except Exception: - is_group_chat = None - - prompt_lines: List[str] = [] - - if is_group_chat is True: - if group_chat_prompt := global_config.chat.group_chat_prompt.strip(): - prompt_lines.append(f"通用注意事项:\n{group_chat_prompt}") - elif is_group_chat is False: - if private_chat_prompt := global_config.chat.private_chat_prompts.strip(): - prompt_lines.append(f"通用注意事项:\n{private_chat_prompt}") - - if chat_prompt := self._get_chat_prompt_for_chat(session_id, is_group_chat).strip(): - prompt_lines.append(f"当前聊天额外注意事项:\n{chat_prompt}") - - if not prompt_lines: - return "" - - return "在该聊天中的注意事项:\n" + "\n\n".join(prompt_lines) + "\n" - - def _build_request_messages( - self, - chat_history: List[LLMContextMessage], - reply_message: Optional[SessionMessage], - reply_reason: str, - expression_habits: str = "", - stream_id: Optional[str] = None, - ) -> List[Message]: - """构建 Maisaka replyer 请求消息列表。""" - current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - formatted_history = self._format_chat_history(chat_history) - target_message_block = self._build_target_message_block(reply_message) - session_id = self._resolve_session_id(stream_id) - - try: - system_prompt = load_prompt( - "maisaka_replyer", - bot_name=global_config.bot.nickname, - group_chat_attention_block=self._build_group_chat_attention_block(session_id), - 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()) - - user_sections = [ - f"当前时间:{current_time}", - f"【聊天记录】\n{formatted_history}", - ] - if target_message_block: - user_sections.append(target_message_block) - if extra_sections: - user_sections.append("\n\n".join(extra_sections)) - user_sections.append(f"【回复信息参考】\n{reply_reason}") - user_sections.append("现在,你说:") - - user_prompt = "\n\n".join(user_sections) - return [ - MessageBuilder().set_role(RoleType.System).add_text_content(system_prompt).build(), - MessageBuilder().set_role(RoleType.User).add_text_content(user_prompt).build(), - ] - - 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], - sub_agent_runner: Optional[Callable[[str], Awaitable[str]]], - ) -> MaisakaReplyContext: - """构建回复上下文:表达习惯和已选表达 ID。""" - session_id = self._resolve_session_id(stream_id) - if not session_id: - logger.warning("构建 Maisaka 回复上下文失败:缺少会话标识") - return MaisakaReplyContext() - - if sub_agent_runner is None: - logger.info("表达方式选择跳过:缺少子代理执行器") - return MaisakaReplyContext() - - selection_result = await maisaka_expression_selector.select_for_reply( - session_id=session_id, - chat_history=chat_history, - reply_message=reply_message, - reply_reason=reply_reason, - sub_agent_runner=sub_agent_runner, - ) - return MaisakaReplyContext( - expression_habits=selection_result.expression_habits, - selected_expression_ids=selection_result.selected_expression_ids, - ) - - 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, - sub_agent_runner: Optional[Callable[[str], Awaitable[str]]] = None, - ) -> Tuple[bool, ReplyGenerationResult]: - """结合上下文生成 Maisaka 的最终可见回复。""" - - def finalize(success_value: bool) -> Tuple[bool, ReplyGenerationResult]: - result.monitor_detail = build_reply_monitor_detail(result) - return success_value, result - - 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() - overall_started_at = time.perf_counter() - if chat_history is None: - result.error_message = "聊天历史为空" - return finalize(False) - - logger.info( - f"Maisaka 回复器开始生成: 会话流标识={stream_id} 回复原因={reply_reason!r} " - f"历史消息数={len(chat_history)} 目标消息编号={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)}") - - if self.express_model is None: - logger.error("Maisaka 回复器的回复模型未初始化") - result.error_message = "回复模型尚未初始化" - return finalize(False) - - 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, - sub_agent_runner=sub_agent_runner, - ) - except Exception as exc: - import traceback - - logger.error(f"Maisaka 回复器构建回复上下文失败: {exc}\n{traceback.format_exc()}") - result.error_message = f"构建回复上下文失败: {exc}" - result.metrics = GenerationMetrics( - overall_ms=round((time.perf_counter() - overall_started_at) * 1000, 2), - ) - return finalize(False) - - 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}" - ) - - prompt_started_at = time.perf_counter() - try: - request_messages = self._build_request_messages( - chat_history=filtered_history, - reply_message=reply_message, - reply_reason=reply_reason or "", - expression_habits=merged_expression_habits, - stream_id=stream_id, - ) - except Exception as exc: - import traceback - - logger.error(f"Maisaka 回复器构建提示词失败: {exc}\n{traceback.format_exc()}") - result.error_message = f"构建提示词失败: {exc}" - result.metrics = GenerationMetrics( - overall_ms=round((time.perf_counter() - overall_started_at) * 1000, 2), - ) - return finalize(False) - - prompt_ms = round((time.perf_counter() - prompt_started_at) * 1000, 2) - request_prompt = PromptCLIVisualizer._build_prompt_dump_text(request_messages) - result.completion.request_prompt = request_prompt - result.request_messages = serialize_prompt_messages(request_messages) - show_replyer_prompt = bool(getattr(global_config.debug, "show_replyer_prompt", False)) - show_replyer_reasoning = bool(getattr(global_config.debug, "show_replyer_reasoning", False)) - preview_chat_id = self._resolve_session_id(stream_id) or "unknown" - - if show_replyer_prompt: - console.print( - Panel( - PromptCLIVisualizer.build_prompt_access_panel( - request_messages, - category="replyer", - chat_id=preview_chat_id, - request_kind="replyer", - selection_reason=f"ID: {preview_chat_id}", - image_display_mode="path_link" if global_config.maisaka.show_image_path else "legacy", - ), - title="Maisaka Replyer Prompt", - border_style="bright_yellow", - padding=(0, 1), - ) - ) - - def message_factory(_client: object) -> List[Message]: - return request_messages - - llm_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( - prompt_ms=prompt_ms, - llm_ms=round((time.perf_counter() - llm_started_at) * 1000, 2), - overall_ms=round((time.perf_counter() - overall_started_at) * 1000, 2), - ) - return finalize(False) - - llm_ms = round((time.perf_counter() - llm_started_at) * 1000, 2) - response_text = (generation_result.response or "").strip() - result.success = bool(response_text) - result.completion = LLMCompletionResult( - request_prompt=request_prompt, - response_text=response_text, - reasoning_text=generation_result.reasoning or "", - model_name=generation_result.model_name or "", - tool_calls=generation_result.tool_calls or [], - prompt_tokens=generation_result.prompt_tokens, - completion_tokens=generation_result.completion_tokens, - total_tokens=generation_result.total_tokens, - ) - result.metrics = GenerationMetrics( - prompt_ms=prompt_ms, - llm_ms=llm_ms, - overall_ms=round((time.perf_counter() - overall_started_at) * 1000, 2), - stage_logs=[ - f"prompt: {prompt_ms} ms", - f"llm: {llm_ms} ms", - ], - ) - - if 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 finalize(False) - - logger.info( - f"Maisaka 回复器生成成功: 回复文本={response_text!r} " - f"总耗时毫秒={result.metrics.overall_ms} " - f"已选表达编号={result.selected_expression_ids!r}" - ) - result.text_fragments = [response_text] - return finalize(True) diff --git a/src/chat/replyer/maisaka_generator_multi.py b/src/chat/replyer/maisaka_generator_base.py similarity index 95% rename from src/chat/replyer/maisaka_generator_multi.py rename to src/chat/replyer/maisaka_generator_base.py index 8a3f9726..3178647e 100644 --- a/src/chat/replyer/maisaka_generator_multi.py +++ b/src/chat/replyer/maisaka_generator_base.py @@ -1,8 +1,9 @@ -import random import time from dataclasses import dataclass, field from datetime import datetime -from typing import Awaitable, Callable, Dict, List, Optional, Tuple +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +import random from rich.console import Group, RenderableType from rich.panel import Panel @@ -20,13 +21,10 @@ from src.common.data_models.reply_generation_data_models import ( build_reply_monitor_detail, ) from src.common.logger import get_logger -from src.common.prompt_i18n import load_prompt from src.common.utils.utils_session import SessionUtils from src.config.config import global_config from src.core.types import ActionInfo from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType -from src.services.llm_service import LLMServiceClient - from src.maisaka.context_messages import ( AssistantMessage, LLMContextMessage, @@ -34,8 +32,8 @@ from src.maisaka.context_messages import ( SessionBackedMessage, ToolResultMessage, ) +from src.maisaka.display.prompt_cli_renderer import PromptCLIVisualizer from src.maisaka.message_adapter import clone_message_sequence, parse_speaker_content -from src.maisaka.prompt_cli_renderer import PromptCLIVisualizer from src.plugin_runtime.hook_payloads import serialize_prompt_messages from .maisaka_expression_selector import maisaka_expression_selector @@ -51,17 +49,24 @@ class MaisakaReplyContext: selected_expression_ids: List[int] = field(default_factory=list) -class MaisakaReplyGenerator: - """生成 Maisaka 的最终可见回复(多模态管线)。""" +class BaseMaisakaReplyGenerator: + """Maisaka replyer 的共享实现。""" def __init__( self, + *, chat_stream: Optional[BotChatSession] = None, request_type: str = "maisaka_replyer", + llm_client_cls: Any, + load_prompt_func: Callable[..., str], + enable_visual_message: bool, ) -> None: self.chat_stream = chat_stream self.request_type = request_type - self.express_model = LLMServiceClient( + self._llm_client_cls = llm_client_cls + self._load_prompt = load_prompt_func + self._enable_visual_message = enable_visual_message + self.express_model = llm_client_cls( task_name="replyer", request_type=request_type, ) @@ -232,7 +237,7 @@ class MaisakaReplyGenerator: session_id = self._resolve_session_id(stream_id) try: - system_prompt = load_prompt( + system_prompt = self._load_prompt( "maisaka_replyer", bot_name=global_config.bot.nickname, group_chat_attention_block=self._build_group_chat_attention_block(session_id), @@ -255,17 +260,20 @@ class MaisakaReplyGenerator: return f"{system_prompt}\n\n" + "\n\n".join(sections) def _build_reply_instruction(self) -> str: - return "请自然地回复。不要输出多余说明、括号、at 或额外标记,只输出实际要发送的内容。" + return "请自然地回复。不要输出多余说明、括号、@ 或额外标记,只输出实际要发送的内容。" - def _build_multimodal_user_message( + def _build_visual_user_message( self, message: SessionBackedMessage, ) -> Optional[Message]: + if not self._enable_visual_message: + return None + raw_message = clone_message_sequence(message.raw_message) if not raw_message.components: raw_message = MessageSequence([TextComponent(message.processed_plain_text)]) - multimodal_message = SessionBackedMessage( + visual_message = SessionBackedMessage( raw_message=raw_message, visible_text=message.processed_plain_text, timestamp=message.timestamp, @@ -273,7 +281,7 @@ class MaisakaReplyGenerator: original_message=message.original_message, source_kind=message.source_kind, ) - return multimodal_message.to_llm_message() + return visual_message.to_llm_message() def _build_history_messages(self, chat_history: List[LLMContextMessage]) -> List[Message]: bot_nickname = global_config.bot.nickname.strip() or "Bot" @@ -292,9 +300,9 @@ class MaisakaReplyGenerator: ) continue - multimodal_message = self._build_multimodal_user_message(message) - if multimodal_message is not None: - messages.append(multimodal_message) + visual_message = self._build_visual_user_message(message) + if visual_message is not None: + messages.append(visual_message) continue for speaker_name, content_body in self._split_user_message_segments(message.processed_plain_text): @@ -398,7 +406,6 @@ class MaisakaReplyGenerator: selected_expression_ids: Optional[List[int]] = None, sub_agent_runner: Optional[Callable[[str], Awaitable[str]]] = None, ) -> Tuple[bool, ReplyGenerationResult]: - def finalize(success_value: bool) -> Tuple[bool, ReplyGenerationResult]: result.monitor_detail = build_reply_monitor_detail(result) return success_value, result @@ -460,7 +467,7 @@ class MaisakaReplyGenerator: ) logger.info( - f"回复上下文完成: 流={stream_id} 已选表达={result.selected_expression_ids!r}" + f"回复上下文完成 流={stream_id} 已选表达={result.selected_expression_ids!r}" ) prompt_started_at = time.perf_counter() @@ -556,7 +563,7 @@ class MaisakaReplyGenerator: return finalize(False) logger.info( - f"Maisaka 回复器生成成功: 文本={response_text!r} " + f"Maisaka 回复器生成成功 文本={response_text!r} " f"总耗时ms={result.metrics.overall_ms} 已选表达={result.selected_expression_ids!r}" ) if show_replyer_prompt or show_replyer_reasoning: diff --git a/src/chat/replyer/maisaka_replyer_factory.py b/src/chat/replyer/maisaka_replyer_factory.py deleted file mode 100644 index e8194cfa..00000000 --- a/src/chat/replyer/maisaka_replyer_factory.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Type - -from src.config.config import global_config - - -def get_maisaka_replyer_class() -> Type[object]: - """根据配置返回 Maisaka replyer 类。""" - generator_type = get_maisaka_replyer_generator_type() - if generator_type == "multimodal": - 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 "multimodal" if global_config.visual.multimodal_replyer else "legacy" diff --git a/src/chat/replyer/replyer_manager.py b/src/chat/replyer/replyer_manager.py index 712fb421..8afb8c20 100644 --- a/src/chat/replyer/replyer_manager.py +++ b/src/chat/replyer/replyer_manager.py @@ -1,12 +1,11 @@ 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.config.config import global_config from src.common.logger import get_logger +from .maisaka_generator import MaisakaReplyGenerator + if TYPE_CHECKING: from src.chat.replyer.group_generator import DefaultReplyer from src.chat.replyer.private_generator import PrivateReplyer @@ -20,6 +19,11 @@ class ReplyerManager: def __init__(self) -> None: self._repliers: Dict[str, Any] = {} + @staticmethod + def _get_maisaka_generator_type() -> str: + """返回当前配置下 Maisaka replyer 的消息模式。""" + return "multimodal" if global_config.visual.multimodal_replyer else "legacy" + def get_replyer( self, chat_stream: Optional[BotChatSession] = None, @@ -33,7 +37,7 @@ class ReplyerManager: logger.warning("[ReplyerManager] 缺少 stream_id,无法获取 replyer") return None - generator_type = get_maisaka_replyer_generator_type() if replyer_type == "maisaka" else "" + generator_type = self._get_maisaka_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}") @@ -50,13 +54,14 @@ class ReplyerManager: ) try: - maisaka_replyer_class = get_maisaka_replyer_class() - - replyer = maisaka_replyer_class( - chat_stream=target_stream, - request_type=request_type, - ) - + if replyer_type == "maisaka": + replyer = MaisakaReplyGenerator( + chat_stream=target_stream, + request_type=request_type, + ) + else: + logger.warning(f"[ReplyerManager] 不支持的 replyer_type={replyer_type}") + return None except Exception: logger.exception(f"[ReplyerManager] 创建 replyer 失败: cache_key={cache_key}") raise diff --git a/src/config/config.py b/src/config/config.py index 48a14bb7..6adcf706 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -55,7 +55,7 @@ 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.5.5" -MODEL_CONFIG_VERSION: str = "1.13.1" +MODEL_CONFIG_VERSION: str = "1.14.0" logger = get_logger("config") diff --git a/src/config/model_configs.py b/src/config/model_configs.py index a501be66..2d436c77 100644 --- a/src/config/model_configs.py +++ b/src/config/model_configs.py @@ -307,6 +307,15 @@ class ModelInfo(ConfigBase): ) """强制流式输出模式 (若模型不支持非流式输出, 请设置为true启用强制流式输出, 默认值为false)""" + visual: bool = Field( + default=False, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "image", + }, + ) + """是否为多模态模型。开启后表示该模型支持视觉输入。""" + extra_params: dict[str, Any] = Field( default_factory=dict, json_schema_extra={ @@ -437,4 +446,4 @@ class ModelTaskConfig(ConfigBase): "x-icon": "database", }, ) - """嵌入模型配置""" \ No newline at end of file + """嵌入模型配置""" diff --git a/src/llm_models/request_snapshot.py b/src/llm_models/request_snapshot.py index 05469933..a5ca84d0 100644 --- a/src/llm_models/request_snapshot.py +++ b/src/llm_models/request_snapshot.py @@ -228,6 +228,7 @@ def serialize_model_info_snapshot(model_info: ModelInfo) -> dict[str, Any]: "model_identifier": model_info.model_identifier, "name": model_info.name, "temperature": model_info.temperature, + "visual": model_info.visual, } @@ -244,6 +245,7 @@ def deserialize_model_info_snapshot(raw_model_info: Any) -> ModelInfo: model_identifier=str(raw_model_info.get("model_identifier") or ""), name=str(raw_model_info.get("name") or ""), temperature=raw_model_info.get("temperature"), + visual=bool(raw_model_info.get("visual", False)), ) diff --git a/src/main.py b/src/main.py index 771b9b1c..8b5eb9a1 100644 --- a/src/main.py +++ b/src/main.py @@ -18,7 +18,7 @@ from src.common.message_server.server import Server, get_global_server from src.common.remote import TelemetryHeartBeatTask from src.config.config import config_manager, global_config from src.manager.async_task_manager import async_task_manager -from src.maisaka.stage_status_board import disable_stage_status_board, enable_stage_status_board +from src.maisaka.display.stage_status_board import disable_stage_status_board, enable_stage_status_board from src.plugin_runtime.integration import get_plugin_runtime_manager from src.prompt.prompt_manager import prompt_manager from src.services.memory_flow_service import memory_automation_service diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 1a8ee0d8..5a6b1712 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -32,7 +32,7 @@ from src.services.llm_service import LLMServiceClient from .builtin_tool import get_builtin_tools from .context_messages import AssistantMessage, LLMContextMessage, ToolResultMessage from .history_utils import drop_orphan_tool_results -from .prompt_cli_renderer import PromptCLIVisualizer +from .display.prompt_cli_renderer import PromptCLIVisualizer TIMING_GATE_TOOL_NAMES = {"continue", "no_reply", "wait"} diff --git a/src/maisaka/display/__init__.py b/src/maisaka/display/__init__.py new file mode 100644 index 00000000..0101d921 --- /dev/null +++ b/src/maisaka/display/__init__.py @@ -0,0 +1,33 @@ +"""Maisaka 展示模块。""" + +from .display_utils import ( + build_tool_call_summary_lines, + format_token_count, + format_tool_call_for_display, + get_request_panel_style, + get_role_badge_label, + get_role_badge_style, +) +from .prompt_cli_renderer import PromptCLIVisualizer +from .prompt_preview_logger import PromptPreviewLogger +from .stage_status_board import ( + disable_stage_status_board, + enable_stage_status_board, + remove_stage_status, + update_stage_status, +) + +__all__ = [ + "PromptCLIVisualizer", + "PromptPreviewLogger", + "build_tool_call_summary_lines", + "disable_stage_status_board", + "enable_stage_status_board", + "format_token_count", + "format_tool_call_for_display", + "get_request_panel_style", + "get_role_badge_label", + "get_role_badge_style", + "remove_stage_status", + "update_stage_status", +] diff --git a/src/maisaka/display_utils.py b/src/maisaka/display/display_utils.py similarity index 76% rename from src/maisaka/display_utils.py rename to src/maisaka/display/display_utils.py index 311a8013..5f15ed7f 100644 --- a/src/maisaka/display_utils.py +++ b/src/maisaka/display/display_utils.py @@ -4,15 +4,15 @@ from typing import Any _REQUEST_PANEL_STYLE_MAP: dict[str, tuple[str, str]] = { - "planner": ("\u004d\u0061\u0069\u0053\u0061\u006b\u0061 \u5927\u6a21\u578b\u8bf7\u6c42 - \u5bf9\u8bdd\u5355\u6b65", "green"), - "timing_gate": ("\u004d\u0061\u0069\u0053\u0061\u006b\u0061 \u5927\u6a21\u578b\u8bf7\u6c42 - Timing Gate \u5b50\u4ee3\u7406", "bright_magenta"), - "replyer": ("\u004d\u0061\u0069\u0053\u0061\u006b\u0061 \u56de\u590d\u5668 Prompt", "bright_yellow"), + "planner": ("MaiSaka 大模型请求 - 对话单步", "green"), + "timing_gate": ("MaiSaka 大模型请求 - Timing Gate 子代理", "bright_magenta"), + "replyer": ("MaiSaka 回复器 Prompt", "bright_yellow"), "emotion": ("MaiSaka Emotion Tool Prompt", "bright_cyan"), - "sub_agent": ("\u004d\u0061\u0069\u0053\u0061\u006b\u0061 \u5927\u6a21\u578b\u8bf7\u6c42 - \u5b50\u4ee3\u7406", "bright_blue"), + "sub_agent": ("MaiSaka 大模型请求 - 子代理", "bright_blue"), } _DEFAULT_REQUEST_PANEL_STYLE: tuple[str, str] = ( - "\u004d\u0061\u0069\u0053\u0061\u006b\u0061 \u5927\u6a21\u578b\u8bf7\u6c42 - \u5bf9\u8bdd\u5355\u6b65", + "MaiSaka 大模型请求 - 对话单步", "cyan", ) @@ -24,10 +24,10 @@ _ROLE_BADGE_STYLE_MAP: dict[str, str] = { } _ROLE_BADGE_LABEL_MAP: dict[str, str] = { - "system": "\u7cfb\u7edf", - "user": "\u7528\u6237", - "assistant": "\u52a9\u624b", - "tool": "\u5de5\u5177", + "system": "系统", + "user": "用户", + "assistant": "助手", + "tool": "工具", } @@ -55,7 +55,7 @@ def get_role_badge_style(role: str) -> str: def get_role_badge_label(role: str) -> str: """返回角色标签对应的展示文案。""" - return _ROLE_BADGE_LABEL_MAP.get(role, "\u672a\u77e5") + return _ROLE_BADGE_LABEL_MAP.get(role, "未知") def format_tool_call_for_display(tool_call: Any) -> dict[str, Any]: diff --git a/src/maisaka/prompt_cli_renderer.py b/src/maisaka/display/prompt_cli_renderer.py similarity index 97% rename from src/maisaka/prompt_cli_renderer.py rename to src/maisaka/display/prompt_cli_renderer.py index 5af8ca8c..3ac84f26 100644 --- a/src/maisaka/prompt_cli_renderer.py +++ b/src/maisaka/display/prompt_cli_renderer.py @@ -181,6 +181,16 @@ class PromptCLIVisualizer: padding=(0, 1), ) + @staticmethod + def _extract_image_pair(item: Any) -> tuple[str, str] | None: + """兼容图片片段被序列化为 tuple 或 list 的两种形式。""" + + if isinstance(item, (tuple, list)) and len(item) == 2: + image_format, image_base64 = item + if isinstance(image_format, str) and isinstance(image_base64, str): + return image_format, image_base64 + return None + @classmethod def _render_message_content(cls, content: Any, settings: PromptImageDisplaySettings) -> RenderableType: if isinstance(content, str): @@ -192,11 +202,11 @@ class PromptCLIVisualizer: if isinstance(item, str): parts.append(Text(item)) continue - if isinstance(item, tuple) and len(item) == 2: - image_format, image_base64 = item - if isinstance(image_format, str) and isinstance(image_base64, str): - parts.append(cls._render_image_item(image_format, image_base64, settings)) - continue + image_pair = cls._extract_image_pair(item) + if image_pair is not None: + image_format, image_base64 = image_pair + parts.append(cls._render_image_item(image_format, image_base64, settings)) + continue if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): parts.append(Text(item["text"])) else: @@ -218,8 +228,9 @@ class PromptCLIVisualizer: if isinstance(item, str): parts.append(item) continue - if isinstance(item, tuple) and len(item) == 2: - image_format, image_base64 = item + image_pair = cls._extract_image_pair(item) + if image_pair is not None: + image_format, image_base64 = image_pair approx_size = max(0, len(str(image_base64)) * 3 // 4) parts.append(f"[图片 image/{image_format} {approx_size} B]") continue @@ -395,8 +406,9 @@ class PromptCLIVisualizer: if isinstance(item, str): parts.append(f"
{html.escape(item)}
") continue - if isinstance(item, tuple) and len(item) == 2: - image_format, image_base64 = item + image_pair = cls._extract_image_pair(item) + if image_pair is not None: + image_format, image_base64 = image_pair image_html = cls._render_image_item_html(str(image_format), str(image_base64)) parts.append(image_html) continue diff --git a/src/maisaka/prompt_preview_logger.py b/src/maisaka/display/prompt_preview_logger.py similarity index 99% rename from src/maisaka/prompt_preview_logger.py rename to src/maisaka/display/prompt_preview_logger.py index 3e755e5a..2b8cee86 100644 --- a/src/maisaka/prompt_preview_logger.py +++ b/src/maisaka/display/prompt_preview_logger.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Dict from uuid import uuid4 + class PromptPreviewLogger: """负责保存 Maisaka Prompt 预览文件并控制目录容量。""" diff --git a/src/maisaka/stage_status_board.py b/src/maisaka/display/stage_status_board.py similarity index 100% rename from src/maisaka/stage_status_board.py rename to src/maisaka/display/stage_status_board.py diff --git a/src/maisaka/stage_status_viewer.py b/src/maisaka/display/stage_status_viewer.py similarity index 100% rename from src/maisaka/stage_status_viewer.py rename to src/maisaka/display/stage_status_viewer.py index 8e7f571f..7d7a1477 100644 --- a/src/maisaka/stage_status_viewer.py +++ b/src/maisaka/display/stage_status_viewer.py @@ -8,8 +8,8 @@ from typing import Any import json import os import sys -import traceback import time +import traceback def _clear_screen() -> None: diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py index b836e20e..78d0a4b2 100644 --- a/src/maisaka/runtime.py +++ b/src/maisaka/runtime.py @@ -34,10 +34,10 @@ from src.plugin_runtime.hook_payloads import deserialize_prompt_messages from .chat_loop_service import ChatResponse, MaisakaChatLoopService from .context_messages import LLMContextMessage -from .display_utils import build_tool_call_summary_lines, format_token_count -from .prompt_cli_renderer import PromptCLIVisualizer +from .display.display_utils import build_tool_call_summary_lines, format_token_count +from .display.prompt_cli_renderer import PromptCLIVisualizer +from .display.stage_status_board import remove_stage_status, update_stage_status from .reasoning_engine import MaisakaReasoningEngine -from .stage_status_board import remove_stage_status, update_stage_status from .tool_provider import MaisakaBuiltinToolProvider logger = get_logger("maisaka_runtime") diff --git a/src/services/llm_service.py b/src/services/llm_service.py index 9b69898a..264d2dd2 100644 --- a/src/services/llm_service.py +++ b/src/services/llm_service.py @@ -267,6 +267,46 @@ def _parse_data_url_image(image_url: str) -> Tuple[str, str]: return image_format, image_base64 +def _append_image_content(message_builder: MessageBuilder, content_item: Any) -> bool: + """向消息构建器追加图片片段。 + + 兼容两种输入格式: + 1. 旧序列化格式中的 `(image_format, image_base64)` 元组。 + 2. 标准字典片段中的 Data URL 或 `image_format`/`image_base64` 字段。 + """ + + if isinstance(content_item, (tuple, list)) and len(content_item) == 2: + image_format, image_base64 = content_item + if not isinstance(image_format, str) or not isinstance(image_base64, str): + raise ValueError("图片元组片段必须包含字符串类型的 image_format 和 image_base64") + + message_builder.add_image_content(image_format=image_format, image_base64=image_base64) + return True + + if not isinstance(content_item, dict): + return False + + part_type = str(content_item.get("type", "text")).strip().lower() + if part_type not in {"image", "image_url", "input_image"}: + return False + + image_url = content_item.get("image_url") + if isinstance(image_url, dict): + image_url = image_url.get("url") + if isinstance(image_url, str): + image_format, image_base64 = _parse_data_url_image(image_url) + message_builder.add_image_content(image_format=image_format, image_base64=image_base64) + return True + + image_format = content_item.get("image_format") + image_base64 = content_item.get("image_base64") + if isinstance(image_format, str) and isinstance(image_base64, str): + message_builder.add_image_content(image_format=image_format, image_base64=image_base64) + return True + + raise ValueError("图片片段缺少可识别的图片数据") + + def _append_content_parts(message_builder: MessageBuilder, content: Any) -> None: """将原始消息内容追加到内部消息构建器。 @@ -293,8 +333,10 @@ def _append_content_parts(message_builder: MessageBuilder, content: Any) -> None if isinstance(content_item, str): message_builder.add_text_content(content_item) continue + if _append_image_content(message_builder, content_item): + continue if not isinstance(content_item, dict): - raise ValueError("消息内容列表中仅支持字符串或字典片段") + raise ValueError("消息内容列表中仅支持字符串、图片元组或字典片段") part_type = str(content_item.get("type", "text")).strip().lower() if part_type == "text": @@ -304,22 +346,6 @@ def _append_content_parts(message_builder: MessageBuilder, content: Any) -> None message_builder.add_text_content(text_content) continue - if part_type in {"image", "image_url", "input_image"}: - image_url = content_item.get("image_url") - if isinstance(image_url, dict): - image_url = image_url.get("url") - if isinstance(image_url, str): - image_format, image_base64 = _parse_data_url_image(image_url) - message_builder.add_image_content(image_format=image_format, image_base64=image_base64) - continue - - image_format = content_item.get("image_format") - image_base64 = content_item.get("image_base64") - if isinstance(image_format, str) and isinstance(image_base64, str): - message_builder.add_image_content(image_format=image_format, image_base64=image_base64) - continue - raise ValueError("图片片段缺少可识别的图片数据") - raise ValueError(f"不支持的消息片段类型: {part_type}")