From 03017665ccc4066ba52a5e368ac09ad8dc676434 Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sat, 15 Apr 2023 21:31:33 +0200 Subject: [PATCH 01/16] Implementing custom system roles --- sgpt/__init__.py | 12 +- sgpt/app.py | 15 ++- sgpt/client.py | 3 +- sgpt/config.py | 21 ++-- sgpt/handlers/chat_handler.py | 72 ++++------- sgpt/handlers/default_handler.py | 17 +-- sgpt/handlers/handler.py | 5 +- sgpt/handlers/repl_handler.py | 17 +-- sgpt/role.py | 209 +++++++++++++++++++++++++++++++ sgpt/utils.py | 14 --- tests/test_integration.py | 4 +- 11 files changed, 281 insertions(+), 108 deletions(-) create mode 100644 sgpt/role.py diff --git a/sgpt/__init__.py b/sgpt/__init__.py index 3b35d9b2..75ce674d 100644 --- a/sgpt/__init__.py +++ b/sgpt/__init__.py @@ -1,12 +1,4 @@ -from .config import cfg as cfg -from .cache import Cache as Cache -from .client import OpenAIClient as OpenAIClient -from .handlers.chat_handler import ChatHandler as ChatHandler -from .handlers.default_handler import DefaultHandler as DefaultHandler -from .handlers.repl_handler import ReplHandler as ReplHandler -from . import utils as utils -from .app import main as main -from .app import entry_point as cli # noqa: F401 -from . import make_prompt as make_prompt +# from .app import main as main +# from .app import entry_point as cli # noqa: F401 __version__ = "0.8.9" diff --git a/sgpt/app.py b/sgpt/app.py index a367103c..147ae262 100644 --- a/sgpt/app.py +++ b/sgpt/app.py @@ -18,8 +18,13 @@ # Click is part of typer. from click import BadArgumentUsage, MissingParameter -from sgpt import ChatHandler, DefaultHandler, OpenAIClient, ReplHandler, cfg +from sgpt.handlers.chat_handler import ChatHandler +from sgpt.handlers.default_handler import DefaultHandler +from sgpt.handlers.repl_handler import ReplHandler from sgpt.utils import ModelOptions, get_edited_prompt, run_command +from sgpt.role import DefaultRoles +from sgpt.config import cfg +from sgpt.client import OpenAIClient def main( @@ -111,9 +116,11 @@ def main( api_key = cfg.get("OPENAI_API_KEY") client = OpenAIClient(api_host, api_key) + role = DefaultRoles.get(shell, code) + if repl: # Will be in infinite loop here until user exits with Ctrl+C. - ReplHandler(client, repl, shell, code).handle( + ReplHandler(client, repl, role).handle( prompt, model=model.value, temperature=temperature, @@ -123,7 +130,7 @@ def main( ) if chat: - full_completion = ChatHandler(client, chat, shell, code).handle( + full_completion = ChatHandler(client, chat, role).handle( prompt, model=model.value, temperature=temperature, @@ -132,7 +139,7 @@ def main( caching=cache, ) else: - full_completion = DefaultHandler(client, shell, code).handle( + full_completion = DefaultHandler(client, role).handle( prompt, model=model.value, temperature=temperature, diff --git a/sgpt/client.py b/sgpt/client.py index 90220f5e..febad84a 100644 --- a/sgpt/client.py +++ b/sgpt/client.py @@ -4,7 +4,8 @@ import requests -from sgpt import Cache, cfg +from .cache import Cache +from .config import cfg CACHE_LENGTH = int(cfg.get("CACHE_LENGTH")) CACHE_PATH = Path(cfg.get("CACHE_PATH")) diff --git a/sgpt/config.py b/sgpt/config.py index 0fb85560..938c0d8d 100644 --- a/sgpt/config.py +++ b/sgpt/config.py @@ -6,31 +6,34 @@ from click import UsageError -from sgpt.utils import ModelOptions +from .utils import ModelOptions + CONFIG_FOLDER = os.path.expanduser("~/.config") -CONFIG_PATH = Path(CONFIG_FOLDER) / "shell_gpt" / ".sgptrc" +SHELL_GPT_CONFIG_FOLDER = Path(CONFIG_FOLDER) / "shell_gpt" +SHELL_GPT_CONFIG_PATH = SHELL_GPT_CONFIG_FOLDER / ".sgptrc" +ROLE_STORAGE_PATH = SHELL_GPT_CONFIG_FOLDER / "roles" +CHAT_CACHE_PATH = Path(gettempdir()) / "chat_cache" +CACHE_PATH = Path(gettempdir()) / "cache" # TODO: Refactor ENV variables with SGPT_ prefix. DEFAULT_CONFIG = { # TODO: Refactor it to CHAT_STORAGE_PATH. - "CHAT_CACHE_PATH": os.getenv( - "CHAT_CACHE_PATH", str(Path(gettempdir()) / "shell_gpt" / "chat_cache") - ), - "CACHE_PATH": os.getenv( - "CACHE_PATH", str(Path(gettempdir()) / "shell_gpt" / "cache") - ), + "CHAT_CACHE_PATH": os.getenv("CHAT_CACHE_PATH", str(CHAT_CACHE_PATH)), + "CACHE_PATH": os.getenv("CACHE_PATH", str(CACHE_PATH)), "CHAT_CACHE_LENGTH": int(os.getenv("CHAT_CACHE_LENGTH", "100")), "CACHE_LENGTH": int(os.getenv("CHAT_CACHE_LENGTH", "100")), "REQUEST_TIMEOUT": int(os.getenv("REQUEST_TIMEOUT", "60")), "DEFAULT_MODEL": os.getenv("DEFAULT_MODEL", ModelOptions.GPT3.value), "OPENAI_API_HOST": os.getenv("OPENAI_API_HOST", "https://api.openai.com"), "DEFAULT_COLOR": os.getenv("DEFAULT_COLOR", "magenta"), + "ROLE_STORAGE_PATH": os.getenv("ROLE_STORAGE_PATH", str(ROLE_STORAGE_PATH)), # New features might add their own config variables here. } class Config(dict): # type: ignore + def __init__(self, config_path: Path, **defaults: Any): self.config_path = config_path @@ -77,4 +80,4 @@ def get(self, key: str) -> str: # type: ignore return value -cfg = Config(CONFIG_PATH, **DEFAULT_CONFIG) +cfg = Config(SHELL_GPT_CONFIG_PATH, **DEFAULT_CONFIG) diff --git a/sgpt/handlers/chat_handler.py b/sgpt/handlers/chat_handler.py index fe99efe9..a9ee5c91 100644 --- a/sgpt/handlers/chat_handler.py +++ b/sgpt/handlers/chat_handler.py @@ -5,9 +5,10 @@ import typer from click import BadArgumentUsage -from sgpt import OpenAIClient, cfg, make_prompt -from sgpt.handlers.handler import Handler -from sgpt.utils import CompletionModes +from ..client import OpenAIClient +from ..config import cfg +from ..role import SystemRole +from .handler import Handler CHAT_CACHE_LENGTH = int(cfg.get("CHAT_CACHE_LENGTH")) CHAT_CACHE_PATH = Path(cfg.get("CHAT_CACHE_PATH")) @@ -94,14 +95,13 @@ def __init__( self, client: OpenAIClient, chat_id: str, - shell: bool = False, - code: bool = False, + role: SystemRole, model: str = "gpt-3.5-turbo", ) -> None: super().__init__(client) self.chat_id = chat_id self.client = client - self.mode = CompletionModes.get_mode(shell, code) + self.role = role self.model = model if chat_id == "temp": @@ -124,20 +124,14 @@ def initiated(self) -> bool: return self.chat_session.exists(self.chat_id) @property - def is_shell_chat(self) -> bool: - # TODO: Should be optimized for REPL mode. - chat_history = self.chat_session.get_messages(self.chat_id) - return bool(chat_history and chat_history[0].endswith("###\nCommand:")) - - @property - def is_code_chat(self) -> bool: + def initial_message(self) -> str: chat_history = self.chat_session.get_messages(self.chat_id) - return bool(chat_history and chat_history[0].endswith("###\nCode:")) + return chat_history[0] if chat_history else "" @property - def is_default_chat(self) -> bool: - chat_history = self.chat_session.get_messages(self.chat_id) - return bool(chat_history and chat_history[0].endswith("###")) + def is_same_role(self) -> bool: + # TODO: Should be optimized for REPL mode. + return self.role.same_role(self.initial_message) @classmethod def show_messages_callback(cls, chat_id: str) -> None: @@ -156,41 +150,21 @@ def show_messages(cls, chat_id: str) -> None: def validate(self) -> None: if self.initiated: - if self.is_shell_chat and self.mode == CompletionModes.CODE: - raise BadArgumentUsage( - f'Chat session "{self.chat_id}" was initiated as shell assistant, ' - "and can be used with --shell only" - ) - if self.is_code_chat and self.mode == CompletionModes.SHELL: - raise BadArgumentUsage( - f'Chat "{self.chat_id}" was initiated as code assistant, ' - "and can be used with --code only" - ) - if self.is_default_chat and self.mode != CompletionModes.NORMAL: - raise BadArgumentUsage( - f'Chat "{self.chat_id}" was initiated as default assistant, ' - "and can't be used with --shell or --code" - ) - # If user didn't pass chat mode, we will use the one that was used to initiate the chat. - if self.mode == CompletionModes.NORMAL: - if self.is_shell_chat: - self.mode = CompletionModes.SHELL - elif self.is_code_chat: - self.mode = CompletionModes.CODE + # print("initial message:", self.initial_message) + chat_role_name = self.role.get_role_name(self.initial_message) + if self.role.name == "default": + # If user didn't pass chat mode, we will use the one that was used to initiate the chat. + self.role = SystemRole.get(chat_role_name) + else: + if not self.is_same_role: + raise BadArgumentUsage( + f"Cant change chat role \"{self.role.name}\" " + f"of initiated \"{chat_role_name}\" chat." + ) def make_prompt(self, prompt: str) -> str: prompt = prompt.strip() - if self.initiated: - if self.is_shell_chat: - prompt += "\nCommand:" - elif self.is_code_chat: - prompt += "\nCode:" - return prompt - return make_prompt.initial( - prompt, - self.mode == CompletionModes.SHELL, - self.mode == CompletionModes.CODE, - ) + return self.role.make_prompt(prompt, not self.initiated) @chat_session def get_completion( diff --git a/sgpt/handlers/default_handler.py b/sgpt/handlers/default_handler.py index cb0b7f88..034961b0 100644 --- a/sgpt/handlers/default_handler.py +++ b/sgpt/handlers/default_handler.py @@ -1,8 +1,8 @@ from pathlib import Path -from sgpt import OpenAIClient, cfg, make_prompt -from sgpt.utils import CompletionModes - +from ..client import OpenAIClient +from ..config import cfg +from ..role import SystemRole from .handler import Handler CHAT_CACHE_LENGTH = int(cfg.get("CHAT_CACHE_LENGTH")) @@ -13,19 +13,14 @@ class DefaultHandler(Handler): def __init__( self, client: OpenAIClient, - shell: bool = False, - code: bool = False, + role: SystemRole, model: str = "gpt-3.5-turbo", ) -> None: super().__init__(client) self.client = client - self.mode = CompletionModes.get_mode(shell, code) + self.role = role self.model = model def make_prompt(self, prompt: str) -> str: prompt = prompt.strip() - return make_prompt.initial( - prompt, - self.mode == CompletionModes.SHELL, - self.mode == CompletionModes.CODE, - ) + return self.role.make_prompt(prompt, initial=True) diff --git a/sgpt/handlers/handler.py b/sgpt/handlers/handler.py index 259cd847..1a5a4fba 100644 --- a/sgpt/handlers/handler.py +++ b/sgpt/handlers/handler.py @@ -2,7 +2,8 @@ import typer -from sgpt import OpenAIClient, cfg +from ..client import OpenAIClient +from ..config import cfg class Handler: @@ -31,6 +32,8 @@ def get_completion( def handle(self, prompt: str, **kwargs: Any) -> str: prompt = self.make_prompt(prompt) + # print(prompt) + # print(kwargs) messages = [{"role": "user", "content": prompt}] full_completion = "" for word in self.get_completion(messages=messages, **kwargs): diff --git a/sgpt/handlers/repl_handler.py b/sgpt/handlers/repl_handler.py index f6c7ce30..b31cf99c 100644 --- a/sgpt/handlers/repl_handler.py +++ b/sgpt/handlers/repl_handler.py @@ -4,9 +4,11 @@ from rich import print as rich_print from rich.rule import Rule -from sgpt.client import OpenAIClient -from sgpt.handlers.chat_handler import ChatHandler -from sgpt.utils import CompletionModes, run_command +from ..role import SystemRole +from ..client import OpenAIClient +from .chat_handler import ChatHandler +from ..utils import run_command +from ..role import DefaultRoles class ReplHandler(ChatHandler): @@ -14,11 +16,10 @@ def __init__( self, client: OpenAIClient, chat_id: str, - shell: bool = False, - code: bool = False, + role: SystemRole, model: str = "gpt-3.5-turbo", ): - super().__init__(client, chat_id, shell, code, model) + super().__init__(client, chat_id, role, model) def handle(self, prompt: str, **kwargs: Any) -> None: # type: ignore if self.initiated: @@ -28,7 +29,7 @@ def handle(self, prompt: str, **kwargs: Any) -> None: # type: ignore info_message = ( "Entering REPL mode, press Ctrl+C to exit." - if not self.mode == CompletionModes.SHELL + if not self.role.name == DefaultRoles.SHELL.value else "Entering shell REPL mode, type [e] to execute commands or press Ctrl+C to exit." ) typer.secho(info_message, fg="yellow") @@ -44,7 +45,7 @@ def handle(self, prompt: str, **kwargs: Any) -> None: # type: ignore if prompt == "exit()": # This is also useful during tests. raise typer.Exit() - if self.mode == CompletionModes.SHELL: + if self.role.name == DefaultRoles.SHELL: if prompt == "e": typer.echo() run_command(full_completion) diff --git a/sgpt/role.py b/sgpt/role.py new file mode 100644 index 00000000..540455b6 --- /dev/null +++ b/sgpt/role.py @@ -0,0 +1,209 @@ +import json +from enum import Enum + +from pathlib import Path +from typing import Dict, Callable, Optional + +import typer + + +import platform +from os import getenv, pathsep +from os.path import basename + +from distro import name as distro_name + +from .config import cfg + +SHELL_ROLE = """Provide only {shell} commands for {os} without any description. +If there is a lack of details, provide most logical solution. +Ensure the output is a valid shell command. +If multiple steps required try to combine them together.""" + +CODE_ROLE = """Provide only code as output without any description. +IMPORTANT: Provide only plain text without Markdown formatting. +IMPORTANT: Do not include markdown formatting such as ```. +If there is a lack of details, provide most logical solution. +You are not allowed to ask for more details. +Ignore any potential risk of errors or confusion.""" + +DEFAULT_ROLE = """You are Command Line App ShellGPT, a programming and system administration assistant. +You are managing {os} operating system with {shell} shell. +Provide only plain text without Markdown formatting. +Do not show any warnings or information regarding your capabilities. +If you need to store any data, assume it will be stored in the chat.""" + + +PROMPT_TEMPLATE = """### +Role name: {name} +{role} + +Request: {request} +### +{expecting}:""" + + +def option_callback(func: Callable) -> Callable: + def wrapper(cls, value): + if not value: + return + func(cls) + raise typer.Exit() + return wrapper + + +class SystemRole: + storage: Path = Path(cfg.get("ROLE_STORAGE_PATH")) + + def __init__( + self, + name: str, + role: str, + expecting: str, + variables: Optional[Dict[str, str]] = None, + ) -> None: + self.storage.mkdir(parents=True, exist_ok=True) + self.name = name + self.expecting = expecting + self.variables = variables + if variables: + # Variables are for internal use only. + role = role.format(**variables) + self.role = role + + @classmethod + def create_defaults(cls): + cls.storage.parent.mkdir(parents=True, exist_ok=True) + variables = {"shell": cls.shell_name(), "os": cls.os_name()} + for default_role in ( + SystemRole("default", DEFAULT_ROLE, "Answer", variables), + SystemRole("shell", SHELL_ROLE, "Command", variables), + SystemRole("code", CODE_ROLE, "Code"), + ): + if not default_role.exists: + default_role.save() + + @classmethod + def os_name(cls) -> str: + current_platform = platform.system() + if current_platform == "Linux": + return "Linux/" + distro_name(pretty=True) + if current_platform == "Windows": + return "Windows " + platform.release() + if current_platform == "Darwin": + return "Darwin/MacOS " + platform.mac_ver()[0] + return current_platform + + @classmethod + def shell_name(cls) -> str: + current_platform = platform.system() + if current_platform in ("Windows", "nt"): + is_powershell = len(getenv("PSModulePath", "").split(pathsep)) >= 3 + return "powershell.exe" if is_powershell else "cmd.exe" + return basename(getenv("SHELL", "/bin/sh")) + + @classmethod + def get_role_name(cls, initial_message: str) -> Optional[str]: + if not initial_message: + return None + message_lines = initial_message.splitlines() + if "###" in message_lines[0]: + return message_lines[1].split("Role name: ")[1].strip() + return None + + @classmethod + def get(cls, name: str) -> "SystemRole": + file_path = cls.storage / f"{name}.json" + # print(file_path) + if not file_path.exists(): + raise FileNotFoundError(f'Role "{name}" not found.') + return cls(**json.loads(file_path.read_text())) + + @classmethod + def create(cls, name: str) -> None: + if not name: + return + role = typer.prompt("Enter role description") + expecting = typer.prompt("Enter output type, e.g. answer, code, shell command, json, etc.") + role = cls(name, role, expecting) + role.save() + raise typer.Exit() + + @classmethod + @option_callback + def list(cls) -> None: + if not cls.storage.exists(): + return + # Get all files in the folder. + files = cls.storage.glob("*") + # Sort files by last modification time in ascending order. + for path in sorted(files, key=lambda f: f.stat().st_mtime): + typer.echo(path) + + @classmethod + @option_callback + def show(cls, name: str): + typer.echo(cls.get(name).role) + + @property + def exists(self) -> bool: + return self.file_path.exists() + + @property + def system_message(self) -> Dict[str, str]: + return {"role": "system", "content": self.role} + + @property + def file_path(self) -> Path: + return self.storage / f"{self.name}.json" + + def save(self) -> None: + if self.exists: + typer.confirm( + f'Role "{self.name}" already exists, overwrite it?', + abort=True, + ) + self.file_path.write_text(json.dumps(self.__dict__), encoding="utf-8") + + def delete(self) -> None: + if self.exists: + typer.confirm( + f'Role "{self.name}" exist, delete it?', + abort=True, + ) + self.file_path.unlink() + + def make_prompt(self, request: str, initial: bool) -> str: + if initial: + prompt = PROMPT_TEMPLATE.format( + name=self.name, + role=self.role, + request=request, + expecting=self.expecting, + ) + else: + prompt = f"{request}\n{self.expecting}:" + + return prompt + + def same_role(self, initial_message: str) -> bool: + if not initial_message: + return False + return True if f"Role name: {self.name}" in initial_message else False + + +class DefaultRoles(Enum): + DEFAULT = "default" + SHELL = "shell" + CODE = "code" + + @classmethod + def get(cls, shell: bool, code: bool) -> SystemRole: + if shell: + return SystemRole.get(DefaultRoles.SHELL.value) + if code: + return SystemRole.get(DefaultRoles.CODE.value) + return SystemRole.get(DefaultRoles.DEFAULT.value) + + +SystemRole.create_defaults() diff --git a/sgpt/utils.py b/sgpt/utils.py index 02a5a7c3..8388af5f 100644 --- a/sgpt/utils.py +++ b/sgpt/utils.py @@ -13,20 +13,6 @@ class ModelOptions(str, Enum): GPT4_32K = "gpt-4-32k" -class CompletionModes(Enum): - NORMAL = "normal" - SHELL = "shell" - CODE = "code" - - @classmethod - def get_mode(cls, shell: bool, code: bool) -> "CompletionModes": - if shell: - return CompletionModes.SHELL - if code: - return CompletionModes.CODE - return CompletionModes.NORMAL - - def get_edited_prompt() -> str: """ Opens the user's default editor to let them diff --git a/tests/test_integration.py b/tests/test_integration.py index a270010a..e4bdf72a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -19,7 +19,9 @@ import typer from typer.testing import CliRunner -from sgpt import OpenAIClient, cfg, main +from sgpt.client import OpenAIClient +from sgpt.config import cfg +from sgpt.app import main from sgpt.handlers.handler import Handler runner = CliRunner() From 8053fd93bb0b31aa323a1b4460e2f8a24c926286 Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sat, 15 Apr 2023 21:50:11 +0200 Subject: [PATCH 02/16] User can add custom roles --- sgpt/app.py | 26 +++++++++++++++++++++++++- sgpt/handlers/handler.py | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/sgpt/app.py b/sgpt/app.py index 147ae262..760748f6 100644 --- a/sgpt/app.py +++ b/sgpt/app.py @@ -25,6 +25,7 @@ from sgpt.role import DefaultRoles from sgpt.config import cfg from sgpt.client import OpenAIClient +from sgpt.role import SystemRole def main( @@ -91,6 +92,29 @@ def main( callback=ChatHandler.list_ids, rich_help_panel="Chat Options", ), + role: str = typer.Option( + None, + help="System role for GPT model.", + rich_help_panel="Role Options", + ), + create_role: str = typer.Option( + None, + help="Create role.", + rich_help_panel="Role Options", + callback=SystemRole.create, + ), + show_role: str = typer.Option( # pylint: disable=W0613 + None, + help="Show role.", + callback=SystemRole.show, + rich_help_panel="Role Options", + ), + list_roles: bool = typer.Option( # pylint: disable=W0613 + False, + help="List roles.", + callback=SystemRole.list, + rich_help_panel="Role Options", + ), ) -> None: stdin_passed = not sys.stdin.isatty() @@ -116,7 +140,7 @@ def main( api_key = cfg.get("OPENAI_API_KEY") client = OpenAIClient(api_host, api_key) - role = DefaultRoles.get(shell, code) + role = DefaultRoles.get(shell, code) if not role else SystemRole.get(role) if repl: # Will be in infinite loop here until user exits with Ctrl+C. diff --git a/sgpt/handlers/handler.py b/sgpt/handlers/handler.py index 1a5a4fba..8024dd32 100644 --- a/sgpt/handlers/handler.py +++ b/sgpt/handlers/handler.py @@ -32,7 +32,7 @@ def get_completion( def handle(self, prompt: str, **kwargs: Any) -> str: prompt = self.make_prompt(prompt) - # print(prompt) + print(prompt) # print(kwargs) messages = [{"role": "user", "content": prompt}] full_completion = "" From 7c591911b8de8fdd67c93a67899664e4d3abece0 Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sat, 15 Apr 2023 23:13:20 +0200 Subject: [PATCH 03/16] Linting, typing, minor fixes --- sgpt/__init__.py | 4 ++-- sgpt/app.py | 15 +++++++-------- sgpt/config.py | 2 -- sgpt/handlers/chat_handler.py | 8 ++++++-- sgpt/handlers/repl_handler.py | 7 +++---- sgpt/role.py | 31 +++++++++++++++---------------- tests/test_integration.py | 2 +- tests/test_unit.py | 2 +- 8 files changed, 35 insertions(+), 36 deletions(-) diff --git a/sgpt/__init__.py b/sgpt/__init__.py index 75ce674d..2fc75787 100644 --- a/sgpt/__init__.py +++ b/sgpt/__init__.py @@ -1,4 +1,4 @@ -# from .app import main as main -# from .app import entry_point as cli # noqa: F401 +from .app import main as main +from .app import entry_point as cli # noqa: F401 __version__ = "0.8.9" diff --git a/sgpt/app.py b/sgpt/app.py index 760748f6..13740c3c 100644 --- a/sgpt/app.py +++ b/sgpt/app.py @@ -18,14 +18,13 @@ # Click is part of typer. from click import BadArgumentUsage, MissingParameter +from sgpt.client import OpenAIClient +from sgpt.config import cfg from sgpt.handlers.chat_handler import ChatHandler from sgpt.handlers.default_handler import DefaultHandler from sgpt.handlers.repl_handler import ReplHandler +from sgpt.role import DefaultRoles, SystemRole from sgpt.utils import ModelOptions, get_edited_prompt, run_command -from sgpt.role import DefaultRoles -from sgpt.config import cfg -from sgpt.client import OpenAIClient -from sgpt.role import SystemRole def main( @@ -140,11 +139,11 @@ def main( api_key = cfg.get("OPENAI_API_KEY") client = OpenAIClient(api_host, api_key) - role = DefaultRoles.get(shell, code) if not role else SystemRole.get(role) + role_class = DefaultRoles.get(shell, code) if not role else SystemRole.get(role) if repl: # Will be in infinite loop here until user exits with Ctrl+C. - ReplHandler(client, repl, role).handle( + ReplHandler(client, repl, role_class).handle( prompt, model=model.value, temperature=temperature, @@ -154,7 +153,7 @@ def main( ) if chat: - full_completion = ChatHandler(client, chat, role).handle( + full_completion = ChatHandler(client, chat, role_class).handle( prompt, model=model.value, temperature=temperature, @@ -163,7 +162,7 @@ def main( caching=cache, ) else: - full_completion = DefaultHandler(client, role).handle( + full_completion = DefaultHandler(client, role_class).handle( prompt, model=model.value, temperature=temperature, diff --git a/sgpt/config.py b/sgpt/config.py index 938c0d8d..47956ee3 100644 --- a/sgpt/config.py +++ b/sgpt/config.py @@ -8,7 +8,6 @@ from .utils import ModelOptions - CONFIG_FOLDER = os.path.expanduser("~/.config") SHELL_GPT_CONFIG_FOLDER = Path(CONFIG_FOLDER) / "shell_gpt" SHELL_GPT_CONFIG_PATH = SHELL_GPT_CONFIG_FOLDER / ".sgptrc" @@ -33,7 +32,6 @@ class Config(dict): # type: ignore - def __init__(self, config_path: Path, **defaults: Any): self.config_path = config_path diff --git a/sgpt/handlers/chat_handler.py b/sgpt/handlers/chat_handler.py index a9ee5c91..e5f589ab 100644 --- a/sgpt/handlers/chat_handler.py +++ b/sgpt/handlers/chat_handler.py @@ -152,14 +152,18 @@ def validate(self) -> None: if self.initiated: # print("initial message:", self.initial_message) chat_role_name = self.role.get_role_name(self.initial_message) + if not chat_role_name: + raise BadArgumentUsage( + f'Could not determine chat role of "{self.chat_id}"' + ) if self.role.name == "default": # If user didn't pass chat mode, we will use the one that was used to initiate the chat. self.role = SystemRole.get(chat_role_name) else: if not self.is_same_role: raise BadArgumentUsage( - f"Cant change chat role \"{self.role.name}\" " - f"of initiated \"{chat_role_name}\" chat." + f'Cant change chat role "{self.role.name}" ' + f'of initiated "{chat_role_name}" chat.' ) def make_prompt(self, prompt: str) -> str: diff --git a/sgpt/handlers/repl_handler.py b/sgpt/handlers/repl_handler.py index b31cf99c..1a1e6382 100644 --- a/sgpt/handlers/repl_handler.py +++ b/sgpt/handlers/repl_handler.py @@ -4,11 +4,10 @@ from rich import print as rich_print from rich.rule import Rule -from ..role import SystemRole from ..client import OpenAIClient -from .chat_handler import ChatHandler +from ..role import DefaultRoles, SystemRole from ..utils import run_command -from ..role import DefaultRoles +from .chat_handler import ChatHandler class ReplHandler(ChatHandler): @@ -45,7 +44,7 @@ def handle(self, prompt: str, **kwargs: Any) -> None: # type: ignore if prompt == "exit()": # This is also useful during tests. raise typer.Exit() - if self.role.name == DefaultRoles.SHELL: + if self.role.name == DefaultRoles.SHELL.value: if prompt == "e": typer.echo() run_command(full_completion) diff --git a/sgpt/role.py b/sgpt/role.py index 540455b6..e5b24991 100644 --- a/sgpt/role.py +++ b/sgpt/role.py @@ -1,16 +1,12 @@ import json -from enum import Enum - -from pathlib import Path -from typing import Dict, Callable, Optional - -import typer - - import platform +from enum import Enum from os import getenv, pathsep from os.path import basename +from pathlib import Path +from typing import Any, Callable, Dict, Optional +import typer from distro import name as distro_name from .config import cfg @@ -43,12 +39,13 @@ {expecting}:""" -def option_callback(func: Callable) -> Callable: - def wrapper(cls, value): +def option_callback(func: Callable) -> Callable: # type: ignore + def wrapper(cls: Any, value: Any) -> None: if not value: return func(cls) raise typer.Exit() + return wrapper @@ -72,13 +69,13 @@ def __init__( self.role = role @classmethod - def create_defaults(cls): + def create_defaults(cls) -> None: cls.storage.parent.mkdir(parents=True, exist_ok=True) variables = {"shell": cls.shell_name(), "os": cls.os_name()} for default_role in ( - SystemRole("default", DEFAULT_ROLE, "Answer", variables), - SystemRole("shell", SHELL_ROLE, "Command", variables), - SystemRole("code", CODE_ROLE, "Code"), + SystemRole("default", DEFAULT_ROLE, "Answer", variables), + SystemRole("shell", SHELL_ROLE, "Command", variables), + SystemRole("code", CODE_ROLE, "Code"), ): if not default_role.exists: default_role.save() @@ -124,7 +121,9 @@ def create(cls, name: str) -> None: if not name: return role = typer.prompt("Enter role description") - expecting = typer.prompt("Enter output type, e.g. answer, code, shell command, json, etc.") + expecting = typer.prompt( + "Enter output type, e.g. answer, code, shell command, json, etc." + ) role = cls(name, role, expecting) role.save() raise typer.Exit() @@ -142,7 +141,7 @@ def list(cls) -> None: @classmethod @option_callback - def show(cls, name: str): + def show(cls, name: str) -> None: typer.echo(cls.get(name).role) @property diff --git a/tests/test_integration.py b/tests/test_integration.py index e4bdf72a..b25af0c2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -19,9 +19,9 @@ import typer from typer.testing import CliRunner +from sgpt.app import main from sgpt.client import OpenAIClient from sgpt.config import cfg -from sgpt.app import main from sgpt.handlers.handler import Handler runner = CliRunner() diff --git a/tests/test_unit.py b/tests/test_unit.py index 462acc59..888305c0 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -4,7 +4,7 @@ import requests import requests_mock -from sgpt import OpenAIClient +from sgpt.client import OpenAIClient class TestMain(unittest.TestCase): From adc4c0180196681b4944a47de6f24ac6ae34d8ae Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sat, 15 Apr 2023 23:29:14 +0200 Subject: [PATCH 04/16] Fixing role options, better error handling --- sgpt/role.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/sgpt/role.py b/sgpt/role.py index e5b24991..58ef189f 100644 --- a/sgpt/role.py +++ b/sgpt/role.py @@ -4,9 +4,10 @@ from os import getenv, pathsep from os.path import basename from pathlib import Path -from typing import Any, Callable, Dict, Optional +from typing import Callable, Dict, Optional import typer +from click import BadArgumentUsage from distro import name as distro_name from .config import cfg @@ -40,10 +41,10 @@ def option_callback(func: Callable) -> Callable: # type: ignore - def wrapper(cls: Any, value: Any) -> None: + def wrapper(cls: "SystemRole", value: str) -> None: if not value: return - func(cls) + func(cls, value) raise typer.Exit() return wrapper @@ -113,24 +114,22 @@ def get(cls, name: str) -> "SystemRole": file_path = cls.storage / f"{name}.json" # print(file_path) if not file_path.exists(): - raise FileNotFoundError(f'Role "{name}" not found.') + raise BadArgumentUsage(f'Role "{name}" not found.') return cls(**json.loads(file_path.read_text())) @classmethod + @option_callback def create(cls, name: str) -> None: - if not name: - return role = typer.prompt("Enter role description") expecting = typer.prompt( "Enter output type, e.g. answer, code, shell command, json, etc." ) role = cls(name, role, expecting) role.save() - raise typer.Exit() @classmethod @option_callback - def list(cls) -> None: + def list(cls, _value: str) -> None: if not cls.storage.exists(): return # Get all files in the folder. From 75eae5d1f9e60e1231efa1958e2aee881addf1cf Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sat, 15 Apr 2023 23:43:18 +0200 Subject: [PATCH 05/16] Minor fixes, optimizing --- sgpt/app.py | 4 ++-- sgpt/role.py | 13 ++----------- sgpt/utils.py | 12 ++++++++++++ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/sgpt/app.py b/sgpt/app.py index 13740c3c..3cfb677e 100644 --- a/sgpt/app.py +++ b/sgpt/app.py @@ -102,13 +102,13 @@ def main( rich_help_panel="Role Options", callback=SystemRole.create, ), - show_role: str = typer.Option( # pylint: disable=W0613 + show_role: str = typer.Option( None, help="Show role.", callback=SystemRole.show, rich_help_panel="Role Options", ), - list_roles: bool = typer.Option( # pylint: disable=W0613 + list_roles: bool = typer.Option( False, help="List roles.", callback=SystemRole.list, diff --git a/sgpt/role.py b/sgpt/role.py index 58ef189f..76915cb1 100644 --- a/sgpt/role.py +++ b/sgpt/role.py @@ -4,13 +4,14 @@ from os import getenv, pathsep from os.path import basename from pathlib import Path -from typing import Callable, Dict, Optional +from typing import Dict, Optional import typer from click import BadArgumentUsage from distro import name as distro_name from .config import cfg +from .utils import option_callback SHELL_ROLE = """Provide only {shell} commands for {os} without any description. If there is a lack of details, provide most logical solution. @@ -40,16 +41,6 @@ {expecting}:""" -def option_callback(func: Callable) -> Callable: # type: ignore - def wrapper(cls: "SystemRole", value: str) -> None: - if not value: - return - func(cls, value) - raise typer.Exit() - - return wrapper - - class SystemRole: storage: Path = Path(cfg.get("ROLE_STORAGE_PATH")) diff --git a/sgpt/utils.py b/sgpt/utils.py index 8388af5f..889345bb 100644 --- a/sgpt/utils.py +++ b/sgpt/utils.py @@ -3,7 +3,9 @@ import shlex from enum import Enum from tempfile import NamedTemporaryFile +from typing import Callable, Any +import typer from click import BadParameter @@ -53,3 +55,13 @@ def run_command(command: str) -> None: full_command = f"{shell} -c {shlex.quote(command)}" os.system(full_command) + + +def option_callback(func: Callable) -> Callable: # type: ignore + def wrapper(cls: Any, value: str) -> None: + if not value: + return + func(cls, value) + raise typer.Exit() + + return wrapper From efa024415ecf79b66273d6625be552dcbf583a7e Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sat, 15 Apr 2023 23:45:27 +0200 Subject: [PATCH 06/16] Lint CI --- sgpt/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sgpt/utils.py b/sgpt/utils.py index 889345bb..89ebf3e8 100644 --- a/sgpt/utils.py +++ b/sgpt/utils.py @@ -3,7 +3,7 @@ import shlex from enum import Enum from tempfile import NamedTemporaryFile -from typing import Callable, Any +from typing import Any, Callable import typer from click import BadParameter From a64c761015ae35161421d10181206cf9f86c17a0 Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 04:10:58 +0200 Subject: [PATCH 07/16] Roles for chat mode, optional System Roles, optimization and fixes --- sgpt/config.py | 1 + sgpt/handlers/chat_handler.py | 24 ++++++++++++++++-------- sgpt/handlers/default_handler.py | 12 +++++++++--- sgpt/handlers/handler.py | 29 +++++++++-------------------- sgpt/handlers/repl_handler.py | 10 ++-------- sgpt/role.py | 1 - tests/test_integration.py | 12 +++++++++--- 7 files changed, 46 insertions(+), 43 deletions(-) diff --git a/sgpt/config.py b/sgpt/config.py index 47956ee3..46367ad3 100644 --- a/sgpt/config.py +++ b/sgpt/config.py @@ -27,6 +27,7 @@ "OPENAI_API_HOST": os.getenv("OPENAI_API_HOST", "https://api.openai.com"), "DEFAULT_COLOR": os.getenv("DEFAULT_COLOR", "magenta"), "ROLE_STORAGE_PATH": os.getenv("ROLE_STORAGE_PATH", str(ROLE_STORAGE_PATH)), + "SYSTEM_ROLES": os.getenv("SYSTEM_ROLES", "false"), # New features might add their own config variables here. } diff --git a/sgpt/handlers/chat_handler.py b/sgpt/handlers/chat_handler.py index e5f589ab..6956427a 100644 --- a/sgpt/handlers/chat_handler.py +++ b/sgpt/handlers/chat_handler.py @@ -96,13 +96,11 @@ def __init__( client: OpenAIClient, chat_id: str, role: SystemRole, - model: str = "gpt-3.5-turbo", ) -> None: - super().__init__(client) + super().__init__(client, role) self.chat_id = chat_id self.client = client self.role = role - self.model = model if chat_id == "temp": # If the chat id is "temp", we don't want to save the chat session. @@ -126,7 +124,8 @@ def initiated(self) -> bool: @property def initial_message(self) -> str: chat_history = self.chat_session.get_messages(self.chat_id) - return chat_history[0] if chat_history else "" + index = 1 if cfg.get("SYSTEM_ROLES") == "true" else 0 + return chat_history[index] if chat_history else "" @property def is_same_role(self) -> bool: @@ -144,8 +143,10 @@ def show_messages_callback(cls, chat_id: str) -> None: def show_messages(cls, chat_id: str) -> None: # Prints all messages from a specified chat ID to the console. for index, message in enumerate(cls.chat_session.get_messages(chat_id)): - message = message.replace("\nCommand:", "").replace("\nCode:", "") - color = "cyan" if index % 2 == 0 else "green" + # Remove output type from the message, e.g. "text\nCommand:" -> "text" + if message.startswith("user:"): + message = "\n".join(message.splitlines()[:-1]) + color = "magenta" if index % 2 == 0 else "green" typer.secho(message, fg=color) def validate(self) -> None: @@ -162,14 +163,21 @@ def validate(self) -> None: else: if not self.is_same_role: raise BadArgumentUsage( - f'Cant change chat role "{self.role.name}" ' - f'of initiated "{chat_role_name}" chat.' + f'Cant change chat role to "{self.role.name}" ' + f'since it was initiated as "{chat_role_name}" chat.' ) def make_prompt(self, prompt: str) -> str: prompt = prompt.strip() return self.role.make_prompt(prompt, not self.initiated) + def make_messages(self, prompt: str) -> List[Dict[str, str]]: + messages = [] + if not self.initiated and cfg.get("SYSTEM_ROLES") == "true": + messages.append({"role": "system", "content": self.role.role}) + messages.append({"role": "user", "content": prompt}) + return messages + @chat_session def get_completion( self, diff --git a/sgpt/handlers/default_handler.py b/sgpt/handlers/default_handler.py index 034961b0..7afa5644 100644 --- a/sgpt/handlers/default_handler.py +++ b/sgpt/handlers/default_handler.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Dict, List from ..client import OpenAIClient from ..config import cfg @@ -14,13 +15,18 @@ def __init__( self, client: OpenAIClient, role: SystemRole, - model: str = "gpt-3.5-turbo", ) -> None: - super().__init__(client) + super().__init__(client, role) self.client = client self.role = role - self.model = model def make_prompt(self, prompt: str) -> str: prompt = prompt.strip() return self.role.make_prompt(prompt, initial=True) + + def make_messages(self, prompt: str) -> List[Dict[str, str]]: + messages = [] + if cfg.get("SYSTEM_ROLES") == "true": + messages.append({"role": "system", "content": self.role.role}) + messages.append({"role": "user", "content": prompt}) + return messages diff --git a/sgpt/handlers/handler.py b/sgpt/handlers/handler.py index 8024dd32..c436df1f 100644 --- a/sgpt/handlers/handler.py +++ b/sgpt/handlers/handler.py @@ -4,37 +4,26 @@ from ..client import OpenAIClient from ..config import cfg +from ..role import SystemRole class Handler: - def __init__(self, client: OpenAIClient) -> None: + def __init__(self, client: OpenAIClient, role: SystemRole) -> None: self.client = client + self.role = role self.color = cfg.get("DEFAULT_COLOR") def make_prompt(self, prompt: str) -> str: raise NotImplementedError - def get_completion( - self, - messages: List[Dict[str, str]], - model: str = "gpt-3.5-turbo", - temperature: float = 1, - top_probability: float = 1, - caching: bool = True, - ) -> Generator[str, None, None]: - yield from self.client.get_completion( - messages, - model, - temperature, - top_probability, - caching=caching, - ) + def make_messages(self, prompt: str) -> List[Dict[str, str]]: + raise NotImplementedError + + def get_completion(self, **kwargs: Any) -> Generator[str, None, None]: + yield from self.client.get_completion(**kwargs) def handle(self, prompt: str, **kwargs: Any) -> str: - prompt = self.make_prompt(prompt) - print(prompt) - # print(kwargs) - messages = [{"role": "user", "content": prompt}] + messages = self.make_messages(self.make_prompt(prompt)) full_completion = "" for word in self.get_completion(messages=messages, **kwargs): typer.secho(word, fg=self.color, bold=True, nl=False) diff --git a/sgpt/handlers/repl_handler.py b/sgpt/handlers/repl_handler.py index 1a1e6382..a17ee783 100644 --- a/sgpt/handlers/repl_handler.py +++ b/sgpt/handlers/repl_handler.py @@ -11,14 +11,8 @@ class ReplHandler(ChatHandler): - def __init__( - self, - client: OpenAIClient, - chat_id: str, - role: SystemRole, - model: str = "gpt-3.5-turbo", - ): - super().__init__(client, chat_id, role, model) + def __init__(self, client: OpenAIClient, chat_id: str, role: SystemRole) -> None: + super().__init__(client, chat_id, role) def handle(self, prompt: str, **kwargs: Any) -> None: # type: ignore if self.initiated: diff --git a/sgpt/role.py b/sgpt/role.py index 76915cb1..ce578198 100644 --- a/sgpt/role.py +++ b/sgpt/role.py @@ -103,7 +103,6 @@ def get_role_name(cls, initial_message: str) -> Optional[str]: @classmethod def get(cls, name: str) -> "SystemRole": file_path = cls.storage / f"{name}.json" - # print(file_path) if not file_path.exists(): raise BadArgumentUsage(f'Role "{name}" not found.') return cls(**json.loads(file_path.read_text())) diff --git a/tests/test_integration.py b/tests/test_integration.py index b25af0c2..17d5a2ce 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -23,6 +23,7 @@ from sgpt.client import OpenAIClient from sgpt.config import cfg from sgpt.handlers.handler import Handler +from sgpt.role import SystemRole runner = CliRunner() app = typer.Typer() @@ -309,16 +310,21 @@ def test_model_option(self, mocked_get_completion): } result = runner.invoke(app, self.get_arguments(**dict_arguments)) mocked_get_completion.assert_called_once_with( - ANY, "gpt-4", 0.1, 1.0, caching=False + messages=ANY, + model="gpt-4", + temperature=0.1, + top_probability=1.0, + caching=False, ) assert result.exit_code == 0 def test_color_output(self): color = cfg.get("DEFAULT_COLOR") - handler = Handler(OpenAIClient("test", "test")) + role = SystemRole.get("default") + handler = Handler(OpenAIClient("test", "test"), role=role) assert handler.color == color os.environ["DEFAULT_COLOR"] = "red" - handler = Handler(OpenAIClient("test", "test")) + handler = Handler(OpenAIClient("test", "test"), role=role) assert handler.color == "red" def test_simple_stdin(self): From 79b1a6b4923d84f8f9440f0257fb88dc20ca970c Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 04:37:42 +0200 Subject: [PATCH 08/16] Minor changes --- README.md | 4 ++-- sgpt/__init__.py | 2 +- sgpt/app.py | 9 ++------- sgpt/client.py | 2 +- tests/test_integration.py | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5a2e2645..454cc382 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Shell GPT +# ShellGPT A command-line productivity tool powered by OpenAI's GPT-3.5 model. As developers, we can leverage AI capabilities to generate shell commands, code snippets, comments, and documentation, among other things. Forget about cheat sheets and notes, with this tool you can get accurate answers right in your terminal, and you'll probably find yourself reducing your daily Google searches, saving you valuable time and effort. https://user-images.githubusercontent.com/16740832/231569156-a3a9f9d4-18b1-4fff-a6e1-6807651aa894.mp4 ## Installation ```shell -pip install shell-gpt==0.8.9 +pip install shell-gpt==0.9.0 ``` You'll need an OpenAI API key, you can generate one [here](https://beta.openai.com/account/api-keys). diff --git a/sgpt/__init__.py b/sgpt/__init__.py index 2fc75787..514e2c53 100644 --- a/sgpt/__init__.py +++ b/sgpt/__init__.py @@ -1,4 +1,4 @@ from .app import main as main from .app import entry_point as cli # noqa: F401 -__version__ = "0.8.9" +__version__ = "0.9.0" diff --git a/sgpt/app.py b/sgpt/app.py index 3cfb677e..954c1ae3 100644 --- a/sgpt/app.py +++ b/sgpt/app.py @@ -1,13 +1,9 @@ """ -shell-gpt: An interface to OpenAI's ChatGPT (GPT-3.5) API - -This module provides a simple interface for OpenAI's ChatGPT API using Typer +This module provides a simple interface for OpenAI API using Typer as the command line interface. It supports different modes of output including shell commands and code, and allows users to specify the desired OpenAI model and length and other options of the output. Additionally, it supports executing shell commands directly from the interface. - -API Key is stored locally for easy use in future runs. """ # To allow users to use arrow keys in the REPL. import readline # noqa: F401 @@ -15,7 +11,6 @@ import typer -# Click is part of typer. from click import BadArgumentUsage, MissingParameter from sgpt.client import OpenAIClient @@ -99,8 +94,8 @@ def main( create_role: str = typer.Option( None, help="Create role.", - rich_help_panel="Role Options", callback=SystemRole.create, + rich_help_panel="Role Options", ), show_role: str = typer.Option( None, diff --git a/sgpt/client.py b/sgpt/client.py index febad84a..fd779529 100644 --- a/sgpt/client.py +++ b/sgpt/client.py @@ -28,7 +28,7 @@ def _request( top_probability: float = 1, ) -> Generator[str, None, None]: """ - Make request to OpenAI ChatGPT API, read more: + Make request to OpenAI API, read more: https://platform.openai.com/docs/api-reference/chat :param messages: List of messages {"role": user or assistant, "content": message_string} diff --git a/tests/test_integration.py b/tests/test_integration.py index 17d5a2ce..67d9ae6d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -65,7 +65,7 @@ def test_shell(self): def test_code(self): """ - This test will request from ChatGPT a python code to make CLI app, + This test will request from OpenAI API a python code to make CLI app, which will be written to a temp file, and then it will be executed in shell with two positional int arguments. As the output we are expecting the result of multiplying them. From 7955881fe350af68205a7547885b1d75018a7668 Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 04:39:08 +0200 Subject: [PATCH 09/16] lint --- sgpt/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sgpt/app.py b/sgpt/app.py index 954c1ae3..4b3a58d2 100644 --- a/sgpt/app.py +++ b/sgpt/app.py @@ -10,7 +10,6 @@ import sys import typer - from click import BadArgumentUsage, MissingParameter from sgpt.client import OpenAIClient From c2fc42cea6dfadbedc0ee8de547c5bb87ae18d6c Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 15:14:31 +0200 Subject: [PATCH 10/16] Integraion tests --- sgpt/app.py | 2 +- tests/test_integration.py | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/sgpt/app.py b/sgpt/app.py index 4b3a58d2..cc7b1e1d 100644 --- a/sgpt/app.py +++ b/sgpt/app.py @@ -112,7 +112,7 @@ def main( stdin_passed = not sys.stdin.isatty() if stdin_passed and not repl: - prompt = sys.stdin.read() + (prompt or "") + prompt = f"{sys.stdin.read()}\n\n{prompt or ''}" if not prompt and not editor and not repl: raise MissingParameter(param_hint="PROMPT", param_type="string") diff --git a/tests/test_integration.py b/tests/test_integration.py index 67d9ae6d..f6322162 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -339,3 +339,56 @@ def test_shell_stdin_with_prompt(self): stdin = "What is in current folder\n" result = runner.invoke(app, self.get_arguments(**dict_arguments), input=stdin) assert result.stdout == "ls | sort\n" + + def test_role(self): + test_role = Path(cfg.get("ROLE_STORAGE_PATH")) / "test_json.json" + test_role.unlink(missing_ok=True) + dict_arguments = { + "prompt": "test", + "--create-role": "test_json", + } + input = "You are a JSON generator, return only JSON as response.\n" "json\n" + result = runner.invoke(app, self.get_arguments(**dict_arguments), input=input) + assert result.exit_code == 0 + + dict_arguments = { + "prompt": "test", + "--list-roles": True, + } + result = runner.invoke(app, self.get_arguments(**dict_arguments)) + assert result.exit_code == 0 + assert "test_json" in result.stdout + + dict_arguments = { + "prompt": "test", + "--show-role": "test_json", + } + result = runner.invoke(app, self.get_arguments(**dict_arguments)) + assert result.exit_code == 0 + assert "You are a JSON generator" in result.stdout + + # Test with command line argument prompt. + dict_arguments = { + "prompt": "random username, password, email", + "--role": "test_json", + } + result = runner.invoke(app, self.get_arguments(**dict_arguments)) + assert result.exit_code == 0 + generated_json = json.loads(result.stdout) + assert "username" in generated_json + assert "password" in generated_json + assert "email" in generated_json + + # Test with stdin prompt. + dict_arguments = { + "prompt": "", + "--role": "test_json", + } + stdin = "random username, password, email" + result = runner.invoke(app, self.get_arguments(**dict_arguments), input=stdin) + assert result.exit_code == 0 + generated_json = json.loads(result.stdout) + assert "username" in generated_json + assert "password" in generated_json + assert "email" in generated_json + test_role.unlink(missing_ok=True) From 31a67b06f885bbb12725ead800dcffb0b800e8eb Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 17:17:44 +0200 Subject: [PATCH 11/16] Readme instructions --- README.md | 22 +++++++++++++++++++++- sgpt/role.py | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 454cc382..cc63bf83 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # ShellGPT -A command-line productivity tool powered by OpenAI's GPT-3.5 model. As developers, we can leverage AI capabilities to generate shell commands, code snippets, comments, and documentation, among other things. Forget about cheat sheets and notes, with this tool you can get accurate answers right in your terminal, and you'll probably find yourself reducing your daily Google searches, saving you valuable time and effort. +A command-line productivity tool powered by OpenAI's GPT-3.5 model. As developers, we can leverage AI capabilities to generate shell commands, code snippets, comments, and documentation, among other things. Forget about cheat sheets and notes, with this tool you can get accurate answers right in your terminal, and you'll probably find yourself reducing your daily Google searches, saving you valuable time and effort. ShellGPT is cross-platform compatible and supports all major operating systems, including Linux, macOS, and Windows with all major shells, such as Windows PowerShell, CMD, Bash, Zsh, Fish. https://user-images.githubusercontent.com/16740832/231569156-a3a9f9d4-18b1-4fff-a6e1-6807651aa894.mp4 @@ -233,6 +233,26 @@ sgpt --show-chat number # assistant: Your favorite number is 4, so if we add 4 to it, the result would be 8. ``` +### Roles +ShellGPT allows you to create custom system roles, which can be utilized to generate code, shell commands, or to fulfill your specific needs. To create a new role, use the `--create-role` option followed by the role name. You will be prompted to provide a description for the role, along with other details. This will create a JSON file in `~/.config/shell_gpt/roles` with the role name. Inside this directory, you can also edit default `sgpt` roles, such as **shell**, **code**, and **default**. Use the `--list-roles` option to list all available roles, and the `--show-role` option to display the details of a specific role. Here's an example of a custom role: +```shell +sgpt --create-role json +# Enter role description: You are JSON generator, provide only valid json as response. +# Enter expecting result, e.g. answer, code, shell command, etc.: json +sgpt --role json "random: user, password, email, address" +{ + "user": "JohnDoe", + "password": "p@ssw0rd", + "email": "johndoe@example.com", + "address": { + "street": "123 Main St", + "city": "Anytown", + "state": "CA", + "zip": "12345" + } +} +``` + ### Request cache Control cache using `--cache` (default) and `--no-cache` options. This caching applies for all `sgpt` requests to OpenAI API: ```shell diff --git a/sgpt/role.py b/sgpt/role.py index ce578198..679671b3 100644 --- a/sgpt/role.py +++ b/sgpt/role.py @@ -112,7 +112,7 @@ def get(cls, name: str) -> "SystemRole": def create(cls, name: str) -> None: role = typer.prompt("Enter role description") expecting = typer.prompt( - "Enter output type, e.g. answer, code, shell command, json, etc." + "Enter expecting result, e.g. answer, code, shell command, etc." ) role = cls(name, role, expecting) role.save() From 296cc6dc514cb77c63ac02bb29f88a82a9da04af Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 17:20:17 +0200 Subject: [PATCH 12/16] minor fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc63bf83..2dd5ced1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # ShellGPT -A command-line productivity tool powered by OpenAI's GPT-3.5 model. As developers, we can leverage AI capabilities to generate shell commands, code snippets, comments, and documentation, among other things. Forget about cheat sheets and notes, with this tool you can get accurate answers right in your terminal, and you'll probably find yourself reducing your daily Google searches, saving you valuable time and effort. ShellGPT is cross-platform compatible and supports all major operating systems, including Linux, macOS, and Windows with all major shells, such as Windows PowerShell, CMD, Bash, Zsh, Fish. +A command-line productivity tool powered by OpenAI's GPT-3.5 model. As developers, we can leverage AI capabilities to generate shell commands, code snippets, comments, and documentation, among other things. Forget about cheat sheets and notes, with this tool you can get accurate answers right in your terminal, and you'll probably find yourself reducing your daily Google searches, saving you valuable time and effort. ShellGPT is cross-platform compatible and supports all major operating systems, including Linux, macOS, and Windows with all major shells, such as Windows PowerShell, CMD, Bash, Zsh, Fish, and many others. https://user-images.githubusercontent.com/16740832/231569156-a3a9f9d4-18b1-4fff-a6e1-6807651aa894.mp4 From 93fdbc329bd9a060c53a1b38a48f5dbd59aa33e0 Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 17:43:05 +0200 Subject: [PATCH 13/16] Readme info --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2dd5ced1..42af9497 100644 --- a/README.md +++ b/README.md @@ -284,9 +284,13 @@ REQUEST_TIMEOUT=60 DEFAULT_MODEL=gpt-3.5-turbo # Default color for OpenAI completions. DEFAULT_COLOR=magenta +# Force use system role messages (not recommended). +SYSTEM_ROLES=false ``` Possible options for `DEFAULT_COLOR`: black, red, green, yellow, blue, magenta, cyan, white, bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white. +Switch `SYSTEM_ROLES` to force use [system roles](https://help.openai.com/en/articles/7042661-chatgpt-api-transition-guide) messages, this is not recommended, since it doesn't perform well with current GPT models. + ### Full list of arguments ```text ╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────────────╮ From cd059131594037ac2bad6dd172650540739d8cbd Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 17:49:50 +0200 Subject: [PATCH 14/16] Readme changes --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 42af9497..37037548 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # ShellGPT -A command-line productivity tool powered by OpenAI's GPT-3.5 model. As developers, we can leverage AI capabilities to generate shell commands, code snippets, comments, and documentation, among other things. Forget about cheat sheets and notes, with this tool you can get accurate answers right in your terminal, and you'll probably find yourself reducing your daily Google searches, saving you valuable time and effort. ShellGPT is cross-platform compatible and supports all major operating systems, including Linux, macOS, and Windows with all major shells, such as Windows PowerShell, CMD, Bash, Zsh, Fish, and many others. +A command-line productivity tool powered by OpenAI's GPT models. As developers, we can leverage AI capabilities to generate shell commands, code snippets, comments, and documentation, among other things. Forget about cheat sheets and notes, with this tool you can get accurate answers right in your terminal, and you'll probably find yourself reducing your daily Google searches, saving you valuable time and effort. ShellGPT is cross-platform compatible and supports all major operating systems, including Linux, macOS, and Windows with all major shells, such as PowerShell, CMD, Bash, Zsh, Fish, and many others. https://user-images.githubusercontent.com/16740832/231569156-a3a9f9d4-18b1-4fff-a6e1-6807651aa894.mp4 @@ -234,7 +234,7 @@ sgpt --show-chat number ``` ### Roles -ShellGPT allows you to create custom system roles, which can be utilized to generate code, shell commands, or to fulfill your specific needs. To create a new role, use the `--create-role` option followed by the role name. You will be prompted to provide a description for the role, along with other details. This will create a JSON file in `~/.config/shell_gpt/roles` with the role name. Inside this directory, you can also edit default `sgpt` roles, such as **shell**, **code**, and **default**. Use the `--list-roles` option to list all available roles, and the `--show-role` option to display the details of a specific role. Here's an example of a custom role: +ShellGPT allows you to create custom roles, which can be utilized to generate code, shell commands, or to fulfill your specific needs. To create a new role, use the `--create-role` option followed by the role name. You will be prompted to provide a description for the role, along with other details. This will create a JSON file in `~/.config/shell_gpt/roles` with the role name. Inside this directory, you can also edit default `sgpt` roles, such as **shell**, **code**, and **default**. Use the `--list-roles` option to list all available roles, and the `--show-role` option to display the details of a specific role. Here's an example of a custom role: ```shell sgpt --create-role json # Enter role description: You are JSON generator, provide only valid json as response. From 7d31bc217df43702df998868916988514f7b6933 Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 18:03:47 +0200 Subject: [PATCH 15/16] List chat fix, and readme changes --- README.md | 48 ++++++++++++++++++++++----------------- sgpt/app.py | 2 +- tests/test_integration.py | 2 +- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 37037548..dd3c6edb 100644 --- a/README.md +++ b/README.md @@ -293,27 +293,33 @@ Switch `SYSTEM_ROLES` to force use [system roles](https://help.openai.com/en/art ### Full list of arguments ```text -╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ prompt [PROMPT] The prompt to generate completions for. │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --model [gpt-3.5-turbo|gpt-4|gpt-4-32k] OpenAI GPT model to use. [default: gpt-3.5-turbo] │ -│ --temperature FLOAT RANGE [0.0<=x<=1.0] Randomness of generated output. [default: 0.1] │ -│ --top-probability FLOAT RANGE [0.1<=x<=1.0] Limits highest probable tokens (words). [default: 1.0] │ -│ --editor Open $EDITOR to provide a prompt. [default: no-editor] │ -│ --cache Cache completion results. [default: cache] │ -│ --help Show this message and exit. │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Assistance Options ───────────────────────────────────────────────────────────────────────────────────────╮ -│ --shell -s Generate and execute shell commands. │ -│ --code --no-code Generate only code. [default: no-code] │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Chat Options ─────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --chat TEXT Follow conversation with id, use "temp" for quick session. [default: None] │ -│ --repl TEXT Start a REPL (Read–eval–print loop) session. [default: None] │ -│ --show-chat TEXT Show all messages from provided chat id. [default: None] │ -│ --list-chat List all existing chat ids. [default: no-list-chat] │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ prompt [PROMPT] The prompt to generate completions for. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --model [gpt-3.5-turbo|gpt-4|gpt-4-32k] OpenAI GPT model to use. [default: gpt-3.5-turbo] │ +│ --temperature FLOAT RANGE [0.0<=x<=1.0] Randomness of generated output. [default: 0.1] │ +│ --top-probability FLOAT RANGE [0.1<=x<=1.0] Limits highest probable tokens (words). [default: 1.0] │ +│ --editor Open $EDITOR to provide a prompt. [default: no-editor] │ +│ --cache Cache completion results. [default: cache] │ +│ --help Show this message and exit. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Assistance Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --shell -s Generate and execute shell commands. │ +│ --code --no-code Generate only code. [default: no-code] │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Chat Options ──────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --chat TEXT Follow conversation with id, use "temp" for quick session. [default: None] │ +│ --repl TEXT Start a REPL (Read–eval–print loop) session. [default: None] │ +│ --show-chat TEXT Show all messages from provided chat id. [default: None] │ +│ --list-chats List all existing chat ids. [default: no-list-chats] │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Role Options ──────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --role TEXT System role for GPT model. [default: None] │ +│ --create-role TEXT Create role. [default: None] │ +│ --show-role TEXT Show role. [default: None] │ +│ --list-roles List roles. [default: no-list-roles] │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` ## Docker diff --git a/sgpt/app.py b/sgpt/app.py index cc7b1e1d..ef226a7e 100644 --- a/sgpt/app.py +++ b/sgpt/app.py @@ -79,7 +79,7 @@ def main( callback=ChatHandler.show_messages_callback, rich_help_panel="Chat Options", ), - list_chat: bool = typer.Option( + list_chats: bool = typer.Option( False, help="List all existing chat ids.", callback=ChatHandler.list_ids, diff --git a/tests/test_integration.py b/tests/test_integration.py index f6322162..f468de2d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -167,7 +167,7 @@ def test_chat_code(self): assert result.exit_code == 2 def test_list_chat(self): - result = runner.invoke(app, ["--list-chat"]) + result = runner.invoke(app, ["--list-chats"]) assert result.exit_code == 0 assert "test_" in result.stdout From 9621063d5c5c4743376e1d1dc253966dfbb6ab26 Mon Sep 17 00:00:00 2001 From: Farkhod Sadykov Date: Sun, 16 Apr 2023 18:16:24 +0200 Subject: [PATCH 16/16] Fixing --list-chats description --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dd3c6edb..34419037 100644 --- a/README.md +++ b/README.md @@ -218,9 +218,9 @@ print(response.text) ``` ### Chat sessions -To list all the current chat sessions, use the `--list-chat` option: +To list all the current chat sessions, use the `--list-chats` option: ```shell -sgpt --list-chat +sgpt --list-chats # .../shell_gpt/chat_cache/number # .../shell_gpt/chat_cache/python_request ```