diff --git a/prompts/en-US/maidairy_context_summarize.prompt b/prompts/en-US/maidairy_context_summarize.prompt deleted file mode 100644 index 6ee4fb5d..00000000 --- a/prompts/en-US/maidairy_context_summarize.prompt +++ /dev/null @@ -1,12 +0,0 @@ -你是一个对话上下文总结模块。你的任务是对早期的对话内容进行简洁的总结,以便存入记忆系统。 - -总结要求: -1. 提取对话中的关键信息(人名、事件、时间、地点等) -2. 记录用户的态度、情绪和偏好 -3. 保留重要的对话内容和结论 -4. 总结要简洁明了,便于后续检索和理解 -5. 用第三人称客观叙述,不要包含「我记得」「之前说过」等指代词 - -输出格式: -- 2-5 句话的简洁总结 -- 直接输出总结内容,不要有前缀或格式标题 diff --git a/prompts/ja-JP/maidairy_context_summarize.prompt b/prompts/ja-JP/maidairy_context_summarize.prompt deleted file mode 100644 index 6ee4fb5d..00000000 --- a/prompts/ja-JP/maidairy_context_summarize.prompt +++ /dev/null @@ -1,12 +0,0 @@ -你是一个对话上下文总结模块。你的任务是对早期的对话内容进行简洁的总结,以便存入记忆系统。 - -总结要求: -1. 提取对话中的关键信息(人名、事件、时间、地点等) -2. 记录用户的态度、情绪和偏好 -3. 保留重要的对话内容和结论 -4. 总结要简洁明了,便于后续检索和理解 -5. 用第三人称客观叙述,不要包含「我记得」「之前说过」等指代词 - -输出格式: -- 2-5 句话的简洁总结 -- 直接输出总结内容,不要有前缀或格式标题 diff --git a/prompts/zh-CN/maidairy_chat.prompt b/prompts/zh-CN/maidairy_chat.prompt index 2418d045..043b6dc1 100644 --- a/prompts/zh-CN/maidairy_chat.prompt +++ b/prompts/zh-CN/maidairy_chat.prompt @@ -1,8 +1,8 @@ 你的任务是分析聊天和聊天中的互动情况。 -你需要关注 麦麦(AI) 与用户的对话来为选择正确的动作和行为提供建议 +你需要关注 {bot_name}(AI) 与不同用户的对话来为选择正确的动作和行为提供建议 【参考信息】 -麦麦的人设:{identity} +{bot_name}的人设:{identity} 【参考信息结束】 你需要根据提供的参考信息,当前场景和输出规则来进行分析 @@ -11,16 +11,17 @@ 你可以使用这些工具: -• wait(seconds) - 暂时停止对话,等待(seconds)秒,把话语权交给用户,等待对方新的发言。 -• stop() - 结束对话,不进行任何回复,直到对方有新消息。 -- `reply()`:当你判断现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。 -- `no_reply()`:当你判断现在不应该发言,应该继续内部思考时调用。这个工具不会做任何外部行为,只会继续下一轮循环。 +- wait(seconds) - 暂时停止对话,等待(seconds)秒,把话语权交给用户,等待对方新的发言。 +- stop() - 结束对话,不进行任何回复,直到对方有新消息。 +- reply():当你判断现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。 +- no_reply():当你判断现在不应该发言,应该继续内部思考时调用。这个工具不会做任何外部行为,只会继续下一轮循环。 {file_tools_section} 工具使用规则: 1.如果麦麦已经回复,但用户暂时没有新的回复,且没有新信息需要搜集,使用wait或者stop进行等待 2.如果用户有新发言,但是你评估用户还有后续发言尚未发送,可以适当等待让用户说完 -3.如果你想指导麦麦直接发言,可以不使用任何工具 +3.在特定情况下也可以连续回复,例如想要追问,或者补充自己先前的发言,可以不使用stop或者wait +4.如果你想指导麦麦直接发言,可以不使用任何工具 你的输出规则: 1. 默认直接输出你当前的最新分析,不要重复之前的分析内容。 diff --git a/prompts/zh-CN/maidairy_context_summarize.prompt b/prompts/zh-CN/maidairy_context_summarize.prompt deleted file mode 100644 index 6ee4fb5d..00000000 --- a/prompts/zh-CN/maidairy_context_summarize.prompt +++ /dev/null @@ -1,12 +0,0 @@ -你是一个对话上下文总结模块。你的任务是对早期的对话内容进行简洁的总结,以便存入记忆系统。 - -总结要求: -1. 提取对话中的关键信息(人名、事件、时间、地点等) -2. 记录用户的态度、情绪和偏好 -3. 保留重要的对话内容和结论 -4. 总结要简洁明了,便于后续检索和理解 -5. 用第三人称客观叙述,不要包含「我记得」「之前说过」等指代词 - -输出格式: -- 2-5 句话的简洁总结 -- 直接输出总结内容,不要有前缀或格式标题 diff --git a/src/config/config.py b/src/config/config.py index bee81efb..bce391c3 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -56,7 +56,7 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config" BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute() MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute() MMC_VERSION: str = "1.0.0" -CONFIG_VERSION: str = "8.1.2" +CONFIG_VERSION: str = "8.1.4" MODEL_CONFIG_VERSION: str = "1.12.0" logger = get_logger("config") diff --git a/src/config/official_configs.py b/src/config/official_configs.py index fde3f800..a7470fb3 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1618,6 +1618,24 @@ class MaiSakaConfig(ConfigBase): ) """是否在 CLI 中显示 analyze_timing 的 Prompt""" + show_thinking: bool = Field( + default=True, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "brain", + }, + ) + """鏄惁鍦?CLI 涓樉绀哄唴蹇冩€濊€冨拰瀹屾暣 Prompt""" + + user_name: str = Field( + default="用户", + json_schema_extra={ + "x-widget": "input", + "x-icon": "user", + }, + ) + """MaiSaka 涓敤鎴风殑鏄剧ず鍚嶇О""" + class PluginRuntimeConfig(ConfigBase): """插件运行时配置类""" diff --git a/src/maisaka/builtin_tools.py b/src/maisaka/builtin_tools.py index 080a0f79..0017f1fb 100644 --- a/src/maisaka/builtin_tools.py +++ b/src/maisaka/builtin_tools.py @@ -2,7 +2,7 @@ MaiSaka built-in tool definitions. """ -from typing import Any, Dict, List +from typing import List from src.llm_models.payload_content.tool_option import ToolOption, ToolParamType @@ -43,44 +43,6 @@ def create_builtin_tools() -> List[ToolOption]: return tools -def builtin_tools_as_dicts() -> List[Dict[str, Any]]: - """Return built-in tools as plain dictionaries.""" - return [ - { - "name": "wait", - "description": "Pause speaking and wait for the user to provide more input.", - "parameters": { - "type": "object", - "properties": { - "seconds": { - "type": "number", - "description": "How many seconds to wait before timing out.", - } - }, - "required": ["seconds"], - }, - }, - { - "name": "reply", - "description": "Generate and emit a visible reply based on the current thought.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - { - "name": "no_reply", - "description": "Do not emit a visible reply this round and continue thinking.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - { - "name": "stop", - "description": "Stop the current inner loop and return control to the outer chat flow.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - ] - - def get_builtin_tools() -> List[ToolOption]: """Return built-in tools.""" return create_builtin_tools() - - -BUILTIN_TOOLS_DICTS = builtin_tools_as_dicts() diff --git a/src/maisaka/cli.py b/src/maisaka/cli.py index bb5ae2a8..ba4c85b1 100644 --- a/src/maisaka/cli.py +++ b/src/maisaka/cli.py @@ -1,623 +1,448 @@ -""" -MaiSaka - CLI 交互界面与对话引擎 -BufferCLI 整合主循环、对话引擎、子代理管理。 -""" - -import os -import asyncio -from datetime import datetime -from typing import Optional - -from rich.panel import Panel -from rich.markdown import Markdown -from rich.text import Text -from rich import box - -from .config import ( - console, - ENABLE_EMOTION_MODULE, - ENABLE_COGNITION_MODULE, - ENABLE_TIMING_MODULE, - ENABLE_KNOWLEDGE_MODULE, - ENABLE_MCP, -) -from .input_reader import InputReader -from .knowledge import retrieve_relevant_knowledge, store_knowledge_from_context -from .knowledge_store import get_knowledge_store -from .llm_service import MaiSakaLLMService, build_message, remove_last_perception -from .mcp_client import MCPManager -from .timing import build_timing_info -from .tool_handlers import ( - ToolHandlerContext, - handle_list_files, - handle_mcp_tool, - handle_read_file, - handle_stop, - handle_unknown_tool, - handle_wait, - handle_write_file, -) - - -class BufferCLI: - """命令行交互界面""" - - def __init__(self): - self.llm_service: Optional[MaiSakaLLMService] = None - self._reader = InputReader() - self._chat_history: Optional[list] = None # 持久化的对话历史 - self._knowledge_store = get_knowledge_store() # 了解存储实例 - - # 显示了解存储统计 - knowledge_stats = self._knowledge_store.get_stats() - if knowledge_stats["total_items"] > 0: - console.print(f"[success][OK] 了解系统: {knowledge_stats['total_items']}条特征信息[/success]") - else: - console.print("[muted][OK] 了解系统: 已初始化 (暂无数据)[/muted]") - # Timing 模块时间戳跟踪 - self._chat_start_time: Optional[datetime] = None - self._last_user_input_time: Optional[datetime] = None - self._last_assistant_response_time: Optional[datetime] = None - self._user_input_times: list[datetime] = [] # 所有用户输入时间戳 - # MCP 管理器(异步初始化,在 run() 中完成) - self._mcp_manager: Optional[MCPManager] = None - self._init_llm() - - def _init_llm(self): - """初始化 LLM 服务 - 使用主项目配置系统""" - thinking_env = os.getenv("ENABLE_THINKING", "").strip().lower() - enable_thinking: Optional[bool] = True if thinking_env == "true" else False if thinking_env == "false" else None - - # MaiSakaLLMService 现在使用主项目的配置系统 - # 参数仅为兼容性保留,实际从 config_manager 读取配置 - self.llm_service = MaiSakaLLMService( - api_key="", - base_url=None, - model="", - enable_thinking=enable_thinking, - ) - - # 获取实际使用的模型名称 - model_name = self.llm_service._model_name - console.print(f"[success][OK] LLM 服务已初始化[/success] [muted](模型: {model_name})[/muted]") - - def _build_tool_context(self) -> ToolHandlerContext: - """构建工具处理器所需的上下文。""" - ctx = ToolHandlerContext( - llm_service=self.llm_service, - reader=self._reader, - user_input_times=self._user_input_times, - ) - ctx.last_user_input_time = self._last_user_input_time - return ctx - - def _show_banner(self): - """显示欢迎横幅""" - banner = Text() - banner.append("MaiSaka", style="bold cyan") - banner.append(" v2.0\n", style="muted") - banner.append("直接输入文字开始对话 | Ctrl+C 退出", style="muted") - - console.print(Panel(banner, box=box.DOUBLE_EDGE, border_style="cyan", padding=(1, 2))) - console.print() - - # ──────── 上下文管理 ──────── - - def _get_safe_removal_indices(self, chat_history: list, count: int) -> list[int]: - """ - 获取可以安全删除的消息索引。 - - 确保 tool_calls 和 tool 响应消息成对删除,避免破坏 API 要求的配对关系。 - 只删除完整的消息块(user/assistant + 可选的 tool 响应序列)。 - - 保留最后 3 条非 tool 消息,避免删除可能还在处理中的内容。 - - Returns: - 可以安全删除的消息索引列表(从后往前排序) - """ - indices_to_remove = [] - removed_count = 0 - i = 0 - - # 计算保留的消息数量(最后 3 条非 tool 消息) - safe_zone_count = 3 - non_tool_count = 0 - for msg in reversed(chat_history): - if msg.get("role") != "tool": - non_tool_count += 1 - if non_tool_count >= safe_zone_count: - break - - # 只处理前 (len - non_tool_count) 条消息 - max_process_index = len(chat_history) - non_tool_count - - while i < max_process_index and removed_count < count: - msg = chat_history[i] - role = msg.get("role", "") - - # 跳过 role=tool 的消息(它们会被对应的 assistant 消息一起处理) - if role == "tool": - i += 1 - continue - - # 检查这是否是一个带 tool_calls 的 assistant 消息 - if role == "assistant" and "tool_calls" in msg: - # 收集这个 assistant 消息及其后续的 tool 响应消息 - block_indices = [i] - j = i + 1 - while j < len(chat_history): - next_msg = chat_history[j] - if next_msg.get("role") == "tool": - block_indices.append(j) - j += 1 - else: - break - indices_to_remove.extend(block_indices) - removed_count += 1 - i = j - elif role in ["user", "assistant"]: - # 普通消息,可以直接删除 - indices_to_remove.append(i) - removed_count += 1 - i += 1 - else: - i += 1 - - # 从后往前排序,避免索引问题 - return sorted(indices_to_remove, reverse=True) - - async def _manage_context_length(self, chat_history: list) -> None: - """ - 上下文管理:当对话历史过长时进行压缩。 - - 当达到 20 条上下文时: - 1. 移除最早 10 条上下文 - 2. 对这 10 条内容进行 LLM 总结 - 3. 将总结后的内容存入记忆 - """ - CONTEXT_LIMIT = 20 - COMPRESS_COUNT = 10 - - # 计算实际消息数量(排除 role=tool 的工具返回消息) - actual_messages = [m for m in chat_history if m.get("role") != "tool"] - - if len(actual_messages) >= CONTEXT_LIMIT: - # 获取安全删除的索引 - indices_to_remove = self._get_safe_removal_indices(chat_history, COMPRESS_COUNT) - - if indices_to_remove: - # 收集要总结的消息(在删除前) - to_compress = [] - for i in sorted(indices_to_remove): - if 0 <= i < len(chat_history): - to_compress.append(chat_history[i]) - - if to_compress: - # 总结上下文 - try: - console.print("[accent]🧠 上下文过长,正在压缩并存入记忆...[/accent]") - summary = await self.llm_service.summarize_context(to_compress) - - # 存储了解信息(如果启用) - if ENABLE_KNOWLEDGE_MODULE: - try: - knowledge_count = await store_knowledge_from_context( - self.llm_service, - to_compress, - store_result_callback=lambda cat_id, cat_name, content: console.print( - f"[muted] [OK] 存储了解信息: {cat_name}[/muted]" - ), - ) - if knowledge_count > 0: - console.print(f"[success][OK] 了解模块: 存储{knowledge_count}条特征信息[/success]") - except Exception as e: - console.print(f"[warning]了解存储失败: {e}[/warning]") - if summary: - # 存入记忆 - # 显示压缩结果 - console.print( - Panel( - Markdown(summary), - title="📝 上下文已压缩", - border_style="green", - padding=(0, 1), - style="dim", - ) - ) - except Exception as e: - console.print(f"[warning]上下文总结失败: {e}[/warning]") - - # 从后往前删除 - for i in indices_to_remove: - if 0 <= i < len(chat_history): - chat_history.pop(i) - - # 清理"孤儿" tool 消息(没有对应 tool_calls 的 tool 消息) - valid_tool_call_ids = set() - for msg in chat_history: - if msg.get("role") == "assistant" and "tool_calls" in msg: - for tool_call in msg["tool_calls"]: - valid_tool_call_ids.add(tool_call.get("id", "")) - - # 删除无效的 tool 消息(从后往前) - i = len(chat_history) - 1 - while i >= 0: - msg = chat_history[i] - if msg.get("role") == "tool": - tool_call_id = msg.get("tool_call_id", "") - if tool_call_id not in valid_tool_call_ids: - chat_history.pop(i) - i -= 1 - - # ──────── LLM 循环架构 ──────── - - async def _start_chat(self, user_text: str): - """接收用户输入并启动/继续 LLM 对话循环""" - if not self.llm_service: - console.print("[warning]LLM 服务未初始化,跳过对话。[/warning]") - return - - now = datetime.now() - self._last_user_input_time = now - self._user_input_times.append(now) - - if self._chat_history is None: - # 首次对话:初始化上下文 - self._chat_start_time = now - self._last_assistant_response_time = None - self._chat_history = self.llm_service.build_chat_context(user_text) - else: - # 后续对话:追加用户消息到已有上下文 - self._chat_history.append(build_message(role="user", content=user_text)) - - await self._run_llm_loop(self._chat_history) - - async def _run_llm_loop(self, chat_history: list): - """ - LLM 循环架构核心。 - - LLM 持续运行,每步可能输出文本(内心思考)和/或调用工具: - - say(text): 对用户说话 - - wait(seconds): 暂停等待用户输入,超时或收到输入后继续 - - stop(): 结束循环,进入待机,直到用户下次输入 - - 不调用工具: 继续下一轮思考/生成 - - 每轮流程: - 1. 上下文管理:达到上限时自动压缩 - 2. 情商 + Timing + 了解模块(并行):分析用户情绪、对话时间节奏、检索用户特征 - *注:如果上次没有调用工具,跳过模块分析 - 3. 调用主 LLM:基于完整上下文生成响应 - """ - consecutive_errors = 0 - last_had_tool_calls = True # 第一次循环总是执行模块分析 - - while True: - # ── 上下文管理 ── - await self._manage_context_length(chat_history) - - # ── 情商模块 + Timing 模块 + 了解模块(并行) ── - # 只有上次调用了工具才重新分析(首次循环除外) - if last_had_tool_calls: - timing_info = build_timing_info( - self._chat_start_time, - self._last_user_input_time, - self._last_assistant_response_time, - self._user_input_times, - ) - - # 根据配置决定要执行的模块 - tasks = [] - status_text_parts = [] - - if ENABLE_EMOTION_MODULE: - tasks.append(("eq", self.llm_service.analyze_emotion(chat_history))) - status_text_parts.append("🎭") - if ENABLE_COGNITION_MODULE: - tasks.append(("cognition", self.llm_service.analyze_cognition(chat_history))) - status_text_parts.append("🧩") - if ENABLE_TIMING_MODULE: - tasks.append(("timing", self.llm_service.analyze_timing(chat_history, timing_info))) - status_text_parts.append("⏱️🪞") - if ENABLE_KNOWLEDGE_MODULE: - tasks.append(("knowledge", retrieve_relevant_knowledge(self.llm_service, chat_history))) - status_text_parts.append("👤") - - with console.status( - f"[info]{' '.join(status_text_parts)} {' + '.join(status_text_parts)} 模块并行分析中...[/info]", - spinner="dots", - ): - results = await asyncio.gather(*[task for _, task in tasks], return_exceptions=True) - - # 解析结果 - eq_result, cognition_result, timing_result, knowledge_result = None, None, None, None - result_idx = 0 - if ENABLE_EMOTION_MODULE: - eq_result = results[result_idx] - result_idx += 1 - if ENABLE_COGNITION_MODULE: - cognition_result = results[result_idx] - result_idx += 1 - if ENABLE_TIMING_MODULE: - timing_result = results[result_idx] - result_idx += 1 - if ENABLE_KNOWLEDGE_MODULE: - knowledge_result = results[result_idx] - result_idx += 1 - - # 处理情商模块结果 - eq_analysis = "" - if ENABLE_EMOTION_MODULE: - if isinstance(eq_result, Exception): - console.print(f"[warning]情商模块分析失败: {eq_result}[/warning]") - elif eq_result: - eq_analysis = eq_result - console.print( - Panel( - Markdown(eq_analysis), - title="🎭 情绪感知", - border_style="bright_yellow", - padding=(0, 1), - style="dim", - ) - ) - - # 处理认知模块结果 - cognition_analysis = "" - if ENABLE_COGNITION_MODULE: - if isinstance(cognition_result, Exception): - console.print(f"[warning]认知模块分析失败: {cognition_result}[/warning]") - elif cognition_result: - cognition_analysis = cognition_result - console.print( - Panel( - Markdown(cognition_analysis), - title="🧩 意图感知", - border_style="bright_cyan", - padding=(0, 1), - style="dim", - ) - ) - - # 处理 Timing 模块结果(含自我反思功能) - timing_analysis = "" - if ENABLE_TIMING_MODULE: - if isinstance(timing_result, Exception): - console.print(f"[warning]Timing 模块分析失败: {timing_result}[/warning]") - elif timing_result: - timing_analysis = timing_result - console.print( - Panel( - Markdown(timing_analysis), - title="⏱️🪞 时间感知 & 自我反思", - border_style="bright_blue", - padding=(0, 1), - style="dim", - ) - ) - - # 处理了解模块结果 - knowledge_analysis = "" - if ENABLE_KNOWLEDGE_MODULE: - if isinstance(knowledge_result, Exception): - console.print(f"[warning]了解模块分析失败: {knowledge_result}[/warning]") - elif knowledge_result: - knowledge_analysis = knowledge_result - console.print( - Panel( - Markdown(knowledge_analysis), - title="👤 用户特征", - border_style="bright_magenta", - padding=(0, 1), - style="dim", - ) - ) - - # 注入感知信息(作为 assistant 的感知消息) - # 移除上一条感知消息(如果存在) - remove_last_perception(chat_history) - - # 构建感知内容 - perception_parts = [] - if eq_analysis: - perception_parts.append(f"情绪感知\n{eq_analysis}") - if cognition_analysis: - perception_parts.append(f"意图感知\n{cognition_analysis}") - if timing_analysis: - perception_parts.append(f"时间感知 & 自我反思\n{timing_analysis}") - if knowledge_analysis: - perception_parts.append(f"用户特征\n{knowledge_analysis}") - - if perception_parts: - # 添加感知消息(AI 的感知能力结果) - chat_history.append( - build_message( - role="assistant", - content="\n\n".join(perception_parts), - msg_type="perception", - ) - ) - else: - # 上次没有调用工具,跳过模块分析 - console.print("[muted]ℹ️ 上次未调用工具,跳过模块分析[/muted]") - - # ── 调用 LLM ── - with console.status("[info]💬 AI 正在思考...[/info]", spinner="dots"): - try: - response = await self.llm_service.chat_loop_step(chat_history) - consecutive_errors = 0 - except Exception as e: - consecutive_errors += 1 - console.print(f"[error]LLM 调用出错: {e}[/error]") - if consecutive_errors >= 3: - console.print("[error]连续出错,退出对话[/error]\n") - break - continue - - # 将 assistant 消息追加到历史 - chat_history.append(response.raw_message) - self._last_assistant_response_time = datetime.now() - - - # 显示内心思考(content 部分,淡色呈现) - if response.content: - console.print( - Panel( - Markdown(response.content), - title="💭 内心思考", - border_style="dim", - padding=(1, 2), - style="dim", - ) - ) - - # ── 处理工具调用 ── +""" +MaiSaka CLI and conversation loop. +""" + +from datetime import datetime +from typing import Optional + +import asyncio +import os + +from rich import box +from rich.markdown import Markdown +from rich.panel import Panel +from rich.text import Text + +from src.common.data_models.mai_message_data_model import MaiMessage +from src.config.config import global_config + +from .config import ( + ENABLE_COGNITION_MODULE, + ENABLE_EMOTION_MODULE, + ENABLE_KNOWLEDGE_MODULE, + ENABLE_MCP, + ENABLE_TIMING_MODULE, + SHOW_THINKING, + USER_NAME, + console, +) +from .input_reader import InputReader +from .knowledge import retrieve_relevant_knowledge +from .knowledge_store import get_knowledge_store +from .llm_service import MaiSakaLLMService, build_message, remove_last_perception +from .message_adapter import format_speaker_content +from .mcp_client import MCPManager +from .timing import build_timing_info +from .tool_handlers import ( + ToolHandlerContext, + handle_list_files, + handle_mcp_tool, + handle_read_file, + handle_stop, + handle_unknown_tool, + handle_wait, + handle_write_file, +) + + +class BufferCLI: + """Command line interface for Maisaka.""" + + def __init__(self): + self.llm_service: Optional[MaiSakaLLMService] = None + self._reader = InputReader() + self._chat_history: Optional[list[MaiMessage]] = None + self._knowledge_store = get_knowledge_store() + + knowledge_stats = self._knowledge_store.get_stats() + if knowledge_stats["total_items"] > 0: + console.print(f"[success][OK] Knowledge store: {knowledge_stats['total_items']} item(s)[/success]") + else: + console.print("[muted][OK] Knowledge store: initialized with no data[/muted]") + + self._chat_start_time: Optional[datetime] = None + self._last_user_input_time: Optional[datetime] = None + self._last_assistant_response_time: Optional[datetime] = None + self._user_input_times: list[datetime] = [] + self._mcp_manager: Optional[MCPManager] = None + self._init_llm() + + def _init_llm(self): + """Initialize the LLM service from the main project config.""" + thinking_env = os.getenv("ENABLE_THINKING", "").strip().lower() + enable_thinking: Optional[bool] = True if thinking_env == "true" else False if thinking_env == "false" else None + + self.llm_service = MaiSakaLLMService( + api_key="", + base_url=None, + model="", + enable_thinking=enable_thinking, + ) + + model_name = self.llm_service._model_name + console.print(f"[success][OK] LLM service initialized[/success] [muted](model: {model_name})[/muted]") + + def _build_tool_context(self) -> ToolHandlerContext: + """Build the shared tool handler context.""" + ctx = ToolHandlerContext( + llm_service=self.llm_service, + reader=self._reader, + user_input_times=self._user_input_times, + ) + ctx.last_user_input_time = self._last_user_input_time + return ctx + + def _show_banner(self): + """Render the startup banner.""" + banner = Text() + banner.append("MaiSaka", style="bold cyan") + banner.append(" v2.0\n", style="muted") + banner.append("Type to chat | Ctrl+C to exit", style="muted") + + console.print(Panel(banner, box=box.DOUBLE_EDGE, border_style="cyan", padding=(1, 2))) + console.print() + + async def _start_chat(self, user_text: str): + """Append user input and continue the inner loop.""" + if not self.llm_service: + console.print("[warning]LLM service is not initialized; skipping chat.[/warning]") + return + + now = datetime.now() + self._last_user_input_time = now + self._user_input_times.append(now) + + if self._chat_history is None: + self._chat_start_time = now + self._last_assistant_response_time = None + self._chat_history = self.llm_service.build_chat_context(user_text) + else: + self._chat_history.append(build_message(role="user", content=format_speaker_content(USER_NAME, user_text))) + + await self._run_llm_loop(self._chat_history) + + async def _run_llm_loop(self, chat_history: list[MaiMessage]): + """ + Main inner loop for the Maisaka planner. + + Each round may produce internal thoughts and optionally call tools: + - reply(): generate a visible reply for the current round + - no_reply(): skip visible output and continue the loop + - wait(seconds): wait for new user input + - stop(): stop the current inner loop and return to idle + + Per round: + 1. Run enabled analysis modules in parallel when the previous round used tools. + 2. Call the planner model with the current history. + 3. Append the assistant thought and execute any requested tools. + """ + consecutive_errors = 0 + last_had_tool_calls = True + + while True: + if last_had_tool_calls: + timing_info = build_timing_info( + self._chat_start_time, + self._last_user_input_time, + self._last_assistant_response_time, + self._user_input_times, + ) + + tasks = [] + status_text_parts = [] + + if ENABLE_EMOTION_MODULE: + tasks.append(("eq", self.llm_service.analyze_emotion(chat_history))) + status_text_parts.append("emotion") + if ENABLE_COGNITION_MODULE: + tasks.append(("cognition", self.llm_service.analyze_cognition(chat_history))) + status_text_parts.append("cognition") + if ENABLE_TIMING_MODULE: + tasks.append(("timing", self.llm_service.analyze_timing(chat_history, timing_info))) + status_text_parts.append("timing") + if ENABLE_KNOWLEDGE_MODULE: + tasks.append(("knowledge", retrieve_relevant_knowledge(self.llm_service, chat_history))) + status_text_parts.append("knowledge") + + with console.status( + f"[info]{' + '.join(status_text_parts)} analyzing...[/info]", + spinner="dots", + ): + results = await asyncio.gather(*[task for _, task in tasks], return_exceptions=True) + + eq_result, cognition_result, timing_result, knowledge_result = None, None, None, None + result_idx = 0 + if ENABLE_EMOTION_MODULE: + eq_result = results[result_idx] + result_idx += 1 + if ENABLE_COGNITION_MODULE: + cognition_result = results[result_idx] + result_idx += 1 + if ENABLE_TIMING_MODULE: + timing_result = results[result_idx] + result_idx += 1 + if ENABLE_KNOWLEDGE_MODULE: + knowledge_result = results[result_idx] + result_idx += 1 + + eq_analysis = "" + if ENABLE_EMOTION_MODULE: + if isinstance(eq_result, Exception): + console.print(f"[warning]Emotion analysis failed: {eq_result}[/warning]") + elif eq_result: + eq_analysis = eq_result + if SHOW_THINKING: + console.print( + Panel( + Markdown(eq_analysis), + title="Emotion", + border_style="bright_yellow", + padding=(0, 1), + style="dim", + ) + ) + + cognition_analysis = "" + if ENABLE_COGNITION_MODULE: + if isinstance(cognition_result, Exception): + console.print(f"[warning]Cognition analysis failed: {cognition_result}[/warning]") + elif cognition_result: + cognition_analysis = cognition_result + if SHOW_THINKING: + console.print( + Panel( + Markdown(cognition_analysis), + title="Cognition", + border_style="bright_cyan", + padding=(0, 1), + style="dim", + ) + ) + + timing_analysis = "" + if ENABLE_TIMING_MODULE: + if isinstance(timing_result, Exception): + console.print(f"[warning]Timing analysis failed: {timing_result}[/warning]") + elif timing_result: + timing_analysis = timing_result + if SHOW_THINKING: + console.print( + Panel( + Markdown(timing_analysis), + title="Timing", + border_style="bright_blue", + padding=(0, 1), + style="dim", + ) + ) + + knowledge_analysis = "" + if ENABLE_KNOWLEDGE_MODULE: + if isinstance(knowledge_result, Exception): + console.print(f"[warning]Knowledge analysis failed: {knowledge_result}[/warning]") + elif knowledge_result: + knowledge_analysis = knowledge_result + if SHOW_THINKING: + console.print( + Panel( + Markdown(knowledge_analysis), + title="Knowledge", + border_style="bright_magenta", + padding=(0, 1), + style="dim", + ) + ) + + remove_last_perception(chat_history) + + perception_parts = [] + if eq_analysis: + perception_parts.append(f"Emotion\n{eq_analysis}") + if cognition_analysis: + perception_parts.append(f"Cognition\n{cognition_analysis}") + if timing_analysis: + perception_parts.append(f"Timing\n{timing_analysis}") + if knowledge_analysis: + perception_parts.append(f"Knowledge\n{knowledge_analysis}") + + if perception_parts: + chat_history.append( + build_message( + role="assistant", + content="\n\n".join(perception_parts), + message_kind="perception", + source="assistant", + ) + ) + else: + if SHOW_THINKING: + console.print("[muted]Skipping module analysis because the last round used no tools.[/muted]") + + with console.status("[info]AI is thinking...[/info]", spinner="dots"): + try: + response = await self.llm_service.chat_loop_step(chat_history) + consecutive_errors = 0 + except Exception as exc: + consecutive_errors += 1 + console.print(f"[error]LLM call failed: {exc}[/error]") + if consecutive_errors >= 3: + console.print("[error]Too many consecutive errors. Exiting chat.[/error]\n") + break + continue + + chat_history.append(response.raw_message) + self._last_assistant_response_time = datetime.now() + + if SHOW_THINKING and response.content: + console.print( + Panel( + Markdown(response.content), + title="Thought", + border_style="dim", + padding=(1, 2), + style="dim", + ) + ) + if response.content and not response.tool_calls: last_had_tool_calls = False continue - - if response.tool_calls: - should_stop = False - ctx = self._build_tool_context() - - for tc in response.tool_calls: - if tc.name == "stop": + + if response.tool_calls: + should_stop = False + ctx = self._build_tool_context() + + for tc in response.tool_calls: + if tc.func_name == "stop": await handle_stop(tc, chat_history) should_stop = True - elif tc.name == "reply": + elif tc.func_name == "reply": reply = await self._generate_visible_reply(chat_history, response.content) chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": "Visible reply generated and recorded.", - } + build_message( + role="tool", + content="Visible reply generated and recorded.", + source="tool", + tool_call_id=tc.call_id, + ) ) chat_history.append( build_message( role="user", - content=f"\u3010\u9ea6\u9ea6\u7684\u53d1\u8a00\u3011{reply}", + content=format_speaker_content(global_config.bot.nickname.strip() or "MaiSaka", reply), + source="guided_reply", ) ) - elif tc.name == "no_reply": - console.print("[muted]No visible reply this round.[/muted]") + elif tc.func_name == "no_reply": + if SHOW_THINKING: + console.print("[muted]No visible reply this round.[/muted]") chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": "No visible reply was sent for this round.", - } + build_message( + role="tool", + content="No visible reply was sent for this round.", + source="tool", + tool_call_id=tc.call_id, + ) ) - elif tc.name == "wait": + elif tc.func_name == "wait": tool_result = await handle_wait(tc, chat_history, ctx) - # 同步回 timing 时间戳 - if ctx.last_user_input_time != self._last_user_input_time: - self._last_user_input_time = ctx.last_user_input_time - if tool_result.startswith("[[QUIT]]"): - should_stop = True - - elif tc.name == "write_file": - await handle_write_file(tc, chat_history) - - elif tc.name == "read_file": - await handle_read_file(tc, chat_history) - - elif tc.name == "list_files": - await handle_list_files(tc, chat_history) - - elif self._mcp_manager and self._mcp_manager.is_mcp_tool(tc.name): - await handle_mcp_tool(tc, chat_history, self._mcp_manager) - - else: - await handle_unknown_tool(tc, chat_history) - - if should_stop: - console.print("[muted]对话暂停,等待新输入...[/muted]\n") - break - - # 调用了工具,下次循环需要重新分析模块 - last_had_tool_calls = True - else: - # LLM 未调用任何工具 → 继续下一轮思考 - # (不做任何额外操作,直接回到循环顶部再次调用 LLM) - # 标记上次没有调用工具,下次循环跳过模块分析 - last_had_tool_calls = False - continue - - # ──────── 主循环 ──────── - - async def _init_mcp(self): - """初始化 MCP 服务器连接,发现并注册外部工具。""" - config_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "mcp_config.json", - ) - self._mcp_manager = await MCPManager.from_config(config_path) - - if self._mcp_manager and self.llm_service: - mcp_tools = self._mcp_manager.get_openai_tools() - if mcp_tools: - self.llm_service.set_extra_tools(mcp_tools) - summary = self._mcp_manager.get_tool_summary() - console.print( - Panel( - f"已加载 {len(mcp_tools)} 个 MCP 工具:\n{summary}", - title="🔌 MCP 工具", - border_style="green", - padding=(0, 1), - ) - ) - - async def _generate_visible_reply(self, chat_history: list, latest_thought: str) -> str: + if ctx.last_user_input_time != self._last_user_input_time: + self._last_user_input_time = ctx.last_user_input_time + if tool_result.startswith("[[QUIT]]"): + should_stop = True + + elif tc.func_name == "write_file": + await handle_write_file(tc, chat_history) + + elif tc.func_name == "read_file": + await handle_read_file(tc, chat_history) + + elif tc.func_name == "list_files": + await handle_list_files(tc, chat_history) + + elif self._mcp_manager and self._mcp_manager.is_mcp_tool(tc.func_name): + await handle_mcp_tool(tc, chat_history, self._mcp_manager) + + else: + await handle_unknown_tool(tc, chat_history) + + if should_stop: + console.print("[muted]Conversation paused. Waiting for new input...[/muted]\n") + break + + last_had_tool_calls = True + else: + last_had_tool_calls = False + continue + + async def _init_mcp(self): + """Initialize MCP servers and register exposed tools.""" + config_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "mcp_config.json", + ) + self._mcp_manager = await MCPManager.from_config(config_path) + + if self._mcp_manager and self.llm_service: + mcp_tools = self._mcp_manager.get_openai_tools() + if mcp_tools: + self.llm_service.set_extra_tools(mcp_tools) + summary = self._mcp_manager.get_tool_summary() + console.print( + Panel( + f"Loaded {len(mcp_tools)} MCP tool(s):\n{summary}", + title="MCP Tools", + border_style="green", + padding=(0, 1), + ) + ) + + async def _generate_visible_reply(self, chat_history: list[MaiMessage], latest_thought: str) -> str: """Generate and emit a visible reply based on the latest thought.""" if not self.llm_service or not latest_thought: return "" with console.status("[info]Generating visible reply...[/info]", spinner="dots"): reply = await self.llm_service.generate_reply(latest_thought, chat_history) - - console.print( - Panel( - Markdown(reply), - title="MaiSaka", - border_style="magenta", - padding=(1, 2), - ) - ) + + console.print( + Panel( + Markdown(reply), + title="MaiSaka", + border_style="magenta", + padding=(1, 2), + ) + ) + return reply - - async def run(self): - """主循环:直接输入文本即可对话""" - # 根据配置决定是否初始化 MCP 服务器 - if ENABLE_MCP: - await self._init_mcp() - else: - console.print("[muted]🔌 MCP 已禁用 (ENABLE_MCP=false)[/muted]") - - # 启动异步输入读取器 - self._reader.start(asyncio.get_event_loop()) - - self._show_banner() - - try: - while True: - console.print("[bold cyan]> [/bold cyan]", end="") - raw_input = await self._reader.get_line() - - if raw_input is None: # EOF - console.print("\n[muted]再见![/muted]") - break - - raw_input = raw_input.strip() - if not raw_input: - continue - - await self._start_chat(raw_input) - finally: - if self._mcp_manager: - await self._mcp_manager.close() - - - + + async def run(self): + """Main interactive loop.""" + if ENABLE_MCP: + await self._init_mcp() + else: + console.print("[muted]MCP is disabled (ENABLE_MCP=false)[/muted]") + + self._reader.start(asyncio.get_event_loop()) + self._show_banner() + + try: + while True: + console.print("[bold cyan]> [/bold cyan]", end="") + raw_input = await self._reader.get_line() + + if raw_input is None: + console.print("\n[muted]Goodbye![/muted]") + break + + raw_input = raw_input.strip() + if not raw_input: + continue + + await self._start_chat(raw_input) + finally: + if self._mcp_manager: + await self._mcp_manager.close() diff --git a/src/maisaka/config.py b/src/maisaka/config.py index c34247f6..b454ddfd 100644 --- a/src/maisaka/config.py +++ b/src/maisaka/config.py @@ -27,6 +27,8 @@ ENABLE_READ_FILE = global_config.maisaka.enable_read_file ENABLE_LIST_FILES = global_config.maisaka.enable_list_files SHOW_ANALYZE_COGNITION_PROMPT = global_config.maisaka.show_analyze_cognition_prompt SHOW_ANALYZE_TIMING_PROMPT = global_config.maisaka.show_analyze_timing_prompt +SHOW_THINKING = global_config.maisaka.show_thinking +USER_NAME = global_config.maisaka.user_name.strip() or "用户" # ──────────────────── Rich 主题 & Console ──────────────────── diff --git a/src/maisaka/emotion.py b/src/maisaka/emotion.py index 4c0e85ae..d2a4f657 100644 --- a/src/maisaka/emotion.py +++ b/src/maisaka/emotion.py @@ -8,8 +8,13 @@ MaiSaka - Emotion 模块 from typing import List, Optional +from src.common.data_models.mai_message_data_model import MaiMessage -def extract_user_messages(chat_history: List[dict], limit: Optional[int] = None) -> List[dict]: +from .config import USER_NAME +from .message_adapter import get_message_role, get_message_text + + +def extract_user_messages(chat_history: List[MaiMessage], limit: Optional[int] = None) -> List[MaiMessage]: """ 从对话历史中提取用户消息。 @@ -20,13 +25,13 @@ def extract_user_messages(chat_history: List[dict], limit: Optional[int] = None) Returns: 只包含用户消息的列表 """ - user_messages = [msg for msg in chat_history if msg.get("role") == "user"] + user_messages = [msg for msg in chat_history if get_message_role(msg) == "user"] if limit and len(user_messages) > limit: return user_messages[-limit:] return user_messages -def build_emotion_context(chat_history: List[dict]) -> str: +def build_emotion_context(chat_history: List[MaiMessage]) -> str: """ 构建用于情绪分析的对话上下文文本。 @@ -41,11 +46,11 @@ def build_emotion_context(chat_history: List[dict]) -> str: context_parts = [] for msg in recent_messages: - role = msg.get("role", "") - content = msg.get("content", "") + role = get_message_role(msg) + content = get_message_text(msg) if role == "user": - context_parts.append(f"用户: {content}") + context_parts.append(f"{USER_NAME}: {content}") elif role == "assistant": # 只显示 assistant 的实际发言,跳过感知信息 if "【AI 感知】" not in content: diff --git a/src/maisaka/knowledge.py b/src/maisaka/knowledge.py index 760dafbd..f56fbdc5 100644 --- a/src/maisaka/knowledge.py +++ b/src/maisaka/knowledge.py @@ -1,171 +1,58 @@ """ -MaiSaka - 了解模块 -负责从对话中提取和存储用户个人特征信息。 +MaiSaka knowledge retrieval helpers. """ from typing import List + +from src.common.data_models.mai_message_data_model import MaiMessage + from .knowledge_store import KNOWLEDGE_CATEGORIES, get_knowledge_store - -def build_knowledge_summary() -> str: - """ - 构建了解分类摘要,用于 LLM 请求。 - - Returns: - 格式化的分类列表文本 - """ - store = get_knowledge_store() - return store.get_categories_summary() +NO_RESULT_KEYWORDS = [ + "\u65e0", + "\u6ca1\u6709", + "\u4e0d\u9002\u7528", + "\u65e0\u9700", + "\u65e0\u76f8\u5173", +] def extract_category_ids_from_result(result: str) -> List[str]: - """ - 从 LLM 返回结果中提取分类编号。 - - Args: - result: LLM 返回的结果文本 - - Returns: - 分类编号列表 - """ + """Extract valid category ids from an LLM result string.""" if not result: return [] - # 检查是否表示"无相关内容" - if any(keyword in result for keyword in ["无", "没有", "不适用", "无需", "无相关"]): + normalized = result.strip() + if not normalized: return [] - # 解析编号(支持逗号分隔、空格分隔、换行分隔) - category_ids = [] - for part in result.replace(",", " ").replace(",", " ").replace("\n", " ").split(): - part = part.strip() - if part in KNOWLEDGE_CATEGORIES: - category_ids.append(part) + lowered = normalized.lower() + if any(keyword in lowered for keyword in ["none", "no relevant", "no_need", "no need"]): + return [] + if any(keyword in normalized for keyword in NO_RESULT_KEYWORDS): + return [] + + category_ids: List[str] = [] + for part in normalized.replace(",", " ").replace("\uff0c", " ").replace("\n", " ").split(): + candidate = part.strip() + if candidate in KNOWLEDGE_CATEGORIES and candidate not in category_ids: + category_ids.append(candidate) return category_ids -def format_context_for_memory(context_messages: List[dict]) -> str: - """ - 格式化上下文消息为文本,用于记忆分析。 - - Args: - context_messages: 上下文消息列表 - - Returns: - 格式化后的文本 - """ - parts = [] - for msg in context_messages: - role = msg.get("role", "") - content = msg.get("content", "") - - if role == "user": - parts.append(f"用户: {content}") - elif role == "assistant": - # 跳过感知消息 - if "【AI 感知】" not in content: - parts.append(f"助手: {content}") - - return "\n".join(parts) - - -async def store_knowledge_from_context( - llm_service, - context_messages: List[dict], - store_result_callback=None, -) -> int: - """ - 记忆部分:从上下文中提取并存储了解信息。 - - 在上下文裁切时触发: - 1. 请求 LLM 分析聊天内容涉及哪些分类 - 2. 为每个分类创建 subAgent 提取相关内容 - 3. 存入了解列表 - - Args: - llm_service: LLM 服务实例 - context_messages: 需要分析的上下文消息 - store_result_callback: 存储结果回调函数 - - Returns: - 成功存储的了解信息数量 - """ - store = get_knowledge_store() - context_text = format_context_for_memory(context_messages) - categories_summary = build_knowledge_summary() - - if not context_text: - return 0 - - try: - # 第一步:分析涉及哪些分类 - category_ids = await llm_service.analyze_knowledge_categories(context_messages, categories_summary) - - if not category_ids: - return 0 - - # 第二步:为每个分类提取内容并存储 - stored_count = 0 - for category_id in category_ids: - try: - # 提取该分类的相关内容 - extracted_content = await llm_service.extract_knowledge_for_category( - context_messages, category_id, store.get_category_name(category_id) - ) - - if extracted_content: - # 存储到了解列表 - success = store.add_knowledge( - category_id=category_id, content=extracted_content, metadata={"source": "context_compression"} - ) - if success: - stored_count += 1 - if store_result_callback: - store_result_callback(category_id, store.get_category_name(category_id), extracted_content) - except Exception: - # 单个分类失败不影响其他分类 - continue - - return stored_count - - except Exception: - return 0 - - async def retrieve_relevant_knowledge( llm_service, - chat_history: List[dict], + chat_history: List[MaiMessage], ) -> str: - """ - 提取部分:根据当前上下文检索相关的了解信息。 - - 在每次对话前触发(EQ 模块和 timing 模块位置): - 1. 请求 LLM 分析需要哪些分类的了解内容 - 2. 提取对应分类的所有内容并拼接 - 3. 返回格式化后的了解内容 - - Args: - llm_service: LLM 服务实例 - chat_history: 当前对话历史 - - Returns: - 格式化后的了解内容文本 - """ + """Retrieve formatted knowledge snippets relevant to the current chat history.""" store = get_knowledge_store() categories_summary = store.get_categories_summary() try: - # 分析需要哪些分类 category_ids = await llm_service.analyze_knowledge_need(chat_history, categories_summary) - if not category_ids: return "" - - # 获取并格式化了解内容 - formatted_knowledge = store.get_formatted_knowledge(category_ids) - - return formatted_knowledge - + return store.get_formatted_knowledge(category_ids) except Exception: return "" diff --git a/src/maisaka/llm_service.py b/src/maisaka/llm_service.py index bc83e9b9..63b6d505 100644 --- a/src/maisaka/llm_service.py +++ b/src/maisaka/llm_service.py @@ -5,78 +5,47 @@ MaiSaka LLM 服务 - 使用主项目 LLM 系统 from datetime import datetime -import json import random from dataclasses import dataclass -from typing import Any, List, Literal, Optional +from typing import Any, List, Optional from rich.console import Group from rich.panel import Panel from rich.pretty import Pretty from rich.text import Text +from src.common.data_models.mai_message_data_model import MaiMessage from src.common.logger import get_logger from src.config.config import config_manager, global_config -from src.llm_models.payload_content.message import MessageBuilder, RoleType -from src.llm_models.payload_content.tool_option import ToolCall as ToolCallOption, ToolOption +from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType +from src.llm_models.payload_content.tool_option import ToolCall, ToolOption from src.llm_models.utils_model import LLMRequest from src.prompt.prompt_manager import prompt_manager + from . import config from .config import console from .builtin_tools import get_builtin_tools +from .message_adapter import ( + build_message, + format_speaker_content, + get_message_kind, + get_message_role, + get_message_text, + get_tool_call_id, + get_tool_calls, + remove_last_perception, + to_llm_message, +) logger = get_logger("maisaka_llm") -# ──────────────────── 消息类型 ──────────────────── - -MessageType = Literal["user", "assistant", "system", "perception"] - -# 内部使用的字段前缀,用于标记不应发送给 API 的元数据 -INTERNAL_FIELD_PREFIX = "_" - -# 消息类型字段名 -MSG_TYPE_FIELD = "_type" - - -@dataclass -class ToolCall: - """工具调用信息""" - - id: str - name: str - arguments: dict - - @dataclass class ChatResponse: """LLM 对话循环单步响应""" content: Optional[str] tool_calls: List[ToolCall] - raw_message: dict # 可直接追加到对话历史的消息字典 - - -# ──────────────────── 工具函数 ──────────────────── - - -def build_message(role: str, content: str, msg_type: MessageType = "user", **kwargs) -> dict: - """构建消息字典,包含消息类型标记。""" - msg = { - "role": role, - "content": content, - MSG_TYPE_FIELD: msg_type, - "_time": datetime.now().strftime("%H:%M:%S"), - **kwargs, - } - return msg - - -def remove_last_perception(messages: list[dict]) -> None: - """移除最后一条感知消息(直接修改原列表)。""" - for i in range(len(messages) - 1, -1, -1): - if messages[i].get(MSG_TYPE_FIELD) == "perception": - messages.pop(i) - break + raw_message: MaiMessage class MaiSakaLLMService: @@ -132,7 +101,6 @@ class MaiSakaLLMService: if chat_system_prompt is None: try: chat_prompt = prompt_manager.get_prompt("maidairy_chat") - logger.info("成功加载 maidairy_chat 提示词模板") tools_section = "" if config.ENABLE_WRITE_FILE: tools_section += "\n• write_file(filename, content) — 在 mai_files 目录下写入文件。" @@ -142,6 +110,7 @@ class MaiSakaLLMService: tools_section += "\n• list_files() — 获取 mai_files 目录下所有文件的元信息列表。" chat_prompt.add_context("file_tools_section", tools_section if tools_section else "") + chat_prompt.add_context("bot_name", global_config.bot.nickname) chat_prompt.add_context("identity", personality_prompt) import asyncio @@ -167,8 +136,6 @@ class MaiSakaLLMService: self._emotion_prompt: Optional[str] = None self._cognition_prompt: Optional[str] = None self._timing_prompt: Optional[str] = None - self._context_summarize_prompt: Optional[str] = None - try: import asyncio @@ -184,9 +151,6 @@ class MaiSakaLLMService: self._timing_prompt = loop.run_until_complete( prompt_manager.render_prompt(prompt_manager.get_prompt("maidairy_timing")) ) - self._context_summarize_prompt = loop.run_until_complete( - prompt_manager.render_prompt(prompt_manager.get_prompt("maidairy_context_summarize")) - ) logger.info("成功加载 MaiSaka 子模块提示词") finally: loop.close() @@ -367,12 +331,12 @@ class MaiSakaLLMService: params.append((param.name, param.param_type, param.description, param.required, param.enum_values)) return {"name": tool.name, "description": tool.description, "parameters": params} - async def chat_loop_step(self, chat_history: List[dict]) -> ChatResponse: + async def chat_loop_step(self, chat_history: list[MaiMessage]) -> ChatResponse: """执行对话循环的一步 - 使用 tool_use 模型""" - def message_factory(client) -> List: + def message_factory(client) -> list[Message]: """将 MaiSaka 的 chat_history 转换为主项目的 Message 格式""" - messages = [] + messages: list[Message] = [] # 首先添加系统提示词 system_msg = MessageBuilder().set_role(RoleType.System) @@ -381,48 +345,9 @@ class MaiSakaLLMService: # 然后添加对话历史 for msg in chat_history: - role = msg.get("role", "") - content = msg.get("content", "") - - # 跳过内部字段类型的消息和系统消息(已经有系统提示词了) - if role in ("perception", "system"): - continue - - # 映射角色类型 - if role == "user": - role_type = RoleType.User - elif role == "assistant": - role_type = RoleType.Assistant - elif role == "tool": - role_type = RoleType.Tool - else: - continue - - builder = MessageBuilder().set_role(role_type) - - # 处理工具调用 - if role == "assistant" and "tool_calls" in msg: - # 转换 tool_calls 格式:从 MaiSaka 格式转为主项目格式 - tool_calls_list = [] - for tc in msg["tool_calls"]: - tc_func = tc.get("function", {}) - # 主项目的 ToolCall: call_id, func_name, args - tool_calls_list.append( - ToolCallOption( - call_id=tc.get("id", ""), - func_name=tc_func.get("name", ""), - args=json.loads(tc_func.get("arguments", "{}")) if tc_func.get("arguments") else {}, - ) - ) - builder.set_tool_calls(tool_calls_list) - elif role == "tool" and "tool_call_id" in msg: - builder.add_tool_call(msg["tool_call_id"]) - - # 添加文本内容 - if content: - builder.add_text_content(content) - - messages.append(builder.build()) + llm_message = to_llm_message(msg) + if llm_message is not None: + messages.append(llm_message) return messages @@ -435,33 +360,18 @@ class MaiSakaLLMService: # 打印消息列表 built_messages = message_factory(None) - # 将消息分为普通消息和 tool 消息 - non_tool_panels = [] - tool_panels = [] + ordered_panels = [self._render_message_panel(msg, index + 1) for index, msg in enumerate(built_messages)] - for index, msg in enumerate(built_messages): - panel = self._render_message_panel(msg, index + 1) - role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) - - if role == "tool": - tool_panels.append(panel) - else: - non_tool_panels.append(panel) - - # 先显示普通消息(group 在一个 panel 内) - if non_tool_panels: + if config.SHOW_THINKING and ordered_panels: console.print( Panel( - Group(*non_tool_panels), + Group(*ordered_panels), title="MaiSaka LLM Request - chat_loop_step", border_style="cyan", padding=(0, 1), ) ) - # tool 消息作为单独的块展示 - for panel in tool_panels: - console.print(panel) response, (reasoning, model, tool_calls) = await self._llm_chat.generate_response_with_message_async( message_factory=message_factory, @@ -469,86 +379,60 @@ class MaiSakaLLMService: temperature=self._temperature, max_tokens=self._max_tokens, ) - - # 转换 tool_calls 格式:从主项目格式转为 MaiSaka 格式 - converted_tool_calls = [] - if tool_calls: - for tc in tool_calls: - # 主项目的 ToolCall 有 call_id, func_name, args - call_id = tc.call_id if hasattr(tc, "call_id") else "" - func_name = tc.func_name if hasattr(tc, "func_name") else "" - args = tc.args if hasattr(tc, "args") else {} - - converted_tool_calls.append( - ToolCall( - id=call_id, - name=func_name, - arguments=args, - ) - ) - - # 构建原始消息格式(MaiSaka 风格) - raw_message = { - "role": "assistant", - "content": response, - "_time": datetime.now().strftime("%H:%M:%S"), - } - if converted_tool_calls: - raw_message["tool_calls"] = [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.name, - "arguments": json.dumps(tc.arguments), - }, - } - for tc in converted_tool_calls - ] + raw_message = build_message( + role=RoleType.Assistant.value, + content=response or "", + source="assistant", + tool_calls=tool_calls or None, + ) return ChatResponse( content=response, - tool_calls=converted_tool_calls, + tool_calls=tool_calls or [], raw_message=raw_message, ) - def _filter_for_api(self, chat_history: List[dict]) -> str: + def _filter_for_api(self, chat_history: list[MaiMessage]) -> str: """过滤对话历史为 API 格式""" parts = [] for msg in chat_history: - role = msg.get("role", "") - content = msg.get("content", "") + role = get_message_role(msg) + content = get_message_text(msg) # 跳过内部字段 - if role in ("perception", "tool"): + if get_message_kind(msg) == "perception" or role == RoleType.Tool.value: continue - if role == "system": + if role == RoleType.System.value: parts.append(f"System: {content}") - elif role == "user": + elif role == RoleType.User.value: parts.append(f"User: {content}") - elif role == "assistant": + elif role == RoleType.Assistant.value: # 处理工具调用 - if "tool_calls" in msg: - tool_desc = ", ".join([tc.get("name", "") for tc in msg["tool_calls"]]) + tool_calls = get_tool_calls(msg) + if tool_calls: + tool_desc = ", ".join([tc.func_name for tc in tool_calls if tc.func_name]) parts.append(f"Assistant (called tools: {tool_desc})") else: parts.append(f"Assistant: {content}") return "\n\n".join(parts) - def build_chat_context(self, user_text: str) -> List[dict]: + def build_chat_context(self, user_text: str) -> list[MaiMessage]: """构建对话上下文""" return [ - {"role": "system", "content": self._chat_system_prompt}, - {"role": "user", "content": user_text}, + build_message( + role=RoleType.User.value, + content=format_speaker_content(config.USER_NAME, user_text), + source="user", + ) ] # ──────── 分析模块(使用 utils 模型) ──────── - async def analyze_emotion(self, chat_history: List[dict]) -> str: + async def analyze_emotion(self, chat_history: list[MaiMessage]) -> str: """情绪分析 - 使用 utils 模型""" - filtered = [m for m in chat_history if m.get("_type") != "perception"] + filtered = [m for m in chat_history if get_message_kind(m) != "perception"] recent = filtered[-10:] if len(filtered) > 10 else filtered # 使用加载的系统提示词 @@ -556,17 +440,20 @@ class MaiSakaLLMService: prompt_parts = [f"{system_prompt}\n\n【对话内容】\n"] for msg in recent: - if msg.get("role") == "user": - prompt_parts.append(f"用户: {msg.get('content', '')}") - elif msg.get("role") == "assistant": - prompt_parts.append(f"助手: {msg.get('content', '')}") + role = get_message_role(msg) + content = get_message_text(msg) + if role == RoleType.User.value: + prompt_parts.append(f"{config.USER_NAME}: {content}") + elif role == RoleType.Assistant.value: + prompt_parts.append(f"助手: {content}") prompt = "\n".join(prompt_parts) - print("\n" + "=" * 60) - print("MaiSaka LLM Request - analyze_emotion:") - print(f" {prompt}") - print("=" * 60 + "\n") + if config.SHOW_THINKING: + print("\n" + "=" * 60) + print("MaiSaka LLM Request - analyze_emotion:") + print(f" {prompt}") + print("=" * 60 + "\n") try: response, _ = await self._llm_utils.generate_response_async( @@ -580,9 +467,9 @@ class MaiSakaLLMService: logger.error(f"情绪分析 LLM 调用出错: {e}") return "" - async def analyze_cognition(self, chat_history: List[dict]) -> str: + async def analyze_cognition(self, chat_history: list[MaiMessage]) -> str: """认知分析 - 使用 utils 模型""" - filtered = [m for m in chat_history if m.get("_type") != "perception"] + filtered = [m for m in chat_history if get_message_kind(m) != "perception"] recent = filtered[-10:] if len(filtered) > 10 else filtered # 使用加载的系统提示词 @@ -590,14 +477,16 @@ class MaiSakaLLMService: prompt_parts = [f"{system_prompt}\n\n【对话内容】\n"] for msg in recent: - if msg.get("role") == "user": - prompt_parts.append(f"用户: {msg.get('content', '')}") - elif msg.get("role") == "assistant": - prompt_parts.append(f"助手: {msg.get('content', '')}") + role = get_message_role(msg) + content = get_message_text(msg) + if role == RoleType.User.value: + prompt_parts.append(f"{config.USER_NAME}: {content}") + elif role == RoleType.Assistant.value: + prompt_parts.append(f"助手: {content}") prompt = "\n".join(prompt_parts) - if config.SHOW_ANALYZE_COGNITION_PROMPT: + if config.SHOW_THINKING and config.SHOW_ANALYZE_COGNITION_PROMPT: print("\n" + "=" * 60) print("MaiSaka LLM Request - analyze_cognition:") print(f" {prompt}") @@ -615,25 +504,29 @@ class MaiSakaLLMService: logger.error(f"认知分析 LLM 调用出错: {e}") return "" - async def analyze_timing(self, chat_history: List[dict], timing_info: str) -> str: + async def analyze_timing(self, chat_history: list[MaiMessage], timing_info: str) -> str: """时间分析 - 使用 utils 模型""" - filtered = [m for m in chat_history if m.get("_type") not in ("perception", "system")] + filtered = [ + m + for m in chat_history + if get_message_kind(m) != "perception" and get_message_role(m) != RoleType.System.value + ] # 使用加载的系统提示词 system_prompt = self._timing_prompt or "请分析以下对话的时间节奏和用户状态:" prompt_parts = [f"{system_prompt}\n\n【系统时间戳信息】\n{timing_info}\n\n【当前对话记录】\n"] for msg in filtered: - role = msg.get("role", "") - content = msg.get("content", "") - if role == "user": - prompt_parts.append(f"用户: {content}") - elif role == "assistant": + role = get_message_role(msg) + content = get_message_text(msg) + if role == RoleType.User.value: + prompt_parts.append(f"{config.USER_NAME}: {content}") + elif role == RoleType.Assistant.value: prompt_parts.append(f"助手: {content}") prompt = "\n".join(prompt_parts) - if config.SHOW_ANALYZE_TIMING_PROMPT: + if config.SHOW_THINKING and config.SHOW_ANALYZE_TIMING_PROMPT: print("\n" + "=" * 60) print("MaiSaka LLM Request - analyze_timing:") print(f" {prompt}") @@ -651,44 +544,9 @@ class MaiSakaLLMService: logger.error(f"时间分析 LLM 调用出错: {e}") return "" - async def summarize_context(self, context_messages: List[dict]) -> str: - """上下文总结 - 使用 utils 模型""" - filtered = [m for m in context_messages if m.get("role") != "system"] - - # 使用加载的系统提示词 - system_prompt = self._context_summarize_prompt or "请对以下对话内容进行总结:" - - prompt_parts = [f"{system_prompt}\n\n【对话内容】\n"] - for msg in filtered: - role = msg.get("role", "") - content = msg.get("content", "") - if role == "user": - prompt_parts.append(f"用户: {content}") - elif role == "assistant": - prompt_parts.append(f"助手: {content}") - - prompt = "\n".join(prompt_parts) - - print("\n" + "=" * 60) - print("MaiSaka LLM Request - summarize_context:") - print(f" {prompt}") - print("=" * 60 + "\n") - - try: - response, _ = await self._llm_utils.generate_response_async( - prompt=prompt, - temperature=0.3, - max_tokens=1024, - ) - - return response - except Exception as e: - logger.error(f"上下文总结 LLM 调用出错: {e}") - return "" - # ──────── 回复生成(使用 replyer 模型) ──────── - async def generate_reply(self, reason: str, chat_history: List[dict]) -> str: + async def generate_reply(self, reason: str, chat_history: list[MaiMessage]) -> str: """ 生成回复 - 使用 replyer 模型 可供 Replyer 类直接调用 @@ -700,7 +558,9 @@ class MaiSakaLLMService: # 格式化对话历史 filtered_history = [ - msg for msg in chat_history if msg.get("role") != "system" and msg.get("_type") != "perception" + msg + for msg in chat_history + if get_message_role(msg) != RoleType.System.value and get_message_kind(msg) != "perception" ] formatted_history = format_chat_history(filtered_history) @@ -717,10 +577,11 @@ class MaiSakaLLMService: messages = f"System: {system_prompt}\n\nUser: {user_prompt}" - print("\n" + "=" * 60) - print("MaiSaka LLM Request - generate_reply:") - print(f" {messages}") - print("=" * 60 + "\n") + if config.SHOW_THINKING: + print("\n" + "=" * 60) + print("MaiSaka LLM Request - generate_reply:") + print(f" {messages}") + print("=" * 60 + "\n") try: response, _ = await self._llm_replyer.generate_response_async( diff --git a/src/maisaka/message_adapter.py b/src/maisaka/message_adapter.py new file mode 100644 index 00000000..caa9d6dd --- /dev/null +++ b/src/maisaka/message_adapter.py @@ -0,0 +1,181 @@ +""" +MaiSaka message adapters built on top of the main project's MaiMessage model. +""" + +from datetime import datetime +import re +from typing import Optional +from uuid import uuid4 + +from src.common.data_models.mai_message_data_model import MaiMessage, MessageInfo, UserInfo +from src.common.data_models.message_component_data_model import MessageSequence +from src.config.config import global_config +from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType +from src.llm_models.payload_content.tool_option import ToolCall + +from .config import USER_NAME + +MAISAKA_PLATFORM = "maisaka" +MAISAKA_SESSION_ID = "maisaka_cli" +MESSAGE_KIND_KEY = "maisaka_message_kind" +SOURCE_KEY = "maisaka_source" +LLM_ROLE_KEY = "maisaka_llm_role" +TOOL_CALL_ID_KEY = "maisaka_tool_call_id" +TOOL_CALLS_KEY = "maisaka_tool_calls" +SPEAKER_PREFIX_PATTERN = re.compile(r"^\[(?P[^\]]+)\](?P.*)$", re.DOTALL) + + +def _build_user_info_for_role(role: str) -> UserInfo: + if role == RoleType.User.value: + return UserInfo(user_id="maisaka_user", user_nickname=USER_NAME, user_cardname=None) + if role == RoleType.Tool.value: + return UserInfo(user_id="maisaka_tool", user_nickname="tool", user_cardname=None) + return UserInfo( + user_id="maisaka_assistant", + user_nickname=global_config.bot.nickname.strip() or "MaiSaka", + user_cardname=None, + ) + + +def _serialize_tool_call(tool_call: ToolCall) -> dict: + return { + "call_id": tool_call.call_id, + "func_name": tool_call.func_name, + "args": tool_call.args or {}, + } + + +def _deserialize_tool_call(data: dict) -> ToolCall: + return ToolCall( + call_id=str(data.get("call_id", "")), + func_name=str(data.get("func_name", "")), + args=data.get("args", {}) or {}, + ) + + +def build_message( + role: str, + content: str, + *, + message_kind: str = "normal", + source: Optional[str] = None, + tool_call_id: Optional[str] = None, + tool_calls: Optional[list[ToolCall]] = None, + timestamp: Optional[datetime] = None, + message_id: Optional[str] = None, +) -> MaiMessage: + """Build a MaiMessage for the Maisaka session history.""" + resolved_timestamp = timestamp or datetime.now() + resolved_role = role.value if isinstance(role, RoleType) else role + message = MaiMessage( + message_id=message_id or f"maisaka_{uuid4().hex}", + timestamp=resolved_timestamp, + platform=MAISAKA_PLATFORM, + ) + message.message_info = MessageInfo( + user_info=_build_user_info_for_role(resolved_role), + group_info=None, + additional_config={ + LLM_ROLE_KEY: resolved_role, + MESSAGE_KIND_KEY: message_kind, + SOURCE_KEY: source or resolved_role, + TOOL_CALL_ID_KEY: tool_call_id, + TOOL_CALLS_KEY: [_serialize_tool_call(tool_call) for tool_call in (tool_calls or [])], + }, + ) + message.session_id = MAISAKA_SESSION_ID + message.raw_message = MessageSequence([]) + if content: + message.raw_message.text(content) + message.processed_plain_text = content + message.display_message = content + return message + + +def format_speaker_content(speaker_name: str, content: str) -> str: + """Format visible conversation content with an explicit speaker label.""" + return f"[{speaker_name}]{content}" + + +def parse_speaker_content(content: str) -> tuple[Optional[str], str]: + """Parse content formatted as [speaker]message.""" + match = SPEAKER_PREFIX_PATTERN.match(content or "") + if not match: + return None, content or "" + return match.group("speaker"), match.group("content") + + +def get_message_text(message: MaiMessage) -> str: + if message.processed_plain_text is not None: + return message.processed_plain_text + if message.display_message is not None: + return message.display_message + + parts: list[str] = [] + for component in message.raw_message.components: + text = getattr(component, "text", None) + if isinstance(text, str): + parts.append(text) + return "".join(parts) + + +def get_message_role(message: MaiMessage) -> str: + return str(message.message_info.additional_config.get(LLM_ROLE_KEY, RoleType.User.value)) + + +def get_message_kind(message: MaiMessage) -> str: + return str(message.message_info.additional_config.get(MESSAGE_KIND_KEY, "normal")) + + +def get_message_source(message: MaiMessage) -> str: + return str(message.message_info.additional_config.get(SOURCE_KEY, get_message_role(message))) + + +def is_perception_message(message: MaiMessage) -> bool: + return get_message_kind(message) == "perception" + + +def get_tool_call_id(message: MaiMessage) -> Optional[str]: + value = message.message_info.additional_config.get(TOOL_CALL_ID_KEY) + return str(value) if value else None + + +def get_tool_calls(message: MaiMessage) -> list[ToolCall]: + raw_tool_calls = message.message_info.additional_config.get(TOOL_CALLS_KEY, []) + if not isinstance(raw_tool_calls, list): + return [] + return [_deserialize_tool_call(item) for item in raw_tool_calls if isinstance(item, dict)] + + +def remove_last_perception(messages: list[MaiMessage]) -> None: + for index in range(len(messages) - 1, -1, -1): + if is_perception_message(messages[index]): + messages.pop(index) + break + + +def to_llm_message(message: MaiMessage) -> Optional[Message]: + role = get_message_role(message) + content = get_message_text(message) + tool_call_id = get_tool_call_id(message) + tool_calls = get_tool_calls(message) + + if role == RoleType.System.value: + role_type = RoleType.System + elif role == RoleType.User.value: + role_type = RoleType.User + elif role == RoleType.Assistant.value: + role_type = RoleType.Assistant + elif role == RoleType.Tool.value: + role_type = RoleType.Tool + else: + return None + + builder = MessageBuilder().set_role(role_type) + if role_type == RoleType.Assistant and tool_calls: + builder.set_tool_calls(tool_calls) + if role_type == RoleType.Tool and tool_call_id: + builder.add_tool_call(tool_call_id) + if content: + builder.add_text_content(content) + return builder.build() diff --git a/src/maisaka/replyer.py b/src/maisaka/replyer.py index 2cb428a2..9483f2ab 100644 --- a/src/maisaka/replyer.py +++ b/src/maisaka/replyer.py @@ -2,14 +2,14 @@ MaiSaka reply helper. """ -from datetime import datetime -from typing import Any, Optional +from typing import Optional +from src.common.data_models.mai_message_data_model import MaiMessage from src.config.config import global_config +from .config import USER_NAME from .llm_service import MaiSakaLLMService - -VISIBLE_REPLY_PREFIX = "\u3010\u9ea6\u9ea6\u7684\u53d1\u8a00\u3011" +from .message_adapter import get_message_role, get_message_text, is_perception_message, parse_speaker_content def _normalize_content(content: str, limit: int = 500) -> str: @@ -19,57 +19,49 @@ def _normalize_content(content: str, limit: int = 500) -> str: return normalized -def _format_message_time(_: dict[str, Any]) -> str: - return datetime.now().strftime("%H:%M:%S") +def _format_message_time(message: MaiMessage) -> str: + return message.timestamp.strftime("%H:%M:%S") -def _extract_visible_assistant_reply(message: dict[str, Any]) -> str: - if message.get("_type") == "perception": +def _extract_visible_assistant_reply(message: MaiMessage) -> str: + if is_perception_message(message): return "" - - content = (message.get("content", "") or "").strip() - if not content: - return "" - - marker = "[generated_reply]" - if marker in content: - _, visible_reply = content.rsplit(marker, 1) - return _normalize_content(visible_reply) - return "" -def _extract_guided_bot_reply(message: dict[str, Any]) -> str: - content = (message.get("content", "") or "").strip() - if content.startswith(VISIBLE_REPLY_PREFIX): - return _normalize_content(content[len(VISIBLE_REPLY_PREFIX) :].strip()) +def _extract_guided_bot_reply(message: MaiMessage) -> str: + speaker_name, body = parse_speaker_content(get_message_text(message).strip()) + bot_nickname = global_config.bot.nickname.strip() or "Bot" + if speaker_name == bot_nickname: + return _normalize_content(body.strip()) return "" -def format_chat_history(messages: list[dict[str, Any]]) -> str: +def format_chat_history(messages: list[MaiMessage]) -> str: """Format visible chat history for reply generation.""" bot_nickname = global_config.bot.nickname.strip() or "Bot" parts: list[str] = [] for message in messages: - role = message.get("role", "") + role = get_message_role(message) timestamp = _format_message_time(message) if role == "user": guided_reply = _extract_guided_bot_reply(message) if guided_reply: - parts.append(f"{timestamp} {bot_nickname}(分析器指导的麦麦发言):{guided_reply}") + parts.append(f"{timestamp} {bot_nickname}(you): {guided_reply}") continue - content = _normalize_content(message.get("content", "") or "") + _, content_body = parse_speaker_content(get_message_text(message)) + content = _normalize_content(content_body) if content: - parts.append(f"{timestamp} 用户:{content}") + parts.append(f"{timestamp} {USER_NAME}: {content}") continue if role == "assistant": visible_reply = _extract_visible_assistant_reply(message) if visible_reply: - parts.append(f"{timestamp} {bot_nickname}(你):{visible_reply}") + parts.append(f"{timestamp} {bot_nickname}(you): {visible_reply}") return "\n".join(parts) @@ -87,7 +79,7 @@ class Replyer: def set_enabled(self, enabled: bool) -> None: self._enabled = enabled - async def reply(self, reason: str, chat_history: list[dict[str, Any]]) -> str: + async def reply(self, reason: str, chat_history: list[MaiMessage]) -> str: if not self._enabled or not reason or self._llm_service is None: return "..." diff --git a/src/maisaka/timing.py b/src/maisaka/timing.py index 522511de..1709506f 100644 --- a/src/maisaka/timing.py +++ b/src/maisaka/timing.py @@ -1,75 +1,67 @@ """ -MaiSaka - Timing 模块(含自我反思功能) -构建对话时间戳信息,供 Timing 分析模块使用。 -该模块同时负责分析对话的时间维度和进行自我反思分析。 +MaiSaka timing helpers. """ from datetime import datetime from typing import Optional +def _format_duration(total_seconds: int) -> str: + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + if hours > 0: + return f"{hours}h {minutes}m {seconds}s" + if minutes > 0: + return f"{minutes}m {seconds}s" + return f"{seconds}s" + + +def _get_time_period_label(hour: int) -> str: + if 0 <= hour < 6: + return "late_night" + if 6 <= hour < 9: + return "morning" + if 9 <= hour < 12: + return "late_morning" + if 12 <= hour < 14: + return "noon" + if 14 <= hour < 18: + return "afternoon" + if 18 <= hour < 22: + return "evening" + return "night" + + def build_timing_info( chat_start_time: Optional[datetime], last_user_input_time: Optional[datetime], last_assistant_response_time: Optional[datetime], user_input_times: list[datetime], ) -> str: - """ - 构建当前时间戳信息文本,供 Timing 模块分析。 - - Args: - chat_start_time: 对话开始时间 - last_user_input_time: 用户上次输入时间 - last_assistant_response_time: 助手上次回复时间 - user_input_times: 所有用户输入时间戳列表 - """ + """Build readable timing context for the timing analysis prompt.""" now = datetime.now() - parts: list[str] = [] - - parts.append(f"当前时间: {now.strftime('%Y-%m-%d %H:%M:%S')}") + parts: list[str] = [f"Current time: {now.strftime('%Y-%m-%d %H:%M:%S')}"] if chat_start_time: - elapsed = now - chat_start_time - minutes, seconds = divmod(int(elapsed.total_seconds()), 60) - hours, minutes = divmod(minutes, 60) - if hours > 0: - parts.append(f"对话已持续: {hours}小时{minutes}分{seconds}秒") - elif minutes > 0: - parts.append(f"对话已持续: {minutes}分{seconds}秒") - else: - parts.append(f"对话已持续: {seconds}秒") + elapsed_seconds = int((now - chat_start_time).total_seconds()) + parts.append(f"Conversation duration: {_format_duration(elapsed_seconds)}") if last_user_input_time: - since_user = now - last_user_input_time - parts.append(f"距用户上次输入: {int(since_user.total_seconds())}秒") + since_user_seconds = int((now - last_user_input_time).total_seconds()) + parts.append(f"Seconds since last user input: {since_user_seconds}") if last_assistant_response_time: - since_assistant = now - last_assistant_response_time - parts.append(f"距助手上次回复: {int(since_assistant.total_seconds())}秒") + since_assistant_seconds = int((now - last_assistant_response_time).total_seconds()) + parts.append(f"Seconds since last Maisaka reply: {since_assistant_seconds}") if len(user_input_times) >= 2: intervals = [ - (user_input_times[i] - user_input_times[i - 1]).total_seconds() for i in range(1, len(user_input_times)) + int((user_input_times[index] - user_input_times[index - 1]).total_seconds()) + for index in range(1, len(user_input_times)) ] - avg_interval = sum(intervals) / len(intervals) - parts.append(f"用户平均回复间隔: {int(avg_interval)}秒") - parts.append(f"用户总共发言次数: {len(user_input_times)}") - - # 时段判断 - hour = now.hour - if 0 <= hour < 6: - parts.append("当前时段: 深夜/凌晨") - elif 6 <= hour < 9: - parts.append("当前时段: 早晨") - elif 9 <= hour < 12: - parts.append("当前时段: 上午") - elif 12 <= hour < 14: - parts.append("当前时段: 中午") - elif 14 <= hour < 18: - parts.append("当前时段: 下午") - elif 18 <= hour < 22: - parts.append("当前时段: 晚上") - else: - parts.append("当前时段: 深夜") + average_interval = sum(intervals) / len(intervals) + parts.append(f"Average user input interval: {int(average_interval)}s") + parts.append(f"Total user input count: {len(user_input_times)}") + parts.append(f"Current time period: {_get_time_period_label(now.hour)}") return "\n".join(parts) diff --git a/src/maisaka/tool_handlers.py b/src/maisaka/tool_handlers.py index 9ac91941..68d00f22 100644 --- a/src/maisaka/tool_handlers.py +++ b/src/maisaka/tool_handlers.py @@ -9,13 +9,15 @@ from typing import TYPE_CHECKING, Any, Optional import json as _json import os -from rich.markdown import Markdown from rich.panel import Panel +from src.common.data_models.mai_message_data_model import MaiMessage +from src.llm_models.payload_content.tool_option import ToolCall + from .config import console from .input_reader import InputReader from .llm_service import MaiSakaLLMService -from .replyer import Replyer +from .message_adapter import build_message if TYPE_CHECKING: from .mcp_client import MCPManager @@ -23,18 +25,6 @@ if TYPE_CHECKING: MAI_FILES_DIR = Path(os.path.join(os.path.dirname(os.path.abspath(__file__)), "mai_files")) -_replyer: Optional[Replyer] = None - - -def get_replyer(llm_service: MaiSakaLLMService) -> Replyer: - """Return a shared replyer instance.""" - global _replyer - if _replyer is None: - _replyer = Replyer(llm_service) - elif _replyer._llm_service is None: - _replyer.set_llm_service(llm_service) - return _replyer - class ToolHandlerContext: """Shared context for tool handlers.""" @@ -51,78 +41,22 @@ class ToolHandlerContext: self.last_user_input_time: Optional[datetime] = None -async def handle_send_message(tc: Any, chat_history: list[dict[str, Any]], ctx: ToolHandlerContext) -> None: - """Backward-compatible handler for legacy send-message style tools.""" - reason = tc.arguments.get("reason", "") - console.print("[accent]Calling tool: send_message(...)[/accent]") - - if not reason: - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": "Missing required argument: reason", - } - ) - return - - console.print( - Panel( - Markdown(reason), - title="Reply Reason", - border_style="dim", - padding=(0, 1), - style="dim", - ) - ) - - with console.status("[info]Generating visible reply...[/info]", spinner="dots"): - replyer = get_replyer(ctx.llm_service) - reply = await replyer.reply(reason, chat_history) - - console.print( - Panel( - Markdown(reply), - title="MaiSaka", - border_style="magenta", - padding=(1, 2), - ) - ) - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": f"Visible reply generated:\n{reply}", - } - ) - - -async def handle_stop(tc: Any, chat_history: list[dict[str, Any]]) -> None: +async def handle_stop(tc: ToolCall, chat_history: list[MaiMessage]) -> None: """Handle the stop tool.""" console.print("[accent]Calling tool: stop()[/accent]") chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": "Conversation loop will stop after this round.", - } + build_message(role="tool", content="Conversation loop will stop after this round.", tool_call_id=tc.call_id) ) -async def handle_wait(tc: Any, chat_history: list[dict[str, Any]], ctx: ToolHandlerContext) -> str: +async def handle_wait(tc: ToolCall, chat_history: list[MaiMessage], ctx: ToolHandlerContext) -> str: """Handle the wait tool.""" - seconds = tc.arguments.get("seconds", 30) + seconds = (tc.args or {}).get("seconds", 30) seconds = max(5, min(seconds, 300)) console.print(f"[accent]Calling tool: wait({seconds})[/accent]") tool_result = await _do_wait(seconds, ctx) - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": tool_result, - } - ) + chat_history.append(build_message(role="tool", content=tool_result, tool_call_id=tc.call_id)) return tool_result @@ -152,49 +86,37 @@ async def _do_wait(seconds: int, ctx: ToolHandlerContext) -> str: return f"User input received: {user_input}" -async def handle_mcp_tool(tc: Any, chat_history: list[dict[str, Any]], mcp_manager: "MCPManager") -> None: +async def handle_mcp_tool(tc: ToolCall, chat_history: list[MaiMessage], mcp_manager: "MCPManager") -> None: """Handle an MCP tool call.""" - args_str = _json.dumps(tc.arguments, ensure_ascii=False) + args_str = _json.dumps(tc.args or {}, ensure_ascii=False) args_preview = args_str if len(args_str) <= 120 else args_str[:120] + "..." - console.print(f"[accent]Calling MCP tool: {tc.name}({args_preview})[/accent]") + console.print(f"[accent]Calling MCP tool: {tc.func_name}({args_preview})[/accent]") - with console.status(f"[info]Running MCP tool {tc.name}...[/info]", spinner="dots"): - result = await mcp_manager.call_tool(tc.name, tc.arguments) + with console.status(f"[info]Running MCP tool {tc.func_name}...[/info]", spinner="dots"): + result = await mcp_manager.call_tool(tc.func_name, tc.args or {}) display_text = result if len(result) <= 800 else result[:800] + "\n... (truncated)" console.print( Panel( display_text, - title=f"MCP: {tc.name}", + title=f"MCP: {tc.func_name}", border_style="bright_green", padding=(0, 1), ) ) - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": result, - } - ) + chat_history.append(build_message(role="tool", content=result, tool_call_id=tc.call_id)) -async def handle_unknown_tool(tc: Any, chat_history: list[dict[str, Any]]) -> None: +async def handle_unknown_tool(tc: ToolCall, chat_history: list[MaiMessage]) -> None: """Handle an unknown tool call.""" - console.print(f"[accent]Calling unknown tool: {tc.name}({tc.arguments})[/accent]") - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": f"Unknown tool: {tc.name}", - } - ) + console.print(f"[accent]Calling unknown tool: {tc.func_name}({tc.args})[/accent]") + chat_history.append(build_message(role="tool", content=f"Unknown tool: {tc.func_name}", tool_call_id=tc.call_id)) -async def handle_write_file(tc: Any, chat_history: list[dict[str, Any]]) -> None: +async def handle_write_file(tc: ToolCall, chat_history: list[MaiMessage]) -> None: """Write a file under the local mai_files workspace.""" - filename = tc.arguments.get("filename", "") - content = tc.arguments.get("content", "") + filename = (tc.args or {}).get("filename", "") + content = (tc.args or {}).get("content", "") console.print(f'[accent]Calling tool: write_file("{filename}")[/accent]') MAI_FILES_DIR.mkdir(parents=True, exist_ok=True) @@ -215,27 +137,21 @@ async def handle_write_file(tc: Any, chat_history: list[dict[str, Any]]) -> None ) ) chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": f"File written successfully: {filename} ({file_size} bytes)", - } + build_message( + role="tool", + content=f"File written successfully: {filename} ({file_size} bytes)", + tool_call_id=tc.call_id, + ) ) except Exception as exc: error_msg = f"Failed to write file: {exc}" console.print(f"[error]{error_msg}[/error]") - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": error_msg, - } - ) + chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id)) -async def handle_read_file(tc: Any, chat_history: list[dict[str, Any]]) -> None: +async def handle_read_file(tc: ToolCall, chat_history: list[MaiMessage]) -> None: """Read a file from the local mai_files workspace.""" - filename = tc.arguments.get("filename", "") + filename = (tc.args or {}).get("filename", "") console.print(f'[accent]Calling tool: read_file("{filename}")[/accent]') file_path = MAI_FILES_DIR / filename @@ -244,25 +160,13 @@ async def handle_read_file(tc: Any, chat_history: list[dict[str, Any]]) -> None: if not file_path.exists(): error_msg = f"File does not exist: {filename}" console.print(f"[warning]{error_msg}[/warning]") - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": error_msg, - } - ) + chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id)) return if not file_path.is_file(): error_msg = f"Path is not a file: {filename}" console.print(f"[warning]{error_msg}[/warning]") - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": error_msg, - } - ) + chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id)) return with open(file_path, "r", encoding="utf-8") as file: @@ -278,25 +182,15 @@ async def handle_read_file(tc: Any, chat_history: list[dict[str, Any]]) -> None: ) ) chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": f"File content of {filename}:\n{file_content}", - } + build_message(role="tool", content=f"File content of {filename}:\n{file_content}", tool_call_id=tc.call_id) ) except Exception as exc: error_msg = f"Failed to read file: {exc}" console.print(f"[error]{error_msg}[/error]") - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": error_msg, - } - ) + chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id)) -async def handle_list_files(tc: Any, chat_history: list[dict[str, Any]]) -> None: +async def handle_list_files(tc: ToolCall, chat_history: list[MaiMessage]) -> None: """List files under the local mai_files workspace.""" console.print("[accent]Calling tool: list_files()[/accent]") @@ -332,23 +226,11 @@ async def handle_list_files(tc: Any, chat_history: list[dict[str, Any]]) -> None padding=(0, 1), ) ) - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": result_text, - } - ) + chat_history.append(build_message(role="tool", content=result_text, tool_call_id=tc.call_id)) except Exception as exc: error_msg = f"Failed to list files: {exc}" console.print(f"[error]{error_msg}[/error]") - chat_history.append( - { - "role": "tool", - "tool_call_id": tc.id, - "content": error_msg, - } - ) + chat_history.append(build_message(role="tool", content=error_msg, tool_call_id=tc.call_id)) try: