From f684a7f6f06920e0054501c253fe0048ed36d058 Mon Sep 17 00:00:00 2001 From: "tuyang.yhj" Date: Wed, 10 May 2023 16:42:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=BC=E5=AE=B9autogpt=20plugin=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 3 + .idea/DB-GPT.iml | 12 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + pilot/agent/base_open_ai_plugin.py | 199 ++++++++++++ pilot/configs/config.py | 19 +- pilot/log/__init__.py | 0 pilot/log/json_handler.py | 20 ++ pilot/logs.py | 287 ++++++++++++++++++ pilot/plugins.py | 275 +++++++++++++++++ pilot/speech/__init__.py | 3 + pilot/speech/base.py | 50 +++ pilot/speech/brian.py | 43 +++ pilot/speech/eleven_labs.py | 88 ++++++ pilot/speech/gtts.py | 22 ++ pilot/speech/macos_tts.py | 21 ++ pilot/speech/say.py | 46 +++ .../Auto-GPT-TiDB-Serverless-Plugin-main.zip | Bin 0 -> 11819 bytes plugins/__PUT_PLUGIN_ZIPS_HERE__ | 0 requirements.txt | 21 +- tests/__init__.py | 0 .../Auto-GPT-Plugin-Test-master.zip | Bin 0 -> 14927 bytes tests/unit/test_plugins.py | 135 ++++++++ 25 files changed, 1264 insertions(+), 4 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/DB-GPT.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 pilot/agent/base_open_ai_plugin.py create mode 100644 pilot/log/__init__.py create mode 100644 pilot/log/json_handler.py create mode 100644 pilot/logs.py create mode 100644 pilot/plugins.py create mode 100644 pilot/speech/__init__.py create mode 100644 pilot/speech/base.py create mode 100644 pilot/speech/brian.py create mode 100644 pilot/speech/eleven_labs.py create mode 100644 pilot/speech/gtts.py create mode 100644 pilot/speech/macos_tts.py create mode 100644 pilot/speech/say.py create mode 100644 plugins/Auto-GPT-TiDB-Serverless-Plugin-main.zip create mode 100644 plugins/__PUT_PLUGIN_ZIPS_HERE__ create mode 100644 tests/__init__.py create mode 100644 tests/unit/data/test_plugins/Auto-GPT-Plugin-Test-master.zip create mode 100644 tests/unit/test_plugins.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/DB-GPT.iml b/.idea/DB-GPT.iml new file mode 100644 index 000000000..9725c1b01 --- /dev/null +++ b/.idea/DB-GPT.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000..105ce2da2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..e965926fe --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..a22f312c8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..94a25f7f4 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pilot/agent/base_open_ai_plugin.py b/pilot/agent/base_open_ai_plugin.py new file mode 100644 index 000000000..046295c0d --- /dev/null +++ b/pilot/agent/base_open_ai_plugin.py @@ -0,0 +1,199 @@ +"""Handles loading of plugins.""" +from typing import Any, Dict, List, Optional, Tuple, TypedDict, TypeVar + +from auto_gpt_plugin_template import AutoGPTPluginTemplate + +PromptGenerator = TypeVar("PromptGenerator") + + +class Message(TypedDict): + role: str + content: str + + +class BaseOpenAIPlugin(AutoGPTPluginTemplate): + """ + This is a BaseOpenAIPlugin class for generating Auto-GPT plugins. + """ + + def __init__(self, manifests_specs_clients: dict): + # super().__init__() + self._name = manifests_specs_clients["manifest"]["name_for_model"] + self._version = manifests_specs_clients["manifest"]["schema_version"] + self._description = manifests_specs_clients["manifest"]["description_for_model"] + self._client = manifests_specs_clients["client"] + self._manifest = manifests_specs_clients["manifest"] + self._openapi_spec = manifests_specs_clients["openapi_spec"] + + def can_handle_on_response(self) -> bool: + """This method is called to check that the plugin can + handle the on_response method. + Returns: + bool: True if the plugin can handle the on_response method.""" + return False + + def on_response(self, response: str, *args, **kwargs) -> str: + """This method is called when a response is received from the model.""" + return response + + def can_handle_post_prompt(self) -> bool: + """This method is called to check that the plugin can + handle the post_prompt method. + Returns: + bool: True if the plugin can handle the post_prompt method.""" + return False + + def post_prompt(self, prompt: PromptGenerator) -> PromptGenerator: + """This method is called just after the generate_prompt is called, + but actually before the prompt is generated. + Args: + prompt (PromptGenerator): The prompt generator. + Returns: + PromptGenerator: The prompt generator. + """ + return prompt + + def can_handle_on_planning(self) -> bool: + """This method is called to check that the plugin can + handle the on_planning method. + Returns: + bool: True if the plugin can handle the on_planning method.""" + return False + + def on_planning( + self, prompt: PromptGenerator, messages: List[Message] + ) -> Optional[str]: + """This method is called before the planning chat completion is done. + Args: + prompt (PromptGenerator): The prompt generator. + messages (List[str]): The list of messages. + """ + pass + + def can_handle_post_planning(self) -> bool: + """This method is called to check that the plugin can + handle the post_planning method. + Returns: + bool: True if the plugin can handle the post_planning method.""" + return False + + def post_planning(self, response: str) -> str: + """This method is called after the planning chat completion is done. + Args: + response (str): The response. + Returns: + str: The resulting response. + """ + return response + + def can_handle_pre_instruction(self) -> bool: + """This method is called to check that the plugin can + handle the pre_instruction method. + Returns: + bool: True if the plugin can handle the pre_instruction method.""" + return False + + def pre_instruction(self, messages: List[Message]) -> List[Message]: + """This method is called before the instruction chat is done. + Args: + messages (List[Message]): The list of context messages. + Returns: + List[Message]: The resulting list of messages. + """ + return messages + + def can_handle_on_instruction(self) -> bool: + """This method is called to check that the plugin can + handle the on_instruction method. + Returns: + bool: True if the plugin can handle the on_instruction method.""" + return False + + def on_instruction(self, messages: List[Message]) -> Optional[str]: + """This method is called when the instruction chat is done. + Args: + messages (List[Message]): The list of context messages. + Returns: + Optional[str]: The resulting message. + """ + pass + + def can_handle_post_instruction(self) -> bool: + """This method is called to check that the plugin can + handle the post_instruction method. + Returns: + bool: True if the plugin can handle the post_instruction method.""" + return False + + def post_instruction(self, response: str) -> str: + """This method is called after the instruction chat is done. + Args: + response (str): The response. + Returns: + str: The resulting response. + """ + return response + + def can_handle_pre_command(self) -> bool: + """This method is called to check that the plugin can + handle the pre_command method. + Returns: + bool: True if the plugin can handle the pre_command method.""" + return False + + def pre_command( + self, command_name: str, arguments: Dict[str, Any] + ) -> Tuple[str, Dict[str, Any]]: + """This method is called before the command is executed. + Args: + command_name (str): The command name. + arguments (Dict[str, Any]): The arguments. + Returns: + Tuple[str, Dict[str, Any]]: The command name and the arguments. + """ + return command_name, arguments + + def can_handle_post_command(self) -> bool: + """This method is called to check that the plugin can + handle the post_command method. + Returns: + bool: True if the plugin can handle the post_command method.""" + return False + + def post_command(self, command_name: str, response: str) -> str: + """This method is called after the command is executed. + Args: + command_name (str): The command name. + response (str): The response. + Returns: + str: The resulting response. + """ + return response + + def can_handle_chat_completion( + self, messages: Dict[Any, Any], model: str, temperature: float, max_tokens: int + ) -> bool: + """This method is called to check that the plugin can + handle the chat_completion method. + Args: + messages (List[Message]): The messages. + model (str): The model name. + temperature (float): The temperature. + max_tokens (int): The max tokens. + Returns: + bool: True if the plugin can handle the chat_completion method.""" + return False + + def handle_chat_completion( + self, messages: List[Message], model: str, temperature: float, max_tokens: int + ) -> str: + """This method is called when the chat completion is done. + Args: + messages (List[Message]): The messages. + model (str): The model name. + temperature (float): The temperature. + max_tokens (int): The max tokens. + Returns: + str: The resulting response. + """ + pass diff --git a/pilot/configs/config.py b/pilot/configs/config.py index 799bcd59b..ffc2a13ed 100644 --- a/pilot/configs/config.py +++ b/pilot/configs/config.py @@ -29,7 +29,14 @@ class Config(metaclass=Singleton): "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36" " (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36", ) - + + self.elevenlabs_api_key = os.getenv("ELEVENLABS_API_KEY") + self.elevenlabs_voice_1_id = os.getenv("ELEVENLABS_VOICE_1_ID") + self.elevenlabs_voice_2_id = os.getenv("ELEVENLABS_VOICE_2_ID") + + self.use_mac_os_tts = False + self.use_mac_os_tts = os.getenv("USE_MAC_OS_TTS") + # milvus or zilliz cloud configuration self.milvus_addr = os.getenv("MILVUS_ADDR", "localhost:19530") self.milvus_username = os.getenv("MILVUS_USERNAME") @@ -37,14 +44,20 @@ class Config(metaclass=Singleton): self.milvus_collection = os.getenv("MILVUS_COLLECTION", "dbgpt") self.milvus_secure = os.getenv("MILVUS_SECURE") == "True" + self.plugins_dir = os.getenv("PLUGINS_DIR", "plugins") + self.plugins: List[AutoGPTPluginTemplate] = [] + self.plugins_openai = [] + plugins_allowlist = os.getenv("ALLOWLISTED_PLUGINS") if plugins_allowlist: self.plugins_allowlist = plugins_allowlist.split(",") else: - self.plugins_allowlist = [] + self.plugins_allowlist = [] - plugins_denylist = os.getenv("DENYLISTED_PLUGINS") + plugins_denylist = os.getenv("DENYLISTED_PLUGINS") if plugins_denylist: + self.plugins_denylist = plugins_denylist.split(",") + else: self.plugins_denylist = [] def set_debug_mode(self, value: bool) -> None: diff --git a/pilot/log/__init__.py b/pilot/log/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pilot/log/json_handler.py b/pilot/log/json_handler.py new file mode 100644 index 000000000..51ae9ae03 --- /dev/null +++ b/pilot/log/json_handler.py @@ -0,0 +1,20 @@ +import json +import logging + + +class JsonFileHandler(logging.FileHandler): + def __init__(self, filename, mode="a", encoding=None, delay=False): + super().__init__(filename, mode, encoding, delay) + + def emit(self, record): + json_data = json.loads(self.format(record)) + with open(self.baseFilename, "w", encoding="utf-8") as f: + json.dump(json_data, f, ensure_ascii=False, indent=4) + + +import logging + + +class JsonFormatter(logging.Formatter): + def format(self, record): + return record.msg diff --git a/pilot/logs.py b/pilot/logs.py new file mode 100644 index 000000000..b5a1fad82 --- /dev/null +++ b/pilot/logs.py @@ -0,0 +1,287 @@ +import logging +import os +import random +import re +import time +from logging import LogRecord +from typing import Any + +from colorama import Fore, Style + +from pilot.log.json_handler import JsonFileHandler, JsonFormatter +from pilot.singleton import Singleton +from pilot.speech import say_text + + +class Logger(metaclass=Singleton): + """ + Logger that handle titles in different colors. + Outputs logs in console, activity.log, and errors.log + For console handler: simulates typing + """ + + def __init__(self): + # create log directory if it doesn't exist + this_files_dir_path = os.path.dirname(__file__) + log_dir = os.path.join(this_files_dir_path, "../logs") + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + log_file = "activity.log" + error_file = "error.log" + + console_formatter = DbGptFormatter("%(title_color)s %(message)s") + + # Create a handler for console which simulate typing + self.typing_console_handler = TypingConsoleHandler() + self.typing_console_handler.setLevel(logging.INFO) + self.typing_console_handler.setFormatter(console_formatter) + + # Create a handler for console without typing simulation + self.console_handler = ConsoleHandler() + self.console_handler.setLevel(logging.DEBUG) + self.console_handler.setFormatter(console_formatter) + + # Info handler in activity.log + self.file_handler = logging.FileHandler( + os.path.join(log_dir, log_file), "a", "utf-8" + ) + self.file_handler.setLevel(logging.DEBUG) + info_formatter = DbGptFormatter( + "%(asctime)s %(levelname)s %(title)s %(message_no_color)s" + ) + self.file_handler.setFormatter(info_formatter) + + # Error handler error.log + error_handler = logging.FileHandler( + os.path.join(log_dir, error_file), "a", "utf-8" + ) + error_handler.setLevel(logging.ERROR) + error_formatter = DbGptFormatter( + "%(asctime)s %(levelname)s %(module)s:%(funcName)s:%(lineno)d %(title)s" + " %(message_no_color)s" + ) + error_handler.setFormatter(error_formatter) + + self.typing_logger = logging.getLogger("TYPER") + self.typing_logger.addHandler(self.typing_console_handler) + self.typing_logger.addHandler(self.file_handler) + self.typing_logger.addHandler(error_handler) + self.typing_logger.setLevel(logging.DEBUG) + + self.logger = logging.getLogger("LOGGER") + self.logger.addHandler(self.console_handler) + self.logger.addHandler(self.file_handler) + self.logger.addHandler(error_handler) + self.logger.setLevel(logging.DEBUG) + + self.json_logger = logging.getLogger("JSON_LOGGER") + self.json_logger.addHandler(self.file_handler) + self.json_logger.addHandler(error_handler) + self.json_logger.setLevel(logging.DEBUG) + + self.speak_mode = False + self.chat_plugins = [] + + def typewriter_log( + self, title="", title_color="", content="", speak_text=False, level=logging.INFO + ): + if speak_text and self.speak_mode: + say_text(f"{title}. {content}") + + for plugin in self.chat_plugins: + plugin.report(f"{title}. {content}") + + if content: + if isinstance(content, list): + content = " ".join(content) + else: + content = "" + + self.typing_logger.log( + level, content, extra={"title": title, "color": title_color} + ) + + def debug( + self, + message, + title="", + title_color="", + ): + self._log(title, title_color, message, logging.DEBUG) + + def info( + self, + message, + title="", + title_color="", + ): + self._log(title, title_color, message, logging.INFO) + + def warn( + self, + message, + title="", + title_color="", + ): + self._log(title, title_color, message, logging.WARN) + + def error(self, title, message=""): + self._log(title, Fore.RED, message, logging.ERROR) + + def _log( + self, + title: str = "", + title_color: str = "", + message: str = "", + level=logging.INFO, + ): + if message: + if isinstance(message, list): + message = " ".join(message) + self.logger.log( + level, message, extra={"title": str(title), "color": str(title_color)} + ) + + def set_level(self, level): + self.logger.setLevel(level) + self.typing_logger.setLevel(level) + + def double_check(self, additionalText=None): + if not additionalText: + additionalText = ( + "Please ensure you've setup and configured everything" + " correctly. Read https://github.com/Torantulino/Auto-GPT#readme to " + "double check. You can also create a github issue or join the discord" + " and ask there!" + ) + + self.typewriter_log("DOUBLE CHECK CONFIGURATION", Fore.YELLOW, additionalText) + + def log_json(self, data: Any, file_name: str) -> None: + # Define log directory + this_files_dir_path = os.path.dirname(__file__) + log_dir = os.path.join(this_files_dir_path, "../logs") + + # Create a handler for JSON files + json_file_path = os.path.join(log_dir, file_name) + json_data_handler = JsonFileHandler(json_file_path) + json_data_handler.setFormatter(JsonFormatter()) + + # Log the JSON data using the custom file handler + self.json_logger.addHandler(json_data_handler) + self.json_logger.debug(data) + self.json_logger.removeHandler(json_data_handler) + + def get_log_directory(self): + this_files_dir_path = os.path.dirname(__file__) + log_dir = os.path.join(this_files_dir_path, "../logs") + return os.path.abspath(log_dir) + +""" +Output stream to console using simulated typing +""" + +class TypingConsoleHandler(logging.StreamHandler): + def emit(self, record): + min_typing_speed = 0.05 + max_typing_speed = 0.01 + + msg = self.format(record) + try: + words = msg.split() + for i, word in enumerate(words): + print(word, end="", flush=True) + if i < len(words) - 1: + print(" ", end="", flush=True) + typing_speed = random.uniform(min_typing_speed, max_typing_speed) + time.sleep(typing_speed) + # type faster after each word + min_typing_speed = min_typing_speed * 0.95 + max_typing_speed = max_typing_speed * 0.95 + print() + except Exception: + self.handleError(record) + +class ConsoleHandler(logging.StreamHandler): + def emit(self, record) -> None: + msg = self.format(record) + try: + print(msg) + except Exception: + self.handleError(record) + + +class DbGptFormatter(logging.Formatter): + """ + Allows to handle custom placeholders 'title_color' and 'message_no_color'. + To use this formatter, make sure to pass 'color', 'title' as log extras. + """ + + def format(self, record: LogRecord) -> str: + if hasattr(record, "color"): + record.title_color = ( + getattr(record, "color") + + getattr(record, "title", "") + + " " + + Style.RESET_ALL + ) + else: + record.title_color = getattr(record, "title", "") + + # Add this line to set 'title' to an empty string if it doesn't exist + record.title = getattr(record, "title", "") + + if hasattr(record, "msg"): + record.message_no_color = remove_color_codes(getattr(record, "msg")) + else: + record.message_no_color = "" + return super().format(record) + + +def remove_color_codes(s: str) -> str: + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", s) + + +logger = Logger() + + +def print_assistant_thoughts( + ai_name: object, + assistant_reply_json_valid: object, + speak_mode: bool = False, +) -> None: + assistant_thoughts_reasoning = None + assistant_thoughts_plan = None + assistant_thoughts_speak = None + assistant_thoughts_criticism = None + + assistant_thoughts = assistant_reply_json_valid.get("thoughts", {}) + assistant_thoughts_text = assistant_thoughts.get("text") + if assistant_thoughts: + assistant_thoughts_reasoning = assistant_thoughts.get("reasoning") + assistant_thoughts_plan = assistant_thoughts.get("plan") + assistant_thoughts_criticism = assistant_thoughts.get("criticism") + assistant_thoughts_speak = assistant_thoughts.get("speak") + logger.typewriter_log( + f"{ai_name.upper()} THOUGHTS:", Fore.YELLOW, f"{assistant_thoughts_text}" + ) + logger.typewriter_log("REASONING:", Fore.YELLOW, f"{assistant_thoughts_reasoning}") + if assistant_thoughts_plan: + logger.typewriter_log("PLAN:", Fore.YELLOW, "") + # If it's a list, join it into a string + if isinstance(assistant_thoughts_plan, list): + assistant_thoughts_plan = "\n".join(assistant_thoughts_plan) + elif isinstance(assistant_thoughts_plan, dict): + assistant_thoughts_plan = str(assistant_thoughts_plan) + + # Split the input_string using the newline character and dashes + lines = assistant_thoughts_plan.split("\n") + for line in lines: + line = line.lstrip("- ") + logger.typewriter_log("- ", Fore.GREEN, line.strip()) + logger.typewriter_log("CRITICISM:", Fore.YELLOW, f"{assistant_thoughts_criticism}") + # Speak the assistant's thoughts + if speak_mode and assistant_thoughts_speak: + say_text(assistant_thoughts_speak) diff --git a/pilot/plugins.py b/pilot/plugins.py new file mode 100644 index 000000000..72b0a13a8 --- /dev/null +++ b/pilot/plugins.py @@ -0,0 +1,275 @@ +"""加载组件""" + +import importlib +import json +import os +import zipfile +from pathlib import Path +from typing import List, Optional, Tuple +from urllib.parse import urlparse +from zipimport import zipimporter + +import openapi_python_client +import requests +from auto_gpt_plugin_template import AutoGPTPluginTemplate +from openapi_python_client.cli import Config as OpenAPIConfig + +from pilot.configs.config import Config +from pilot.logs import logger +from pilot.agent.base_open_ai_plugin import BaseOpenAIPlugin + +def inspect_zip_for_modules(zip_path: str, debug: bool = False) -> list[str]: + """ + 加载zip文件的插件,完全兼容Auto_gpt_plugin + + Args: + zip_path (str): Path to the zipfile. + debug (bool, optional): Enable debug logging. Defaults to False. + + Returns: + list[str]: The list of module names found or empty list if none were found. + """ + result = [] + with zipfile.ZipFile(zip_path, "r") as zfile: + for name in zfile.namelist(): + if name.endswith("__init__.py") and not name.startswith("__MACOSX"): + logger.debug(f"Found module '{name}' in the zipfile at: {name}") + result.append(name) + if len(result) == 0: + logger.debug(f"Module '__init__.py' not found in the zipfile @ {zip_path}.") + return result + +def write_dict_to_json_file(data: dict, file_path: str) -> None: + """ + Write a dictionary to a JSON file. + Args: + data (dict): Dictionary to write. + file_path (str): Path to the file. + """ + with open(file_path, "w") as file: + json.dump(data, file, indent=4) + + + +def fetch_openai_plugins_manifest_and_spec(cfg: Config) -> dict: + """ + Fetch the manifest for a list of OpenAI plugins. + Args: + urls (List): List of URLs to fetch. + Returns: + dict: per url dictionary of manifest and spec. + """ + # TODO add directory scan + manifests = {} + for url in cfg.plugins_openai: + openai_plugin_client_dir = f"{cfg.plugins_dir}/openai/{urlparse(url).netloc}" + create_directory_if_not_exists(openai_plugin_client_dir) + if not os.path.exists(f"{openai_plugin_client_dir}/ai-plugin.json"): + try: + response = requests.get(f"{url}/.well-known/ai-plugin.json") + if response.status_code == 200: + manifest = response.json() + if manifest["schema_version"] != "v1": + logger.warn( + f"Unsupported manifest version: {manifest['schem_version']} for {url}" + ) + continue + if manifest["api"]["type"] != "openapi": + logger.warn( + f"Unsupported API type: {manifest['api']['type']} for {url}" + ) + continue + write_dict_to_json_file( + manifest, f"{openai_plugin_client_dir}/ai-plugin.json" + ) + else: + logger.warn( + f"Failed to fetch manifest for {url}: {response.status_code}" + ) + except requests.exceptions.RequestException as e: + logger.warn(f"Error while requesting manifest from {url}: {e}") + else: + logger.info(f"Manifest for {url} already exists") + manifest = json.load(open(f"{openai_plugin_client_dir}/ai-plugin.json")) + if not os.path.exists(f"{openai_plugin_client_dir}/openapi.json"): + openapi_spec = openapi_python_client._get_document( + url=manifest["api"]["url"], path=None, timeout=5 + ) + write_dict_to_json_file( + openapi_spec, f"{openai_plugin_client_dir}/openapi.json" + ) + else: + logger.info(f"OpenAPI spec for {url} already exists") + openapi_spec = json.load(open(f"{openai_plugin_client_dir}/openapi.json")) + manifests[url] = {"manifest": manifest, "openapi_spec": openapi_spec} + return manifests + + + +def create_directory_if_not_exists(directory_path: str) -> bool: + """ + Create a directory if it does not exist. + Args: + directory_path (str): Path to the directory. + Returns: + bool: True if the directory was created, else False. + """ + if not os.path.exists(directory_path): + try: + os.makedirs(directory_path) + logger.debug(f"Created directory: {directory_path}") + return True + except OSError as e: + logger.warn(f"Error creating directory {directory_path}: {e}") + return False + else: + logger.info(f"Directory {directory_path} already exists") + return True + + +def initialize_openai_plugins( + manifests_specs: dict, cfg: Config, debug: bool = False +) -> dict: + """ + Initialize OpenAI plugins. + Args: + manifests_specs (dict): per url dictionary of manifest and spec. + cfg (Config): Config instance including plugins config + debug (bool, optional): Enable debug logging. Defaults to False. + Returns: + dict: per url dictionary of manifest, spec and client. + """ + openai_plugins_dir = f"{cfg.plugins_dir}/openai" + if create_directory_if_not_exists(openai_plugins_dir): + for url, manifest_spec in manifests_specs.items(): + openai_plugin_client_dir = f"{openai_plugins_dir}/{urlparse(url).hostname}" + _meta_option = (openapi_python_client.MetaType.SETUP,) + _config = OpenAPIConfig( + **{ + "project_name_override": "client", + "package_name_override": "client", + } + ) + prev_cwd = Path.cwd() + os.chdir(openai_plugin_client_dir) + Path("ai-plugin.json") + if not os.path.exists("client"): + client_results = openapi_python_client.create_new_client( + url=manifest_spec["manifest"]["api"]["url"], + path=None, + meta=_meta_option, + config=_config, + ) + if client_results: + logger.warn( + f"Error creating OpenAPI client: {client_results[0].header} \n" + f" details: {client_results[0].detail}" + ) + continue + spec = importlib.util.spec_from_file_location( + "client", "client/client/client.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + client = module.Client(base_url=url) + os.chdir(prev_cwd) + manifest_spec["client"] = client + return manifests_specs + + +def instantiate_openai_plugin_clients( + manifests_specs_clients: dict, cfg: Config, debug: bool = False +) -> dict: + """ + Instantiates BaseOpenAIPlugin instances for each OpenAI plugin. + Args: + manifests_specs_clients (dict): per url dictionary of manifest, spec and client. + cfg (Config): Config instance including plugins config + debug (bool, optional): Enable debug logging. Defaults to False. + Returns: + plugins (dict): per url dictionary of BaseOpenAIPlugin instances. + + """ + plugins = {} + for url, manifest_spec_client in manifests_specs_clients.items(): + plugins[url] = BaseOpenAIPlugin(manifest_spec_client) + return plugins + + +def scan_plugins(cfg: Config, debug: bool = False) -> List[AutoGPTPluginTemplate]: + """Scan the plugins directory for plugins and loads them. + + Args: + cfg (Config): Config instance including plugins config + debug (bool, optional): Enable debug logging. Defaults to False. + + Returns: + List[Tuple[str, Path]]: List of plugins. + """ + loaded_plugins = [] + # Generic plugins + plugins_path_path = Path(cfg.plugins_dir) + + logger.debug(f"Allowlisted Plugins: {cfg.plugins_allowlist}") + logger.debug(f"Denylisted Plugins: {cfg.plugins_denylist}") + + for plugin in plugins_path_path.glob("*.zip"): + if moduleList := inspect_zip_for_modules(str(plugin), debug): + for module in moduleList: + plugin = Path(plugin) + module = Path(module) + logger.debug(f"Plugin: {plugin} Module: {module}") + zipped_package = zipimporter(str(plugin)) + zipped_module = zipped_package.load_module(str(module.parent)) + for key in dir(zipped_module): + if key.startswith("__"): + continue + a_module = getattr(zipped_module, key) + a_keys = dir(a_module) + if ( + "_abc_impl" in a_keys + and a_module.__name__ != "AutoGPTPluginTemplate" + and denylist_allowlist_check(a_module.__name__, cfg) + ): + loaded_plugins.append(a_module()) + # OpenAI plugins + if cfg.plugins_openai: + manifests_specs = fetch_openai_plugins_manifest_and_spec(cfg) + if manifests_specs.keys(): + manifests_specs_clients = initialize_openai_plugins( + manifests_specs, cfg, debug + ) + for url, openai_plugin_meta in manifests_specs_clients.items(): + if denylist_allowlist_check(url, cfg): + plugin = BaseOpenAIPlugin(openai_plugin_meta) + loaded_plugins.append(plugin) + + if loaded_plugins: + logger.info(f"\nPlugins found: {len(loaded_plugins)}\n" "--------------------") + for plugin in loaded_plugins: + logger.info(f"{plugin._name}: {plugin._version} - {plugin._description}") + return loaded_plugins + + +def denylist_allowlist_check(plugin_name: str, cfg: Config) -> bool: + """Check if the plugin is in the allowlist or denylist. + + Args: + plugin_name (str): Name of the plugin. + cfg (Config): Config object. + + Returns: + True or False + """ + logger.debug(f"Checking if plugin {plugin_name} should be loaded") + if plugin_name in cfg.plugins_denylist: + logger.debug(f"Not loading plugin {plugin_name} as it was in the denylist.") + return False + if plugin_name in cfg.plugins_allowlist: + logger.debug(f"Loading plugin {plugin_name} as it was in the allowlist.") + return True + ack = input( + f"WARNING: Plugin {plugin_name} found. But not in the" + f" allowlist... Load? ({cfg.authorise_key}/{cfg.exit_key}): " + ) + return ack.lower() == cfg.authorise_key diff --git a/pilot/speech/__init__.py b/pilot/speech/__init__.py new file mode 100644 index 000000000..58bfda398 --- /dev/null +++ b/pilot/speech/__init__.py @@ -0,0 +1,3 @@ +from pilot.speech.say import say_text + +__all__ = ["say_text"] diff --git a/pilot/speech/base.py b/pilot/speech/base.py new file mode 100644 index 000000000..55154df10 --- /dev/null +++ b/pilot/speech/base.py @@ -0,0 +1,50 @@ +"""Base class for all voice classes.""" +import abc +from threading import Lock + +from pilot.singleton import AbstractSingleton + + +class VoiceBase(AbstractSingleton): + """ + Base class for all voice classes. + """ + + def __init__(self): + """ + Initialize the voice class. + """ + self._url = None + self._headers = None + self._api_key = None + self._voices = [] + self._mutex = Lock() + self._setup() + + def say(self, text: str, voice_index: int = 0) -> bool: + """ + Say the given text. + + Args: + text (str): The text to say. + voice_index (int): The index of the voice to use. + """ + with self._mutex: + return self._speech(text, voice_index) + + @abc.abstractmethod + def _setup(self) -> None: + """ + Setup the voices, API key, etc. + """ + pass + + @abc.abstractmethod + def _speech(self, text: str, voice_index: int = 0) -> bool: + """ + Play the given text. + + Args: + text (str): The text to play. + """ + pass diff --git a/pilot/speech/brian.py b/pilot/speech/brian.py new file mode 100644 index 000000000..505c9a6f8 --- /dev/null +++ b/pilot/speech/brian.py @@ -0,0 +1,43 @@ +import logging +import os + +import requests +from playsound import playsound + +from pilot.speech.base import VoiceBase + + +class BrianSpeech(VoiceBase): + """Brian speech module for autogpt""" + + def _setup(self) -> None: + """Setup the voices, API key, etc.""" + pass + + def _speech(self, text: str, _: int = 0) -> bool: + """Speak text using Brian with the streamelements API + + Args: + text (str): The text to speak + + Returns: + bool: True if the request was successful, False otherwise + """ + tts_url = ( + f"https://api.streamelements.com/kappa/v2/speech?voice=Brian&text={text}" + ) + response = requests.get(tts_url) + + if response.status_code == 200: + with open("speech.mp3", "wb") as f: + f.write(response.content) + playsound("speech.mp3") + os.remove("speech.mp3") + return True + else: + logging.error( + "Request failed with status code: %s, response content: %s", + response.status_code, + response.content, + ) + return False diff --git a/pilot/speech/eleven_labs.py b/pilot/speech/eleven_labs.py new file mode 100644 index 000000000..8a93ab5ae --- /dev/null +++ b/pilot/speech/eleven_labs.py @@ -0,0 +1,88 @@ +"""ElevenLabs speech module""" +import os + +import requests +from playsound import playsound + +from pilot.configs.config import Config +from pilot.speech.base import VoiceBase + +PLACEHOLDERS = {"your-voice-id"} + + +class ElevenLabsSpeech(VoiceBase): + """ElevenLabs speech class""" + + def _setup(self) -> None: + """Set up the voices, API key, etc. + + Returns: + None: None + """ + + cfg = Config() + default_voices = ["ErXwobaYiN019PkySvjV", "EXAVITQu4vr4xnSDxMaL"] + voice_options = { + "Rachel": "21m00Tcm4TlvDq8ikWAM", + "Domi": "AZnzlk1XvdvUeBnXmlld", + "Bella": "EXAVITQu4vr4xnSDxMaL", + "Antoni": "ErXwobaYiN019PkySvjV", + "Elli": "MF3mGyEYCl7XYWbV9V6O", + "Josh": "TxGEqnHWrfWFTfGW9XjX", + "Arnold": "VR6AewLTigWG4xSOukaG", + "Adam": "pNInz6obpgDQGcFmaJgB", + "Sam": "yoZ06aMxZJJ28mfd3POQ", + } + self._headers = { + "Content-Type": "application/json", + "xi-api-key": cfg.elevenlabs_api_key, + } + self._voices = default_voices.copy() + if cfg.elevenlabs_voice_1_id in voice_options: + cfg.elevenlabs_voice_1_id = voice_options[cfg.elevenlabs_voice_1_id] + if cfg.elevenlabs_voice_2_id in voice_options: + cfg.elevenlabs_voice_2_id = voice_options[cfg.elevenlabs_voice_2_id] + self._use_custom_voice(cfg.elevenlabs_voice_1_id, 0) + self._use_custom_voice(cfg.elevenlabs_voice_2_id, 1) + + def _use_custom_voice(self, voice, voice_index) -> None: + """Use a custom voice if provided and not a placeholder + + Args: + voice (str): The voice ID + voice_index (int): The voice index + + Returns: + None: None + """ + # Placeholder values that should be treated as empty + if voice and voice not in PLACEHOLDERS: + self._voices[voice_index] = voice + + def _speech(self, text: str, voice_index: int = 0) -> bool: + """Speak text using elevenlabs.io's API + + Args: + text (str): The text to speak + voice_index (int, optional): The voice to use. Defaults to 0. + + Returns: + bool: True if the request was successful, False otherwise + """ + from autogpt.logs import logger + + tts_url = ( + f"https://api.elevenlabs.io/v1/text-to-speech/{self._voices[voice_index]}" + ) + response = requests.post(tts_url, headers=self._headers, json={"text": text}) + + if response.status_code == 200: + with open("speech.mpeg", "wb") as f: + f.write(response.content) + playsound("speech.mpeg", True) + os.remove("speech.mpeg") + return True + else: + logger.warn("Request failed with status code:", response.status_code) + logger.info("Response content:", response.content) + return False diff --git a/pilot/speech/gtts.py b/pilot/speech/gtts.py new file mode 100644 index 000000000..7ad164f30 --- /dev/null +++ b/pilot/speech/gtts.py @@ -0,0 +1,22 @@ +""" GTTS Voice. """ +import os + +import gtts +from playsound import playsound + +from pilot.speech.base import VoiceBase + + +class GTTSVoice(VoiceBase): + """GTTS Voice.""" + + def _setup(self) -> None: + pass + + def _speech(self, text: str, _: int = 0) -> bool: + """Play the given text.""" + tts = gtts.gTTS(text) + tts.save("speech.mp3") + playsound("speech.mp3", True) + os.remove("speech.mp3") + return True diff --git a/pilot/speech/macos_tts.py b/pilot/speech/macos_tts.py new file mode 100644 index 000000000..51292c240 --- /dev/null +++ b/pilot/speech/macos_tts.py @@ -0,0 +1,21 @@ +""" MacOS TTS Voice. """ +import os + +from pilot.speech.base import VoiceBase + + +class MacOSTTS(VoiceBase): + """MacOS TTS Voice.""" + + def _setup(self) -> None: + pass + + def _speech(self, text: str, voice_index: int = 0) -> bool: + """Play the given text.""" + if voice_index == 0: + os.system(f'say "{text}"') + elif voice_index == 1: + os.system(f'say -v "Ava (Premium)" "{text}"') + else: + os.system(f'say -v Samantha "{text}"') + return True diff --git a/pilot/speech/say.py b/pilot/speech/say.py new file mode 100644 index 000000000..b0f6b0516 --- /dev/null +++ b/pilot/speech/say.py @@ -0,0 +1,46 @@ +""" Text to speech module """ +import threading +from threading import Semaphore + +from pilot.configs.config import Config +from pilot.speech.base import VoiceBase +from pilot.speech.brian import BrianSpeech +from pilot.speech.eleven_labs import ElevenLabsSpeech +from pilot.speech.gtts import GTTSVoice +from pilot.speech.macos_tts import MacOSTTS + +_QUEUE_SEMAPHORE = Semaphore( + 1 +) # The amount of sounds to queue before blocking the main thread + + +def say_text(text: str, voice_index: int = 0) -> None: + """Speak the given text using the given voice index""" + cfg = Config() + default_voice_engine, voice_engine = _get_voice_engine(cfg) + + def speak() -> None: + success = voice_engine.say(text, voice_index) + if not success: + default_voice_engine.say(text) + + _QUEUE_SEMAPHORE.release() + + _QUEUE_SEMAPHORE.acquire(True) + thread = threading.Thread(target=speak) + thread.start() + + +def _get_voice_engine(config: Config) -> tuple[VoiceBase, VoiceBase]: + """Get the voice engine to use for the given configuration""" + default_voice_engine = GTTSVoice() + if config.elevenlabs_api_key: + voice_engine = ElevenLabsSpeech() + elif config.use_mac_os_tts == "True": + voice_engine = MacOSTTS() + elif config.use_brian_tts == "True": + voice_engine = BrianSpeech() + else: + voice_engine = GTTSVoice() + + return default_voice_engine, voice_engine diff --git a/plugins/Auto-GPT-TiDB-Serverless-Plugin-main.zip b/plugins/Auto-GPT-TiDB-Serverless-Plugin-main.zip new file mode 100644 index 0000000000000000000000000000000000000000..1c7b3273685cc642a00aae2f5a068653113994a6 GIT binary patch literal 11819 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 literal 0 HcmV?d00001 diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py new file mode 100644 index 000000000..21dbaaf27 --- /dev/null +++ b/tests/unit/test_plugins.py @@ -0,0 +1,135 @@ +import pytest +import os + + +from pilot.configs.config import Config +from pilot.plugins import ( + denylist_allowlist_check, + inspect_zip_for_modules, + scan_plugins, +) + +PLUGINS_TEST_DIR = "tests/unit/data/test_plugins" +PLUGINS_TEST_DIR_TEMP = "data/test_plugins" +PLUGIN_TEST_ZIP_FILE = "Auto-GPT-Plugin-Test-master.zip" +PLUGIN_TEST_INIT_PY = "Auto-GPT-Plugin-Test-master/src/auto_gpt_vicuna/__init__.py" +PLUGIN_TEST_OPENAI = "https://weathergpt.vercel.app/" + +def test_inspect_zip_for_modules(): + current_dir = os.getcwd() + print(current_dir) + result = inspect_zip_for_modules(str(f"{current_dir}/{PLUGINS_TEST_DIR_TEMP}/{PLUGIN_TEST_ZIP_FILE}")) + assert result == [PLUGIN_TEST_INIT_PY] + + +@pytest.fixture +def mock_config_denylist_allowlist_check(): + class MockConfig: + """Mock config object for testing the denylist_allowlist_check function""" + + plugins_denylist = ["BadPlugin"] + plugins_allowlist = ["GoodPlugin"] + authorise_key = "y" + exit_key = "n" + + return MockConfig() + + +def test_denylist_allowlist_check_denylist( + mock_config_denylist_allowlist_check, monkeypatch +): + # Test that the function returns False when the plugin is in the denylist + monkeypatch.setattr("builtins.input", lambda _: "y") + assert not denylist_allowlist_check( + "BadPlugin", mock_config_denylist_allowlist_check + ) + + +def test_denylist_allowlist_check_allowlist( + mock_config_denylist_allowlist_check, monkeypatch +): + # Test that the function returns True when the plugin is in the allowlist + monkeypatch.setattr("builtins.input", lambda _: "y") + assert denylist_allowlist_check("GoodPlugin", mock_config_denylist_allowlist_check) + + +def test_denylist_allowlist_check_user_input_yes( + mock_config_denylist_allowlist_check, monkeypatch +): + # Test that the function returns True when the user inputs "y" + monkeypatch.setattr("builtins.input", lambda _: "y") + assert denylist_allowlist_check( + "UnknownPlugin", mock_config_denylist_allowlist_check + ) + + +def test_denylist_allowlist_check_user_input_no( + mock_config_denylist_allowlist_check, monkeypatch +): + # Test that the function returns False when the user inputs "n" + monkeypatch.setattr("builtins.input", lambda _: "n") + assert not denylist_allowlist_check( + "UnknownPlugin", mock_config_denylist_allowlist_check + ) + + +def test_denylist_allowlist_check_user_input_invalid( + mock_config_denylist_allowlist_check, monkeypatch +): + # Test that the function returns False when the user inputs an invalid value + monkeypatch.setattr("builtins.input", lambda _: "invalid") + assert not denylist_allowlist_check( + "UnknownPlugin", mock_config_denylist_allowlist_check + ) + + +@pytest.fixture +def config_with_plugins(): + """Mock config object for testing the scan_plugins function""" + # Test that the function returns the correct number of plugins + cfg = Config() + cfg.plugins_dir = PLUGINS_TEST_DIR + cfg.plugins_openai = ["https://weathergpt.vercel.app/"] + return cfg + + +@pytest.fixture +def mock_config_openai_plugin(): + """Mock config object for testing the scan_plugins function""" + + class MockConfig: + """Mock config object for testing the scan_plugins function""" + current_dir = os.getcwd() + plugins_dir = f"{current_dir}/{PLUGINS_TEST_DIR_TEMP}/" + plugins_openai = [PLUGIN_TEST_OPENAI] + plugins_denylist = ["AutoGPTPVicuna"] + plugins_allowlist = [PLUGIN_TEST_OPENAI] + + return MockConfig() + + +def test_scan_plugins_openai(mock_config_openai_plugin): + # Test that the function returns the correct number of plugins + result = scan_plugins(mock_config_openai_plugin, debug=True) + assert len(result) == 1 + + +@pytest.fixture +def mock_config_generic_plugin(): + """Mock config object for testing the scan_plugins function""" + + # Test that the function returns the correct number of plugins + class MockConfig: + current_dir = os.getcwd() + plugins_dir = f"{current_dir}/{PLUGINS_TEST_DIR_TEMP}/" + plugins_openai = [] + plugins_denylist = [] + plugins_allowlist = ["AutoGPTPVicuna"] + + return MockConfig() + + +def test_scan_plugins_generic(mock_config_generic_plugin): + # Test that the function returns the correct number of plugins + result = scan_plugins(mock_config_generic_plugin, debug=True) + assert len(result) == 1