From a1fa469026d5b436c34e22733a1e550cfe0db1e9 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 26 Mar 2026 02:45:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=9B=B8=E5=85=B3agent=E5=B7=A5=E5=85=B7=EF=BC=88=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=8F=92=E4=BB=B6=E3=80=81=E6=9F=A5=E8=AF=A2=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=83=BD=E5=8A=9B=E3=80=81=E8=BF=90=E8=A1=8C=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=91=BD=E4=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/tools/factory.py | 6 + .../tools/impl/query_installed_plugins.py | 71 +++++++++++ .../tools/impl/query_plugin_capabilities.py | 117 ++++++++++++++++++ app/agent/tools/impl/run_plugin_command.py | 111 +++++++++++++++++ 4 files changed, 305 insertions(+) create mode 100644 app/agent/tools/impl/query_installed_plugins.py create mode 100644 app/agent/tools/impl/query_plugin_capabilities.py create mode 100644 app/agent/tools/impl/run_plugin_command.py diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index b5f60f4d..52d10bf6 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -46,6 +46,9 @@ from app.agent.tools.impl.edit_file import EditFileTool from app.agent.tools.impl.write_file import WriteFileTool from app.agent.tools.impl.read_file import ReadFileTool from app.agent.tools.impl.browse_webpage import BrowseWebpageTool +from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool +from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool +from app.agent.tools.impl.run_plugin_command import RunPluginCommandTool from app.core.plugin import PluginManager from app.log import logger from .base import MoviePilotTool @@ -116,6 +119,9 @@ class MoviePilotToolFactory: WriteFileTool, ReadFileTool, BrowseWebpageTool, + QueryInstalledPluginsTool, + QueryPluginCapabilitiesTool, + RunPluginCommandTool, ] # 创建内置工具 for ToolClass in tool_definitions: diff --git a/app/agent/tools/impl/query_installed_plugins.py b/app/agent/tools/impl/query_installed_plugins.py new file mode 100644 index 00000000..f79a0438 --- /dev/null +++ b/app/agent/tools/impl/query_installed_plugins.py @@ -0,0 +1,71 @@ +"""查询已安装插件工具""" + +import json +from typing import Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.core.plugin import PluginManager +from app.log import logger + + +class QueryInstalledPluginsInput(BaseModel): + """查询已安装插件工具的输入参数模型""" + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + + +class QueryInstalledPluginsTool(MoviePilotTool): + name: str = "query_installed_plugins" + description: str = ( + "Query all installed plugins in MoviePilot. Returns a list of installed plugins with their ID, name, " + "description, version, author, running state, and other information. " + "Use this tool to discover what plugins are available before querying plugin capabilities or running plugin commands." + ) + args_schema: Type[BaseModel] = QueryInstalledPluginsInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """生成友好的提示消息""" + return "正在查询已安装插件" + + async def run(self, **kwargs) -> str: + logger.info(f"执行工具: {self.name}") + try: + plugin_manager = PluginManager() + local_plugins = plugin_manager.get_local_plugins() + # 仅返回已安装的插件 + installed_plugins = [plugin for plugin in local_plugins if plugin.installed] + + if not installed_plugins: + return "当前没有已安装的插件" + + plugins_list = [] + for plugin in installed_plugins: + plugins_list.append( + { + "id": plugin.id, + "plugin_name": plugin.plugin_name, + "plugin_desc": plugin.plugin_desc, + "plugin_version": plugin.plugin_version, + "plugin_author": plugin.plugin_author, + "state": plugin.state, + "has_page": plugin.has_page, + } + ) + + total_count = len(plugins_list) + result_json = json.dumps(plugins_list, ensure_ascii=False, indent=2) + + if total_count > 50: + limited_plugins = plugins_list[:50] + limited_json = json.dumps(limited_plugins, ensure_ascii=False, indent=2) + return f"注意:共找到 {total_count} 个已安装插件,为节省上下文空间,仅显示前 50 个。\n\n{limited_json}" + + return result_json + except Exception as e: + logger.error(f"查询已安装插件失败: {e}", exc_info=True) + return f"查询已安装插件时发生错误: {str(e)}" diff --git a/app/agent/tools/impl/query_plugin_capabilities.py b/app/agent/tools/impl/query_plugin_capabilities.py new file mode 100644 index 00000000..b83f0c82 --- /dev/null +++ b/app/agent/tools/impl/query_plugin_capabilities.py @@ -0,0 +1,117 @@ +"""查询插件能力工具""" + +import json +from typing import Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.core.plugin import PluginManager +from app.log import logger + + +class QueryPluginCapabilitiesInput(BaseModel): + """查询插件能力工具的输入参数模型""" + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + plugin_id: Optional[str] = Field( + None, + description="Optional plugin ID to query capabilities for a specific plugin. " + "If not provided, returns capabilities of all running plugins. " + "Use query_installed_plugins tool to get the plugin IDs first.", + ) + + +class QueryPluginCapabilitiesTool(MoviePilotTool): + name: str = "query_plugin_capabilities" + description: str = ( + "Query the capabilities of installed plugins, including supported commands and scheduled services. " + "Commands are slash-commands (e.g. /xxx) that can be executed via the run_plugin_command tool. " + "Scheduled services are periodic tasks that can be triggered via the run_scheduler tool. " + "Optionally specify a plugin_id to query a specific plugin, or omit to query all running plugins." + ) + args_schema: Type[BaseModel] = QueryPluginCapabilitiesInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """生成友好的提示消息""" + plugin_id = kwargs.get("plugin_id") + if plugin_id: + return f"正在查询插件 {plugin_id} 的能力" + return "正在查询所有插件的能力" + + async def run(self, plugin_id: Optional[str] = None, **kwargs) -> str: + logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}") + try: + plugin_manager = PluginManager() + result = {} + + # 获取插件命令 + commands = plugin_manager.get_plugin_commands(pid=plugin_id) + if commands: + commands_list = [] + for cmd in commands: + cmd_info = { + "cmd": cmd.get("cmd"), + "desc": cmd.get("desc"), + "plugin_id": cmd.get("pid"), + } + # data 字段可能包含额外参数信息 + if cmd.get("data"): + cmd_info["data"] = cmd.get("data") + commands_list.append(cmd_info) + result["commands"] = commands_list + + # 获取插件动作 + actions = plugin_manager.get_plugin_actions(pid=plugin_id) + if actions: + actions_list = [] + for action_group in actions: + plugin_actions = { + "plugin_id": action_group.get("plugin_id"), + "plugin_name": action_group.get("plugin_name"), + "actions": [], + } + for action in action_group.get("actions", []): + plugin_actions["actions"].append( + { + "id": action.get("id"), + "name": action.get("name"), + } + ) + actions_list.append(plugin_actions) + result["actions"] = actions_list + + # 获取插件定时服务 + services = plugin_manager.get_plugin_services(pid=plugin_id) + if services: + services_list = [] + for svc in services: + svc_info = { + "id": svc.get("id"), + "name": svc.get("name"), + } + # 包含触发器信息 + trigger = svc.get("trigger") + if trigger: + svc_info["trigger"] = str(trigger) + # 包含定时器参数 + svc_kwargs = svc.get("kwargs") + if svc_kwargs: + svc_info["trigger_kwargs"] = { + k: str(v) for k, v in svc_kwargs.items() + } + services_list.append(svc_info) + result["services"] = services_list + + if not result: + if plugin_id: + return f"插件 {plugin_id} 没有注册任何命令、动作或定时服务" + return "当前没有运行中的插件注册了命令、动作或定时服务" + + return json.dumps(result, ensure_ascii=False, indent=2) + except Exception as e: + logger.error(f"查询插件能力失败: {e}", exc_info=True) + return f"查询插件能力时发生错误: {str(e)}" diff --git a/app/agent/tools/impl/run_plugin_command.py b/app/agent/tools/impl/run_plugin_command.py new file mode 100644 index 00000000..1aaa67af --- /dev/null +++ b/app/agent/tools/impl/run_plugin_command.py @@ -0,0 +1,111 @@ +"""运行插件命令工具""" + +import json +from typing import Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.core.event import eventmanager +from app.core.plugin import PluginManager +from app.log import logger +from app.schemas.types import EventType, MessageChannel + + +class RunPluginCommandInput(BaseModel): + """运行插件命令工具的输入参数模型""" + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + command: str = Field( + ..., + description="The slash command to execute, e.g. '/cookiecloud'. " + "Must start with '/'. Can include arguments after the command, e.g. '/command arg1 arg2'. " + "Use query_plugin_capabilities tool to discover available commands first.", + ) + + +class RunPluginCommandTool(MoviePilotTool): + name: str = "run_plugin_command" + description: str = ( + "Execute a plugin command by sending a CommandExcute event. " + "Plugin commands are slash-commands (starting with '/') registered by plugins. " + "Use the query_plugin_capabilities tool first to discover available commands and their descriptions. " + "The command will be executed asynchronously. " + "Note: This tool triggers the command execution but the actual processing happens in the background." + ) + args_schema: Type[BaseModel] = RunPluginCommandInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """生成友好的提示消息""" + command = kwargs.get("command", "") + return f"正在执行插件命令: {command}" + + async def run(self, command: str, **kwargs) -> str: + logger.info(f"执行工具: {self.name}, 参数: command={command}") + + try: + # 确保命令以 / 开头 + if not command.startswith("/"): + command = f"/{command}" + + # 验证命令是否存在 + plugin_manager = PluginManager() + registered_commands = plugin_manager.get_plugin_commands() + cmd_name = command.split()[0] + matched_command = None + for cmd in registered_commands: + if cmd.get("cmd") == cmd_name: + matched_command = cmd + break + + if not matched_command: + # 列出可用命令帮助用户 + available_cmds = [ + f"{cmd.get('cmd')} - {cmd.get('desc', '无描述')}" + for cmd in registered_commands + ] + result = { + "success": False, + "message": f"命令 {cmd_name} 不存在", + } + if available_cmds: + result["available_commands"] = available_cmds + return json.dumps(result, ensure_ascii=False, indent=2) + + # 构建消息渠道,优先使用当前会话的渠道信息 + channel = None + if self._channel: + try: + channel = MessageChannel(self._channel) + except (ValueError, KeyError): + channel = None + + # 发送命令执行事件,与 message.py 中的方式一致 + eventmanager.send_event( + EventType.CommandExcute, + { + "cmd": command, + "user": self._user_id, + "channel": channel, + "source": self._source, + }, + ) + + result = { + "success": True, + "message": f"命令 {cmd_name} 已触发执行", + "command": command, + "command_desc": matched_command.get("desc", ""), + "plugin_id": matched_command.get("pid", ""), + } + return json.dumps(result, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"执行插件命令失败: {e}", exc_info=True) + return json.dumps( + {"success": False, "message": f"执行插件命令时发生错误: {str(e)}"}, + ensure_ascii=False, + )