From 5cdca2bbd45bd8a29d0601e3ee9d415537960be8 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 3 Apr 2026 18:33:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix=EF=BC=9A=E9=99=8D=E4=BD=8Equote?= =?UTF-8?q?=E7=9A=84=E6=A6=82=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/maisaka/builtin_tool/reply.py | 29 +++++++----------- src/maisaka/chat_loop_service.py | 13 -------- src/maisaka/reasoning_engine.py | 46 ++++++++++++++++++++++------- src/maisaka/runtime.py | 49 ++++++++++++++++++++++++------- 4 files changed, 84 insertions(+), 53 deletions(-) diff --git a/src/maisaka/builtin_tool/reply.py b/src/maisaka/builtin_tool/reply.py index ac7fe767..dc6ef24c 100644 --- a/src/maisaka/builtin_tool/reply.py +++ b/src/maisaka/builtin_tool/reply.py @@ -22,8 +22,7 @@ def get_tool_spec() -> ToolSpec: detailed_description=( "参数说明:\n" "- msg_id:string,必填。要回复的目标用户消息编号。\n" - "- quote:boolean,可选。当有非常明确的回复目标时,以引用回复的方式发送,默认 true。\n" - "- unknown_words:array,可选。回复前可能需要查询的黑话或词条列表。" + "- set_quote:boolean,可选。以引用回复的方式发送,默认 true。" ), parameters_schema={ "type": "object", @@ -32,16 +31,11 @@ def get_tool_spec() -> ToolSpec: "type": "string", "description": "要回复的目标用户消息编号。", }, - "quote": { + "set_quote": { "type": "boolean", - "description": "当有非常明确的回复目标时,以引用回复的方式发送。", + "description": "以引用回复的方式发送这条回复,不用每句都引用。", "default": True, }, - "unknown_words": { - "type": "array", - "description": "回复前可能需要查询的黑话或词条列表。", - "items": {"type": "string"}, - }, }, "required": ["msg_id"], }, @@ -59,9 +53,7 @@ async def handle_tool( latest_thought = context.reasoning if context is not None else invocation.reasoning target_message_id = str(invocation.arguments.get("msg_id") or "").strip() - quote_reply = bool(invocation.arguments.get("quote", True)) - raw_unknown_words = invocation.arguments.get("unknown_words") - unknown_words = raw_unknown_words if isinstance(raw_unknown_words, list) else None + set_quote = bool(invocation.arguments.get("set_quote", True)) if not target_message_id: return tool_ctx.build_failure_result( @@ -77,8 +69,8 @@ async def handle_tool( ) logger.info( - f"{tool_ctx.runtime.log_prefix} 已触发回复工具 " - f"目标消息编号={target_message_id} 引用回复={quote_reply} 最新思考={latest_thought!r}" + f"{tool_ctx.runtime.log_prefix} 已触发回复工具," + f"目标消息编号={target_message_id} 引用回复={set_quote} 最新思考={latest_thought!r}" ) try: replyer = replyer_manager.get_replyer( @@ -108,7 +100,6 @@ async def handle_tool( stream_id=tool_ctx.runtime.session_id, reply_message=target_message, chat_history=tool_ctx.runtime._chat_history, - unknown_words=unknown_words, log_reply=False, ) except Exception as exc: @@ -144,8 +135,8 @@ async def handle_tool( sent = await send_service.text_to_stream( text=segment, stream_id=tool_ctx.runtime.session_id, - set_reply=quote_reply if index == 0 else False, - reply_message=target_message if quote_reply and index == 0 else None, + set_reply=set_quote if index == 0 else False, + reply_message=target_message if set_quote and index == 0 else None, selected_expressions=reply_result.selected_expression_ids or None, typing=index > 0, ) @@ -166,7 +157,7 @@ async def handle_tool( "可见回复生成成功,但发送失败。", structured_content={ "msg_id": target_message_id, - "quote": quote_reply, + "set_quote": set_quote, "reply_segments": reply_segments, }, ) @@ -180,7 +171,7 @@ async def handle_tool( "回复已生成并发送。", structured_content={ "msg_id": target_message_id, - "quote": quote_reply, + "set_quote": set_quote, "reply_text": combined_reply_text, "reply_segments": reply_segments, "target_user_name": target_user_name, diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 236277b6..93dba8b5 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -821,19 +821,6 @@ class MaisakaChatLoopService: ) total_tokens = self._coerce_int(after_response_kwargs.get("total_tokens"), generation_result.total_tokens) - tool_call_summaries = [ - { - "调用编号": getattr(tool_call, "call_id", getattr(tool_call, "id", None)), - "工具名": getattr(tool_call, "func_name", getattr(tool_call, "name", None)), - "参数": getattr(tool_call, "args", getattr(tool_call, "arguments", None)), - } - for tool_call in final_tool_calls - ] - logger.info( - f"Maisaka 规划器返回结果: 内容={final_response!r} " - f"工具调用={tool_call_summaries}" - ) - raw_message = AssistantMessage( content=final_response, timestamp=datetime.now(), diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 72539a5b..2b20c1d3 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -125,24 +125,33 @@ class MaisakaReasoningEngine: logger.info(f"{self._runtime.log_prefix} 当前思考与上一轮过于相似,已替换为重新思考提示") self._last_reasoning_content = reasoning_content - self._runtime._render_context_usage_panel( - selected_history_count=response.selected_history_count, - prompt_tokens=response.prompt_tokens, - ) self._runtime._chat_history.append(response.raw_message) + tool_result_summaries: list[str] = [] if response.tool_calls: tool_started_at = time.time() - should_pause = await self._handle_tool_calls( + should_pause, tool_result_summaries = await self._handle_tool_calls( response.tool_calls, response.content or "", anchor_message, ) cycle_detail.time_records["tool_calls"] = time.time() - tool_started_at + self._runtime._render_context_usage_panel( + selected_history_count=response.selected_history_count, + prompt_tokens=response.prompt_tokens, + planner_response=response.content or "", + tool_calls=response.tool_calls, + tool_results=tool_result_summaries, + ) if should_pause: break continue + self._runtime._render_context_usage_panel( + selected_history_count=response.selected_history_count, + prompt_tokens=response.prompt_tokens, + planner_response=response.content or "", + ) if response.content: continue @@ -701,12 +710,25 @@ class MaisakaReasoningEngine: ) ) + def _build_tool_result_summary(self, tool_call: ToolCall, result: ToolExecutionResult) -> str: + """构建用于终端展示的工具结果摘要。""" + + history_content = result.get_history_content().strip() + if not history_content: + history_content = result.error_message.strip() + if not history_content: + history_content = "执行成功" if result.success else "执行失败" + + summary_prefix = "[成功]" if result.success else "[失败]" + normalized_content = self._truncate_tool_record_text(history_content, max_length=200) + return f"- {tool_call.func_name} {summary_prefix}: {normalized_content}" + async def _handle_tool_calls( self, tool_calls: list[ToolCall], latest_thought: str, anchor_message: SessionMessage, - ) -> bool: + ) -> tuple[bool, list[str]]: """执行一批统一工具调用。 Args: @@ -715,9 +737,11 @@ class MaisakaReasoningEngine: anchor_message: 当前轮的锚点消息。 Returns: - bool: 是否需要暂停当前思考循环。 + tuple[bool, list[str]]: 是否需要暂停当前思考循环,以及工具结果摘要列表。 """ + tool_result_summaries: list[str] = [] + if self._runtime._tool_registry is None: for tool_call in tool_calls: invocation = self._build_tool_invocation(tool_call, latest_thought) @@ -728,7 +752,8 @@ class MaisakaReasoningEngine: ) await self._store_tool_execution_record(invocation, result, None) self._append_tool_execution_result(tool_call, result) - return False + tool_result_summaries.append(self._build_tool_result_summary(tool_call, result)) + return False, tool_result_summaries execution_context = self._build_tool_execution_context(latest_thought, anchor_message) tool_spec_map = { @@ -744,12 +769,13 @@ class MaisakaReasoningEngine: tool_spec_map.get(invocation.tool_name), ) self._append_tool_execution_result(tool_call, result) + tool_result_summaries.append(self._build_tool_result_summary(tool_call, result)) if not result.success and tool_call.func_name == "reply": logger.warning(f"{self._runtime.log_prefix} 回复工具未生成可见消息,将继续下一轮循环") if bool(result.metadata.get("pause_execution", False)): - return True + return True, tool_result_summaries - return False + return False, tool_result_summaries diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py index 33efb4dd..1617dbd2 100644 --- a/src/maisaka/runtime.py +++ b/src/maisaka/runtime.py @@ -1,6 +1,6 @@ """Maisaka 非 CLI 运行时。""" -from typing import Literal, Optional +from typing import Any, Literal, Optional import asyncio import time @@ -450,28 +450,55 @@ class MaisakaHeartFlowChatting: *, selected_history_count: int, prompt_tokens: int, + planner_response: str = "", + tool_calls: Optional[list[Any]] = None, + tool_results: Optional[list[str]] = None, ) -> None: - """在终端展示当前聊天流的上下文占用情况。""" + """在终端展示当前聊天流的上下文占用、规划结果与工具摘要。""" if not global_config.maisaka.show_thinking: return session_name = chat_manager.get_session_name(self.session_id) or self.session_id - body = "\n".join( - [ - f"聊天流: {session_name}", - f"Chat ID: {self.session_id}", - f"上下文占用: {selected_history_count}条 / {self._format_token_count(prompt_tokens)}", - ] - ) + body_lines = [ + f"聊天流: {session_name}", + f"Chat ID: {self.session_id}", + f"上下文占用: {selected_history_count}条 / {self._format_token_count(prompt_tokens)}", + ] + + normalized_response = planner_response.strip() + if normalized_response: + body_lines.extend(["", "Maisaka 返回:", normalized_response]) + + normalized_tool_calls = self._build_tool_call_summary_lines(tool_calls or []) + if normalized_tool_calls: + body_lines.extend(["", "工具调用:", *normalized_tool_calls]) + + normalized_tool_results = [result.strip() for result in tool_results or [] if isinstance(result, str) and result.strip()] + if normalized_tool_results: + body_lines.extend(["", "工具结果:", *normalized_tool_results]) + console.print( Panel( - Text(body), - title="MaiSaka 上下文占用", + Text("\n".join(body_lines)), + title="MaiSaka 上下文与结果", border_style="bright_blue", padding=(0, 1), ) ) + @staticmethod + def _build_tool_call_summary_lines(tool_calls: list[Any]) -> list[str]: + """构建工具调用摘要文本。""" + summary_lines: list[str] = [] + for tool_call in tool_calls: + tool_name = str(getattr(tool_call, "func_name", getattr(tool_call, "name", "")) or "").strip() or "unknown" + tool_args = getattr(tool_call, "args", getattr(tool_call, "arguments", None)) + if isinstance(tool_args, dict) and tool_args: + summary_lines.append(f"- {tool_name}: {tool_args}") + else: + summary_lines.append(f"- {tool_name}") + return summary_lines + def _log_cycle_started(self, cycle_detail: CycleDetail, round_index: int) -> None: logger.info( f"{self.log_prefix} MaiSaka 轮次开始: 循环编号={cycle_detail.cycle_id} " From ce580d1f8b3ebd51fcbd6c3abde51eca9ee809bc Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 3 Apr 2026 20:55:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat=EF=BC=9A=E5=8F=AF=E4=BB=A5=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E8=BD=AC=E5=8F=91=E6=B6=88=E6=81=AF=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=A4=8D=E6=9D=82=E6=B6=88=E6=81=AF=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=A1=A8=E6=83=85=E5=8C=85=E7=BC=93=E5=AD=98=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=94=B9bot=E8=87=AA=E5=B7=B1=E7=9A=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=9B=9E=E8=B0=83msg=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/emoji_system/emoji_manager.py | 100 ++++++++--- src/chat/message_receive/bot.py | 19 +- src/common/utils/utils_message.py | 51 ++++++ src/maisaka/builtin_tool/__init__.py | 9 + src/maisaka/builtin_tool/send_emoji.py | 5 +- .../builtin_tool/view_complex_message.py | 99 +++++++++++ src/maisaka/context_messages.py | 166 +++++++++++++++++- src/maisaka/reasoning_engine.py | 63 ++++--- 8 files changed, 460 insertions(+), 52 deletions(-) create mode 100644 src/maisaka/builtin_tool/view_complex_message.py diff --git a/src/chat/emoji_system/emoji_manager.py b/src/chat/emoji_system/emoji_manager.py index 45cc3012..bca195fb 100644 --- a/src/chat/emoji_system/emoji_manager.py +++ b/src/chat/emoji_system/emoji_manager.py @@ -290,12 +290,68 @@ class EmojiManager: if not emoji_bytes: return None if not wait_for_build: + await self.ensure_emoji_saved(emoji_bytes, emoji_hash=emoji_hash) self._schedule_description_build(emoji_hash, emoji_bytes) return None # 找不到尝试构建 return await self._build_and_cache_emoji_description(emoji_hash, emoji_bytes) + async def ensure_emoji_saved( + self, + emoji_bytes: bytes, + *, + emoji_hash: Optional[str] = None, + ) -> MaiEmoji: + """先缓存表情包文件与数据库记录,确保后续可按 hash 回填。""" + hash_str = emoji_hash or hashlib.sha256(emoji_bytes).hexdigest() + + try: + with get_db_session() as session: + statement = select(Images).filter_by(image_hash=hash_str, image_type=ImageType.EMOJI).limit(1) + if record := session.exec(statement).first(): + record_path = Path(record.full_path) + if not record.no_file_flag and record_path.exists(): + record.last_used_time = datetime.now() + record.query_count += 1 + session.add(record) + return MaiEmoji.from_db_instance(record) + except Exception as e: + logger.error(f"缓存表情包前查询数据库时出错: {e}") + raise e + + logger.info(f"表情包不存在于数据库中,准备缓存新表情包,哈希值: {hash_str}") + tmp_file_path = EMOJI_DIR / f"{hash_str}.tmp" + with tmp_file_path.open("wb") as file: + file.write(emoji_bytes) + + emoji = MaiEmoji(full_path=tmp_file_path, image_bytes=emoji_bytes) + await emoji.calculate_hash_format() + + try: + with get_db_session() as session: + statement = select(Images).filter_by(image_hash=emoji.file_hash, image_type=ImageType.EMOJI).limit(1) + if existing_record := session.exec(statement).first(): + existing_record.full_path = str(emoji.full_path) + existing_record.no_file_flag = False + existing_record.is_banned = False + existing_record.last_used_time = datetime.now() + existing_record.query_count += 1 + session.add(existing_record) + else: + image_record = emoji.to_db_instance() + image_record.is_registered = False + image_record.is_banned = False + image_record.no_file_flag = False + image_record.last_used_time = datetime.now() + image_record.query_count = 1 + session.add(image_record) + except Exception as e: + logger.error(f"缓存表情包记录到数据库时出错: {e}") + raise e + + return emoji + def _schedule_description_build(self, emoji_hash: str, emoji_bytes: bytes) -> None: """调度表情包描述后台构建任务。 @@ -342,48 +398,36 @@ class EmojiManager: emoji_hash: str, emoji_bytes: bytes, ) -> Optional[Tuple[str, List[str]]]: - """构建并缓存表情包描述与情感标签。 - - Args: - emoji_hash: 表情包哈希值。 - emoji_bytes: 表情包字节数据。 - - Returns: - Optional[Tuple[str, List[str]]]: 构建成功时返回描述和情感标签,否则返回 ``None``。 - """ - logger.info(f"未找到哈希值为 {emoji_hash} 的表情包与其描述,尝试构建描述") - full_path = EMOJI_DIR / f"{emoji_hash}.png" - try: - full_path.write_bytes(emoji_bytes) - new_emoji = MaiEmoji(full_path=full_path, image_bytes=emoji_bytes) - await new_emoji.calculate_hash_format() - except Exception as exc: - logger.error(f"缓存表情包文件时出错: {exc}") - raise exc + """Build and cache emoji description and emotion labels.""" + logger.info(f"Start building cached emoji description, hash={emoji_hash}") + new_emoji = await self.ensure_emoji_saved(emoji_bytes, emoji_hash=emoji_hash) success_desc, new_emoji = await self.build_emoji_description(new_emoji) if not success_desc: - logger.error("构建表情包描述失败") + logger.error("Build emoji description failed") return None success_emotion, new_emoji = await self.build_emoji_emotion(new_emoji) if not success_emotion: - logger.error("构建表情包情感标签失败") + logger.error("Build emoji emotion labels failed") return None with get_db_session() as session: try: - image_record = new_emoji.to_db_instance() - image_record.is_registered = False - image_record.is_banned = False - image_record.register_time = datetime.now() - image_record.no_file_flag = True - session.add(image_record) + statement = select(Images).filter_by(image_hash=new_emoji.file_hash, image_type=ImageType.EMOJI).limit(1) + if image_record := session.exec(statement).first(): + image_record.full_path = str(new_emoji.full_path) + image_record.description = new_emoji.description + image_record.emotion = ",".join(new_emoji.emotion) if new_emoji.emotion else None + image_record.no_file_flag = False + image_record.is_banned = False + session.add(image_record) except Exception as exc: - logger.error(f"缓存表情包描述时出错: {exc}") + logger.error(f"Update cached emoji description failed: {exc}") return new_emoji.description, new_emoji.emotion or [] def load_emojis_from_db(self) -> None: + """ 从数据库加载已注册的表情包 @@ -398,6 +442,8 @@ class EmojiManager: for record in results: if record.image_type != ImageType.EMOJI: continue + if not record.is_registered: + continue if record.no_file_flag: continue if record.is_banned: diff --git a/src/chat/message_receive/bot.py b/src/chat/message_receive/bot.py index 1015bf07..5a611796 100644 --- a/src/chat/message_receive/bot.py +++ b/src/chat/message_receive/bot.py @@ -466,8 +466,23 @@ class ChatBot: return mmc_message_id = message_data.get("echo") actual_message_id = message_data.get("actual_id") - # TODO: Implement message ID update in new architecture - logger.debug(f"收到回送消息ID: {mmc_message_id} -> {actual_message_id}") + normalized_mmc_message_id = str(mmc_message_id or "").strip() + normalized_actual_message_id = str(actual_message_id or "").strip() + if not normalized_mmc_message_id or not normalized_actual_message_id: + return + + updated = MessageUtils.update_message_id( + old_message_id=normalized_mmc_message_id, + new_message_id=normalized_actual_message_id, + ) + if updated: + logger.debug(f"收到回送消息ID: {normalized_mmc_message_id} -> {normalized_actual_message_id}") + return + + logger.debug( + "收到回送消息 ID,但未找到可回填的本地消息: " + f"{normalized_mmc_message_id} -> {normalized_actual_message_id}" + ) async def message_process(self, message_data: Dict[str, Any]) -> None: """处理统一格式的入站消息字典。 diff --git a/src/common/utils/utils_message.py b/src/common/utils/utils_message.py index e1db1d29..a0f0201e 100644 --- a/src/common/utils/utils_message.py +++ b/src/common/utils/utils_message.py @@ -149,6 +149,57 @@ class MessageUtils: db_message = message.to_db_instance() session.add(db_message) + @staticmethod + def update_message_id(old_message_id: str, new_message_id: str) -> bool: + """将已入库消息的临时 ID 回填为平台真实 ID。 + + Args: + old_message_id: 发送阶段生成的内部临时消息 ID。 + new_message_id: 适配器回传的真实平台消息 ID。 + + Returns: + bool: 存在并成功更新目标消息时返回 ``True``,否则返回 ``False``。 + """ + normalized_old_message_id = str(old_message_id).strip() + normalized_new_message_id = str(new_message_id).strip() + if not normalized_old_message_id or not normalized_new_message_id: + return False + if normalized_old_message_id == normalized_new_message_id: + return False + + from src.common.database.database import get_db_session + from src.common.database.database_model import Messages + + with get_db_session() as session: + existing_target = session.exec( + select(Messages).filter_by(message_id=normalized_new_message_id).limit(1) + ).first() + if existing_target is not None: + logger.warning( + "消息 ID 回填时发现真实 ID 已存在,已跳过更新: " + f"{normalized_old_message_id} -> {normalized_new_message_id}" + ) + return False + + source_messages = session.exec( + select(Messages).filter_by(message_id=normalized_old_message_id) + ).all() + if not source_messages: + return False + + for source_message in source_messages: + source_message.message_id = normalized_new_message_id + session.add(source_message) + + reply_target_messages = session.exec( + select(Messages).filter_by(reply_to=normalized_old_message_id) + ).all() + for reply_target_message in reply_target_messages: + reply_target_message.reply_to = normalized_new_message_id + session.add(reply_target_message) + + return True + @staticmethod async def build_readable_message( messages: List["SessionMessage"], diff --git a/src/maisaka/builtin_tool/__init__.py b/src/maisaka/builtin_tool/__init__.py index 215db370..74d1672f 100644 --- a/src/maisaka/builtin_tool/__init__.py +++ b/src/maisaka/builtin_tool/__init__.py @@ -17,6 +17,8 @@ from .reply import get_tool_spec as get_reply_tool_spec from .reply import handle_tool as handle_reply_tool from .send_emoji import get_tool_spec as get_send_emoji_tool_spec from .send_emoji import handle_tool as handle_send_emoji_tool +from .view_complex_message import get_tool_spec as get_view_complex_message_tool_spec +from .view_complex_message import handle_tool as handle_view_complex_message_tool from .wait import get_tool_spec as get_wait_tool_spec from .wait import handle_tool as handle_wait_tool @@ -29,6 +31,7 @@ def get_builtin_tool_specs() -> List[ToolSpec]: return [ get_wait_tool_spec(), get_reply_tool_spec(), + get_view_complex_message_tool_spec(), get_query_jargon_tool_spec(), get_no_reply_tool_spec(), get_send_emoji_tool_spec(), @@ -41,6 +44,7 @@ def get_all_builtin_tool_specs() -> List[ToolSpec]: return [ get_wait_tool_spec(), get_reply_tool_spec(), + get_view_complex_message_tool_spec(), get_query_jargon_tool_spec(), get_query_person_info_tool_spec(), get_no_reply_tool_spec(), @@ -68,4 +72,9 @@ def build_builtin_tool_handlers(tool_ctx: BuiltinToolRuntimeContext) -> Dict[str ), "wait": lambda invocation, context=None: handle_wait_tool(tool_ctx, invocation, context), "send_emoji": lambda invocation, context=None: handle_send_emoji_tool(tool_ctx, invocation, context), + "view_complex_message": lambda invocation, context=None: handle_view_complex_message_tool( + tool_ctx, + invocation, + context, + ), } diff --git a/src/maisaka/builtin_tool/send_emoji.py b/src/maisaka/builtin_tool/send_emoji.py index 0a1baccb..66fb32d2 100644 --- a/src/maisaka/builtin_tool/send_emoji.py +++ b/src/maisaka/builtin_tool/send_emoji.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Optional from src.chat.emoji_system.maisaka_tool import send_emoji_for_maisaka from src.common.logger import get_logger from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec +from src.maisaka.context_messages import LLMContextMessage from .context import BuiltinToolRuntimeContext @@ -42,9 +43,9 @@ async def handle_tool( del context emotion = str(invocation.arguments.get("emotion") or "").strip() context_texts = [ - message.get_history_text() + message.processed_plain_text.strip() for message in tool_ctx.runtime._chat_history[-5:] - if message.get_history_text().strip() + if isinstance(message, LLMContextMessage) and message.processed_plain_text.strip() ] structured_result: Dict[str, Any] = { "success": False, diff --git a/src/maisaka/builtin_tool/view_complex_message.py b/src/maisaka/builtin_tool/view_complex_message.py new file mode 100644 index 00000000..741a1b4c --- /dev/null +++ b/src/maisaka/builtin_tool/view_complex_message.py @@ -0,0 +1,99 @@ +"""view_complex_message 内置工具。""" + +from typing import Optional + +from src.common.logger import get_logger +from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec + +from ..context_messages import build_full_complex_message_content, contains_complex_message +from .context import BuiltinToolRuntimeContext + +logger = get_logger("maisaka_builtin_view_complex_message") + + +def get_tool_spec() -> ToolSpec: + """获取 view_complex_message 工具声明。""" + + return ToolSpec( + name="view_complex_message", + brief_description="根据 msg_id 查看复杂消息的完整内容,适用于 Prompt 中标记为 [消息类型]复杂消息 的消息。", + detailed_description=( + "参数说明:\n" + "- msg_id:string,必填。要查看完整内容的目标消息编号。\n\n" + "当你在上下文中看到 [消息类型]复杂消息 时,可调用本工具查看对应转发消息的完整展开内容。" + ), + parameters_schema={ + "type": "object", + "properties": { + "msg_id": { + "type": "string", + "description": "要查看完整内容的目标消息编号。", + }, + }, + "required": ["msg_id"], + }, + provider_name="maisaka_builtin", + provider_type="builtin", + ) + + +async def handle_tool( + tool_ctx: BuiltinToolRuntimeContext, + invocation: ToolInvocation, + context: Optional[ToolExecutionContext] = None, +) -> ToolExecutionResult: + """执行 view_complex_message 内置工具。""" + + del context + target_message_id = str(invocation.arguments.get("msg_id") or "").strip() + if not target_message_id: + return tool_ctx.build_failure_result( + invocation.tool_name, + "查看复杂消息工具需要提供有效的 `msg_id` 参数。", + ) + + target_message = tool_ctx.runtime._source_messages_by_id.get(target_message_id) + if target_message is None: + return tool_ctx.build_failure_result( + invocation.tool_name, + f"未找到目标复杂消息,msg_id={target_message_id}", + ) + + if not contains_complex_message(target_message.raw_message): + return tool_ctx.build_failure_result( + invocation.tool_name, + f"目标消息不是可展开查看的转发消息,msg_id={target_message_id}", + ) + + logger.info( + f"{tool_ctx.runtime.log_prefix} 触发复杂消息查看工具,目标消息编号={target_message_id}" + ) + try: + full_content = await build_full_complex_message_content(target_message) + except Exception as exc: + logger.exception( + f"{tool_ctx.runtime.log_prefix} 查看复杂消息时发生异常: 目标消息编号={target_message_id} 异常={exc}" + ) + return tool_ctx.build_failure_result( + invocation.tool_name, + "查看复杂消息完整内容时发生异常。", + ) + + if not full_content: + return tool_ctx.build_failure_result( + invocation.tool_name, + f"复杂消息内容为空,msg_id={target_message_id}", + ) + + return tool_ctx.build_success_result( + invocation.tool_name, + full_content, + structured_content={ + "msg_id": target_message_id, + "message_type": "forward", + "full_content": full_content, + }, + metadata={ + "record_display_prompt": f"你查看了复杂消息 {target_message_id} 的完整内容。", + }, + ) diff --git a/src/maisaka/context_messages.py b/src/maisaka/context_messages.py index 65404d20..27d5bd08 100644 --- a/src/maisaka/context_messages.py +++ b/src/maisaka/context_messages.py @@ -5,22 +5,29 @@ from dataclasses import dataclass, field from datetime import datetime from enum import Enum from io import BytesIO -from typing import Optional +from typing import Optional, Sequence import base64 from PIL import Image as PILImage from src.chat.message_receive.message import SessionMessage from src.common.data_models.message_component_data_model import ( + AtComponent, + DictComponent, EmojiComponent, + ForwardNodeComponent, ImageComponent, MessageSequence, ReplyComponent, + StandardMessageComponents, TextComponent, + VoiceComponent, ) from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType from src.llm_models.payload_content.tool_option import ToolCall +FORWARD_PREVIEW_LIMIT = 4 + def _guess_image_format(image_bytes: bytes) -> Optional[str]: if not image_bytes: @@ -71,6 +78,118 @@ def _append_reply_component(builder: MessageBuilder, component: ReplyComponent) return True +def contains_complex_message(message_sequence: MessageSequence) -> bool: + """判断消息序列中是否包含复杂消息组件。""" + + return any(isinstance(component, ForwardNodeComponent) for component in message_sequence.components) + + +async def build_full_complex_message_content(message: SessionMessage) -> str: + """构造复杂消息的完整文本内容。""" + + if not message.processed_plain_text: + await message.process() + return (message.processed_plain_text or "").strip() + + +def _build_complex_message_prompt_text(message_sequence: MessageSequence) -> str: + """将复杂消息转换为适合注入 Prompt 的摘要文本。""" + + prompt_parts: list[str] = [] + for component in message_sequence.components: + rendered_text = _render_component_for_prompt(component) + if rendered_text: + prompt_parts.append(rendered_text) + return "\n".join(part for part in prompt_parts if part).strip() + + +def _render_component_for_prompt(component: StandardMessageComponents) -> str: + """将单个组件渲染为 Prompt 文本。""" + + if isinstance(component, TextComponent): + return (component.text or "").strip() + + if isinstance(component, ImageComponent): + return component.content.strip() if component.content else "[图片]" + + if isinstance(component, EmojiComponent): + return component.content.strip() if component.content else "[表情包]" + + if isinstance(component, VoiceComponent): + return component.content.strip() if component.content else "[语音消息]" + + if isinstance(component, AtComponent): + target_name = component.target_user_cardname or component.target_user_nickname or component.target_user_id + return f"@{target_name}".strip() + + if isinstance(component, ReplyComponent): + sender_name = ( + component.target_message_sender_cardname + or component.target_message_sender_nickname + or component.target_message_sender_id + ) + target_content = (component.target_message_content or "").strip() + if sender_name and target_content: + return f"[回复了{sender_name}的消息: {target_content}]" + if target_content: + return f"[回复消息: {target_content}]" + target_message_id = component.target_message_id.strip() + return f"[引用回复]({target_message_id})" if target_message_id else "[回复消息]" + + if isinstance(component, ForwardNodeComponent): + return _build_forward_preview_block(component) + + if isinstance(component, DictComponent): + raw_type = component.data.get("type") if isinstance(component.data, dict) else None + if isinstance(raw_type, str) and raw_type.strip(): + return f"[{raw_type.strip()}消息]" + return "[复杂消息]" + + return "" + + +def _build_forward_preview_block(component: ForwardNodeComponent) -> str: + """构造转发消息的预览块。""" + + preview_lines = ["[消息类型]复杂消息", "转发消息", f"预览前{FORWARD_PREVIEW_LIMIT}条:"] + preview_nodes = component.forward_components[:FORWARD_PREVIEW_LIMIT] + + for node in preview_nodes: + sender_name = node.user_cardname or node.user_nickname or node.user_id or "未知用户" + content = _render_components_inline(node.content) or "[空消息]" + preview_lines.append(f"{sender_name}:{content}") + + total_count = len(component.forward_components) + if total_count > FORWARD_PREVIEW_LIMIT: + preview_lines.append("......") + preview_lines.append(f"共{total_count}条,可以选择使用 view_complex_message 查看完整内容。") + + return "\n".join(preview_lines).strip() + + +def _render_components_inline(components: Sequence[StandardMessageComponents]) -> str: + """将组件序列压缩为单行预览文本。""" + + rendered_parts: list[str] = [] + for component in components: + if isinstance(component, ForwardNodeComponent): + rendered_parts.append("[转发消息]") + continue + + rendered_text = _render_component_for_prompt(component) + normalized_text = _normalize_inline_text(rendered_text) + if normalized_text: + rendered_parts.append(normalized_text) + + return " ".join(rendered_parts).strip() + + +def _normalize_inline_text(text: str) -> str: + """将多行文本压缩为适合预览的一行。""" + + return " ".join((text or "").split()).strip() + + def _build_message_from_sequence( role: RoleType, message_sequence: MessageSequence, @@ -209,6 +328,51 @@ class SessionBackedMessage(LLMContextMessage): ) +@dataclass(slots=True) +class ComplexSessionMessage(SessionBackedMessage): + """复杂消息上下文消息。""" + + prompt_text: str = "" + complex_message_type: str = "forward" + + @property + def source(self) -> str: + return f"{self.source_kind}:{self.complex_message_type}" + + def to_llm_message(self) -> Optional[Message]: + message_sequence = MessageSequence([TextComponent(self.prompt_text)]) + return _build_message_from_sequence( + RoleType.User, + message_sequence, + self.prompt_text, + ) + + @classmethod + def from_session_message( + cls, + session_message: SessionMessage, + *, + planner_prefix: str, + visible_text: str, + source_kind: str = "user", + ) -> Optional["ComplexSessionMessage"]: + """从真实 SessionMessage 构造复杂消息上下文消息。""" + + prompt_text = _build_complex_message_prompt_text(session_message.raw_message) + if not prompt_text: + return None + + return cls( + raw_message=session_message.raw_message, + visible_text=visible_text, + timestamp=session_message.timestamp, + message_id=session_message.message_id, + original_message=session_message, + source_kind=source_kind, + prompt_text=f"{planner_prefix}{prompt_text}", + ) + + @dataclass(slots=True) class ReferenceMessage(LLMContextMessage): """参考消息。""" diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 2b20c1d3..362e3e87 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -24,9 +24,11 @@ from .builtin_tool import build_builtin_tool_handlers as build_split_builtin_too from .builtin_tool.context import BuiltinToolRuntimeContext from .context_messages import ( AssistantMessage, + ComplexSessionMessage, LLMContextMessage, SessionBackedMessage, ToolResultMessage, + contains_complex_message, ) from .message_adapter import ( build_visible_text_from_sequence, @@ -220,26 +222,49 @@ class MaisakaReasoningEngine: async def _ingest_messages(self, messages: list[SessionMessage]) -> None: """处理传入消息列表,将其转换为历史消息并加入聊天历史缓存。""" for message in messages: - # 构建用户消息序列 - user_sequence, visible_text = await self._build_message_sequence(message) - if not user_sequence.components: + history_message = await self._build_history_message(message) + if history_message is None: continue - history_message = SessionBackedMessage.from_session_message( - message, - raw_message=user_sequence, - visible_text=visible_text, - source_kind="user", - ) self._insert_chat_history_message(history_message) self._trim_chat_history() - async def _build_message_sequence(self, message: SessionMessage) -> tuple[MessageSequence, str]: - message_sequence = MessageSequence([]) + async def _build_history_message(self, message: SessionMessage) -> Optional[LLMContextMessage]: + """根据真实消息构造对应的上下文消息。""" + + source_sequence = message.raw_message + visible_text = self._build_legacy_visible_text(message, source_sequence) planner_prefix = build_planner_user_prefix_from_session_message(message) + if contains_complex_message(source_sequence): + return ComplexSessionMessage.from_session_message( + message, + planner_prefix=planner_prefix, + visible_text=visible_text, + source_kind="user", + ) + + user_sequence = await self._build_message_sequence(message, planner_prefix=planner_prefix) + if not user_sequence.components: + return None + + return SessionBackedMessage.from_session_message( + message, + raw_message=user_sequence, + visible_text=visible_text, + source_kind="user", + ) + + async def _build_message_sequence( + self, + message: SessionMessage, + *, + planner_prefix: str, + ) -> MessageSequence: + message_sequence = MessageSequence([]) appended_component = False source_sequence = message.raw_message + planner_components = clone_message_sequence(source_sequence).components if global_config.maisaka.direct_image_input: await self._hydrate_visual_components(planner_components) @@ -252,16 +277,14 @@ class MaisakaReasoningEngine: message_sequence.components.append(component) appended_component = True - legacy_visible_text = self._build_legacy_visible_text(message, source_sequence) if not appended_component: if not message.processed_plain_text: await message.process() content = (message.processed_plain_text or "").strip() if content: message_sequence.text(planner_prefix + content) - legacy_visible_text = self._build_legacy_visible_text_from_text(message, content) - return message_sequence, legacy_visible_text + return message_sequence async def _hydrate_visual_components(self, planner_components: list[object]) -> None: """在 Maisaka 真正需要图片或表情时,按需回填二进制数据。""" @@ -291,12 +314,6 @@ class MaisakaReasoningEngine: legacy_sequence.components.append(component) return build_visible_text_from_sequence(legacy_sequence).strip() - def _build_legacy_visible_text_from_text(self, message: SessionMessage, content: str) -> str: - user_info = message.message_info.user_info - speaker_name = user_info.user_cardname or user_info.user_nickname or user_info.user_id - visible_message_id = None if message.is_notify else message.message_id - return format_speaker_content(speaker_name, content, message.timestamp, visible_message_id).strip() - def _insert_chat_history_message(self, message: LLMContextMessage) -> int: """将消息按处理顺序追加到聊天历史末尾。""" self._runtime._chat_history.append(message) @@ -628,6 +645,12 @@ class MaisakaReasoningEngine: return f"你查询了人物信息:{person_name}" return "你查询了一次人物信息。" + if invocation.tool_name == "view_complex_message": + target_message_id = str(invocation.arguments.get("msg_id") or "").strip() + if target_message_id: + return f"你查看了复杂消息 {target_message_id} 的完整内容。" + return "你查看了一条复杂消息的完整内容。" + brief_description = "" if tool_spec is not None: brief_description = tool_spec.brief_description.strip()