From 5b92942194a9ba4797850abdcc5982c619e7d02b Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 1 Apr 2026 14:26:03 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat=EF=BC=9A=E4=BF=AE=E6=94=B9=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=BC=8F=EF=BC=8C=E5=90=88?= =?UTF-8?q?=E5=B9=B6no=5Freply=E5=92=8Cstop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prompts/zh-CN/maisaka_chat.prompt | 2 +- src/cli/maisaka_cli.py | 15 +- src/config/official_configs.py | 9 + src/maisaka/builtin_tools.py | 2 +- src/maisaka/prompt_cli_renderer.py | 335 +++++++++++++++++++++++++++++ src/maisaka/reasoning_engine.py | 84 ++++---- src/maisaka/runtime.py | 38 ++++ src/maisaka/tool_handlers.py | 13 -- 8 files changed, 427 insertions(+), 71 deletions(-) create mode 100644 src/maisaka/prompt_cli_renderer.py diff --git a/prompts/zh-CN/maisaka_chat.prompt b/prompts/zh-CN/maisaka_chat.prompt index 65945db6..40a233a5 100644 --- a/prompts/zh-CN/maisaka_chat.prompt +++ b/prompts/zh-CN/maisaka_chat.prompt @@ -14,7 +14,7 @@ 你可以使用这些工具: - wait(seconds) - 暂时停止对话,等待(seconds)秒,把话语权交给用户,等待对方新的发言。 - no_reply() - 当你判断{bot_name}现在不应该发言,结束对话,不进行任何回复,直到对方有新消息。 -- reply():当你判断{bot_name}现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。 +- reply():当你判断{bot_name}现在应该正式对用户发出一条可见回复时调用。调用后系统会基于你当前这轮的想法生成一条真正展示给用户的回复。你可以针对某个用户回复,也可以对所有用户回复。 - query_jargon():当你认为某些词的含义不明确,或用户询问某些词的含义,需要进行查询 - 其他定义的工具,你可以视情况合适使用 diff --git a/src/cli/maisaka_cli.py b/src/cli/maisaka_cli.py index 1174ea67..3aec362c 100644 --- a/src/cli/maisaka_cli.py +++ b/src/cli/maisaka_cli.py @@ -33,7 +33,6 @@ from src.maisaka.message_adapter import format_speaker_content from src.maisaka.tool_handlers import ( ToolHandlerContext, handle_mcp_tool, - handle_stop, handle_unknown_tool, handle_wait, ) @@ -230,9 +229,8 @@ class BufferCLI: Each round may produce internal thoughts and optionally call tools: - reply(msg_id): generate a visible reply for the current round - - no_reply(): skip visible output and continue the loop + - no_reply(): pause the inner loop until a new user message arrives - wait(seconds): wait for new user input - - stop(): stop the current inner loop and return to idle """ if self._chat_loop_service is None: return @@ -329,11 +327,7 @@ class BufferCLI: tool_context = self._build_tool_context() for tool_call in response.tool_calls: - if tool_call.func_name == "stop": - await handle_stop(tool_call, chat_history) - should_stop = True - - elif tool_call.func_name == "reply": + if tool_call.func_name == "reply": reply = await self._generate_visible_reply(chat_history, response.content or "") chat_history.append( ToolResultMessage( @@ -354,15 +348,16 @@ class BufferCLI: elif tool_call.func_name == "no_reply": if global_config.maisaka.show_thinking: - console.print("[muted]本轮未发送可见回复。[/muted]") + console.print("[muted]对话已暂停,等待新的输入...[/muted]") chat_history.append( ToolResultMessage( - content="本轮未发送可见回复。", + content="当前对话循环已暂停,等待新消息到来。", timestamp=datetime.now(), tool_call_id=tool_call.call_id, tool_name=tool_call.func_name, ) ) + should_stop = True elif tool_call.func_name == "wait": tool_result = await handle_wait(tool_call, chat_history, tool_context) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 3a10d5f8..dceb29cf 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1559,6 +1559,15 @@ class MaiSakaConfig(ConfigBase): ) """是否渲染低分辨率终端预览图片""" + terminal_image_display_mode: Literal["legacy", "path_link"] = Field( + default="legacy", + json_schema_extra={ + "x-widget": "select", + "x-icon": "image", + }, + ) + """图片展示模式:legacy(仅显示元信息)/ path_link(可点击本地路径)""" + terminal_image_preview_width: int = Field( default=24, ge=8, diff --git a/src/maisaka/builtin_tools.py b/src/maisaka/builtin_tools.py index ccb5f8c7..0222b173 100644 --- a/src/maisaka/builtin_tools.py +++ b/src/maisaka/builtin_tools.py @@ -73,7 +73,7 @@ def create_builtin_tool_specs() -> List[ToolSpec]: }, "quote": { "type": "boolean", - "description": "是否以引用回复的方式发送。", + "description": "当有非常明确的回复目标时,以引用回复的方式发送。", "default": True, }, "unknown_words": { diff --git a/src/maisaka/prompt_cli_renderer.py b/src/maisaka/prompt_cli_renderer.py new file mode 100644 index 00000000..104558be --- /dev/null +++ b/src/maisaka/prompt_cli_renderer.py @@ -0,0 +1,335 @@ +"""CLI 下的 Prompt 可视化渲染模块。""" + +from __future__ import annotations + +import hashlib +from base64 import b64decode +from dataclasses import dataclass +from enum import Enum +from io import BytesIO +from pathlib import Path +from urllib.parse import quote +from typing import Any, Dict, List, Literal, Optional + +import tempfile + +from PIL import Image as PILImage +from pydantic import BaseModel, Field as PydanticField +from rich.console import Group, RenderableType +from rich.pretty import Pretty +from rich.panel import Panel +from rich.text import Text + + +class PromptImageDisplayMode(str, Enum): + """图片在终端中的展示模式。""" + + LEGACY = "legacy" + """不新增链接,仅保留原有的元信息展示。""" + + PATH_LINK = "path_link" + """把图片落盘到临时目录并输出可点击路径。""" + + +class PromptImageDisplaySettings(BaseModel): + """图片展示参数。""" + + display_mode: PromptImageDisplayMode = PydanticField(default=PromptImageDisplayMode.LEGACY) + """图片展示模式。""" + + enable_terminal_preview: bool = PydanticField(default=False) + """是否开启低分辨率终端 ASCII 预览。""" + + terminal_preview_width: int = PydanticField(default=24, ge=1) + """终端预览宽度(字符数)。""" + + +@dataclass(slots=True) +class _MessageRenderResult: + """可渲染结果与是否有工具调用信息。""" + + message_panel: Panel + tool_call_panels: List[Panel] + + +class PromptCLIVisualizer: + """负责构建 CLI 下 prompt 展示所需的所有可视化组件。""" + + _ASCII_CHARS = " .:-=+*#%@" + + @staticmethod + def _get_role_badge_style(role: str) -> str: + if role == "system": + return "bold white on blue" + if role == "user": + return "bold black on green" + if role == "assistant": + return "bold black on yellow" + if role == "tool": + return "bold white on magenta" + return "bold white on bright_black" + + @staticmethod + def _get_role_badge_label(role: str) -> str: + if role == "system": + return "系统" + if role == "user": + return "用户" + if role == "assistant": + return "助手" + if role == "tool": + return "工具" + return "未知" + + @staticmethod + def _format_token_count(token_count: int) -> str: + if token_count >= 10_000: + return f"{token_count / 1000:.1f}k" + return str(token_count) + + @classmethod + def build_prompt_stats_text( + cls, + *, + selected_history_count: int, + built_message_count: int, + prompt_tokens: int, + completion_tokens: int, + total_tokens: int, + ) -> str: + """构造 prompt 统计文本。""" + return ( + f"上下文消息数量={selected_history_count} " + f"已构建消息数={built_message_count} " + f"实际输入Token={cls._format_token_count(prompt_tokens)} " + f"输出Token={cls._format_token_count(completion_tokens)} " + f"总Token={cls._format_token_count(total_tokens)}" + ) + + @staticmethod + def _normalize_image_format(image_format: str) -> str: + """归一化图片扩展名。""" + normalized = image_format.strip().lower() + if normalized == "jpg": + return "jpeg" + return normalized + + @staticmethod + def _build_terminal_image_preview(image_base64: str, *, preview_width: int) -> Optional[str]: + """从 base64 构建 ASCII 预览。""" + try: + image_bytes = b64decode(image_base64) + with PILImage.open(BytesIO(image_bytes)) as image: + grayscale = image.convert("L") + width, height = grayscale.size + if width <= 0 or height <= 0: + return None + + preview_width = max(8, preview_width) + preview_height = max(1, int(height * (preview_width / width) * 0.5)) + resized = grayscale.resize((preview_width, preview_height)) + pixels = list(resized.tobytes()) + except Exception: + return None + + rows: List[str] = [] + for row_index in range(preview_height): + row_pixels = pixels[row_index * preview_width : (row_index + 1) * preview_width] + row = "".join( + PromptCLIVisualizer._ASCII_CHARS[min(len(PromptCLIVisualizer._ASCII_CHARS) - 1, pixel * len(PromptCLIVisualizer._ASCII_CHARS) // 256)] + for pixel in row_pixels + ) + rows.append(row) + + return "\n".join(rows) + + @staticmethod + def _build_image_cache_path(image_format: str, image_base64: str) -> Path: + image_format = PromptCLIVisualizer._normalize_image_format(image_format) + root = Path(tempfile.gettempdir()) / "maisaka_prompt_images" + root.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(image_base64.encode("utf-8")).hexdigest() + return root / f"{digest}.{image_format}" + + @staticmethod + def _build_file_uri(file_path: Path) -> str: + normalized = file_path.as_posix() + return f"file:///{quote(normalized, safe='/:')}" + + @staticmethod + def _build_image_file_link(image_format: str, image_base64: str) -> tuple[str, Path] | None: + """把图片内容写入临时目录并返回可点击链接文本。""" + normalized_format = PromptCLIVisualizer._normalize_image_format(image_format) or "bin" + try: + image_bytes = b64decode(image_base64) + except Exception: + return None + + path = PromptCLIVisualizer._build_image_cache_path(normalized_format, image_base64) + if not path.exists(): + try: + path.write_bytes(image_bytes) + except Exception: + return None + return PromptCLIVisualizer._build_file_uri(path), path + + @classmethod + def _render_image_item(cls, image_format: str, image_base64: str, settings: PromptImageDisplaySettings) -> Panel: + normalized_format = cls._normalize_image_format(image_format) + approx_size = max(0, len(image_base64) * 3 // 4) + size_text = f"{approx_size / 1024:.1f} KB" if approx_size >= 1024 else f"{approx_size} B" + + preview_parts: List[RenderableType] = [ + Text(f"图片格式 image/{normalized_format} {size_text}", style="magenta") + ] + + if settings.display_mode == PromptImageDisplayMode.PATH_LINK: + path_result = cls._build_image_file_link(image_format, image_base64) + if path_result is not None: + file_uri, file_path = path_result + preview_parts.append(Text.from_markup(f"\n[link={file_uri}]点击打开图片[/link]", style="cyan")) + preview_parts.append(Text(f"\n{file_path}", style="dim")) + + if settings.enable_terminal_preview: + preview_text = cls._build_terminal_image_preview( + image_base64, + preview_width=settings.terminal_preview_width, + ) + if preview_text: + preview_parts.append(Text(preview_text, style="white")) + + return Panel( + Group(*preview_parts), + border_style="magenta", + padding=(0, 1), + ) + + @classmethod + def _render_message_content(cls, content: Any, settings: PromptImageDisplaySettings) -> RenderableType: + if isinstance(content, str): + return Text(content) + + if isinstance(content, list): + parts: List[RenderableType] = [] + for item in content: + if isinstance(item, str): + parts.append(Text(item)) + continue + if isinstance(item, tuple) and len(item) == 2: + image_format, image_base64 = item + if isinstance(image_format, str) and isinstance(image_base64, str): + parts.append(cls._render_image_item(image_format, image_base64, settings)) + continue + if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): + parts.append(Text(item["text"])) + else: + parts.append(Pretty(item, expand_all=True)) + return Group(*parts) if parts else Text("") + + if content is None: + return Text("") + + return Pretty(content, expand_all=True) + + @classmethod + def format_tool_call_for_display(cls, tool_call: Any) -> Dict[str, Any]: + if isinstance(tool_call, dict): + function_info = tool_call.get("function", {}) + return { + "id": tool_call.get("id"), + "name": function_info.get("name", tool_call.get("name")), + "arguments": function_info.get("arguments", tool_call.get("arguments")), + } + + return { + "id": getattr(tool_call, "call_id", getattr(tool_call, "id", None)), + "name": getattr(tool_call, "func_name", getattr(tool_call, "name", None)), + "arguments": getattr(tool_call, "args", getattr(tool_call, "arguments", None)), + } + + @classmethod + def _render_tool_call_panel(cls, tool_call: Any, index: int, parent_index: int) -> Panel: + title = Text.assemble( + Text(" 工具调用 ", style="bold white on magenta"), + Text(f" #{parent_index}.{index}", style="muted"), + ) + return Panel( + Pretty(cls.format_tool_call_for_display(tool_call), expand_all=True), + title=title, + border_style="magenta", + padding=(0, 1), + ) + + @classmethod + def _render_message_panel(cls, message: Any, index: int, settings: PromptImageDisplaySettings) -> _MessageRenderResult: + if isinstance(message, dict): + raw_role = message.get("role", "unknown") + content = message.get("content") + tool_call_id = message.get("tool_call_id") + else: + raw_role = getattr(message, "role", "unknown") + content = getattr(message, "content", None) + tool_call_id = getattr(message, "tool_call_id", None) + + role = raw_role.value if hasattr(raw_role, "value") else str(raw_role) + title = Text.assemble( + Text(f" {cls._get_role_badge_label(role)} ", style=cls._get_role_badge_style(role)), + Text(f" #{index}", style="muted"), + ) + + parts: List[RenderableType] = [] + if content not in (None, "", []): + parts.append(Text(" 内容 ", style="bold cyan")) + parts.append(cls._render_message_content(content, settings)) + + if tool_call_id: + parts.append( + Text.assemble( + Text(" 工具调用ID ", style="bold magenta"), + Text(" "), + Text(str(tool_call_id), style="magenta"), + ) + ) + + if not parts: + parts.append(Text("[空]", style="muted")) + + message_panel = Panel( + Group(*parts), + title=title, + border_style="dim", + padding=(0, 1), + ) + + tool_call_panels: List[Panel] = [] + tool_calls = getattr(message, "tool_calls", None) + if tool_calls: + for tool_call_index, tool_call in enumerate(tool_calls, start=1): + tool_call_panels.append(cls._render_tool_call_panel(tool_call, tool_call_index, index)) + + return _MessageRenderResult(message_panel=message_panel, tool_call_panels=tool_call_panels) + + @classmethod + def build_prompt_panels( + cls, + messages: list[Any], + *, + image_display_mode: Literal["legacy", "path_link"], + enable_terminal_image_preview: bool, + terminal_image_preview_width: int, + ) -> List[Panel]: + """构建完整 prompt 可视化面板。""" + if image_display_mode not in {mode.value for mode in PromptImageDisplayMode}: + image_display_mode = PromptImageDisplayMode.LEGACY + settings = PromptImageDisplaySettings( + display_mode=PromptImageDisplayMode(image_display_mode), + enable_terminal_preview=enable_terminal_image_preview, + terminal_preview_width=terminal_image_preview_width, + ) + + ordered_panels: List[Panel] = [] + for index, message in enumerate(messages, start=1): + message_render_result = cls._render_message_panel(message, index, settings) + ordered_panels.append(message_render_result.message_panel) + ordered_panels.extend(message_render_result.tool_call_panels) + return ordered_panels diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 4c7f86dc..01ca66bb 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -67,7 +67,6 @@ class MaisakaReasoningEngine: "query_jargon": self._invoke_query_jargon_tool, "query_person_info": self._invoke_query_person_info_tool, "wait": self._invoke_wait_tool, - "stop": self._invoke_stop_tool, "send_emoji": self._invoke_send_emoji_tool, } @@ -132,6 +131,10 @@ class MaisakaReasoningEngine: logger.info(f"{self._runtime.log_prefix} 当前思考与上一轮过于相似,已替换为重新思考提示") self._last_reasoning_content = reasoning_content + self._runtime._render_context_usage_panel( + selected_history_count=response.selected_history_count, + prompt_tokens=response.prompt_tokens, + ) self._runtime._chat_history.append(response.raw_message) if response.tool_calls: @@ -570,7 +573,7 @@ class MaisakaReasoningEngine: wait_seconds = invocation.arguments.get("seconds", 30) return f"你让当前对话先等待 {wait_seconds} 秒。" - if invocation.tool_name == "stop": + if invocation.tool_name == "no_reply": return "你暂停了当前对话循环,等待新的外部消息。" if invocation.tool_name == "query_jargon": @@ -760,7 +763,12 @@ class MaisakaReasoningEngine: """执行 no_reply 内置工具。""" del context - return self._build_tool_success_result(invocation.tool_name, "本轮未发送可见回复。") + self._runtime._enter_stop_state() + return self._build_tool_success_result( + invocation.tool_name, + "当前对话循环已暂停,等待新消息到来。", + metadata={"pause_execution": True}, + ) async def _invoke_query_jargon_tool( self, @@ -803,21 +811,6 @@ class MaisakaReasoningEngine: metadata={"pause_execution": True}, ) - async def _invoke_stop_tool( - self, - invocation: ToolInvocation, - context: Optional[ToolExecutionContext] = None, - ) -> ToolExecutionResult: - """执行 stop 内置工具。""" - - del context - self._runtime._enter_stop_state() - return self._build_tool_success_result( - invocation.tool_name, - "当前对话循环已暂停,等待新消息到来。", - metadata={"pause_execution": True}, - ) - async def _invoke_send_emoji_tool( self, invocation: ToolInvocation, @@ -1017,36 +1010,35 @@ class MaisakaReasoningEngine: .order_by(col(PersonInfo.last_known_time).desc(), col(PersonInfo.id).desc()) .limit(limit) ).all() + persons: list[dict[str, Any]] = [] + for record in records: + memory_points: list[str] = [] + if record.memory_points: + try: + parsed_points = json.loads(record.memory_points) + if isinstance(parsed_points, list): + memory_points = [str(point).strip() for point in parsed_points if str(point).strip()] + except (json.JSONDecodeError, TypeError, ValueError): + memory_points = [] - persons: list[dict[str, Any]] = [] - for record in records: - memory_points: list[str] = [] - if record.memory_points: - try: - parsed_points = json.loads(record.memory_points) - if isinstance(parsed_points, list): - memory_points = [str(point).strip() for point in parsed_points if str(point).strip()] - except (json.JSONDecodeError, TypeError, ValueError): - memory_points = [] + persons.append( + { + "person_id": record.person_id, + "person_name": record.person_name or "", + "user_nickname": record.user_nickname, + "user_id": record.user_id, + "platform": record.platform, + "name_reason": record.name_reason or "", + "is_known": record.is_known, + "know_counts": record.know_counts, + "memory_points": memory_points[:20], + "last_known_time": ( + record.last_known_time.isoformat() if record.last_known_time is not None else None + ), + } + ) - persons.append( - { - "person_id": record.person_id, - "person_name": record.person_name or "", - "user_nickname": record.user_nickname, - "user_id": record.user_id, - "platform": record.platform, - "name_reason": record.name_reason or "", - "is_known": record.is_known, - "know_counts": record.know_counts, - "memory_points": memory_points[:20], - "last_known_time": ( - record.last_known_time.isoformat() if record.last_known_time is not None else None - ), - } - ) - - return persons + return persons def _query_related_knowledge( self, diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py index 21c03a06..a4b67a23 100644 --- a/src/maisaka/runtime.py +++ b/src/maisaka/runtime.py @@ -5,6 +5,10 @@ from typing import Literal, Optional import asyncio import time +from rich.panel import Panel +from rich.text import Text + +from src.cli.console import console from src.chat.heart_flow.heartFC_utils import CycleDetail from src.chat.message_receive.chat_manager import BotChatSession, chat_manager from src.chat.message_receive.message import SessionMessage @@ -431,6 +435,40 @@ class MaisakaHeartFlowChatting: return GroupInfo(group_id=group_info.group_id, group_name=group_info.group_name) + @staticmethod + def _format_token_count(token_count: int) -> str: + """格式化 token 数量展示文本。""" + if token_count >= 10_000: + return f"{token_count / 1000:.1f}k" + return str(token_count) + + def _render_context_usage_panel( + self, + *, + selected_history_count: int, + prompt_tokens: int, + ) -> None: + """在终端展示当前聊天流的上下文占用情况。""" + if not global_config.maisaka.show_thinking: + return + + session_name = chat_manager.get_session_name(self.session_id) or self.session_id + body = "\n".join( + [ + f"聊天流: {session_name}", + f"Chat ID: {self.session_id}", + f"上下文占用: {selected_history_count}条 / {self._format_token_count(prompt_tokens)}", + ] + ) + console.print( + Panel( + Text(body), + title="MaiSaka 上下文占用", + border_style="bright_blue", + padding=(0, 1), + ) + ) + def _log_cycle_started(self, cycle_detail: CycleDetail, round_index: int) -> None: logger.info( f"{self.log_prefix} MaiSaka 轮次开始: 循环编号={cycle_detail.cycle_id} " diff --git a/src/maisaka/tool_handlers.py b/src/maisaka/tool_handlers.py index 57d98c9d..9aac35c1 100644 --- a/src/maisaka/tool_handlers.py +++ b/src/maisaka/tool_handlers.py @@ -32,19 +32,6 @@ class ToolHandlerContext: self.last_user_input_time: Optional[datetime] = None -async def handle_stop(tc: ToolCall, chat_history: list[LLMContextMessage]) -> None: - """处理 stop 工具。""" - console.print("[accent]调用工具: stop()[/accent]") - chat_history.append( - ToolResultMessage( - content="当前轮次结束后将停止对话循环。", - timestamp=datetime.now(), - tool_call_id=tc.call_id, - tool_name=tc.func_name, - ) - ) - - async def handle_wait(tc: ToolCall, chat_history: list[LLMContextMessage], ctx: ToolHandlerContext) -> str: """处理 wait 工具。""" seconds = (tc.args or {}).get("seconds", 30) From cecc58a9e7ec98461eb3c426a1991324505370ff Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 1 Apr 2026 16:21:30 +0800 Subject: [PATCH 02/18] =?UTF-8?q?feat=EF=BC=9A=E4=BD=BF=E7=94=A8url?= =?UTF-8?q?=E9=98=85=E8=A7=88=E5=9B=BE=E7=89=87=EF=BC=8C=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E8=A1=A8=E6=83=85=E5=8C=85=E7=B1=BB=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/official_configs.py | 19 -- src/maisaka/chat_loop_service.py | 286 ++--------------------------- src/maisaka/context_messages.py | 8 + src/maisaka/prompt_cli_renderer.py | 75 +++----- 4 files changed, 49 insertions(+), 339 deletions(-) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index dceb29cf..1dfad725 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1550,15 +1550,6 @@ class MaiSakaConfig(ConfigBase): ) """工具筛选阶段最多保留的非内置工具数量""" - terminal_image_preview: bool = Field( - default=False, - json_schema_extra={ - "x-widget": "switch", - "x-icon": "image", - }, - ) - """是否渲染低分辨率终端预览图片""" - terminal_image_display_mode: Literal["legacy", "path_link"] = Field( default="legacy", json_schema_extra={ @@ -1568,16 +1559,6 @@ class MaiSakaConfig(ConfigBase): ) """图片展示模式:legacy(仅显示元信息)/ path_link(可点击本地路径)""" - terminal_image_preview_width: int = Field( - default=24, - ge=8, - json_schema_extra={ - "x-widget": "input", - "x-icon": "columns", - }, - ) - """Maisaka终端图片预览的字符宽度""" - class MCPAuthorizationConfig(ConfigBase): """MCP HTTP 认证配置。""" diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 58724a4f..ccdf78cd 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -1,22 +1,17 @@ """Maisaka 对话循环服务。""" -from base64 import b64decode from dataclasses import dataclass from datetime import datetime -from io import BytesIO from time import perf_counter -from typing import Any, Dict, List, Optional, Sequence +from typing import 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.console import Group from rich.panel import Panel -from rich.pretty import Pretty -from rich.text import Text from src.cli.console import console from src.common.data_models.llm_service_data_models import LLMGenerationOptions @@ -35,6 +30,7 @@ from src.services.llm_service import LLMServiceClient from .builtin_tools import get_builtin_tools from .context_messages import AssistantMessage, LLMContextMessage, SessionBackedMessage from .message_adapter import format_speaker_content +from .prompt_cli_renderer import PromptCLIVisualizer @dataclass(slots=True) @@ -44,6 +40,11 @@ class ChatResponse: content: Optional[str] tool_calls: List[ToolCall] raw_message: AssistantMessage + selected_history_count: int + prompt_tokens: int + built_message_count: int + completion_tokens: int + total_tokens: int class ToolFilterSelection(BaseModel): @@ -468,259 +469,6 @@ class MaisakaChatLoopService: return extract_category_ids_from_result(generation_result.response or "") - @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": - return "bold black on green" - if role == "assistant": - return "bold black on yellow" - if role == "tool": - return "bold white on magenta" - return "bold white on bright_black" - - @staticmethod - def _get_role_badge_label(role: str) -> str: - """返回终端中角色标签的中文名称。 - - Args: - role: 消息角色名称。 - - Returns: - str: 用于展示的中文角色名称。 - """ - - if role == "system": - return "系统" - if role == "user": - return "用户" - if role == "assistant": - return "助手" - if role == "tool": - return "工具" - return "未知" - - @staticmethod - def _build_terminal_image_preview(image_base64: str) -> Optional[str]: - """构造终端图片预览字符画。 - - Args: - image_base64: 图片的 Base64 编码。 - - Returns: - Optional[str]: 生成成功时返回字符画文本,否则返回 ``None``。 - """ - - ascii_chars = " .:-=+*#%@" - - try: - image_bytes = b64decode(image_base64) - with PILImage.open(BytesIO(image_bytes)) as image: - grayscale = image.convert("L") - width, height = grayscale.size - if width <= 0 or height <= 0: - return None - - preview_width = max(8, int(global_config.maisaka.terminal_image_preview_width)) - preview_height = max(1, int(height * (preview_width / width) * 0.5)) - resized = grayscale.resize((preview_width, preview_height)) - pixels = list(resized.tobytes()) - except Exception: - return None - - rows: List[str] = [] - for row_index in range(preview_height): - row_pixels = pixels[row_index * preview_width : (row_index + 1) * preview_width] - row = "".join(ascii_chars[min(len(ascii_chars) - 1, pixel * len(ascii_chars) // 256)] for pixel in row_pixels) - rows.append(row) - - return "\n".join(rows) - - @classmethod - def _render_message_content(cls, content: Any) -> RenderableType: - """将消息内容渲染为终端可展示对象。 - - Args: - content: 原始消息内容。 - - Returns: - RenderableType: Rich 可渲染对象。 - """ - - if isinstance(content, str): - return Text(content) - - if isinstance(content, list): - parts: List[RenderableType] = [] - for item in content: - if isinstance(item, str): - parts.append(Text(item)) - continue - if isinstance(item, tuple) and len(item) == 2: - image_format, image_base64 = item - if isinstance(image_format, str) and isinstance(image_base64, str): - approx_size = max(0, len(image_base64) * 3 // 4) - size_text = f"{approx_size / 1024:.1f} KB" if approx_size >= 1024 else f"{approx_size} B" - preview_parts: List[RenderableType] = [ - Text(f"图片格式 image/{image_format} {size_text}\nbase64 内容已省略", style="magenta") - ] - if global_config.maisaka.terminal_image_preview: - preview_text = cls._build_terminal_image_preview(image_base64) - if preview_text: - preview_parts.append(Text(preview_text, style="white")) - parts.append( - Panel( - Group(*preview_parts), - border_style="magenta", - padding=(0, 1), - ) - ) - continue - if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str): - parts.append(Text(item["text"])) - else: - parts.append(Pretty(item, expand_all=True)) - return Group(*parts) if parts else Text("") - - if content is None: - return Text("") - - return Pretty(content, expand_all=True) - - @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 { - "id": tool_call.get("id"), - "name": function_info.get("name", tool_call.get("name")), - "arguments": function_info.get("arguments", tool_call.get("arguments")), - } - - return { - "id": getattr(tool_call, "call_id", getattr(tool_call, "id", None)), - "name": getattr(tool_call, "func_name", getattr(tool_call, "name", None)), - "arguments": getattr(tool_call, "args", getattr(tool_call, "arguments", None)), - } - - 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"), - ) - return Panel( - Pretty(self._format_tool_call_for_display(tool_call), expand_all=True), - title=title, - border_style="magenta", - padding=(0, 1), - ) - - 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") - tool_call_id = message.get("tool_call_id") - else: - raw_role = getattr(message, "role", "unknown") - content = getattr(message, "content", None) - tool_call_id = getattr(message, "tool_call_id", None) - - role = raw_role.value if isinstance(raw_role, RoleType) else str(raw_role) - title = Text.assemble( - Text(f" {self._get_role_badge_label(role)} ", style=self._get_role_badge_style(role)), - Text(f" #{index}", style="muted"), - ) - - parts: List[RenderableType] = [] - if content not in (None, "", []): - parts.append(Text(" 消息 ", style="bold cyan")) - parts.append(self._render_message_content(content)) - - if tool_call_id: - parts.append( - Text.assemble( - Text(" 工具调用编号 ", style="bold magenta"), - Text(" "), - Text(str(tool_call_id), style="magenta"), - ) - ) - - if not parts: - parts.append(Text("[空消息]", style="muted")) - - return Panel( - Group(*parts), - title=title, - border_style="dim", - padding=(0, 1), - ) - - @staticmethod - def _format_token_count(token_count: int) -> str: - """格式化 token 数量展示文本。""" - if token_count >= 10_000: - return f"{token_count / 1000:.1f}k" - return str(token_count) - - @classmethod - def _build_prompt_stats_text( - cls, - *, - selected_history_count: int, - built_message_count: int, - prompt_tokens: int, - completion_tokens: int, - total_tokens: int, - ) -> str: - """构造本轮 prompt 的统计信息文本。""" - return ( - f"已选上下文消息数={selected_history_count} " - f"大模型消息数={built_message_count} " - f"实际输入Token={cls._format_token_count(prompt_tokens)} " - f"输出Token={cls._format_token_count(completion_tokens)} " - f"总Token={cls._format_token_count(total_tokens)}" - ) - async def chat_loop_step(self, chat_history: List[LLMContextMessage]) -> ChatResponse: """执行一轮 Maisaka 规划器请求。 @@ -756,13 +504,10 @@ class MaisakaChatLoopService: else: all_tools = [*get_builtin_tools(), *self._extra_tools] - ordered_panels: List[Panel] = [] - for index, msg in enumerate(built_messages, start=1): - ordered_panels.append(self._render_message_panel(msg, index)) - tool_calls = getattr(msg, "tool_calls", None) - if tool_calls: - for tool_call_index, tool_call in enumerate(tool_calls, start=1): - ordered_panels.append(self._render_tool_call_panel(tool_call, tool_call_index, index)) + ordered_panels = PromptCLIVisualizer.build_prompt_panels( + built_messages, + image_display_mode=global_config.maisaka.terminal_image_display_mode, + ) if global_config.maisaka.show_thinking and ordered_panels: console.print( @@ -795,7 +540,7 @@ class MaisakaChatLoopService: request_elapsed = perf_counter() - request_started_at logger.info(f"规划器请求完成,耗时={request_elapsed:.3f} 秒") - prompt_stats_text = self._build_prompt_stats_text( + prompt_stats_text = PromptCLIVisualizer.build_prompt_stats_text( selected_history_count=len(selected_history), built_message_count=len(built_messages), prompt_tokens=generation_result.prompt_tokens, @@ -826,6 +571,11 @@ class MaisakaChatLoopService: content=generation_result.response, tool_calls=generation_result.tool_calls or [], raw_message=raw_message, + selected_history_count=len(selected_history), + prompt_tokens=generation_result.prompt_tokens, + built_message_count=len(built_messages), + completion_tokens=generation_result.completion_tokens, + total_tokens=generation_result.total_tokens, ) @staticmethod diff --git a/src/maisaka/context_messages.py b/src/maisaka/context_messages.py index 8da06a23..174da097 100644 --- a/src/maisaka/context_messages.py +++ b/src/maisaka/context_messages.py @@ -27,6 +27,13 @@ def _guess_image_format(image_bytes: bytes) -> Optional[str]: return None +def _build_binary_component_type_text(component: EmojiComponent | ImageComponent) -> str: + """为图片类消息组件构造显式的消息类型标记。""" + if isinstance(component, EmojiComponent): + return "[消息类型]表情包" + return "[消息类型]图片" + + def _build_message_from_sequence( role: RoleType, message_sequence: MessageSequence, @@ -53,6 +60,7 @@ def _build_message_from_sequence( if isinstance(component, (EmojiComponent, ImageComponent)): image_format = _guess_image_format(component.binary_data) if image_format and component.binary_data: + builder.add_text_content(_build_binary_component_type_text(component)) builder.add_image_content(image_format, base64.b64encode(component.binary_data).decode("utf-8")) has_content = True continue diff --git a/src/maisaka/prompt_cli_renderer.py b/src/maisaka/prompt_cli_renderer.py index 104558be..7b4301c7 100644 --- a/src/maisaka/prompt_cli_renderer.py +++ b/src/maisaka/prompt_cli_renderer.py @@ -6,20 +6,21 @@ import hashlib from base64 import b64decode from dataclasses import dataclass from enum import Enum -from io import BytesIO from pathlib import Path from urllib.parse import quote -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal import tempfile -from PIL import Image as PILImage from pydantic import BaseModel, Field as PydanticField from rich.console import Group, RenderableType from rich.pretty import Pretty from rich.panel import Panel from rich.text import Text +PROJECT_ROOT = Path(__file__).parent.parent.parent.absolute().resolve() +DATA_IMAGE_DIR = PROJECT_ROOT / "data" / "images" + class PromptImageDisplayMode(str, Enum): """图片在终端中的展示模式。""" @@ -37,11 +38,6 @@ class PromptImageDisplaySettings(BaseModel): display_mode: PromptImageDisplayMode = PydanticField(default=PromptImageDisplayMode.LEGACY) """图片展示模式。""" - enable_terminal_preview: bool = PydanticField(default=False) - """是否开启低分辨率终端 ASCII 预览。""" - - terminal_preview_width: int = PydanticField(default=24, ge=1) - """终端预览宽度(字符数)。""" @dataclass(slots=True) @@ -55,8 +51,6 @@ class _MessageRenderResult: class PromptCLIVisualizer: """负责构建 CLI 下 prompt 展示所需的所有可视化组件。""" - _ASCII_CHARS = " .:-=+*#%@" - @staticmethod def _get_role_badge_style(role: str) -> str: if role == "system": @@ -114,35 +108,6 @@ class PromptCLIVisualizer: return "jpeg" return normalized - @staticmethod - def _build_terminal_image_preview(image_base64: str, *, preview_width: int) -> Optional[str]: - """从 base64 构建 ASCII 预览。""" - try: - image_bytes = b64decode(image_base64) - with PILImage.open(BytesIO(image_bytes)) as image: - grayscale = image.convert("L") - width, height = grayscale.size - if width <= 0 or height <= 0: - return None - - preview_width = max(8, preview_width) - preview_height = max(1, int(height * (preview_width / width) * 0.5)) - resized = grayscale.resize((preview_width, preview_height)) - pixels = list(resized.tobytes()) - except Exception: - return None - - rows: List[str] = [] - for row_index in range(preview_height): - row_pixels = pixels[row_index * preview_width : (row_index + 1) * preview_width] - row = "".join( - PromptCLIVisualizer._ASCII_CHARS[min(len(PromptCLIVisualizer._ASCII_CHARS) - 1, pixel * len(PromptCLIVisualizer._ASCII_CHARS) // 256)] - for pixel in row_pixels - ) - rows.append(row) - - return "\n".join(rows) - @staticmethod def _build_image_cache_path(image_format: str, image_base64: str) -> Path: image_format = PromptCLIVisualizer._normalize_image_format(image_format) @@ -156,10 +121,28 @@ class PromptCLIVisualizer: normalized = file_path.as_posix() return f"file:///{quote(normalized, safe='/:')}" + @staticmethod + def _build_official_image_path(image_format: str, image_base64: str) -> Path | None: + normalized_format = PromptCLIVisualizer._normalize_image_format(image_format) + try: + image_bytes = b64decode(image_base64) + except Exception: + return None + + digest = hashlib.sha256(image_bytes).hexdigest() + official_path = DATA_IMAGE_DIR / f"{digest}.{normalized_format}" + if official_path.exists(): + return official_path + return None + @staticmethod def _build_image_file_link(image_format: str, image_base64: str) -> tuple[str, Path] | None: - """把图片内容写入临时目录并返回可点击链接文本。""" + """优先返回正式图片路径;不存在时回退到临时缓存路径。""" normalized_format = PromptCLIVisualizer._normalize_image_format(image_format) or "bin" + official_path = PromptCLIVisualizer._build_official_image_path(image_format, image_base64) + if official_path is not None: + return PromptCLIVisualizer._build_file_uri(official_path), official_path + try: image_bytes = b64decode(image_base64) except Exception: @@ -190,14 +173,6 @@ class PromptCLIVisualizer: preview_parts.append(Text.from_markup(f"\n[link={file_uri}]点击打开图片[/link]", style="cyan")) preview_parts.append(Text(f"\n{file_path}", style="dim")) - if settings.enable_terminal_preview: - preview_text = cls._build_terminal_image_preview( - image_base64, - preview_width=settings.terminal_preview_width, - ) - if preview_text: - preview_parts.append(Text(preview_text, style="white")) - return Panel( Group(*preview_parts), border_style="magenta", @@ -315,16 +290,12 @@ class PromptCLIVisualizer: messages: list[Any], *, image_display_mode: Literal["legacy", "path_link"], - enable_terminal_image_preview: bool, - terminal_image_preview_width: int, ) -> List[Panel]: """构建完整 prompt 可视化面板。""" if image_display_mode not in {mode.value for mode in PromptImageDisplayMode}: image_display_mode = PromptImageDisplayMode.LEGACY settings = PromptImageDisplaySettings( display_mode=PromptImageDisplayMode(image_display_mode), - enable_terminal_preview=enable_terminal_image_preview, - terminal_preview_width=terminal_image_preview_width, ) ordered_panels: List[Panel] = [] From f2aedf7681a9b700c588640740eb85794307fc8d Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 1 Apr 2026 17:06:44 +0800 Subject: [PATCH 03/18] =?UTF-8?q?remove=EF=BC=9Aemoji=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8Dtool=E8=A2=AB=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/emoji_system/maisaka_tool.py | 202 ++++++++++++++++++ src/maisaka/chat_loop_service.py | 33 ++- src/maisaka/reasoning_engine.py | 107 +++++++++- .../built_in/emoji_plugin/_manifest.json | 41 ---- src/plugins/built_in/emoji_plugin/plugin.py | 129 ----------- 5 files changed, 339 insertions(+), 173 deletions(-) create mode 100644 src/chat/emoji_system/maisaka_tool.py delete mode 100644 src/plugins/built_in/emoji_plugin/_manifest.json delete mode 100644 src/plugins/built_in/emoji_plugin/plugin.py diff --git a/src/chat/emoji_system/maisaka_tool.py b/src/chat/emoji_system/maisaka_tool.py new file mode 100644 index 00000000..1a9885bc --- /dev/null +++ b/src/chat/emoji_system/maisaka_tool.py @@ -0,0 +1,202 @@ +"""Maisaka 表情工具内置能力。""" + +from dataclasses import dataclass, field +from typing import Sequence + +import random + +from src.common.data_models.image_data_model import MaiEmoji +from src.common.data_models.llm_service_data_models import LLMGenerationOptions +from src.common.logger import get_logger +from src.common.utils.utils_image import ImageUtils +from src.services import send_service + +from .emoji_manager import emoji_manager, emoji_manager_emotion_judge_llm + +logger = get_logger("emoji_maisaka_tool") + + +@dataclass(slots=True) +class MaisakaEmojiSendResult: + """Maisaka 表情发送结果。""" + + success: bool + message: str + emoji_base64: str = "" + description: str = "" + emotions: list[str] = field(default_factory=list) + requested_emotion: str = "" + matched_emotion: str = "" + + +def _normalize_emotions(emoji: MaiEmoji) -> list[str]: + """提取并清洗单个表情的情绪标签。""" + + return [str(item).strip() for item in emoji.emotion if str(item).strip()] + + +def _build_recent_context_text(context_texts: Sequence[str], max_items: int = 5) -> str: + """构建供情绪判断使用的最近上下文文本。""" + + normalized_items = [str(item).strip() for item in context_texts if str(item).strip()] + if not normalized_items: + return "" + return "\n".join(normalized_items[-max_items:]) + + +async def _select_emoji_with_llm( + *, + sampled_emojis: Sequence[MaiEmoji], + reasoning: str, + context_text: str, +) -> tuple[MaiEmoji, str]: + """让模型在采样表情中选择更合适的情绪标签。""" + + emotion_map: dict[str, list[MaiEmoji]] = {} + for emoji in sampled_emojis: + for emotion in _normalize_emotions(emoji): + emotion_map.setdefault(emotion, []).append(emoji) + + available_emotions = list(emotion_map.keys()) + if not available_emotions: + return random.choice(list(sampled_emojis)), "" + + prompt = ( + "你正在为聊天场景选择一个最合适的表情包情绪标签。\n" + f"发送原因:{reasoning or '辅助表达当前语气和情绪'}\n" + f"最近聊天记录:\n{context_text or '(暂无额外上下文)'}\n\n" + "可选情绪标签如下:\n" + f"{chr(10).join(available_emotions)}\n\n" + "请只返回一个最匹配的情绪标签,不要解释。" + ) + + try: + llm_result = await emoji_manager_emotion_judge_llm.generate_response( + prompt, + options=LLMGenerationOptions(temperature=0.3, max_tokens=60), + ) + chosen_emotion = (llm_result.response or "").strip().strip("\"'") + except Exception as exc: + logger.warning(f"使用 LLM 选择表情情绪失败,将回退为随机选择: {exc}") + chosen_emotion = "" + + if chosen_emotion and chosen_emotion in emotion_map: + return random.choice(emotion_map[chosen_emotion]), chosen_emotion + return random.choice(list(sampled_emojis)), "" + + +async def select_emoji_for_maisaka( + *, + requested_emotion: str = "", + reasoning: str = "", + context_texts: Sequence[str] | None = None, + sample_size: int = 30, +) -> tuple[MaiEmoji | None, str]: + """为 Maisaka 选择一个合适的表情。""" + + available_emojis = list(emoji_manager.emojis) + if not available_emojis: + return None, "" + + normalized_requested_emotion = requested_emotion.strip() + if normalized_requested_emotion: + matched_emojis = [ + emoji + for emoji in available_emojis + if normalized_requested_emotion.lower() in (emotion.lower() for emotion in _normalize_emotions(emoji)) + ] + if matched_emojis: + return random.choice(matched_emojis), normalized_requested_emotion + + sampled_emojis = random.sample( + available_emojis, + min(max(sample_size, 1), len(available_emojis)), + ) + context_text = _build_recent_context_text(context_texts or []) + return await _select_emoji_with_llm( + sampled_emojis=sampled_emojis, + reasoning=reasoning, + context_text=context_text, + ) + + +async def send_emoji_for_maisaka( + *, + stream_id: str, + requested_emotion: str = "", + reasoning: str = "", + context_texts: Sequence[str] | None = None, +) -> MaisakaEmojiSendResult: + """为 Maisaka 选择并发送一个表情。""" + + selected_emoji, matched_emotion = await select_emoji_for_maisaka( + requested_emotion=requested_emotion, + reasoning=reasoning, + context_texts=context_texts, + ) + if selected_emoji is None: + return MaisakaEmojiSendResult( + success=False, + message="当前表情包库中没有可用表情。", + requested_emotion=requested_emotion.strip(), + ) + + try: + emoji_base64 = ImageUtils.image_path_to_base64(str(selected_emoji.full_path)) + if not emoji_base64: + raise ValueError("表情图片转换为 base64 失败") + except Exception as exc: + return MaisakaEmojiSendResult( + success=False, + message=f"发送表情包失败:{exc}", + description=selected_emoji.description.strip(), + emotions=_normalize_emotions(selected_emoji), + requested_emotion=requested_emotion.strip(), + matched_emotion=matched_emotion, + ) + + try: + sent = await send_service.emoji_to_stream( + emoji_base64=emoji_base64, + stream_id=stream_id, + storage_message=True, + set_reply=False, + reply_message=None, + ) + except Exception as exc: + return MaisakaEmojiSendResult( + success=False, + message=f"发送表情包时发生异常:{exc}", + description=selected_emoji.description.strip(), + emotions=_normalize_emotions(selected_emoji), + requested_emotion=requested_emotion.strip(), + matched_emotion=matched_emotion, + ) + + description = selected_emoji.description.strip() + emotions = _normalize_emotions(selected_emoji) + if not sent: + return MaisakaEmojiSendResult( + success=False, + message="发送表情包失败。", + description=description, + emotions=emotions, + requested_emotion=requested_emotion.strip(), + matched_emotion=matched_emotion, + ) + + emoji_manager.update_emoji_usage(selected_emoji) + success_message = ( + f"已发送表情包:{description}(情绪:{', '.join(emotions)})" + if emotions + else f"已发送表情包:{description}" + ) + return MaisakaEmojiSendResult( + success=True, + message=success_message, + emoji_base64=emoji_base64, + description=description, + emotions=emotions, + requested_emotion=requested_emotion.strip(), + matched_emotion=matched_emotion, + ) diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index ccdf78cd..038fc0f0 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -28,7 +28,7 @@ from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionI from src.services.llm_service import LLMServiceClient from .builtin_tools import get_builtin_tools -from .context_messages import AssistantMessage, LLMContextMessage, SessionBackedMessage +from .context_messages import AssistantMessage, LLMContextMessage, SessionBackedMessage, ToolResultMessage from .message_adapter import format_speaker_content from .prompt_cli_renderer import PromptCLIVisualizer @@ -609,6 +609,7 @@ class MaisakaChatLoopService: selected_indices.reverse() selected_history = [chat_history[index] for index in selected_indices] + selected_history = MaisakaChatLoopService._drop_leading_orphan_tool_results(selected_history) return ( selected_history, ( @@ -617,6 +618,36 @@ class MaisakaChatLoopService: ), ) + @staticmethod + def _drop_leading_orphan_tool_results( + selected_history: List[LLMContextMessage], + ) -> List[LLMContextMessage]: + """移除窗口前缀中缺少对应 tool_call 的工具结果消息。""" + + if not selected_history: + return selected_history + + available_tool_call_ids = { + tool_call.call_id + for message in selected_history + if isinstance(message, AssistantMessage) + for tool_call in message.tool_calls + if tool_call.call_id + } + + first_valid_index = 0 + while first_valid_index < len(selected_history): + message = selected_history[first_valid_index] + if not isinstance(message, ToolResultMessage): + break + if message.tool_call_id in available_tool_call_ids: + break + first_valid_index += 1 + + if first_valid_index == 0: + return selected_history + return selected_history[first_valid_index:] + @staticmethod def build_chat_context(user_text: str) -> List[LLMContextMessage]: """根据用户输入构造最小对话上下文。 diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py index 01ca66bb..e2f3d5bb 100644 --- a/src/maisaka/reasoning_engine.py +++ b/src/maisaka/reasoning_engine.py @@ -328,15 +328,48 @@ class MaisakaReasoningEngine: trimmed_history = list(self._runtime._chat_history) removed_count = 0 - while conversation_message_count >= self._runtime._max_context_size and trimmed_history: + while conversation_message_count > self._runtime._max_context_size and trimmed_history: removed_message = trimmed_history.pop(0) removed_count += 1 if removed_message.count_in_context: conversation_message_count -= 1 + trimmed_history, pruned_orphan_count = self._drop_leading_orphan_tool_results(trimmed_history) + removed_count += pruned_orphan_count + self._runtime._chat_history = trimmed_history self._runtime._log_history_trimmed(removed_count, conversation_message_count) + @staticmethod + def _drop_leading_orphan_tool_results( + chat_history: list[LLMContextMessage], + ) -> tuple[list[LLMContextMessage], int]: + """清理历史前缀中缺少对应 assistant tool_call 的工具结果消息。""" + + if not chat_history: + return chat_history, 0 + + available_tool_call_ids = { + tool_call.call_id + for message in chat_history + if isinstance(message, AssistantMessage) + for tool_call in message.tool_calls + if tool_call.call_id + } + + first_valid_index = 0 + while first_valid_index < len(chat_history): + message = chat_history[first_valid_index] + if not isinstance(message, ToolResultMessage): + break + if message.tool_call_id in available_tool_call_ids: + break + first_valid_index += 1 + + if first_valid_index == 0: + return chat_history, 0 + return chat_history[first_valid_index:], first_valid_index + @staticmethod def _calculate_similarity(text1: str, text2: str) -> float: """计算两个文本之间的相似度。 @@ -819,7 +852,77 @@ class MaisakaReasoningEngine: """执行 send_emoji 内置工具。""" del context - return await self._handle_send_emoji(self._build_tool_call_from_invocation(invocation)) + return await self._invoke_builtin_send_emoji(self._build_tool_call_from_invocation(invocation)) + + async def _invoke_builtin_send_emoji(self, tool_call: ToolCall) -> ToolExecutionResult: + """执行内置表情工具。""" + from src.chat.emoji_system.maisaka_tool import send_emoji_for_maisaka + + tool_args = tool_call.args or {} + emotion = str(tool_args.get("emotion") or "").strip() + context_texts = [ + message.get_history_text() + for message in self._runtime._chat_history[-5:] + if message.get_history_text().strip() + ] + structured_result: dict[str, Any] = { + "success": False, + "message": "", + "description": "", + "emotion": [], + "requested_emotion": emotion, + "matched_emotion": "", + } + + logger.info(f"{self._runtime.log_prefix} 触发表情包发送工具,请求情绪={emotion!r}") + + try: + send_result = await send_emoji_for_maisaka( + stream_id=self._runtime.session_id, + requested_emotion=emotion, + reasoning=self._last_reasoning_content, + context_texts=context_texts, + ) + except Exception as exc: + logger.exception(f"{self._runtime.log_prefix} 发送表情包时发生异常: {exc}") + structured_result["message"] = f"发送表情包时发生异常:{exc}" + return self._build_tool_failure_result( + tool_call.func_name, + structured_result["message"], + structured_content=structured_result, + ) + + structured_result["description"] = send_result.description + structured_result["emotion"] = list(send_result.emotions) + structured_result["matched_emotion"] = send_result.matched_emotion + structured_result["message"] = send_result.message + + if send_result.success: + logger.info( + f"{self._runtime.log_prefix} 表情包发送成功: " + f"描述={send_result.description!r} 情绪标签={send_result.emotions} " + f"请求情绪={emotion!r} 命中情绪={send_result.matched_emotion!r}" + ) + self._append_sent_emoji_to_chat_history( + emoji_base64=send_result.emoji_base64, + success_message=send_result.message, + ) + structured_result["success"] = True + return self._build_tool_success_result( + tool_call.func_name, + send_result.message, + structured_content=structured_result, + ) + + logger.warning( + f"{self._runtime.log_prefix} 表情包发送失败: " + f"请求情绪={emotion!r} 错误信息={send_result.message}" + ) + return self._build_tool_failure_result( + tool_call.func_name, + structured_result["message"], + structured_content=structured_result, + ) async def _handle_tool_calls( self, diff --git a/src/plugins/built_in/emoji_plugin/_manifest.json b/src/plugins/built_in/emoji_plugin/_manifest.json deleted file mode 100644 index 5b53abad..00000000 --- a/src/plugins/built_in/emoji_plugin/_manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "manifest_version": 2, - "version": "2.0.0", - "name": "Emoji插件 (Emoji Actions)", - "description": "可以发送和管理 Emoji", - "author": { - "name": "SengokuCola", - "url": "https://github.com/MaiM-with-u" - }, - "license": "GPL-v3.0-or-later", - "urls": { - "repository": "https://github.com/MaiM-with-u/maibot", - "homepage": "https://github.com/MaiM-with-u/maibot", - "documentation": "https://github.com/MaiM-with-u/maibot", - "issues": "https://github.com/MaiM-with-u/maibot/issues" - }, - "host_application": { - "min_version": "1.0.0", - "max_version": "1.0.0" - }, - "sdk": { - "min_version": "2.0.0", - "max_version": "2.99.99" - }, - "dependencies": [], - "capabilities": [ - "emoji.get_random", - "message.get_recent", - "message.build_readable", - "llm.generate", - "send.emoji", - "config.get" - ], - "i18n": { - "default_locale": "zh-CN", - "supported_locales": [ - "zh-CN" - ] - }, - "id": "builtin.emoji-plugin" -} diff --git a/src/plugins/built_in/emoji_plugin/plugin.py b/src/plugins/built_in/emoji_plugin/plugin.py deleted file mode 100644 index cc6b87c5..00000000 --- a/src/plugins/built_in/emoji_plugin/plugin.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Emoji 插件 — 新 SDK 版本 - -根据聊天上下文的情感,使用 LLM 选择并发送合适的表情包。 -""" - -from maibot_sdk import Action, MaiBotPlugin -from maibot_sdk.types import ActivationType - -import random - - -class EmojiPlugin(MaiBotPlugin): - """表情包插件""" - - @Action( - "emoji", - description="发送表情包辅助表达情绪", - activation_type=ActivationType.RANDOM, - activation_probability=0.3, - parallel_action=True, - action_require=[ - "发送表情包辅助表达情绪", - "表达情绪时可以选择使用", - "不要连续发送,如果你已经发过[表情包],就不要选择此动作", - ], - associated_types=["emoji"], - ) - async def handle_emoji(self, stream_id: str = "", reasoning: str = "", chat_id: str = "", **kwargs): - """执行表情动作""" - reason = reasoning or "表达当前情绪" - - # 1. 随机获取30个表情包 - sampled_emojis = await self.ctx.emoji.get_random(30) - if not sampled_emojis: - return False, "无法获取随机表情包" - - # 2. 按情感分组 - emotion_map: dict[str, list] = {} - for emoji in sampled_emojis: - emo = emoji.get("emotion", "") - if emo not in emotion_map: - emotion_map[emo] = [] - emotion_map[emo].append(emoji) - - available_emotions = list(emotion_map.keys()) - - if not available_emotions: - # 无情感标签,随机发送 - chosen = random.choice(sampled_emojis) - await self.ctx.send.emoji(chosen["base64"], stream_id) - return True, "随机发送了表情包" - - # 3. 获取最近消息作为上下文 - messages_text = "" - if chat_id: - recent_messages = await self.ctx.message.get_recent(chat_id=chat_id, limit=5) - if recent_messages: - messages_text = await self.ctx.message.build_readable( - recent_messages, - timestamp_mode="normal_no_YMD", - truncate=False, - ) - - # 4. 构建 prompt 让 LLM 选择情感 - available_emotions_str = "\n".join(available_emotions) - prompt = f"""你正在进行QQ聊天,你需要根据聊天记录,选出一个合适的情感标签。 -请你根据以下原因和聊天记录进行选择 -原因:{reason} -聊天记录: -{messages_text} - -这里是可用的情感标签: -{available_emotions_str} -请直接返回最匹配的那个情感标签,不要进行任何解释或添加其他多余的文字。 -""" - - # 5. 调用 LLM - llm_result = await self.ctx.llm.generate(prompt=prompt, model_name="utils") - if not llm_result or not llm_result.get("success"): - chosen = random.choice(sampled_emojis) - await self.ctx.send.emoji(chosen["base64"], stream_id) - return True, "LLM调用失败,随机发送了表情包" - - chosen_emotion = llm_result.get("response", "").strip().replace('"', "").replace("'", "") - - # 6. 根据选择的情感匹配表情包 - if chosen_emotion in emotion_map: - chosen = random.choice(emotion_map[chosen_emotion]) - else: - chosen = random.choice(sampled_emojis) - - # 7. 发送 - send_ok = await self.ctx.send.emoji(chosen["base64"], stream_id) - if send_ok: - return True, f"成功发送表情包:[表情包:{chosen_emotion}]" - return False, "发送表情包失败" - - async def on_load(self) -> None: - """处理插件加载。""" - - # 从插件配置读取 emoji_chance 来覆盖默认概率 - await self.ctx.config.get("emoji.emoji_chance") - - async def on_unload(self) -> None: - """处理插件卸载。""" - - async def on_config_update(self, scope: str, config_data: dict[str, object], version: str) -> None: - """处理配置热重载事件。 - - Args: - scope: 配置变更范围。 - config_data: 最新配置数据。 - version: 配置版本号。 - """ - - del config_data - del version - if scope == "self": - await self.ctx.config.get("emoji.emoji_chance") - - -def create_plugin() -> EmojiPlugin: - """创建 Emoji 插件实例。 - - Returns: - EmojiPlugin: 新的 Emoji 插件实例。 - """ - - return EmojiPlugin() From efb84df768a67d7c757e68208df50280f1551ad9 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 1 Apr 2026 18:28:00 +0800 Subject: [PATCH 04/18] =?UTF-8?q?fix=EF=BC=9A=E6=97=A0=E5=8F=82=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=9C=A8=E6=9F=90=E4=BA=9Bapi=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prompts/zh-CN/maisaka_chat.prompt | 2 +- src/llm_models/model_client/openai_client.py | 8 +++++--- src/llm_models/payload_content/tool_option.py | 12 ++++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/prompts/zh-CN/maisaka_chat.prompt b/prompts/zh-CN/maisaka_chat.prompt index 40a233a5..95d94b6a 100644 --- a/prompts/zh-CN/maisaka_chat.prompt +++ b/prompts/zh-CN/maisaka_chat.prompt @@ -23,7 +23,7 @@ 2.如果用户有新发言,但是你评估用户还有后续发言尚未发送,可以适当等待让用户说完 3.在特定情况下也可以连续回复,例如想要追问,或者补充自己先前的发言,可以不使用no_reply或者wait 4.你需要控制自己发言的频率,如果用户一对一聊天,可以以均匀地频率发言,如果用户较多,不要每句都回复,控制回复频率。当你决定暂时不发言,可以使用wait暂时等待一定时间或者no_reply等待新消息 -5.不要每条消息都回复,不要直接回复别的用户发送的表情包消息,控制回复频率 +5.不要每条消息都回复,不要直接回复别的用户发送的表情包消息,控制回复频率,控制你的发言占所有用户的1/10,也就是其他用户10条发言左右你回复一条。 6.如果存在用户的疑问,或者对某些概念的不确定,你可以使用工具来搜集信息或者查询含义,你可以使用多个工具 你的分析规则: diff --git a/src/llm_models/model_client/openai_client.py b/src/llm_models/model_client/openai_client.py index 44e085eb..d7730df9 100644 --- a/src/llm_models/model_client/openai_client.py +++ b/src/llm_models/model_client/openai_client.py @@ -314,13 +314,15 @@ def _convert_tool_options(tool_options: List[ToolOption]) -> List[ChatCompletion """ converted_tools: List[ChatCompletionToolParam] = [] for tool_option in tool_options: + parameters_schema = cast( + Dict[str, object], + tool_option.parameters_schema or {"type": "object", "properties": {}}, + ) function_schema: FunctionDefinition = { "name": tool_option.name, "description": tool_option.description, + "parameters": parameters_schema, } - parameters_schema = tool_option.parameters_schema - if parameters_schema is not None: - function_schema["parameters"] = cast(Dict[str, object], parameters_schema) converted_tools.append( { "type": "function", diff --git a/src/llm_models/payload_content/tool_option.py b/src/llm_models/payload_content/tool_option.py index ac5224cc..27345170 100644 --- a/src/llm_models/payload_content/tool_option.py +++ b/src/llm_models/payload_content/tool_option.py @@ -88,6 +88,15 @@ def _build_parameters_schema_from_property_map(property_map: Dict[str, Any]) -> return parameters_schema +def _build_empty_object_schema() -> Dict[str, Any]: + """构建无参工具使用的空对象 Schema。""" + + return { + "type": "object", + "properties": {}, + } + + @dataclass(slots=True) class ToolParam: """工具参数定义。""" @@ -333,9 +342,8 @@ class ToolOption: function_schema: Dict[str, Any] = { "name": self.name, "description": self.description, + "parameters": self.parameters_schema or _build_empty_object_schema(), } - if self.parameters_schema is not None: - function_schema["parameters"] = self.parameters_schema return { "type": "function", "function": function_schema, From 7b3c12ba0255728dff2d6e4db265f29d234cb6a3 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Wed, 1 Apr 2026 19:39:55 +0800 Subject: [PATCH 05/18] feat: add runtime validation for plugin configurations - Introduced a new method `validate_plugin_config` in `PluginRuntimeManager` to validate plugin configurations at runtime. - Implemented the `_normalize_plugin_config` method in `PluginRunner` to normalize and persist plugin configurations. - Enhanced the `PluginRunner` to handle configuration validation requests and return normalized configurations. - Updated the WebUI routes to utilize runtime validation for plugin configurations, ensuring that configurations are validated and normalized before saving. - Added tests for runtime configuration validation and normalization processes, including handling of invalid configurations. --- pytests/test_plugin_config_runtime.py | 253 ++++++++++++++++++ src/plugin_runtime/capabilities/components.py | 47 ++++ src/plugin_runtime/capabilities/registry.py | 1 + src/plugin_runtime/component_query.py | 50 ++++ src/plugin_runtime/host/supervisor.py | 50 +++- src/plugin_runtime/integration.py | 79 ++++-- src/plugin_runtime/protocol/envelope.py | 84 +++++- src/plugin_runtime/runner/runner_main.py | 233 ++++++++++++++-- src/webui/routers/plugin/config_routes.py | 214 ++++++++++++++- 9 files changed, 946 insertions(+), 65 deletions(-) create mode 100644 pytests/test_plugin_config_runtime.py diff --git a/pytests/test_plugin_config_runtime.py b/pytests/test_plugin_config_runtime.py new file mode 100644 index 00000000..df03d343 --- /dev/null +++ b/pytests/test_plugin_config_runtime.py @@ -0,0 +1,253 @@ +"""插件配置运行时测试。""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, Mapping, Optional, Tuple, cast + +import tomllib + +import pytest + +from src.plugin_runtime.component_query import component_query_service +from src.plugin_runtime.protocol.envelope import ( + Envelope, + MessageType, + RegisterPluginPayload, + ValidatePluginConfigPayload, +) +from src.plugin_runtime.runner.runner_main import PluginRunner +from src.webui.routers.plugin.config_routes import update_plugin_config +from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest + + +class _DemoConfigPlugin: + """用于测试 Runner 配置归一化流程的伪插件。""" + + def __init__(self) -> None: + """初始化测试插件状态。""" + + self.received_config: Dict[str, Any] = {} + + def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]: + """补齐测试插件的默认配置。 + + Args: + config_data: 原始配置数据。 + + Returns: + Tuple[Dict[str, Any], bool]: 补齐后的配置,以及是否发生变更。 + """ + + current_config = dict(config_data or {}) + plugin_section = dict(current_config.get("plugin", {})) + changed = "retry_count" not in plugin_section + plugin_section.setdefault("enabled", True) + plugin_section.setdefault("retry_count", 3) + return {"plugin": plugin_section}, changed + + def set_plugin_config(self, config: Dict[str, Any]) -> None: + """记录 Runner 注入的配置内容。 + + Args: + config: 当前最新配置。 + """ + + self.received_config = config + + +class _StrictConfigPlugin: + """用于测试配置校验错误的伪插件。""" + + def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]: + """校验重试次数不能为负数。 + + Args: + config_data: 原始配置数据。 + + Returns: + Tuple[Dict[str, Any], bool]: 规范化配置结果。 + + Raises: + ValueError: 当重试次数为负数时抛出。 + """ + + current_config = dict(config_data or {}) + plugin_section = dict(current_config.get("plugin", {})) + retry_count = int(plugin_section.get("retry_count", 0)) + if retry_count < 0: + raise ValueError("重试次数不能小于 0") + plugin_section.setdefault("enabled", True) + return {"plugin": plugin_section}, False + + def set_plugin_config(self, config: Dict[str, Any]) -> None: + """兼容 Runner 配置注入接口。 + + Args: + config: 当前配置字典。 + """ + + del config + + +def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None: + """Runner 注入配置时应自动补齐并落盘 config.toml。""" + + plugin = _DemoConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin) + + runner._apply_plugin_config(cast(Any, meta), config_data={"plugin": {"enabled": False}}) + + config_path = tmp_path / "config.toml" + assert config_path.exists() + assert plugin.received_config == {"plugin": {"enabled": False, "retry_count": 3}} + + with config_path.open("rb") as handle: + saved_config = tomllib.load(handle) + assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} + + +def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None: + """组件查询服务应支持按插件 ID 返回配置 Schema。""" + + payload = RegisterPluginPayload( + plugin_id="demo.plugin", + plugin_version="1.0.0", + default_config={"plugin": {"enabled": True}}, + config_schema={ + "plugin_id": "demo.plugin", + "plugin_info": { + "name": "Demo", + "version": "1.0.0", + "description": "", + "author": "", + }, + "sections": {"plugin": {"fields": {}}}, + "layout": {"type": "auto", "tabs": []}, + }, + ) + fake_supervisor = SimpleNamespace(_registered_plugins={"demo.plugin": payload}) + fake_manager = SimpleNamespace(_get_supervisor_for_plugin=lambda plugin_id: fake_supervisor) + + monkeypatch.setattr( + type(component_query_service), + "_get_runtime_manager", + staticmethod(lambda: fake_manager), + ) + + assert component_query_service.get_plugin_config_schema("demo.plugin") == payload.config_schema + assert component_query_service.get_plugin_default_config("demo.plugin") == payload.default_config + + +@pytest.mark.asyncio +async def test_runner_validate_plugin_config_handler_returns_normalized_config(monkeypatch: pytest.MonkeyPatch) -> None: + """Runner 应返回插件模型归一化后的配置。""" + + plugin = _DemoConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir="", instance=plugin) + monkeypatch.setattr(runner._loader, "get_plugin", lambda plugin_id: meta if plugin_id == "demo.plugin" else None) + + envelope = Envelope( + request_id=1, + message_type=MessageType.REQUEST, + method="plugin.validate_config", + plugin_id="demo.plugin", + payload=ValidatePluginConfigPayload(config_data={"plugin": {"enabled": False}}).model_dump(), + ) + + response = await runner._handle_validate_plugin_config(envelope) + + assert response.error is None + assert response.payload["success"] is True + assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}} + + +@pytest.mark.asyncio +async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Runner 应在插件拒绝配置时返回错误响应。""" + + plugin = _StrictConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir="", instance=plugin) + monkeypatch.setattr(runner._loader, "get_plugin", lambda plugin_id: meta if plugin_id == "demo.plugin" else None) + + envelope = Envelope( + request_id=1, + message_type=MessageType.REQUEST, + method="plugin.validate_config", + plugin_id="demo.plugin", + payload=ValidatePluginConfigPayload(config_data={"plugin": {"retry_count": -1}}).model_dump(), + ) + + response = await runner._handle_validate_plugin_config(envelope) + + assert response.error is not None + assert response.error["message"] == "重试次数不能小于 0" + + +@pytest.mark.asyncio +async def test_update_plugin_config_prefers_runtime_validation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """WebUI 保存插件配置时应优先使用运行时校验结果。""" + + config_path = tmp_path / "config.toml" + + async def _mock_validate_plugin_config(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: + """返回运行时归一化后的配置。 + + Args: + plugin_id: 插件 ID。 + config_data: 原始配置。 + + Returns: + Dict[str, Any] | None: 归一化后的配置。 + """ + + assert plugin_id == "demo.plugin" + assert config_data == {"plugin": {"enabled": False}} + return {"plugin": {"enabled": False, "retry_count": 3}} + + fake_runtime_manager = SimpleNamespace(validate_plugin_config=_mock_validate_plugin_config) + + monkeypatch.setattr( + "src.webui.routers.plugin.config_routes.require_plugin_token", + lambda session: session or "session-token", + ) + monkeypatch.setattr( + "src.webui.routers.plugin.config_routes.find_plugin_path_by_id", + lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None, + ) + monkeypatch.setattr( + "src.plugin_runtime.integration.get_plugin_runtime_manager", + lambda: fake_runtime_manager, + ) + + response = await update_plugin_config( + "demo.plugin", + UpdatePluginConfigRequest(config={"plugin.enabled": False}), + maibot_session="session-token", + ) + + assert response["success"] is True + with config_path.open("rb") as handle: + saved_config = tomllib.load(handle) + assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} diff --git a/src/plugin_runtime/capabilities/components.py b/src/plugin_runtime/capabilities/components.py index 1e1827cf..a3caebf9 100644 --- a/src/plugin_runtime/capabilities/components.py +++ b/src/plugin_runtime/capabilities/components.py @@ -458,6 +458,17 @@ class RuntimeComponentCapabilityMixin: async def _cap_component_get_plugin_info( self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] ) -> Any: + """获取指定插件的基础信息。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 当前能力名称。 + args: 能力调用参数。 + + Returns: + Any: 插件基础信息响应。 + """ + plugin_name: str = args.get("plugin_name", plugin_id) try: sv = self._get_supervisor_for_plugin(plugin_name) @@ -473,10 +484,46 @@ class RuntimeComponentCapabilityMixin: "description": "", "author": "", "enabled": True, + "default_config": reg.default_config, + "config_schema": reg.config_schema, }, } return {"success": False, "error": f"未找到插件: {plugin_name}"} + async def _cap_component_get_plugin_config_schema( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + """获取指定插件注册时上报的配置 Schema。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 当前能力名称。 + args: 能力调用参数。 + + Returns: + Any: 包含配置 Schema 与默认配置的响应。 + """ + + plugin_name: str = args.get("plugin_name", plugin_id) + try: + sv = self._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + return {"success": False, "error": str(exc)} + + if sv is None: + return {"success": False, "error": f"未找到插件: {plugin_name}"} + + registration = sv._registered_plugins.get(plugin_name) + if registration is None: + return {"success": False, "error": f"未找到插件: {plugin_name}"} + + return { + "success": True, + "plugin_id": plugin_name, + "schema": registration.config_schema, + "default_config": registration.default_config, + } + async def _cap_component_list_loaded_plugins( self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] ) -> Any: diff --git a/src/plugin_runtime/capabilities/registry.py b/src/plugin_runtime/capabilities/registry.py index 7f87604d..a4aed9bd 100644 --- a/src/plugin_runtime/capabilities/registry.py +++ b/src/plugin_runtime/capabilities/registry.py @@ -81,6 +81,7 @@ def register_capability_impls(manager: "PluginRuntimeManager", supervisor: Plugi _register("component.get_all_plugins", manager._cap_component_get_all_plugins) _register("component.get_plugin_info", manager._cap_component_get_plugin_info) + _register("component.get_plugin_config_schema", manager._cap_component_get_plugin_config_schema) _register("component.list_loaded_plugins", manager._cap_component_list_loaded_plugins) _register("component.list_registered_plugins", manager._cap_component_list_registered_plugins) _register("component.enable", manager._cap_component_enable) diff --git a/src/plugin_runtime/component_query.py b/src/plugin_runtime/component_query.py index e2ba7366..366c3c0f 100644 --- a/src/plugin_runtime/component_query.py +++ b/src/plugin_runtime/component_query.py @@ -858,5 +858,55 @@ class ComponentQueryService: logger.error(f"读取插件 {plugin_name} 配置失败: {exc}", exc_info=True) return None + def get_plugin_default_config(self, plugin_name: str) -> Optional[dict]: + """获取指定插件注册时上报的默认配置。 + + Args: + plugin_name: 插件名称。 + + Returns: + Optional[dict]: 默认配置字典;未找到时返回 ``None``。 + """ + + runtime_manager = self._get_runtime_manager() + try: + supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + logger.error(f"读取插件默认配置失败: {exc}") + return None + + if supervisor is None: + return None + + registration = supervisor._registered_plugins.get(plugin_name) + if registration is None: + return None + return dict(registration.default_config) + + def get_plugin_config_schema(self, plugin_name: str) -> Optional[dict]: + """获取指定插件注册时上报的配置 Schema。 + + Args: + plugin_name: 插件名称。 + + Returns: + Optional[dict]: 配置 Schema;未找到时返回 ``None``。 + """ + + runtime_manager = self._get_runtime_manager() + try: + supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + logger.error(f"读取插件配置 Schema 失败: {exc}") + return None + + if supervisor is None: + return None + + registration = supervisor._registered_plugins.get(plugin_name) + if registration is None: + return None + return dict(registration.config_schema) + component_query_service = ComponentQueryService() diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py index c94fcb3f..7a023167 100644 --- a/src/plugin_runtime/host/supervisor.py +++ b/src/plugin_runtime/host/supervisor.py @@ -39,6 +39,8 @@ from src.plugin_runtime.protocol.envelope import ( RunnerReadyPayload, ShutdownPayload, UnregisterPluginPayload, + ValidatePluginConfigPayload, + ValidatePluginConfigResultPayload, ) from src.plugin_runtime.protocol.codec import MsgPackCodec from src.plugin_runtime.protocol.errors import ErrorCode, RPCError @@ -59,6 +61,7 @@ if TYPE_CHECKING: logger = get_logger("plugin_runtime.host.runner_manager") + @dataclass(slots=True) class _MessageGatewayRuntimeState: """保存消息网关当前的运行时连接状态。""" @@ -100,9 +103,7 @@ class PluginRunnerSupervisor: self._group_name: str = str(group_name or "third_party").strip() or "third_party" self._plugin_dirs: List[Path] = plugin_dirs or [] self._health_interval: float = health_check_interval_sec or runtime_config.health_check_interval_sec or 30.0 - self._runner_spawn_timeout: float = ( - runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0 - ) + self._runner_spawn_timeout: float = runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0 self._max_restart_attempts: int = max_restart_attempts or runtime_config.max_restart_attempts or 3 self._transport = create_transport_server(socket_path=socket_path) @@ -200,10 +201,7 @@ class PluginRunnerSupervisor: Returns: Dict[str, str]: 已注册插件版本映射,键为插件 ID,值为插件版本。 """ - return { - plugin_id: registration.plugin_version - for plugin_id, registration in self._registered_plugins.items() - } + return {plugin_id: registration.plugin_version for plugin_id, registration in self._registered_plugins.items()} @staticmethod def _normalize_reload_plugin_ids(plugin_ids: Optional[List[str] | str]) -> List[str]: @@ -550,6 +548,39 @@ class PluginRunnerSupervisor: return bool(response.payload.get("acknowledged", False)) + async def validate_plugin_config(self, plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any]: + """请求 Runner 使用插件自身配置模型校验配置。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any]: 插件模型归一化后的配置字典。 + + Raises: + ValueError: 插件拒绝该配置或校验失败时抛出。 + """ + + payload = ValidatePluginConfigPayload(config_data=config_data) + try: + response = await self._rpc_server.send_request( + "plugin.validate_config", + plugin_id=plugin_id, + payload=payload.model_dump(), + timeout_ms=10000, + ) + except Exception as exc: + raise ValueError(f"插件配置校验请求失败: {exc}") from exc + + if response.error: + raise ValueError(str(response.error.get("message", "插件配置校验失败"))) + + result = ValidatePluginConfigResultPayload.model_validate(response.payload) + if not result.success: + raise ValueError("插件配置校验失败") + return dict(result.normalized_config) + def get_config_reload_subscribers(self, scope: str) -> List[str]: """返回订阅指定全局配置广播的插件列表。 @@ -608,6 +639,7 @@ class PluginRunnerSupervisor: Raises: TimeoutError: 在超时时间内 Runner 未完成初始化。 """ + async def wait_for_ready() -> RunnerReadyPayload: """轮询等待 Runner 上报就绪。""" while True: @@ -1058,7 +1090,9 @@ class PluginRunnerSupervisor: route_key = RouteKey(platform=platform) route_account_id, route_scope = RouteKeyFactory.extract_components(route_metadata) - account_id = route_key.account_id or route_account_id or runtime_state.account_id or gateway_entry.account_id or None + account_id = ( + route_key.account_id or route_account_id or runtime_state.account_id or gateway_entry.account_id or None + ) scope = route_key.scope or route_scope or runtime_state.scope or gateway_entry.scope or None return RouteKey( platform=platform, diff --git a/src/plugin_runtime/integration.py b/src/plugin_runtime/integration.py index 264c8ed2..deecaba8 100644 --- a/src/plugin_runtime/integration.py +++ b/src/plugin_runtime/integration.py @@ -9,7 +9,20 @@ """ from pathlib import Path -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, Iterable, List, Optional, Sequence, Set, Tuple +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Coroutine, + Dict, + Iterable, + List, + Optional, + Sequence, + Set, + Tuple, +) import asyncio @@ -364,9 +377,7 @@ class PluginRuntimeManager( """构建当前已注册插件到所属 Supervisor 的映射。""" return { - plugin_id: supervisor - for supervisor in self.supervisors - for plugin_id in supervisor.get_loaded_plugin_ids() + plugin_id: supervisor for supervisor in self.supervisors for plugin_id in supervisor.get_loaded_plugin_ids() } def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> Dict[str, str]: @@ -411,9 +422,7 @@ class PluginRuntimeManager( local_plugin_ids = set(supervisor.get_loaded_plugin_ids()) local_dependency_map = { plugin_id: { - dependency - for dependency in dependency_map.get(plugin_id, set()) - if dependency in local_plugin_ids + dependency for dependency in dependency_map.get(plugin_id, set()) if dependency in local_plugin_ids } for plugin_id in local_plugin_ids } @@ -440,9 +449,7 @@ class PluginRuntimeManager( """ normalized_plugin_ids = [ - normalized_plugin_id - for plugin_id in plugin_ids - if (normalized_plugin_id := str(plugin_id or "").strip()) + normalized_plugin_id for plugin_id in plugin_ids if (normalized_plugin_id := str(plugin_id or "").strip()) ] if not normalized_plugin_ids: return True @@ -518,9 +525,7 @@ class PluginRuntimeManager( return False config_payload = ( - config_data - if config_data is not None - else self._load_plugin_config_for_supervisor(sv, plugin_id) + config_data if config_data is not None else self._load_plugin_config_for_supervisor(sv, plugin_id) ) return await sv.notify_plugin_config_updated( plugin_id=plugin_id, @@ -529,6 +534,41 @@ class PluginRuntimeManager( config_scope=config_scope, ) + async def validate_plugin_config(self, plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: + """请求运行时按插件自身配置模型校验配置。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any] | None: 校验成功时返回规范化后的配置;若插件当前未加载 + 或运行时不可用,则返回 ``None`` 以便调用方回退到静态 Schema 方案。 + + Raises: + ValueError: 插件已加载,但配置校验失败时抛出。 + """ + + if not self._started: + return None + + try: + supervisor = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + logger.warning(f"插件 {plugin_id} 配置校验路由失败,将回退到静态 Schema: {exc}") + return None + + if supervisor is None: + return None + + try: + return await supervisor.validate_plugin_config(plugin_id, config_data) + except ValueError: + raise + except Exception as exc: + logger.warning(f"插件 {plugin_id} 运行时配置校验不可用,将回退到静态 Schema: {exc}") + return None + @staticmethod def _normalize_config_reload_scopes(changed_scopes: Sequence[str]) -> tuple[str, ...]: """规范化配置热重载范围列表。 @@ -869,7 +909,9 @@ class PluginRuntimeManager( if self._plugin_dir_matches(cached_path, Path(plugin_dir)): return cached_path - for candidate_plugin_id, plugin_path in self._iter_discovered_plugin_paths(getattr(supervisor, "_plugin_dirs", [])): + for candidate_plugin_id, plugin_path in self._iter_discovered_plugin_paths( + getattr(supervisor, "_plugin_dirs", []) + ): if candidate_plugin_id != plugin_id: continue self._plugin_path_cache[plugin_id] = plugin_path @@ -908,9 +950,7 @@ class PluginRuntimeManager( ) self._plugin_config_watcher_subscriptions[plugin_id] = (config_path, subscription_id) - def _build_plugin_config_change_callback( - self, plugin_id: str - ) -> Callable[[Sequence[FileChange]], Awaitable[None]]: + def _build_plugin_config_change_callback(self, plugin_id: str) -> Callable[[Sequence[FileChange]], Awaitable[None]]: """为指定插件生成配置文件变更回调。""" async def _callback(changes: Sequence[FileChange]) -> None: @@ -1018,7 +1058,10 @@ class PluginRuntimeManager( return plugin_id for plugin_id, plugin_path in self._plugin_path_cache.items(): - if not any(self._plugin_dir_matches(plugin_path, Path(plugin_dir)) for plugin_dir in getattr(supervisor, "_plugin_dirs", [])): + if not any( + self._plugin_dir_matches(plugin_path, Path(plugin_dir)) + for plugin_dir in getattr(supervisor, "_plugin_dirs", []) + ): continue if resolved_path == plugin_path or resolved_path.is_relative_to(plugin_path): return plugin_id diff --git a/src/plugin_runtime/protocol/envelope.py b/src/plugin_runtime/protocol/envelope.py index e738d019..88c5c7df 100644 --- a/src/plugin_runtime/protocol/envelope.py +++ b/src/plugin_runtime/protocol/envelope.py @@ -1,7 +1,7 @@ -"""RPC Envelope 消息模型 +"""RPC Envelope 消息模型。 定义 Host 与 Runner 之间所有 RPC 消息的统一信封格式。 -使用 Pydantic 进行 schema 定义与校验。 +使用 Pydantic 进行 Schema 定义与校验。 """ from enum import Enum @@ -39,12 +39,23 @@ class ConfigReloadScope(str, Enum): # ====== 请求 ID 生成器 ====== class RequestIdGenerator: - """单调递增 int64 请求 ID 生成器""" + """单调递增 int64 请求 ID 生成器。""" def __init__(self, start: int = 1) -> None: + """初始化请求 ID 生成器。 + + Args: + start: 起始请求 ID。 + """ self._counter = start async def next(self) -> int: + """返回下一个请求 ID。 + + Returns: + int: 下一个可用的请求 ID。 + """ + current = self._counter self._counter += 1 return current @@ -52,7 +63,7 @@ class RequestIdGenerator: # ====== Envelope 模型 ====== class Envelope(BaseModel): - """RPC 统一消息封装 + """RPC 统一消息封装。 所有 Host <-> Runner 消息均封装为此格式。 序列化流程:Envelope -> .model_dump() -> MsgPack encode @@ -79,18 +90,44 @@ class Envelope(BaseModel): """错误信息 (仅 response)""" def is_request(self) -> bool: + """判断当前信封是否为请求消息。 + + Returns: + bool: 当前消息类型是否为 ``REQUEST``。 + """ + return self.message_type == MessageType.REQUEST def is_response(self) -> bool: + """判断当前信封是否为响应消息。 + + Returns: + bool: 当前消息类型是否为 ``RESPONSE``。 + """ + return self.message_type == MessageType.RESPONSE def is_broadcast(self) -> bool: + """判断当前信封是否为广播消息。 + + Returns: + bool: 当前消息类型是否为 ``BROADCAST``。 + """ + return self.message_type == MessageType.BROADCAST def make_response( self, payload: Optional[Dict[str, Any]] = None, error: Optional[Dict[str, Any]] = None ) -> "Envelope": - """基于当前请求创建对应的响应信封""" + """基于当前请求创建对应的响应信封。 + + Args: + payload: 响应业务载荷。 + error: 响应错误信息。 + + Returns: + Envelope: 对应的响应信封。 + """ return Envelope( protocol_version=self.protocol_version, request_id=self.request_id, @@ -102,7 +139,16 @@ class Envelope(BaseModel): ) def make_error_response(self, code: str, message: str = "", details: Optional[Dict[str, Any]] = None) -> "Envelope": - """基于当前请求创建错误响应""" + """基于当前请求创建错误响应。 + + Args: + code: 错误码。 + message: 错误描述。 + details: 详细错误信息。 + + Returns: + Envelope: 错误响应信封。 + """ return self.make_response( error={ "code": code, @@ -141,9 +187,7 @@ class ComponentDeclaration(BaseModel): name: str = Field(description="组件名称") """组件名称""" - component_type: str = Field( - description="组件类型:action/command/tool/event_handler/hook_handler/message_gateway" - ) + component_type: str = Field(description="组件类型:action/command/tool/event_handler/hook_handler/message_gateway") """组件类型:`action`/`command`/`tool`/`event_handler`/`hook_handler`/`message_gateway`""" plugin_id: str = Field(description="所属插件 ID") """所属插件 ID""" @@ -170,6 +214,10 @@ class RegisterPluginPayload(BaseModel): """插件级依赖插件 ID 列表""" config_reload_subscriptions: List[str] = Field(default_factory=list, description="订阅的全局配置热重载范围") """订阅的全局配置热重载范围""" + default_config: Dict[str, Any] = Field(default_factory=dict, description="插件默认配置") + """插件默认配置""" + config_schema: Dict[str, Any] = Field(default_factory=dict, description="插件配置 Schema") + """插件配置 Schema""" class BootstrapPluginPayload(BaseModel): @@ -256,6 +304,24 @@ class ConfigUpdatedPayload(BaseModel): """配置内容""" +class ValidatePluginConfigPayload(BaseModel): + """plugin.validate_config 请求 payload。""" + + config_data: Dict[str, Any] = Field(default_factory=dict, description="待校验的配置内容") + """待校验的配置内容""" + + +class ValidatePluginConfigResultPayload(BaseModel): + """plugin.validate_config 响应 payload。""" + + success: bool = Field(description="是否校验成功") + """是否校验成功""" + normalized_config: Dict[str, Any] = Field(default_factory=dict, description="校验后的规范化配置") + """校验后的规范化配置""" + changed: bool = Field(default=False, description="是否在校验过程中自动补齐或归一化") + """是否在校验过程中自动补齐或归一化""" + + # ====== 关停 ====== class ShutdownPayload(BaseModel): """plugin.shutdown / plugin.prepare_shutdown payload""" diff --git a/src/plugin_runtime/runner/runner_main.py b/src/plugin_runtime/runner/runner_main.py index a1dc56fa..55c53d8d 100644 --- a/src/plugin_runtime/runner/runner_main.py +++ b/src/plugin_runtime/runner/runner_main.py @@ -10,7 +10,7 @@ """ from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Protocol, Set, cast +from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Set, Tuple, cast import asyncio import contextlib @@ -23,6 +23,8 @@ import sys import time import tomllib +import tomlkit + from src.common.logger import get_console_handler, get_logger, initialize_logging from src.plugin_runtime import ( ENV_EXTERNAL_PLUGIN_IDS, @@ -46,6 +48,8 @@ from src.plugin_runtime.protocol.envelope import ( ReloadPluginsResultPayload, RunnerReadyPayload, UnregisterPluginPayload, + ValidatePluginConfigPayload, + ValidatePluginConfigResultPayload, ) from src.plugin_runtime.protocol.errors import ErrorCode from src.plugin_runtime.runner.log_handler import RunnerIPCLogHandler @@ -79,6 +83,64 @@ class _ContextAwarePlugin(Protocol): """ +class _ConfigAwarePlugin(Protocol): + """支持声明式插件配置能力的插件协议。""" + + def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]: + """对插件配置进行归一化与补齐。 + + Args: + config_data: 原始配置数据。 + + Returns: + Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否发生自动变更。 + """ + + ... + + def set_plugin_config(self, config: Dict[str, Any]) -> None: + """注入插件当前配置。 + + Args: + config: 当前最新插件配置。 + """ + + ... + + def get_default_config(self) -> Dict[str, Any]: + """返回插件默认配置。 + + Returns: + Dict[str, Any]: 默认配置字典。 + """ + + ... + + def get_webui_config_schema( + self, + *, + plugin_id: str = "", + plugin_name: str = "", + plugin_version: str = "", + plugin_description: str = "", + plugin_author: str = "", + ) -> Dict[str, Any]: + """返回插件配置 Schema。 + + Args: + plugin_id: 插件 ID。 + plugin_name: 插件名称。 + plugin_version: 插件版本。 + plugin_description: 插件描述。 + plugin_author: 插件作者。 + + Returns: + Dict[str, Any]: WebUI 配置 Schema。 + """ + + ... + + def _install_shutdown_signal_handlers( mark_runner_shutting_down: Callable[[], None], loop: Optional[asyncio.AbstractEventLoop] = None, @@ -271,14 +333,11 @@ class PluginRunner: 始终绑定为当前插件实例,避免伪造其他插件身份申请能力。 """ if plugin_id and plugin_id != bound_plugin_id: - logger.warning( - f"插件 {bound_plugin_id} 尝试以 {plugin_id} 身份发起 RPC,已强制绑定回自身身份" - ) + logger.warning(f"插件 {bound_plugin_id} 尝试以 {plugin_id} 身份发起 RPC,已强制绑定回自身身份") normalized_method = str(method or "").strip() if normalized_method not in _PLUGIN_ALLOWED_RAW_HOST_METHODS: raise PermissionError( - f"插件 {bound_plugin_id} 不允许直接调用 Host 原始 RPC 方法: " - f"{normalized_method or ''}" + f"插件 {bound_plugin_id} 不允许直接调用 Host 原始 RPC 方法: {normalized_method or ''}" ) resp = await rpc_client.send_request( method=normalized_method, @@ -294,17 +353,72 @@ class PluginRunner: logger.debug(f"已为插件 {plugin_id} 注入 PluginContext") def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> None: - """在 Runner 侧为插件实例注入当前插件配置。""" + """在 Runner 侧为插件实例注入当前插件配置。 + + Args: + meta: 插件元数据。 + config_data: 可选的配置数据;留空时自动从插件目录读取。 + """ instance = meta.instance if not hasattr(instance, "set_plugin_config"): return - plugin_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir) + raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir) + plugin_config, should_persist = self._normalize_plugin_config(instance, raw_config) + config_path = Path(meta.plugin_dir) / "config.toml" + default_config = self._get_plugin_default_config(instance) + should_initialize_file = not config_path.exists() and bool(default_config) + if should_persist or should_initialize_file: + self._save_plugin_config(meta.plugin_dir, plugin_config) try: - instance.set_plugin_config(plugin_config) + cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config) except Exception as exc: logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}") + def _normalize_plugin_config( + self, + instance: object, + config_data: Optional[Dict[str, Any]], + *, + suppress_errors: bool = True, + ) -> Tuple[Dict[str, Any], bool]: + """对插件配置做统一归一化处理。 + + Args: + instance: 插件实例。 + config_data: 原始配置数据。 + suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。 + + Returns: + Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否需要回写文件。 + """ + + normalized_config = dict(config_data or {}) + if not hasattr(instance, "normalize_plugin_config"): + return normalized_config, False + + try: + return cast(_ConfigAwarePlugin, instance).normalize_plugin_config(normalized_config) + except Exception as exc: + if not suppress_errors: + raise + logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}") + return normalized_config, False + + @staticmethod + def _save_plugin_config(plugin_dir: str, config_data: Dict[str, Any]) -> None: + """将插件配置写回到 ``config.toml``。 + + Args: + plugin_dir: 插件目录。 + config_data: 需要写回的配置字典。 + """ + + config_path = Path(plugin_dir) / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + with config_path.open("w", encoding="utf-8") as handle: + handle.write(tomlkit.dumps(config_data)) + @staticmethod def _load_plugin_config(plugin_dir: str) -> Dict[str, Any]: """从插件目录读取 config.toml。""" @@ -334,6 +448,7 @@ class PluginRunner: self._rpc_client.register_method("plugin.prepare_shutdown", self._handle_prepare_shutdown) self._rpc_client.register_method("plugin.shutdown", self._handle_shutdown) self._rpc_client.register_method("plugin.config_updated", self._handle_config_updated) + self._rpc_client.register_method("plugin.validate_config", self._handle_validate_plugin_config) self._rpc_client.register_method("plugin.reload", self._handle_reload_plugin) self._rpc_client.register_method("plugin.reload_batch", self._handle_reload_plugins) @@ -451,6 +566,8 @@ class PluginRunner: capabilities_required=meta.capabilities_required, dependencies=meta.dependencies, config_reload_subscriptions=config_reload_subscriptions, + default_config=self._get_plugin_default_config(instance), + config_schema=self._get_plugin_config_schema(meta), ) try: @@ -468,6 +585,53 @@ class PluginRunner: logger.error(f"插件 {meta.plugin_id} 注册失败: {e}") return False + @staticmethod + def _get_plugin_default_config(instance: object) -> Dict[str, Any]: + """获取插件默认配置。 + + Args: + instance: 插件实例。 + + Returns: + Dict[str, Any]: 默认配置;插件未声明时返回空字典。 + """ + + if not hasattr(instance, "get_default_config"): + return {} + try: + default_config = cast(_ConfigAwarePlugin, instance).get_default_config() + except Exception as exc: + logger.warning(f"读取插件默认配置失败: {exc}") + return {} + return default_config if isinstance(default_config, dict) else {} + + @staticmethod + def _get_plugin_config_schema(meta: PluginMeta) -> Dict[str, Any]: + """获取插件 WebUI 配置 Schema。 + + Args: + meta: 插件元数据。 + + Returns: + Dict[str, Any]: 插件配置 Schema;插件未声明时返回空字典。 + """ + + instance = meta.instance + if not hasattr(instance, "get_webui_config_schema"): + return {} + try: + schema = cast(_ConfigAwarePlugin, instance).get_webui_config_schema( + plugin_id=meta.plugin_id, + plugin_name=meta.manifest.name, + plugin_version=meta.version, + plugin_description=meta.manifest.description, + plugin_author=meta.manifest.author.name, + ) + except Exception as exc: + logger.warning(f"构造插件配置 Schema 失败: {exc}") + return {} + return schema if isinstance(schema, dict) else {} + async def _unregister_plugin(self, plugin_id: str, reason: str) -> None: """通知 Host 注销指定插件。 @@ -631,7 +795,9 @@ class PluginRunner: continue dependency_graph[plugin_id] = {dependency for dependency in meta.dependencies if dependency in plugin_ids} - indegree: Dict[str, int] = {plugin_id: len(dependencies) for plugin_id, dependencies in dependency_graph.items()} + indegree: Dict[str, int] = { + plugin_id: len(dependencies) for plugin_id, dependencies in dependency_graph.items() + } reverse_graph: Dict[str, Set[str]] = {plugin_id: set() for plugin_id in dependency_graph} for plugin_id, dependencies in dependency_graph.items(): @@ -677,9 +843,7 @@ class PluginRunner: for failed_plugin_id, failure_reason in failed_plugins.items(): rollback_failure = rollback_failures.get(failed_plugin_id) if rollback_failure: - finalized_failures[failed_plugin_id] = ( - f"{failure_reason};且旧版本恢复失败: {rollback_failure}" - ) + finalized_failures[failed_plugin_id] = f"{failure_reason};且旧版本恢复失败: {rollback_failure}" else: finalized_failures[failed_plugin_id] = f"{failure_reason}(已恢复旧版本)" @@ -761,9 +925,7 @@ class PluginRunner: failed_plugins=failed_plugins, ) - target_plugin_ids: Set[str] = { - plugin_id for plugin_id in reload_root_ids if plugin_id not in loaded_plugin_ids - } + target_plugin_ids: Set[str] = {plugin_id for plugin_id in reload_root_ids if plugin_id not in loaded_plugin_ids} if loaded_root_plugin_ids := reload_root_ids & loaded_plugin_ids: target_plugin_ids.update(self._collect_reverse_dependents_for_roots(loaded_root_plugin_ids)) @@ -1127,6 +1289,42 @@ class PluginRunner: return envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(e)) return envelope.make_response(payload={"acknowledged": True}) + async def _handle_validate_plugin_config(self, envelope: Envelope) -> Envelope: + """处理插件配置校验请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + + try: + payload = ValidatePluginConfigPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + plugin_id = envelope.plugin_id + meta = self._loader.get_plugin(plugin_id) + if meta is None: + return envelope.make_error_response(ErrorCode.E_PLUGIN_NOT_FOUND.value, f"未找到插件: {plugin_id}") + + try: + normalized_config, changed = self._normalize_plugin_config( + meta.instance, + payload.config_data, + suppress_errors=False, + ) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + result = ValidatePluginConfigResultPayload( + success=True, + normalized_config=normalized_config, + changed=changed, + ) + return envelope.make_response(payload=result.model_dump()) + async def _handle_reload_plugin(self, envelope: Envelope) -> Envelope: """处理按插件 ID 的精确重载请求。 @@ -1212,8 +1410,7 @@ async def _async_main() -> None: session_token, plugin_dirs, external_available_plugins={ - str(plugin_id): str(plugin_version) - for plugin_id, plugin_version in external_plugin_ids.items() + str(plugin_id): str(plugin_version) for plugin_id, plugin_version in external_plugin_ids.items() }, ) diff --git a/src/webui/routers/plugin/config_routes.py b/src/webui/routers/plugin/config_routes.py index 128e86a8..3a24503e 100644 --- a/src/webui/routers/plugin/config_routes.py +++ b/src/webui/routers/plugin/config_routes.py @@ -1,3 +1,5 @@ +"""插件配置相关 WebUI 路由。""" + import json from typing import Any, Dict, Optional, cast @@ -5,13 +7,12 @@ import tomlkit from fastapi import APIRouter, Cookie, HTTPException from src.common.logger import get_logger +from src.plugin_runtime.component_query import component_query_service from src.webui.utils.toml_utils import save_toml_with_format from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest from .support import ( backup_file, - coerce_types, - find_plugin_instance, find_plugin_path_by_id, normalize_dotted_keys, require_plugin_token, @@ -24,6 +25,16 @@ router = APIRouter() def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]: + """根据当前配置内容自动推断一个兜底 Schema。 + + Args: + plugin_id: 插件 ID。 + current_config: 当前配置对象。 + + Returns: + Dict[str, Any]: 可供前端渲染的兜底 Schema。 + """ + schema: Dict[str, Any] = { "plugin_id": plugin_id, "plugin_info": { @@ -119,15 +130,123 @@ def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Di return schema +def _coerce_scalar_value(field_schema: Dict[str, Any], value: Any) -> Any: + """根据字段 Schema 规范化单个字段值。 + + Args: + field_schema: 单个字段 Schema。 + value: 当前字段值。 + + Returns: + Any: 规范化后的字段值。 + """ + + field_type = str(field_schema.get("type", "") or "").lower() + if field_type == "boolean" and isinstance(value, str): + normalized_value = value.strip().lower() + if normalized_value in {"1", "true", "yes", "on"}: + return True + if normalized_value in {"0", "false", "no", "off"}: + return False + if field_type == "integer" and isinstance(value, str): + try: + return int(value) + except ValueError: + return value + if field_type == "number" and isinstance(value, str): + try: + return float(value) + except ValueError: + return value + if field_type == "array" and isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return value + + +def _coerce_config_by_plugin_schema(schema: Dict[str, Any], config_data: Dict[str, Any]) -> None: + """根据插件配置 Schema 就地规范化配置值类型。 + + Args: + schema: 插件配置 Schema。 + config_data: 待规范化的配置字典。 + """ + + sections = schema.get("sections") + if not isinstance(sections, dict): + return + + for section_name, section_schema in sections.items(): + if not isinstance(section_schema, dict): + continue + if section_name not in config_data or not isinstance(config_data[section_name], dict): + continue + + section_fields = section_schema.get("fields") + if not isinstance(section_fields, dict): + continue + + section_config = cast(Dict[str, Any], config_data[section_name]) + for field_name, field_schema in section_fields.items(): + if field_name not in section_config or not isinstance(field_schema, dict): + continue + section_config[field_name] = _coerce_scalar_value(field_schema, section_config[field_name]) + + +def _build_toml_document(config_data: Dict[str, Any]) -> tomlkit.TOMLDocument: + """将普通字典转换为 TOML 文档对象。 + + Args: + config_data: 原始配置字典。 + + Returns: + tomlkit.TOMLDocument: 解析后的 TOML 文档。 + """ + + if not config_data: + return tomlkit.document() + return tomlkit.parse(tomlkit.dumps(config_data)) + + +async def _validate_plugin_config_via_runtime(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: + """通过插件运行时对配置进行校验。 + + Args: + plugin_id: 插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any] | None: 校验成功时返回规范化后的配置;若运行时不可用则返回 + ``None``,由调用方自行回退到静态 Schema 方案。 + + Raises: + ValueError: 插件运行时明确判定配置非法时抛出。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + runtime_manager = get_plugin_runtime_manager() + return await runtime_manager.validate_plugin_config(plugin_id, config_data) + + @router.get("/config/{plugin_id}/schema") async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """按插件 ID 返回配置 Schema。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 包含 Schema 的响应字典。 + """ + require_plugin_token(maibot_session) logger.info(f"获取插件配置 Schema: {plugin_id}") try: - plugin_instance = find_plugin_instance(plugin_id) - if plugin_instance and hasattr(plugin_instance, "get_webui_config_schema"): - return {"success": True, "schema": plugin_instance.get_webui_config_schema()} + registration_schema = component_query_service.get_plugin_config_schema(plugin_id) + if isinstance(registration_schema, dict) and registration_schema: + return {"success": True, "schema": registration_schema} plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: @@ -141,7 +260,7 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] except Exception as e: logger.warning(f"读取 config_schema.json 失败,回退到自动推断: {e}") - current_config: Any = {} + current_config: Any = component_query_service.get_plugin_default_config(plugin_id) or {} config_path = resolve_plugin_file_path(plugin_path, "config.toml") if config_path.exists(): with open(config_path, "r", encoding="utf-8") as file_obj: @@ -157,6 +276,16 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] @router.get("/config/{plugin_id}/raw") async def get_plugin_config_raw(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """获取插件原始 TOML 配置内容。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 包含原始配置文本的响应字典。 + """ + require_plugin_token(maibot_session) logger.info(f"获取插件原始配置: {plugin_id}") @@ -184,6 +313,17 @@ async def update_plugin_config_raw( request: UpdatePluginRawConfigRequest, maibot_session: Optional[str] = Cookie(None), ) -> Dict[str, Any]: + """更新插件原始 TOML 配置内容。 + + Args: + plugin_id: 插件 ID。 + request: 原始配置更新请求。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 更新结果。 + """ + require_plugin_token(maibot_session) logger.info(f"更新插件原始配置: {plugin_id}") @@ -216,6 +356,16 @@ async def update_plugin_config_raw( @router.get("/config/{plugin_id}") async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """获取插件配置字典。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 当前配置响应。 + """ + require_plugin_token(maibot_session) logger.info(f"获取插件配置: {plugin_id}") @@ -226,6 +376,9 @@ async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cook config_path = resolve_plugin_file_path(plugin_path, "config.toml") if not config_path.exists(): + default_config = component_query_service.get_plugin_default_config(plugin_id) + if isinstance(default_config, dict): + return {"success": True, "config": default_config, "message": "配置文件不存在,已返回默认配置"} return {"success": True, "config": {}, "message": "配置文件不存在"} with open(config_path, "r", encoding="utf-8") as file_obj: @@ -244,17 +397,31 @@ async def update_plugin_config( request: UpdatePluginConfigRequest, maibot_session: Optional[str] = Cookie(None), ) -> Dict[str, Any]: + """更新插件结构化配置。 + + Args: + plugin_id: 插件 ID。 + request: 结构化配置更新请求。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 更新结果。 + """ + require_plugin_token(maibot_session) logger.info(f"更新插件配置: {plugin_id}") try: - plugin_instance = find_plugin_instance(plugin_id) config_data = request.config or {} - if plugin_instance and isinstance(config_data, dict): + if isinstance(config_data, dict): config_data = normalize_dotted_keys(config_data) - if isinstance(plugin_instance.config_schema, dict): - coerce_types(plugin_instance.config_schema, config_data) - + runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data) + if isinstance(runtime_validated_config, dict): + config_data = runtime_validated_config + else: + plugin_schema = component_query_service.get_plugin_config_schema(plugin_id) + if isinstance(plugin_schema, dict) and plugin_schema: + _coerce_config_by_plugin_schema(plugin_schema, config_data) plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") @@ -267,6 +434,8 @@ async def update_plugin_config( save_toml_with_format(config_data, str(config_path)) logger.info(f"已更新插件配置: {plugin_id}") return {"success": True, "message": "配置已保存", "note": "配置更改将自动热更新到对应插件"} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc except HTTPException: raise except Exception as e: @@ -276,6 +445,16 @@ async def update_plugin_config( @router.post("/config/{plugin_id}/reset") async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """重置插件配置文件。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 重置结果。 + """ + require_plugin_token(maibot_session) logger.info(f"重置插件配置: {plugin_id}") @@ -300,6 +479,16 @@ async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Co @router.post("/config/{plugin_id}/toggle") async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """切换插件启用状态。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 切换结果。 + """ + require_plugin_token(maibot_session) logger.info(f"切换插件状态: {plugin_id}") @@ -309,7 +498,8 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") - config = tomlkit.document() + default_config = component_query_service.get_plugin_default_config(plugin_id) + config = _build_toml_document(default_config if isinstance(default_config, dict) else {}) if config_path.exists(): with open(config_path, "r", encoding="utf-8") as file_obj: config = tomlkit.load(file_obj) From fe6ccaaf865c3d7567f18d5bde9534a143978565 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 1 Apr 2026 19:56:08 +0800 Subject: [PATCH 06/18] =?UTF-8?q?fix=EF=BC=9A=E9=83=A8=E5=88=86=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=B8=8D=E6=94=AF=E6=8C=81gif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/llm_models/model_client/openai_client.py | 101 +++++++++++++++++-- src/maisaka/builtin_tools.py | 38 +++---- 2 files changed, 114 insertions(+), 25 deletions(-) diff --git a/src/llm_models/model_client/openai_client.py b/src/llm_models/model_client/openai_client.py index d7730df9..5010f1d0 100644 --- a/src/llm_models/model_client/openai_client.py +++ b/src/llm_models/model_client/openai_client.py @@ -1,12 +1,12 @@ -from collections.abc import Iterable -from dataclasses import dataclass, field -from typing import Any, Callable, Coroutine, Dict, List, Tuple, cast - import asyncio import base64 +import binascii import io import json import re +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Any, Callable, Coroutine, Dict, List, Tuple, cast from json_repair import repair_json from openai import APIConnectionError, APIStatusError, AsyncOpenAI, AsyncStream @@ -27,6 +27,7 @@ from openai.types.chat import ( ) from openai.types.shared_params.function_definition import FunctionDefinition from openai.types.chat.chat_completion_chunk import ChoiceDelta +from PIL import Image as PILImage from src.common.logger import get_logger from src.config.model_configs import APIProvider, ReasoningParseMode, ToolArgumentParseMode @@ -62,6 +63,9 @@ from .base_client import ( logger = get_logger("llm_models") +SUPPORTED_OPENAI_IMAGE_FORMATS = {"jpeg", "png", "webp"} +"""OpenAI 兼容图片输入稳定支持的格式集合。""" + THINK_CONTENT_PATTERN = re.compile( r"(?P.*?)(?P.*)|(?P.*)|(?P.+)", re.DOTALL, @@ -149,14 +153,85 @@ def _build_image_content_part(part: ImageMessagePart) -> ChatCompletionContentPa Returns: ChatCompletionContentPartImageParam: OpenAI 兼容的图片片段。 """ + normalized_image = _normalize_image_part_for_openai(part) + if normalized_image is None: + raise ValueError("图片数据无效,无法构建图片消息片段") + + image_format, image_base64 = normalized_image return { "type": "image_url", "image_url": { - "url": f"data:image/{part.normalized_image_format};base64,{part.image_base64}", + "url": f"data:image/{image_format};base64,{image_base64}", }, } +def _normalize_image_part_for_openai(part: ImageMessagePart) -> Tuple[str, str] | None: + """将图片片段规范化为 OpenAI 兼容格式。 + + Args: + part: 内部图片片段。 + + Returns: + Tuple[str, str] | None: `(image_format, image_base64)`;无法解析时返回 `None`。 + """ + try: + image_bytes = base64.b64decode(part.image_base64, validate=True) + except (binascii.Error, ValueError) as exc: + logger.warning(f"图片 Base64 解码失败,已跳过该图片片段: {exc}") + return None + + try: + with PILImage.open(io.BytesIO(image_bytes)) as image: + image_format = (image.format or part.normalized_image_format).lower() + if image_format in {"jpg", "jpeg"}: + image_format = "jpeg" + + if image_format in SUPPORTED_OPENAI_IMAGE_FORMATS: + return image_format, part.image_base64 + + if image_format == "gif": + frame_count = getattr(image, "n_frames", 1) + frames: List[PILImage.Image] = [] + durations: List[int] = [] + + for frame_index in range(frame_count): + image.seek(frame_index) + frame = image.copy() + if frame.mode not in {"RGB", "RGBA"}: + frame = frame.convert("RGBA") + frames.append(frame) + durations.append(int(image.info.get("duration", 100) or 100)) + + output_buffer = io.BytesIO() + save_kwargs: Dict[str, Any] = { + "format": "WEBP", + "save_all": True, + "append_images": frames[1:], + "duration": durations, + "loop": int(image.info.get("loop", 0) or 0), + } + if frame_count > 1: + save_kwargs["lossless"] = True + + frames[0].save(output_buffer, **save_kwargs) + converted_base64 = base64.b64encode(output_buffer.getvalue()).decode("utf-8") + return "webp", converted_base64 + + image.seek(0) + normalized_image = image.copy() + if normalized_image.mode not in {"RGB", "RGBA"}: + normalized_image = normalized_image.convert("RGBA") + + output_buffer = io.BytesIO() + normalized_image.save(output_buffer, format="PNG") + converted_base64 = base64.b64encode(output_buffer.getvalue()).decode("utf-8") + return "png", converted_base64 + except Exception as exc: + logger.warning(f"图片内容无法被识别为有效图片,已跳过该图片片段: {exc}") + return None + + def _convert_response_format(response_format: RespFormat | None) -> Any: """将内部响应格式转换为 OpenAI 兼容结构。 @@ -222,7 +297,21 @@ def _convert_user_message_content(message: Message) -> str | List[ChatCompletion if isinstance(part, TextMessagePart): content.append(_build_text_content_part(part.text)) continue - content.append(_build_image_content_part(part)) + + normalized_image = _normalize_image_part_for_openai(part) + if normalized_image is None: + content.append(_build_text_content_part("[图片内容不可用]")) + continue + + image_format, image_base64 = normalized_image + content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:image/{image_format};base64,{image_base64}", + }, + } + ) return content diff --git a/src/maisaka/builtin_tools.py b/src/maisaka/builtin_tools.py index 0222b173..14aed475 100644 --- a/src/maisaka/builtin_tools.py +++ b/src/maisaka/builtin_tools.py @@ -100,25 +100,25 @@ def create_builtin_tool_specs() -> List[ToolSpec]: "required": ["words"], }, ), - _build_tool_spec( - name="query_person_info", - brief_description="查询某个人的档案和相关记忆信息。", - parameters_schema={ - "type": "object", - "properties": { - "person_name": { - "type": "string", - "description": "人物名称、昵称或用户 ID。", - }, - "limit": { - "type": "integer", - "description": "最多返回多少条匹配记录。", - "default": 3, - }, - }, - "required": ["person_name"], - }, - ), + # _build_tool_spec( + # name="query_person_info", + # brief_description="查询某个人的档案和相关记忆信息。", + # parameters_schema={ + # "type": "object", + # "properties": { + # "person_name": { + # "type": "string", + # "description": "人物名称、昵称或用户 ID。", + # }, + # "limit": { + # "type": "integer", + # "description": "最多返回多少条匹配记录。", + # "default": 3, + # }, + # }, + # "required": ["person_name"], + # }, + # ), _build_tool_spec( name="no_reply", brief_description="本轮不进行回复,等待其他用户的新消息。", From 56f7184c4dc6718470dd10dfcc35b22430ba673b Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Wed, 1 Apr 2026 20:14:31 +0800 Subject: [PATCH 07/18] =?UTF-8?q?feat=EF=BC=9A=E8=81=8A=E5=A4=A9=E7=89=B9?= =?UTF-8?q?=E5=AE=9A=E7=9A=84=E9=A2=9D=E5=A4=96prompt=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prompts/zh-CN/maisaka_chat.prompt | 8 ++-- src/config/official_configs.py | 18 ++++++++ src/maisaka/chat_loop_service.py | 76 +++++++++++++++++++++++++++++++ src/maisaka/runtime.py | 5 +- 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/prompts/zh-CN/maisaka_chat.prompt b/prompts/zh-CN/maisaka_chat.prompt index 95d94b6a..c6f9daf1 100644 --- a/prompts/zh-CN/maisaka_chat.prompt +++ b/prompts/zh-CN/maisaka_chat.prompt @@ -6,7 +6,7 @@ 【参考信息结束】 你需要根据提供的参考信息,当前场景和输出规则来进行分析 -在当前场景中,用户正在与AI麦麦进行聊天互动,你的任务不是生成对用户可见的发言,而是进行分析来指导AI进行回复。 +在当前场景中,不同的用户正在互动,用户也可能与AI{bot_name}进行聊天互动,你的任务不是生成对用户可见的发言,而是进行分析来指导AI进行回复。 “分析”应该体现你对当前局面的判断、你的建议、你的下一步计划,以及你为什么这样想。 你需要先搜集能够帮助{bot_name}回复的信息,然后再给出回复意见 @@ -27,10 +27,12 @@ 6.如果存在用户的疑问,或者对某些概念的不确定,你可以使用工具来搜集信息或者查询含义,你可以使用多个工具 你的分析规则: -1. 默认直接输出你当前的最新分析,不要重复之前的分析内容。 -2. 最新分析应尽量具体,贴近上下文,不要空泛重复。 +1. 默认直接输出你当前的最新分析,不要重复之前的分析内容。最新分析应尽量具体,贴近上下文,不要空泛重复。 +2. 你需要先评估是用户之间在互动还是和{bot_name}在互动,不要盲目插话,弄错回复对象 3. 如果你刚刚做了工具调用,下一轮应结合工具结果继续输出新的分析。 4. 你需要评估哪些话是对{bot_name}的发言,哪些是用户之间的交流或者自言自语,不要频繁插入无关的话题。 5. 如果你上一轮没有发言,需要重新进行分析,输出新的分析内容,不要重复上一轮的分析内容 +{group_chat_attention_block} + 现在,请你输出你对{bot_name}发言的分析,你必须先输出文本内容的分析,然后再进行工具调用: diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 1dfad725..392c4f18 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1075,6 +1075,24 @@ class ExperimentalConfig(ConfigBase): ) """_wrap_私聊说话规则,行为风格(实验性功能)""" + group_chat_prompt: str = Field( + default="", + json_schema_extra={ + "x-widget": "textarea", + "x-icon": "users", + }, + ) + """_wrap_群聊通用注意事项(实验性功能)""" + + private_chat_prompts: str = Field( + default="", + json_schema_extra={ + "x-widget": "textarea", + "x-icon": "user", + }, + ) + """_wrap_私聊通用注意事项(实验性功能)""" + chat_prompts: list[ExtraPromptItem] = Field( default_factory=lambda: [], json_schema_extra={ diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 038fc0f0..93b48753 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -18,6 +18,7 @@ from src.common.data_models.llm_service_data_models import LLMGenerationOptions from src.common.data_models.message_component_data_model import MessageSequence, TextComponent from src.common.logger import get_logger from src.common.prompt_i18n import load_prompt +from src.common.utils.utils_session import SessionUtils from src.config.config import global_config from src.core.tooling import ToolRegistry, ToolSpec from src.know_u.knowledge import extract_category_ids_from_result @@ -63,6 +64,8 @@ class MaisakaChatLoopService: def __init__( self, chat_system_prompt: Optional[str] = None, + session_id: Optional[str] = None, + is_group_chat: Optional[bool] = None, temperature: float = 0.5, max_tokens: int = 2048, ) -> None: @@ -70,12 +73,16 @@ class MaisakaChatLoopService: Args: chat_system_prompt: 可选的系统提示词。 + session_id: 当前会话 ID,用于匹配会话级额外提示。 + is_group_chat: 当前会话是否为群聊。 temperature: 规划器温度参数。 max_tokens: 规划器最大输出长度。 """ self._temperature = temperature self._max_tokens = max_tokens + self._is_group_chat = is_group_chat + self._session_id = session_id or "" self._extra_tools: List[ToolOption] = [] self._interrupt_flag: asyncio.Event | None = None self._tool_registry: ToolRegistry | None = None @@ -141,6 +148,7 @@ class MaisakaChatLoopService: "maisaka_chat", file_tools_section=tools_section, bot_name=global_config.bot.nickname, + group_chat_attention_block=self._build_group_chat_attention_block(), identity=self._personality_prompt, ) except Exception: @@ -148,6 +156,74 @@ class MaisakaChatLoopService: self._prompts_loaded = True + def _build_group_chat_attention_block(self) -> str: + """构建当前聊天场景下的额外注意事项块。""" + + prompt_lines: List[str] = [] + + if self._is_group_chat is True: + if group_chat_prompt := str(global_config.experimental.group_chat_prompt or "").strip(): + prompt_lines.append(group_chat_prompt) + elif self._is_group_chat is False: + if private_chat_prompt := str(global_config.experimental.private_chat_prompts or "").strip(): + prompt_lines.append(private_chat_prompt) + + if self._session_id: + if chat_prompt := self._get_chat_prompt_for_chat(self._session_id, self._is_group_chat).strip(): + prompt_lines.append(chat_prompt) + + if not prompt_lines: + return "" + + return f"在该聊天中的注意事项:\n" + "\n".join(prompt_lines) + "\n" + + @staticmethod + def _get_chat_prompt_for_chat(chat_id: str, is_group_chat: Optional[bool]) -> str: + """根据聊天流 ID 获取匹配的额外提示。""" + + if not global_config.experimental.chat_prompts: + return "" + + for chat_prompt_item in global_config.experimental.chat_prompts: + if hasattr(chat_prompt_item, "platform"): + platform = str(chat_prompt_item.platform or "").strip() + item_id = str(chat_prompt_item.item_id or "").strip() + rule_type = str(chat_prompt_item.rule_type or "").strip() + prompt_content = str(chat_prompt_item.prompt or "").strip() + elif isinstance(chat_prompt_item, str): + parts = chat_prompt_item.split(":", 3) + if len(parts) != 4: + continue + + platform, item_id, rule_type, prompt_content = parts + platform = platform.strip() + item_id = item_id.strip() + rule_type = rule_type.strip() + prompt_content = prompt_content.strip() + else: + continue + + if not platform or not item_id or not prompt_content: + continue + + if rule_type == "group": + config_is_group = True + config_chat_id = SessionUtils.calculate_session_id(platform, group_id=item_id) + elif rule_type == "private": + config_is_group = False + config_chat_id = SessionUtils.calculate_session_id(platform, user_id=item_id) + else: + continue + + if is_group_chat is not None and config_is_group != is_group_chat: + continue + + if config_chat_id == chat_id: + logger.debug(f"匹配到 Maisaka 聊天额外提示,chat_id: {chat_id}, prompt: {prompt_content[:50]}...") + return prompt_content + + return "" + def set_extra_tools(self, tools: Sequence[ToolDefinitionInput]) -> None: """设置额外工具定义。 diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py index a4b67a23..33efb4dd 100644 --- a/src/maisaka/runtime.py +++ b/src/maisaka/runtime.py @@ -49,7 +49,10 @@ class MaisakaHeartFlowChatting: session_name = chat_manager.get_session_name(session_id) or session_id self.log_prefix = f"[{session_name}]" - self._chat_loop_service = MaisakaChatLoopService() + self._chat_loop_service = MaisakaChatLoopService( + session_id=session_id, + is_group_chat=self.chat_stream.is_group_session, + ) self._chat_history: list[LLMContextMessage] = [] self.history_loop: list[CycleDetail] = [] From 975531592b03532fa5a86e61f1b6895a1672c380 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 2 Apr 2026 17:56:46 +0800 Subject: [PATCH 08/18] =?UTF-8?q?feat=EF=BC=9A=E4=BF=AE=E6=94=B9=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prompts/en-US/action.prompt | 5 - prompts/en-US/brain_action.prompt | 10 +- prompts/en-US/brain_planner.prompt | 100 ++++++------------ prompts/en-US/chat_target_group1.prompt | 1 - prompts/en-US/chat_target_group2.prompt | 1 - prompts/en-US/chat_target_private1.prompt | 1 - prompts/en-US/chat_target_private2.prompt | 1 - prompts/en-US/default_expressor.prompt | 16 +-- prompts/en-US/emoji_content_analysis.prompt | 8 +- prompts/en-US/emoji_content_filtration.prompt | 12 +-- prompts/en-US/emoji_replace.prompt | 16 +-- prompts/en-US/expression_evaluation.prompt | 16 +-- prompts/en-US/expression_select.prompt | 20 ++-- prompts/en-US/hippo_topic_analysis.prompt | 32 +++--- prompts/en-US/hippo_topic_summary.prompt | 28 ++--- prompts/en-US/jargon_compare_inference.prompt | 16 +-- .../en-US/jargon_explainer_summarize.prompt | 14 +-- .../jargon_inference_content_only.prompt | 14 +-- .../jargon_inference_with_context.prompt | 20 ++-- prompts/en-US/learn_style.prompt | 74 ++++++------- prompts/en-US/lpmm_get_knowledge.prompt | 10 -- prompts/en-US/maidairy_cognition.prompt | 11 -- prompts/en-US/maidairy_emotion.prompt | 11 -- .../en-US/maidairy_knowledge_category.prompt | 22 ++-- .../en-US/maidairy_knowledge_extract.prompt | 24 ++--- .../en-US/maidairy_knowledge_retrieve.prompt | 22 ++-- prompts/en-US/maisaka_chat.prompt | 40 +++---- prompts/en-US/maisaka_replyer.prompt | 11 +- .../en-US/memory_retrieval_react_final.prompt | 19 ---- ...ry_retrieval_react_prompt_head_lpmm.prompt | 17 --- prompts/en-US/planner.prompt | 52 ++++----- prompts/en-US/private_replyer.prompt | 15 --- prompts/en-US/private_replyer_self.prompt | 14 --- prompts/en-US/replyer.prompt | 18 ---- prompts/en-US/replyer_light.prompt | 18 ---- prompts/en-US/tool_executor.prompt | 11 -- prompts/ja-JP/action.prompt | 5 - prompts/ja-JP/brain_action.prompt | 8 +- prompts/ja-JP/brain_planner.prompt | 100 ++++++------------ prompts/ja-JP/chat_target_group1.prompt | 1 - prompts/ja-JP/chat_target_group2.prompt | 1 - prompts/ja-JP/chat_target_private1.prompt | 1 - prompts/ja-JP/chat_target_private2.prompt | 1 - prompts/ja-JP/default_expressor.prompt | 16 +-- prompts/ja-JP/emoji_content_analysis.prompt | 8 +- prompts/ja-JP/emoji_content_filtration.prompt | 12 +-- prompts/ja-JP/emoji_replace.prompt | 16 +-- prompts/ja-JP/expression_evaluation.prompt | 16 +-- prompts/ja-JP/expression_select.prompt | 20 ++-- prompts/ja-JP/hippo_topic_analysis.prompt | 32 +++--- prompts/ja-JP/hippo_topic_summary.prompt | 28 ++--- prompts/ja-JP/jargon_compare_inference.prompt | 14 +-- .../ja-JP/jargon_explainer_summarize.prompt | 14 +-- .../jargon_inference_content_only.prompt | 14 +-- .../jargon_inference_with_context.prompt | 20 ++-- prompts/ja-JP/learn_style.prompt | 74 ++++++------- prompts/ja-JP/lpmm_get_knowledge.prompt | 10 -- prompts/ja-JP/maidairy_cognition.prompt | 11 -- prompts/ja-JP/maidairy_emotion.prompt | 11 -- .../ja-JP/maidairy_knowledge_category.prompt | 22 ++-- .../ja-JP/maidairy_knowledge_extract.prompt | 24 ++--- .../ja-JP/maidairy_knowledge_retrieve.prompt | 22 ++-- prompts/ja-JP/maisaka_chat.prompt | 43 ++++---- prompts/ja-JP/maisaka_replyer.prompt | 13 ++- .../ja-JP/memory_retrieval_react_final.prompt | 19 ---- ...ry_retrieval_react_prompt_head_lpmm.prompt | 17 --- prompts/ja-JP/planner.prompt | 52 ++++----- prompts/ja-JP/private_replyer.prompt | 15 --- prompts/ja-JP/private_replyer_self.prompt | 14 --- prompts/ja-JP/replyer.prompt | 18 ---- prompts/ja-JP/replyer_light.prompt | 18 ---- prompts/ja-JP/tool_executor.prompt | 11 -- prompts/zh-CN/maisaka_chat.prompt | 4 +- 73 files changed, 530 insertions(+), 915 deletions(-) delete mode 100644 prompts/en-US/action.prompt delete mode 100644 prompts/en-US/chat_target_group1.prompt delete mode 100644 prompts/en-US/chat_target_group2.prompt delete mode 100644 prompts/en-US/chat_target_private1.prompt delete mode 100644 prompts/en-US/chat_target_private2.prompt delete mode 100644 prompts/en-US/lpmm_get_knowledge.prompt delete mode 100644 prompts/en-US/maidairy_cognition.prompt delete mode 100644 prompts/en-US/maidairy_emotion.prompt delete mode 100644 prompts/en-US/memory_retrieval_react_final.prompt delete mode 100644 prompts/en-US/memory_retrieval_react_prompt_head_lpmm.prompt delete mode 100644 prompts/en-US/private_replyer.prompt delete mode 100644 prompts/en-US/private_replyer_self.prompt delete mode 100644 prompts/en-US/replyer.prompt delete mode 100644 prompts/en-US/replyer_light.prompt delete mode 100644 prompts/en-US/tool_executor.prompt delete mode 100644 prompts/ja-JP/action.prompt delete mode 100644 prompts/ja-JP/chat_target_group1.prompt delete mode 100644 prompts/ja-JP/chat_target_group2.prompt delete mode 100644 prompts/ja-JP/chat_target_private1.prompt delete mode 100644 prompts/ja-JP/chat_target_private2.prompt delete mode 100644 prompts/ja-JP/lpmm_get_knowledge.prompt delete mode 100644 prompts/ja-JP/maidairy_cognition.prompt delete mode 100644 prompts/ja-JP/maidairy_emotion.prompt delete mode 100644 prompts/ja-JP/memory_retrieval_react_final.prompt delete mode 100644 prompts/ja-JP/memory_retrieval_react_prompt_head_lpmm.prompt delete mode 100644 prompts/ja-JP/private_replyer.prompt delete mode 100644 prompts/ja-JP/private_replyer_self.prompt delete mode 100644 prompts/ja-JP/replyer.prompt delete mode 100644 prompts/ja-JP/replyer_light.prompt delete mode 100644 prompts/ja-JP/tool_executor.prompt diff --git a/prompts/en-US/action.prompt b/prompts/en-US/action.prompt deleted file mode 100644 index 91831b2a..00000000 --- a/prompts/en-US/action.prompt +++ /dev/null @@ -1,5 +0,0 @@ -{action_name} -动作描述:{action_description} -使用条件{parallel_text}: -{action_require} -{{"action":"{action_name}",{action_parameters}, "target_message_id":"消息id(m+数字)"}} \ No newline at end of file diff --git a/prompts/en-US/brain_action.prompt b/prompts/en-US/brain_action.prompt index 8de841c7..3cae6511 100644 --- a/prompts/en-US/brain_action.prompt +++ b/prompts/en-US/brain_action.prompt @@ -1,9 +1,9 @@ {action_name} -动作描述:{action_description} -使用条件: +Action description: {action_description} +Usage conditions: {action_require} {{ "action": "{action_name}",{action_parameters}, - "target_message_id":"触发action的消息id", - "reason":"触发action的原因" -}} \ No newline at end of file + "target_message_id":"message ID that triggered the action", + "reason":"reason for triggering the action" +}} diff --git a/prompts/en-US/brain_planner.prompt b/prompts/en-US/brain_planner.prompt index a3bfd10c..22b9a00d 100644 --- a/prompts/en-US/brain_planner.prompt +++ b/prompts/en-US/brain_planner.prompt @@ -1,77 +1,37 @@ -{time_block} -{name_block} -{chat_context_description},以下是具体的聊天内容 +Your task is to analyze the conversation and the interactions happening in the chat. +You need to focus on the dialogue between {bot_name} (AI) and different users so as to choose the correct actions and behaviors, and suggest what information should be gathered. -**聊天内容** -{chat_content_block} +[Reference Information] +{bot_name}'s persona: {identity} +[End of Reference Information] -**动作记录** -{actions_before_now_block} +You need to analyze based on the provided reference information, the current scenario, and the output rules. +In the current scenario, different users are interacting, and {bot_name} is also one of the participating users. Users may also be chatting with each other. Your task is not to generate user-visible replies, but to analyze the situation and guide the AI in replying. +"Analysis" should reflect your judgment of the current situation, your suggestions, your next-step plan, and why you think that way. +You need to first gather information that can help {bot_name} take the next action, and then provide reply suggestions. -**可用的action** -reply -动作描述: -进行回复,你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题 -{{ - "action": "reply", - "target_message_id":"想要回复的消息id", - "reason":"回复的原因" -}} +You can use these tools: +- wait(seconds) - Temporarily pause the conversation, wait for `seconds`, hand the speaking turn to the user, and wait for the other party's new message. +- no_reply() - When you judge that {bot_name} should not speak right now, end the conversation and do not reply in any way until the other party sends a new message. +- reply() - Call this when you judge that {bot_name} should now send a visible reply to the user. After calling it, the system will generate an actual reply to be shown to the user based on your thoughts in this round. You may reply to a specific user or to all users. +- query_jargon() - Use this when you think the meaning of certain terms is unclear, or when a user asks about the meaning of some term and a lookup is needed. +- Other defined tools may also be used as appropriate. -wait -动作描述: -暂时不再发言,等待指定时间。适用于以下情况: -- 你已经表达清楚一轮,想给对方留出空间 -- 你感觉对方的话还没说完,或者自己刚刚发了好几条连续消息 -- 你想要等待一定时间来让对方把话说完,或者等待对方反应 -- 你想保持安静,专注"听"而不是马上回复 -请你根据上下文来判断要等待多久,请你灵活判断: -- 如果你们交流间隔时间很短,聊的很频繁,不宜等待太久 -- 如果你们交流间隔时间很长,聊的很少,可以等待较长时间 -{{ - "action": "wait", - "target_message_id":"想要作为这次等待依据的消息id(通常是对方的最新消息)", - "wait_seconds": 等待的秒数(必填,例如:5 表示等待5秒), - "reason":"选择等待的原因" -}} +Tool usage rules: +1. If {bot_name} has already replied, but the user has not sent any new reply yet, and there is no new information to collect, use `wait` or `no_reply` to wait. +2. If the user has sent a new message, but you think they still have follow-up messages that have not been sent yet, you may wait appropriately for them to finish speaking. +3. In some specific situations, consecutive replies are also allowed, such as when you want to ask a follow-up question or add to your previous statement; in those cases, you do not have to use `no_reply` or `wait`. +4. You need to control your speaking frequency. If it is a one-on-one chat, you may speak at a relatively even frequency. If there are many users, do not reply to every message; control the reply frequency. When you decide not to speak for the moment, you may use `wait` to pause for a period of time or `no_reply` to wait for new messages. +5. Do not reply to every message. Do not directly reply to sticker-only messages sent by other users. Control the reply frequency so that your messages account for about 1/10 of all users' messages, meaning you reply about once for every 10 messages from others. +6. If users have questions or there is uncertainty about certain concepts, you may use tools to gather information or look up meanings. You may use multiple tools. -complete_talk -动作描述: -当前聊天暂时结束了,对方离开,没有更多话题了 -你可以使用该动作来暂时休息,等待对方有新发言再继续: -- 多次wait之后,对方迟迟不回复消息才用 -- 如果对方只是短暂不回复,应该使用wait而不是complete_talk -- 聊天内容显示当前聊天已经结束或者没有新内容时候,选择complete_talk -选择此动作后,将不再继续循环思考,直到收到对方的新消息 -{{ - "action": "complete_talk", - "target_message_id":"触发完成对话的消息id(通常是对方的最新消息)", - "reason":"选择完成对话的原因" -}} +Your analysis rules: +1. By default, directly output your latest analysis. Do not repeat previous analysis content. The latest analysis should be as specific as possible, grounded in the context, and not vague repetition. +2. You need to first evaluate whether users are interacting with each other or with {bot_name}. Do not jump in blindly and reply to the wrong target. +3. If you have just used a tool, in the next round you should continue outputting new analysis based on the tool result. +4. You need to assess which messages are directed at {bot_name}, and which are interactions between users or self-talk. Do not frequently insert yourself into unrelated topics. +5. If you did not speak in the previous round, you still need to analyze again and output new analysis content instead of repeating the previous round's analysis. -{action_options_text} +{group_chat_attention_block} -请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字 -先输出你的选择思考理由,再输出你选择的action,理由是一段平文本,不要分点,精简。 -**动作选择要求** -请你根据聊天内容,用户的最新消息和以下标准选择合适的动作: -{plan_style} -{moderation_prompt} - -请选择所有符合使用要求的action,动作用json格式输出,如果输出多个json,每个json都要单独用```json包裹,你可以重复使用同一个动作或不同动作: -**示例** -// 理由文本 -```json -{{ - "action":"动作名", - "target_message_id":"触发动作的消息id", - //对应参数 -}} -``` -```json -{{ - "action":"动作名", - "target_message_id":"触发动作的消息id", - //对应参数 -}} -``` \ No newline at end of file +Now, please output your analysis of how {bot_name} should speak. You must first output the textual analysis, and only then make tool calls: diff --git a/prompts/en-US/chat_target_group1.prompt b/prompts/en-US/chat_target_group1.prompt deleted file mode 100644 index 77e89bcc..00000000 --- a/prompts/en-US/chat_target_group1.prompt +++ /dev/null @@ -1 +0,0 @@ -你正在qq群里聊天,下面是群里正在聊的内容: \ No newline at end of file diff --git a/prompts/en-US/chat_target_group2.prompt b/prompts/en-US/chat_target_group2.prompt deleted file mode 100644 index 5b71bace..00000000 --- a/prompts/en-US/chat_target_group2.prompt +++ /dev/null @@ -1 +0,0 @@ -正在群里聊天 \ No newline at end of file diff --git a/prompts/en-US/chat_target_private1.prompt b/prompts/en-US/chat_target_private1.prompt deleted file mode 100644 index 3e86c71f..00000000 --- a/prompts/en-US/chat_target_private1.prompt +++ /dev/null @@ -1 +0,0 @@ -你正在和{sender_name}聊天,这是你们之前聊的内容: \ No newline at end of file diff --git a/prompts/en-US/chat_target_private2.prompt b/prompts/en-US/chat_target_private2.prompt deleted file mode 100644 index 9225ec82..00000000 --- a/prompts/en-US/chat_target_private2.prompt +++ /dev/null @@ -1 +0,0 @@ -和{sender_name}聊天 \ No newline at end of file diff --git a/prompts/en-US/default_expressor.prompt b/prompts/en-US/default_expressor.prompt index 4d05bc60..a97c8366 100644 --- a/prompts/en-US/default_expressor.prompt +++ b/prompts/en-US/default_expressor.prompt @@ -3,14 +3,14 @@ {chat_info} {identity} -你正在{chat_target_2},{reply_target_block} -现在请你对这句内容进行改写,请你参考上述内容进行改写,原句是:{raw_reply}: -原因是:{reason} -现在请你将这条具体内容改写成一条适合在群聊中发送的回复消息。 -你需要使用合适的语法和句法,参考聊天内容,组织一条日常且口语化的回复。请你修改你想表达的原句,符合你的表达风格和语言习惯 +You are currently in {chat_target_2},{reply_target_block} +Now please rewrite this sentence. Please refer to the content above when rewriting. The original sentence is: {raw_reply}: +The reason is: {reason} +Now please rewrite this specific content into a reply message suitable for sending in a group chat. +You need to use appropriate grammar and syntax, refer to the chat content, and organize a natural, colloquial reply for daily conversation. Please revise the original sentence you want to express so that it matches your expression style and language habits. {reply_style} -你可以完全重组回复,保留最基本的表达含义就好,但重组后保持语意通顺。 +You may completely restructure the reply. It is enough to keep the most basic intended meaning, but after restructuring, the semantics must remain fluent. {keywords_reaction_prompt} {moderation_prompt} -不要输出多余内容(包括冒号和引号,表情包,emoji,at或 @等 ),只输出一条回复就好。不要思考的太长。 -改写后的回复: \ No newline at end of file +Do not output any extra content (including colons, quotation marks, stickers, emoji, at, or @). Only output a single reply. Do not think for too long. +Rewritten reply: diff --git a/prompts/en-US/emoji_content_analysis.prompt b/prompts/en-US/emoji_content_analysis.prompt index c3834ce3..99c7f49a 100644 --- a/prompts/en-US/emoji_content_analysis.prompt +++ b/prompts/en-US/emoji_content_analysis.prompt @@ -1,5 +1,5 @@ -这是一个聊天场景中的表情包描述:"{description}" +This is a description of a sticker used in a chat scene: "{description}" -请你识别这个表情包的含义和适用场景,给我简短的描述,每个描述不要超过15个字 -你可以关注其幽默和讽刺意味,动用贴吧,微博,小红书的知识,必须从互联网梗、meme的角度去分析 -请直接输出描述,不要出现任何其他内容,如果有多个描述,可以用逗号分隔 \ No newline at end of file +Please identify the meaning and suitable usage scenarios of this sticker, and give me brief descriptions. Each description should not exceed 15 words. +You may focus on its humorous and sarcastic meaning, draw on knowledge from Tieba, Weibo, and Xiaohongshu, and you must analyze it from the perspective of internet slang and memes. +Please directly output the descriptions without any other content. If there are multiple descriptions, separate them with commas. diff --git a/prompts/en-US/emoji_content_filtration.prompt b/prompts/en-US/emoji_content_filtration.prompt index 6bb73a53..65cbd443 100644 --- a/prompts/en-US/emoji_content_filtration.prompt +++ b/prompts/en-US/emoji_content_filtration.prompt @@ -1,6 +1,6 @@ -这是一个表情包,请对这个表情包进行审核,标准如下: -1. 必须符合"{demand}"的要求 -2. 不能是色情、暴力、等违法违规内容,必须符合公序良俗 -3. 不能是任何形式的截图,聊天记录或视频截图 -4. 不要出现5个以上文字 -请回答这个表情包是否满足上述要求,是则回答是,否则回答否,不要出现任何其他内容 \ No newline at end of file +This is a sticker. Please review it according to the following criteria: +1. It must meet the requirement of "{demand}" +2. It must not contain pornography, violence, or other illegal or non-compliant content, and it must conform to public order and good morals +3. It must not be any form of screenshot, chat record, or video screenshot +4. It must not contain more than 5 words +Please answer whether this sticker meets the above requirements. If it does, answer yes; otherwise answer no. Do not output any other content. diff --git a/prompts/en-US/emoji_replace.prompt b/prompts/en-US/emoji_replace.prompt index 69093fda..72b8f204 100644 --- a/prompts/en-US/emoji_replace.prompt +++ b/prompts/en-US/emoji_replace.prompt @@ -1,12 +1,12 @@ -{nickname}的表情包存储已满({emoji_num}/{emoji_num_max}),需要决定是否删除一个旧表情包来为新表情包腾出空间。 +{nickname}'s sticker storage is full ({emoji_num}/{emoji_num_max}), and it is necessary to decide whether to delete an old sticker to make room for a new sticker. -新表情包信息: -描述: {description} +New sticker information: +Description: {description} -现有表情包列表: +Existing sticker list: {emoji_list} -请决定: -1. 是否要删除某个现有表情包来为新表情包腾出空间? -2. 如果要删除,应该删除哪一个(给出编号)? -请只回答:'不删除'或'删除编号X'(X为表情包编号)。 \ No newline at end of file +Please decide: +1. Whether to delete an existing sticker to make room for the new sticker +2. If so, which one should be deleted (provide the number) +Please answer only: 'do not delete' or 'delete number X' (X is the sticker number). diff --git a/prompts/en-US/expression_evaluation.prompt b/prompts/en-US/expression_evaluation.prompt index abb5b5aa..e1d2efba 100644 --- a/prompts/en-US/expression_evaluation.prompt +++ b/prompts/en-US/expression_evaluation.prompt @@ -1,15 +1,15 @@ -请评估以下表达方式或语言风格以及使用条件或使用情景是否合适: -使用条件或使用情景:{situation} -表达方式或言语风格:{style} +Please evaluate whether the following expression or language style, together with its usage condition or usage scenario, is appropriate: +Usage condition or usage scenario: {situation} +Expression or language style: {style} -请从以下方面进行评估: +Please evaluate from the following aspects: {criteria_list} -请以JSON格式输出评估结果: +Please output the evaluation result in JSON format: {{ "suitable": true/false, - "reason": "评估理由(如果不合适,请说明原因)" + "reason": "Reason for evaluation (if inappropriate, explain why)" }} -如果合适,suitable设为true;如果不合适,suitable设为false,并在reason中说明原因。 -请严格按照JSON格式输出,不要包含其他内容。 \ No newline at end of file +If it is appropriate, set suitable to true. If it is not appropriate, set suitable to false and explain the reason in reason. +Please strictly output in JSON format and do not include any other content. diff --git a/prompts/en-US/expression_select.prompt b/prompts/en-US/expression_select.prompt index 69fa1dc5..ccab72dc 100644 --- a/prompts/en-US/expression_select.prompt +++ b/prompts/en-US/expression_select.prompt @@ -1,22 +1,22 @@ {chat_observe_info} -你的名字是{bot_name}{target_message} +Your name is {bot_name}{target_message} {reply_reason_block} -以下是可选的表达情境: +The following are optional expression situations: {all_situations} -请你分析聊天内容的语境、情绪、话题类型,从上述情境中选择最适合当前聊天情境的,最多{max_num}个情境。 -考虑因素包括: -1.聊天的情绪氛围(轻松、严肃、幽默等) -2.话题类型(日常、技术、游戏、情感等) -3.情境与当前语境的匹配度 +Please analyze the context, emotional tone, and topic type of the chat content, and select the situations above that best fit the current chat situation, up to {max_num} situations. +Factors to consider include: +1. The emotional atmosphere of the chat (relaxed, serious, humorous, etc.) +2. The topic type (daily life, technology, games, emotions, etc.) +3. The degree of match between the situation and the current context {target_message_extra_block} -请以JSON格式输出,只需要输出选中的情境编号: -例如: +Please output in JSON format. You only need to output the selected situation numbers: +For example: {{ "selected_situations": [2, 3, 5, 7, 19] }} -请严格按照JSON格式输出,不要包含其他内容: \ No newline at end of file +Please strictly output in JSON format and do not include any other content: diff --git a/prompts/en-US/hippo_topic_analysis.prompt b/prompts/en-US/hippo_topic_analysis.prompt index 14f3eee1..0fce62eb 100644 --- a/prompts/en-US/hippo_topic_analysis.prompt +++ b/prompts/en-US/hippo_topic_analysis.prompt @@ -1,27 +1,27 @@ -【历史话题标题列表】(仅标题,不含具体内容): +[Historical Topic Title List] (titles only, no specific content): {history_topics_block} -【历史话题标题列表结束】 +[End of Historical Topic Title List] -【本次聊天记录】(每条消息前有编号,用于后续引用): +[Current Chat Log] (each message has an index before it for later reference): {messages_block} -【本次聊天记录结束】 +[End of Current Chat Log] -请完成以下任务: -**识别话题** -1. 识别【本次聊天记录】中正在进行的一个或多个话题; -2. 【本次聊天记录】的中的消息可能与历史话题有关,也可能毫无关联。 -2. 判断【历史话题标题列表】中的话题是否在【本次聊天记录】中出现,如果出现,则直接使用该历史话题标题字符串; +Please complete the following tasks: +**Identify topics** +1. Identify one or more ongoing topics in the [Current Chat Log]; +2. Messages in the [Current Chat Log] may be related to historical topics, or they may be completely unrelated; +3. Determine whether the topics in the [Historical Topic Title List] appear in the [Current Chat Log]. If they do, directly use that historical topic title string; -**选取消息** -1. 对于每个话题(新话题或历史话题),从上述带编号的消息中选出与该话题强相关的消息编号列表; -2. 每个话题用一句话清晰地描述正在发生的事件,必须包含时间(大致即可)、人物、主要事件和主题,保证精准且有区分度; +**Select messages** +1. For each topic (whether new or historical), select a list of message indices from the numbered messages above that are strongly related to that topic; +2. For each topic, use one sentence to clearly describe the event that is happening. It must include time (approximate is fine), people, the main event, and the theme, ensuring accuracy and distinction; -请先输出一段简短思考,说明有什么话题,哪些是不包含在历史话题中的,哪些是包含在历史话题中的,并说明为什么; -然后严格以 JSON 格式输出【本次聊天记录】中涉及的话题,格式如下: +Please first output a short piece of reasoning explaining what topics exist, which are not included in the historical topics, which are included in the historical topics, and why; +Then strictly output the topics involved in the [Current Chat Log] in JSON format as follows: [ {{ - "topic": "话题", + "topic": "topic", "message_indices": [1, 2, 5] }}, ... -] \ No newline at end of file +] diff --git a/prompts/en-US/hippo_topic_summary.prompt b/prompts/en-US/hippo_topic_summary.prompt index efd3e142..a8d843ef 100644 --- a/prompts/en-US/hippo_topic_summary.prompt +++ b/prompts/en-US/hippo_topic_summary.prompt @@ -1,22 +1,22 @@ -请基于以下话题,对聊天记录片段进行概括,提取以下信息: +Please summarize the following chat record segment based on the topic and extract the following information: -**话题**:{topic} +**Topic**: {topic} -**要求**: -1. 关键词:提取与话题相关的关键词,用列表形式返回(3-10个关键词) -2. 概括:对这段话的平文本概括(50-200字),要求: - - 仔细地转述发生的事件和聊天内容; - - 重点突出事件的发展过程和结果; - - 围绕话题这个中心进行概括。 - - 提取话题中的关键信息点,关键信息点应该简洁明了。 +**Requirements**: +1. Keywords: extract keywords related to the topic and return them as a list (3-10 keywords) +2. Summary: provide a plain-text summary of this segment (50-200 words). Requirements: + - Carefully retell the event and the chat content; + - Highlight the development process and the result of the event; + - Summarize around the central topic; + - Extract the key information points in the topic, and keep them concise and clear. -请以JSON格式返回,格式如下: +Please return in JSON format as follows: {{ - "keywords": ["关键词1", "关键词2", ...], - "summary": "概括内容" + "keywords": ["keyword1", "keyword2", ...], + "summary": "summary content" }} -聊天记录: +Chat record: {original_text} -请直接返回JSON,不要包含其他内容。 \ No newline at end of file +Please return JSON directly and do not include any other content. diff --git a/prompts/en-US/jargon_compare_inference.prompt b/prompts/en-US/jargon_compare_inference.prompt index eca8fcee..5d020b7a 100644 --- a/prompts/en-US/jargon_compare_inference.prompt +++ b/prompts/en-US/jargon_compare_inference.prompt @@ -1,15 +1,15 @@ -**推断结果1(基于上下文)** +**Inference Result 1 (based on context)** {inference1} -**推断结果2(仅基于词条)** +**Inference Result 2 (based only on the term itself)** {inference2} -请比较这两个推断结果,判断它们是否相同或类似。 -- 如果两个推断结果的"含义"相同或类似,说明这个词条不是黑话(含义明确) -- 如果两个推断结果有差异,说明这个词条可能是黑话(需要上下文才能理解) +Please compare these two inference results and determine whether they are the same or similar. +- If the "meaning" in the two inference results is the same or similar, it means this term is not jargon (its meaning is clear) +- If the two inference results differ, it means this term may be jargon (it can only be understood with context) -以 JSON 格式输出: +Output in JSON format: {{ "is_similar": true/false, - "reason": "判断理由" -}} \ No newline at end of file + "reason": "Reason for the judgment" +}} diff --git a/prompts/en-US/jargon_explainer_summarize.prompt b/prompts/en-US/jargon_explainer_summarize.prompt index 427d9f05..791da166 100644 --- a/prompts/en-US/jargon_explainer_summarize.prompt +++ b/prompts/en-US/jargon_explainer_summarize.prompt @@ -1,11 +1,11 @@ -上下文聊天内容: +Context chat content: {chat_context} -在上下文中提取到的黑话及其含义: +Jargon extracted from the context and their meanings: {jargon_explanations} -请根据上述信息,对黑话解释进行概括和整理。 -- 如果上下文中有黑话出现,请简要说明这些黑话在上下文中的使用情况 -- 将所有黑话解释整理成简洁、易读的一段话 -- 输出格式要自然,适合作为回复参考信息 -请输出概括后的黑话解释(直接输出一段平文本,不要标题,无特殊格式或markdown格式,不要使用JSON格式): \ No newline at end of file +Please summarize and organize the jargon explanations based on the information above. +- If jargon appears in the context, briefly explain how the jargon is used in the context +- Organize all jargon explanations into a concise and readable paragraph +- The output format should be natural and suitable as reference information for replies +Please output the summarized jargon explanations (directly output one plain-text paragraph, no title, no special formatting or Markdown, and do not use JSON): diff --git a/prompts/en-US/jargon_inference_content_only.prompt b/prompts/en-US/jargon_inference_content_only.prompt index f6258e91..6dcdcedb 100644 --- a/prompts/en-US/jargon_inference_content_only.prompt +++ b/prompts/en-US/jargon_inference_content_only.prompt @@ -1,11 +1,11 @@ -**词条内容** +**Term content** {content} -请仅根据这个词条本身,推断其含义。 -- 如果这是一个黑话、俚语或网络用语,请推断其含义 -- 如果含义明确(常规词汇),也请说明 +Please infer its meaning based only on this term itself. +- If this is jargon, slang, or internet language, please infer its meaning +- If the meaning is clear (ordinary vocabulary), please also explain it -以 JSON 格式输出: +Output in JSON format: {{ - "meaning": "详细含义说明(包含使用场景、来源、具体解释等)" -}} \ No newline at end of file + "meaning": "Detailed explanation of the meaning (including usage scenarios, source, specific explanation, etc.)" +}} diff --git a/prompts/en-US/jargon_inference_with_context.prompt b/prompts/en-US/jargon_inference_with_context.prompt index 295896e3..92e58a44 100644 --- a/prompts/en-US/jargon_inference_with_context.prompt +++ b/prompts/en-US/jargon_inference_with_context.prompt @@ -1,19 +1,19 @@ -**词条内容** +**Term content** {content} -**词条出现的上下文。其中的{bot_name}的发言内容是你自己的发言** +**The context in which the term appears. The lines spoken by {bot_name} are your own lines** {raw_content_list} {previous_meaning_section} -请根据上下文,推断"{content}"这个词条的含义。 -- 如果这是一个黑话、俚语或网络用语,请推断其含义 -- 如果含义明确(常规词汇),也请说明 -- {bot_name} 的发言内容可能包含错误,请不要参考其发言内容 -- 如果上下文信息不足,无法推断含义,请设置 no_info 为 true +Please infer the meaning of the term "{content}" based on the context. +- If this is jargon, slang, or internet language, please infer its meaning +- If the meaning is clear (ordinary vocabulary), please also explain it +- The lines spoken by {bot_name} may contain mistakes, so do not rely on those lines +- If the contextual information is insufficient and the meaning cannot be inferred, please set no_info to true {previous_meaning_instruction} -以 JSON 格式输出: +Output in JSON format: {{ - "meaning": "详细含义说明(包含使用场景、来源、具体解释等)", + "meaning": "Detailed explanation of the meaning (including usage scenarios, source, specific explanation, etc.)", "no_info": false }} -注意:如果信息不足无法推断,请设置 "no_info": true,此时 meaning 可以为空字符串 \ No newline at end of file +Note: If there is insufficient information to infer the meaning, please set "no_info": true. In that case, meaning may be an empty string. diff --git a/prompts/en-US/learn_style.prompt b/prompts/en-US/learn_style.prompt index 9dd2e591..77fcf082 100644 --- a/prompts/en-US/learn_style.prompt +++ b/prompts/en-US/learn_style.prompt @@ -1,49 +1,49 @@ {chat_str} -你的名字是{bot_name},现在请你完成两个提取任务 -任务1:请从上面这段群聊中用户的语言风格和说话方式 -1. 只考虑文字,不要考虑表情包和图片 -2. 不要总结SELF的发言,因为这是你自己的发言,不要重复学习你自己的发言 -3. 不要涉及具体的人名,也不要涉及具体名词 -4. 思考有没有特殊的梗,一并总结成语言风格 -5. 例子仅供参考,请严格根据群聊内容总结!!! -注意:总结成如下格式的规律,总结的内容要详细,但具有概括性: -例如:当"AAAAA"时,可以"BBBBB", AAAAA代表某个场景,不超过20个字。BBBBB代表对应的语言风格,特定句式或表达方式,不超过20个字。 -表达方式在3-5个左右,不要超过10个 +Your name is {bot_name}. Now please complete two extraction tasks. +Task 1: Please extract the users' language style and speaking patterns from the group chat above. +1. Only consider text; do not consider stickers or images +2. Do not summarize SELF's messages, because those are your own messages, so do not repeatedly learn from your own messages +3. Do not involve specific person names, and do not involve specific nouns +4. Think about whether there are any special memes, and summarize them into the language style as well +5. The examples are for reference only. Please summarize strictly according to the group chat content!!! +Note: Summarize them into rules in the following format. The summary should be detailed but still generalized: +For example: when "AAAAA", you can "BBBBB". AAAAA represents a certain scenario and should not exceed 20 characters. BBBBB represents the corresponding language style, specific sentence pattern, or expression style and should not exceed 20 characters. +There should be around 3-5 expression styles, and no more than 10. -任务2:请从上面这段聊天内容中提取"可能是黑话"的候选项(黑话/俚语/网络缩写/口头禅)。 -- 必须为对话中真实出现过的短词或短语 -- 必须是你无法理解含义的词语,没有明确含义的词语,请不要选择有明确含义,或者含义清晰的词语 -- 排除:人名、@、表情包/图片中的内容、纯标点、常规功能词(如的、了、呢、啊等) -- 每个词条长度建议 2-8 个字符(不强制),尽量短小 -- 请你提取出可能的黑话,最多30个黑话,请尽量提取所有 +Task 2: Please extract candidate items from the chat content above that "may be jargon" (jargon/slang/internet abbreviations/catchphrases). +- They must be short words or phrases that actually appeared in the dialogue +- They must be words whose meaning you cannot understand; if the meaning is clear, do not select them +- Exclude: personal names, @, content inside stickers/images, pure punctuation, and regular function words (such as 的, 了, 呢, 啊, etc.) +- Each term is recommended to be 2-8 characters long (not mandatory), and should be as short as possible +- Please extract as many possible jargon items as you can, up to 30 in total -黑话必须为以下几种类型: -- 由字母构成的,汉语拼音首字母的简写词,例如:nb、yyds、xswl -- 英文词语的缩写,用英文字母概括一个词汇或含义,例如:CPU、GPU、API -- 中文词语的缩写,用几个汉字概括一个词汇或含义,例如:社死、内卷 +The jargon must be one of the following types: +- Abbreviations made of letters and formed from the initials of Chinese pinyin, such as: nb, yyds, xswl +- English abbreviations that summarize a word or meaning with letters, such as: CPU, GPU, API +- Chinese abbreviations that summarize a word or meaning with a few Chinese characters, such as: 社死, 内卷 -输出要求: -将表达方式,语言风格和黑话以 JSON 数组输出,每个元素为一个对象,结构如下(注意字段名): -注意请不要输出重复内容,请对表达方式和黑话进行去重。 +Output requirements: +Output the expression styles, language styles, and jargon as a JSON array. Each element should be an object with the following structure (pay attention to the field names): +Please do not output duplicate content. Deduplicate both expression styles and jargon. [ {{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}}, {{"situation": "CCCC", "style": "DDDD", "source_id": "7"}} - {{"situation": "对某件事表示十分惊叹", "style": "使用 我嘞个xxxx", "source_id": "[消息编号]"}}, - {{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}}, - {{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "使用 这么强!", "source_id": "[消息编号]"}}, - {{"content": "词条", "source_id": "12"}}, - {{"content": "词条2", "source_id": "5"}} + {{"situation": "expressing strong surprise about something", "style": "use 我嘞个xxxx", "source_id": "[message number]"}}, + {{"situation": "showing sarcastic agreement without reasoning", "style": "对对对", "source_id": "[message number]"}}, + {{"situation": "when talking about games, praising with a slightly teasing tone", "style": "use 这么强!", "source_id": "[message number]"}}, + {{"content": "term", "source_id": "12"}}, + {{"content": "term2", "source_id": "5"}} ] -其中: -表达方式条目: -- situation:表示“在什么情境下”的简短概括(不超过20个字) -- style:表示对应的语言风格或常用表达(不超过20个字) -- source_id:该表达方式对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 -黑话jargon条目: -- content:表示黑话的内容 -- source_id:该黑话对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 +Where: +Expression-style entries: +- situation: a short summary of "under what situation" (no more than 20 characters) +- style: the corresponding language style or commonly used expression (no more than 20 characters) +- source_id: the "source line number" corresponding to that expression style, namely the number inside square brackets in the chat log above (for example [3]); output only the number itself, without brackets +Jargon entries: +- content: the content of the jargon +- source_id: the "source line number" corresponding to that jargon, namely the number inside square brackets in the chat log above (for example [3]); output only the number itself, without brackets -现在请你输出 JSON: \ No newline at end of file +Now please output JSON: diff --git a/prompts/en-US/lpmm_get_knowledge.prompt b/prompts/en-US/lpmm_get_knowledge.prompt deleted file mode 100644 index 2ade0d0f..00000000 --- a/prompts/en-US/lpmm_get_knowledge.prompt +++ /dev/null @@ -1,10 +0,0 @@ -你是一个专门获取知识的助手。你的名字是{bot_name}。现在是{time_now}。 -群里正在进行的聊天内容: -{chat_history} - -现在,{sender}发送了内容:{target_message},你想要回复ta。 -请仔细分析聊天内容,考虑以下几点: -1. 内容中是否包含需要查询信息的问题 -2. 是否有明确的知识获取指令 - -If you need to use the search tool, please directly call the function "lpmm_search_knowledge". If you do not need to use any tool, simply output "No tool needed". \ No newline at end of file diff --git a/prompts/en-US/maidairy_cognition.prompt b/prompts/en-US/maidairy_cognition.prompt deleted file mode 100644 index 7c5c814a..00000000 --- a/prompts/en-US/maidairy_cognition.prompt +++ /dev/null @@ -1,11 +0,0 @@ -你是一个认知感知分析模块。你的任务是根据对话上下文,分析对话中用户的: -1. 核心意图(如:寻求帮助、纯粹聊天、请求任务、发泄情绪、获取信息、表达观点等) -2. 认知状态(如:明确具体、模糊试探、犹豫不决、困惑迷茫、思路清晰、逻辑混乱等) -3. 隐含目的(如:解决问题、获得安慰、打发时间、寻求认同、交换想法、表达自我等) - -要求: -- 只分析用户(对话中 role=user 的内容),不要分析助手自己 -- 根据用户最新发言重点分析,同时结合上下文理解深层动机 -- 输出简洁(2-4 句话),不要太长 -- 如果信息太少无法判断,就说信息不足,给出初步印象 -- 直接输出分析结果,不要有格式标题 diff --git a/prompts/en-US/maidairy_emotion.prompt b/prompts/en-US/maidairy_emotion.prompt deleted file mode 100644 index b8440527..00000000 --- a/prompts/en-US/maidairy_emotion.prompt +++ /dev/null @@ -1,11 +0,0 @@ -你是一个情绪感知分析模块。你的任务是根据对话上下文,分析对话中用户的: -1. 当前情绪状态(如:开心、沮丧、焦虑、平静、兴奋、愤怒等) -2. 言语态度(如:友好、冷淡、热情、敷衍、试探、认真、调侃等) -3. 潜在的情感需求(如:需要倾听、需要鼓励、想要倾诉、只是闲聊等) - -要求: -- 只分析用户(对话中 role=user 的内容),不要分析助手自己 -- 根据用户最新发言重点分析,同时结合上下文理解变化趋势 -- 输出简洁(2-4 句话),不要太长 -- 如果信息太少无法判断,就说信息不足,给出初步印象 -- 直接输出分析结果,不要有格式标题 diff --git a/prompts/en-US/maidairy_knowledge_category.prompt b/prompts/en-US/maidairy_knowledge_category.prompt index 738b5a70..510de367 100644 --- a/prompts/en-US/maidairy_knowledge_category.prompt +++ b/prompts/en-US/maidairy_knowledge_category.prompt @@ -1,18 +1,18 @@ -你是一个用户特征分类分析专家。你的任务是分析对话内容,判断其中涉及哪些个人特征分类。 +You are an expert in analyzing user trait categories. Your task is to analyze the conversation content and determine which personal trait categories are involved. -请仔细阅读以下对话内容,判断其中涉及了哪些个人特征分类。 +Please carefully read the following conversation content and determine which personal trait categories are involved. -【个人特征分类列表】 +[Personal Trait Category List] {categories_summary} -【任务要求】 -1. 分析对话内容,判断涉及哪些个人特征分类 -2. 只输出涉及到的分类编号,用空格分隔 -3. 如果对话内容不涉及任何个人特征分类,输出"无" +[Task Requirements] +1. Analyze the conversation content and determine which personal trait categories are involved +2. Output only the category numbers involved, separated by spaces +3. If the conversation content does not involve any personal trait category, output "none" -【输出格式示例】 +[Output Format Example] 1 3 5 -或 -无 +or +none -请开始分析: +Please start analyzing: diff --git a/prompts/en-US/maidairy_knowledge_extract.prompt b/prompts/en-US/maidairy_knowledge_extract.prompt index 9a8054b5..61667a2d 100644 --- a/prompts/en-US/maidairy_knowledge_extract.prompt +++ b/prompts/en-US/maidairy_knowledge_extract.prompt @@ -1,17 +1,17 @@ -你是一个用户特征信息提取专家。你的任务是从对话内容中提取与指定分类相关的个人特征信息。 +You are an expert in extracting user trait information. Your task is to extract personal trait information related to the specified category from the conversation content. -【目标分类】 +[Target Category] {category_name} -【任务要求】 -1. 仔细阅读对话内容,找出与"{category_name}"相关的所有信息 -2. 提取的信息应该具体、准确,避免模糊的描述 -3. 如果有多条相关信息,请整合成一段简洁的描述 -4. 如果对话中没有与该分类相关的信息,输出"无" +[Task Requirements] +1. Carefully read the conversation content and find all information related to "{category_name}" +2. The extracted information should be specific and accurate, avoiding vague descriptions +3. If there are multiple relevant pieces of information, merge them into one concise description +4. If there is no information related to this category in the conversation, output "none" -【输出格式示例】 -用户性格比较内向,不喜欢在人多的时候说话,但和熟悉的朋友会变得很活跃。 -或 -无 +[Output Format Example] +The user is rather introverted and does not like speaking when many people are around, but becomes very lively with close friends. +or +none -请开始提取: +Please start extracting: diff --git a/prompts/en-US/maidairy_knowledge_retrieve.prompt b/prompts/en-US/maidairy_knowledge_retrieve.prompt index 8519b85c..9208377f 100644 --- a/prompts/en-US/maidairy_knowledge_retrieve.prompt +++ b/prompts/en-US/maidairy_knowledge_retrieve.prompt @@ -1,19 +1,19 @@ -你是一个用户特征检索专家。你的任务是根据当前对话上下文,判断需要检索哪些个人特征分类的信息。 +You are an expert in retrieving user traits. Your task is to determine which categories of personal trait information need to be retrieved based on the current conversation context. -【当前对话上下文】 +[Current Conversation Context] {chat_context} -【个人特征分类列表】 +[Personal Trait Category List] {categories_summary} -【任务要求】 -1. 分析当前对话上下文,判断需要哪些个人特征信息来帮助理解用户 -2. 只输出需要的分类编号,用空格分隔 -3. 如果当前对话不需要任何个人特征信息,输出"无" +[Task Requirements] +1. Analyze the current conversation context and determine which personal trait information is needed to help understand the user +2. Output only the needed category numbers, separated by spaces +3. If the current conversation does not need any personal trait information, output "none" -【输出格式示例】 +[Output Format Example] 2 5 8 -或 -无 +or +none -请开始分析: +Please start analyzing: diff --git a/prompts/en-US/maisaka_chat.prompt b/prompts/en-US/maisaka_chat.prompt index b8678058..7db19cc0 100644 --- a/prompts/en-US/maisaka_chat.prompt +++ b/prompts/en-US/maisaka_chat.prompt @@ -1,36 +1,38 @@ Your task is to analyze the conversation and the interactions happening in the chat. -You need to focus on the dialogue between {bot_name} (AI) and different users in order to choose the correct actions and behaviors, and to suggest what information should be gathered. +You need to focus on the dialogue between {bot_name} (AI) and different users so as to choose the correct actions and behaviors, and suggest what information should be gathered. [Reference Information] {identity} [End of Reference Information] You need to analyze based on the provided reference information, the current scenario, and the output rules. -In the current scenario, the user is chatting and interacting with the AI MaiMai. Your task is not to generate a user-visible reply directly, but to analyze the situation and guide the AI's response. -Your "analysis" should reflect your judgment of the current situation, your suggestions, your next-step plan, and why you think that way. -You should first gather information that can help {bot_name} reply, and then provide reply guidance. +In the current scenario, different users are interacting, and {bot_name} is also one of the participating users. Users may also be chatting with each other. Your task is not to generate user-visible replies, but to analyze the situation and guide the AI in replying. +"Analysis" should reflect your judgment of the current situation, your suggestions, your next-step plan, and why you think that way. +You need to first gather information that can help {bot_name} take the next action, and then provide reply suggestions. You can use these tools: -- wait(seconds) - Temporarily pause the conversation, wait for `seconds`, hand the turn back to the user, and wait for the other party's new message. -- stop() - When you judge that {bot_name} should not speak right now, end the conversation loop and do not reply until the other party sends a new message. -- reply() - Call this when you judge that {bot_name} should now send a formal visible reply to the user. After calling it, the system will generate the actual user-visible reply based on your current round of thinking. -- query_jargon() - Use this when the meaning of certain words is unclear, or when the user asks about the meaning of some terms and a lookup is needed. -- Other defined tools may also be used when appropriate. +- wait(seconds) - Temporarily pause the conversation, wait for `seconds`, hand the speaking turn to the user, and wait for the other party's new message. +- no_reply() - When you judge that {bot_name} should not speak right now, end the conversation and do not reply in any way until the other party sends a new message. +- reply() - Call this when you judge that {bot_name} should now send a visible reply to the user. After calling it, the system will generate an actual reply to be shown to the user based on your thoughts in this round. +- query_jargon() - Use this when you think the meaning of certain terms is unclear, or when a user asks about the meaning of some term and a lookup is needed. +- Other defined tools may also be used as appropriate. Tool usage rules: -1. If {bot_name} has already replied, the user has not sent anything new for now, and no new information needs to be collected, use `wait` or `stop`. -2. If the user has sent a new message, but you think they may still have follow-up messages that have not been sent yet, you may wait appropriately for them to finish. -3. In certain cases, consecutive replies are also allowed. For example, if you want to ask a follow-up question or add to your previous message, you do not have to use `stop` or `wait`. -4. You need to control how often you speak. In a one-on-one chat, you may reply at a relatively even frequency. If there are many users, do not reply to every single message. Control the reply frequency. When you decide not to speak for the moment, you may use `wait` to pause for a period of time or `stop` to wait for new messages. -5. Do not reply to every message. Do not directly reply to sticker-only messages sent by other users. Control the reply frequency. -6. If users have questions, or if there is uncertainty about certain concepts, you may use tools to gather information or look up meanings, and you may use multiple tools. +1. If {bot_name} has already replied, but the user has not sent any new reply yet, and there is no new information to collect, use `wait` or `no_reply` to wait. +2. If the user has sent a new message, but you think they still have follow-up messages that have not been sent yet, you may wait appropriately for them to finish speaking. +3. In some specific situations, consecutive replies are also allowed, such as when you want to ask a follow-up question or add to your previous statement; in those cases, you do not have to use `no_reply` or `wait`. +4. You need to control your speaking frequency. If it is a one-on-one chat, you may speak at a relatively even frequency. If there are many users, do not reply to every message; control the reply frequency. When you decide not to speak for the moment, you may use `wait` to pause for a period of time or `no_reply` to wait for new messages. +5. Do not reply to every message. Do not directly reply to sticker-only messages sent by other users. Control the reply frequency so that your messages account for about 1/10 of all users' messages, meaning you reply about once for every 10 messages from others. +6. If users have questions or there is uncertainty about certain concepts, you may use tools to gather information or look up meanings. You may use multiple tools. Your analysis rules: -1. By default, directly output your latest current analysis instead of repeating previous analysis. -2. The latest analysis should be as specific as possible and closely grounded in the context, rather than vague repetition. -3. If you have just used a tool, in the next round you should continue with new analysis based on the tool result. -4. You need to assess which messages are directed at {bot_name}, and which are exchanges between users or self-talk, so that you do not frequently insert unrelated replies. +1. By default, directly output your latest analysis. Do not repeat previous analysis content. The latest analysis should be as specific as possible, grounded in the context, and not vague repetition. +2. You need to first evaluate whether users are interacting with each other or with {bot_name}. Do not jump in blindly and reply to the wrong target. +3. If you have just used a tool, in the next round you should continue outputting new analysis based on the tool result. +4. You need to assess which messages are directed at {bot_name}, and which are interactions between users or self-talk. Do not frequently insert yourself into unrelated topics. 5. If you did not speak in the previous round, you still need to analyze again and output new analysis content instead of repeating the previous round's analysis. +{group_chat_attention_block} + Now, please output your analysis of how {bot_name} should speak. You must first output the textual analysis, and only then make tool calls: diff --git a/prompts/en-US/maisaka_replyer.prompt b/prompts/en-US/maisaka_replyer.prompt index 17715661..76e62df7 100644 --- a/prompts/en-US/maisaka_replyer.prompt +++ b/prompts/en-US/maisaka_replyer.prompt @@ -1,12 +1,11 @@ -You are chatting in a QQ group. Below is the ongoing group conversation, including chat history and images shared in the chat. +You are chatting in a QQ group. Below is the content currently being discussed in the group, including chat records and images in the chat. Messages marked with {bot_name} (you) are your own messages, so please distinguish them carefully: {time_block} {identity} -You are chatting in a group now. Please read the previous chat history, understand the current topic, and then give a natural, colloquial reply. -Keep it as short as possible. -It is best to reply to only one topic at a time, so the response does not become long-winded or messy. Please stay aligned with the chat content. +You are chatting in the group now. Please read the previous chat records, grasp the current topic, and then give a natural, colloquial reply. +Try to keep it short. It is best to reply to only one topic at a time, so the reply does not become verbose or messy. Please pay attention to the chat content. {reply_style} -You may refer to the information in [Reply Reference], but use it only when appropriate and do not follow it rigidly. -Do not output any extra content, including unnecessary prefixes or suffixes, colons, parentheses, stickers, or `at` / `@` mentions. Output only the message content itself. +You may refer to the information in [Reply Reference], but depending on the situation, you do not have to follow it completely. +Please do not output any extra content (including unnecessary prefixes or suffixes, colons, brackets, stickers, at, or @). Only output the message content itself. diff --git a/prompts/en-US/memory_retrieval_react_final.prompt b/prompts/en-US/memory_retrieval_react_final.prompt deleted file mode 100644 index f37620d3..00000000 --- a/prompts/en-US/memory_retrieval_react_final.prompt +++ /dev/null @@ -1,19 +0,0 @@ -你的名字是{bot_name}。现在是{time_now}。 -你正在参与聊天,你需要根据搜集到的信息总结信息。 -如果搜集到的信息对于参与聊天,回答问题有帮助,请加入总结,如果无关,请不要加入到总结。 - -当前聊天记录: -{chat_history} - -已收集的信息: -{collected_info} - - -分析: -- 基于已收集的信息,总结出对当前聊天有帮助的相关信息 -- **如果收集的信息对当前聊天有帮助**,在思考中直接给出总结信息,格式为:return_information(information="你的总结信息") -- **如果信息无关或没有帮助**,在思考中给出:return_information(information="") - -**重要规则:** -- 必须严格使用检索到的信息回答问题,不要编造信息 -- 答案必须精简,不要过多解释 \ No newline at end of file diff --git a/prompts/en-US/memory_retrieval_react_prompt_head_lpmm.prompt b/prompts/en-US/memory_retrieval_react_prompt_head_lpmm.prompt deleted file mode 100644 index ce174308..00000000 --- a/prompts/en-US/memory_retrieval_react_prompt_head_lpmm.prompt +++ /dev/null @@ -1,17 +0,0 @@ -你的名字是{bot_name}。现在是{time_now}。 -你正在参与聊天,你需要搜集信息来帮助你进行回复。 -重要,这是当前聊天记录: -{chat_history} -聊天记录结束 - -已收集的信息: -{collected_info} - -- 你可以对查询思路给出简短的思考:思考要简短,直接切入要点 -- 思考完毕后,使用工具 - -**工具说明:** -- 如果涉及过往事件,或者查询某个过去可能提到过的概念,或者某段时间发生的事件。可以使用lpmm知识库查询 -- 如果遇到不熟悉的词语、缩写、黑话或网络用语,可以使用query_words工具查询其含义 -- 你必须使用tool,如果需要查询你必须给出使用什么工具进行查询 -- 当你决定结束查询时,必须调用return_information工具返回总结信息并结束查询 \ No newline at end of file diff --git a/prompts/en-US/planner.prompt b/prompts/en-US/planner.prompt index d6bf0de7..bcd563d7 100644 --- a/prompts/en-US/planner.prompt +++ b/prompts/en-US/planner.prompt @@ -1,44 +1,44 @@ {time_block} {name_block} -{chat_context_description},以下是具体的聊天内容 -**聊天内容** +{chat_context_description}, the specific chat content is shown below +**Chat content** {chat_content_block} -**可选的action** +**Available actions** reply -动作描述: -1.你可以选择呼叫了你的名字,但是你没有做出回应的消息进行回复 -2.你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题 -3.最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 -4.不要选择回复你自己发送的消息 -5.不要单独对表情包进行回复 -6.将上下文中所有含义不明的,疑似黑话的,缩写词均写入unknown_words中 +Action description: +1. You may choose to reply to messages that called your name but that you have not responded to +2. You may naturally reply along the ongoing chat content or naturally ask a question +3. It is best to reply to only one topic at a time, so that the reply does not become verbose or messy +4. Do not choose to reply to messages sent by yourself +5. Do not reply to stickers alone +6. Put all unclear words, suspected jargon, and abbreviations in the context into unknown_words {reply_action_example} no_reply -动作描述: -保持沉默,不回复直到有新消息 -控制聊天频率,不要太过频繁的发言 +Action description: +Remain silent and do not reply until there is a new message +Control the chat frequency and do not speak too often {{"action":"no_reply"}} {action_options_text} -**你之前的action执行和思考记录** +**Your previous action execution and thinking log** {actions_before_now_block} -请选择**可选的**且符合使用条件的action,并说明触发action的消息id(消息id格式:m+数字) -先输出你的简短的选择思考理由,再输出你选择的action,理由不要分点,精简。 -**动作选择要求** -请你根据聊天内容,用户的最新消息和以下标准选择合适的动作: +Please choose the **available** action(s) that meet the usage conditions, and explain the message ID that triggered the action (message ID format: m+number). +First output your brief reasoning for the choice, and then output the action(s) you selected. The reason should not be in bullet points and should be concise. +**Action selection requirements** +Please choose appropriate actions according to the chat content, the user's latest message, and the following standards: {plan_style} {moderation_prompt} -target_message_id为必填,表示触发消息的id -请选择所有符合使用要求的action,每个动作最多选择一次,但是可以选择多个动作; -动作用json格式输出,用```json包裹,如果输出多个json,每个json都要单独一行放在同一个```json代码块内: -**示例** -// 理由文本(简短) +target_message_id is required and indicates the ID of the triggering message. +Please select all actions that meet the usage requirements. Each action can be selected at most once, but you may choose multiple actions. +Output the actions in JSON format, wrapped in ```json. If you output multiple JSON objects, each JSON object must appear on a separate line inside the same ```json code block: +**Example** +// Reason text (brief) ```json -{{"action":"动作名", "target_message_id":"m123", .....}} -{{"action":"动作名", "target_message_id":"m456", .....}} -``` \ No newline at end of file +{{"action":"action name", "target_message_id":"m123", .....}} +{{"action":"action name", "target_message_id":"m456", .....}} +``` diff --git a/prompts/en-US/private_replyer.prompt b/prompts/en-US/private_replyer.prompt deleted file mode 100644 index ff0cc5a9..00000000 --- a/prompts/en-US/private_replyer.prompt +++ /dev/null @@ -1,15 +0,0 @@ -{knowledge_prompt}{tool_info_block}{extra_info_block} -{expression_habits_block}{memory_retrieval}{jargon_explanation} - -你正在和{sender_name}聊天,这是你们之前聊的内容: -{time_block} -{dialogue_prompt} - -{reply_target_block}。 -{planner_reasoning} -{identity} -{chat_prompt}你正在和{sender_name}聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些, -尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。 -{reply_style} -请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 -{moderation_prompt}不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 \ No newline at end of file diff --git a/prompts/en-US/private_replyer_self.prompt b/prompts/en-US/private_replyer_self.prompt deleted file mode 100644 index f58136ef..00000000 --- a/prompts/en-US/private_replyer_self.prompt +++ /dev/null @@ -1,14 +0,0 @@ -{knowledge_prompt}{tool_info_block}{extra_info_block} -{expression_habits_block}{memory_retrieval}{jargon_explanation} - -你正在和{sender_name}聊天,这是你们之前聊的内容: -{time_block} -{dialogue_prompt} - -你现在想补充说明你刚刚自己的发言内容:{target},原因是{reason} -请你根据聊天内容,组织一条新回复。注意,{target} 是刚刚你自己的发言,你要在这基础上进一步发言,请按照你自己的角度来继续进行回复。注意保持上下文的连贯性。 -{identity} -{chat_prompt}尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。 -{reply_style} -请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 -{moderation_prompt}不要输出多余内容(包括冒号和引号,括号,表情包,at或 @等 )。 \ No newline at end of file diff --git a/prompts/en-US/replyer.prompt b/prompts/en-US/replyer.prompt deleted file mode 100644 index 4da5c062..00000000 --- a/prompts/en-US/replyer.prompt +++ /dev/null @@ -1,18 +0,0 @@ -{knowledge_prompt}{tool_info_block}{extra_info_block} -{expression_habits_block}{memory_retrieval}{jargon_explanation} - -你正在qq群里聊天,下面是群里正在聊的内容,其中包含聊天记录和聊天中的图片 -其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分: -{time_block} -{dialogue_prompt} - -{reply_target_block}。 -{planner_reasoning} -{identity} -{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,把握当前的话题,然后给出日常且简短的回复。 -最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 -{keywords_reaction_prompt} -请注意把握聊天内容。 -{reply_style} -请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,at或 @等 ),只输出发言内容就好。 -现在,你说: \ No newline at end of file diff --git a/prompts/en-US/replyer_light.prompt b/prompts/en-US/replyer_light.prompt deleted file mode 100644 index 8e3a425a..00000000 --- a/prompts/en-US/replyer_light.prompt +++ /dev/null @@ -1,18 +0,0 @@ -{knowledge_prompt}{tool_info_block}{extra_info_block} -{expression_habits_block}{memory_retrieval}{jargon_explanation} - -你正在qq群里聊天,下面是群里正在聊的内容,其中包含聊天记录和聊天中的图片 -其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分: -{time_block} -{dialogue_prompt} - -{reply_target_block}。 -{planner_reasoning} -{identity} -{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复, -尽量简短一些。{keywords_reaction_prompt} -请注意把握聊天内容,不要回复的太有条理。 -{reply_style} -请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,表情包,at或 @等 ),只输出发言内容就好。 -最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 -现在,你说: \ No newline at end of file diff --git a/prompts/en-US/tool_executor.prompt b/prompts/en-US/tool_executor.prompt deleted file mode 100644 index 23f2b043..00000000 --- a/prompts/en-US/tool_executor.prompt +++ /dev/null @@ -1,11 +0,0 @@ -你是一个专门执行工具的助手。你的名字是{bot_name}。现在是{time_now}。 -群里正在进行的聊天内容: -{chat_history} - -现在,{sender}发送了内容:{target_message},你想要回复ta。 -请仔细分析聊天内容,考虑以下几点: -1. 内容中是否包含需要查询信息的问题 -2. 是否有明确的工具使用指令 -你可以选择多个动作 - -If you need to use tools, please directly call the corresponding tool function. If you do not need to use any tool, simply output "No tool needed". \ No newline at end of file diff --git a/prompts/ja-JP/action.prompt b/prompts/ja-JP/action.prompt deleted file mode 100644 index 91831b2a..00000000 --- a/prompts/ja-JP/action.prompt +++ /dev/null @@ -1,5 +0,0 @@ -{action_name} -动作描述:{action_description} -使用条件{parallel_text}: -{action_require} -{{"action":"{action_name}",{action_parameters}, "target_message_id":"消息id(m+数字)"}} \ No newline at end of file diff --git a/prompts/ja-JP/brain_action.prompt b/prompts/ja-JP/brain_action.prompt index 8de841c7..69610b33 100644 --- a/prompts/ja-JP/brain_action.prompt +++ b/prompts/ja-JP/brain_action.prompt @@ -1,9 +1,9 @@ {action_name} -动作描述:{action_description} +アクションの説明:{action_description} 使用条件: {action_require} {{ "action": "{action_name}",{action_parameters}, - "target_message_id":"触发action的消息id", - "reason":"触发action的原因" -}} \ No newline at end of file + "target_message_id":"アクションを発動したメッセージID", + "reason":"アクションを発動した理由" +}} diff --git a/prompts/ja-JP/brain_planner.prompt b/prompts/ja-JP/brain_planner.prompt index a3bfd10c..ed37816f 100644 --- a/prompts/ja-JP/brain_planner.prompt +++ b/prompts/ja-JP/brain_planner.prompt @@ -1,77 +1,37 @@ -{time_block} -{name_block} -{chat_context_description},以下是具体的聊天内容 +あなたのタスクは、会話とチャット内で起きているやり取りを分析することです。 +{bot_name}(AI)と複数のユーザーの対話に注目し、適切な行動や振る舞いを選び、収集すべき情報を提案してください。 -**聊天内容** -{chat_content_block} +【参考情報】 +{bot_name} の人格: {identity} +【参考情報ここまで】 -**动作记录** -{actions_before_now_block} +提供された参考情報、現在の状況、そして出力ルールに基づいて分析してください。 +現在の状況では、複数のユーザーがやり取りしており、{bot_name} もその参加者の一人です。ユーザー同士で会話している場合もあります。あなたの役割は、ユーザーに見える発言を生成することではなく、状況を分析して AI の返信を導くことです。 +「分析」には、現在の状況判断、提案、次に取るべき行動計画、そしてその理由を含めてください。 +まず {bot_name} が次の行動を取るのに役立つ情報を集め、そのうえで返信方針を示してください。 -**可用的action** -reply -动作描述: -进行回复,你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题 -{{ - "action": "reply", - "target_message_id":"想要回复的消息id", - "reason":"回复的原因" -}} +使用できるツール: +- wait(seconds) - 会話を一時停止し、`seconds` 秒待って発話権をユーザーに渡し、相手の新しい発言を待ちます。 +- no_reply() - {bot_name} が今は発言すべきでないと判断した場合、会話を終了し、相手に新しいメッセージが来るまで一切返信しません。 +- reply() - {bot_name} が今ユーザーに対して可視の返信を送るべきだと判断したときに呼び出します。呼び出し後、システムはこのターンの思考に基づいて、実際にユーザーへ表示される返信を生成します。特定のユーザーに返信しても、全員に向けて返信しても構いません。 +- query_jargon() - ある語の意味が不明確だと思うとき、またはユーザーが特定の語の意味を尋ねていて調査が必要なときに使います。 +- その他定義済みのツールも、状況に応じて使用できます。 -wait -动作描述: -暂时不再发言,等待指定时间。适用于以下情况: -- 你已经表达清楚一轮,想给对方留出空间 -- 你感觉对方的话还没说完,或者自己刚刚发了好几条连续消息 -- 你想要等待一定时间来让对方把话说完,或者等待对方反应 -- 你想保持安静,专注"听"而不是马上回复 -请你根据上下文来判断要等待多久,请你灵活判断: -- 如果你们交流间隔时间很短,聊的很频繁,不宜等待太久 -- 如果你们交流间隔时间很长,聊的很少,可以等待较长时间 -{{ - "action": "wait", - "target_message_id":"想要作为这次等待依据的消息id(通常是对方的最新消息)", - "wait_seconds": 等待的秒数(必填,例如:5 表示等待5秒), - "reason":"选择等待的原因" -}} +ツール使用ルール: +1. {bot_name} がすでに返信済みで、ユーザーからまだ新しい返信がなく、新たに集めるべき情報もない場合は `wait` または `no_reply` を使って待ってください。 +2. ユーザーに新しい発言があっても、まだ続きの発言が送られていないだけだと判断するなら、適切に待って話し終えるのを待っても構いません。 +3. 特定の状況では連続返信も可能です。たとえば追問したいときや、自分の直前の発言を補足したいときは、`no_reply` や `wait` を使わなくても構いません。 +4. 発言頻度は制御してください。一対一の会話なら比較的均等な頻度で発言して構いませんが、ユーザーが多い場合はすべての発言に反応しないでください。しばらく発言しないと決めた場合は、`wait` で一定時間待つか、`no_reply` で新着メッセージを待ってください。 +5. すべてのメッセージに返信しないでください。他ユーザーが送ったスタンプだけのメッセージには直接返信しないでください。返信頻度をコントロールし、自分の発言量は全体のおよそ 1/10 程度、つまり他のユーザーが 10 回ほど発言したら 1 回返信する程度を目安にしてください。 +6. ユーザーの疑問や、ある概念への不確かさがある場合は、ツールを使って情報収集や意味調査をして構いません。複数ツールを使ってもよいです。 -complete_talk -动作描述: -当前聊天暂时结束了,对方离开,没有更多话题了 -你可以使用该动作来暂时休息,等待对方有新发言再继续: -- 多次wait之后,对方迟迟不回复消息才用 -- 如果对方只是短暂不回复,应该使用wait而不是complete_talk -- 聊天内容显示当前聊天已经结束或者没有新内容时候,选择complete_talk -选择此动作后,将不再继续循环思考,直到收到对方的新消息 -{{ - "action": "complete_talk", - "target_message_id":"触发完成对话的消息id(通常是对方的最新消息)", - "reason":"选择完成对话的原因" -}} +分析ルール: +1. 基本的には、以前の分析を繰り返さず、現在の最新の分析をそのまま出力してください。最新の分析は、できるだけ具体的で文脈に密着しており、曖昧な繰り返しにならないようにしてください。 +2. まず、ユーザー同士のやり取りなのか、{bot_name} に向けたやり取りなのかを判断してください。やみくもに割り込んで、相手を取り違えて返信してはいけません。 +3. 直前にツールを使った場合は、次のラウンドでその結果を踏まえた新しい分析を続けてください。 +4. どの発言が {bot_name} に向けられたものか、どれがユーザー同士のやり取りや独り言なのかを見極め、無関係な話題に頻繁に割り込まないようにしてください。 +5. 前のターンで発言しなかった場合でも、改めて分析し、新しい分析内容を出力してください。前のターンの分析を繰り返してはいけません。 -{action_options_text} +{group_chat_attention_block} -请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字 -先输出你的选择思考理由,再输出你选择的action,理由是一段平文本,不要分点,精简。 -**动作选择要求** -请你根据聊天内容,用户的最新消息和以下标准选择合适的动作: -{plan_style} -{moderation_prompt} - -请选择所有符合使用要求的action,动作用json格式输出,如果输出多个json,每个json都要单独用```json包裹,你可以重复使用同一个动作或不同动作: -**示例** -// 理由文本 -```json -{{ - "action":"动作名", - "target_message_id":"触发动作的消息id", - //对应参数 -}} -``` -```json -{{ - "action":"动作名", - "target_message_id":"触发动作的消息id", - //对应参数 -}} -``` \ No newline at end of file +それでは、{bot_name} がどう発言すべきかについての分析を出力してください。必ず先にテキストで分析を出力し、そのあとでツール呼び出しを行ってください。 diff --git a/prompts/ja-JP/chat_target_group1.prompt b/prompts/ja-JP/chat_target_group1.prompt deleted file mode 100644 index 77e89bcc..00000000 --- a/prompts/ja-JP/chat_target_group1.prompt +++ /dev/null @@ -1 +0,0 @@ -你正在qq群里聊天,下面是群里正在聊的内容: \ No newline at end of file diff --git a/prompts/ja-JP/chat_target_group2.prompt b/prompts/ja-JP/chat_target_group2.prompt deleted file mode 100644 index 5b71bace..00000000 --- a/prompts/ja-JP/chat_target_group2.prompt +++ /dev/null @@ -1 +0,0 @@ -正在群里聊天 \ No newline at end of file diff --git a/prompts/ja-JP/chat_target_private1.prompt b/prompts/ja-JP/chat_target_private1.prompt deleted file mode 100644 index 3e86c71f..00000000 --- a/prompts/ja-JP/chat_target_private1.prompt +++ /dev/null @@ -1 +0,0 @@ -你正在和{sender_name}聊天,这是你们之前聊的内容: \ No newline at end of file diff --git a/prompts/ja-JP/chat_target_private2.prompt b/prompts/ja-JP/chat_target_private2.prompt deleted file mode 100644 index 9225ec82..00000000 --- a/prompts/ja-JP/chat_target_private2.prompt +++ /dev/null @@ -1 +0,0 @@ -和{sender_name}聊天 \ No newline at end of file diff --git a/prompts/ja-JP/default_expressor.prompt b/prompts/ja-JP/default_expressor.prompt index 4d05bc60..9956bbfc 100644 --- a/prompts/ja-JP/default_expressor.prompt +++ b/prompts/ja-JP/default_expressor.prompt @@ -3,14 +3,14 @@ {chat_info} {identity} -你正在{chat_target_2},{reply_target_block} -现在请你对这句内容进行改写,请你参考上述内容进行改写,原句是:{raw_reply}: -原因是:{reason} -现在请你将这条具体内容改写成一条适合在群聊中发送的回复消息。 -你需要使用合适的语法和句法,参考聊天内容,组织一条日常且口语化的回复。请你修改你想表达的原句,符合你的表达风格和语言习惯 +あなたは今 {chat_target_2} にいます。{reply_target_block} +それでは、この内容を言い換えてください。上記の内容を参考にしながら書き換えてください。元の文は:{raw_reply}: +理由は:{reason} +この具体的な内容を、グループチャットに送るのに適した返信メッセージへ書き換えてください。 +適切な文法と構文を使い、チャット内容を参考にしながら、日常的で口語的な返信を組み立ててください。あなたが伝えたい元の文を、あなたの表現スタイルや言語習慣に合うように修正してください。 {reply_style} -你可以完全重组回复,保留最基本的表达含义就好,但重组后保持语意通顺。 +意味の核だけ残っていれば、返信は完全に組み替えてもかまいません。ただし、組み替えたあとも意味が自然に通るようにしてください。 {keywords_reaction_prompt} {moderation_prompt} -不要输出多余内容(包括冒号和引号,表情包,emoji,at或 @等 ),只输出一条回复就好。不要思考的太长。 -改写后的回复: \ No newline at end of file +余計な内容(コロンや引用符、スタンプ、emoji、at や @ など)を出力しないでください。返信は 1 件だけ出力してください。考えすぎないでください。 +書き換え後の返信: diff --git a/prompts/ja-JP/emoji_content_analysis.prompt b/prompts/ja-JP/emoji_content_analysis.prompt index c3834ce3..06f5677f 100644 --- a/prompts/ja-JP/emoji_content_analysis.prompt +++ b/prompts/ja-JP/emoji_content_analysis.prompt @@ -1,5 +1,5 @@ -这是一个聊天场景中的表情包描述:"{description}" +これはチャットシーンにおけるスタンプの説明です: "{description}" -请你识别这个表情包的含义和适用场景,给我简短的描述,每个描述不要超过15个字 -你可以关注其幽默和讽刺意味,动用贴吧,微博,小红书的知识,必须从互联网梗、meme的角度去分析 -请直接输出描述,不要出现任何其他内容,如果有多个描述,可以用逗号分隔 \ No newline at end of file +このスタンプの意味と適した使用場面を見極め、短い説明をください。各説明は15文字以内にしてください。 +ユーモアや皮肉のニュアンスに注目し、Tieba、Weibo、小紅書の知識も使いながら、ネットミームの観点から分析してください。 +説明だけを直接出力し、ほかの内容は出さないでください。複数ある場合は読点で区切ってください。 diff --git a/prompts/ja-JP/emoji_content_filtration.prompt b/prompts/ja-JP/emoji_content_filtration.prompt index 6bb73a53..b3d957aa 100644 --- a/prompts/ja-JP/emoji_content_filtration.prompt +++ b/prompts/ja-JP/emoji_content_filtration.prompt @@ -1,6 +1,6 @@ -这是一个表情包,请对这个表情包进行审核,标准如下: -1. 必须符合"{demand}"的要求 -2. 不能是色情、暴力、等违法违规内容,必须符合公序良俗 -3. 不能是任何形式的截图,聊天记录或视频截图 -4. 不要出现5个以上文字 -请回答这个表情包是否满足上述要求,是则回答是,否则回答否,不要出现任何其他内容 \ No newline at end of file +これはスタンプです。次の基準に従って審査してください: +1. "{demand}" の要求を満たしていること +2. 色情、暴力などの違法・不適切な内容ではなく、公序良俗に反しないこと +3. いかなる形式のスクリーンショット、チャット履歴、動画のスクリーンショットでもないこと +4. 5文字を超える文字が含まれないこと +このスタンプが上記の要件を満たすかどうか答えてください。満たすなら「はい」、満たさないなら「いいえ」とだけ答え、ほかの内容は出さないでください。 diff --git a/prompts/ja-JP/emoji_replace.prompt b/prompts/ja-JP/emoji_replace.prompt index 69093fda..06e7d708 100644 --- a/prompts/ja-JP/emoji_replace.prompt +++ b/prompts/ja-JP/emoji_replace.prompt @@ -1,12 +1,12 @@ -{nickname}的表情包存储已满({emoji_num}/{emoji_num_max}),需要决定是否删除一个旧表情包来为新表情包腾出空间。 +{nickname} のスタンプ保存枠が上限です ({emoji_num}/{emoji_num_max})。新しいスタンプのために古いスタンプを削除するか決める必要があります。 -新表情包信息: -描述: {description} +新しいスタンプ情報: +説明: {description} -现有表情包列表: +既存スタンプ一覧: {emoji_list} -请决定: -1. 是否要删除某个现有表情包来为新表情包腾出空间? -2. 如果要删除,应该删除哪一个(给出编号)? -请只回答:'不删除'或'删除编号X'(X为表情包编号)。 \ No newline at end of file +決めてください: +1. 新しいスタンプのために既存スタンプを削除するか +2. 削除する場合、どれを削除するか(番号を答える) +回答は「削除しない」または「番号Xを削除」のみで答えてください。 diff --git a/prompts/ja-JP/expression_evaluation.prompt b/prompts/ja-JP/expression_evaluation.prompt index abb5b5aa..48c25ef7 100644 --- a/prompts/ja-JP/expression_evaluation.prompt +++ b/prompts/ja-JP/expression_evaluation.prompt @@ -1,15 +1,15 @@ -请评估以下表达方式或语言风格以及使用条件或使用情景是否合适: -使用条件或使用情景:{situation} -表达方式或言语风格:{style} +次の表現方法または言語スタイルと、その使用条件または使用場面が適切かどうか評価してください: +使用条件または使用場面:{situation} +表現方法または言語スタイル:{style} -请从以下方面进行评估: +次の観点から評価してください: {criteria_list} -请以JSON格式输出评估结果: +評価結果を JSON 形式で出力してください: {{ "suitable": true/false, - "reason": "评估理由(如果不合适,请说明原因)" + "reason": "評価理由(不適切な場合は理由を説明)" }} -如果合适,suitable设为true;如果不合适,suitable设为false,并在reason中说明原因。 -请严格按照JSON格式输出,不要包含其他内容。 \ No newline at end of file +適切なら suitable を true、不適切なら suitable を false にし、reason に理由を書いてください。 +JSON 形式のみを厳守し、ほかの内容は含めないでください。 diff --git a/prompts/ja-JP/expression_select.prompt b/prompts/ja-JP/expression_select.prompt index 69fa1dc5..5b0f050e 100644 --- a/prompts/ja-JP/expression_select.prompt +++ b/prompts/ja-JP/expression_select.prompt @@ -1,22 +1,22 @@ {chat_observe_info} -你的名字是{bot_name}{target_message} +あなたの名前は{bot_name}{target_message} {reply_reason_block} -以下是可选的表达情境: +以下は選択可能な表現シチュエーションです: {all_situations} -请你分析聊天内容的语境、情绪、话题类型,从上述情境中选择最适合当前聊天情境的,最多{max_num}个情境。 -考虑因素包括: -1.聊天的情绪氛围(轻松、严肃、幽默等) -2.话题类型(日常、技术、游戏、情感等) -3.情境与当前语境的匹配度 +チャット内容の文脈、感情、話題タイプを分析し、上記の中から現在のチャット状況に最も適したシチュエーションを最大 {max_num} 個選んでください。 +考慮要素: +1. チャットの感情的な雰囲気(気楽、真面目、ユーモラスなど) +2. 話題タイプ(日常、技術、ゲーム、感情など) +3. シチュエーションと現在の文脈の一致度 {target_message_extra_block} -请以JSON格式输出,只需要输出选中的情境编号: -例如: +JSON 形式で、選んだシチュエーション番号だけを出力してください: +例: {{ "selected_situations": [2, 3, 5, 7, 19] }} -请严格按照JSON格式输出,不要包含其他内容: \ No newline at end of file +JSON 形式のみを厳守し、ほかの内容は含めないでください: diff --git a/prompts/ja-JP/hippo_topic_analysis.prompt b/prompts/ja-JP/hippo_topic_analysis.prompt index 14f3eee1..425776b1 100644 --- a/prompts/ja-JP/hippo_topic_analysis.prompt +++ b/prompts/ja-JP/hippo_topic_analysis.prompt @@ -1,27 +1,27 @@ -【历史话题标题列表】(仅标题,不含具体内容): +【過去の話題タイトル一覧】(タイトルのみ、具体的な内容は含まない): {history_topics_block} -【历史话题标题列表结束】 +【過去の話題タイトル一覧ここまで】 -【本次聊天记录】(每条消息前有编号,用于后续引用): +【今回のチャット記録】(各メッセージの前に番号があり、後で参照するために使う): {messages_block} -【本次聊天记录结束】 +【今回のチャット記録ここまで】 -请完成以下任务: -**识别话题** -1. 识别【本次聊天记录】中正在进行的一个或多个话题; -2. 【本次聊天记录】的中的消息可能与历史话题有关,也可能毫无关联。 -2. 判断【历史话题标题列表】中的话题是否在【本次聊天记录】中出现,如果出现,则直接使用该历史话题标题字符串; +以下のタスクを完了してください: +**話題の識別** +1. 【今回のチャット記録】に含まれる進行中の話題を 1 つ以上識別する; +2. 【今回のチャット記録】中のメッセージは、過去の話題に関係している場合もあれば、まったく無関係な場合もある; +3. 【過去の話題タイトル一覧】の話題が【今回のチャット記録】に現れているか判断し、現れている場合はその過去の話題タイトル文字列をそのまま使う; -**选取消息** -1. 对于每个话题(新话题或历史话题),从上述带编号的消息中选出与该话题强相关的消息编号列表; -2. 每个话题用一句话清晰地描述正在发生的事件,必须包含时间(大致即可)、人物、主要事件和主题,保证精准且有区分度; +**メッセージの選択** +1. 各話題(新規話題または過去話題)について、上記の番号付きメッセージからその話題と強く関係するメッセージ番号一覧を選ぶ; +2. 各話題について、何が起きているのかを 1 文で明確に説明すること。時間(おおまかで可)、人物、主な出来事、テーマを必ず含め、正確で区別しやすい内容にする; -请先输出一段简短思考,说明有什么话题,哪些是不包含在历史话题中的,哪些是包含在历史话题中的,并说明为什么; -然后严格以 JSON 格式输出【本次聊天记录】中涉及的话题,格式如下: +まず短い思考を出力し、どんな話題があるか、どれが過去話題に含まれず、どれが過去話題に含まれているか、そしてその理由を説明してください; +その後、【今回のチャット記録】に含まれる話題を次の JSON 形式で厳密に出力してください: [ {{ - "topic": "话题", + "topic": "話題", "message_indices": [1, 2, 5] }}, ... -] \ No newline at end of file +] diff --git a/prompts/ja-JP/hippo_topic_summary.prompt b/prompts/ja-JP/hippo_topic_summary.prompt index efd3e142..2fa85135 100644 --- a/prompts/ja-JP/hippo_topic_summary.prompt +++ b/prompts/ja-JP/hippo_topic_summary.prompt @@ -1,22 +1,22 @@ -请基于以下话题,对聊天记录片段进行概括,提取以下信息: +以下の話題に基づいて、チャット記録の一部を要約し、次の情報を抽出してください: -**话题**:{topic} +**話題**:{topic} -**要求**: -1. 关键词:提取与话题相关的关键词,用列表形式返回(3-10个关键词) -2. 概括:对这段话的平文本概括(50-200字),要求: - - 仔细地转述发生的事件和聊天内容; - - 重点突出事件的发展过程和结果; - - 围绕话题这个中心进行概括。 - - 提取话题中的关键信息点,关键信息点应该简洁明了。 +**要件**: +1. キーワード:話題に関連するキーワードを抽出し、リスト形式で返す(3〜10 個) +2. 要約:この会話部分の平文要約を行う(50〜200 文字)。要件: + - 起こった出来事とチャット内容を丁寧に言い換える; + - 出来事の展開過程と結果を重点的に示す; + - この話題という中心を軸に要約する; + - 話題内の重要情報を抽出し、簡潔で明確にする。 -请以JSON格式返回,格式如下: +JSON 形式で次のように返してください: {{ - "keywords": ["关键词1", "关键词2", ...], - "summary": "概括内容" + "keywords": ["キーワード1", "キーワード2", ...], + "summary": "要約内容" }} -聊天记录: +チャット記録: {original_text} -请直接返回JSON,不要包含其他内容。 \ No newline at end of file +JSON のみを直接返し、ほかの内容は含めないでください。 diff --git a/prompts/ja-JP/jargon_compare_inference.prompt b/prompts/ja-JP/jargon_compare_inference.prompt index eca8fcee..738a879e 100644 --- a/prompts/ja-JP/jargon_compare_inference.prompt +++ b/prompts/ja-JP/jargon_compare_inference.prompt @@ -1,15 +1,15 @@ -**推断结果1(基于上下文)** +**推論結果1(文脈ベース)** {inference1} -**推断结果2(仅基于词条)** +**推論結果2(語句単体ベース)** {inference2} -请比较这两个推断结果,判断它们是否相同或类似。 -- 如果两个推断结果的"含义"相同或类似,说明这个词条不是黑话(含义明确) -- 如果两个推断结果有差异,说明这个词条可能是黑话(需要上下文才能理解) +この 2 つの推論結果を比較し、同じまたは近いかどうか判断してください。 +- 2 つの推論結果の「意味」が同じまたは近いなら、この語句は黒話ではない(意味が明確) +- 2 つの推論結果に差があるなら、この語句は黒話かもしれない(文脈がないと理解できない) -以 JSON 格式输出: +JSON 形式で出力してください: {{ "is_similar": true/false, "reason": "判断理由" -}} \ No newline at end of file +}} diff --git a/prompts/ja-JP/jargon_explainer_summarize.prompt b/prompts/ja-JP/jargon_explainer_summarize.prompt index 427d9f05..301898f6 100644 --- a/prompts/ja-JP/jargon_explainer_summarize.prompt +++ b/prompts/ja-JP/jargon_explainer_summarize.prompt @@ -1,11 +1,11 @@ -上下文聊天内容: +文脈のチャット内容: {chat_context} -在上下文中提取到的黑话及其含义: +文脈から抽出された黒話とその意味: {jargon_explanations} -请根据上述信息,对黑话解释进行概括和整理。 -- 如果上下文中有黑话出现,请简要说明这些黑话在上下文中的使用情况 -- 将所有黑话解释整理成简洁、易读的一段话 -- 输出格式要自然,适合作为回复参考信息 -请输出概括后的黑话解释(直接输出一段平文本,不要标题,无特殊格式或markdown格式,不要使用JSON格式): \ No newline at end of file +上記の情報に基づき、黒話の説明を要約して整理してください。 +- 文脈内に黒話が出ている場合は、その黒話が文脈でどのように使われているかを簡潔に説明する +- すべての黒話の説明を、簡潔で読みやすい一段落にまとめる +- 出力形式は自然で、返信参考情報として使いやすいものにする +要約後の黒話説明を出力してください(タイトルなし、特殊な書式や markdown なし、JSON ではなく平文 1 段落を直接出力): diff --git a/prompts/ja-JP/jargon_inference_content_only.prompt b/prompts/ja-JP/jargon_inference_content_only.prompt index f6258e91..a06491ca 100644 --- a/prompts/ja-JP/jargon_inference_content_only.prompt +++ b/prompts/ja-JP/jargon_inference_content_only.prompt @@ -1,11 +1,11 @@ -**词条内容** +**語句内容** {content} -请仅根据这个词条本身,推断其含义。 -- 如果这是一个黑话、俚语或网络用语,请推断其含义 -- 如果含义明确(常规词汇),也请说明 +この語句そのものだけを基に、意味を推測してください。 +- これが黒話・スラング・ネット用語なら、その意味を推測してください +- 意味が明確(一般語彙)な場合も、その意味を説明してください -以 JSON 格式输出: +JSON 形式で出力してください: {{ - "meaning": "详细含义说明(包含使用场景、来源、具体解释等)" -}} \ No newline at end of file + "meaning": "詳細な意味説明(使用場面、由来、具体的な解釈などを含む)" +}} diff --git a/prompts/ja-JP/jargon_inference_with_context.prompt b/prompts/ja-JP/jargon_inference_with_context.prompt index 295896e3..49246780 100644 --- a/prompts/ja-JP/jargon_inference_with_context.prompt +++ b/prompts/ja-JP/jargon_inference_with_context.prompt @@ -1,19 +1,19 @@ -**词条内容** +**語句内容** {content} -**词条出现的上下文。其中的{bot_name}的发言内容是你自己的发言** +**この語句が現れた文脈。{bot_name} の発言はあなた自身の発言** {raw_content_list} {previous_meaning_section} -请根据上下文,推断"{content}"这个词条的含义。 -- 如果这是一个黑话、俚语或网络用语,请推断其含义 -- 如果含义明确(常规词汇),也请说明 -- {bot_name} 的发言内容可能包含错误,请不要参考其发言内容 -- 如果上下文信息不足,无法推断含义,请设置 no_info 为 true +文脈に基づいて、「{content}」という語句の意味を推測してください。 +- これが黒話・スラング・ネット用語なら、その意味を推測してください +- 意味が明確(一般語彙)な場合も、その意味を説明してください +- {bot_name} の発言内容には誤りが含まれる可能性があるため、参考にしないでください +- 文脈情報が不足していて意味を推測できない場合は no_info を true にしてください {previous_meaning_instruction} -以 JSON 格式输出: +JSON 形式で出力してください: {{ - "meaning": "详细含义说明(包含使用场景、来源、具体解释等)", + "meaning": "詳細な意味説明(使用場面、由来、具体的な解釈などを含む)", "no_info": false }} -注意:如果信息不足无法推断,请设置 "no_info": true,此时 meaning 可以为空字符串 \ No newline at end of file +注意:情報が不足して推測できない場合は "no_info": true にしてください。その場合、meaning は空文字でもかまいません diff --git a/prompts/ja-JP/learn_style.prompt b/prompts/ja-JP/learn_style.prompt index 9dd2e591..a5bbe6b6 100644 --- a/prompts/ja-JP/learn_style.prompt +++ b/prompts/ja-JP/learn_style.prompt @@ -1,49 +1,49 @@ {chat_str} -你的名字是{bot_name},现在请你完成两个提取任务 -任务1:请从上面这段群聊中用户的语言风格和说话方式 -1. 只考虑文字,不要考虑表情包和图片 -2. 不要总结SELF的发言,因为这是你自己的发言,不要重复学习你自己的发言 -3. 不要涉及具体的人名,也不要涉及具体名词 -4. 思考有没有特殊的梗,一并总结成语言风格 -5. 例子仅供参考,请严格根据群聊内容总结!!! -注意:总结成如下格式的规律,总结的内容要详细,但具有概括性: -例如:当"AAAAA"时,可以"BBBBB", AAAAA代表某个场景,不超过20个字。BBBBB代表对应的语言风格,特定句式或表达方式,不超过20个字。 -表达方式在3-5个左右,不要超过10个 +あなたの名前は{bot_name}です。これから 2 つの抽出タスクを完了してください。 +タスク1:上のグループチャットから、ユーザーの言語スタイルや話し方を抽出してください。 +1. 文字だけを考慮し、スタンプや画像は考慮しないこと +2. SELF の発言はあなた自身の発言なので要約しないこと。自分の発言を繰り返し学習しないこと +3. 具体的な人名や具体的な名詞を含めないこと +4. 特殊なミームやネタがあるかも考え、それも言語スタイルとしてまとめること +5. 例は参考用のみです。必ずグループチャットの内容に厳密に基づいて要約してください!!! +注意:次の形式のルールにまとめてください。要約内容は詳しくしつつ、概括性も持たせてください: +例:「AAAAA」のとき、「BBBBB」と言える。AAAAA はある場面を表し、20 字以内。BBBBB は対応する言語スタイル、特定の文型、または表現方法で、20 字以内。 +表現方式は 3〜5 個程度、最大 10 個までにしてください。 -任务2:请从上面这段聊天内容中提取"可能是黑话"的候选项(黑话/俚语/网络缩写/口头禅)。 -- 必须为对话中真实出现过的短词或短语 -- 必须是你无法理解含义的词语,没有明确含义的词语,请不要选择有明确含义,或者含义清晰的词语 -- 排除:人名、@、表情包/图片中的内容、纯标点、常规功能词(如的、了、呢、啊等) -- 每个词条长度建议 2-8 个字符(不强制),尽量短小 -- 请你提取出可能的黑话,最多30个黑话,请尽量提取所有 +タスク2:上のチャット内容から、「黒話かもしれない」候補(黒話/スラング/ネット略語/口癖)を抽出してください。 +- 必ず対話中に実際に出現した短い語句であること +- 意味を理解できない語句であること。意味が明確なものは選ばないこと +- 除外:人名、@、スタンプ/画像内の内容、純粋な記号、一般的な機能語(例:の、ね、よ、啊 など) +- 各語句の長さは 2〜8 文字程度を推奨(必須ではない)し、できるだけ短くすること +- 可能な限りすべての黒話候補を抽出し、最大 30 個まで -黑话必须为以下几种类型: -- 由字母构成的,汉语拼音首字母的简写词,例如:nb、yyds、xswl -- 英文词语的缩写,用英文字母概括一个词汇或含义,例如:CPU、GPU、API -- 中文词语的缩写,用几个汉字概括一个词汇或含义,例如:社死、内卷 +黒話は次のいずれかのタイプでなければなりません: +- アルファベットで構成された、中国語ピンイン頭文字の略語。例:nb、yyds、xswl +- 英単語の略語で、英字で単語や意味を要約したもの。例:CPU、GPU、API +- 中国語の略語で、数文字の漢字で語句や意味を要約したもの。例:社死、内卷 -输出要求: -将表达方式,语言风格和黑话以 JSON 数组输出,每个元素为一个对象,结构如下(注意字段名): -注意请不要输出重复内容,请对表达方式和黑话进行去重。 +出力要件: +表現方法、言語スタイル、黒話を JSON 配列で出力してください。各要素は次の構造のオブジェクトです(フィールド名に注意): +重複内容は出力しないでください。表現方法と黒話はどちらも重複を除去してください。 [ {{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}}, {{"situation": "CCCC", "style": "DDDD", "source_id": "7"}} - {{"situation": "对某件事表示十分惊叹", "style": "使用 我嘞个xxxx", "source_id": "[消息编号]"}}, - {{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}}, - {{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "使用 这么强!", "source_id": "[消息编号]"}}, - {{"content": "词条", "source_id": "12"}}, - {{"content": "词条2", "source_id": "5"}} + {{"situation": "あることに非常に驚いたとき", "style": "我嘞个xxxx を使う", "source_id": "[メッセージ番号]"}}, + {{"situation": "皮肉っぽく同意し、理屈は言わない", "style": "对对对", "source_id": "[メッセージ番号]"}}, + {{"situation": "ゲーム関連の話題で、少しからかうように褒める", "style": "这么强!を使う", "source_id": "[メッセージ番号]"}}, + {{"content": "語句", "source_id": "12"}}, + {{"content": "語句2", "source_id": "5"}} ] -其中: -表达方式条目: -- situation:表示“在什么情境下”的简短概括(不超过20个字) -- style:表示对应的语言风格或常用表达(不超过20个字) -- source_id:该表达方式对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 -黑话jargon条目: -- content:表示黑话的内容 -- source_id:该黑话对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 +内訳: +表現方式の項目: +- situation: 「どんな状況で」を表す短い要約(20 字以内) +- style: 対応する言語スタイルまたはよく使う表現(20 字以内) +- source_id: その表現方式に対応する「出典行番号」。上のチャット記録の角括弧内の数字(例 [3])で、数字だけを出力し角括弧は含めない +黒話 jargon 項目: +- content: 黒話の内容 +- source_id: その黒話に対応する「出典行番号」。上のチャット記録の角括弧内の数字(例 [3])で、数字だけを出力し角括弧は含めない -现在请你输出 JSON: \ No newline at end of file +それでは JSON を出力してください: diff --git a/prompts/ja-JP/lpmm_get_knowledge.prompt b/prompts/ja-JP/lpmm_get_knowledge.prompt deleted file mode 100644 index 2ade0d0f..00000000 --- a/prompts/ja-JP/lpmm_get_knowledge.prompt +++ /dev/null @@ -1,10 +0,0 @@ -你是一个专门获取知识的助手。你的名字是{bot_name}。现在是{time_now}。 -群里正在进行的聊天内容: -{chat_history} - -现在,{sender}发送了内容:{target_message},你想要回复ta。 -请仔细分析聊天内容,考虑以下几点: -1. 内容中是否包含需要查询信息的问题 -2. 是否有明确的知识获取指令 - -If you need to use the search tool, please directly call the function "lpmm_search_knowledge". If you do not need to use any tool, simply output "No tool needed". \ No newline at end of file diff --git a/prompts/ja-JP/maidairy_cognition.prompt b/prompts/ja-JP/maidairy_cognition.prompt deleted file mode 100644 index 7c5c814a..00000000 --- a/prompts/ja-JP/maidairy_cognition.prompt +++ /dev/null @@ -1,11 +0,0 @@ -你是一个认知感知分析模块。你的任务是根据对话上下文,分析对话中用户的: -1. 核心意图(如:寻求帮助、纯粹聊天、请求任务、发泄情绪、获取信息、表达观点等) -2. 认知状态(如:明确具体、模糊试探、犹豫不决、困惑迷茫、思路清晰、逻辑混乱等) -3. 隐含目的(如:解决问题、获得安慰、打发时间、寻求认同、交换想法、表达自我等) - -要求: -- 只分析用户(对话中 role=user 的内容),不要分析助手自己 -- 根据用户最新发言重点分析,同时结合上下文理解深层动机 -- 输出简洁(2-4 句话),不要太长 -- 如果信息太少无法判断,就说信息不足,给出初步印象 -- 直接输出分析结果,不要有格式标题 diff --git a/prompts/ja-JP/maidairy_emotion.prompt b/prompts/ja-JP/maidairy_emotion.prompt deleted file mode 100644 index b8440527..00000000 --- a/prompts/ja-JP/maidairy_emotion.prompt +++ /dev/null @@ -1,11 +0,0 @@ -你是一个情绪感知分析模块。你的任务是根据对话上下文,分析对话中用户的: -1. 当前情绪状态(如:开心、沮丧、焦虑、平静、兴奋、愤怒等) -2. 言语态度(如:友好、冷淡、热情、敷衍、试探、认真、调侃等) -3. 潜在的情感需求(如:需要倾听、需要鼓励、想要倾诉、只是闲聊等) - -要求: -- 只分析用户(对话中 role=user 的内容),不要分析助手自己 -- 根据用户最新发言重点分析,同时结合上下文理解变化趋势 -- 输出简洁(2-4 句话),不要太长 -- 如果信息太少无法判断,就说信息不足,给出初步印象 -- 直接输出分析结果,不要有格式标题 diff --git a/prompts/ja-JP/maidairy_knowledge_category.prompt b/prompts/ja-JP/maidairy_knowledge_category.prompt index 738b5a70..7b98eb59 100644 --- a/prompts/ja-JP/maidairy_knowledge_category.prompt +++ b/prompts/ja-JP/maidairy_knowledge_category.prompt @@ -1,18 +1,18 @@ -你是一个用户特征分类分析专家。你的任务是分析对话内容,判断其中涉及哪些个人特征分类。 +あなたはユーザー特性カテゴリ分析の専門家です。あなたのタスクは、会話内容を分析し、どの個人特性カテゴリが関係しているかを判断することです。 -请仔细阅读以下对话内容,判断其中涉及了哪些个人特征分类。 +以下の会話内容を注意深く読み、どの個人特性カテゴリが関係しているかを判断してください。 -【个人特征分类列表】 +【個人特性カテゴリ一覧】 {categories_summary} -【任务要求】 -1. 分析对话内容,判断涉及哪些个人特征分类 -2. 只输出涉及到的分类编号,用空格分隔 -3. 如果对话内容不涉及任何个人特征分类,输出"无" +【タスク要件】 +1. 会話内容を分析し、どの個人特性カテゴリが関係しているかを判断する +2. 関係しているカテゴリ番号だけを、スペース区切りで出力する +3. 会話内容がどの個人特性カテゴリにも関係しない場合は「無」と出力する -【输出格式示例】 +【出力形式の例】 1 3 5 -或 -无 +または +無 -请开始分析: +分析を開始してください: diff --git a/prompts/ja-JP/maidairy_knowledge_extract.prompt b/prompts/ja-JP/maidairy_knowledge_extract.prompt index 9a8054b5..5f99f57d 100644 --- a/prompts/ja-JP/maidairy_knowledge_extract.prompt +++ b/prompts/ja-JP/maidairy_knowledge_extract.prompt @@ -1,17 +1,17 @@ -你是一个用户特征信息提取专家。你的任务是从对话内容中提取与指定分类相关的个人特征信息。 +あなたはユーザー特性情報抽出の専門家です。あなたのタスクは、会話内容から指定カテゴリに関係する個人特性情報を抽出することです。 -【目标分类】 +【対象カテゴリ】 {category_name} -【任务要求】 -1. 仔细阅读对话内容,找出与"{category_name}"相关的所有信息 -2. 提取的信息应该具体、准确,避免模糊的描述 -3. 如果有多条相关信息,请整合成一段简洁的描述 -4. 如果对话中没有与该分类相关的信息,输出"无" +【タスク要件】 +1. 会話内容を注意深く読み、「{category_name}」に関係するすべての情報を見つける +2. 抽出する情報は具体的かつ正確で、曖昧な表現を避ける +3. 関連情報が複数ある場合は、簡潔な 1 段落にまとめる +4. 会話内にこのカテゴリに関係する情報がない場合は「無」と出力する -【输出格式示例】 -用户性格比较内向,不喜欢在人多的时候说话,但和熟悉的朋友会变得很活跃。 -或 -无 +【出力形式の例】 +ユーザーはやや内向的で、人が多い場面では話すのが苦手だが、親しい友人とはとても活発になる。 +または +無 -请开始提取: +抽出を開始してください: diff --git a/prompts/ja-JP/maidairy_knowledge_retrieve.prompt b/prompts/ja-JP/maidairy_knowledge_retrieve.prompt index 8519b85c..b337054b 100644 --- a/prompts/ja-JP/maidairy_knowledge_retrieve.prompt +++ b/prompts/ja-JP/maidairy_knowledge_retrieve.prompt @@ -1,19 +1,19 @@ -你是一个用户特征检索专家。你的任务是根据当前对话上下文,判断需要检索哪些个人特征分类的信息。 +あなたはユーザー特性検索の専門家です。あなたのタスクは、現在の会話文脈に基づいて、どの個人特性カテゴリ情報を検索する必要があるかを判断することです。 -【当前对话上下文】 +【現在の会話文脈】 {chat_context} -【个人特征分类列表】 +【個人特性カテゴリ一覧】 {categories_summary} -【任务要求】 -1. 分析当前对话上下文,判断需要哪些个人特征信息来帮助理解用户 -2. 只输出需要的分类编号,用空格分隔 -3. 如果当前对话不需要任何个人特征信息,输出"无" +【タスク要件】 +1. 現在の会話文脈を分析し、ユーザー理解の助けになる個人特性情報が何かを判断する +2. 必要なカテゴリ番号だけを、スペース区切りで出力する +3. 現在の会話で個人特性情報がまったく不要な場合は「無」と出力する -【输出格式示例】 +【出力形式の例】 2 5 8 -或 -无 +または +無 -请开始分析: +分析を開始してください: diff --git a/prompts/ja-JP/maisaka_chat.prompt b/prompts/ja-JP/maisaka_chat.prompt index 243a6f61..eab1d3ef 100644 --- a/prompts/ja-JP/maisaka_chat.prompt +++ b/prompts/ja-JP/maisaka_chat.prompt @@ -1,36 +1,37 @@ -あなたの任務は、会話と会話中のやり取りの状況を分析することです。 -{bot_name}(AI)と複数ユーザーの対話に注目し、適切な行動や振る舞いの選択、および収集すべき情報の提案を行ってください。 +あなたのタスクは、会話とチャット内で起きているやり取りを分析することです。 +{bot_name}(AI)と複数のユーザーの対話に注目し、適切な行動や振る舞いを選び、収集すべき情報を提案してください。 【参考情報】 {identity} 【参考情報ここまで】 -提供された参考情報、現在の場面、そして出力ルールに基づいて分析してください。 -現在の場面では、ユーザーは AI の MaiMai と会話・やり取りをしています。あなたの役割は、ユーザーに見える発言を直接生成することではなく、状況を分析して AI の返答を導くことです。 +提供された参考情報、現在の状況、そして出力ルールに基づいて分析してください。 +現在の状況では、複数のユーザーがやり取りしており、{bot_name} もその参加者の一人です。ユーザー同士で会話している場合もあります。あなたの役割は、ユーザーに見える発言を生成することではなく、状況を分析して AI の返信を導くことです。 「分析」には、現在の状況判断、提案、次に取るべき行動計画、そしてその理由を含めてください。 -まず {bot_name} の返答に役立つ情報を集め、そのあとで返答方針を示してください。 - +まず {bot_name} が次の行動を取るのに役立つ情報を集め、そのうえで返信方針を示してください。 使用できるツール: -- wait(seconds) - 会話を一時停止し、`seconds` 秒待って発話権をユーザーに戻し、相手の新しい発言を待ちます。 -- stop() - {bot_name} が今は発言すべきではないと判断した場合、会話ループを終了し、相手に新しいメッセージが来るまで返信しません。 -- reply() - {bot_name} が今ユーザーに対して正式な可視返信を送るべきだと判断したときに呼び出します。呼び出し後、システムはこのラウンドの考えに基づいて、実際にユーザーへ表示される返信を生成します。 -- query_jargon() - ある語の意味が不明確なとき、またはユーザーが特定の用語の意味を尋ねており、調査が必要なときに使います。 -- そのほか定義済みのツールも、状況に応じて使用できます。 +- wait(seconds) - 会話を一時停止し、`seconds` 秒待って発話権をユーザーに渡し、相手の新しい発言を待ちます。 +- no_reply() - {bot_name} が今は発言すべきでないと判断した場合、会話を終了し、相手に新しいメッセージが来るまで一切返信しません。 +- reply() - {bot_name} が今ユーザーに対して可視の返信を送るべきだと判断したときに呼び出します。呼び出し後、システムはこのターンの思考に基づいて、実際にユーザーへ表示される返信を生成します。 +- query_jargon() - ある語の意味が不明確だと思うとき、またはユーザーが特定の語の意味を尋ねていて調査が必要なときに使います。 +- その他定義済みのツールも、状況に応じて使用できます。 ツール使用ルール: -1. {bot_name} がすでに返信済みで、ユーザーからまだ新しい返信がなく、新たに集めるべき情報もない場合は `wait` または `stop` を使ってください。 -2. ユーザーに新しい発言があっても、まだ続きの発言が来そうだと判断するなら、適切に待って話し終えるのを待って構いません。 -3. 特定の状況では連続返信も可能です。たとえば追問したいときや、自分の直前の発言を補足したいときは、`stop` や `wait` を使わなくても構いません。 -4. 発言頻度は制御してください。一対一の会話なら比較的均等な頻度で発言して構いませんが、ユーザーが多い場合はすべての発言に反応しないでください。しばらく発言しないと決めた場合は、`wait` で一定時間待つか、`stop` で新着メッセージを待ってください。 -5. すべてのメッセージに返信しないでください。他ユーザーが送ったスタンプだけのメッセージには直接返信しないでください。返信頻度をコントロールしてください。 -6. ユーザーの疑問や、ある概念への不確実さがある場合は、ツールを使って情報収集や意味調査をして構いません。複数ツールを使ってもよいです。 +1. {bot_name} がすでに返信済みで、ユーザーからまだ新しい返信がなく、新たに集めるべき情報もない場合は `wait` または `no_reply` を使って待ってください。 +2. ユーザーに新しい発言があっても、まだ続きの発言が送られていないだけだと判断するなら、適切に待って話し終えるのを待っても構いません。 +3. 特定の状況では連続返信も可能です。たとえば追問したいときや、自分の直前の発言を補足したいときは、`no_reply` や `wait` を使わなくても構いません。 +4. 発言頻度は制御してください。一対一の会話なら比較的均等な頻度で発言して構いませんが、ユーザーが多い場合はすべての発言に反応しないでください。しばらく発言しないと決めた場合は、`wait` で一定時間待つか、`no_reply` で新着メッセージを待ってください。 +5. すべてのメッセージに返信しないでください。他ユーザーが送ったスタンプだけのメッセージには直接返信しないでください。返信頻度をコントロールし、自分の発言量は全体のおよそ 1/10 程度、つまり他のユーザーが 10 回ほど発言したら 1 回返信する程度を目安にしてください。 +6. ユーザーの疑問や、ある概念への不確かさがある場合は、ツールを使って情報収集や意味調査をして構いません。複数ツールを使ってもよいです。 分析ルール: -1. 基本的には、以前の分析を繰り返さず、現在の最新の分析をそのまま出力してください。 -2. 最新の分析は、できるだけ具体的で文脈に密着しており、抽象的な繰り返しにならないようにしてください。 +1. 基本的には、以前の分析を繰り返さず、現在の最新の分析をそのまま出力してください。最新の分析は、できるだけ具体的で文脈に密着しており、曖昧な繰り返しにならないようにしてください。 +2. まず、ユーザー同士のやり取りなのか、{bot_name} に向けたやり取りなのかを判断してください。やみくもに割り込んで、相手を取り違えて返信してはいけません。 3. 直前にツールを使った場合は、次のラウンドでその結果を踏まえた新しい分析を続けてください。 -4. どの発言が {bot_name} に向けられたものか、どれがユーザー同士のやり取りや独り言なのかを評価し、無関係な話題に頻繁に割り込まないようにしてください。 -5. 前のラウンドで発言しなかった場合でも、改めて分析し、新しい分析内容を出力してください。前ラウンドの分析を繰り返してはいけません。 +4. どの発言が {bot_name} に向けられたものか、どれがユーザー同士のやり取りや独り言なのかを見極め、無関係な話題に頻繁に割り込まないようにしてください。 +5. 前のターンで発言しなかった場合でも、改めて分析し、新しい分析内容を出力してください。前のターンの分析を繰り返してはいけません。 + +{group_chat_attention_block} それでは、{bot_name} がどう発言すべきかについての分析を出力してください。必ず先にテキストで分析を出力し、そのあとでツール呼び出しを行ってください。 diff --git a/prompts/ja-JP/maisaka_replyer.prompt b/prompts/ja-JP/maisaka_replyer.prompt index 8eae44d1..e6de81a3 100644 --- a/prompts/ja-JP/maisaka_replyer.prompt +++ b/prompts/ja-JP/maisaka_replyer.prompt @@ -1,12 +1,11 @@ -あなたは QQ グループで会話しています。以下はグループ内で進行中の会話内容で、チャット履歴と会話中に共有された画像が含まれています。 -そのうち、{bot_name}(あなた)と示された発言はあなた自身の発言なので、区別に注意してください。 +あなたは QQ グループで会話しています。以下はグループ内で現在話されている内容で、チャット記録とチャット中の画像が含まれます。 +{bot_name}(あなた) と記された発言はあなた自身の発言です。区別に注意してください: {time_block} {identity} -あなたは今グループ内で会話しています。これまでのチャット履歴を読んで現在の話題を把握し、日常的で口語的な返答をしてください。 -できるだけ短めにしてください。 -一度に一つの話題にだけ返すのが望ましいです。そうすることで、くどくなったり内容が散らかったりするのを防げます。会話の流れにしっかり沿ってください。 +今あなたはグループ内で会話しています。これまでのチャット記録を読み、現在の話題を把握したうえで、日常的で口語的な返信をしてください。 +できるだけ短くしてください。話題は一度に一つだけに返信したほうが、冗長になったり内容が散らかったりしません。チャット内容をしっかり踏まえてください。 {reply_style} -[返信情報参考] の内容は必要に応じて参考にして構いませんが、必ずしも完全に従う必要はありません。 -不要な内容は出力しないでください。たとえば不要な前置きや後置き、コロン、かっこ、スタンプ、`at` や `@` などは含めず、発言内容だけを出力してください。 +【返信情報参考】の情報は参考にしてかまいませんが、状況に応じて完全に従う必要はありません。 +余計な内容(不要な前置きや後置き、コロン、括弧、スタンプ、at や @ など)は出力せず、発言内容だけを出力してください。 diff --git a/prompts/ja-JP/memory_retrieval_react_final.prompt b/prompts/ja-JP/memory_retrieval_react_final.prompt deleted file mode 100644 index f37620d3..00000000 --- a/prompts/ja-JP/memory_retrieval_react_final.prompt +++ /dev/null @@ -1,19 +0,0 @@ -你的名字是{bot_name}。现在是{time_now}。 -你正在参与聊天,你需要根据搜集到的信息总结信息。 -如果搜集到的信息对于参与聊天,回答问题有帮助,请加入总结,如果无关,请不要加入到总结。 - -当前聊天记录: -{chat_history} - -已收集的信息: -{collected_info} - - -分析: -- 基于已收集的信息,总结出对当前聊天有帮助的相关信息 -- **如果收集的信息对当前聊天有帮助**,在思考中直接给出总结信息,格式为:return_information(information="你的总结信息") -- **如果信息无关或没有帮助**,在思考中给出:return_information(information="") - -**重要规则:** -- 必须严格使用检索到的信息回答问题,不要编造信息 -- 答案必须精简,不要过多解释 \ No newline at end of file diff --git a/prompts/ja-JP/memory_retrieval_react_prompt_head_lpmm.prompt b/prompts/ja-JP/memory_retrieval_react_prompt_head_lpmm.prompt deleted file mode 100644 index ce174308..00000000 --- a/prompts/ja-JP/memory_retrieval_react_prompt_head_lpmm.prompt +++ /dev/null @@ -1,17 +0,0 @@ -你的名字是{bot_name}。现在是{time_now}。 -你正在参与聊天,你需要搜集信息来帮助你进行回复。 -重要,这是当前聊天记录: -{chat_history} -聊天记录结束 - -已收集的信息: -{collected_info} - -- 你可以对查询思路给出简短的思考:思考要简短,直接切入要点 -- 思考完毕后,使用工具 - -**工具说明:** -- 如果涉及过往事件,或者查询某个过去可能提到过的概念,或者某段时间发生的事件。可以使用lpmm知识库查询 -- 如果遇到不熟悉的词语、缩写、黑话或网络用语,可以使用query_words工具查询其含义 -- 你必须使用tool,如果需要查询你必须给出使用什么工具进行查询 -- 当你决定结束查询时,必须调用return_information工具返回总结信息并结束查询 \ No newline at end of file diff --git a/prompts/ja-JP/planner.prompt b/prompts/ja-JP/planner.prompt index d6bf0de7..6112b721 100644 --- a/prompts/ja-JP/planner.prompt +++ b/prompts/ja-JP/planner.prompt @@ -1,44 +1,44 @@ {time_block} {name_block} -{chat_context_description},以下是具体的聊天内容 -**聊天内容** +{chat_context_description}、以下が具体的なチャット内容です +**チャット内容** {chat_content_block} -**可选的action** +**選択可能な action** reply -动作描述: -1.你可以选择呼叫了你的名字,但是你没有做出回应的消息进行回复 -2.你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题 -3.最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 -4.不要选择回复你自己发送的消息 -5.不要单独对表情包进行回复 -6.将上下文中所有含义不明的,疑似黑话的,缩写词均写入unknown_words中 +アクションの説明: +1. あなたの名前が呼ばれているのに、まだ反応していないメッセージに返信してよいです +2. 進行中の会話内容に自然に沿って返信したり、自然に質問を返したりしてよいです +3. 冗長になったり内容が散らかったりしないよう、できれば一度に一つの話題だけに返信してください +4. 自分が送信したメッセージに返信しないでください +5. スタンプだけに単独で返信しないでください +6. 文脈内で意味が不明なもの、黒話と思われるもの、略語はすべて unknown_words に書き入れてください {reply_action_example} no_reply -动作描述: -保持沉默,不回复直到有新消息 -控制聊天频率,不要太过频繁的发言 +アクションの説明: +黙ったままにし、新しいメッセージが来るまで返信しない +会話頻度を制御し、発言しすぎない {{"action":"no_reply"}} {action_options_text} -**你之前的action执行和思考记录** +**これまでの action 実行と考えの記録** {actions_before_now_block} -请选择**可选的**且符合使用条件的action,并说明触发action的消息id(消息id格式:m+数字) -先输出你的简短的选择思考理由,再输出你选择的action,理由不要分点,精简。 -**动作选择要求** -请你根据聊天内容,用户的最新消息和以下标准选择合适的动作: +使用条件を満たす **選択可能な** action を選び、その action を発動したメッセージ ID を示してください(メッセージ ID の形式: m+数字)。 +まず簡潔な選択理由を出力し、そのあとに選んだ action を出力してください。理由は箇条書きにせず、短くまとめてください。 +**アクション選択の要件** +チャット内容、ユーザーの最新メッセージ、そして次の基準に基づいて適切な action を選んでください: {plan_style} {moderation_prompt} -target_message_id为必填,表示触发消息的id -请选择所有符合使用要求的action,每个动作最多选择一次,但是可以选择多个动作; -动作用json格式输出,用```json包裹,如果输出多个json,每个json都要单独一行放在同一个```json代码块内: -**示例** -// 理由文本(简短) +target_message_id は必須で、トリガーとなったメッセージの ID を表します。 +使用要件を満たすすべての action を選んでください。各 action は最大 1 回までですが、複数の action を選ぶことはできます。 +action は JSON 形式で出力し、```json で囲んでください。複数の JSON を出力する場合は、それぞれを同じ ```json コードブロック内の別々の行に置いてください: +**例** +// 理由テキスト(簡潔) ```json -{{"action":"动作名", "target_message_id":"m123", .....}} -{{"action":"动作名", "target_message_id":"m456", .....}} -``` \ No newline at end of file +{{"action":"アクション名", "target_message_id":"m123", .....}} +{{"action":"アクション名", "target_message_id":"m456", .....}} +``` diff --git a/prompts/ja-JP/private_replyer.prompt b/prompts/ja-JP/private_replyer.prompt deleted file mode 100644 index ff0cc5a9..00000000 --- a/prompts/ja-JP/private_replyer.prompt +++ /dev/null @@ -1,15 +0,0 @@ -{knowledge_prompt}{tool_info_block}{extra_info_block} -{expression_habits_block}{memory_retrieval}{jargon_explanation} - -你正在和{sender_name}聊天,这是你们之前聊的内容: -{time_block} -{dialogue_prompt} - -{reply_target_block}。 -{planner_reasoning} -{identity} -{chat_prompt}你正在和{sender_name}聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些, -尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。 -{reply_style} -请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 -{moderation_prompt}不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 \ No newline at end of file diff --git a/prompts/ja-JP/private_replyer_self.prompt b/prompts/ja-JP/private_replyer_self.prompt deleted file mode 100644 index f58136ef..00000000 --- a/prompts/ja-JP/private_replyer_self.prompt +++ /dev/null @@ -1,14 +0,0 @@ -{knowledge_prompt}{tool_info_block}{extra_info_block} -{expression_habits_block}{memory_retrieval}{jargon_explanation} - -你正在和{sender_name}聊天,这是你们之前聊的内容: -{time_block} -{dialogue_prompt} - -你现在想补充说明你刚刚自己的发言内容:{target},原因是{reason} -请你根据聊天内容,组织一条新回复。注意,{target} 是刚刚你自己的发言,你要在这基础上进一步发言,请按照你自己的角度来继续进行回复。注意保持上下文的连贯性。 -{identity} -{chat_prompt}尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。 -{reply_style} -请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 -{moderation_prompt}不要输出多余内容(包括冒号和引号,括号,表情包,at或 @等 )。 \ No newline at end of file diff --git a/prompts/ja-JP/replyer.prompt b/prompts/ja-JP/replyer.prompt deleted file mode 100644 index 4da5c062..00000000 --- a/prompts/ja-JP/replyer.prompt +++ /dev/null @@ -1,18 +0,0 @@ -{knowledge_prompt}{tool_info_block}{extra_info_block} -{expression_habits_block}{memory_retrieval}{jargon_explanation} - -你正在qq群里聊天,下面是群里正在聊的内容,其中包含聊天记录和聊天中的图片 -其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分: -{time_block} -{dialogue_prompt} - -{reply_target_block}。 -{planner_reasoning} -{identity} -{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,把握当前的话题,然后给出日常且简短的回复。 -最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 -{keywords_reaction_prompt} -请注意把握聊天内容。 -{reply_style} -请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,at或 @等 ),只输出发言内容就好。 -现在,你说: \ No newline at end of file diff --git a/prompts/ja-JP/replyer_light.prompt b/prompts/ja-JP/replyer_light.prompt deleted file mode 100644 index 8e3a425a..00000000 --- a/prompts/ja-JP/replyer_light.prompt +++ /dev/null @@ -1,18 +0,0 @@ -{knowledge_prompt}{tool_info_block}{extra_info_block} -{expression_habits_block}{memory_retrieval}{jargon_explanation} - -你正在qq群里聊天,下面是群里正在聊的内容,其中包含聊天记录和聊天中的图片 -其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分: -{time_block} -{dialogue_prompt} - -{reply_target_block}。 -{planner_reasoning} -{identity} -{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复, -尽量简短一些。{keywords_reaction_prompt} -请注意把握聊天内容,不要回复的太有条理。 -{reply_style} -请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,表情包,at或 @等 ),只输出发言内容就好。 -最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 -现在,你说: \ No newline at end of file diff --git a/prompts/ja-JP/tool_executor.prompt b/prompts/ja-JP/tool_executor.prompt deleted file mode 100644 index 23f2b043..00000000 --- a/prompts/ja-JP/tool_executor.prompt +++ /dev/null @@ -1,11 +0,0 @@ -你是一个专门执行工具的助手。你的名字是{bot_name}。现在是{time_now}。 -群里正在进行的聊天内容: -{chat_history} - -现在,{sender}发送了内容:{target_message},你想要回复ta。 -请仔细分析聊天内容,考虑以下几点: -1. 内容中是否包含需要查询信息的问题 -2. 是否有明确的工具使用指令 -你可以选择多个动作 - -If you need to use tools, please directly call the corresponding tool function. If you do not need to use any tool, simply output "No tool needed". \ No newline at end of file diff --git a/prompts/zh-CN/maisaka_chat.prompt b/prompts/zh-CN/maisaka_chat.prompt index c6f9daf1..bf26eebd 100644 --- a/prompts/zh-CN/maisaka_chat.prompt +++ b/prompts/zh-CN/maisaka_chat.prompt @@ -6,9 +6,9 @@ 【参考信息结束】 你需要根据提供的参考信息,当前场景和输出规则来进行分析 -在当前场景中,不同的用户正在互动,用户也可能与AI{bot_name}进行聊天互动,你的任务不是生成对用户可见的发言,而是进行分析来指导AI进行回复。 +在当前场景中,不同的用户正在互动({bot_name}也是一位参与的用户),用户也可能与进行聊天互动,你的任务不是生成对用户可见的发言,而是进行分析来指导AI进行回复。 “分析”应该体现你对当前局面的判断、你的建议、你的下一步计划,以及你为什么这样想。 -你需要先搜集能够帮助{bot_name}回复的信息,然后再给出回复意见 +你需要先搜集能够帮助{bot_name}进行下一步行动的信息,然后再给出回复意见 你可以使用这些工具: From 7d0d429640292bf4ceb6ea75071141e67cc86396 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Thu, 2 Apr 2026 21:16:31 +0800 Subject: [PATCH 09/18] feat: Enhance plugin runtime configuration and hook management - Added `inactive_plugins` field to `RunnerReadyPayload` and `ReloadPluginResultPayload` to track plugins that are not activated due to being disabled or unmet dependencies. - Introduced `InspectPluginConfigPayload` and `InspectPluginConfigResultPayload` for inspecting plugin configuration metadata. - Implemented `PluginActivationStatus` enum to better represent plugin activation states. - Updated `_activate_plugin` method to return activation status and handle inactive plugins accordingly. - Added hooks for send service to allow modification of messages before and after sending. - Created new runtime routes for listing hook specifications in the WebUI. - Refactored plugin configuration handling to utilize runtime inspection for better accuracy and flexibility. - Enhanced error handling and logging for plugin configuration operations. --- .gitignore | 1 + mai_knowledge/knowledge.json | 887 ------------------ pytests/test_plugin_config_runtime.py | 185 +++- pytests/test_plugin_runtime.py | 228 ++++- src/chat/message_receive/bot.py | 352 ++++++- src/maisaka/chat_loop_service.py | 225 ++++- src/plugin_runtime/component_query.py | 23 + src/plugin_runtime/hook_catalog.py | 46 + src/plugin_runtime/hook_payloads.py | 178 ++++ src/plugin_runtime/hook_schema_utils.py | 31 + src/plugin_runtime/host/component_registry.py | 319 ++++++- src/plugin_runtime/host/hook_dispatcher.py | 67 +- src/plugin_runtime/host/hook_spec_registry.py | 190 ++++ src/plugin_runtime/host/supervisor.py | 79 +- src/plugin_runtime/integration.py | 210 ++++- src/plugin_runtime/protocol/envelope.py | 32 + src/plugin_runtime/runner/runner_main.py | 315 ++++++- src/services/send_service.py | 256 +++++ src/webui/routers/plugin/__init__.py | 2 + src/webui/routers/plugin/config_routes.py | 148 ++- src/webui/routers/plugin/runtime_routes.py | 28 + src/webui/routers/plugin/schemas.py | 16 + 22 files changed, 2698 insertions(+), 1120 deletions(-) delete mode 100644 mai_knowledge/knowledge.json create mode 100644 src/plugin_runtime/hook_catalog.py create mode 100644 src/plugin_runtime/hook_payloads.py create mode 100644 src/plugin_runtime/hook_schema_utils.py create mode 100644 src/plugin_runtime/host/hook_spec_registry.py create mode 100644 src/webui/routers/plugin/runtime_routes.py diff --git a/.gitignore b/.gitignore index 156a41dc..093ba248 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ data/ data1/ +mai_knowledge/knowledge.json mongodb/ NapCat.Framework.Windows.Once/ NapCat.Framework.Windows.OneKey/ diff --git a/mai_knowledge/knowledge.json b/mai_knowledge/knowledge.json deleted file mode 100644 index feae33c6..00000000 --- a/mai_knowledge/knowledge.json +++ /dev/null @@ -1,887 +0,0 @@ -{ - "1": [ - { - "id": "know_1_1774770946.623486", - "content": "备战中考", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:55:46.623486" - }, - { - "id": "know_1_1774771765.051286", - "content": "性别为女性", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:09:25.051286" - }, - { - "id": "know_1_1774771851.333504", - "content": "用户是I人(内向型人格)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:10:51.333504" - }, - { - "id": "know_1_1774771894.517183", - "content": "用户名为小千,被他人称为“宝宝”,结合语境推测为女性", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:11:34.517183" - }, - { - "id": "know_1_1774771923.859455", - "content": "小千是I人(内向型人格)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:12:03.859455" - }, - { - "id": "know_1_1774771993.479732", - "content": "小千是女性", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:13:13.479732" - }, - { - "id": "know_1_1774772079.496335", - "content": "用户名为小千,被他人称为“宝宝”,推测为女性或处于亲密社交语境中(注:性别非明确陈述,但基于昵称高频使用及语境,高置信度归纳为女性或女性化称呼偏好,若严格遵循“明确表达”则此项存疑。鉴于指令要求“高置信度可归纳”,且群内互动模式符合典型女性向昵称习惯,此处提取为倾向性事实)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:14:39.496335" - }, - { - "id": "know_1_1774773435.68612", - "content": "用户名为小千", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:37:15.686120" - }, - { - "id": "know_1_1774773676.69252", - "content": "用户自称猫娘(二次元人设)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:41:16.692520" - } - ], - "2": [ - { - "id": "know_2_1774768612.298128", - "content": "性格自信,常以“真理在我这边”自居", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:16:52.298128" - }, - { - "id": "know_2_1774768645.029561", - "content": "性格自信且带有自嘲精神,喜欢用轻松调侃的方式应对他人评价", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:17:25.029561" - }, - { - "id": "know_2_1774771068.355999", - "content": "喜欢用夸张、幽默或古风修辞表达观点", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:57:48.355999" - }, - { - "id": "know_2_1774771397.764996", - "content": "性格幽默,喜欢使用夸张比喻和古风表达", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:03:17.764996" - }, - { - "id": "know_2_1774771471.03367", - "content": "幽默风趣,喜欢使用夸张比喻和玩梗", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:04:31.033670" - }, - { - "id": "know_2_1774771765.052285", - "content": "性格不孤僻,社交圈较广", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:09:25.052285" - }, - { - "id": "know_2_1774771851.33601", - "content": "用户表现出社恐倾向,喜欢回避社交互动", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:10:51.336010" - }, - { - "id": "know_2_1774771894.520185", - "content": "性格偏向内向(I人),有社恐倾向,喜欢回避社交压力", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:11:34.520185" - }, - { - "id": "know_2_1774771958.585244", - "content": "小千是内向型人格(I人)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:12:38.585244" - }, - { - "id": "know_2_1774771993.481732", - "content": "小千性格内向(I人)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:13:13.481732" - } - ], - "3": [ - { - "id": "know_3_1774773676.695521", - "content": "喜欢冰淇淋", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:41:16.695521" - } - ], - "4": [], - "5": [], - "6": [ - { - "id": "know_6_1774768486.451792", - "content": "正在搭建 RAG 测试集", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:14:46.451792" - }, - { - "id": "know_6_1774768517.122405", - "content": "熟悉 NapCat、RAG 等技术工具及互联网梗文化", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:15:17.122405" - }, - { - "id": "know_6_1774769406.247087", - "content": "喜欢动漫风格插画", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:30:06.247087" - }, - { - "id": "know_6_1774770487.207364", - "content": "关注显卡硬件参数(如显存、型号)及深度学习/炼丹应用", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:48:07.207364" - }, - { - "id": "know_6_1774770487.209372", - "content": "对游戏光影效果感兴趣", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:48:07.209372" - }, - { - "id": "know_6_1774770603.063873", - "content": "喜欢玩《我的世界》和VRChat", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:50:03.063873" - }, - { - "id": "know_6_1774770654.654349", - "content": "关注显卡硬件参数(如4090、48G显存、5090)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:50:54.654349" - }, - { - "id": "know_6_1774770654.655356", - "content": "使用VRChat进行社交娱乐", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:50:54.655356" - }, - { - "id": "know_6_1774770734.287947", - "content": "关注显卡硬件(如4090、3050)及AI炼丹技术", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:52:14.287947" - }, - { - "id": "know_6_1774770734.289944", - "content": "玩《我的世界》并配置光影效果", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:52:14.289944" - }, - { - "id": "know_6_1774770734.291944", - "content": "计划游玩VRChat", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:52:14.291944" - }, - { - "id": "know_6_1774771033.111011", - "content": "喜欢玩VRChat", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:57:13.111011" - }, - { - "id": "know_6_1774771068.358999", - "content": "关注VRChat等虚拟现实游戏及硬件性能话题", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:57:48.358999" - }, - { - "id": "know_6_1774771233.980219", - "content": "使用VRChat(VRC)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:00:33.980219" - }, - { - "id": "know_6_1774771397.766996", - "content": "对VRChat(VRC)及虚拟形象社交感兴趣", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:03:17.766996" - }, - { - "id": "know_6_1774771471.03567", - "content": "对VRChat等虚拟社交游戏感兴趣", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:04:31.035670" - }, - { - "id": "know_6_1774771894.521183", - "content": "熟悉二次元文化、动漫角色及互联网流行梗(Meme)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:11:34.521183" - }, - { - "id": "know_6_1774771923.861534", - "content": "小千玩CS:GO游戏", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:12:03.861534" - }, - { - "id": "know_6_1774771958.587243", - "content": "回声者_Echoderd喜欢玩CS:GO游戏", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:12:38.587243" - }, - { - "id": "know_6_1774771993.483732", - "content": "小千喜欢二次元文化及动漫游戏圈梗", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:13:13.483732" - }, - { - "id": "know_6_1774772079.499335", - "content": "熟悉并喜爱二次元文化、动漫角色及互联网梗图(如阴间美学、病娇系、黑长直萌妹等风格)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:14:39.499335" - }, - { - "id": "know_6_1774772112.716455", - "content": "小千关注CS:GO游戏及中考备考话题", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:15:12.716455" - }, - { - "id": "know_6_1774772154.873237", - "content": "用户玩CS:GO游戏", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:15:54.873237" - }, - { - "id": "know_6_1774772186.438797", - "content": "玩CS:GO游戏", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:16:26.438797" - }, - { - "id": "know_6_1774772730.867535", - "content": "熟悉《我的青春恋爱物语果然有问题》及二次元表情包文化", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:25:30.867535" - }, - { - "id": "know_6_1774773338.849271", - "content": "熟悉《原神》等二次元游戏及网络梗文化", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:35:38.849271" - }, - { - "id": "know_6_1774773371.406209", - "content": "关注高分屏字体显示效果", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:36:11.406209" - }, - { - "id": "know_6_1774773401.48921", - "content": "熟悉电脑显示技术(如高分屏字体选择)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:36:41.489210" - }, - { - "id": "know_6_1774773435.688119", - "content": "关注高分屏显示效果与字体选择(无衬线/衬线体)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:37:15.688119" - }, - { - "id": "know_6_1774773608.256103", - "content": "关注屏幕字体与分辨率(无衬线/有衬线)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:40:08.256103" - }, - { - "id": "know_6_1774773645.671546", - "content": "关注屏幕分辨率与字体显示效果(高分屏/无衬线体)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:40:45.671546" - }, - { - "id": "know_6_1774773676.698035", - "content": "关注字体设计(无衬线体)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:41:16.698035" - }, - { - "id": "know_6_1774773740.83822", - "content": "喜欢二次元文化及 VTuber 风格内容", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:42:20.838220" - } - ], - "7": [ - { - "id": "know_7_1774768517.120403", - "content": "从事 RAG 测试集搭建或相关技术工作", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:15:17.120403" - }, - { - "id": "know_7_1774768573.741823", - "content": "从事 RAG(检索增强生成)测试集搭建相关工作", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:16:13.741823" - }, - { - "id": "know_7_1774770603.062873", - "content": "备战中考", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:50:03.062873" - }, - { - "id": "know_7_1774771471.036668", - "content": "正在备战中考的学生", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:04:31.036668" - }, - { - "id": "know_7_1774771923.862535", - "content": "小千正在备战中考", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:12:03.862535" - }, - { - "id": "know_7_1774771958.588749", - "content": "回声者_Echoderd正在备战中考", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:12:38.588749" - }, - { - "id": "know_7_1774772112.714455", - "content": "小千使用AI模型进行对话", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:15:12.714455" - }, - { - "id": "know_7_1774772154.870238", - "content": "用户正在备战中考", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:15:54.870238" - }, - { - "id": "know_7_1774773185.194069", - "content": "使用 NapCat 框架", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:33:05.194069" - }, - { - "id": "know_7_1774773338.851275", - "content": "使用 NapCat 框架,具备技术平台认知能力", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:35:38.851275" - }, - { - "id": "know_7_1774773371.403696", - "content": "熟悉 NapCat 框架", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:36:11.403696" - } - ], - "8": [ - { - "id": "know_8_1774770946.624486", - "content": "日常逛游戏地图", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:55:46.624486" - }, - { - "id": "know_8_1774771397.769034", - "content": "备考中考期间仍保持日常游戏娱乐习惯", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:03:17.769034" - }, - { - "id": "know_8_1774771851.338018", - "content": "用户有备考中考的学习任务", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:10:51.338018" - }, - { - "id": "know_8_1774771894.523189", - "content": "备考中(备战中考)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:11:34.523189" - }, - { - "id": "know_8_1774771993.484733", - "content": "小千有打CS:GO的游戏习惯", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:13:13.484733" - }, - { - "id": "know_8_1774772079.501334", - "content": "有在高压环境下(如中考前)进行游戏娱乐(CS:GO)的习惯,自称或认同“摆烂”的生活态度", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:14:39.501334" - }, - { - "id": "know_8_1774772154.875743", - "content": "用户在备考期间有打游戏摸鱼的习惯", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:15:54.875743" - }, - { - "id": "know_8_1774773435.690121", - "content": "习惯使用表情包表达情绪或进行网络互动", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:37:15.690121" - }, - { - "id": "know_8_1774773676.701034", - "content": "备战中考", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:41:16.701034" - } - ], - "9": [], - "10": [ - { - "id": "know_10_1774768486.452792", - "content": "沟通风格带有调侃和自信,习惯用反问句表达观点", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:14:46.452792" - }, - { - "id": "know_10_1774768517.121403", - "content": "沟通风格带有较强的好胜心和防御性,习惯用反问和调侃回应质疑", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:15:17.121403" - }, - { - "id": "know_10_1774768573.742824", - "content": "沟通风格幽默,擅长使用逻辑闭环和反问句式进行辩论或调侃", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:16:13.742824" - }, - { - "id": "know_10_1774768612.299126", - "content": "沟通风格幽默风趣,擅长使用网络梗和表情包互动", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:16:52.299126" - }, - { - "id": "know_10_1774768612.299845", - "content": "偶尔会文绉绉地表达(自称“文青病犯了”),但能迅速切换回口语化", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:16:52.299845" - }, - { - "id": "know_10_1774768645.028561", - "content": "沟通风格幽默风趣,偶尔会文青病发作使用古风表达", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:17:25.028561" - }, - { - "id": "know_10_1774769406.249584", - "content": "沟通中常使用文言文或半文言表达", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:30:06.249584" - }, - { - "id": "know_10_1774769406.251097", - "content": "习惯用反问句和夸张语气进行互动", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:30:06.251097" - }, - { - "id": "know_10_1774770487.211056", - "content": "沟通风格幽默,常使用网络梗和夸张表达", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:48:07.211056" - }, - { - "id": "know_10_1774771471.038677", - "content": "沟通风格轻松随意,善于接话和调侃", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:04:31.038677" - }, - { - "id": "know_10_1774771765.053285", - "content": "沟通风格活泼,喜欢使用语气词和表情符号撒娇", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:09:25.053285" - }, - { - "id": "know_10_1774772079.503333", - "content": "沟通风格幽默调侃,擅长用反话(如“烦到了”)和夸张修辞(如“耳朵起茧子”、“要报警了”)表达情绪", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:14:39.503333" - }, - { - "id": "know_10_1774773338.853274", - "content": "沟通风格幽默风趣,擅长玩梗与自嘲", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:35:38.853274" - }, - { - "id": "know_10_1774773371.408719", - "content": "喜欢用幽默调侃的方式回应他人", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:36:11.408719" - }, - { - "id": "know_10_1774773401.491209", - "content": "沟通风格幽默风趣,擅长玩梗和角色扮演", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:36:41.491209" - }, - { - "id": "know_10_1774773435.693121", - "content": "沟通风格幽默、喜欢玩梗和自嘲,擅长接话茬", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:37:15.693121" - }, - { - "id": "know_10_1774773532.488374", - "content": "沟通风格幽默,喜欢使用网络梗和表情包活跃气氛", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:38:52.488374" - }, - { - "id": "know_10_1774773532.490959", - "content": "在争论中倾向于据理力争,并自嘲或调侃对方阅读理解能力", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:38:52.490959" - }, - { - "id": "know_10_1774773569.709356", - "content": "喜欢用幽默、夸张和自嘲的方式活跃气氛", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:39:29.709356" - } - ], - "11": [ - { - "id": "know_11_1774771068.360999", - "content": "乐于接受并学习新的技术技巧(如加速器用法)", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:57:48.360999" - } - ], - "12": [ - { - "id": "know_12_1774770654.657355", - "content": "面对网络延迟问题倾向于寻找加速器解决方案", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T15:50:54.657355" - }, - { - "id": "know_12_1774773185.196068", - "content": "备战中考", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:33:05.196068" - }, - { - "id": "know_12_1774773740.836223", - "content": "面对压力或冲突时,倾向于通过撒娇、耍赖和寻求盟友支持来应对", - "metadata": { - "session_id": "628336b082552269377e9d0648e26c60", - "source": "maisaka_learning" - }, - "created_at": "2026-03-29T16:42:20.836223" - } - ] -} \ No newline at end of file diff --git a/pytests/test_plugin_config_runtime.py b/pytests/test_plugin_config_runtime.py index df03d343..51eb4a80 100644 --- a/pytests/test_plugin_config_runtime.py +++ b/pytests/test_plugin_config_runtime.py @@ -13,12 +13,13 @@ import pytest from src.plugin_runtime.component_query import component_query_service from src.plugin_runtime.protocol.envelope import ( Envelope, + InspectPluginConfigPayload, MessageType, RegisterPluginPayload, ValidatePluginConfigPayload, ) from src.plugin_runtime.runner.runner_main import PluginRunner -from src.webui.routers.plugin.config_routes import update_plugin_config +from src.webui.routers.plugin.config_routes import get_plugin_config, get_plugin_config_schema, update_plugin_config from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest @@ -56,6 +57,61 @@ class _DemoConfigPlugin: self.received_config = config + def get_default_config(self) -> Dict[str, Any]: + """返回测试插件的默认配置。 + + Returns: + Dict[str, Any]: 默认配置字典。 + """ + + return {"plugin": {"enabled": True, "retry_count": 3}} + + def get_webui_config_schema( + self, + *, + plugin_id: str = "", + plugin_name: str = "", + plugin_version: str = "", + plugin_description: str = "", + plugin_author: str = "", + ) -> Dict[str, Any]: + """返回测试插件的 WebUI 配置 Schema。 + + Args: + plugin_id: 插件 ID。 + plugin_name: 插件名称。 + plugin_version: 插件版本。 + plugin_description: 插件描述。 + plugin_author: 插件作者。 + + Returns: + Dict[str, Any]: 测试配置 Schema。 + """ + + del plugin_name, plugin_description, plugin_author + return { + "plugin_id": plugin_id, + "plugin_info": { + "name": "Demo", + "version": plugin_version, + "description": "", + "author": "", + }, + "sections": { + "plugin": { + "fields": { + "enabled": { + "type": "boolean", + "label": "启用", + "default": True, + "ui_type": "switch", + } + } + } + }, + "layout": {"type": "auto", "tabs": []}, + } + class _StrictConfigPlugin: """用于测试配置校验错误的伪插件。""" @@ -173,6 +229,63 @@ async def test_runner_validate_plugin_config_handler_returns_normalized_config(m assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}} +@pytest.mark.asyncio +async def test_runner_inspect_plugin_config_handler_supports_unloaded_plugin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Runner 应支持对未加载插件执行冷检查。""" + + plugin = _DemoConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace( + plugin_id="demo.plugin", + plugin_dir="/tmp/demo-plugin", + instance=plugin, + manifest=SimpleNamespace( + name="Demo", + description="", + author=SimpleNamespace(name="tester"), + ), + version="1.0.0", + ) + purged_plugins: list[tuple[str, str]] = [] + + monkeypatch.setattr( + runner, + "_resolve_plugin_meta_for_config_request", + lambda plugin_id: (meta, True, None) if plugin_id == "demo.plugin" else (None, False, "not-found"), + ) + monkeypatch.setattr( + runner._loader, + "purge_plugin_modules", + lambda plugin_id, plugin_dir: purged_plugins.append((plugin_id, plugin_dir)), + ) + + envelope = Envelope( + request_id=1, + message_type=MessageType.REQUEST, + method="plugin.inspect_config", + plugin_id="demo.plugin", + payload=InspectPluginConfigPayload( + config_data={"plugin": {"enabled": False}}, + use_provided_config=True, + ).model_dump(), + ) + + response = await runner._handle_inspect_plugin_config(envelope) + + assert response.error is None + assert response.payload["success"] is True + assert response.payload["enabled"] is False + assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}} + assert response.payload["default_config"] == {"plugin": {"enabled": True, "retry_count": 3}} + assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")] + + @pytest.mark.asyncio async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_config( monkeypatch: pytest.MonkeyPatch, @@ -251,3 +364,73 @@ async def test_update_plugin_config_prefers_runtime_validation( with config_path.open("rb") as handle: saved_config = tomllib.load(handle) assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} + + +@pytest.mark.asyncio +async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """WebUI 在插件未加载时也应从代码定义返回配置与 Schema。""" + + async def _mock_inspect_plugin_config( + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + *, + use_provided_config: bool = False, + ) -> SimpleNamespace | None: + """返回运行时冷检查结果。 + + Args: + plugin_id: 插件 ID。 + config_data: 可选配置。 + use_provided_config: 是否使用传入配置。 + + Returns: + SimpleNamespace | None: 冷检查结果。 + """ + + del config_data, use_provided_config + if plugin_id != "demo.plugin": + return None + return SimpleNamespace( + config_schema={ + "plugin_id": "demo.plugin", + "plugin_info": { + "name": "Demo", + "version": "1.0.0", + "description": "", + "author": "", + }, + "sections": {"plugin": {"fields": {}}}, + "layout": {"type": "auto", "tabs": []}, + }, + normalized_config={"plugin": {"enabled": True, "retry_count": 3}}, + enabled=True, + ) + + fake_runtime_manager = SimpleNamespace(inspect_plugin_config=_mock_inspect_plugin_config) + + monkeypatch.setattr( + "src.webui.routers.plugin.config_routes.require_plugin_token", + lambda session: session or "session-token", + ) + monkeypatch.setattr( + "src.webui.routers.plugin.config_routes.find_plugin_path_by_id", + lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None, + ) + monkeypatch.setattr( + "src.plugin_runtime.integration.get_plugin_runtime_manager", + lambda: fake_runtime_manager, + ) + + schema_response = await get_plugin_config_schema("demo.plugin", maibot_session="session-token") + config_response = await get_plugin_config("demo.plugin", maibot_session="session-token") + + assert schema_response["success"] is True + assert schema_response["schema"]["plugin_id"] == "demo.plugin" + assert config_response == { + "success": True, + "config": {"plugin": {"enabled": True, "retry_count": 3}}, + "message": "配置文件不存在,已返回默认配置", + } diff --git a/pytests/test_plugin_runtime.py b/pytests/test_plugin_runtime.py index b866d3ff..aef14b6b 100644 --- a/pytests/test_plugin_runtime.py +++ b/pytests/test_plugin_runtime.py @@ -5,7 +5,7 @@ from pathlib import Path from types import SimpleNamespace -from typing import Any, Awaitable, Callable, Dict, List, Optional +from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence import asyncio import json @@ -1405,6 +1405,57 @@ class TestComponentRegistry: assert warnings assert "plugin_a.broken" in warnings[0] + def test_register_hook_handler_rejects_unknown_hook(self): + from src.plugin_runtime.host.component_registry import ComponentRegistrationError, ComponentRegistry + from src.plugin_runtime.host.hook_spec_registry import HookSpecRegistry + + reg = ComponentRegistry(hook_spec_registry=HookSpecRegistry()) + + with pytest.raises(ComponentRegistrationError, match="未注册的 Hook"): + reg.register_component( + "broken_hook", + "hook_handler", + "plugin_a", + { + "hook": "chat.receive.unknown", + "mode": "blocking", + }, + ) + + def test_register_plugin_components_is_atomic_when_hook_invalid(self): + from src.plugin_runtime.host.component_registry import ComponentRegistrationError, ComponentRegistry + from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry + + hook_spec_registry = HookSpecRegistry() + hook_spec_registry.register_hook_spec(HookSpec(name="chat.receive.before_process")) + reg = ComponentRegistry(hook_spec_registry=hook_spec_registry) + reg.register_plugin_components( + "plugin_a", + [ + {"name": "cmd_old", "component_type": "command", "metadata": {"command_pattern": r"^/old"}}, + ], + ) + + with pytest.raises(ComponentRegistrationError, match="未注册的 Hook"): + reg.register_plugin_components( + "plugin_a", + [ + { + "name": "hook_ok", + "component_type": "hook_handler", + "metadata": {"hook": "chat.receive.before_process", "mode": "blocking"}, + }, + { + "name": "hook_bad", + "component_type": "hook_handler", + "metadata": {"hook": "chat.receive.missing", "mode": "blocking"}, + }, + ], + ) + + assert reg.get_component("plugin_a.cmd_old") is not None + assert reg.get_component("plugin_a.hook_ok") is None + def test_query_by_type(self): from src.plugin_runtime.host.component_registry import ComponentRegistry @@ -2142,6 +2193,18 @@ class TestPluginRuntimeHookEntry: assert result.kwargs["session_id"] == "s-1" assert ("b1", "builtin_guard") in call_log + def test_manager_lists_builtin_hook_specs(self, monkeypatch: pytest.MonkeyPatch) -> None: + """PluginRuntimeManager 应暴露内置 Hook 规格清单。""" + + _ComponentRegistry, PluginRuntimeManager = self._import_manager_modules(monkeypatch) + + manager = PluginRuntimeManager() + hook_names = {spec.name for spec in manager.list_hook_specs()} + + assert "chat.receive.before_process" in hook_names + assert "send_service.before_send" in hook_names + assert "maisaka.planner.after_response" in hook_names + class TestRPCServer: """RPC Server 代际保护测试""" @@ -2974,6 +3037,16 @@ class TestIntegration: self._registered_plugins = {plugin_id: object() for plugin_id in plugins} self.config_updates = [] + async def inspect_plugin_config( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + use_provided_config: bool = False, + ) -> SimpleNamespace: + """返回测试用的配置解析结果。""" + del config_data, use_provided_config + return SimpleNamespace(enabled=True, normalized_config={"enabled": True}, plugin_id=plugin_id) + async def notify_plugin_config_updated( self, plugin_id, @@ -2997,6 +3070,110 @@ class TestIntegration: assert manager._builtin_supervisor.config_updates == [("test.alpha", {"enabled": True}, "", "self")] assert manager._third_party_supervisor.config_updates == [] + @pytest.mark.asyncio + async def test_handle_plugin_config_changes_loads_unloaded_enabled_plugin(self, monkeypatch, tmp_path): + from src.plugin_runtime import integration as integration_module + from src.config.file_watcher import FileChange + import json + + thirdparty_root = tmp_path / "plugins" + alpha_dir = thirdparty_root / "alpha" + alpha_dir.mkdir(parents=True) + (alpha_dir / "config.toml").write_text("[plugin]\nenabled = true\n", encoding="utf-8") + (alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") + (alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8") + + monkeypatch.chdir(tmp_path) + + class FakeSupervisor: + def __init__(self, plugin_dirs): + self._plugin_dirs = plugin_dirs + self._registered_plugins = {} + + async def inspect_plugin_config( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + use_provided_config: bool = False, + ) -> SimpleNamespace: + """返回测试用的启用配置快照。""" + del config_data, use_provided_config + return SimpleNamespace(enabled=True, normalized_config={"plugin": {"enabled": True}}, plugin_id=plugin_id) + + manager = integration_module.PluginRuntimeManager() + manager._started = True + manager._third_party_supervisor = FakeSupervisor([thirdparty_root]) + + load_calls = [] + + async def fake_load_plugin_globally(plugin_id: str, reason: str = "manual") -> bool: + """记录自动加载调用。""" + load_calls.append((plugin_id, reason)) + return True + + monkeypatch.setattr(manager, "load_plugin_globally", fake_load_plugin_globally) + + await manager._handle_plugin_config_changes( + "test.alpha", + [FileChange(change_type=1, path=alpha_dir / "config.toml")], + ) + + assert load_calls == [("test.alpha", "config_enabled")] + + @pytest.mark.asyncio + async def test_handle_plugin_config_changes_unloads_loaded_disabled_plugin(self, monkeypatch, tmp_path): + from src.plugin_runtime import integration as integration_module + from src.config.file_watcher import FileChange + import json + + builtin_root = tmp_path / "src" / "plugins" / "built_in" + alpha_dir = builtin_root / "alpha" + alpha_dir.mkdir(parents=True) + (alpha_dir / "config.toml").write_text("[plugin]\nenabled = false\n", encoding="utf-8") + (alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") + (alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8") + + monkeypatch.chdir(tmp_path) + + class FakeSupervisor: + def __init__(self, plugin_dirs, plugins): + self._plugin_dirs = plugin_dirs + self._registered_plugins = {plugin_id: object() for plugin_id in plugins} + + async def inspect_plugin_config( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + use_provided_config: bool = False, + ) -> SimpleNamespace: + """返回测试用的禁用配置快照。""" + del config_data, use_provided_config + return SimpleNamespace( + enabled=False, + normalized_config={"plugin": {"enabled": False}}, + plugin_id=plugin_id, + ) + + manager = integration_module.PluginRuntimeManager() + manager._started = True + manager._builtin_supervisor = FakeSupervisor([builtin_root], ["test.alpha"]) + + reload_calls = [] + + async def fake_reload_plugins_globally(plugin_ids: Sequence[str], reason: str = "manual") -> bool: + """记录自动卸载调用。""" + reload_calls.append((list(plugin_ids), reason)) + return True + + monkeypatch.setattr(manager, "reload_plugins_globally", fake_reload_plugins_globally) + + await manager._handle_plugin_config_changes( + "test.alpha", + [FileChange(change_type=1, path=alpha_dir / "config.toml")], + ) + + assert reload_calls == [(["test.alpha"], "config_disabled")] + @pytest.mark.asyncio async def test_handle_main_config_reload_only_notifies_subscribers(self, monkeypatch): from src.plugin_runtime import integration as integration_module @@ -3108,6 +3285,55 @@ class TestIntegration: subscription["paths"][0] for subscription in manager._plugin_file_watcher.subscriptions } == {alpha_dir / "config.toml", beta_dir / "config.toml"} + def test_refresh_plugin_config_watch_subscriptions_includes_unloaded_plugins(self, tmp_path): + from src.plugin_runtime import integration as integration_module + import json + + thirdparty_root = tmp_path / "plugins" + alpha_dir = thirdparty_root / "alpha" + beta_dir = thirdparty_root / "beta" + alpha_dir.mkdir(parents=True) + beta_dir.mkdir(parents=True) + (alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") + (beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8") + (alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8") + (beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8") + + class FakeWatcher: + def __init__(self): + self.subscriptions = [] + + def subscribe( + self, + callback: Any, + *, + paths: Optional[Sequence[Path]] = None, + change_types: Any = None, + ) -> str: + """记录新的监听订阅。""" + del callback, change_types + subscription_id = f"sub-{len(self.subscriptions) + 1}" + self.subscriptions.append({"id": subscription_id, "paths": tuple(paths or ())}) + return subscription_id + + def unsubscribe(self, subscription_id: str) -> bool: + """兼容 watcher 取消订阅接口。""" + del subscription_id + return True + + class FakeSupervisor: + def __init__(self, plugin_dirs, plugins): + self._plugin_dirs = plugin_dirs + self._registered_plugins = {plugin_id: object() for plugin_id in plugins} + + manager = integration_module.PluginRuntimeManager() + manager._plugin_file_watcher = FakeWatcher() + manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["test.alpha"]) + + manager._refresh_plugin_config_watch_subscriptions() + + assert set(manager._plugin_config_watcher_subscriptions.keys()) == {"test.alpha", "test.beta"} + @pytest.mark.asyncio async def test_component_reload_plugin_returns_failure_when_reload_rolls_back(self, monkeypatch): from src.plugin_runtime import integration as integration_module diff --git a/src/chat/message_receive/bot.py b/src/chat/message_receive/bot.py index 33a66ffc..27a13821 100644 --- a/src/chat/message_receive/bot.py +++ b/src/chat/message_receive/bot.py @@ -1,6 +1,8 @@ +"""聊天消息入口与主链路调度。""" + from contextlib import suppress from copy import deepcopy -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import os import traceback @@ -13,12 +15,15 @@ from src.common.utils.utils_message import MessageUtils from src.common.utils.utils_session import SessionUtils from src.config.config import global_config from src.platform_io.route_key_factory import RouteKeyFactory - from src.core.announcement_manager import global_announcement_manager from src.plugin_runtime.component_query import component_query_service +from src.plugin_runtime.hook_payloads import deserialize_session_message, serialize_session_message +from src.plugin_runtime.hook_schema_utils import build_object_schema +from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult +from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry -from .message import SessionMessage from .chat_manager import chat_manager +from .message import SessionMessage # 定义日志配置 @@ -29,7 +34,137 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.. logger = get_logger("chat") +def register_chat_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]: + """注册聊天消息主链内置 Hook 规格。 + + Args: + registry: 目标 Hook 规格注册中心。 + + Returns: + List[HookSpec]: 实际注册的 Hook 规格列表。 + """ + + return registry.register_hook_specs( + [ + HookSpec( + name="chat.receive.before_process", + description="在入站消息执行 `SessionMessage.process()` 之前触发,可拦截或改写消息。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "当前入站消息的序列化 SessionMessage。", + }, + }, + required=["message"], + ), + default_timeout_ms=8000, + allow_abort=True, + allow_kwargs_mutation=True, + ), + HookSpec( + name="chat.receive.after_process", + description="在入站消息完成轻量预处理后触发,可改写文本、消息体或中止后续链路。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "已完成 `process()` 的序列化 SessionMessage。", + }, + }, + required=["message"], + ), + default_timeout_ms=8000, + allow_abort=True, + allow_kwargs_mutation=True, + ), + HookSpec( + name="chat.command.before_execute", + description="在命令匹配成功、实际执行前触发,可拦截命令或改写命令上下文。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "当前命令消息的序列化 SessionMessage。", + }, + "command_name": { + "type": "string", + "description": "命中的命令名称。", + }, + "plugin_id": { + "type": "string", + "description": "命令所属插件 ID。", + }, + "matched_groups": { + "type": "object", + "description": "命令正则命名捕获结果。", + }, + }, + required=["message", "command_name", "plugin_id", "matched_groups"], + ), + default_timeout_ms=5000, + allow_abort=True, + allow_kwargs_mutation=True, + ), + HookSpec( + name="chat.command.after_execute", + description="在命令执行结束后触发,可调整返回文本和是否继续主链处理。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "当前命令消息的序列化 SessionMessage。", + }, + "command_name": { + "type": "string", + "description": "命令名称。", + }, + "plugin_id": { + "type": "string", + "description": "命令所属插件 ID。", + }, + "matched_groups": { + "type": "object", + "description": "命令正则命名捕获结果。", + }, + "success": { + "type": "boolean", + "description": "命令执行是否成功。", + }, + "response": { + "type": "string", + "description": "命令返回文本。", + }, + "intercept_message_level": { + "type": "integer", + "description": "命令拦截等级。", + }, + "continue_process": { + "type": "boolean", + "description": "命令执行后是否继续后续消息处理。", + }, + }, + required=[ + "message", + "command_name", + "plugin_id", + "matched_groups", + "success", + "intercept_message_level", + "continue_process", + ], + ), + default_timeout_ms=5000, + allow_abort=False, + allow_kwargs_mutation=True, + ), + ] + ) + + class ChatBot: + """聊天机器人入口协调器。""" + def __init__(self) -> None: """初始化聊天机器人入口。""" @@ -44,6 +179,66 @@ class ChatBot: self._started = True + @staticmethod + def _get_runtime_manager() -> Any: + """获取插件运行时管理器。 + + Returns: + Any: 插件运行时管理器单例。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + return get_plugin_runtime_manager() + + @staticmethod + def _coerce_int(value: Any, default: int) -> int: + """将任意值安全转换为整数。 + + Args: + value: 待转换的值。 + default: 转换失败时的默认值。 + + Returns: + int: 转换后的整数结果。 + """ + + try: + return int(value) + except (TypeError, ValueError): + return default + + async def _invoke_message_hook( + self, + hook_name: str, + message: SessionMessage, + **kwargs: Any, + ) -> tuple[HookDispatchResult, SessionMessage]: + """触发携带会话消息的命名 Hook。 + + Args: + hook_name: 目标 Hook 名称。 + message: 当前会话消息。 + **kwargs: 需要附带传递的额外参数。 + + Returns: + tuple[HookDispatchResult, SessionMessage]: Hook 聚合结果以及可能被改写后的消息对象。 + """ + + hook_result = await self._get_runtime_manager().invoke_hook( + hook_name, + message=serialize_session_message(message), + **kwargs, + ) + mutated_message = message + raw_message = hook_result.kwargs.get("message") + if raw_message is not None: + try: + mutated_message = deserialize_session_message(raw_message) + except Exception as exc: + logger.warning(f"Hook {hook_name} 返回的 message 无法反序列化,已忽略: {exc}") + return hook_result, mutated_message + async def _process_commands(self, message: SessionMessage) -> tuple[bool, Optional[str], bool]: """使用统一组件注册表处理命令。 @@ -71,6 +266,25 @@ class ChatBot: return False, None, True message.is_command = True + before_result, message = await self._invoke_message_hook( + "chat.command.before_execute", + message, + command_name=command_name, + plugin_id=plugin_name, + matched_groups=dict(matched_groups), + ) + if before_result.aborted: + logger.info(f"命令 {command_name} 被 Hook 中止,跳过命令执行") + return True, None, False + + hook_kwargs = before_result.kwargs + command_name = str(hook_kwargs.get("command_name", command_name) or command_name) + plugin_name = str(hook_kwargs.get("plugin_id", plugin_name) or plugin_name) + matched_groups = ( + dict(hook_kwargs["matched_groups"]) + if isinstance(hook_kwargs.get("matched_groups"), dict) + else dict(matched_groups) + ) # 获取插件配置 plugin_config = component_query_service.get_plugin_config(plugin_name) @@ -82,27 +296,43 @@ class ChatBot: plugin_config=plugin_config, matched_groups=matched_groups, ) - self._mark_command_message(message, intercept_message_level) - - # 记录命令执行结果 - if success: - logger.info(f"命令执行成功: {command_name} (拦截等级: {intercept_message_level})") - else: - logger.warning(f"命令执行失败: {command_name} - {response}") - - # 根据命令的拦截设置决定是否继续处理消息 - return ( - True, - response, - not bool(intercept_message_level), - ) # 找到命令,根据intercept_message决定是否继续 - - except Exception as e: - logger.error(f"执行命令时出错: {command_name} - {e}") + continue_process = not bool(intercept_message_level) + except Exception as exc: + logger.error(f"执行命令时出错: {command_name} - {exc}") logger.error(traceback.format_exc()) + success = False + response = str(exc) + intercept_message_level = 1 + continue_process = False - # 命令出错时,根据命令的拦截设置决定是否继续处理消息 - return True, str(e), False # 出错时继续处理消息 + after_result, message = await self._invoke_message_hook( + "chat.command.after_execute", + message, + command_name=command_name, + plugin_id=plugin_name, + matched_groups=dict(matched_groups), + success=success, + response=response, + intercept_message_level=intercept_message_level, + continue_process=continue_process, + ) + after_kwargs = after_result.kwargs + success = bool(after_kwargs.get("success", success)) + raw_response = after_kwargs.get("response", response) + response = None if raw_response is None else str(raw_response) + intercept_message_level = self._coerce_int( + after_kwargs.get("intercept_message_level", intercept_message_level), + intercept_message_level, + ) + continue_process = bool(after_kwargs.get("continue_process", continue_process)) + self._mark_command_message(message, intercept_message_level) + + if success: + logger.info(f"命令执行成功: {command_name} (拦截等级: {intercept_message_level})") + else: + logger.warning(f"命令执行失败: {command_name} - {response}") + + return True, response, continue_process return False, None, True @@ -138,6 +368,17 @@ class ChatBot: cmd_result: Optional[str], continue_process: bool, ) -> bool: + """处理命令链结果并决定是否终止主消息链。 + + Args: + message: 当前命令消息。 + cmd_result: 命令响应文本。 + continue_process: 是否继续后续主链处理。 + + Returns: + bool: ``True`` 表示已经终止后续主链。 + """ + if continue_process: return False @@ -145,9 +386,18 @@ class ChatBot: logger.info(f"命令处理完成,跳过后续消息处理: {cmd_result}") return True - async def handle_notice_message(self, message: SessionMessage): + async def handle_notice_message(self, message: SessionMessage) -> bool: + """处理通知类消息。 + + Args: + message: 当前通知消息。 + + Returns: + bool: 当前消息是否为通知消息。 + """ + if message.message_id != "notice": - return + return False message.is_notify = True logger.debug("notice消息") @@ -203,9 +453,12 @@ class ChatBot: return True async def echo_message_process(self, raw_data: Dict[str, Any]) -> None: + """处理消息回送 ID 对应关系。 + + Args: + raw_data: 平台适配器上报的原始回送载荷。 """ - 用于专门处理回送消息ID的函数 - """ + message_data: Dict[str, Any] = raw_data.get("content", {}) if not message_data: return @@ -218,18 +471,10 @@ class ChatBot: logger.debug(f"收到回送消息ID: {mmc_message_id} -> {actual_message_id}") async def message_process(self, message_data: Dict[str, Any]) -> None: - """处理转化后的统一格式消息 - 这个函数本质是预处理一些数据,根据配置信息和消息内容,预处理消息,并分发到合适的消息处理器中 - heart_flow模式:使用思维流系统进行回复 - - 包含思维流状态管理 - - 在回复前进行观察和状态更新 - - 回复后更新思维流状态 - - 消息过滤 - - 记忆激活 - - 意愿计算 - - 消息生成和发送 - - 表情包处理 - - 性能计时 + """处理统一格式的入站消息字典。 + + Args: + message_data: 适配器整理后的统一消息字典。 """ try: # 确保所有任务已启动 @@ -253,7 +498,13 @@ class ChatBot: logger.error(f"预处理消息失败: {e}") traceback.print_exc() - async def receive_message(self, message: SessionMessage): + async def receive_message(self, message: SessionMessage) -> None: + """处理单条入站会话消息。 + + Args: + message: 待处理的会话消息。 + """ + try: group_info = message.message_info.group_info user_info = message.message_info.user_info @@ -272,6 +523,19 @@ class ChatBot: ) message.session_id = session_id # 正确初始化session_id + before_process_result, message = await self._invoke_message_hook( + "chat.receive.before_process", + message, + ) + if before_process_result.aborted: + logger.info(f"消息 {message.message_id} 在预处理前被 Hook 中止") + return + + group_info = message.message_info.group_info + user_info = message.message_info.user_info + additional_config = message.message_info.additional_config + if isinstance(additional_config, dict): + account_id, scope = RouteKeyFactory.extract_components(additional_config) # TODO: 修复事件预处理部分 # continue_flag, modified_message = await events_manager.handle_mai_events( @@ -294,6 +558,16 @@ class ChatBot: enable_heavy_media_analysis=False, enable_voice_transcription=False, ) + after_process_result, message = await self._invoke_message_hook( + "chat.receive.after_process", + message, + ) + if after_process_result.aborted: + logger.info(f"消息 {message.message_id} 在预处理后被 Hook 中止") + return + + group_info = message.message_info.group_info + user_info = message.message_info.user_info # 平台层的 @ 检测由底层 is_mentioned_bot_in_message 统一处理;此处不做用户名硬编码匹配 diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 93b48753..63ab2fba 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime from time import perf_counter -from typing import List, Optional, Sequence +from typing import Any, List, Optional, Sequence import asyncio import json @@ -26,6 +26,15 @@ 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.plugin_runtime.hook_payloads import ( + deserialize_prompt_messages, + deserialize_tool_calls, + serialize_prompt_messages, + serialize_tool_calls, + serialize_tool_definitions, +) +from src.plugin_runtime.hook_schema_utils import build_object_schema +from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry from src.services.llm_service import LLMServiceClient from .builtin_tools import get_builtin_tools @@ -58,6 +67,123 @@ class ToolFilterSelection(BaseModel): logger = get_logger("maisaka_chat_loop") +def register_maisaka_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]: + """注册 Maisaka 规划器内置 Hook 规格。 + + Args: + registry: 目标 Hook 规格注册中心。 + + Returns: + List[HookSpec]: 实际注册的 Hook 规格列表。 + """ + + return registry.register_hook_specs( + [ + HookSpec( + name="maisaka.planner.before_request", + description="在 Maisaka 向模型发起规划请求前触发,可改写消息窗口与工具定义。", + parameters_schema=build_object_schema( + { + "messages": { + "type": "array", + "description": "即将发给模型的 PromptMessage 列表。", + }, + "tool_definitions": { + "type": "array", + "description": "当前候选工具定义列表。", + }, + "selected_history_count": { + "type": "integer", + "description": "当前选中的上下文消息数量。", + }, + "built_message_count": { + "type": "integer", + "description": "实际发送给模型的消息数量。", + }, + "selection_reason": { + "type": "string", + "description": "上下文选择说明。", + }, + "session_id": { + "type": "string", + "description": "当前会话 ID。", + }, + }, + required=[ + "messages", + "tool_definitions", + "selected_history_count", + "built_message_count", + "selection_reason", + "session_id", + ], + ), + default_timeout_ms=6000, + allow_abort=False, + allow_kwargs_mutation=True, + ), + HookSpec( + name="maisaka.planner.after_response", + description="在 Maisaka 收到模型响应后触发,可调整文本结果与工具调用列表。", + parameters_schema=build_object_schema( + { + "response": { + "type": "string", + "description": "模型返回的文本内容。", + }, + "tool_calls": { + "type": "array", + "description": "模型返回的工具调用列表。", + }, + "selected_history_count": { + "type": "integer", + "description": "当前选中的上下文消息数量。", + }, + "built_message_count": { + "type": "integer", + "description": "实际发送给模型的消息数量。", + }, + "selection_reason": { + "type": "string", + "description": "上下文选择说明。", + }, + "session_id": { + "type": "string", + "description": "当前会话 ID。", + }, + "prompt_tokens": { + "type": "integer", + "description": "输入 Token 数。", + }, + "completion_tokens": { + "type": "integer", + "description": "输出 Token 数。", + }, + "total_tokens": { + "type": "integer", + "description": "总 Token 数。", + }, + }, + required=[ + "response", + "tool_calls", + "selected_history_count", + "built_message_count", + "selection_reason", + "session_id", + "prompt_tokens", + "completion_tokens", + "total_tokens", + ], + ), + default_timeout_ms=6000, + allow_abort=False, + allow_kwargs_mutation=True, + ), + ] + ) + + class MaisakaChatLoopService: """负责 Maisaka 主对话循环、系统提示词和终端渲染。""" @@ -105,6 +231,35 @@ class MaisakaChatLoopService: return self._personality_prompt + @staticmethod + def _get_runtime_manager() -> Any: + """获取插件运行时管理器。 + + Returns: + Any: 插件运行时管理器单例。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + return get_plugin_runtime_manager() + + @staticmethod + def _coerce_int(value: Any, default: int) -> int: + """将任意值安全转换为整数。 + + Args: + value: 待转换的输入值。 + default: 转换失败时的默认值。 + + Returns: + int: 转换后的整数结果。 + """ + + try: + return int(value) + except (TypeError, ValueError): + return default + def _build_personality_prompt(self) -> str: """构造人格提示词。""" @@ -580,6 +735,26 @@ class MaisakaChatLoopService: else: all_tools = [*get_builtin_tools(), *self._extra_tools] + before_request_result = await self._get_runtime_manager().invoke_hook( + "maisaka.planner.before_request", + messages=serialize_prompt_messages(built_messages), + tool_definitions=serialize_tool_definitions(all_tools), + selected_history_count=len(selected_history), + built_message_count=len(built_messages), + selection_reason=selection_reason, + session_id=self._session_id, + ) + before_request_kwargs = before_request_result.kwargs + raw_messages = before_request_kwargs.get("messages") + if isinstance(raw_messages, list): + try: + built_messages = deserialize_prompt_messages(raw_messages) + except Exception as exc: + logger.warning(f"Hook maisaka.planner.before_request 返回的 messages 无法反序列化,已忽略: {exc}") + raw_tool_definitions = before_request_kwargs.get("tool_definitions") + if isinstance(raw_tool_definitions, list): + all_tools = [item for item in raw_tool_definitions if isinstance(item, dict)] + ordered_panels = PromptCLIVisualizer.build_prompt_panels( built_messages, image_display_mode=global_config.maisaka.terminal_image_display_mode, @@ -625,33 +800,63 @@ class MaisakaChatLoopService: ) logger.info(f"本轮Prompt统计: {prompt_stats_text}") + final_response = generation_result.response or "" + final_tool_calls = list(generation_result.tool_calls or []) + after_response_result = await self._get_runtime_manager().invoke_hook( + "maisaka.planner.after_response", + response=final_response, + tool_calls=serialize_tool_calls(final_tool_calls), + selected_history_count=len(selected_history), + built_message_count=len(built_messages), + selection_reason=selection_reason, + session_id=self._session_id, + prompt_tokens=generation_result.prompt_tokens, + completion_tokens=generation_result.completion_tokens, + total_tokens=generation_result.total_tokens, + ) + after_response_kwargs = after_response_result.kwargs + if "response" in after_response_kwargs: + final_response = str(after_response_kwargs.get("response") or "") + raw_tool_calls = after_response_kwargs.get("tool_calls") + if isinstance(raw_tool_calls, list): + try: + final_tool_calls = deserialize_tool_calls(raw_tool_calls) + except Exception as exc: + logger.warning(f"Hook maisaka.planner.after_response 返回的 tool_calls 无法反序列化,已忽略: {exc}") + prompt_tokens = self._coerce_int(after_response_kwargs.get("prompt_tokens"), generation_result.prompt_tokens) + completion_tokens = self._coerce_int( + after_response_kwargs.get("completion_tokens"), + generation_result.completion_tokens, + ) + total_tokens = self._coerce_int(after_response_kwargs.get("total_tokens"), generation_result.total_tokens) + tool_call_summaries = [ { "调用编号": getattr(tool_call, "call_id", getattr(tool_call, "id", None)), "工具名": getattr(tool_call, "func_name", getattr(tool_call, "name", None)), "参数": getattr(tool_call, "args", getattr(tool_call, "arguments", None)), } - for tool_call in (generation_result.tool_calls or []) + for tool_call in final_tool_calls ] logger.info( - f"Maisaka 规划器返回结果: 内容={generation_result.response or ''!r} " + f"Maisaka 规划器返回结果: 内容={final_response!r} " f"工具调用={tool_call_summaries}" ) raw_message = AssistantMessage( - content=generation_result.response or "", + content=final_response, timestamp=datetime.now(), - tool_calls=generation_result.tool_calls or [], + tool_calls=final_tool_calls, ) return ChatResponse( - content=generation_result.response, - tool_calls=generation_result.tool_calls or [], + content=final_response or None, + tool_calls=final_tool_calls, raw_message=raw_message, selected_history_count=len(selected_history), - prompt_tokens=generation_result.prompt_tokens, + prompt_tokens=prompt_tokens, built_message_count=len(built_messages), - completion_tokens=generation_result.completion_tokens, - total_tokens=generation_result.total_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, ) @staticmethod diff --git a/src/plugin_runtime/component_query.py b/src/plugin_runtime/component_query.py index 366c3c0f..c4ded56e 100644 --- a/src/plugin_runtime/component_query.py +++ b/src/plugin_runtime/component_query.py @@ -6,6 +6,7 @@ from __future__ import annotations +from copy import deepcopy from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional, Tuple, cast from src.common.logger import get_logger @@ -908,5 +909,27 @@ class ComponentQueryService: return None return dict(registration.config_schema) + def list_hook_specs(self) -> list[dict[str, Any]]: + """返回当前运行时公开的 Hook 规格清单。 + + Returns: + list[dict[str, Any]]: 可直接序列化给 WebUI 的 Hook 规格列表。 + """ + + runtime_manager = self._get_runtime_manager() + return [ + { + "name": spec.name, + "description": spec.description, + "parameters_schema": deepcopy(spec.parameters_schema), + "default_timeout_ms": spec.default_timeout_ms, + "allow_blocking": spec.allow_blocking, + "allow_observe": spec.allow_observe, + "allow_abort": spec.allow_abort, + "allow_kwargs_mutation": spec.allow_kwargs_mutation, + } + for spec in runtime_manager.list_hook_specs() + ] + component_query_service = ComponentQueryService() diff --git a/src/plugin_runtime/hook_catalog.py b/src/plugin_runtime/hook_catalog.py new file mode 100644 index 00000000..714773eb --- /dev/null +++ b/src/plugin_runtime/hook_catalog.py @@ -0,0 +1,46 @@ +"""内置命名 Hook 目录注册器。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import List + +from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry + + +HookSpecRegistrar = Callable[[HookSpecRegistry], List[HookSpec]] +"""单个业务模块向注册中心写入 Hook 规格的注册器签名。""" + + +def _get_builtin_hook_spec_registrars() -> List[HookSpecRegistrar]: + """返回当前内置 Hook 规格注册器列表。 + + Returns: + List[HookSpecRegistrar]: 已启用的内置 Hook 注册器列表。 + """ + + from src.chat.message_receive.bot import register_chat_hook_specs + from src.maisaka.chat_loop_service import register_maisaka_hook_specs + from src.services.send_service import register_send_service_hook_specs + + return [ + register_chat_hook_specs, + register_send_service_hook_specs, + register_maisaka_hook_specs, + ] + + +def register_builtin_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]: + """向注册中心写入全部内置 Hook 规格。 + + Args: + registry: 目标 Hook 规格注册中心。 + + Returns: + List[HookSpec]: 本次完成注册后的全部内置 Hook 规格。 + """ + + registered_specs: List[HookSpec] = [] + for registrar in _get_builtin_hook_spec_registrars(): + registered_specs.extend(registrar(registry)) + return registered_specs diff --git a/src/plugin_runtime/hook_payloads.py b/src/plugin_runtime/hook_payloads.py new file mode 100644 index 00000000..9d3fbc69 --- /dev/null +++ b/src/plugin_runtime/hook_payloads.py @@ -0,0 +1,178 @@ +"""运行时 Hook 载荷序列化辅助。""" + +from __future__ import annotations + +from typing import Any, Dict, List, Sequence + +from src.chat.message_receive.message import SessionMessage +from src.common.data_models.llm_service_data_models import PromptMessage +from src.llm_models.payload_content.message import Message +from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, normalize_tool_options +from src.plugin_runtime.host.message_utils import PluginMessageUtils + + +def serialize_session_message(message: SessionMessage) -> Dict[str, Any]: + """将会话消息序列化为 Hook 可传输载荷。 + + Args: + message: 待序列化的会话消息。 + + Returns: + Dict[str, Any]: 可通过插件运行时传输的消息字典。 + """ + + return dict(PluginMessageUtils._session_message_to_dict(message)) + + +def deserialize_session_message(raw_message: Any) -> SessionMessage: + """从 Hook 载荷恢复会话消息。 + + Args: + raw_message: Hook 返回的消息字典。 + + Returns: + SessionMessage: 恢复后的会话消息对象。 + + Raises: + ValueError: 消息结构不合法时抛出。 + """ + + if not isinstance(raw_message, dict): + raise ValueError("Hook 返回的 `message` 必须是字典") + return PluginMessageUtils._build_session_message_from_dict(raw_message) + + +def serialize_tool_calls(tool_calls: Sequence[ToolCall] | None) -> List[Dict[str, Any]]: + """将工具调用列表序列化为 Hook 可传输载荷。 + + Args: + tool_calls: 原始工具调用列表。 + + Returns: + List[Dict[str, Any]]: 序列化后的工具调用列表。 + """ + + if not tool_calls: + return [] + + return [ + { + "id": tool_call.call_id, + "function": { + "name": tool_call.func_name, + "arguments": dict(tool_call.args or {}), + }, + } + for tool_call in tool_calls + ] + + +def deserialize_tool_calls(raw_tool_calls: Any) -> List[ToolCall]: + """从 Hook 载荷恢复工具调用列表。 + + Args: + raw_tool_calls: Hook 返回的工具调用列表。 + + Returns: + List[ToolCall]: 恢复后的工具调用列表。 + + Raises: + ValueError: 结构不合法时抛出。 + """ + + if raw_tool_calls in (None, []): + return [] + if not isinstance(raw_tool_calls, list): + raise ValueError("Hook 返回的 `tool_calls` 必须是列表") + + normalized_tool_calls: List[ToolCall] = [] + for raw_tool_call in raw_tool_calls: + if not isinstance(raw_tool_call, dict): + raise ValueError("Hook 返回的工具调用项必须是字典") + + function_info = raw_tool_call.get("function", {}) + if isinstance(function_info, dict): + function_name = function_info.get("name") + function_arguments = function_info.get("arguments") + else: + function_name = raw_tool_call.get("name") + function_arguments = raw_tool_call.get("arguments") + + call_id = raw_tool_call.get("id") or raw_tool_call.get("call_id") + if not isinstance(call_id, str) or not isinstance(function_name, str): + raise ValueError("Hook 返回的工具调用缺少 `id` 或函数名称") + + normalized_tool_calls.append( + ToolCall( + call_id=call_id, + func_name=function_name, + args=function_arguments if isinstance(function_arguments, dict) else {}, + ) + ) + return normalized_tool_calls + + +def serialize_prompt_messages(messages: Sequence[Message]) -> List[PromptMessage]: + """将 LLM 消息列表序列化为 Hook 可传输载荷。 + + Args: + messages: 原始 LLM 消息列表。 + + Returns: + List[PromptMessage]: 序列化后的消息字典列表。 + """ + + serialized_messages: List[PromptMessage] = [] + for message in messages: + serialized_message: PromptMessage = { + "role": message.role.value, + "content": message.content, + } + if message.tool_call_id: + serialized_message["tool_call_id"] = message.tool_call_id + if message.tool_calls: + serialized_message["tool_calls"] = serialize_tool_calls(message.tool_calls) + serialized_messages.append(serialized_message) + return serialized_messages + + +def deserialize_prompt_messages(raw_messages: Any) -> List[Message]: + """从 Hook 载荷恢复 LLM 消息列表。 + + Args: + raw_messages: Hook 返回的消息列表。 + + Returns: + List[Message]: 恢复后的 LLM 消息列表。 + + Raises: + ValueError: 结构不合法时抛出。 + """ + + if not isinstance(raw_messages, list): + raise ValueError("Hook 返回的 `messages` 必须是列表") + + from src.services.llm_service import _build_message_from_dict + + normalized_messages: List[Message] = [] + for raw_message in raw_messages: + if not isinstance(raw_message, dict): + raise ValueError("Hook 返回的消息项必须是字典") + normalized_messages.append(_build_message_from_dict(raw_message)) + return normalized_messages + + +def serialize_tool_definitions(tool_definitions: Sequence[ToolDefinitionInput]) -> List[Dict[str, Any]]: + """将工具定义列表序列化为 Hook 可传输载荷。 + + Args: + tool_definitions: 原始工具定义列表。 + + Returns: + List[Dict[str, Any]]: 序列化后的工具定义列表。 + """ + + normalized_tool_options = normalize_tool_options(list(tool_definitions)) + if not normalized_tool_options: + return [] + return [tool_option.to_openai_function_schema() for tool_option in normalized_tool_options] diff --git a/src/plugin_runtime/hook_schema_utils.py b/src/plugin_runtime/hook_schema_utils.py new file mode 100644 index 00000000..c92d15ea --- /dev/null +++ b/src/plugin_runtime/hook_schema_utils.py @@ -0,0 +1,31 @@ +"""Hook 参数模型构造辅助。""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Dict, Sequence + + +def build_object_schema( + properties: Dict[str, Dict[str, Any]], + *, + required: Sequence[str] | None = None, +) -> Dict[str, Any]: + """构造对象级 JSON Schema。 + + Args: + properties: 字段定义映射。 + required: 必填字段名列表。 + + Returns: + Dict[str, Any]: 标准化后的对象级 Schema。 + """ + + schema: Dict[str, Any] = { + "type": "object", + "properties": deepcopy(properties), + } + normalized_required = [str(item).strip() for item in (required or []) if str(item).strip()] + if normalized_required: + schema["required"] = normalized_required + return schema diff --git a/src/plugin_runtime/host/component_registry.py b/src/plugin_runtime/host/component_registry.py index c91574e5..07e4d8ea 100644 --- a/src/plugin_runtime/host/component_registry.py +++ b/src/plugin_runtime/host/component_registry.py @@ -18,9 +18,37 @@ import re from src.common.logger import get_logger from src.core.tooling import build_tool_detailed_description +from .hook_spec_registry import HookSpecRegistry + logger = get_logger("plugin_runtime.host.component_registry") +class ComponentRegistrationError(ValueError): + """组件注册失败异常。""" + + def __init__( + self, + message: str, + *, + component_name: str = "", + component_type: str = "", + plugin_id: str = "", + ) -> None: + """初始化组件注册失败异常。 + + Args: + message: 原始错误信息。 + component_name: 组件名称。 + component_type: 组件类型。 + plugin_id: 插件 ID。 + """ + + self.component_name = str(component_name or "").strip() + self.component_type = str(component_type or "").strip() + self.plugin_id = str(plugin_id or "").strip() + super().__init__(message) + + class ComponentTypes(str, Enum): ACTION = "ACTION" COMMAND = "COMMAND" @@ -359,7 +387,14 @@ class ComponentRegistry: 供业务层查询可用组件、匹配命令、调度 action/event 等。 """ - def __init__(self) -> None: + def __init__(self, hook_spec_registry: Optional[HookSpecRegistry] = None) -> None: + """初始化组件注册表。 + + Args: + hook_spec_registry: 可选的 Hook 规格注册中心;提供后会在注册 + HookHandler 时执行规格校验。 + """ + # 全量索引 self._components: Dict[str, ComponentEntry] = {} # full_name -> comp @@ -370,6 +405,7 @@ class ComponentRegistry: # 按插件索引 self._by_plugin: Dict[str, List[ComponentEntry]] = {} + self._hook_spec_registry = hook_spec_registry @staticmethod def _convert_action_metadata_to_tool_metadata( @@ -475,77 +511,211 @@ class ComponentRegistry: type_dict.clear() self._by_plugin.clear() - # ====== 注册 / 注销 ====== - def register_component(self, name: str, component_type: str, plugin_id: str, metadata: Dict[str, Any]) -> bool: - """注册单个组件 + @staticmethod + def _is_legacy_action_component(component: ComponentEntry) -> bool: + """判断组件是否为兼容旧 Action 的 Tool 条目。 Args: - name: 组件名称(不含插件id前缀) - component_type: 组件类型(如 `ACTION`、`COMMAND` 等) - plugin_id: 插件id - metadata: 组件元数据 + component: 待判断的组件条目。 + Returns: - success (bool): 是否成功注册(失败原因通常是组件类型无效) + bool: 是否为兼容旧 Action 组件。 """ + + if not isinstance(component, ToolEntry): + return False + return str(component.metadata.get("legacy_component_type", "") or "").strip().upper() == "ACTION" + + def _validate_hook_handler_entry(self, component: HookHandlerEntry) -> None: + """校验 HookHandler 是否满足已注册的 Hook 规格。 + + Args: + component: 待校验的 HookHandler 条目。 + + Raises: + ComponentRegistrationError: HookHandler 声明不合法时抛出。 + """ + + if self._hook_spec_registry is None: + return + + hook_spec = self._hook_spec_registry.get_hook_spec(component.hook) + if hook_spec is None: + raise ComponentRegistrationError( + f"HookHandler {component.full_name} 声明了未注册的 Hook: {component.hook}", + component_name=component.name, + component_type=component.component_type.value, + plugin_id=component.plugin_id, + ) + + if component.is_blocking and not hook_spec.allow_blocking: + raise ComponentRegistrationError( + f"HookHandler {component.full_name} 不能注册为 blocking:Hook {component.hook} 不允许 blocking 处理器", + component_name=component.name, + component_type=component.component_type.value, + plugin_id=component.plugin_id, + ) + + if component.is_observe and not hook_spec.allow_observe: + raise ComponentRegistrationError( + f"HookHandler {component.full_name} 不能注册为 observe:Hook {component.hook} 不允许 observe 处理器", + component_name=component.name, + component_type=component.component_type.value, + plugin_id=component.plugin_id, + ) + + if component.error_policy == "abort" and not hook_spec.allow_abort: + raise ComponentRegistrationError( + f"HookHandler {component.full_name} 不能使用 error_policy=abort:Hook {component.hook} 不允许 abort", + component_name=component.name, + component_type=component.component_type.value, + plugin_id=component.plugin_id, + ) + + def _build_component_entry( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + ) -> ComponentEntry: + """根据声明构造组件条目。 + + Args: + name: 组件名称。 + component_type: 组件类型。 + plugin_id: 插件 ID。 + metadata: 组件元数据。 + + Returns: + ComponentEntry: 已构造并完成校验的组件条目。 + + Raises: + ComponentRegistrationError: 组件声明不合法时抛出。 + """ + try: normalized_type = self._normalize_component_type(component_type) normalized_metadata = dict(metadata) if normalized_type == ComponentTypes.ACTION: normalized_metadata = self._convert_action_metadata_to_tool_metadata(name, normalized_metadata) - comp = ToolEntry(name, ComponentTypes.TOOL.value, plugin_id, normalized_metadata) + component = ToolEntry(name, ComponentTypes.TOOL.value, plugin_id, normalized_metadata) elif normalized_type == ComponentTypes.COMMAND: - comp = CommandEntry(name, normalized_type.value, plugin_id, normalized_metadata) + component = CommandEntry(name, normalized_type.value, plugin_id, normalized_metadata) elif normalized_type == ComponentTypes.TOOL: - comp = ToolEntry(name, normalized_type.value, plugin_id, normalized_metadata) + component = ToolEntry(name, normalized_type.value, plugin_id, normalized_metadata) elif normalized_type == ComponentTypes.EVENT_HANDLER: - comp = EventHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata) + component = EventHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata) elif normalized_type == ComponentTypes.HOOK_HANDLER: - comp = HookHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata) + component = HookHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata) + self._validate_hook_handler_entry(component) elif normalized_type == ComponentTypes.MESSAGE_GATEWAY: - comp = MessageGatewayEntry(name, normalized_type.value, plugin_id, normalized_metadata) + component = MessageGatewayEntry(name, normalized_type.value, plugin_id, normalized_metadata) else: - raise ValueError(f"组件类型 {component_type} 不存在") - except ValueError: - logger.error(f"组件类型 {component_type} 不存在") - return False + raise ComponentRegistrationError( + f"组件类型 {component_type} 不存在", + component_name=name, + component_type=component_type, + plugin_id=plugin_id, + ) + except ComponentRegistrationError: + raise + except Exception as exc: + raise ComponentRegistrationError( + str(exc), + component_name=name, + component_type=component_type, + plugin_id=plugin_id, + ) from exc - if comp.full_name in self._components: - logger.warning(f"组件 {comp.full_name} 已存在,覆盖") - old_comp = self._components[comp.full_name] - # 从 _by_plugin 列表中移除旧条目,防止幽灵组件堆积 - old_list = self._by_plugin.get(old_comp.plugin_id) - if old_list is not None: - with contextlib.suppress(ValueError): - old_list.remove(old_comp) - # 从旧类型索引中移除,防止类型变更时幽灵残留 - if old_type_dict := self._by_type.get(old_comp.component_type): - old_type_dict.pop(comp.full_name, None) + return component - self._components[comp.full_name] = comp - self._by_type[comp.component_type][comp.full_name] = comp - self._by_plugin.setdefault(plugin_id, []).append(comp) + def _remove_existing_component_entry(self, component: ComponentEntry) -> None: + """移除同名旧组件条目。 + Args: + component: 即将写入的新组件条目。 + """ + + if component.full_name not in self._components: + return + + logger.warning(f"组件 {component.full_name} 已存在,覆盖") + old_component = self._components[component.full_name] + old_list = self._by_plugin.get(old_component.plugin_id) + if old_list is not None: + with contextlib.suppress(ValueError): + old_list.remove(old_component) + if old_type_dict := self._by_type.get(old_component.component_type): + old_type_dict.pop(component.full_name, None) + + def _add_component_entry(self, component: ComponentEntry) -> None: + """写入单个组件条目到全部索引。 + + Args: + component: 待写入的组件条目。 + """ + + self._remove_existing_component_entry(component) + self._components[component.full_name] = component + self._by_type[component.component_type][component.full_name] = component + self._by_plugin.setdefault(component.plugin_id, []).append(component) + + # ====== 注册 / 注销 ====== + def register_component(self, name: str, component_type: str, plugin_id: str, metadata: Dict[str, Any]) -> bool: + """注册单个组件。 + + Args: + name: 组件名称(不含插件 ID 前缀)。 + component_type: 组件类型(如 ``ACTION``、``COMMAND`` 等)。 + plugin_id: 插件 ID。 + metadata: 组件元数据。 + + Returns: + bool: 注册成功时恒为 ``True``。 + + Raises: + ComponentRegistrationError: 组件声明不合法时抛出。 + """ + + component = self._build_component_entry(name, component_type, plugin_id, metadata) + self._add_component_entry(component) return True def register_plugin_components(self, plugin_id: str, components: List[Dict[str, Any]]) -> int: - """批量注册一个插件的所有组件,返回成功注册数。 + """批量替换一个插件的组件集合。 + + 该方法会先完整校验所有组件声明,只有全部通过后才会替换旧组件, + 从而避免插件进入半注册状态。 + Args: - plugin_id (str): 插件id - components (List[Dict[str, Any]]): 组件字典列表,每个组件包含 name, component_type, metadata 等字段 + plugin_id: 插件 ID。 + components: 组件声明字典列表。 + Returns: - count (int): 成功注册的组件数量 + int: 实际注册的组件数量。 + + Raises: + ComponentRegistrationError: 任一组件声明不合法时抛出。 """ - count = 0 - for comp_data in components: - ok = self.register_component( - name=comp_data.get("name", ""), - component_type=comp_data.get("component_type", ""), - plugin_id=plugin_id, - metadata=comp_data.get("metadata", {}), + + prepared_components: List[ComponentEntry] = [] + for component_data in components: + prepared_components.append( + self._build_component_entry( + name=str(component_data.get("name", "") or ""), + component_type=str(component_data.get("component_type", "") or ""), + plugin_id=plugin_id, + metadata=component_data.get("metadata", {}) + if isinstance(component_data.get("metadata"), dict) + else {}, + ) ) - if ok: - count += 1 - return count + + self.remove_components_by_plugin(plugin_id) + for component in prepared_components: + self._add_component_entry(component) + return len(prepared_components) def remove_components_by_plugin(self, plugin_id: str) -> int: """移除某个插件的所有组件,返回移除数量。 @@ -652,6 +822,17 @@ class ComponentRegistry: except ValueError: logger.error(f"组件类型 {component_type} 不存在") raise + + if comp_type == ComponentTypes.ACTION: + action_components = [ + component + for component in self._by_type.get(ComponentTypes.TOOL, {}).values() + if self._is_legacy_action_component(component) + ] + if enabled_only: + return [component for component in action_components if self.check_component_enabled(component, session_id)] + return action_components + type_dict = self._by_type.get(comp_type, {}) if enabled_only: return [c for c in type_dict.values() if self.check_component_enabled(c, session_id)] @@ -854,6 +1035,34 @@ class ComponentRegistry: tools.append(comp) return tools + def get_tools_for_llm(self, *, enabled_only: bool = True, session_id: Optional[str] = None) -> List[Dict[str, Any]]: + """兼容旧接口,返回可供 LLM 使用的工具条目列表。 + + Args: + enabled_only: 是否仅返回启用的组件。 + session_id: 可选的会话 ID,若提供则考虑会话禁用状态。 + + Returns: + List[Dict[str, Any]]: 兼容旧结构的工具组件字典列表。 + """ + + return [ + { + "name": tool.full_name, + "description": tool.description, + "parameters": ( + dict(tool.parameters_raw) + if isinstance(tool.parameters_raw, dict) and tool.parameters_raw + else tool._get_parameters_schema() or {} + ), + "parameters_raw": tool.parameters_raw, + "enabled": tool.enabled, + "plugin_id": tool.plugin_id, + } + for tool in self.get_tools(enabled_only=enabled_only, session_id=session_id) + if not self._is_legacy_action_component(tool) + ] + # ====== 统计信息 ====== def get_stats(self) -> StatusDict: """获取注册统计。 @@ -863,9 +1072,21 @@ class ComponentRegistry: """ return StatusDict( total=len(self._components), - action=len(self._by_type[ComponentTypes.ACTION]), + action=len( + [ + component + for component in self._by_type.get(ComponentTypes.TOOL, {}).values() + if self._is_legacy_action_component(component) + ] + ), command=len(self._by_type[ComponentTypes.COMMAND]), - tool=len(self._by_type[ComponentTypes.TOOL]), + tool=len( + [ + component + for component in self._by_type.get(ComponentTypes.TOOL, {}).values() + if not self._is_legacy_action_component(component) + ] + ), event_handler=len(self._by_type[ComponentTypes.EVENT_HANDLER]), hook_handler=len(self._by_type[ComponentTypes.HOOK_HANDLER]), message_gateway=len(self._by_type[ComponentTypes.MESSAGE_GATEWAY]), diff --git a/src/plugin_runtime/host/hook_dispatcher.py b/src/plugin_runtime/host/hook_dispatcher.py index f2979f29..1891b1ed 100644 --- a/src/plugin_runtime/host/hook_dispatcher.py +++ b/src/plugin_runtime/host/hook_dispatcher.py @@ -26,6 +26,8 @@ import contextlib from src.common.logger import get_logger from src.config.config import global_config +from .hook_spec_registry import HookSpec, HookSpecRegistry + if TYPE_CHECKING: from .component_registry import HookHandlerEntry from .supervisor import PluginRunnerSupervisor @@ -33,29 +35,6 @@ if TYPE_CHECKING: logger = get_logger("plugin_runtime.host.hook_dispatcher") -@dataclass(slots=True) -class HookSpec: - """命名 Hook 的静态规格定义。 - - Attributes: - name: Hook 的唯一名称。 - description: Hook 描述。 - default_timeout_ms: 默认超时毫秒数;为 `0` 时退回系统默认值。 - allow_blocking: 是否允许注册阻塞处理器。 - allow_observe: 是否允许注册观察处理器。 - allow_abort: 是否允许处理器中止当前 Hook 调用。 - allow_kwargs_mutation: 是否允许阻塞处理器修改 `kwargs`。 - """ - - name: str - description: str = "" - default_timeout_ms: int = 0 - allow_blocking: bool = True - allow_observe: bool = True - allow_abort: bool = True - allow_kwargs_mutation: bool = True - - @dataclass(slots=True) class HookHandlerExecutionResult: """单个 HookHandler 的执行结果。 @@ -121,17 +100,19 @@ class HookDispatcher: def __init__( self, supervisors_provider: Optional[Callable[[], Sequence["PluginRunnerSupervisor"]]] = None, + hook_spec_registry: Optional[HookSpecRegistry] = None, ) -> None: """初始化 Hook 分发器。 Args: supervisors_provider: 可选的 Supervisor 提供器。若调用 `invoke_hook()` 时未显式传入 `supervisors`,则使用该回调获取目标 Supervisor 列表。 + hook_spec_registry: 可选的 Hook 规格注册中心;留空时使用独立注册中心。 """ self._background_tasks: Set[asyncio.Task[Any]] = set() - self._hook_specs: Dict[str, HookSpec] = {} self._supervisors_provider = supervisors_provider + self._hook_spec_registry = hook_spec_registry or HookSpecRegistry() async def stop(self) -> None: """停止分发器并取消所有未完成的观察任务。""" @@ -148,16 +129,7 @@ class HookDispatcher: spec: 需要注册的 Hook 规格。 """ - normalized_name = self._normalize_hook_name(spec.name) - self._hook_specs[normalized_name] = HookSpec( - name=normalized_name, - description=spec.description, - default_timeout_ms=max(int(spec.default_timeout_ms), 0), - allow_blocking=bool(spec.allow_blocking), - allow_observe=bool(spec.allow_observe), - allow_abort=bool(spec.allow_abort), - allow_kwargs_mutation=bool(spec.allow_kwargs_mutation), - ) + self._hook_spec_registry.register_hook_spec(spec) def register_hook_specs(self, specs: Sequence[HookSpec]) -> None: """批量注册命名 Hook 规格。 @@ -180,14 +152,37 @@ class HookDispatcher: """ normalized_name = self._normalize_hook_name(hook_name) - if normalized_name in self._hook_specs: - return self._hook_specs[normalized_name] + registered_spec = self._hook_spec_registry.get_hook_spec(normalized_name) + if registered_spec is not None: + return registered_spec return HookSpec( name=normalized_name, + parameters_schema={}, default_timeout_ms=self._get_default_timeout_ms(), ) + def unregister_hook_spec(self, hook_name: str) -> bool: + """注销指定命名 Hook 规格。 + + Args: + hook_name: 目标 Hook 名称。 + + Returns: + bool: 是否成功注销。 + """ + + return self._hook_spec_registry.unregister_hook_spec(hook_name) + + def list_hook_specs(self) -> List[HookSpec]: + """返回当前全部显式注册的 Hook 规格。 + + Returns: + List[HookSpec]: 已注册 Hook 规格列表。 + """ + + return self._hook_spec_registry.list_hook_specs() + async def invoke_hook( self, hook_name: str, diff --git a/src/plugin_runtime/host/hook_spec_registry.py b/src/plugin_runtime/host/hook_spec_registry.py new file mode 100644 index 00000000..29ade396 --- /dev/null +++ b/src/plugin_runtime/host/hook_spec_registry.py @@ -0,0 +1,190 @@ +"""命名 Hook 规格注册中心。""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence + + +@dataclass(slots=True) +class HookSpec: + """命名 Hook 的静态规格定义。 + + Attributes: + name: Hook 的唯一名称。 + description: Hook 描述。 + parameters_schema: Hook 参数模型,使用对象级 JSON Schema 表示。 + default_timeout_ms: 默认超时毫秒数;为 ``0`` 时退回系统默认值。 + allow_blocking: 是否允许注册阻塞处理器。 + allow_observe: 是否允许注册观察处理器。 + allow_abort: 是否允许处理器中止当前 Hook 调用。 + allow_kwargs_mutation: 是否允许阻塞处理器修改 ``kwargs``。 + """ + + name: str + description: str = "" + parameters_schema: Dict[str, Any] = field(default_factory=dict) + default_timeout_ms: int = 0 + allow_blocking: bool = True + allow_observe: bool = True + allow_abort: bool = True + allow_kwargs_mutation: bool = True + + +class HookSpecRegistry: + """命名 Hook 规格注册中心。""" + + def __init__(self) -> None: + """初始化 Hook 规格注册中心。""" + + self._hook_specs: Dict[str, HookSpec] = {} + + @staticmethod + def _normalize_hook_name(hook_name: str) -> str: + """规范化 Hook 名称。 + + Args: + hook_name: 原始 Hook 名称。 + + Returns: + str: 规范化后的 Hook 名称。 + + Raises: + ValueError: Hook 名称为空时抛出。 + """ + + normalized_name = str(hook_name or "").strip() + if not normalized_name: + raise ValueError("Hook 名称不能为空") + return normalized_name + + @staticmethod + def _normalize_parameters_schema(raw_schema: Any) -> Dict[str, Any]: + """规范化 Hook 参数模型。 + + Args: + raw_schema: 原始参数模型。 + + Returns: + Dict[str, Any]: 规范化后的对象级 JSON Schema。 + + Raises: + ValueError: 参数模型不是合法对象级 Schema 时抛出。 + """ + + if raw_schema is None: + return {} + if not isinstance(raw_schema, dict): + raise ValueError("Hook 参数模型必须是字典") + if not raw_schema: + return {} + + normalized_schema = deepcopy(raw_schema) + schema_type = normalized_schema.get("type") + properties = normalized_schema.get("properties") + if schema_type not in {"", None, "object"} and properties is None: + raise ValueError("Hook 参数模型必须是 object 类型或属性映射") + if schema_type in {"", None} and properties is None: + normalized_schema = { + "type": "object", + "properties": normalized_schema, + } + elif schema_type in {"", None}: + normalized_schema["type"] = "object" + + if normalized_schema.get("type") != "object": + raise ValueError("Hook 参数模型必须是 object 类型") + return normalized_schema + + @classmethod + def _normalize_spec(cls, spec: HookSpec) -> HookSpec: + """规范化 Hook 规格对象。 + + Args: + spec: 原始 Hook 规格。 + + Returns: + HookSpec: 规范化后的 Hook 规格副本。 + """ + + return HookSpec( + name=cls._normalize_hook_name(spec.name), + description=str(spec.description or "").strip(), + parameters_schema=cls._normalize_parameters_schema(spec.parameters_schema), + default_timeout_ms=max(int(spec.default_timeout_ms), 0), + allow_blocking=bool(spec.allow_blocking), + allow_observe=bool(spec.allow_observe), + allow_abort=bool(spec.allow_abort), + allow_kwargs_mutation=bool(spec.allow_kwargs_mutation), + ) + + def clear(self) -> None: + """清空全部 Hook 规格。""" + + self._hook_specs.clear() + + def register_hook_spec(self, spec: HookSpec) -> HookSpec: + """注册单个 Hook 规格。 + + Args: + spec: 需要注册的 Hook 规格。 + + Returns: + HookSpec: 规范化后实际注册的 Hook 规格。 + """ + + normalized_spec = self._normalize_spec(spec) + self._hook_specs[normalized_spec.name] = normalized_spec + return normalized_spec + + def register_hook_specs(self, specs: Sequence[HookSpec]) -> List[HookSpec]: + """批量注册 Hook 规格。 + + Args: + specs: 需要注册的 Hook 规格列表。 + + Returns: + List[HookSpec]: 规范化后实际注册的 Hook 规格列表。 + """ + + return [self.register_hook_spec(spec) for spec in specs] + + def unregister_hook_spec(self, hook_name: str) -> bool: + """注销指定 Hook 规格。 + + Args: + hook_name: 目标 Hook 名称。 + + Returns: + bool: 是否成功删除。 + """ + + normalized_name = self._normalize_hook_name(hook_name) + return self._hook_specs.pop(normalized_name, None) is not None + + def get_hook_spec(self, hook_name: str) -> Optional[HookSpec]: + """获取指定 Hook 的显式规格。 + + Args: + hook_name: 目标 Hook 名称。 + + Returns: + Optional[HookSpec]: 已注册时返回规格副本,否则返回 ``None``。 + """ + + normalized_name = self._normalize_hook_name(hook_name) + spec = self._hook_specs.get(normalized_name) + return None if spec is None else self._normalize_spec(spec) + + def list_hook_specs(self) -> List[HookSpec]: + """返回当前全部 Hook 规格。 + + Returns: + List[HookSpec]: 按 Hook 名称升序排列的规格副本列表。 + """ + + return [ + self._normalize_spec(spec) + for _, spec in sorted(self._hook_specs.items(), key=lambda item: item[0]) + ] diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py index 7a023167..bc0fca85 100644 --- a/src/plugin_runtime/host/supervisor.py +++ b/src/plugin_runtime/host/supervisor.py @@ -27,6 +27,8 @@ from src.plugin_runtime.protocol.envelope import ( ConfigUpdatedPayload, Envelope, HealthPayload, + InspectPluginConfigPayload, + InspectPluginConfigResultPayload, MessageGatewayStateUpdatePayload, MessageGatewayStateUpdateResultPayload, PROTOCOL_VERSION, @@ -52,6 +54,7 @@ from .capability_service import CapabilityService from .component_registry import ComponentRegistry from .event_dispatcher import EventDispatcher from .hook_dispatcher import HookDispatchResult, HookDispatcher +from .hook_spec_registry import HookSpecRegistry from .logger_bridge import RunnerLogBridge from .message_gateway import MessageGateway from .rpc_server import RPCServer @@ -84,6 +87,7 @@ class PluginRunnerSupervisor: self, plugin_dirs: Optional[List[Path]] = None, group_name: str = "third_party", + hook_spec_registry: Optional[HookSpecRegistry] = None, socket_path: Optional[str] = None, health_check_interval_sec: Optional[float] = None, max_restart_attempts: Optional[int] = None, @@ -94,6 +98,7 @@ class PluginRunnerSupervisor: Args: plugin_dirs: 由当前 Runner 负责加载的插件目录列表。 group_name: 当前 Supervisor 所属运行时分组名称。 + hook_spec_registry: 可选的共享 Hook 规格注册中心。 socket_path: 自定义 IPC 地址;留空时由传输层自动生成。 health_check_interval_sec: 健康检查间隔,单位秒。 max_restart_attempts: 自动重启 Runner 的最大次数。 @@ -110,9 +115,12 @@ class PluginRunnerSupervisor: self._authorization = AuthorizationManager() self._capability_service = CapabilityService(self._authorization) self._api_registry = APIRegistry() - self._component_registry = ComponentRegistry() + self._component_registry = ComponentRegistry(hook_spec_registry=hook_spec_registry) self._event_dispatcher = EventDispatcher(self._component_registry) - self._hook_dispatcher = HookDispatcher(lambda: [self]) + self._hook_dispatcher = HookDispatcher( + lambda: [self], + hook_spec_registry=hook_spec_registry, + ) self._message_gateway = MessageGateway(self._component_registry) self._log_bridge = RunnerLogBridge() @@ -581,6 +589,49 @@ class PluginRunnerSupervisor: raise ValueError("插件配置校验失败") return dict(result.normalized_config) + async def inspect_plugin_config( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + *, + use_provided_config: bool = False, + ) -> InspectPluginConfigResultPayload: + """请求 Runner 解析插件配置元数据。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 可选的配置内容。 + use_provided_config: 是否优先使用传入配置而不是磁盘配置。 + + Returns: + InspectPluginConfigResultPayload: 插件配置解析结果。 + + Raises: + ValueError: Runner 无法解析插件或返回了错误响应时抛出。 + """ + + payload = InspectPluginConfigPayload( + config_data=config_data or {}, + use_provided_config=use_provided_config, + ) + try: + response = await self._rpc_server.send_request( + "plugin.inspect_config", + plugin_id=plugin_id, + payload=payload.model_dump(), + timeout_ms=10000, + ) + except Exception as exc: + raise ValueError(f"插件配置解析请求失败: {exc}") from exc + + if response.error: + raise ValueError(str(response.error.get("message", "插件配置解析失败"))) + + result = InspectPluginConfigResultPayload.model_validate(response.payload) + if not result.success: + raise ValueError("插件配置解析失败") + return result + def get_config_reload_subscribers(self, scope: str) -> List[str]: """返回订阅指定全局配置广播的插件列表。 @@ -713,15 +764,25 @@ class PluginRunnerSupervisor: component_declarations = [component.model_dump() for component in payload.components] runtime_components, api_components = self._split_component_declarations(component_declarations) - self._component_registry.remove_components_by_plugin(payload.plugin_id) - self._api_registry.remove_apis_by_plugin(payload.plugin_id) - await self._unregister_all_message_gateway_drivers_for_plugin(payload.plugin_id) + try: + registered_count = self._component_registry.register_plugin_components( + payload.plugin_id, + runtime_components, + ) + except Exception as exc: + logger.error(f"插件 {payload.plugin_id} 组件注册失败: {exc}") + return envelope.make_error_response( + ErrorCode.E_BAD_PAYLOAD.value, + str(exc), + details={ + "plugin_id": payload.plugin_id, + "component_count": len(runtime_components), + }, + ) - registered_count = self._component_registry.register_plugin_components( - payload.plugin_id, - runtime_components, - ) + self._api_registry.remove_apis_by_plugin(payload.plugin_id) registered_api_count = self._api_registry.register_plugin_apis(payload.plugin_id, api_components) + await self._unregister_all_message_gateway_drivers_for_plugin(payload.plugin_id) self._registered_plugins[payload.plugin_id] = payload self._message_gateway_states[payload.plugin_id] = {} diff --git a/src/plugin_runtime/integration.py b/src/plugin_runtime/integration.py index deecaba8..39d899c3 100644 --- a/src/plugin_runtime/integration.py +++ b/src/plugin_runtime/integration.py @@ -25,6 +25,7 @@ from typing import ( ) import asyncio +import inspect import tomlkit @@ -32,14 +33,17 @@ from src.common.logger import get_logger from src.config.config import config_manager from src.config.file_watcher import FileChange, FileWatcher from src.platform_io import DeliveryBatch, InboundMessageEnvelope, get_platform_io_manager +from src.plugin_runtime.hook_catalog import register_builtin_hook_specs from src.plugin_runtime.capabilities import ( RuntimeComponentCapabilityMixin, RuntimeCoreCapabilityMixin, RuntimeDataCapabilityMixin, ) from src.plugin_runtime.capabilities.registry import register_capability_impls -from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult, HookDispatcher, HookSpec +from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult, HookDispatcher +from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry from src.plugin_runtime.host.message_utils import MessageDict, PluginMessageUtils +from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload from src.plugin_runtime.runner.manifest_validator import ManifestValidator if TYPE_CHECKING: @@ -87,7 +91,12 @@ class PluginRuntimeManager( self._manifest_validator: ManifestValidator = ManifestValidator() self._config_reload_callback: Callable[[Sequence[str]], Awaitable[None]] = self._handle_main_config_reload self._config_reload_callback_registered: bool = False - self._hook_dispatcher: HookDispatcher = HookDispatcher(lambda: self.supervisors) + self._hook_spec_registry: HookSpecRegistry = HookSpecRegistry() + self._builtin_hook_specs_registered: bool = False + self._hook_dispatcher: HookDispatcher = HookDispatcher( + lambda: self.supervisors, + hook_spec_registry=self._hook_spec_registry, + ) async def _dispatch_platform_inbound(self, envelope: InboundMessageEnvelope) -> None: """接收 Platform IO 审核后的入站消息并送入主消息链。 @@ -155,6 +164,33 @@ class PluginRuntimeManager( return ["third_party", "builtin"] return ["builtin", "third_party"] + @staticmethod + def _instantiate_supervisor(supervisor_cls: Any, **kwargs: Any) -> Any: + """兼容不同构造签名地实例化 Supervisor。 + + Args: + supervisor_cls: 目标 Supervisor 类。 + **kwargs: 期望传入的构造参数。 + + Returns: + Any: 实例化后的 Supervisor。 + """ + + signature = inspect.signature(supervisor_cls) + accepts_var_keyword = any( + parameter.kind == inspect.Parameter.VAR_KEYWORD + for parameter in signature.parameters.values() + ) + if accepts_var_keyword: + return supervisor_cls(**kwargs) + + supported_kwargs = { + key: value + for key, value in kwargs.items() + if key in signature.parameters + } + return supervisor_cls(**supported_kwargs) + # ─── 生命周期 ───────────────────────────────────────────── async def start(self) -> None: @@ -185,6 +221,7 @@ class PluginRuntimeManager( logger.info("未找到任何插件目录,跳过插件运行时启动") return + self.ensure_builtin_hook_specs_registered() platform_io_manager = get_platform_io_manager() # 从配置读取自定义 IPC socket 路径(留空则自动生成) @@ -196,17 +233,21 @@ class PluginRuntimeManager( # 创建两个 Supervisor,各自拥有独立的 socket / Runner 子进程 if builtin_dirs: - self._builtin_supervisor = PluginSupervisor( + self._builtin_supervisor = self._instantiate_supervisor( + PluginSupervisor, plugin_dirs=builtin_dirs, group_name="builtin", + hook_spec_registry=self._hook_spec_registry, socket_path=builtin_socket, ) self._register_capability_impls(self._builtin_supervisor) if third_party_dirs: - self._third_party_supervisor = PluginSupervisor( + self._third_party_supervisor = self._instantiate_supervisor( + PluginSupervisor, plugin_dirs=third_party_dirs, group_name="third_party", + hook_spec_registry=self._hook_spec_registry, socket_path=third_party_socket, ) self._register_capability_impls(self._third_party_supervisor) @@ -328,6 +369,7 @@ class PluginRuntimeManager( spec: 需要注册的 Hook 规格。 """ + self.ensure_builtin_hook_specs_registered() self._hook_dispatcher.register_hook_spec(spec) def register_hook_specs(self, specs: Sequence[HookSpec]) -> None: @@ -337,8 +379,41 @@ class PluginRuntimeManager( specs: 需要注册的 Hook 规格序列。 """ + self.ensure_builtin_hook_specs_registered() self._hook_dispatcher.register_hook_specs(specs) + def unregister_hook_spec(self, hook_name: str) -> bool: + """注销指定命名 Hook 规格。 + + Args: + hook_name: 目标 Hook 名称。 + + Returns: + bool: 是否成功注销。 + """ + + self.ensure_builtin_hook_specs_registered() + return self._hook_dispatcher.unregister_hook_spec(hook_name) + + def list_hook_specs(self) -> List[HookSpec]: + """返回当前全部命名 Hook 规格。 + + Returns: + List[HookSpec]: 当前已注册的 Hook 规格列表。 + """ + + self.ensure_builtin_hook_specs_registered() + return self._hook_dispatcher.list_hook_specs() + + def ensure_builtin_hook_specs_registered(self) -> None: + """确保内置 Hook 规格已经注册到共享中心表。""" + + if self._builtin_hook_specs_registered: + return + + register_builtin_hook_specs(self._hook_spec_registry) + self._builtin_hook_specs_registered = True + def _build_registered_dependency_map(self) -> Dict[str, Set[str]]: """根据当前已注册插件构建全局依赖图。""" @@ -542,8 +617,8 @@ class PluginRuntimeManager( config_data: 待校验的配置内容。 Returns: - Dict[str, Any] | None: 校验成功时返回规范化后的配置;若插件当前未加载 - 或运行时不可用,则返回 ``None`` 以便调用方回退到静态 Schema 方案。 + Dict[str, Any] | None: 校验成功时返回规范化后的配置;若插件不存在、 + 当前不可路由或运行时不可用,则返回 ``None`` 以便调用方回退到弱推断方案。 Raises: ValueError: 插件已加载,但配置校验失败时抛出。 @@ -558,6 +633,8 @@ class PluginRuntimeManager( logger.warning(f"插件 {plugin_id} 配置校验路由失败,将回退到静态 Schema: {exc}") return None + if supervisor is None: + supervisor = self._find_supervisor_by_plugin_directory(plugin_id) if supervisor is None: return None @@ -569,6 +646,54 @@ class PluginRuntimeManager( logger.warning(f"插件 {plugin_id} 运行时配置校验不可用,将回退到静态 Schema: {exc}") return None + async def inspect_plugin_config( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + *, + use_provided_config: bool = False, + ) -> InspectPluginConfigResultPayload | None: + """请求运行时解析插件配置元数据。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 可选的配置内容。 + use_provided_config: 是否优先使用传入的配置内容而不是磁盘配置。 + + Returns: + InspectPluginConfigResultPayload | None: 解析成功时返回结构化结果;若插件 + 当前不可路由或运行时不可用,则返回 ``None``。 + + Raises: + ValueError: 插件存在,但运行时明确拒绝解析请求时抛出。 + """ + + if not self._started: + return None + + try: + supervisor = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + logger.warning(f"插件 {plugin_id} 配置解析路由失败: {exc}") + return None + + if supervisor is None: + supervisor = self._find_supervisor_by_plugin_directory(plugin_id) + if supervisor is None: + return None + + try: + return await supervisor.inspect_plugin_config( + plugin_id=plugin_id, + config_data=config_data, + use_provided_config=use_provided_config, + ) + except ValueError: + raise + except Exception as exc: + logger.warning(f"插件 {plugin_id} 配置解析不可用: {exc}") + return None + @staticmethod def _normalize_config_reload_scopes(changed_scopes: Sequence[str]) -> tuple[str, ...]: """规范化配置热重载范围列表。 @@ -771,7 +896,15 @@ class PluginRuntimeManager( return matches[0] if matches else None async def load_plugin_globally(self, plugin_id: str, reason: str = "manual") -> bool: - """加载或重载单个插件,并为其补齐跨 Supervisor 外部依赖。""" + """加载或重载单个插件,并为其补齐跨 Supervisor 外部依赖。 + + Args: + plugin_id: 目标插件 ID。 + reason: 加载或重载原因。 + + Returns: + bool: 插件最终是否处于已加载状态。 + """ normalized_plugin_id = str(plugin_id or "").strip() if not normalized_plugin_id: @@ -789,11 +922,12 @@ class PluginRuntimeManager( if supervisor is None: return False - return await supervisor.reload_plugins( + reloaded = await supervisor.reload_plugins( plugin_ids=[normalized_plugin_id], reason=reason, external_available_plugins=self._build_external_available_plugins_for_supervisor(supervisor), ) + return reloaded and normalized_plugin_id in supervisor.get_loaded_plugin_ids() @classmethod def _find_duplicate_plugin_ids(cls, plugin_dirs: List[Path]) -> Dict[str, List[Path]]: @@ -920,15 +1054,16 @@ class PluginRuntimeManager( return None def _refresh_plugin_config_watch_subscriptions(self) -> None: - """按当前已注册插件集合刷新 config.toml 的单插件订阅。 + """按当前可识别插件集合刷新 config.toml 的单插件订阅。 当插件热重载后,插件集合或目录位置可能发生变化,因此需要重新对齐 watcher 的订阅,确保每个插件配置变更只触发对应 plugin_id。 + 这里不仅覆盖当前已注册插件,也覆盖已存在但暂未激活的合法插件。 """ if self._plugin_file_watcher is None: return - desired_plugin_paths = dict(self._iter_registered_plugin_paths()) + desired_plugin_paths = dict(self._iter_watchable_plugin_paths()) self._plugin_path_cache = desired_plugin_paths.copy() desired_config_paths = { plugin_id: plugin_path / "config.toml" for plugin_id, plugin_path in desired_plugin_paths.items() @@ -970,6 +1105,18 @@ class PluginRuntimeManager( if plugin_path := self._get_plugin_path_for_supervisor(supervisor, plugin_id): yield plugin_id, plugin_path + def _iter_watchable_plugin_paths(self) -> Iterable[Tuple[str, Path]]: + """迭代应被配置监听器追踪的插件目录。 + + Returns: + Iterable[Tuple[str, Path]]: ``(plugin_id, plugin_path)`` 迭代器。 + """ + + watchable_plugin_paths = dict(self._iter_discovered_plugin_paths(self._iter_plugin_dirs())) + for plugin_id, plugin_path in self._iter_registered_plugin_paths(): + watchable_plugin_paths.setdefault(plugin_id, plugin_path) + yield from watchable_plugin_paths.items() + def _get_plugin_config_path_for_supervisor(self, supervisor: Any, plugin_id: str) -> Optional[Path]: """从指定 Supervisor 的插件目录中定位某个插件的 config.toml。""" plugin_path = self._get_plugin_path_for_supervisor(supervisor, plugin_id) @@ -993,18 +1140,43 @@ class PluginRuntimeManager( return if supervisor is None: + supervisor = self._find_supervisor_by_plugin_directory(plugin_id) + if supervisor is None: + return + + plugin_is_loaded = plugin_id in getattr(supervisor, "_registered_plugins", {}) + + try: + snapshot = await supervisor.inspect_plugin_config(plugin_id) + except Exception as exc: + logger.warning(f"插件 {plugin_id} 配置文件变更解析失败: {exc}") return try: - config_payload = self._load_plugin_config_for_supervisor(supervisor, plugin_id) - delivered = await supervisor.notify_plugin_config_updated( - plugin_id=plugin_id, - config_data=config_payload, - config_version="", - config_scope="self", - ) - if not delivered: - logger.warning(f"插件 {plugin_id} 配置文件变更后通知失败") + if plugin_is_loaded and snapshot.enabled: + delivered = await supervisor.notify_plugin_config_updated( + plugin_id=plugin_id, + config_data=dict(snapshot.normalized_config), + config_version="", + config_scope="self", + ) + if not delivered: + logger.warning(f"插件 {plugin_id} 配置文件变更后通知失败") + return + + if plugin_is_loaded and not snapshot.enabled: + reloaded = await self.reload_plugins_globally([plugin_id], reason="config_disabled") + if not reloaded: + logger.warning(f"插件 {plugin_id} 禁用配置已写入,但运行时卸载失败") + return + + if not snapshot.enabled: + logger.info(f"插件 {plugin_id} 当前处于禁用状态,跳过自动加载") + return + + loaded = await self.load_plugin_globally(plugin_id, reason="config_enabled") + if not loaded: + logger.warning(f"插件 {plugin_id} 配置文件变更后自动加载失败") except Exception as exc: logger.warning(f"插件 {plugin_id} 配置文件变更处理失败: {exc}") diff --git a/src/plugin_runtime/protocol/envelope.py b/src/plugin_runtime/protocol/envelope.py index 88c5c7df..e4bebba9 100644 --- a/src/plugin_runtime/protocol/envelope.py +++ b/src/plugin_runtime/protocol/envelope.py @@ -288,6 +288,8 @@ class RunnerReadyPayload(BaseModel): """已完成初始化的插件列表""" failed_plugins: List[str] = Field(default_factory=list, description="初始化失败的插件列表") """初始化失败的插件列表""" + inactive_plugins: List[str] = Field(default_factory=list, description="当前因禁用或依赖不可用而未激活的插件列表") + """当前因禁用或依赖不可用而未激活的插件列表""" # ====== 配置更新 ====== @@ -311,6 +313,32 @@ class ValidatePluginConfigPayload(BaseModel): """待校验的配置内容""" +class InspectPluginConfigPayload(BaseModel): + """plugin.inspect_config 请求 payload。""" + + config_data: Dict[str, Any] = Field(default_factory=dict, description="可选的配置内容") + """可选的配置内容""" + use_provided_config: bool = Field(default=False, description="是否优先使用请求中携带的配置内容") + """是否优先使用请求中携带的配置内容""" + + +class InspectPluginConfigResultPayload(BaseModel): + """plugin.inspect_config 响应 payload。""" + + success: bool = Field(description="是否解析成功") + """是否解析成功""" + default_config: Dict[str, Any] = Field(default_factory=dict, description="插件默认配置") + """插件默认配置""" + config_schema: Dict[str, Any] = Field(default_factory=dict, description="插件配置 Schema") + """插件配置 Schema""" + normalized_config: Dict[str, Any] = Field(default_factory=dict, description="归一化后的配置内容") + """归一化后的配置内容""" + changed: bool = Field(default=False, description="是否在归一化过程中自动补齐或修正了配置") + """是否在归一化过程中自动补齐或修正了配置""" + enabled: bool = Field(default=True, description="插件在当前配置下是否应被视为启用") + """插件在当前配置下是否应被视为启用""" + + class ValidatePluginConfigResultPayload(BaseModel): """plugin.validate_config 响应 payload。""" @@ -380,6 +408,8 @@ class ReloadPluginResultPayload(BaseModel): """成功完成重载的插件列表""" unloaded_plugins: List[str] = Field(default_factory=list, description="本次已卸载的插件列表") """本次已卸载的插件列表""" + inactive_plugins: List[str] = Field(default_factory=list, description="本次处于未激活状态的插件列表") + """本次处于未激活状态的插件列表""" failed_plugins: Dict[str, str] = Field(default_factory=dict, description="重载失败的插件及原因") """重载失败的插件及原因""" @@ -395,6 +425,8 @@ class ReloadPluginsResultPayload(BaseModel): """成功完成重载的插件列表""" unloaded_plugins: List[str] = Field(default_factory=list, description="本次已卸载的插件列表") """本次已卸载的插件列表""" + inactive_plugins: List[str] = Field(default_factory=list, description="本次处于未激活状态的插件列表") + """本次处于未激活状态的插件列表""" failed_plugins: Dict[str, str] = Field(default_factory=dict, description="重载失败的插件及原因") """重载失败的插件及原因""" diff --git a/src/plugin_runtime/runner/runner_main.py b/src/plugin_runtime/runner/runner_main.py index 55c53d8d..cc910bd8 100644 --- a/src/plugin_runtime/runner/runner_main.py +++ b/src/plugin_runtime/runner/runner_main.py @@ -9,8 +9,10 @@ 6. 转发插件的能力调用到 Host """ +from collections.abc import Mapping +from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Set, Tuple, cast +from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast import asyncio import contextlib @@ -39,6 +41,8 @@ from src.plugin_runtime.protocol.envelope import ( ConfigUpdatedPayload, Envelope, HealthPayload, + InspectPluginConfigPayload, + InspectPluginConfigResultPayload, InvokePayload, InvokeResultPayload, RegisterPluginPayload, @@ -141,6 +145,14 @@ class _ConfigAwarePlugin(Protocol): ... +class PluginActivationStatus(str, Enum): + """描述插件激活结果。""" + + LOADED = "loaded" + INACTIVE = "inactive" + FAILED = "failed" + + def _install_shutdown_signal_handlers( mark_runner_shutting_down: Callable[[], None], loop: Optional[asyncio.AbstractEventLoop] = None, @@ -236,13 +248,43 @@ class PluginRunner: # 4. 注入 PluginContext + 调用 on_load 生命周期钩子 failed_plugins: Set[str] = set(self._loader.failed_plugins.keys()) + inactive_plugins: Set[str] = set() + available_plugin_versions: Dict[str, str] = dict(self._external_available_plugins) for meta in plugins: - ok = await self._activate_plugin(meta) - if not ok: + unsatisfied_dependencies = [ + dependency.id + for dependency in meta.manifest.plugin_dependencies + if dependency.id not in available_plugin_versions + or not self._loader.manifest_validator.is_plugin_dependency_satisfied( + dependency, + available_plugin_versions[dependency.id], + ) + ] + if unsatisfied_dependencies: + if any(dependency_id in inactive_plugins for dependency_id in unsatisfied_dependencies): + logger.info( + f"插件 {meta.plugin_id} 依赖的插件当前未激活,跳过本次启动: {', '.join(unsatisfied_dependencies)}" + ) + inactive_plugins.add(meta.plugin_id) + continue failed_plugins.add(meta.plugin_id) + continue - successful_plugins = [meta.plugin_id for meta in plugins if meta.plugin_id not in failed_plugins] - await self._notify_ready(successful_plugins, sorted(failed_plugins)) + activation_status = await self._activate_plugin(meta) + if activation_status == PluginActivationStatus.LOADED: + available_plugin_versions[meta.plugin_id] = meta.version + continue + if activation_status == PluginActivationStatus.INACTIVE: + inactive_plugins.add(meta.plugin_id) + continue + failed_plugins.add(meta.plugin_id) + + successful_plugins = [ + meta.plugin_id + for meta in plugins + if meta.plugin_id not in failed_plugins and meta.plugin_id not in inactive_plugins + ] + await self._notify_ready(successful_plugins, sorted(failed_plugins), sorted(inactive_plugins)) # 5. 等待直到收到关停信号 with contextlib.suppress(asyncio.CancelledError): @@ -352,17 +394,17 @@ class PluginRunner: cast(_ContextAwarePlugin, instance)._set_context(ctx) logger.debug(f"已为插件 {plugin_id} 注入 PluginContext") - def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> None: + def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """在 Runner 侧为插件实例注入当前插件配置。 Args: meta: 插件元数据。 config_data: 可选的配置数据;留空时自动从插件目录读取。 + + Returns: + Dict[str, Any]: 归一化后的当前插件配置。 """ instance = meta.instance - if not hasattr(instance, "set_plugin_config"): - return - raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir) plugin_config, should_persist = self._normalize_plugin_config(instance, raw_config) config_path = Path(meta.plugin_dir) / "config.toml" @@ -370,10 +412,12 @@ class PluginRunner: should_initialize_file = not config_path.exists() and bool(default_config) if should_persist or should_initialize_file: self._save_plugin_config(meta.plugin_dir, plugin_config) - try: - cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config) - except Exception as exc: - logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}") + if hasattr(instance, "set_plugin_config"): + try: + cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config) + except Exception as exc: + logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}") + return plugin_config def _normalize_plugin_config( self, @@ -405,6 +449,33 @@ class PluginRunner: logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}") return normalized_config, False + @staticmethod + def _is_plugin_enabled(config_data: Optional[Mapping[str, Any]]) -> bool: + """根据配置内容判断插件是否应被视为启用。 + + Args: + config_data: 当前插件配置。 + + Returns: + bool: 插件是否启用。 + """ + + if not isinstance(config_data, Mapping): + return True + + plugin_section = config_data.get("plugin") + if not isinstance(plugin_section, Mapping): + return True + + enabled_value = plugin_section.get("enabled", True) + if isinstance(enabled_value, str): + normalized_value = enabled_value.strip().lower() + if normalized_value in {"0", "false", "no", "off"}: + return False + if normalized_value in {"1", "true", "yes", "on"}: + return True + return bool(enabled_value) + @staticmethod def _save_plugin_config(plugin_dir: str, config_data: Dict[str, Any]) -> None: """将插件配置写回到 ``config.toml``。 @@ -435,6 +506,99 @@ class PluginRunner: return loaded if isinstance(loaded, dict) else {} + def _resolve_plugin_candidate(self, plugin_id: str) -> Tuple[Optional[PluginCandidate], Optional[str]]: + """解析指定插件的候选目录。 + + Args: + plugin_id: 目标插件 ID。 + + Returns: + Tuple[Optional[PluginCandidate], Optional[str]]: 候选插件与错误信息。 + """ + + candidates, duplicate_candidates = self._loader.discover_candidates(self._plugin_dirs) + if plugin_id in duplicate_candidates: + conflict_paths = ", ".join(str(path) for path in duplicate_candidates[plugin_id]) + return None, f"检测到重复插件 ID: {conflict_paths}" + + candidate = candidates.get(plugin_id) + if candidate is None: + return None, f"未找到插件: {plugin_id}" + return candidate, None + + def _resolve_plugin_meta_for_config_request( + self, + plugin_id: str, + ) -> Tuple[Optional[PluginMeta], bool, Optional[str]]: + """为配置相关请求解析插件元数据。 + + Args: + plugin_id: 目标插件 ID。 + + Returns: + Tuple[Optional[PluginMeta], bool, Optional[str]]: 依次为插件元数据、 + 是否为临时冷加载实例、以及错误信息。 + """ + + loaded_meta = self._loader.get_plugin(plugin_id) + if loaded_meta is not None: + return loaded_meta, False, None + + candidate, error_message = self._resolve_plugin_candidate(plugin_id) + if candidate is None: + return None, False, error_message + + try: + meta = self._loader.load_candidate(plugin_id, candidate) + except Exception as exc: + return None, False, str(exc) + if meta is None: + return None, False, "插件模块加载失败" + return meta, True, None + + def _inspect_plugin_config( + self, + meta: PluginMeta, + *, + config_data: Optional[Dict[str, Any]] = None, + use_provided_config: bool = False, + suppress_errors: bool = True, + ) -> InspectPluginConfigResultPayload: + """解析插件代码定义的配置元数据。 + + Args: + meta: 插件元数据。 + config_data: 可选的配置内容。 + use_provided_config: 是否优先使用传入的配置内容。 + suppress_errors: 是否在归一化失败时回退原始配置。 + + Returns: + InspectPluginConfigResultPayload: 结构化解析结果。 + """ + + raw_config = config_data if use_provided_config else self._load_plugin_config(meta.plugin_dir) + if use_provided_config and config_data is None: + raw_config = {} + + normalized_config, changed = self._normalize_plugin_config( + meta.instance, + raw_config, + suppress_errors=suppress_errors, + ) + default_config = self._get_plugin_default_config(meta.instance) + if not normalized_config and not raw_config and default_config: + normalized_config = dict(default_config) + changed = True + + return InspectPluginConfigResultPayload( + success=True, + default_config=default_config, + config_schema=self._get_plugin_config_schema(meta), + normalized_config=normalized_config, + changed=changed, + enabled=self._is_plugin_enabled(normalized_config), + ) + def _register_handlers(self) -> None: """注册 Host -> Runner 的方法处理器。""" self._rpc_client.register_method("plugin.invoke_command", self._handle_invoke) @@ -448,6 +612,7 @@ class PluginRunner: self._rpc_client.register_method("plugin.prepare_shutdown", self._handle_prepare_shutdown) self._rpc_client.register_method("plugin.shutdown", self._handle_shutdown) self._rpc_client.register_method("plugin.config_updated", self._handle_config_updated) + self._rpc_client.register_method("plugin.inspect_config", self._handle_inspect_plugin_config) self._rpc_client.register_method("plugin.validate_config", self._handle_validate_plugin_config) self._rpc_client.register_method("plugin.reload", self._handle_reload_plugin) self._rpc_client.register_method("plugin.reload_batch", self._handle_reload_plugins) @@ -579,6 +744,9 @@ class PluginRunner: ) if response.error: raise RuntimeError(response.error.get("message", "插件注册失败")) + response_payload = response.payload if isinstance(response.payload, dict) else {} + if not bool(response_payload.get("accepted", True)): + raise RuntimeError(str(response_payload.get("reason", "插件注册失败"))) logger.info(f"插件 {meta.plugin_id} 注册完成") return True except Exception as e: @@ -689,36 +857,40 @@ class PluginRunner: except Exception as exc: logger.error(f"插件 {meta.plugin_id} on_unload 失败: {exc}", exc_info=True) - async def _activate_plugin(self, meta: PluginMeta) -> bool: + async def _activate_plugin(self, meta: PluginMeta) -> PluginActivationStatus: """完成插件注入、授权、生命周期和组件注册。 Args: meta: 待激活的插件元数据。 Returns: - bool: 是否激活成功。 + PluginActivationStatus: 插件激活结果。 """ self._inject_context(meta.plugin_id, meta.instance) - self._apply_plugin_config(meta) + plugin_config = self._apply_plugin_config(meta) + if not self._is_plugin_enabled(plugin_config): + logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活") + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.INACTIVE if not await self._bootstrap_plugin(meta): self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) - return False + return PluginActivationStatus.FAILED if not await self._register_plugin(meta): await self._invoke_plugin_on_unload(meta) await self._deactivate_plugin(meta) self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) - return False + return PluginActivationStatus.FAILED if not await self._invoke_plugin_on_load(meta): await self._unregister_plugin(meta.plugin_id, reason="on_load_failed") await self._deactivate_plugin(meta) self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) - return False + return PluginActivationStatus.FAILED self._loader.set_loaded_plugin(meta) - return True + return PluginActivationStatus.LOADED async def _unload_plugin(self, meta: PluginMeta, reason: str, *, purge_modules: bool = True) -> None: """卸载单个插件并清理 Host/Runner 两侧状态。 @@ -879,6 +1051,7 @@ class PluginRunner: requested_plugin_id=plugin_id, reloaded_plugins=batch_result.reloaded_plugins, unloaded_plugins=batch_result.unloaded_plugins, + inactive_plugins=batch_result.inactive_plugins, failed_plugins=batch_result.failed_plugins, ) @@ -973,6 +1146,8 @@ class PluginRunner: }, } reloaded_plugins: List[str] = [] + inactive_plugins: List[str] = [] + inactive_plugin_ids: Set[str] = set() for load_plugin_id in load_order: if load_plugin_id in failed_plugins: @@ -983,10 +1158,28 @@ class PluginRunner: continue _, manifest, _ = candidate + unsatisfied_dependency_ids = [ + dependency.id + for dependency in manifest.plugin_dependencies + if dependency.id not in available_plugins + or not self._loader.manifest_validator.is_plugin_dependency_satisfied( + dependency, + available_plugins[dependency.id], + ) + ] if unsatisfied_dependencies := self._loader.manifest_validator.get_unsatisfied_plugin_dependencies( manifest, available_plugin_versions=available_plugins, ): + if load_plugin_id not in reload_root_ids and any( + dependency_id in inactive_plugin_ids for dependency_id in unsatisfied_dependency_ids + ): + logger.info( + f"插件 {load_plugin_id} 的依赖当前未激活,保留为未激活状态: {', '.join(unsatisfied_dependencies)}" + ) + inactive_plugin_ids.add(load_plugin_id) + inactive_plugins.append(load_plugin_id) + continue failed_plugins[load_plugin_id] = f"依赖未满足: {', '.join(unsatisfied_dependencies)}" continue @@ -996,9 +1189,13 @@ class PluginRunner: continue activated = await self._activate_plugin(meta) - if not activated: + if activated == PluginActivationStatus.FAILED: failed_plugins[load_plugin_id] = "插件初始化失败" continue + if activated == PluginActivationStatus.INACTIVE: + inactive_plugin_ids.add(load_plugin_id) + inactive_plugins.append(load_plugin_id) + continue available_plugins[load_plugin_id] = meta.version reloaded_plugins.append(load_plugin_id) @@ -1033,7 +1230,7 @@ class PluginRunner: rollback_failures[rollback_plugin_id] = str(exc) continue - if not restored: + if restored != PluginActivationStatus.LOADED: rollback_failures[rollback_plugin_id] = "无法重新激活旧版本" return ReloadPluginsResultPayload( @@ -1041,29 +1238,40 @@ class PluginRunner: requested_plugin_ids=normalized_plugin_ids, reloaded_plugins=[], unloaded_plugins=unloaded_plugins, + inactive_plugins=[], failed_plugins=self._finalize_failed_reload_messages(failed_plugins, rollback_failures), ) - requested_plugin_success = all(plugin_id in reloaded_plugins for plugin_id in reload_root_ids) + requested_plugin_success = all( + plugin_id in reloaded_plugins or plugin_id in inactive_plugins for plugin_id in reload_root_ids + ) return ReloadPluginsResultPayload( success=requested_plugin_success and not failed_plugins, requested_plugin_ids=normalized_plugin_ids, reloaded_plugins=reloaded_plugins, unloaded_plugins=unloaded_plugins, + inactive_plugins=inactive_plugins, failed_plugins=failed_plugins, ) - async def _notify_ready(self, loaded_plugins: List[str], failed_plugins: List[str]) -> None: + async def _notify_ready( + self, + loaded_plugins: List[str], + failed_plugins: List[str], + inactive_plugins: List[str], + ) -> None: """通知 Host 当前 Runner 已完成插件初始化。 Args: loaded_plugins: 成功初始化的插件列表。 failed_plugins: 初始化失败的插件列表。 + inactive_plugins: 因禁用或依赖不可用而未激活的插件列表。 """ payload = RunnerReadyPayload( loaded_plugins=loaded_plugins, failed_plugins=failed_plugins, + inactive_plugins=inactive_plugins, ) await self._rpc_client.send_request( "runner.ready", @@ -1289,6 +1497,44 @@ class PluginRunner: return envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(e)) return envelope.make_response(payload={"acknowledged": True}) + async def _handle_inspect_plugin_config(self, envelope: Envelope) -> Envelope: + """处理插件配置元数据解析请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + + try: + payload = InspectPluginConfigPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + plugin_id = envelope.plugin_id + meta, is_temporary_meta, error_message = self._resolve_plugin_meta_for_config_request(plugin_id) + if meta is None: + return envelope.make_error_response( + ErrorCode.E_PLUGIN_NOT_FOUND.value, + error_message or f"未找到插件: {plugin_id}", + ) + + try: + result = self._inspect_plugin_config( + meta, + config_data=payload.config_data, + use_provided_config=payload.use_provided_config, + suppress_errors=True, + ) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + finally: + if is_temporary_meta: + self._loader.purge_plugin_modules(plugin_id, meta.plugin_dir) + + return envelope.make_response(payload=result.model_dump()) + async def _handle_validate_plugin_config(self, envelope: Envelope) -> Envelope: """处理插件配置校验请求。 @@ -1305,23 +1551,30 @@ class PluginRunner: return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) plugin_id = envelope.plugin_id - meta = self._loader.get_plugin(plugin_id) + meta, is_temporary_meta, error_message = self._resolve_plugin_meta_for_config_request(plugin_id) if meta is None: - return envelope.make_error_response(ErrorCode.E_PLUGIN_NOT_FOUND.value, f"未找到插件: {plugin_id}") + return envelope.make_error_response( + ErrorCode.E_PLUGIN_NOT_FOUND.value, + error_message or f"未找到插件: {plugin_id}", + ) try: - normalized_config, changed = self._normalize_plugin_config( - meta.instance, - payload.config_data, + inspection_result = self._inspect_plugin_config( + meta, + config_data=payload.config_data, + use_provided_config=True, suppress_errors=False, ) except Exception as exc: return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + finally: + if is_temporary_meta: + self._loader.purge_plugin_modules(plugin_id, meta.plugin_dir) result = ValidatePluginConfigResultPayload( success=True, - normalized_config=normalized_config, - changed=changed, + normalized_config=inspection_result.normalized_config, + changed=inspection_result.changed, ) return envelope.make_response(payload=result.model_dump()) diff --git a/src/services/send_service.py b/src/services/send_service.py index d7f17563..967d3723 100644 --- a/src/services/send_service.py +++ b/src/services/send_service.py @@ -40,10 +40,213 @@ from src.common.utils.utils_message import MessageUtils from src.config.config import global_config from src.platform_io import DeliveryBatch, get_platform_io_manager from src.platform_io.route_key_factory import RouteKeyFactory +from src.plugin_runtime.hook_payloads import deserialize_session_message, serialize_session_message +from src.plugin_runtime.hook_schema_utils import build_object_schema +from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult +from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry logger = get_logger("send_service") +def register_send_service_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]: + """注册发送服务内置 Hook 规格。 + + Args: + registry: 目标 Hook 规格注册中心。 + + Returns: + List[HookSpec]: 实际注册的 Hook 规格列表。 + """ + + return registry.register_hook_specs( + [ + HookSpec( + name="send_service.after_build_message", + description="在出站 SessionMessage 构建完成后触发,可改写消息体或取消发送。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "待发送消息的序列化 SessionMessage。", + }, + "stream_id": { + "type": "string", + "description": "目标会话 ID。", + }, + "display_message": { + "type": "string", + "description": "展示层文本。", + }, + "typing": { + "type": "boolean", + "description": "是否模拟打字。", + }, + "set_reply": { + "type": "boolean", + "description": "是否附带引用回复。", + }, + "storage_message": { + "type": "boolean", + "description": "发送成功后是否写库。", + }, + "show_log": { + "type": "boolean", + "description": "是否输出发送日志。", + }, + }, + required=[ + "message", + "stream_id", + "display_message", + "typing", + "set_reply", + "storage_message", + "show_log", + ], + ), + default_timeout_ms=5000, + allow_abort=True, + allow_kwargs_mutation=True, + ), + HookSpec( + name="send_service.before_send", + description="在真正调用 Platform IO 发送前触发,可改写消息或取消本次发送。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "待发送消息的序列化 SessionMessage。", + }, + "typing": { + "type": "boolean", + "description": "是否模拟打字。", + }, + "set_reply": { + "type": "boolean", + "description": "是否附带引用回复。", + }, + "reply_message_id": { + "type": "string", + "description": "被引用消息 ID。", + }, + "storage_message": { + "type": "boolean", + "description": "发送成功后是否写库。", + }, + "show_log": { + "type": "boolean", + "description": "是否输出发送日志。", + }, + }, + required=["message", "typing", "set_reply", "storage_message", "show_log"], + ), + default_timeout_ms=5000, + allow_abort=True, + allow_kwargs_mutation=True, + ), + HookSpec( + name="send_service.after_send", + description="在发送流程结束后触发,用于观察最终发送结果。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "本次发送消息的序列化 SessionMessage。", + }, + "sent": { + "type": "boolean", + "description": "本次发送是否成功。", + }, + "typing": { + "type": "boolean", + "description": "是否模拟打字。", + }, + "set_reply": { + "type": "boolean", + "description": "是否附带引用回复。", + }, + "reply_message_id": { + "type": "string", + "description": "被引用消息 ID。", + }, + "storage_message": { + "type": "boolean", + "description": "发送成功后是否写库。", + }, + "show_log": { + "type": "boolean", + "description": "是否输出发送日志。", + }, + }, + required=["message", "sent", "typing", "set_reply", "storage_message", "show_log"], + ), + default_timeout_ms=5000, + allow_abort=False, + allow_kwargs_mutation=False, + ), + ] + ) + + +def _get_runtime_manager() -> Any: + """获取插件运行时管理器。 + + Returns: + Any: 插件运行时管理器单例。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + return get_plugin_runtime_manager() + + +def _coerce_bool(value: Any, default: bool) -> bool: + """将任意值安全转换为布尔值。 + + Args: + value: 待转换的值。 + default: 当值为空时使用的默认值。 + + Returns: + bool: 转换后的布尔值。 + """ + + if value is None: + return default + return bool(value) + + +async def _invoke_send_hook( + hook_name: str, + message: SessionMessage, + **kwargs: Any, +) -> tuple[HookDispatchResult, SessionMessage]: + """触发携带出站消息的命名 Hook。 + + Args: + hook_name: 目标 Hook 名称。 + message: 当前待发送消息。 + **kwargs: 需要附带的额外参数。 + + Returns: + tuple[HookDispatchResult, SessionMessage]: Hook 聚合结果以及可能被改写后的消息对象。 + """ + + hook_result = await _get_runtime_manager().invoke_hook( + hook_name, + message=serialize_session_message(message), + **kwargs, + ) + mutated_message = message + raw_message = hook_result.kwargs.get("message") + if raw_message is not None: + try: + mutated_message = deserialize_session_message(raw_message) + except Exception as exc: + logger.warning(f"Hook {hook_name} 返回的 message 无法反序列化,已忽略: {exc}") + return hook_result, mutated_message + + def _inherit_platform_io_route_metadata(target_stream: BotChatSession) -> Dict[str, object]: """从目标会话继承 Platform IO 路由元数据。 @@ -469,6 +672,27 @@ async def _send_via_platform_io( Returns: bool: 发送成功时返回 ``True``。 """ + before_send_result, message = await _invoke_send_hook( + "send_service.before_send", + message, + typing=typing, + set_reply=set_reply, + reply_message_id=reply_message_id, + storage_message=storage_message, + show_log=show_log, + ) + if before_send_result.aborted: + logger.info(f"[SendService] 消息 {message.message_id} 在发送前被 Hook 中止") + return False + + before_kwargs = before_send_result.kwargs + typing = _coerce_bool(before_kwargs.get("typing"), typing) + set_reply = _coerce_bool(before_kwargs.get("set_reply"), set_reply) + storage_message = _coerce_bool(before_kwargs.get("storage_message"), storage_message) + show_log = _coerce_bool(before_kwargs.get("show_log"), show_log) + raw_reply_message_id = before_kwargs.get("reply_message_id", reply_message_id) + reply_message_id = None if raw_reply_message_id in {None, ""} else str(raw_reply_message_id) + platform_io_manager = get_platform_io_manager() try: await platform_io_manager.ensure_send_pipeline_ready() @@ -500,6 +724,18 @@ async def _send_via_platform_io( logger.debug(traceback.format_exc()) return False + sent = bool(delivery_batch.has_success) + await _invoke_send_hook( + "send_service.after_send", + message, + sent=sent, + typing=typing, + set_reply=set_reply, + reply_message_id=reply_message_id, + storage_message=storage_message, + show_log=show_log, + ) + if delivery_batch.has_success: if storage_message: _store_sent_message(message) @@ -606,6 +842,26 @@ async def _send_to_target( if outbound_message is None: return False + after_build_result, outbound_message = await _invoke_send_hook( + "send_service.after_build_message", + outbound_message, + stream_id=stream_id, + display_message=display_message, + typing=typing, + set_reply=set_reply, + storage_message=storage_message, + show_log=show_log, + ) + if after_build_result.aborted: + logger.info(f"[SendService] 消息 {outbound_message.message_id} 在构建后被 Hook 中止") + return False + + after_build_kwargs = after_build_result.kwargs + typing = _coerce_bool(after_build_kwargs.get("typing"), typing) + set_reply = _coerce_bool(after_build_kwargs.get("set_reply"), set_reply) + storage_message = _coerce_bool(after_build_kwargs.get("storage_message"), storage_message) + show_log = _coerce_bool(after_build_kwargs.get("show_log"), show_log) + sent = await send_session_message( outbound_message, typing=typing, diff --git a/src/webui/routers/plugin/__init__.py b/src/webui/routers/plugin/__init__.py index 1be61841..deaa2eac 100644 --- a/src/webui/routers/plugin/__init__.py +++ b/src/webui/routers/plugin/__init__.py @@ -6,11 +6,13 @@ from .catalog import router as catalog_router from .config_routes import router as config_router from .management import router as management_router from .progress import get_progress_router, update_progress +from .runtime_routes import router as runtime_router router = APIRouter(prefix="/plugins", tags=["插件管理"]) router.include_router(catalog_router) router.include_router(management_router) router.include_router(config_router) +router.include_router(runtime_router) set_update_progress_callback(update_progress) diff --git a/src/webui/routers/plugin/config_routes.py b/src/webui/routers/plugin/config_routes.py index 3a24503e..de51a2cf 100644 --- a/src/webui/routers/plugin/config_routes.py +++ b/src/webui/routers/plugin/config_routes.py @@ -1,13 +1,13 @@ """插件配置相关 WebUI 路由。""" -import json +from pathlib import Path from typing import Any, Dict, Optional, cast import tomlkit from fastapi import APIRouter, Cookie, HTTPException from src.common.logger import get_logger -from src.plugin_runtime.component_query import component_query_service +from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload from src.webui.utils.toml_utils import save_toml_with_format from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest @@ -207,6 +207,55 @@ def _build_toml_document(config_data: Dict[str, Any]) -> tomlkit.TOMLDocument: return tomlkit.parse(tomlkit.dumps(config_data)) +def _load_plugin_config_from_disk(plugin_path: Path) -> Dict[str, Any]: + """从磁盘读取插件配置。 + + Args: + plugin_path: 插件目录。 + + Returns: + Dict[str, Any]: 当前配置字典;文件不存在时返回空字典。 + """ + + config_path = resolve_plugin_file_path(plugin_path, "config.toml") + if not config_path.exists(): + return {} + + with open(config_path, "r", encoding="utf-8") as file_obj: + loaded_config = tomlkit.load(file_obj).unwrap() + return loaded_config if isinstance(loaded_config, dict) else {} + + +async def _inspect_plugin_config_via_runtime( + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + *, + use_provided_config: bool = False, +) -> InspectPluginConfigResultPayload | None: + """通过插件运行时解析配置元数据。 + + Args: + plugin_id: 插件 ID。 + config_data: 可选的配置内容。 + use_provided_config: 是否优先使用传入配置而不是磁盘配置。 + + Returns: + InspectPluginConfigResultPayload | None: 运行时可用时返回解析结果,否则返回 ``None``。 + + Raises: + ValueError: 插件运行时明确拒绝解析请求时抛出。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + runtime_manager = get_plugin_runtime_manager() + return await runtime_manager.inspect_plugin_config( + plugin_id, + config_data, + use_provided_config=use_provided_config, + ) + + async def _validate_plugin_config_via_runtime(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: """通过插件运行时对配置进行校验。 @@ -244,27 +293,24 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] logger.info(f"获取插件配置 Schema: {plugin_id}") try: - registration_schema = component_query_service.get_plugin_config_schema(plugin_id) - if isinstance(registration_schema, dict) and registration_schema: - return {"success": True, "schema": registration_schema} - plugin_path = find_plugin_path_by_id(plugin_id) if plugin_path is None: raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") - schema_json_path = resolve_plugin_file_path(plugin_path, "config_schema.json") - if schema_json_path.exists(): - try: - with open(schema_json_path, "r", encoding="utf-8") as file_obj: - return {"success": True, "schema": json.load(file_obj)} - except Exception as e: - logger.warning(f"读取 config_schema.json 失败,回退到自动推断: {e}") + try: + runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) + except ValueError as exc: + logger.warning(f"插件 {plugin_id} 配置 Schema 解析失败,将回退到弱推断: {exc}") + runtime_snapshot = None - current_config: Any = component_query_service.get_plugin_default_config(plugin_id) or {} - config_path = resolve_plugin_file_path(plugin_path, "config.toml") - if config_path.exists(): - with open(config_path, "r", encoding="utf-8") as file_obj: - current_config = tomlkit.load(file_obj) + if runtime_snapshot is not None and runtime_snapshot.config_schema: + return {"success": True, "schema": dict(runtime_snapshot.config_schema)} + + current_config: Any = ( + dict(runtime_snapshot.normalized_config) + if runtime_snapshot is not None + else _load_plugin_config_from_disk(plugin_path) + ) return {"success": True, "schema": _build_schema_from_current_config(plugin_id, current_config)} except HTTPException: @@ -375,15 +421,24 @@ async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cook raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") + try: + runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) + except ValueError as exc: + logger.warning(f"插件 {plugin_id} 配置读取失败,将回退到磁盘内容: {exc}") + runtime_snapshot = None + + if runtime_snapshot is not None: + message = "配置文件不存在,已返回默认配置" if not config_path.exists() else "" + return { + "success": True, + "config": dict(runtime_snapshot.normalized_config), + "message": message, + } + if not config_path.exists(): - default_config = component_query_service.get_plugin_default_config(plugin_id) - if isinstance(default_config, dict): - return {"success": True, "config": default_config, "message": "配置文件不存在,已返回默认配置"} return {"success": True, "config": {}, "message": "配置文件不存在"} - with open(config_path, "r", encoding="utf-8") as file_obj: - config = tomlkit.load(file_obj) - return {"success": True, "config": dict(config)} + return {"success": True, "config": _load_plugin_config_from_disk(plugin_path)} except HTTPException: raise except Exception as e: @@ -412,6 +467,10 @@ async def update_plugin_config( logger.info(f"更新插件配置: {plugin_id}") try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + config_data = request.config or {} if isinstance(config_data, dict): config_data = normalize_dotted_keys(config_data) @@ -419,12 +478,13 @@ async def update_plugin_config( if isinstance(runtime_validated_config, dict): config_data = runtime_validated_config else: - plugin_schema = component_query_service.get_plugin_config_schema(plugin_id) - if isinstance(plugin_schema, dict) and plugin_schema: - _coerce_config_by_plugin_schema(plugin_schema, config_data) - plugin_path = find_plugin_path_by_id(plugin_id) - if plugin_path is None: - raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + runtime_snapshot = await _inspect_plugin_config_via_runtime( + plugin_id, + config_data, + use_provided_config=True, + ) + if runtime_snapshot is not None and runtime_snapshot.config_schema: + _coerce_config_by_plugin_schema(dict(runtime_snapshot.config_schema), config_data) config_path = resolve_plugin_file_path(plugin_path, "config.toml") backup_path = backup_file(config_path, "backup") @@ -498,17 +558,29 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") config_path = resolve_plugin_file_path(plugin_path, "config.toml") - default_config = component_query_service.get_plugin_default_config(plugin_id) - config = _build_toml_document(default_config if isinstance(default_config, dict) else {}) - if config_path.exists(): - with open(config_path, "r", encoding="utf-8") as file_obj: - config = tomlkit.load(file_obj) + try: + runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) + except ValueError as exc: + logger.warning(f"插件 {plugin_id} 状态切换前配置解析失败,将回退到磁盘内容: {exc}") + runtime_snapshot = None - if "plugin" not in config: + current_config = ( + dict(runtime_snapshot.normalized_config) + if runtime_snapshot is not None + else _load_plugin_config_from_disk(plugin_path) + ) + config = _build_toml_document(current_config) + + plugin_section = config.get("plugin") + if plugin_section is None or not hasattr(plugin_section, "get"): config["plugin"] = tomlkit.table() plugin_config = cast(Any, config["plugin"]) - current_enabled = bool(plugin_config.get("enabled", True)) + current_enabled = ( + bool(runtime_snapshot.enabled) + if runtime_snapshot is not None + else bool(plugin_config.get("enabled", True)) + ) new_enabled = not current_enabled plugin_config["enabled"] = new_enabled save_toml_with_format(config, str(config_path)) @@ -519,7 +591,7 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N "success": True, "enabled": new_enabled, "message": f"插件已{status}", - "note": "状态更改将在下次加载插件时生效", + "note": "状态更改将自动热更新到对应插件", } except HTTPException: raise diff --git a/src/webui/routers/plugin/runtime_routes.py b/src/webui/routers/plugin/runtime_routes.py new file mode 100644 index 00000000..fe1773a4 --- /dev/null +++ b/src/webui/routers/plugin/runtime_routes.py @@ -0,0 +1,28 @@ +"""插件运行时相关 WebUI 路由。""" + +from typing import Optional + +from fastapi import APIRouter, Cookie + +from src.plugin_runtime.component_query import component_query_service + +from .schemas import HookSpecListResponse, HookSpecResponse +from .support import require_plugin_token + +router = APIRouter() + + +@router.get("/runtime/hooks", response_model=HookSpecListResponse) +async def list_runtime_hook_specs(maibot_session: Optional[str] = Cookie(None)) -> HookSpecListResponse: + """返回当前插件运行时公开的 Hook 规格清单。 + + Args: + maibot_session: 当前 WebUI 会话令牌。 + + Returns: + HookSpecListResponse: Hook 规格列表响应。 + """ + + require_plugin_token(maibot_session) + hooks = [HookSpecResponse(**hook_data) for hook_data in component_query_service.list_hook_specs()] + return HookSpecListResponse(success=True, hooks=hooks) diff --git a/src/webui/routers/plugin/schemas.py b/src/webui/routers/plugin/schemas.py index 0d431dc0..eda21038 100644 --- a/src/webui/routers/plugin/schemas.py +++ b/src/webui/routers/plugin/schemas.py @@ -111,3 +111,19 @@ class UpdatePluginConfigRequest(BaseModel): class UpdatePluginRawConfigRequest(BaseModel): config: str = Field(..., description="原始 TOML 配置内容") + + +class HookSpecResponse(BaseModel): + name: str = Field(..., description="Hook 名称") + description: str = Field("", description="Hook 描述") + parameters_schema: Dict[str, Any] = Field(default_factory=dict, description="Hook 参数模型") + default_timeout_ms: int = Field(..., description="默认超时毫秒数") + allow_blocking: bool = Field(..., description="是否允许 blocking 处理器") + allow_observe: bool = Field(..., description="是否允许 observe 处理器") + allow_abort: bool = Field(..., description="是否允许 abort") + allow_kwargs_mutation: bool = Field(..., description="是否允许修改 kwargs") + + +class HookSpecListResponse(BaseModel): + success: bool = Field(..., description="是否成功") + hooks: List[HookSpecResponse] = Field(default_factory=list, description="Hook 规格列表") From 1906890b6735519bec4e90e91d67ffb6f960328e Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Thu, 2 Apr 2026 22:08:52 +0800 Subject: [PATCH 10/18] feat: add unified WebSocket connection manager and routing - Implemented UnifiedWebSocketManager for managing WebSocket connections, including subscription handling and message sending. - Created unified WebSocket router to handle client messages, including authentication, subscription, and chat session management. - Added support for logging and plugin progress subscriptions. - Enhanced error handling and response structure for WebSocket operations. --- dashboard/src/lib/chat-ws-client.ts | 161 ++++ dashboard/src/lib/log-websocket.ts | 248 +++--- dashboard/src/lib/plugin-api/marketplace.ts | 48 +- dashboard/src/lib/plugin-progress-client.ts | 58 ++ dashboard/src/lib/unified-ws.ts | 495 ++++++++++++ dashboard/src/lib/ws-utils.ts | 211 ----- dashboard/src/routes/chat/index.tsx | 722 +++++++----------- dashboard/src/routes/plugins/index.tsx | 36 +- pytests/test_runtime_business_hooks.py | 136 ++++ src/chat/emoji_system/emoji_manager.py | 223 +++++- src/chat/emoji_system/maisaka_tool.py | 157 +++- .../message_receive/uni_message_sender.py | 20 +- src/learners/expression_learner.py | 284 ++++++- src/learners/expression_selector.py | 154 +++- src/learners/jargon_miner.py | 277 ++++++- src/maisaka/reasoning_engine.py | 148 +++- src/plugin_runtime/hook_catalog.py | 6 + src/webui/logs_ws.py | 28 +- src/webui/routers/__init__.py | 2 - src/webui/routers/chat/__init__.py | 2 +- src/webui/routers/chat/routes.py | 59 +- .../routers/chat/{support.py => service.py} | 567 ++++++++++++-- src/webui/routers/plugin/progress.py | 72 +- src/webui/routers/websocket/__init__.py | 6 +- src/webui/routers/websocket/logs.py | 11 - src/webui/routers/websocket/manager.py | 297 +++++++ src/webui/routers/websocket/unified.py | 548 +++++++++++++ src/webui/routes.py | 6 +- 28 files changed, 3845 insertions(+), 1137 deletions(-) create mode 100644 dashboard/src/lib/chat-ws-client.ts create mode 100644 dashboard/src/lib/plugin-progress-client.ts create mode 100644 dashboard/src/lib/unified-ws.ts delete mode 100644 dashboard/src/lib/ws-utils.ts create mode 100644 pytests/test_runtime_business_hooks.py rename src/webui/routers/chat/{support.py => service.py} (52%) delete mode 100644 src/webui/routers/websocket/logs.py create mode 100644 src/webui/routers/websocket/manager.py create mode 100644 src/webui/routers/websocket/unified.py diff --git a/dashboard/src/lib/chat-ws-client.ts b/dashboard/src/lib/chat-ws-client.ts new file mode 100644 index 00000000..88e6fa7c --- /dev/null +++ b/dashboard/src/lib/chat-ws-client.ts @@ -0,0 +1,161 @@ +import { unifiedWsClient, type ConnectionStatus } from './unified-ws' + +interface ChatSessionOpenPayload { + group_id?: string + group_name?: string + person_id?: string + platform?: string + user_id?: string + user_name?: string +} + +type ChatSessionListener = (message: Record) => void + +class ChatWsClient { + private initialized = false + private listeners: Map> = new Map() + private sessionPayloads: Map = new Map() + + private initialize(): void { + if (this.initialized) { + return + } + + unifiedWsClient.addEventListener((message) => { + if (message.domain !== 'chat' || !message.session) { + return + } + + const sessionListeners = this.listeners.get(message.session) + if (!sessionListeners) { + return + } + + sessionListeners.forEach((listener) => { + try { + listener(message.data) + } catch (error) { + console.error('聊天会话监听器执行失败:', error) + } + }) + }) + + unifiedWsClient.onReconnect(() => { + void this.reopenSessions() + }) + + this.initialized = true + } + + private async reopenSessions(): Promise { + const reopenTargets = Array.from(this.sessionPayloads.entries()) + for (const [sessionId, payload] of reopenTargets) { + try { + await unifiedWsClient.call({ + domain: 'chat', + method: 'session.open', + session: sessionId, + data: { + ...payload, + restore: true, + } as Record, + }) + } catch (error) { + console.error(`恢复聊天会话失败 (${sessionId}):`, error) + } + } + } + + async openSession(sessionId: string, payload: ChatSessionOpenPayload): Promise { + this.initialize() + this.sessionPayloads.set(sessionId, payload) + await unifiedWsClient.call({ + domain: 'chat', + method: 'session.open', + session: sessionId, + data: payload as Record, + }) + } + + async closeSession(sessionId: string): Promise { + this.sessionPayloads.delete(sessionId) + if (unifiedWsClient.getStatus() !== 'connected') { + return + } + + try { + await unifiedWsClient.call({ + domain: 'chat', + method: 'session.close', + session: sessionId, + data: {}, + }) + } catch (error) { + console.warn(`关闭聊天会话失败 (${sessionId}):`, error) + } + } + + async sendMessage(sessionId: string, content: string, userName: string): Promise { + await unifiedWsClient.call({ + domain: 'chat', + method: 'message.send', + session: sessionId, + data: { + content, + user_name: userName, + }, + }) + } + + async updateNickname(sessionId: string, userName: string): Promise { + const currentPayload = this.sessionPayloads.get(sessionId) + if (currentPayload) { + this.sessionPayloads.set(sessionId, { + ...currentPayload, + user_name: userName, + }) + } + + await unifiedWsClient.call({ + domain: 'chat', + method: 'session.update_nickname', + session: sessionId, + data: { + user_name: userName, + }, + }) + } + + onSessionMessage(sessionId: string, listener: ChatSessionListener): () => void { + this.initialize() + const sessionListeners = this.listeners.get(sessionId) ?? new Set() + sessionListeners.add(listener) + this.listeners.set(sessionId, sessionListeners) + + return () => { + const currentListeners = this.listeners.get(sessionId) + if (!currentListeners) { + return + } + + currentListeners.delete(listener) + if (currentListeners.size === 0) { + this.listeners.delete(sessionId) + } + } + } + + onConnectionChange(listener: (connected: boolean) => void): () => void { + return unifiedWsClient.onConnectionChange(listener) + } + + onStatusChange(listener: (status: ConnectionStatus) => void): () => void { + return unifiedWsClient.onStatusChange(listener) + } + + async restart(): Promise { + await unifiedWsClient.restart() + } +} + +export const chatWsClient = new ChatWsClient() diff --git a/dashboard/src/lib/log-websocket.ts b/dashboard/src/lib/log-websocket.ts index abf9e7f7..b7645368 100644 --- a/dashboard/src/lib/log-websocket.ts +++ b/dashboard/src/lib/log-websocket.ts @@ -1,13 +1,11 @@ /** * 全局日志 WebSocket 管理器 - * 确保整个应用只有一个 WebSocket 连接 + * 确保整个应用只通过统一连接层订阅日志流 */ import { checkAuthStatus } from './fetch-with-auth' import { getSetting } from './settings-manager' -import { createReconnectingWebSocket } from './ws-utils' - -import { getWsBaseUrl } from '@/lib/api-base' +import { unifiedWsClient } from './unified-ws' export interface LogEntry { id: string @@ -17,165 +15,79 @@ export interface LogEntry { message: string } -type LogCallback = (log: LogEntry) => void +type LogCallback = () => void type ConnectionCallback = (connected: boolean) => void class LogWebSocketManager { - private wsControl: ReturnType | null = null - - // 订阅者 - private logCallbacks: Set = new Set() private connectionCallbacks: Set = new Set() - + private initialized = false private isConnected = false - - // 日志缓存 - 保存所有接收到的日志 private logCache: LogEntry[] = [] + private logCallbacks: Set = new Set() + private subscriptionActive = false - /** - * 获取最大缓存大小(从设置读取) - */ private getMaxCacheSize(): number { return getSetting('logCacheSize') } - /** - * 获取最大重连次数(从设置读取) - */ - private getMaxReconnectAttempts(): number { - return getSetting('wsMaxReconnectAttempts') - } - - /** - * 获取重连间隔(从设置读取) - */ - private getReconnectInterval(): number { - return getSetting('wsReconnectInterval') - } - - /** - * 获取 WebSocket URL(不含 token 参数) - */ - private async getWebSocketUrl(): Promise { - const wsBase = await getWsBaseUrl() - return `${wsBase}/ws/logs` - } - - /** - * 连接 WebSocket(会先检查登录状态) - */ - async connect() { - // 检查是否在登录页面 - if (window.location.pathname === '/auth') { - console.log('📡 在登录页面,跳过 WebSocket 连接') + private initialize(): void { + if (this.initialized) { return } - // 检查登录状态,避免未登录时尝试连接 - const isAuthenticated = await checkAuthStatus() - if (!isAuthenticated) { - console.log('📡 未登录,跳过 WebSocket 连接') - return - } + unifiedWsClient.addEventListener((message) => { + if (message.domain !== 'logs') { + return + } - const wsUrl = await this.getWebSocketUrl() + if (message.event === 'snapshot') { + const entries = Array.isArray(message.data.entries) + ? (message.data.entries as LogEntry[]) + : [] + this.logCache = entries.slice(-this.getMaxCacheSize()) + this.notifyLogChange() + return + } - // 使用 ws-utils 创建 WebSocket - this.wsControl = createReconnectingWebSocket(wsUrl, { - onMessage: (data: string) => { - try { - const log: LogEntry = JSON.parse(data) - this.notifyLog(log) - } catch (error) { - console.error('解析日志消息失败:', error) - } - }, - onOpen: () => { - this.isConnected = true - this.notifyConnection(true) - }, - onClose: () => { - this.isConnected = false - this.notifyConnection(false) - }, - onError: (error) => { - console.error('❌ WebSocket 错误:', error) - this.isConnected = false - this.notifyConnection(false) - }, - heartbeatInterval: 30000, - maxRetries: this.getMaxReconnectAttempts(), - backoffBase: this.getReconnectInterval(), - maxBackoff: 30000, + if (message.event === 'entry' && message.data.entry) { + this.appendLog(message.data.entry as LogEntry) + } }) - // 启动连接 - await this.wsControl.connect() + unifiedWsClient.onConnectionChange((connected) => { + this.isConnected = connected + this.notifyConnection(connected) + }) + + this.initialized = true } - /** - * 断开连接 - */ - disconnect() { - if (this.wsControl) { - this.wsControl.disconnect() - this.wsControl = null - } - - this.isConnected = false - } - - /** - * 订阅日志消息 - */ - onLog(callback: LogCallback) { - this.logCallbacks.add(callback) - return () => this.logCallbacks.delete(callback) - } - - /** - * 订阅连接状态 - */ - onConnectionChange(callback: ConnectionCallback) { - this.connectionCallbacks.add(callback) - // 立即通知当前状态 - callback(this.isConnected) - return () => this.connectionCallbacks.delete(callback) - } - - /** - * 通知所有订阅者新日志 - */ - private notifyLog(log: LogEntry) { - // 检查是否已存在(通过 id 去重) + private appendLog(log: LogEntry): void { const exists = this.logCache.some(existingLog => existingLog.id === log.id) - - if (!exists) { - // 添加到缓存 - this.logCache.push(log) - - // 限制缓存大小(动态读取配置) - const maxCacheSize = this.getMaxCacheSize() - if (this.logCache.length > maxCacheSize) { - this.logCache = this.logCache.slice(-maxCacheSize) - } - - // 只有新日志才通知订阅者 - this.logCallbacks.forEach(callback => { - try { - callback(log) - } catch (error) { - console.error('日志回调执行失败:', error) - } - }) + if (exists) { + return } + + this.logCache.push(log) + const maxCacheSize = this.getMaxCacheSize() + if (this.logCache.length > maxCacheSize) { + this.logCache = this.logCache.slice(-maxCacheSize) + } + this.notifyLogChange() } - /** - * 通知所有订阅者连接状态变化 - */ - private notifyConnection(connected: boolean) { - this.connectionCallbacks.forEach(callback => { + private notifyLogChange(): void { + this.logCallbacks.forEach((callback) => { + try { + callback() + } catch (error) { + console.error('日志回调执行失败:', error) + } + }) + } + + private notifyConnection(connected: boolean): void { + this.connectionCallbacks.forEach((callback) => { try { callback(connected) } catch (error) { @@ -184,35 +96,65 @@ class LogWebSocketManager { }) } - /** - * 获取缓存的所有日志 - */ + async connect(): Promise { + if (window.location.pathname === '/auth') { + return + } + + const isAuthenticated = await checkAuthStatus() + if (!isAuthenticated) { + return + } + + this.initialize() + if (this.subscriptionActive) { + return + } + + try { + await unifiedWsClient.subscribe('logs', 'main', { replay: 100 }) + this.subscriptionActive = true + } catch (error) { + console.error('订阅日志流失败:', error) + } + } + + disconnect(): void { + this.subscriptionActive = false + void unifiedWsClient.unsubscribe('logs', 'main') + this.isConnected = false + this.notifyConnection(false) + } + + onLog(callback: LogCallback): () => void { + this.logCallbacks.add(callback) + return () => this.logCallbacks.delete(callback) + } + + onConnectionChange(callback: ConnectionCallback): () => void { + this.connectionCallbacks.add(callback) + callback(this.isConnected) + return () => this.connectionCallbacks.delete(callback) + } + getAllLogs(): LogEntry[] { return [...this.logCache] } - /** - * 清空日志缓存 - */ - clearLogs() { + clearLogs(): void { this.logCache = [] + this.notifyLogChange() } - /** - * 获取当前连接状态 - */ getConnectionStatus(): boolean { return this.isConnected } } -// 导出单例 export const logWebSocket = new LogWebSocketManager() -// 自动连接(应用启动时) if (typeof window !== 'undefined') { - // 延迟一下确保页面加载完成 setTimeout(() => { - logWebSocket.connect() + void logWebSocket.connect() }, 100) } diff --git a/dashboard/src/lib/plugin-api/marketplace.ts b/dashboard/src/lib/plugin-api/marketplace.ts index 82b8e9c7..a7054088 100644 --- a/dashboard/src/lib/plugin-api/marketplace.ts +++ b/dashboard/src/lib/plugin-api/marketplace.ts @@ -1,9 +1,9 @@ import type { ApiResponse } from '@/types/api' import type { PluginInfo } from '@/types/plugin' -import { getWsBaseUrl } from '@/lib/api-base' import { fetchWithAuth } from '@/lib/fetch-with-auth' import { parseResponse } from '@/lib/api-helpers' +import { pluginProgressClient } from '@/lib/plugin-progress-client' import type { GitStatus, MaimaiVersion } from './types' /** @@ -211,41 +211,13 @@ export function isPluginCompatible( */ export async function connectPluginProgressWebSocket( onProgress: (progress: import('./types').PluginLoadProgress) => void, - onError?: (error: Event) => void -): Promise { - const wsBase = await getWsBaseUrl() - const wsUrl = `${wsBase}/api/webui/ws/plugin-progress` - - // 使用 ws-utils 创建 WebSocket - const { createReconnectingWebSocket } = await import('@/lib/ws-utils') - const wsControl = createReconnectingWebSocket(wsUrl, { - onMessage: (data: string) => { - try { - const progressData = JSON.parse(data) as import('./types').PluginLoadProgress - onProgress(progressData) - } catch (error) { - console.error('Failed to parse progress data:', error) - } - }, - onOpen: () => { - console.log('Plugin progress WebSocket connected') - }, - onClose: () => { - console.log('Plugin progress WebSocket disconnected') - }, - onError: (error) => { - console.error('Plugin progress WebSocket error:', error) - onError?.(error) - }, - heartbeatInterval: 30000, - maxRetries: 10, - backoffBase: 1000, - maxBackoff: 30000, - }) - - // 启动连接 - await wsControl.connect() - - // 返回 WebSocket 实例(用于外部检查连接状态) - return wsControl.getWebSocket() + onError?: (error: Error) => void +): Promise<() => Promise> { + try { + return await pluginProgressClient.subscribe(onProgress) + } catch (error) { + const normalizedError = error instanceof Error ? error : new Error('插件进度订阅失败') + onError?.(normalizedError) + return async () => {} + } } diff --git a/dashboard/src/lib/plugin-progress-client.ts b/dashboard/src/lib/plugin-progress-client.ts new file mode 100644 index 00000000..2aea339d --- /dev/null +++ b/dashboard/src/lib/plugin-progress-client.ts @@ -0,0 +1,58 @@ +import type { PluginLoadProgress } from '@/lib/plugin-api/types' + +import { unifiedWsClient } from './unified-ws' + +type ProgressListener = (progress: PluginLoadProgress) => void + +class PluginProgressClient { + private initialized = false + private listeners: Set = new Set() + private subscriptionActive = false + + private initialize(): void { + if (this.initialized) { + return + } + + unifiedWsClient.addEventListener((message) => { + if (message.domain !== 'plugin_progress') { + return + } + + const progress = message.data.progress as PluginLoadProgress | undefined + if (!progress) { + return + } + + this.listeners.forEach((listener) => { + try { + listener(progress) + } catch (error) { + console.error('插件进度监听器执行失败:', error) + } + }) + }) + + this.initialized = true + } + + async subscribe(listener: ProgressListener): Promise<() => Promise> { + this.initialize() + this.listeners.add(listener) + + if (!this.subscriptionActive) { + await unifiedWsClient.subscribe('plugin_progress', 'main') + this.subscriptionActive = true + } + + return async () => { + this.listeners.delete(listener) + if (this.listeners.size === 0 && this.subscriptionActive) { + this.subscriptionActive = false + await unifiedWsClient.unsubscribe('plugin_progress', 'main') + } + } + } +} + +export const pluginProgressClient = new PluginProgressClient() diff --git a/dashboard/src/lib/unified-ws.ts b/dashboard/src/lib/unified-ws.ts new file mode 100644 index 00000000..3cdafce0 --- /dev/null +++ b/dashboard/src/lib/unified-ws.ts @@ -0,0 +1,495 @@ +import { fetchWithAuth } from './fetch-with-auth' +import { getSetting } from './settings-manager' + +import { getWsBaseUrl } from '@/lib/api-base' + +export type ConnectionStatus = 'idle' | 'connecting' | 'connected' + +export interface WsErrorPayload { + code?: string + message: string +} + +export interface WsEventEnvelope { + op: 'event' + domain: string + event: string + session?: string + topic?: string + data: Record +} + +interface WsResponseEnvelope { + op: 'response' + id?: string + ok: boolean + data?: Record + error?: WsErrorPayload +} + +interface WsPongEnvelope { + op: 'pong' + ts: number +} + +type WsServerEnvelope = WsEventEnvelope | WsPongEnvelope | WsResponseEnvelope + +interface PendingRequest { + reject: (error: Error) => void + resolve: (data: Record) => void + timeoutId: number +} + +interface SubscriptionDefinition { + data?: Record + domain: string + topic: string +} + +type EventListener = (message: WsEventEnvelope) => void +type ConnectionListener = (connected: boolean) => void +type StatusListener = (status: ConnectionStatus) => void +type ReconnectListener = () => void + +function isResponseEnvelope(message: WsServerEnvelope): message is WsResponseEnvelope { + return message.op === 'response' +} + +function isEventEnvelope(message: WsServerEnvelope): message is WsEventEnvelope { + return message.op === 'event' +} + +async function getWsToken(): Promise { + try { + const response = await fetchWithAuth('/api/webui/ws-token', { + method: 'GET', + credentials: 'include', + }) + + if (!response.ok) { + return null + } + + const data = await response.json() + if (data.success && data.token) { + return data.token as string + } + + return null + } catch (error) { + console.error('获取统一 WebSocket token 失败:', error) + return null + } +} + +class UnifiedWebSocketClient { + private connectPromise: Promise | null = null + private connectionListeners: Set = new Set() + private eventListeners: Set = new Set() + private hasConnectedOnce = false + private heartbeatIntervalId: number | null = null + private manualDisconnect = false + private pendingRequests: Map = new Map() + private reconnectAttempts = 0 + private reconnectListeners: Set = new Set() + private reconnectTimeout: number | null = null + private requestCounter = 0 + private status: ConnectionStatus = 'idle' + private statusListeners: Set = new Set() + private subscriptions: Map = new Map() + private ws: WebSocket | null = null + + private getReconnectDelay(): number { + const baseDelay = getSetting('wsReconnectInterval') + return Math.min(baseDelay * Math.max(this.reconnectAttempts, 1), 30000) + } + + private getMaxReconnectAttempts(): number { + return getSetting('wsMaxReconnectAttempts') + } + + private getSubscriptionKey(domain: string, topic: string): string { + return `${domain}:${topic}` + } + + private nextRequestId(): string { + this.requestCounter += 1 + return `ws-${Date.now()}-${this.requestCounter}` + } + + private setStatus(status: ConnectionStatus): void { + if (this.status === status) { + return + } + + this.status = status + this.statusListeners.forEach((listener) => { + try { + listener(status) + } catch (error) { + console.error('WebSocket 状态监听器执行失败:', error) + } + }) + + const connected = status === 'connected' + this.connectionListeners.forEach((listener) => { + try { + listener(connected) + } catch (error) { + console.error('WebSocket 连接监听器执行失败:', error) + } + }) + } + + private stopHeartbeat(): void { + if (this.heartbeatIntervalId !== null) { + clearInterval(this.heartbeatIntervalId) + this.heartbeatIntervalId = null + } + } + + private startHeartbeat(): void { + this.stopHeartbeat() + this.heartbeatIntervalId = window.setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ op: 'ping' })) + } + }, 30000) + } + + private clearReconnectTimer(): void { + if (this.reconnectTimeout !== null) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + } + + private rejectPendingRequests(error: Error): void { + this.pendingRequests.forEach((pendingRequest, requestId) => { + clearTimeout(pendingRequest.timeoutId) + pendingRequest.reject(error) + this.pendingRequests.delete(requestId) + }) + } + + private scheduleReconnect(): void { + if (this.manualDisconnect) { + return + } + + if (this.reconnectAttempts >= this.getMaxReconnectAttempts()) { + console.warn(`统一 WebSocket 达到最大重连次数 (${this.getMaxReconnectAttempts()}),停止重连`) + return + } + + this.reconnectAttempts += 1 + const delay = this.getReconnectDelay() + this.clearReconnectTimer() + this.reconnectTimeout = window.setTimeout(() => { + void this.connect().catch((error) => { + console.error('统一 WebSocket 重连失败:', error) + }) + }, delay) + } + + private async createWebSocketUrl(): Promise { + const wsBaseUrl = await getWsBaseUrl() + const wsToken = await getWsToken() + if (!wsBaseUrl || !wsToken) { + return null + } + return `${wsBaseUrl}/api/webui/ws?token=${encodeURIComponent(wsToken)}` + } + + private async sendRequest( + payload: Record, + timeoutMs = 10000, + ): Promise> { + if (this.ws?.readyState !== WebSocket.OPEN) { + throw new Error('统一 WebSocket 尚未连接') + } + + const requestId = payload.id as string + return await new Promise>((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + this.pendingRequests.delete(requestId) + reject(new Error(`统一 WebSocket 请求超时: ${requestId}`)) + }, timeoutMs) + + this.pendingRequests.set(requestId, { + resolve, + reject, + timeoutId, + }) + this.ws?.send(JSON.stringify(payload)) + }) + } + + private async restoreState(shouldNotifyReconnect: boolean): Promise { + const subscriptions = Array.from(this.subscriptions.values()) + for (const subscription of subscriptions) { + try { + await this.sendRequest({ + op: 'subscribe', + id: this.nextRequestId(), + domain: subscription.domain, + topic: subscription.topic, + data: subscription.data ?? {}, + }) + } catch (error) { + console.error('恢复统一 WebSocket 订阅失败:', error) + } + } + + if (shouldNotifyReconnect) { + this.reconnectListeners.forEach((listener) => { + try { + listener() + } catch (error) { + console.error('统一 WebSocket 重连监听器执行失败:', error) + } + }) + } + } + + private handleServerMessage(rawData: string): void { + let message: WsServerEnvelope + try { + message = JSON.parse(rawData) as WsServerEnvelope + } catch (error) { + console.error('解析统一 WebSocket 消息失败:', error) + return + } + + if (message.op === 'pong') { + return + } + + if (isResponseEnvelope(message)) { + const requestId = message.id + if (!requestId) { + return + } + + const pendingRequest = this.pendingRequests.get(requestId) + if (!pendingRequest) { + return + } + + clearTimeout(pendingRequest.timeoutId) + this.pendingRequests.delete(requestId) + if (message.ok) { + pendingRequest.resolve(message.data ?? {}) + } else { + pendingRequest.reject(new Error(message.error?.message ?? '统一 WebSocket 请求失败')) + } + return + } + + if (isEventEnvelope(message)) { + this.eventListeners.forEach((listener) => { + try { + listener(message) + } catch (error) { + console.error('统一 WebSocket 事件监听器执行失败:', error) + } + }) + } + } + + private handleClose(event: CloseEvent): void { + this.stopHeartbeat() + this.ws = null + this.connectPromise = null + this.setStatus('idle') + this.rejectPendingRequests(new Error(`统一 WebSocket 已关闭 (${event.code})`)) + + if (event.code === 4001) { + this.manualDisconnect = true + if (window.location.pathname !== '/auth') { + window.location.href = '/auth' + } + return + } + + this.scheduleReconnect() + } + + async connect(): Promise { + if (this.ws?.readyState === WebSocket.OPEN) { + return + } + + if (this.connectPromise) { + return await this.connectPromise + } + + this.manualDisconnect = false + this.setStatus('connecting') + + this.connectPromise = (async () => { + const wsUrl = await this.createWebSocketUrl() + if (!wsUrl) { + this.setStatus('idle') + throw new Error('无法建立统一 WebSocket 连接') + } + + await new Promise((resolve, reject) => { + let settled = false + const socket = new WebSocket(wsUrl) + this.ws = socket + + socket.onopen = () => { + settled = true + const shouldNotifyReconnect = this.hasConnectedOnce + this.hasConnectedOnce = true + this.reconnectAttempts = 0 + this.startHeartbeat() + this.setStatus('connected') + resolve() + void this.restoreState(shouldNotifyReconnect) + } + + socket.onmessage = (event) => { + this.handleServerMessage(event.data) + } + + socket.onerror = () => { + if (!settled) { + settled = true + reject(new Error('统一 WebSocket 连接失败')) + } + } + + socket.onclose = (event) => { + if (!settled) { + settled = true + reject(new Error(`统一 WebSocket 已关闭 (${event.code})`)) + } + this.handleClose(event) + } + }) + })() + + try { + await this.connectPromise + } finally { + if (this.status !== 'connected') { + this.connectPromise = null + } + } + } + + disconnect(): void { + this.manualDisconnect = true + this.clearReconnectTimer() + this.stopHeartbeat() + this.rejectPendingRequests(new Error('统一 WebSocket 已手动断开')) + this.connectPromise = null + if (this.ws) { + this.ws.close() + this.ws = null + } + this.setStatus('idle') + } + + async restart(): Promise { + this.manualDisconnect = false + this.clearReconnectTimer() + if (this.ws) { + this.ws.close() + return + } + await this.connect() + } + + async call(params: { + data?: Record + domain: string + method: string + session?: string + }): Promise> { + await this.connect() + const requestId = this.nextRequestId() + return await this.sendRequest({ + op: 'call', + id: requestId, + domain: params.domain, + method: params.method, + session: params.session, + data: params.data ?? {}, + }) + } + + async subscribe( + domain: string, + topic: string, + data?: Record, + ): Promise> { + await this.connect() + this.subscriptions.set(this.getSubscriptionKey(domain, topic), { + domain, + topic, + data, + }) + + return await this.sendRequest({ + op: 'subscribe', + id: this.nextRequestId(), + domain, + topic, + data: data ?? {}, + }) + } + + async unsubscribe(domain: string, topic: string): Promise | null> { + this.subscriptions.delete(this.getSubscriptionKey(domain, topic)) + if (this.ws?.readyState !== WebSocket.OPEN) { + return null + } + + return await this.sendRequest({ + op: 'unsubscribe', + id: this.nextRequestId(), + domain, + topic, + data: {}, + }) + } + + addEventListener(listener: EventListener): () => void { + this.eventListeners.add(listener) + return () => { + this.eventListeners.delete(listener) + } + } + + onConnectionChange(listener: ConnectionListener): () => void { + this.connectionListeners.add(listener) + listener(this.status === 'connected') + return () => { + this.connectionListeners.delete(listener) + } + } + + onStatusChange(listener: StatusListener): () => void { + this.statusListeners.add(listener) + listener(this.status) + return () => { + this.statusListeners.delete(listener) + } + } + + onReconnect(listener: ReconnectListener): () => void { + this.reconnectListeners.add(listener) + return () => { + this.reconnectListeners.delete(listener) + } + } + + getStatus(): ConnectionStatus { + return this.status + } +} + +export const unifiedWsClient = new UnifiedWebSocketClient() diff --git a/dashboard/src/lib/ws-utils.ts b/dashboard/src/lib/ws-utils.ts deleted file mode 100644 index 3d3b3240..00000000 --- a/dashboard/src/lib/ws-utils.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { fetchWithAuth } from './fetch-with-auth' - -/** - * WebSocket 配置选项 - */ -export interface WebSocketOptions { - onMessage?: (data: string) => void - onOpen?: () => void - onClose?: () => void - onError?: (error: Event) => void - heartbeatInterval?: number // 心跳间隔(毫秒) - maxRetries?: number // 最大重连次数 - backoffBase?: number // 重连基础间隔(毫秒) - maxBackoff?: number // 最大重连间隔(毫秒) -} - -/** - * 获取 WebSocket 临时认证 token - */ -export async function getWsToken(): Promise { - try { - // 使用相对路径,让前端代理处理请求,避免 CORS 问题 - const response = await fetchWithAuth('/api/webui/ws-token', { - method: 'GET', - credentials: 'include', // 携带 Cookie - }) - - if (!response.ok) { - console.error('获取 WebSocket token 失败:', response.status) - return null - } - - const data = await response.json() - if (data.success && data.token) { - return data.token - } - return null - } catch (error) { - console.error('获取 WebSocket token 失败:', error) - return null - } -} - -/** - * 创建带重连、心跳的 WebSocket 封装 - * - * @param url WebSocket URL(不含 token 参数) - * @param options 配置选项 - * @returns WebSocket 控制对象,包含 connect、disconnect、send 方法 - */ -export function createReconnectingWebSocket( - url: string, - options: WebSocketOptions = {} -) { - const { - onMessage, - onOpen, - onClose, - onError, - heartbeatInterval = 30000, - maxRetries = 10, - backoffBase = 1000, - maxBackoff = 30000, - } = options - - let ws: WebSocket | null = null - let reconnectTimeout: number | null = null - let reconnectAttempts = 0 - let heartbeatIntervalId: number | null = null - let isManualDisconnect = false - - /** - * 启动心跳 - */ - function startHeartbeat() { - stopHeartbeat() - heartbeatIntervalId = window.setInterval(() => { - if (ws?.readyState === WebSocket.OPEN) { - ws.send('ping') - } - }, heartbeatInterval) - } - - /** - * 停止心跳 - */ - function stopHeartbeat() { - if (heartbeatIntervalId !== null) { - clearInterval(heartbeatIntervalId) - heartbeatIntervalId = null - } - } - - /** - * 尝试重连 - */ - function attemptReconnect() { - if (isManualDisconnect) { - return - } - - if (reconnectAttempts >= maxRetries) { - console.warn(`WebSocket 达到最大重连次数 (${maxRetries}),停止重连`) - return - } - - reconnectAttempts += 1 - const delay = Math.min(backoffBase * reconnectAttempts, maxBackoff) - - console.log(`WebSocket 将在 ${delay}ms 后重连(第 ${reconnectAttempts} 次)`) - reconnectTimeout = window.setTimeout(() => { - connect() - }, delay) - } - - /** - * 连接 WebSocket - */ - async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) { - return - } - - // 先获取临时认证 token - const wsToken = await getWsToken() - if (!wsToken) { - console.warn('无法获取 WebSocket token,跳过连接') - return - } - - const wsUrl = `${url}?token=${encodeURIComponent(wsToken)}` - - try { - ws = new WebSocket(wsUrl) - - ws.onopen = () => { - reconnectAttempts = 0 - startHeartbeat() - onOpen?.() - } - - ws.onmessage = (event) => { - // 忽略心跳响应 - if (event.data === 'pong') { - return - } - onMessage?.(event.data) - } - - ws.onerror = (error) => { - console.error('WebSocket 错误:', error) - onError?.(error) - } - - ws.onclose = () => { - stopHeartbeat() - onClose?.() - attemptReconnect() - } - } catch (error) { - console.error('创建 WebSocket 连接失败:', error) - attemptReconnect() - } - } - - /** - * 断开连接 - */ - function disconnect() { - isManualDisconnect = true - - if (reconnectTimeout !== null) { - clearTimeout(reconnectTimeout) - reconnectTimeout = null - } - - stopHeartbeat() - - if (ws) { - ws.close() - ws = null - } - - reconnectAttempts = 0 - } - - /** - * 发送消息 - */ - function send(data: string) { - if (ws?.readyState === WebSocket.OPEN) { - ws.send(data) - } else { - console.warn('WebSocket 未连接,无法发送消息') - } - } - - /** - * 获取当前 WebSocket 实例 - */ - function getWebSocket(): WebSocket | null { - return ws - } - - return { - connect, - disconnect, - send, - getWebSocket, - } -} diff --git a/dashboard/src/routes/chat/index.tsx b/dashboard/src/routes/chat/index.tsx index 5d7044a6..924264f2 100644 --- a/dashboard/src/routes/chat/index.tsx +++ b/dashboard/src/routes/chat/index.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { useToast } from '@/hooks/use-toast' -import { getWsBaseUrl } from '@/lib/api-base' +import { chatWsClient } from '@/lib/chat-ws-client' import { fetchWithAuth } from '@/lib/fetch-with-auth' import { cn } from '@/lib/utils' import { Bot, Edit2, Loader2, RefreshCw, User, Send, Wifi, WifiOff, UserCircle2 } from 'lucide-react' @@ -85,14 +85,17 @@ export function ChatPage() { // 持久化用户 ID const userIdRef = useRef(getOrCreateUserId()) - // 每个标签页的 WebSocket 连接 - const wsMapRef = useRef>(new Map()) const messagesEndRef = useRef(null) - const reconnectTimeoutMapRef = useRef>(new Map()) const messageIdCounterRef = useRef(0) const processedMessagesMapRef = useRef>>(new Map()) + const sessionUnsubscribeMapRef = useRef void>>(new Map()) + const tabsRef = useRef([]) const { toast } = useToast() + useEffect(() => { + tabsRef.current = tabs + }, [tabs]) + // 生成唯一消息 ID const generateMessageId = (prefix: string) => { messageIdCounterRef.current += 1 @@ -197,357 +200,218 @@ export function ChatPage() { } }, [tempVirtualConfig.platform, personSearchQuery, fetchPersons]) - // 加载聊天历史到指定标签页 - const loadChatHistoryForTab = useCallback(async (tabId: string, groupId?: string) => { + const handleSessionMessage = useCallback(( + tabId: string, + tabType: 'webui' | 'virtual', + config: VirtualIdentityConfig | undefined, + data: WsMessage, + ) => { + switch (data.type) { + case 'session_info': + updateTab(tabId, { + sessionInfo: { + session_id: data.session_id, + user_id: data.user_id, + user_name: data.user_name, + bot_name: data.bot_name, + } + }) + break + + case 'system': + addMessageToTab(tabId, { + id: generateMessageId('sys'), + type: 'system', + content: data.content || '', + timestamp: data.timestamp || Date.now() / 1000, + }) + break + + case 'user_message': { + const senderUserId = data.sender?.user_id + const currentUserId = tabType === 'virtual' && config + ? config.userId + : userIdRef.current + + const normalizeSenderId = senderUserId ? senderUserId.replace(/^webui_user_/, '') : '' + const normalizeCurrentId = currentUserId ? currentUserId.replace(/^webui_user_/, '') : '' + if (normalizeSenderId && normalizeCurrentId && normalizeSenderId === normalizeCurrentId) { + break + } + + const processedSet = processedMessagesMapRef.current.get(tabId) || new Set() + const contentHash = `user-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}` + if (processedSet.has(contentHash)) { + break + } + + processedSet.add(contentHash) + processedMessagesMapRef.current.set(tabId, processedSet) + if (processedSet.size > 100) { + const firstKey = processedSet.values().next().value + if (firstKey) processedSet.delete(firstKey) + } + + addMessageToTab(tabId, { + id: data.message_id || generateMessageId('user'), + type: 'user', + content: data.content || '', + timestamp: data.timestamp || Date.now() / 1000, + sender: data.sender, + }) + break + } + + case 'bot_message': { + updateTab(tabId, { isTyping: false }) + const processedSet = processedMessagesMapRef.current.get(tabId) || new Set() + const contentHash = `bot-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}` + if (processedSet.has(contentHash)) { + break + } + + processedSet.add(contentHash) + processedMessagesMapRef.current.set(tabId, processedSet) + if (processedSet.size > 100) { + const firstKey = processedSet.values().next().value + if (firstKey) processedSet.delete(firstKey) + } + + setTabs(prev => prev.map(tab => { + if (tab.id !== tabId) return tab + const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking') + const newMessage: ChatMessage = { + id: generateMessageId('bot'), + type: 'bot', + content: data.content || '', + message_type: (data.message_type === 'rich' ? 'rich' : 'text') as 'text' | 'rich', + segments: data.segments, + timestamp: data.timestamp || Date.now() / 1000, + sender: data.sender, + } + return { + ...tab, + messages: [...filteredMessages, newMessage] + } + })) + break + } + + case 'typing': + updateTab(tabId, { isTyping: data.is_typing || false }) + break + + case 'error': + setTabs(prev => prev.map(tab => { + if (tab.id !== tabId) return tab + const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking') + return { + ...tab, + messages: [...filteredMessages, { + id: generateMessageId('error'), + type: 'error' as const, + content: data.content || '发生错误', + timestamp: data.timestamp || Date.now() / 1000, + }] + } + })) + toast({ + title: '错误', + description: data.content, + variant: 'destructive', + }) + break + + case 'history': { + const historyMessages = data.messages || [] + const processedSet = new Set() + const formattedMessages: ChatMessage[] = historyMessages.map((msg: { + id?: string + content: string + timestamp: number + sender_name?: string + sender_id?: string + is_bot?: boolean + }) => { + const isBot = msg.is_bot || false + const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user') + const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}` + processedSet.add(contentHash) + return { + id: msgId, + type: isBot ? 'bot' : 'user' as const, + content: msg.content, + timestamp: msg.timestamp, + sender: { + name: msg.sender_name || (isBot ? '麦麦' : '用户'), + user_id: msg.sender_id, + is_bot: isBot, + }, + } + }) + + processedMessagesMapRef.current.set(tabId, processedSet) + updateTab(tabId, { messages: formattedMessages }) + setIsLoadingHistory(false) + break + } + + default: + break + } + }, [addMessageToTab, toast, updateTab]) + + const ensureSessionListener = useCallback(( + tabId: string, + tabType: 'webui' | 'virtual', + config?: VirtualIdentityConfig, + ) => { + if (sessionUnsubscribeMapRef.current.has(tabId)) { + return + } + + const unsubscribe = chatWsClient.onSessionMessage(tabId, (message) => { + handleSessionMessage(tabId, tabType, config, message as unknown as WsMessage) + }) + sessionUnsubscribeMapRef.current.set(tabId, unsubscribe) + }, [handleSessionMessage]) + + const openSessionForTab = useCallback(async ( + tabId: string, + tabType: 'webui' | 'virtual', + config?: VirtualIdentityConfig, + ) => { + ensureSessionListener(tabId, tabType, config) setIsLoadingHistory(true) + try { - const params = new URLSearchParams() - params.append('user_id', userIdRef.current) - params.append('limit', '50') - if (groupId) { - params.append('group_id', groupId) + if (tabType === 'virtual' && config) { + await chatWsClient.openSession(tabId, { + user_id: config.userId, + user_name: config.userName, + platform: config.platform, + person_id: config.personId, + group_name: config.groupName || 'WebUI虚拟群聊', + group_id: config.groupId, + }) + } else { + await chatWsClient.openSession(tabId, { + user_id: userIdRef.current, + user_name: userName, + }) } - const url = `/api/chat/history?${params.toString()}` - console.log('[Chat] 正在加载历史消息:', url) - - const response = await fetchWithAuth(url) - - if (response.ok) { - const text = await response.text() - try { - const data = JSON.parse(text) - - if (data.messages && data.messages.length > 0) { - const historyMessages: ChatMessage[] = data.messages.map((msg: { - id: string - type: string - content: string - timestamp: number - sender_name?: string - user_id?: string - is_bot?: boolean - }) => ({ - id: msg.id, - type: msg.type as 'user' | 'bot' | 'system' | 'error', - content: msg.content, - timestamp: msg.timestamp, - sender: { - name: msg.sender_name || (msg.is_bot ? '麦麦' : 'WebUI用户'), - user_id: msg.user_id, - is_bot: msg.is_bot - } - })) - - // 更新标签页的消息 - updateTab(tabId, { messages: historyMessages }) - - // 将历史消息添加到去重缓存 - const processedSet = processedMessagesMapRef.current.get(tabId) || new Set() - historyMessages.forEach(msg => { - if (msg.type === 'bot') { - const contentHash = `bot-${msg.content}-${Math.floor(msg.timestamp * 1000)}` - processedSet.add(contentHash) - } - }) - processedMessagesMapRef.current.set(tabId, processedSet) - } - } catch (parseError) { - console.error('[Chat] JSON 解析失败:', parseError) - } - } - } catch (e) { - console.error('[Chat] 加载历史消息失败:', e) - } finally { - setIsLoadingHistory(false) - } - }, [updateTab]) - // 为指定标签页连接 WebSocket(异步,需要先获取认证 token) - const connectWebSocketForTab = useCallback(async (tabId: string, tabType: 'webui' | 'virtual', config?: VirtualIdentityConfig) => { - // 如果已经有连接,不要重复创建 - const existingWs = wsMapRef.current.get(tabId) - if (existingWs?.readyState === WebSocket.OPEN || - existingWs?.readyState === WebSocket.CONNECTING) { - console.log(`[Tab ${tabId}] WebSocket 已存在,跳过连接`) - return - } - - setIsConnecting(true) - - // 先获取临时 WebSocket token - let wsToken: string | null = null - try { - const tokenResponse = await fetchWithAuth('/api/webui/ws-token') - if (tokenResponse.ok) { - const tokenData = await tokenResponse.json() - if (tokenData.success && tokenData.token) { - wsToken = tokenData.token - } else { - console.warn(`[Tab ${tabId}] 获取 WebSocket token 失败: ${tokenData.message || '未登录'}`) - setIsConnecting(false) - return - } - } + updateTab(tabId, { isConnected: true }) } catch (error) { - console.error(`[Tab ${tabId}] 获取 WebSocket token 失败:`, error) - setIsConnecting(false) - return + console.error(`[Tab ${tabId}] 打开聊天会话失败:`, error) + setIsLoadingHistory(false) + toast({ + title: '连接失败', + description: '无法建立聊天会话,请稍后重试', + variant: 'destructive', + }) } - - // 此时 wsToken 一定有值(前面已经 return) - if (!wsToken) { - setIsConnecting(false) - return - } - - const wsBase = await getWsBaseUrl() - const params = new URLSearchParams() - - // 添加 token 到参数 - params.append('token', wsToken) - - if (tabType === 'virtual' && config) { - params.append('user_id', config.userId) - params.append('user_name', config.userName) - params.append('platform', config.platform) - params.append('person_id', config.personId) - params.append('group_name', config.groupName || 'WebUI虚拟群聊') - // 传递稳定的 group_id,确保历史记录能正确加载 - if (config.groupId) { - params.append('group_id', config.groupId) - } - } else { - params.append('user_id', userIdRef.current) - params.append('user_name', userName) - } - - const wsUrl = `${wsBase}/api/chat/ws?${params.toString()}` - console.log(`[Tab ${tabId}] 正在连接 WebSocket:`, wsUrl) - - try { - const ws = new WebSocket(wsUrl) - wsMapRef.current.set(tabId, ws) - - ws.onopen = () => { - updateTab(tabId, { isConnected: true }) - setIsConnecting(false) - console.log(`[Tab ${tabId}] WebSocket 已连接`) - } - - ws.onmessage = (event) => { - try { - const data: WsMessage = JSON.parse(event.data) - - switch (data.type) { - case 'session_info': - updateTab(tabId, { - sessionInfo: { - session_id: data.session_id, - user_id: data.user_id, - user_name: data.user_name, - bot_name: data.bot_name, - } - }) - break - - case 'system': - addMessageToTab(tabId, { - id: generateMessageId('sys'), - type: 'system', - content: data.content || '', - timestamp: data.timestamp || Date.now() / 1000, - }) - break - - case 'user_message': { - // 检查是否是自己发的消息(已在发送时显示,跳过广播回来的) - const senderUserId = data.sender?.user_id - const currentUserId = tabType === 'virtual' && config - ? config.userId - : userIdRef.current - - console.log(`[Tab ${tabId}] 收到 user_message, sender: ${senderUserId}, current: ${currentUserId}`) - - // 标准化 user_id(去掉可能的前缀) - const normalizeSenderId = senderUserId ? senderUserId.replace(/^webui_user_/, '') : '' - const normalizeCurrentId = currentUserId ? currentUserId.replace(/^webui_user_/, '') : '' - - // 如果是自己发的消息,跳过(避免重复显示) - if (normalizeSenderId && normalizeCurrentId && normalizeSenderId === normalizeCurrentId) { - console.log(`[Tab ${tabId}] 跳过自己的消息(user_id 匹配)`) - break - } - - // 额外的消息去重:检查内容和时间戳 - const processedSet = processedMessagesMapRef.current.get(tabId) || new Set() - const contentHash = `user-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}` - if (processedSet.has(contentHash)) { - console.log(`[Tab ${tabId}] 跳过自己的消息(内容去重)`) - break - } - processedSet.add(contentHash) - processedMessagesMapRef.current.set(tabId, processedSet) - - if (processedSet.size > 100) { - const firstKey = processedSet.values().next().value - if (firstKey) processedSet.delete(firstKey) - } - - addMessageToTab(tabId, { - id: data.message_id || generateMessageId('user'), - type: 'user', - content: data.content || '', - timestamp: data.timestamp || Date.now() / 1000, - sender: data.sender, - }) - break - } - - case 'bot_message': { - updateTab(tabId, { isTyping: false }) - const processedSet = processedMessagesMapRef.current.get(tabId) || new Set() - const contentHash = `bot-${data.content}-${Math.floor((data.timestamp || 0) * 1000)}` - if (processedSet.has(contentHash)) { - break - } - processedSet.add(contentHash) - processedMessagesMapRef.current.set(tabId, processedSet) - - if (processedSet.size > 100) { - const firstKey = processedSet.values().next().value - if (firstKey) processedSet.delete(firstKey) - } - - // 移除"思考中"占位消息,添加真实的机器人回复 - setTabs(prev => prev.map(tab => { - if (tab.id !== tabId) return tab - // 过滤掉 thinking 类型的消息 - const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking') - const newMessage: ChatMessage = { - id: generateMessageId('bot'), - type: 'bot', - content: data.content || '', - message_type: (data.message_type === 'rich' ? 'rich' : 'text') as 'text' | 'rich', - segments: data.segments, - timestamp: data.timestamp || Date.now() / 1000, - sender: data.sender, - } - return { - ...tab, - messages: [...filteredMessages, newMessage] - } - })) - break - } - - case 'typing': - updateTab(tabId, { isTyping: data.is_typing || false }) - break - - case 'error': - // 移除"思考中"占位消息,显示错误 - setTabs(prev => prev.map(tab => { - if (tab.id !== tabId) return tab - const filteredMessages = tab.messages.filter(msg => msg.type !== 'thinking') - return { - ...tab, - messages: [...filteredMessages, { - id: generateMessageId('error'), - type: 'error' as const, - content: data.content || '发生错误', - timestamp: data.timestamp || Date.now() / 1000, - }] - } - })) - toast({ - title: '错误', - description: data.content, - variant: 'destructive', - }) - break - - case 'pong': - break - - case 'history': { - // 处理服务端发送的历史消息 - const historyMessages = data.messages || [] - if (historyMessages.length > 0) { - const processedSet = processedMessagesMapRef.current.get(tabId) || new Set() - const formattedMessages: ChatMessage[] = historyMessages.map((msg: { - id?: string - content: string - timestamp: number - sender_name?: string - sender_id?: string - is_bot?: boolean - }) => { - const isBot = msg.is_bot || false - const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user') - // 添加到去重集合 - const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}` - processedSet.add(contentHash) - return { - id: msgId, - type: isBot ? 'bot' : 'user' as const, - content: msg.content, - timestamp: msg.timestamp, - sender: { - name: msg.sender_name || (isBot ? '麦麦' : '用户'), - user_id: msg.sender_id, - is_bot: isBot, - }, - } - }) - processedMessagesMapRef.current.set(tabId, processedSet) - // 替换当前标签页的所有消息 - updateTab(tabId, { messages: formattedMessages }) - console.log(`[Tab ${tabId}] 已加载 ${formattedMessages.length} 条历史消息`) - } - break - } - - default: - console.log('未知消息类型:', data.type) - } - } catch (e) { - console.error('解析消息失败:', e) - } - } - - ws.onclose = () => { - updateTab(tabId, { isConnected: false }) - setIsConnecting(false) - wsMapRef.current.delete(tabId) - console.log(`[Tab ${tabId}] WebSocket 已断开`) - - // 清除旧的重连定时器 - const oldTimeout = reconnectTimeoutMapRef.current.get(tabId) - if (oldTimeout) { - clearTimeout(oldTimeout) - } - - // 5秒后尝试重连 - const timeout = window.setTimeout(() => { - if (!isUnmountedRef.current) { - const tab = tabs.find(t => t.id === tabId) - if (tab) { - connectWebSocketForTab(tabId, tab.type, tab.virtualConfig) - } - } - }, 5000) - reconnectTimeoutMapRef.current.set(tabId, timeout) - } - - ws.onerror = (error) => { - console.error(`[Tab ${tabId}] WebSocket 错误:`, error) - setIsConnecting(false) - } - } catch (e) { - console.error(`[Tab ${tabId}] 创建 WebSocket 失败:`, e) - setIsConnecting(false) - } - }, [userName, updateTab, addMessageToTab, toast, tabs]) + }, [ensureSessionListener, toast, updateTab, userName]) // 用于追踪组件是否已卸载 const isUnmountedRef = useRef(false) @@ -555,69 +419,49 @@ export function ChatPage() { // 初始化连接(默认 WebUI 标签页) useEffect(() => { isUnmountedRef.current = false - - // 保存 ref 的当前值,用于清理 - const wsMap = wsMapRef.current - const reconnectTimeoutMap = reconnectTimeoutMapRef.current - const processedMessagesMap = processedMessagesMapRef.current - - // 加载默认标签页历史消息 - loadChatHistoryForTab('webui-default') - - // 延迟连接 - const connectTimer = setTimeout(() => { - if (!isUnmountedRef.current) { - connectWebSocketForTab('webui-default', 'webui') - - // 恢复的虚拟标签页也需要建立连接 - tabs.forEach(tab => { - if (tab.type === 'virtual' && tab.virtualConfig) { - // 初始化去重缓存 - processedMessagesMap.set(tab.id, new Set()) - // 建立 WebSocket 连接 - setTimeout(() => { - if (!isUnmountedRef.current) { - connectWebSocketForTab(tab.id, 'virtual', tab.virtualConfig) - } - }, 200) - } - }) - } - }, 100) - // 心跳定时器 - 向所有活动连接发送 - const heartbeat = setInterval(() => { - wsMap.forEach((ws) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'ping' })) - } - }) - }, 30000) + const unsubscribeConnection = chatWsClient.onConnectionChange((connected) => { + if (isUnmountedRef.current) { + return + } + + setTabs(prev => prev.map(tab => ({ + ...tab, + isConnected: connected, + }))) + }) + + const unsubscribeStatus = chatWsClient.onStatusChange((status) => { + if (!isUnmountedRef.current) { + setIsConnecting(status === 'connecting') + } + }) + + tabs.forEach(tab => { + processedMessagesMapRef.current.set(tab.id, new Set()) + void openSessionForTab(tab.id, tab.type, tab.virtualConfig) + }) return () => { isUnmountedRef.current = true - clearTimeout(connectTimer) - clearInterval(heartbeat) - - // 清理所有重连定时器 - reconnectTimeoutMap.forEach((timeout) => { - clearTimeout(timeout) + unsubscribeConnection() + unsubscribeStatus() + + sessionUnsubscribeMapRef.current.forEach((unsubscribe) => { + unsubscribe() }) - reconnectTimeoutMap.clear() - - // 关闭所有 WebSocket 连接 - wsMap.forEach((ws) => { - ws.close() + sessionUnsubscribeMapRef.current.clear() + + tabsRef.current.forEach(tab => { + void chatWsClient.closeSession(tab.id) }) - wsMap.clear() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 发送消息到当前活动标签页 - const sendMessage = useCallback(() => { - const ws = wsMapRef.current.get(activeTabId) - if (!inputValue.trim() || !ws || ws.readyState !== WebSocket.OPEN) { + const sendMessage = useCallback(async () => { + if (!inputValue.trim() || !activeTab?.isConnected) { return } @@ -628,12 +472,6 @@ export function ChatPage() { const messageContent = inputValue.trim() const currentTimestamp = Date.now() / 1000 - ws.send(JSON.stringify({ - type: 'message', - content: messageContent, - user_name: displayName, - })) - // 添加到去重缓存,防止服务器广播回来的消息重复显示 const processedSet = processedMessagesMapRef.current.get(activeTabId) || new Set() const contentHash = `user-${messageContent}-${Math.floor(currentTimestamp * 1000)}` @@ -672,13 +510,32 @@ export function ChatPage() { addMessageToTab(activeTabId, thinkingMessage) setInputValue('') - }, [inputValue, userName, activeTabId, activeTab, addMessageToTab]) + + try { + await chatWsClient.sendMessage(activeTabId, messageContent, displayName) + } catch (error) { + console.error('发送聊天消息失败:', error) + setTabs(prev => prev.map(tab => { + if (tab.id !== activeTabId) return tab + return { + ...tab, + isTyping: false, + messages: tab.messages.filter(msg => msg.type !== 'thinking') + } + })) + toast({ + title: '发送失败', + description: '当前聊天会话不可用,请稍后重试', + variant: 'destructive', + }) + } + }, [activeTab, activeTabId, addMessageToTab, inputValue, toast, userName]) // 处理键盘事件 const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() - sendMessage() + void sendMessage() } } @@ -693,13 +550,9 @@ export function ChatPage() { setUserName(newName) saveUserName(newName) setIsEditingName(false) - // 通知当前标签页的后端昵称变更 - const ws = wsMapRef.current.get(activeTabId) - if (ws?.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'update_nickname', - user_name: newName - })) + + if (activeTab?.isConnected) { + void chatWsClient.updateNickname(activeTabId, newName) } } @@ -719,12 +572,7 @@ export function ChatPage() { // 重新连接当前标签页 const handleReconnect = () => { - const ws = wsMapRef.current.get(activeTabId) - if (ws) { - ws.close() - wsMapRef.current.delete(activeTabId) - } - connectWebSocketForTab(activeTabId, activeTab?.type || 'webui', activeTab?.virtualConfig) + void chatWsClient.restart() } // 打开虚拟身份配置对话框(新建标签页用) @@ -795,10 +643,10 @@ export function ChatPage() { // 初始化去重缓存 processedMessagesMapRef.current.set(newTabId, new Set()) - // 连接 WebSocket - setTimeout(() => { - connectWebSocketForTab(newTabId, 'virtual', tempVirtualConfig) - }, 100) + void openSessionForTab(newTabId, 'virtual', { + ...tempVirtualConfig, + groupId: stableGroupId, + }) toast({ title: '虚拟身份标签页', @@ -814,20 +662,14 @@ export function ChatPage() { if (tabId === 'webui-default') { return } - - // 关闭 WebSocket 连接 - const ws = wsMapRef.current.get(tabId) - if (ws) { - ws.close() - wsMapRef.current.delete(tabId) - } - - // 清理重连定时器 - const timeout = reconnectTimeoutMapRef.current.get(tabId) - if (timeout) { - clearTimeout(timeout) - reconnectTimeoutMapRef.current.delete(tabId) + + const unsubscribe = sessionUnsubscribeMapRef.current.get(tabId) + if (unsubscribe) { + unsubscribe() + sessionUnsubscribeMapRef.current.delete(tabId) } + + void chatWsClient.closeSession(tabId) // 清理去重缓存 processedMessagesMapRef.current.delete(tabId) @@ -1133,7 +975,7 @@ export function ChatPage() { className="flex-1 h-10 sm:h-10" />