From 5ac088ded81be2db7407cbf7ffa8d2856ce17906 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 31 Mar 2026 00:03:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=87=B3=208.4.0=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=AD=9B=E9=80=89=E7=9B=B8=E5=85=B3=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=92=8C=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/config.py | 2 +- src/config/official_configs.py | 29 +++ src/maisaka/chat_loop_service.py | 370 ++++++++++++++++++++++++++++++- 3 files changed, 394 insertions(+), 7 deletions(-) diff --git a/src/config/config.py b/src/config/config.py index c85a170a..318c987f 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -57,7 +57,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.3.0" +CONFIG_VERSION: str = "8.2.0" MODEL_CONFIG_VERSION: str = "1.13.1" logger = get_logger("config") diff --git a/src/config/official_configs.py b/src/config/official_configs.py index f3099a5c..de44bfb6 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1550,6 +1550,35 @@ class MaiSakaConfig(ConfigBase): ) """每个入站消息的最大内部规划轮数""" + tool_filter_task_name: str = Field( + default="utils", + json_schema_extra={ + "x-widget": "input", + "x-icon": "sparkles", + }, + ) + """工具筛选预判使用的模型任务名""" + + tool_filter_threshold: int = Field( + default=20, + ge=1, + json_schema_extra={ + "x-widget": "input", + "x-icon": "filter", + }, + ) + """当可用工具总数超过该阈值时,先进行一轮工具筛选""" + + tool_filter_max_keep: int = Field( + default=5, + ge=1, + json_schema_extra={ + "x-widget": "input", + "x-icon": "list-filter", + }, + ) + """工具筛选阶段最多保留的非内置工具数量""" + terminal_image_preview: bool = Field( default=False, json_schema_extra={ diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 004538e0..9525f299 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -8,9 +8,11 @@ from time import perf_counter from typing import Any, Dict, List, Optional, Sequence import asyncio +import json import random from PIL import Image as PILImage +from pydantic import BaseModel, Field as PydanticField from rich.console import Group, RenderableType from rich.panel import Panel from rich.pretty import Pretty @@ -22,10 +24,11 @@ from src.common.data_models.message_component_data_model import MessageSequence, from src.common.logger import get_logger from src.common.prompt_i18n import load_prompt from src.config.config import global_config -from src.core.tooling import ToolRegistry +from src.core.tooling import ToolRegistry, ToolSpec from src.know_u.knowledge import extract_category_ids_from_result from src.llm_models.model_client.base_client import BaseClient from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType +from src.llm_models.payload_content.resp_format import RespFormat, RespFormatType from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, ToolOption, normalize_tool_options from src.services.llm_service import LLMServiceClient @@ -43,6 +46,13 @@ class ChatResponse: raw_message: AssistantMessage +class ToolFilterSelection(BaseModel): + """工具筛选响应。""" + + selected_tool_names: list[str] = PydanticField(default_factory=list) + """经过预筛后保留的候选工具名称列表。""" + + logger = get_logger("maisaka_chat_loop") @@ -76,6 +86,10 @@ class MaisakaChatLoopService: else: self._chat_system_prompt = chat_system_prompt self._llm_chat = LLMServiceClient(task_name="planner", request_type="maisaka_planner") + self._tool_filter_llm = LLMServiceClient( + task_name=global_config.maisaka.tool_filter_task_name, + request_type="maisaka_tool_filter", + ) @property def personality_prompt(self) -> str: @@ -156,7 +170,15 @@ class MaisakaChatLoopService: self._interrupt_flag = interrupt_flag def _build_request_messages(self, selected_history: List[LLMContextMessage]) -> List[Message]: - """构造发给大模型的消息列表。""" + """构造发给大模型的消息列表。 + + Args: + selected_history: 已选中的上下文消息列表。 + + Returns: + List[Message]: 发送给大模型的消息列表。 + """ + messages: List[Message] = [] system_msg = MessageBuilder().set_role(RoleType.System) system_msg.add_text_content(self._chat_system_prompt) @@ -169,6 +191,248 @@ class MaisakaChatLoopService: return messages + @staticmethod + def _is_builtin_tool_spec(tool_spec: ToolSpec) -> bool: + """判断一个工具是否属于默认内置工具。 + + Args: + tool_spec: 待判断的工具声明。 + + Returns: + bool: 是否为默认内置工具。 + """ + + return tool_spec.provider_type == "builtin" or tool_spec.provider_name == "maisaka_builtin" + + @classmethod + def _split_builtin_and_candidate_tools( + cls, + tool_specs: List[ToolSpec], + ) -> tuple[List[ToolSpec], List[ToolSpec]]: + """拆分内置工具与可筛选工具列表。 + + Args: + tool_specs: 当前全部工具声明。 + + Returns: + tuple[List[ToolSpec], List[ToolSpec]]: `(内置工具, 可筛选工具)`。 + """ + + builtin_tool_specs: List[ToolSpec] = [] + candidate_tool_specs: List[ToolSpec] = [] + for tool_spec in tool_specs: + if cls._is_builtin_tool_spec(tool_spec): + builtin_tool_specs.append(tool_spec) + else: + candidate_tool_specs.append(tool_spec) + return builtin_tool_specs, candidate_tool_specs + + @staticmethod + def _truncate_tool_filter_text(text: str, max_length: int = 180) -> str: + """截断工具筛选阶段展示的文本。 + + Args: + text: 原始文本。 + max_length: 最长保留字符数。 + + Returns: + str: 截断后的文本。 + """ + + normalized_text = text.strip() + if len(normalized_text) <= max_length: + return normalized_text + return f"{normalized_text[: max_length - 1]}…" + + def _build_tool_filter_prompt( + self, + selected_history: List[LLMContextMessage], + candidate_tool_specs: List[ToolSpec], + max_keep: int, + ) -> str: + """构造小模型工具预筛选提示词。 + + Args: + selected_history: 已选中的对话上下文。 + candidate_tool_specs: 非内置候选工具列表。 + max_keep: 最多保留的候选工具数量。 + + Returns: + str: 用于工具预筛的小模型提示词。 + """ + + history_lines: List[str] = [] + for message in selected_history[-10:]: + plain_text = message.processed_plain_text.strip() + if not plain_text: + continue + history_lines.append( + f"- {message.role}: {self._truncate_tool_filter_text(plain_text, max_length=200)}" + ) + + if history_lines: + history_section = "\n".join(history_lines) + else: + history_section = "- 当前没有可用的对话上下文。" + + tool_lines = [ + f"- {tool_spec.name}: {tool_spec.brief_description.strip() or '无简要描述'}" + for tool_spec in candidate_tool_specs + ] + tool_section = "\n".join(tool_lines) if tool_lines else "- 当前没有候选工具。" + + return ( + "你是 Maisaka 的工具预筛选器。\n" + "你的任务是在正式进入 planner 前,根据当前情景从候选工具中挑出最可能马上会用到的工具。\n" + "默认内置工具已经自动保留,不在候选列表中,你不需要再次选择它们。\n" + "你只能参考工具的简要描述,不要假设未描述的隐藏能力。\n" + f"最多保留 {max_keep} 个候选工具;如果都不合适,可以返回空数组。\n" + "请严格返回 JSON 对象,格式为:" + '{"selected_tool_names":["工具名1","工具名2"]}\n\n' + f"【最近对话】\n{history_section}\n\n" + f"【候选工具(仅简要描述)】\n{tool_section}" + ) + + @staticmethod + def _parse_tool_filter_response( + response_text: str, + candidate_tool_specs: List[ToolSpec], + max_keep: int, + ) -> List[ToolSpec] | None: + """解析工具预筛选响应。 + + Args: + response_text: 小模型返回的原始文本。 + candidate_tool_specs: 非内置候选工具列表。 + max_keep: 最多保留的候选工具数量。 + + Returns: + List[ToolSpec] | None: 成功解析时返回筛选后的工具列表;解析失败时返回 ``None``。 + """ + + normalized_response = response_text.strip() + if not normalized_response: + return None + + selected_tool_names: List[str] + try: + selected_tool_names = ToolFilterSelection.model_validate_json(normalized_response).selected_tool_names + except Exception: + try: + parsed_payload = json.loads(normalized_response) + except json.JSONDecodeError: + return None + + if isinstance(parsed_payload, dict): + raw_tool_names = parsed_payload.get("selected_tool_names", []) + elif isinstance(parsed_payload, list): + raw_tool_names = parsed_payload + else: + return None + + if not isinstance(raw_tool_names, list): + return None + + selected_tool_names = [] + for item in raw_tool_names: + normalized_name = str(item).strip() + if normalized_name: + selected_tool_names.append(normalized_name) + + candidate_map = {tool_spec.name: tool_spec for tool_spec in candidate_tool_specs} + filtered_tool_specs: List[ToolSpec] = [] + seen_names: set[str] = set() + for tool_name in selected_tool_names: + normalized_name = tool_name.strip() + if not normalized_name or normalized_name in seen_names: + continue + tool_spec = candidate_map.get(normalized_name) + if tool_spec is None: + continue + + seen_names.add(normalized_name) + filtered_tool_specs.append(tool_spec) + if len(filtered_tool_specs) >= max_keep: + break + + return filtered_tool_specs + + async def _filter_tool_specs_for_planner( + self, + selected_history: List[LLMContextMessage], + tool_specs: List[ToolSpec], + ) -> List[ToolSpec]: + """在将工具交给 planner 前进行快速预筛选。 + + Args: + selected_history: 已选中的对话上下文。 + tool_specs: 当前全部可用工具声明。 + + Returns: + List[ToolSpec]: 最终交给 planner 的工具声明列表。 + """ + + threshold = max(1, int(global_config.maisaka.tool_filter_threshold)) + max_keep = max(1, int(global_config.maisaka.tool_filter_max_keep)) + if len(tool_specs) <= threshold: + return tool_specs + + builtin_tool_specs, candidate_tool_specs = self._split_builtin_and_candidate_tools(tool_specs) + if not candidate_tool_specs: + return tool_specs + if len(candidate_tool_specs) <= max_keep: + return [*builtin_tool_specs, *candidate_tool_specs] + + filter_prompt = self._build_tool_filter_prompt(selected_history, candidate_tool_specs, max_keep) + logger.info( + "工具预筛选开始: " + f"总工具数={len(tool_specs)} " + f"内置工具数={len(builtin_tool_specs)} " + f"候选工具数={len(candidate_tool_specs)} " + f"最多保留候选数={max_keep}" + ) + + try: + generation_result = await self._tool_filter_llm.generate_response( + prompt=filter_prompt, + options=LLMGenerationOptions( + temperature=0.0, + max_tokens=256, + response_format=RespFormat( + format_type=RespFormatType.JSON_SCHEMA, + schema=ToolFilterSelection, + ), + ), + ) + except Exception as exc: + logger.warning(f"工具预筛选失败,保留全部工具。错误={exc}") + return tool_specs + + filtered_candidate_tool_specs = self._parse_tool_filter_response( + generation_result.response or "", + candidate_tool_specs, + max_keep, + ) + if filtered_candidate_tool_specs is None: + logger.warning( + "工具预筛选返回结果无法解析,保留全部工具。" + f" 原始返回={generation_result.response or ''!r}" + ) + return tool_specs + + filtered_tool_specs = [*builtin_tool_specs, *filtered_candidate_tool_specs] + if not filtered_tool_specs: + logger.warning("工具预筛选得到空结果,保留全部工具以避免主流程失去工具能力。") + return tool_specs + + logger.info( + "工具预筛选完成: " + f"筛选前总数={len(tool_specs)} " + f"筛选后总数={len(filtered_tool_specs)} " + f"保留候选工具={[tool_spec.name for tool_spec in filtered_candidate_tool_specs]}" + ) + return filtered_tool_specs + async def analyze_knowledge_need( self, chat_history: List[LLMContextMessage], @@ -206,6 +470,15 @@ class MaisakaChatLoopService: @staticmethod def _get_role_badge_style(role: str) -> str: + """返回终端中角色标签的样式。 + + Args: + role: 消息角色名称。 + + Returns: + str: Rich 可识别的样式字符串。 + """ + if role == "system": return "bold white on blue" if role == "user": @@ -218,6 +491,15 @@ class MaisakaChatLoopService: @staticmethod def _get_role_badge_label(role: str) -> str: + """返回终端中角色标签的中文名称。 + + Args: + role: 消息角色名称。 + + Returns: + str: 用于展示的中文角色名称。 + """ + if role == "system": return "系统" if role == "user": @@ -230,6 +512,15 @@ class MaisakaChatLoopService: @staticmethod def _build_terminal_image_preview(image_base64: str) -> Optional[str]: + """构造终端图片预览字符画。 + + Args: + image_base64: 图片的 Base64 编码。 + + Returns: + Optional[str]: 生成成功时返回字符画文本,否则返回 ``None``。 + """ + ascii_chars = " .:-=+*#%@" try: @@ -257,6 +548,15 @@ class MaisakaChatLoopService: @classmethod def _render_message_content(cls, content: Any) -> RenderableType: + """将消息内容渲染为终端可展示对象。 + + Args: + content: 原始消息内容。 + + Returns: + RenderableType: Rich 可渲染对象。 + """ + if isinstance(content, str): return Text(content) @@ -299,6 +599,15 @@ class MaisakaChatLoopService: @staticmethod def _format_tool_call_for_display(tool_call: Any) -> Dict[str, Any]: + """将工具调用对象格式化为易读字典。 + + Args: + tool_call: 原始工具调用对象或字典。 + + Returns: + Dict[str, Any]: 适合终端展示的工具调用字典。 + """ + if isinstance(tool_call, dict): function_info = tool_call.get("function", {}) return { @@ -314,6 +623,17 @@ class MaisakaChatLoopService: } def _render_tool_call_panel(self, tool_call: Any, index: int, parent_index: int) -> Panel: + """渲染单个工具调用面板。 + + Args: + tool_call: 原始工具调用对象。 + index: 工具调用在当前消息中的序号。 + parent_index: 所属消息的序号。 + + Returns: + Panel: 工具调用展示面板。 + """ + title = Text.assemble( Text(" 工具调用 ", style="bold white on magenta"), Text(f" #{parent_index}.{index}", style="muted"), @@ -326,6 +646,16 @@ class MaisakaChatLoopService: ) def _render_message_panel(self, message: Any, index: int) -> Panel: + """渲染单条消息面板。 + + Args: + message: 原始消息对象或字典。 + index: 消息序号。 + + Returns: + Panel: 终端展示面板。 + """ + if isinstance(message, dict): raw_role = message.get("role", "unknown") content = message.get("content") @@ -377,17 +707,28 @@ class MaisakaChatLoopService: await self.ensure_chat_prompt_loaded() selected_history, selection_reason = self._select_llm_context_messages(chat_history) + built_messages = self._build_request_messages(selected_history) def message_factory(_client: BaseClient) -> List[Message]: + """返回当前轮次已经构建好的请求消息。 + + Args: + _client: 当前模型客户端;此处不依赖客户端能力。 + + Returns: + List[Message]: 已经构建好的消息列表。 + """ + del _client - return self._build_request_messages(selected_history) + return built_messages all_tools: List[ToolDefinitionInput] if self._tool_registry is not None: - all_tools = await self._tool_registry.get_llm_definitions() + tool_specs = await self._tool_registry.list_tools() + filtered_tool_specs = await self._filter_tool_specs_for_planner(selected_history, tool_specs) + all_tools = [tool_spec.to_llm_definition() for tool_spec in filtered_tool_specs] else: all_tools = [*get_builtin_tools(), *self._extra_tools] - built_messages = self._build_request_messages(selected_history) ordered_panels: List[Panel] = [] for index, msg in enumerate(built_messages, start=1): @@ -454,7 +795,15 @@ class MaisakaChatLoopService: @staticmethod def _select_llm_context_messages(chat_history: List[LLMContextMessage]) -> tuple[List[LLMContextMessage], str]: - """选择真正发送给 LLM 的上下文消息。""" + """选择真正发送给 LLM 的上下文消息。 + + Args: + chat_history: 当前全部对话历史。 + + Returns: + tuple[List[LLMContextMessage], str]: `(已选上下文, 选择说明)`。 + """ + max_context_size = max(1, int(global_config.chat.max_context_size)) selected_indices: List[int] = [] counted_message_count = 0 @@ -485,6 +834,15 @@ class MaisakaChatLoopService: @staticmethod def build_chat_context(user_text: str) -> List[LLMContextMessage]: + """根据用户输入构造最小对话上下文。 + + Args: + user_text: 用户输入文本。 + + Returns: + List[LLMContextMessage]: 构造好的上下文消息列表。 + """ + timestamp = datetime.now() visible_text = format_speaker_content( global_config.maisaka.user_name.strip() or "用户",