├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── .mdformat.toml ├── LICENSE ├── MANIFEST.in ├── README.md ├── README_zh.md ├── docs ├── archive │ └── README.md └── demo │ ├── Screenshot_20230302_222757.png │ ├── Screenshot_20230302_222840.png │ ├── Screenshot_20230302_222926.png │ ├── demo.md │ ├── ezgif.com-optimize(1).gif │ └── ezgif.com-optimize.gif ├── pyproject.toml ├── requirements.txt ├── script ├── chat.py └── config.yaml.example └── src ├── chatgpt_cli ├── __init__.py ├── chat.py └── conversation.py └── utils ├── __init__.py ├── cmd.py ├── file.py └── io.py /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | name: Python Formatting Suite 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 3.10 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: "3.10" 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.txt 27 | - name: Check with black 28 | run: | 29 | black . --check 30 | # - name: Lint with flake8 31 | # run: | 32 | # flake8 . --count --statistics --show-source 33 | - name: Lint .md files with mdformat 34 | run: | 35 | mdformat . --check 36 | - name: Build PyPi Package 37 | run: | 38 | python3 -m pip install --upgrade build 39 | python -m build 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | *$py.class 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | dist/ 13 | eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | pip-wheel-metadata/ 21 | share/python-wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # Virtual environments 27 | venv/ 28 | env/ 29 | ENV/ 30 | env.bak/ 31 | pythonenv* 32 | 33 | # Local development settings 34 | .idea/ 35 | .vscode/ 36 | 37 | # Ignore any local configuration files 38 | config.local.*/ 39 | 40 | # Ignore backup files 41 | *~ 42 | 43 | # Ignore any archives or files extracted from them 44 | *.gz 45 | *.bz2 46 | *.zip 47 | *.tar 48 | *.tar.gz 49 | *.tgz 50 | 51 | # Ignore pytest cache directory 52 | __pycache__/ 53 | 54 | # Ignore Django generated files 55 | *.log 56 | *.pot 57 | *.pyc 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Ignore Sphinx documentation 62 | docs/_build/ 63 | 64 | # Ignore dist directory 65 | dist/ 66 | 67 | .python-version 68 | data/* 69 | bin/* 70 | config.yml 71 | config.yaml 72 | sync_bin.* 73 | script/data/* 74 | script/config.yaml 75 | script/config.yml 76 | secrets/* 77 | upload.py 78 | build_and_run.sh 79 | install_and_run.sh -------------------------------------------------------------------------------- /.mdformat.toml: -------------------------------------------------------------------------------- 1 | wrap = 90 2 | number = true 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jerry Yang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include docs * 2 | README_zh.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT CLI 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/chatgpt-cli-md)](https://pypi.org/project/chatgpt-cli-md/) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/chatgpt-cli-md)](https://pypi.org/project/chatgpt-cli-md/) 5 | [![PyPI - License](https://img.shields.io/pypi/l/chatgpt-cli-md)](https://pypi.org/project/chatgpt-cli-md/) 6 | [![Stars](https://img.shields.io/github/stars/efJerryYang/chatgpt-cli)](https://github.com/efJerryYang/chatgpt-cli/stargazers) 7 | 8 | [中文](README_zh.md) 9 | 10 | ## Introduction 11 | 12 | ChatGPT CLI is a command-line interface tool that connects to the ChatGPT language model 13 | using OpenAI's official API key. It features markdown support, allowing you to structure 14 | your inputs in a readable and well-organized format for easier reference. The tool also 15 | saves conversations in `JSON` format, you can load them with `!load` command. Commands 16 | provided enable you to use this tool much like you would use the official web client. For 17 | more command usage, you can type `!help` or see more in the [Commands](#commands) section. 18 | 19 | Here is a simple demonstration of how to use it: 20 | 21 | ![demo](docs/demo/ezgif.com-optimize.gif) 22 | 23 | 24 | 25 | ## Prequisites 26 | 27 | To run the ChatGPT CLI tool, you'll need to have Python version 3.8 or higher installed on 28 | your machine. You can check your Python version by typing `python -V` in your terminal. We 29 | require version at least `3.8` because the feature of `importlib.metadata` is only 30 | available from this version. You'll also need an OpenAI API key, which you can easily 31 | [get here](https://platform.openai.com/account/api-keys). 32 | 33 | The required Python packages can be installed with `pip install -r requirements.txt`, 34 | which includes the following: 35 | 36 | - `openai >= 0.27.0` 37 | - `pyyaml >= 6.0` 38 | - `rich >= 13.3.1` 39 | 40 | ## Installation 41 | 42 | You can install ChatGPT CLI by downloading the package from the 43 | [latest release](https://github.com/efJerryYang/chatgpt-cli/releases) and running either 44 | of the following commands. Note that you will need to replace `` with the version 45 | number of the package you downloaded (e.g. `0.1.0`). 46 | 47 | You can also install the package from PyPI by running `pip install chatgpt-cli-md`. 48 | 49 | ```sh 50 | pip install chatgpt-cli-md-.tar.gz 51 | ``` 52 | 53 | ```sh 54 | pip install chatgpt_cli_md--py3-none-any.whl 55 | ``` 56 | 57 | This will automatically install all the required dependencies. You can also build the 58 | project and get the binary file to install by cloning the repository from GitHub and 59 | running the following commands: 60 | 61 | ```sh 62 | git clone https://github.com/efJerryYang/chatgpt-cli.git 63 | ``` 64 | 65 | Install necessary dependencies: 66 | 67 | ```sh 68 | pip install -r requirements.txt 69 | ``` 70 | 71 | Build the project into package: 72 | 73 | ```sh 74 | python -m build 75 | ``` 76 | 77 | Once built, you can follow the instructions above to install the package. The built 78 | package will be located in `dist` directory. 79 | 80 | We highly recommend that you use the latest version of ChatGPT CLI and install it using 81 | the recommended methods for optimal performance and stability. 82 | 83 | ## Getting Started 84 | 85 | After installation, you can start the ChatGPT CLI tool by typing `chatgpt-cli` in your 86 | terminal. 87 | 88 | ```sh 89 | chatgpt-cli 90 | ``` 91 | 92 | If you are running the tool for the first time, you will be prompted to configure your 93 | `config.yaml` file, and you can also import the `data` directory from your previous script 94 | version. If you do not have a configuration file in the path 95 | `${HOME}/.config/chatgpt-cli/config.yaml`, you can create a new one with the interactive 96 | setup procedure provided by the tool, or you can import the one you have already used in 97 | your previous version. If choose to create a new one, You will need to input your OpenAI 98 | API key and proxy settings (if any). You can also get an OpenAI API key 99 | [here](https://platform.openai.com/account/api-keys) if you do not have one. 100 | 101 | After configuring your settings, a welcome panel with help information will be displayed, 102 | and you can start chatting with ChatGPT using a variety of commands. 103 | 104 | A template of `config.yaml` is shown below: 105 | 106 | ```yaml 107 | # ChatGPT CLI Configuration File 108 | openai: 109 | api_key: 110 | default_prompt: 111 | - role: system 112 | content: You are ChatGPT, a language model trained by OpenAI. Now you are responsible for answering any questions the user asks. 113 | proxy: 114 | http_proxy: http://127.0.0.1:7890 115 | https_proxy: http://127.0.0.1:7890 116 | chat: 117 | use_streaming: true 118 | ``` 119 | 120 | You can remove the `proxy` section or leave its value empty if you do not need to use a 121 | proxy. 122 | 123 | ## Commands 124 | 125 | We've provided several commands to help you use this tool more conveniently. You don't 126 | need to remember all of them at once, as you can type `!help` whenever you want to have a 127 | look. The following is a list a available commands: 128 | 129 | - `!help` or `!h`: shows the help message 130 | - `!show`: displays the current conversation messages 131 | - `!save`: saves the current conversation to a `JSON` file 132 | - `!load`: loads a conversation from a `JSON` file 133 | - `!new` or `!reset`: starts a new conversation 134 | - `!editor` or `!e`: use your default editor (e.g. vim) to submit a message 135 | - `!regen`: regenerates the last response 136 | - `!resend`: resends your last prompt to generate response 137 | - `!edit`: selects messages for editing 138 | - `!drop`: selects messages for deletion 139 | - `!exit` or `!quit` or `!q`: exits the program 140 | 141 | Features (under development): 142 | 143 | - `!tmpl` or `!tmpl load`: select a template to use 144 | - `!tmpl show`: show all templates with complete information 145 | - `!tmpl create`: create a new template 146 | - `!tmpl edit`: edit an existing template (not implemented yet) 147 | - `!tmpl drop`: drop an existing template (not implemented yet) 148 | 149 | These commands are designed to enable you to use this tool much like you would use the 150 | official web client. If you find that you need additional command support, feel free to 151 | open an issue. 152 | 153 | ## Todos 154 | 155 | We have some todos for future improvements, such as: 156 | 157 | - [x] Detect `[Ctrl]+[C]` hotkey and prompt to confirm exiting 158 | - [ ] `!token`: Count tokens in conversation and display the total number 159 | - [ ] `!sum`: Generate a summary of the conversation to reduce token usage 160 | - [x] `!tmpl`: Choose system prompt templates 161 | - [ ] `!conv`: Show conversation list, Delete and Rename saved conversations 162 | - [ ] `!sys `: Enable you to run system command 163 | 164 | ## Contributing 165 | 166 | If you'd like to contribute to ChatGPT CLI, please feel free to submit a pull request or 167 | open an issue! 168 | 169 | ## References 170 | 171 | - The idea of using the `rich.panel` package comes from 172 | [mbroton's chatgpt-api](https://github.com/mbroton/chatgpt-api). 173 | - The `!sum` command for generating a summary of the current conversation to guide the 174 | user in continuing the conversation is inspired by 沙漏/u202e. 175 | 176 | ## License 177 | 178 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for 179 | details. 180 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # ChatGPT CLI 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/chatgpt-cli-md)](https://pypi.org/project/chatgpt-cli-md/) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/chatgpt-cli-md)](https://pypi.org/project/chatgpt-cli-md/) 5 | [![PyPI - License](https://img.shields.io/pypi/l/chatgpt-cli-md)](https://pypi.org/project/chatgpt-cli-md/) 6 | [![Stars](https://img.shields.io/github/stars/efJerryYang/chatgpt-cli)](https://github.com/efJerryYang/chatgpt-cli/stargazers) 7 | 8 | [English](README.md) 9 | 10 | ## 简介 11 | 12 | ChatGPT CLI 是一个使用 OpenAI 官方 API 和 ChatGPT 交互的命令行工具,支持 Markdown 语法的输入和输出,通过 `!show` 可以用 13 | Markdown 渲染展示当前对话。 14 | 15 | 对话记录保存在 `JSON` 文件中,可以通过 `!load` 。提供的命令能够让你有接近官方 web 端功能的使用体验。更多的命令使用可以通过 `!help` 16 | 命令来查看,或者参考[命令](#%E5%91%BD%E4%BB%A4)部分 17 | 18 | 以下是一个简要的展示: 19 | 20 | ![demo](docs/demo/ezgif.com-optimize.gif) 21 | 22 | 23 | 24 | ## 准备 25 | 26 | 运行 ChatGPT CLI 要求 Python 3.8 及以上版本,因为使用了 `importlib.metadata` 特性,但是这一特性在 3.8 27 | 及以后的版本才加入。可以通过在控制台运行 `python -V` 来查看当前环境的 Python 版本。 28 | 29 | 你同样需要一个 OpenAI 的 API key,可以从[官网](https://platform.openai.com/account/api-keys)获取。 30 | 31 | 可以使用 `pip install -r requirements.txt` 安装需要的包,包括: 32 | 33 | - `openai >= 0.27.0` 34 | - `pyyaml >= 6.0` 35 | - `rich >= 13.3.1` 36 | 37 | ## 安装 38 | 39 | 可以从 [latest release](https://github.com/efJerryYang/chatgpt-cli/releases) 40 | 下载最新版本的包,运行以下两个命令之一进行安装。你需要注意的是,请将 `` 替换为你下载的版本号,如 `0.1.0`。 41 | 42 | 你也可以选择执行 `pip install chatgpt-cli-md` 从 PyPI 安装。 43 | 44 | ```sh 45 | pip install chatgpt-cli-md-.tar.gz 46 | ``` 47 | 48 | ```sh 49 | pip install chatgpt_cli_md--py3-none-any.whl 50 | ``` 51 | 52 | 这将自动安装所需的依赖,所以你需要确保你的网络连接没有问题。你也可以选择从源代码打包后安装,可以先 clone 当前项目: 53 | 54 | ```sh 55 | git clone https://github.com/efJerryYang/chatgpt-cli.git 56 | ``` 57 | 58 | 然后安装必要的依赖: 59 | 60 | ```sh 61 | pip install -r requirements.txt 62 | ``` 63 | 64 | 构建 Python 包: 65 | 66 | ```sh 67 | python -m build 68 | ``` 69 | 70 | 当构建完成之后,可以参考上文所给出的安装教程。你可以在 `dist` 目录下找到打包好的文件。 71 | 72 | 我们强烈建议你使用最新版本的 ChatGPT CLI,并且使用推荐的方法进行安装,以保证错误的修复和运行的稳定。 73 | 74 | ## 开始使用 75 | 76 | 在安装完成之后,你就可以通过在控制台执行 `chatgpt-cli` 来运行 ChatGPT CLI: 77 | 78 | ```sh 79 | chatgpt-cli 80 | ``` 81 | 82 | 如果你是第一次运行,将提示你配置 `config.yaml` 文件,以及选择是否导入之前版本 `data` 目录下的对话记录。 如果程序在 83 | `${HOME}/.config/chatgpt-cli/config.yaml` 找不到配置文件,你可以选择根据交互提示新建一个,或者从原来 `config.yaml` 84 | 的路径导入。如果你选择新建一个,你需要根据提示输入你的 OpenAI API 85 | key,并且设置代理(如果你使用代理的话)。你可以从[这里](https://platform.openai.com/account/api-keys) 获取一个 OpenAI 86 | API key。 87 | 88 | 在配置完成之后,你会看到一个欢迎界面,目前只支持来英文显示,你可以正常使用中文和 ChatGPT 交流。欢迎界面也会呈现命令的帮助信息,此时你就已经可以开始和 ChatGPT 89 | 对话了。 90 | 91 | 一个 `config.yaml` 的模板如下: 92 | 93 | ```yaml 94 | # ChatGPT CLI Configuration File 95 | openai: 96 | api_key: 97 | default_prompt: 98 | - role: system 99 | content: You are ChatGPT, a language model trained by OpenAI. Now you are responsible for answering any questions the user asks. 100 | proxy: 101 | http_proxy: http://127.0.0.1:7890 102 | https_proxy: http://127.0.0.1:7890 103 | chat: 104 | use_streaming: true 105 | ``` 106 | 107 | 如果你不需要使用代理,你可以删除 `proxy` 部分或者将其值留空。 108 | 109 | ## 命令 110 | 111 | 这些命令可以很方便的帮助我们使用这个命令行工具,因为这些都是以复刻 ChatGPT 的 web 端功能为目的编写的。你不需要记住太多,随时都可以通过 `!help` 112 | 进行查看。这些都是比较常用的命令: 113 | 114 | - `!help` 或者 `!h` 呈现帮助信息,目前只有英文显示 115 | - `!show` 用来呈现当前会话的所有消息(以 Markdown 渲染的格式) 116 | - `!save` 保存当前会话到 `JSON` 文件 117 | - `!load` 从文件加载会话,如果遇到当前会话未保存的情况,会提醒你是否选择保存当前会话。 118 | - `!regen` 重新生成最后一次 ChatGPT 的回复 119 | - `!editor` 或者 `!e`: 使用你的默认编辑器编辑你的想要发送的消息(如果系统环境变量没有设置,则默认为vim) 120 | - `!new` 或者 `!reset` 重置会话,如果未保存的话会提示是否保存 121 | - `!drop` 目前用于删除掉某一段消息,可以是 ChatGPT 的也可以是你发的 122 | - `!resend` 通常用于在发送失败的情况下,如遇到网络错误,重新发送上一次的消息 123 | - `!edit` 用于编辑会话,双方的话都可以编辑 124 | - `!exit` 或者 `!quit` 或者 `!q` 退出,未保存的情况下也会提示是否保存 125 | 126 | Features (under development): 127 | 128 | - `!tmpl` or `!tmpl load`: select a template to use 129 | - `!tmpl show`: show all templates with complete information 130 | - `!tmpl create`: create a new template 131 | - `!tmpl edit`: edit an existing template (not implemented yet) 132 | - `!tmpl drop`: drop an existing template (not implemented yet) 133 | 134 | 如果你需要新的命令来实现某个特定的功能,可以在这个仓库下开一个 issue,我根据我的时间安排会尽量完成的。 135 | 136 | ## Todos 137 | 138 | We have some todos for future improvements, such as: 139 | 140 | - [x] Detect `[Ctrl]+[C]` hotkey and prompt to confirm exiting 141 | - [ ] `!token`: Count tokens in conversation and display the total number 142 | - [ ] `!sum`: Generate a summary of the conversation to reduce token usage 143 | - [x] `!tmpl`: Choose system prompt templates 144 | - [ ] `!conv`: Show conversation list, Delete and Rename saved conversations 145 | - [ ] `!sys `: Enable you to run system command 146 | 147 | ## Contributing 148 | 149 | If you'd like to contribute to ChatGPT CLI, please feel free to submit a pull request or 150 | open an issue! 151 | 152 | ## References 153 | 154 | - The idea of using the `rich.panel` package comes from 155 | [mbroton's chatgpt-api](https://github.com/mbroton/chatgpt-api). 156 | - The `!sum` command for generating a summary of the current conversation to guide the 157 | user in continuing the conversation is inspired by 沙漏/u202e. 158 | 159 | ## License 160 | 161 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for 162 | details. 163 | -------------------------------------------------------------------------------- /docs/archive/README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT CLI 2 | 3 | ## Introduction 4 | 5 | ChatGPT CLI is a command-line interface tool that connects to the ChatGPT language model 6 | using OpenAI's official API key. With markdown support, it allows you to structure your 7 | inputs in a readable and well-organized format for future reference. Additionally, the 8 | tool saves conversations in JSON format and loads them when it starts. 9 | 10 | Here is a simple demonstration of how to use it: 11 | 12 | ![demo](demo/ezgif.com-optimize.gif) 13 | 14 | ## Commands 15 | 16 | We've provided serveral commands to help you use this tool more conveniently. You don't 17 | need to remember all of them to start, as you can type `!help` whenever you want to have a 18 | look: 19 | 20 | - `!help`: shows the help message 21 | - `!show`: displays the current conversation messages 22 | - `!save`: saves the current conversation to a `JSON` file 23 | - `!load`: loads a conversation from a `JSON` file 24 | - `!new` or `!reset`: starts a new conversation 25 | - `!regen`: regenerates the last response 26 | - `!resend`: resends your last prompt to generate response 27 | - `!edit`: selects messages for editing 28 | - `!drop`: selects messages for deletion 29 | - `!exit` or `!quit`: exits the program 30 | 31 | These commands are designed to enable you to use this tool much like you would use the 32 | official web client. If you find that you need additinal command support, feel free to 33 | open an issue. 34 | 35 | ## Prequisites 36 | 37 | To use ChatGPT CLI, you'll need to have `Python>=3.8` installed on your machine. You can 38 | type `python -V` in your command line to see your python version, and the output of this 39 | command would look like this: 40 | 41 | ```sh 42 | Python 3.11.2 43 | ``` 44 | 45 | You'll also need an OpenAI API key (which you can 46 | [get here](https://platform.openai.com/account/api-keys)). 47 | 48 | The Python packages we used include `openai`, `pyyaml` and `rich`, which can be installed 49 | with `pip install -r requirements.txt`. 50 | 51 | ## Installation 52 | 53 | To install ChatGPT CLI, simply clone this repository to your local machine: 54 | 55 | ```bash 56 | git clone https://github.com/efJerryYang/chatgpt-cli.git 57 | ``` 58 | 59 | Then, navigate to the cloned repository and install the required dependencies: 60 | 61 | ```sh 62 | pip install -r requirements.txt 63 | ``` 64 | 65 | To use this tool, you will need to have a `config.yaml` in the directory as your script 66 | `chat.py`. You can copy the content of `config.yaml.example` and repalce the api key 67 | placeholder with your own OpenAi API key. 68 | 69 | If you're running the tool over a proxy, replace the `http://127.0.0.1:7890` with the 70 | address and port of your proxy server in the `http_proxy` and `https_proxy` fields 71 | respectively. If you're not using a proxy or you're not sure what to set these fields to, 72 | you can ignore the `proxy` field or delete it from your `config.yaml` file. Here is an 73 | example of a `config.yaml` file with a proxy: 74 | 75 | ```yaml 76 | # config.yaml.example 77 | openai: 78 | api_key: 79 | default_prompt: 80 | - role: system 81 | content: You are ChatGPT, a language model trained by OpenAI. Now you are responsible for answering any questions the user asks. 82 | proxy: 83 | http_proxy: http://yourproxyserver.com:8080 84 | https_proxy: http://yourproxyserver.com:8080 85 | chat: 86 | use_streaming: false 87 | ``` 88 | 89 | Remember to replace `http://yourproxyserver.com:8080` with the address and port of your 90 | proxy server. If you don't want to use a proxy, you can delete the `proxy` field from your 91 | `config.yaml` file, like this: 92 | 93 | ```yaml 94 | # config.yaml.example 95 | openai: 96 | api_key: 97 | default_prompt: 98 | - role: system 99 | content: You are ChatGPT, a language model trained by OpenAI. Now you are responsible for answering any questions the user asks. 100 | # no proxy field is okay to run this tool 101 | ``` 102 | 103 | You can enable output streaming by setting `use_streaming` to `true` in the `chat` section 104 | of `config.yaml`: 105 | 106 | ``` 107 | chat: 108 | use_streaming: false 109 | ``` 110 | 111 | ## Running the Tool 112 | 113 | You should have the following directory structure: 114 | 115 | ```txt 116 | . 117 | |-- chat.py 118 | |-- config.yaml 119 | |-- config.yaml.example 120 | |-- data 121 | | |-- example.json 122 | | `-- example2.json 123 | |-- LICENSE 124 | |-- README.md 125 | `-- requirements.txt 126 | ``` 127 | 128 | `chat.py` is the script to run, and `config.yaml` is the configuration file that sets up 129 | the runtime environment. The `data` directory should be created automatically the first 130 | time you run the script after you have installed the required dependencies. 131 | 132 | To start using this tool, run the following command: 133 | 134 | ```sh 135 | python chat.py 136 | ``` 137 | 138 | You can exit the tool by typing `!quit` command during your conversation, and the script 139 | will prompt you to choose storing the conversation or not once you make changes to current 140 | conversation. The `quit` command in previous versions is also supported. 141 | 142 | To send a prompt to ChatGPT, hit the `[Enter]` key twice after your message. If you press 143 | `[Enter]` only once, it will create a new line, but if the message is blank, it will also 144 | be submitted to ChatGPT. Note that if you submit an empty message, only the stripped empty 145 | string will be sent directly to ChatGPT without any prompts. 146 | 147 | ## Using this tool as a binary (Optional) 148 | 149 | It would be more convenient if the script could be run from any directory, but 150 | unfortunately I'm not familiar with packaging Python projects for PyPI. 151 | 152 | Alternatively, the following commands can help run it as a binary, you can save the 153 | command you use as `sync_bin.sh` or `sync_bin.bat` so that it would not be tracked by 154 | `git` (see `.gitignore`), and which name you use depends on the OS type. 155 | 156 | ### Unix-like OS users 157 | 158 | 1. First, create a directory named `bin` if it does not exist in the root directory of the 159 | project: 160 | 161 | ```sh 162 | mkdir bin 163 | ``` 164 | 165 | 2. Then, copy the `chat.py` file to the `bin` directory and rename it to `chatgpt-cli`: 166 | 167 | ```sh 168 | cp -a chat.py bin/chatgpt-cli 169 | ``` 170 | 171 | 3. Modify the shebang line of the `chatgpt-cli` file to use the path of your Python 172 | interpreter that installed the `requirements.txt`. 173 | 174 | ```sh 175 | sed -i '1i\#!/path/to/your/requirements/installed/python' bin/chatgpt-cli 176 | ``` 177 | 178 | > _Note: Replace `/path/to/your/requirements/installed/python` with the actual path to 179 | > your Python interpreter that has the required packages installed. For example, in my 180 | > case it would be: `/home/jerry/.pyenv/versions/3.11.2/envs/openai-utils/bin/python`_ 181 | 182 | 4. Finally, set the execute permission for the `chatgpt-cli` file if it does not have 183 | currently: 184 | 185 | ```sh 186 | sudo chmod a+x bin/chatgpt-cli 187 | ``` 188 | 189 | 5. Add the following line to your shell run command file, such as `.bashrc`, to include 190 | the `bin` directory to your `$PATH` environment variable: 191 | 192 | ```sh 193 | # chatgpt-cli here is the project directory name 194 | export PATH=${PATH}:/absolute/path/to/your/chatgpt-cli/bin 195 | ``` 196 | 197 | 6. Type `source ~/.bashrc` (or a similar command) to start using this tool from any 198 | directory. 199 | 200 | Now, you can run the `chatgpt-cli` command from any terminal or command prompt window, 201 | regardless of your current working directory. 202 | 203 | ### Windows Users 204 | 205 | > _Notice that the following content for windows users is generated by ChatGPT, it might 206 | > not be reliable currently. However, now I am not very convenient to test the steps below 207 | > on a windows machine. If you find any mistakes below, please let me know._ 208 | 209 | 1. Create a directory named bin in the root directory of the project. 210 | 211 | 2. Copy the `chat.py` file to the bin directory and rename it to `chatgpt-cli.py`. 212 | 213 | 3. Open the environment variable settings window by searching for "Environment Variables" 214 | in the Windows search bar. 215 | 216 | 4. Click on "Edit the system environment variables" and then click on the "Environment 217 | Variables" button. 218 | 219 | 5. In the "User variables" or "System variables" section, find the PATH variable, and 220 | click on the "Edit" button. 221 | 222 | 6. In the "Edit environment variable" window, click on the "New" button and add the 223 | absolute path to the bin directory (e.g., `C:\Projects\chatgpt-cli\bin`). Click on "OK" 224 | to close all the windows. 225 | 226 | 7. In the bin directory, create a new file named `chatgpt-cli.bat` and paste the following 227 | contents: 228 | 229 | ```bat 230 | @echo off 231 | python "%~dp0\chatgpt-cli.py" %* 232 | ``` 233 | 234 | 8. Save the file and close it. 235 | 236 | 9. Open a new command prompt or PowerShell window and run the `chatgpt-cli` command. 237 | 238 | Now, you can run the `chatgpt-cli` command from any terminal or command prompt window, 239 | regardless of your current working directory. 240 | 241 | ## Todos 242 | 243 | - [ ] Detect `[Ctrl]+[C]` hotkey and prompt to confirm exiting 244 | - [ ] Fix inconsistent operation for `!edit` and `!drop` 245 | - [ ] `!token`: Count tokens in conversation and display the total number 246 | - [ ] `!sum`: Generate a summary of the conversation to reduce token usage 247 | - [ ] `!tmpl`: Choose system prompt templates 248 | - [ ] `!conv`: Show conversation list, Delete and Rename saved conversations 249 | - [ ] `!sys `: Enable you to run system command 250 | 251 | ## Contributing 252 | 253 | If you'd like to contribute to ChatGPT CLI, please feel free to submit a pull request or 254 | open an issue! 255 | 256 | ## References 257 | 258 | - The idea of using the `rich.panel` package comes from 259 | [mbroton's chatgpt-api](https://github.com/mbroton/chatgpt-api). 260 | - The `!sum` command for generating a summary of the current conversation to guide the 261 | user in continuing the conversation is inspired by 沙漏/u202e. 262 | 263 | ## License 264 | 265 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for 266 | details. 267 | -------------------------------------------------------------------------------- /docs/demo/Screenshot_20230302_222757.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/efJerryYang/chatgpt-cli/45e6e55d5d3aed0bd407958b965d40c621796fc3/docs/demo/Screenshot_20230302_222757.png -------------------------------------------------------------------------------- /docs/demo/Screenshot_20230302_222840.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/efJerryYang/chatgpt-cli/45e6e55d5d3aed0bd407958b965d40c621796fc3/docs/demo/Screenshot_20230302_222840.png -------------------------------------------------------------------------------- /docs/demo/Screenshot_20230302_222926.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/efJerryYang/chatgpt-cli/45e6e55d5d3aed0bd407958b965d40c621796fc3/docs/demo/Screenshot_20230302_222926.png -------------------------------------------------------------------------------- /docs/demo/demo.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | 3 | ## Features to Show 4 | 5 | - Include all commands like `!help`, `!show`, `!save`, `!load`, `!reset`, `!regen`, 6 | `!resend`, `!edit`, `!drop` and `!quit`. 7 | 8 | - Some branches to be displayed: 9 | 10 | - `!save` at the fresh conversation 11 | - `!load` after modifying the conversation 12 | - `!drop` and `!edit` 13 | - `!reset` after modifying the conversation 14 | 15 | - Timeline: 16 | 17 | - First select one to start (bubble-sort) 18 | - Scroll to the start of this history 19 | - Scroll back and type `!save` (branch: nothing to save) 20 | - `!edit` the user's last input Python->Java, `!regen` (?) 21 | - `!drop` the last response, `!resend` (?) 22 | - `!help`, then `!show`, `!reset`, then `!quit` 23 | 24 | - Use `Ezgif` to convert `MP4` format to `GIF`, and embed the GIF file to README 25 | -------------------------------------------------------------------------------- /docs/demo/ezgif.com-optimize(1).gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/efJerryYang/chatgpt-cli/45e6e55d5d3aed0bd407958b965d40c621796fc3/docs/demo/ezgif.com-optimize(1).gif -------------------------------------------------------------------------------- /docs/demo/ezgif.com-optimize.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/efJerryYang/chatgpt-cli/45e6e55d5d3aed0bd407958b965d40c621796fc3/docs/demo/ezgif.com-optimize.gif -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "chatgpt-cli-md" 3 | version = "0.1.13" 4 | description = "A markdown-supported command-line interface tool that connects to ChatGPT using OpenAI's API key." 5 | readme = { file = "README.md", content-type = "text/markdown" } 6 | requires-python = ">=3.8" 7 | license = { file = "LICENSE" } 8 | keywords = [ 9 | "chatbot", 10 | "chatgpt", 11 | "chatgpt-cli", 12 | "openai", 13 | "markdown", 14 | "cli", 15 | "command-line", 16 | ] 17 | authors = [{ name = "Jerry Yang", email = "efjerryyang@outlook.com" }] 18 | classifiers = [ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | ] 23 | scripts = { chatgpt-cli = "chatgpt_cli.chat:main" } 24 | dependencies = ["openai >= 0.27.0", "pyyaml >= 6.0", "rich >= 13.3.1", "pyreadline3 >= 3.4.1; platform_system=='Windows'"] 25 | 26 | [build-system] 27 | requires = ["setuptools>=61.0", "wheel"] 28 | build-backend = "setuptools.build_meta" 29 | 30 | [project.urls] 31 | "Homepage" = "https://github.com/efJerryYang/chatgpt-cli" 32 | "Bug Tracker" = "https://github.com/efJerryYang/chatgpt-cli/issues" 33 | "Repository" = "https://github.com/efJerryYang/chatgpt-cli.git" 34 | 35 | 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.4 2 | aiosignal==1.3.1 3 | async-timeout==4.0.2 4 | attrs==22.2.0 5 | black==23.1.0 6 | build==0.10.0 7 | certifi==2022.12.7 8 | charset-normalizer==3.1.0 9 | click==8.1.3 10 | frozenlist==1.3.3 11 | idna==3.4 12 | markdown-it-py==2.2.0 13 | mdformat==0.7.16 14 | mdformat-gfm==0.3.5 15 | mdformat-frontmatter==2.0.1 16 | mdformat-footnote==0.1.1 17 | mdurl==0.1.2 18 | multidict==6.0.4 19 | mypy-extensions==1.0.0 20 | openai==0.27.2 21 | packaging==23.0 22 | pathspec==0.11.1 23 | platformdirs==3.2.0 24 | pyreadline3==3.4.1; platform_system=='Windows' 25 | Pygments==2.14.0 26 | PyYAML==6.0 27 | requests==2.31.0 28 | rich==13.3.2 29 | tomli==2.0.1 30 | tqdm==4.65.0 31 | urllib3==1.26.15 32 | yarl==1.8.2 33 | -------------------------------------------------------------------------------- /script/chat.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import readline 4 | from datetime import datetime 5 | from typing import Dict, List 6 | 7 | import openai 8 | import yaml 9 | from rich import print 10 | from rich.console import Console 11 | from rich.markdown import Markdown 12 | from rich.panel import Panel 13 | 14 | 15 | def get_script_dir() -> str: 16 | return os.path.dirname(os.path.abspath(__file__)) 17 | 18 | 19 | def get_data_dir() -> str: 20 | return os.path.join(get_script_dir(), "data") 21 | 22 | 23 | def get_config_dir() -> str: 24 | return get_script_dir() 25 | 26 | 27 | def get_config_path() -> str: 28 | return os.path.join(get_config_dir(), "config.yaml") 29 | 30 | 31 | def printpnl( 32 | msg: str, title="ChatGPT CLI", border_style="white", width=120, markdown=True 33 | ) -> None: 34 | print() 35 | if markdown: 36 | print(Panel(Markdown(msg), title=title, border_style=border_style, width=width)) 37 | else: 38 | print(Panel(msg, title=title, border_style=border_style, width=width)) 39 | print() 40 | 41 | 42 | def load_config() -> Dict: 43 | # check if config file exists 44 | first_launch_msg = """ 45 | Welcome to ChatGPT CLI! 46 | 47 | It looks like this is the first time you're using this tool. 48 | 49 | To use the ChatGPT API you need to provide your OpenAI API key in the `config.yaml` file. 50 | 51 | To get started, please follow these steps: 52 | 53 | 1. Copy the `config.yaml.example` file to `config.yaml` in the same directory. 54 | 2. Open `config.yaml` using a text editor an replace `` with your actual OpenAI API key. 55 | 3. Optionally, you can also set a default prompt to use for generating your GPT output. 56 | 57 | If you don't have an OpenAI API key, you can get one at https://platform.openai.com/account/api-keys/. 58 | 59 | Once you've configured your `config.yaml` file, you can start this tool again. 60 | 61 | Thank you for using ChatGPT CLI! 62 | """ 63 | # check setup 64 | config_path = get_config_path() 65 | if not os.path.exists(config_path): 66 | printpnl(first_launch_msg, "ChatGPT CLI Setup", "red", 120) 67 | exit(1) 68 | # load configurations from config.yaml 69 | with open(config_path, "r") as f: 70 | try: 71 | config = yaml.safe_load(f) 72 | except yaml.YAMLError: 73 | print("Error in configuration file:", config_path) 74 | exit(1) 75 | return config 76 | 77 | 78 | def setup_runtime_env() -> Dict: 79 | config = load_config() 80 | try: 81 | # set up openai API key and system prompt 82 | openai.api_key = config["openai"]["api_key"] 83 | # set proxy if defined 84 | if "proxy" in config: 85 | os.environ["http_proxy"] = config["proxy"].get("http_proxy", "") 86 | os.environ["https_proxy"] = config["proxy"].get("https_proxy", "") 87 | default_prompt = config.get("openai", {}).get("default_prompt", None) 88 | if default_prompt is None: 89 | raise (Exception("Error: the `default_prompt` is empty in `config.yaml`")) 90 | except Exception: 91 | print("Error in configuration file:", get_config_path()) 92 | exit(1) 93 | return config 94 | 95 | 96 | def is_command(user_input: str) -> bool: 97 | """Check if user input is a command""" 98 | quit_words = ["quit", "exit"] 99 | return user_input.startswith("!") or user_input in quit_words 100 | 101 | 102 | def save_data(data: List[Dict[str, str]], filename: str) -> None: 103 | """Save list of dict to JSON file""" 104 | 105 | data_dir = get_data_dir() 106 | print("Data Directory: ", data_dir) 107 | if not os.path.exists(data_dir): 108 | os.mkdir(data_dir) 109 | if filename.endswith(".json"): 110 | filepath = os.path.join(data_dir, filename) 111 | else: 112 | filepath = os.path.join(data_dir, filename + ".json") 113 | with open(filepath, "w") as f: 114 | json.dump(data, f, indent=4) 115 | print(f"Data saved to {filepath}") 116 | 117 | 118 | def load_data(messages: List[Dict[str, str]]) -> str: 119 | """Load JSON file from 'data' directory to 'messages', and return the filepath""" 120 | 121 | data_dir = get_data_dir() 122 | print("Data Directory: ", data_dir) 123 | if not os.path.exists(data_dir): 124 | os.mkdir(data_dir) 125 | 126 | files = [f for f in os.listdir(data_dir) if f.endswith(".json")] 127 | if not files: 128 | print("No data files found in 'data' directory") 129 | return "" 130 | 131 | # prompt user to select a file to load 132 | print("Available data files:\n") 133 | for i, f in enumerate(files): 134 | print(f"{i + 1}. {f}") 135 | for a in range(3): 136 | selected_file = input( 137 | f"\nEnter file number to load (1-{len(files)}), or Enter to start a fresh one: " 138 | ) 139 | if not selected_file.strip(): 140 | return "" 141 | try: 142 | index = int(selected_file) - 1 143 | if not 0 <= index < len(files): 144 | raise ValueError() 145 | filepath = os.path.join(data_dir, files[index]) 146 | with open(filepath, "r") as f: 147 | data = json.load(f) 148 | messages.clear() 149 | messages.extend(data) 150 | print(f"Data loaded from {filepath}") 151 | return filepath 152 | except (ValueError, IndexError): 153 | print("Invalid input, please try again") 154 | print("Too many invalid inputs, aborting") 155 | exit(1) 156 | 157 | 158 | class Conversation: 159 | def __init__(self, default_prompt: List[Dict[str, str]]) -> None: 160 | self.messages = list(default_prompt) 161 | self.default_prompt = list(default_prompt) 162 | self.filepath = load_data(self.messages) 163 | self.modified = False 164 | 165 | def __len__(self) -> int: 166 | return len(self.messages) 167 | 168 | def __add_message(self, message: Dict[str, str]) -> None: 169 | self.messages.append(message) 170 | self.modified = True 171 | 172 | def add_user_message(self, content: str) -> None: 173 | self.__add_message({"role": "user", "content": content}) 174 | 175 | def add_assistant_message(self, content: str) -> None: 176 | self.__add_message({"role": "assistant", "content": content}) 177 | 178 | def edit_system_message(self, content: str) -> None: 179 | if self.messages[0]["role"] == "system": 180 | self.messages[0]["content"] = content 181 | self.modified = True 182 | else: 183 | raise Exception("The first message is not a system message.") 184 | 185 | def save(self, enable_prompt: bool) -> None: 186 | if enable_prompt and self.modified: 187 | user_input = input("Save conversation? [y/n]: ").strip() 188 | if user_input.lower() != "y": 189 | printmd("**Conversation not saved.**") 190 | return 191 | 192 | if self.modified: 193 | if self.filepath: 194 | filename = os.path.basename(self.filepath) 195 | else: 196 | t = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 197 | tmp = f"conversation_{t}.json" 198 | filename = input(f"Enter filename to save to [{tmp}]: ").strip() 199 | if not filename: 200 | filename = tmp 201 | self.filepath = os.path.join(get_data_dir(), filename) 202 | printmd(f"**Conversation save to [{filename}].**") 203 | save_data(self.messages, filename) 204 | self.modified = False 205 | else: 206 | printmd("**Conversation not modified. Nothing to save.**") 207 | 208 | def load(self) -> None: 209 | if self.modified: 210 | user_input = input("Save conversation? [y/n]: ").strip() 211 | if user_input.lower() == "y": 212 | self.save(enable_prompt=False) 213 | self.messages = list(self.default_prompt) 214 | self.filepath = load_data(self.messages) 215 | self.modified = False 216 | printpnl("### Conversation loaded.", "ChatGPT CLI", "green", 120) 217 | 218 | def reset(self) -> None: 219 | if self.modified: 220 | user_input = input("Save conversation? [y/n]: ").strip() 221 | if user_input.lower() == "y": 222 | self.save(enable_prompt=False) 223 | self.filepath = "" 224 | self.messages = list(self.default_prompt) 225 | self.modified = False 226 | printpnl("### Conversation reset.", "ChatGPT CLI", "green", 120) 227 | 228 | def resend(self) -> None: 229 | self.resend_last_prompt() 230 | 231 | def resend_last_prompt(self) -> None: 232 | if len(self.messages) < 2: 233 | printmd("**No previous prompt to resend.**") 234 | return 235 | # Resend last prompt 236 | last_message = self.messages[-1] 237 | if last_message["role"] == "user": 238 | assistant_message = generate_response(self.messages) 239 | if not assistant_message: 240 | printmd("**Last response is empty. Resend failed.**") 241 | return 242 | self.add_assistant_message(assistant_message) 243 | printmd("**Last prompt resent.**") 244 | assistant_output(assistant_message) 245 | else: 246 | printmd("**Last message is assistant message. Nothing to resend.**") 247 | 248 | def regen(self) -> None: 249 | self.regenerate_last_response() 250 | 251 | def regenerate_last_response(self) -> None: 252 | """Regenerate last response""" 253 | if len(self.messages) < 2: 254 | printmd("**No previous response to regenerate.**") 255 | return 256 | # Regenerate last response 257 | last_message = self.messages[-1] 258 | if last_message["role"] == "user": 259 | printmd( 260 | "**Last message is user message. Nothing to regenerate. You may want to use `!resend` instead.**" 261 | ) 262 | return 263 | content = generate_response(self.messages[:-1]) 264 | if not content: 265 | printmd("**Last response is empty. Content not regenerated.**") 266 | return 267 | self.__fill_content(-1, content) 268 | assistant_output(self.messages[-1]["content"]) 269 | printmd("**Last response regenerated.**") 270 | 271 | def __fill_content(self, index: int, content: str) -> None: 272 | """Fill content""" 273 | self.messages[index]["content"] = content 274 | self.modified = True 275 | 276 | def __edit_message(self, index: int, prompt=True) -> None: 277 | """Edit message""" 278 | if prompt: 279 | msg = self.messages[index] 280 | printpnl(f"### Editing Message {index}", "Editing Messages", "yellow") 281 | show_message(msg) 282 | new_content = user_input("\nEnter new content, leave blank to skip: ") 283 | if new_content: 284 | self.__fill_content(index, new_content) 285 | printmd("**Message edited.**") 286 | else: 287 | printmd("**Message not edited.**") 288 | 289 | def show_history(self, index=False, panel=True): 290 | """Show conversation history""" 291 | if panel: 292 | printpnl("### Messages History", "ChatGPT CLI", "green") 293 | if index: 294 | for i in range(len(self.messages)): 295 | printpnl(f"### Message {i}", "Messages History", "green") 296 | show_message(self.messages[i]) 297 | else: 298 | for message in self.messages: 299 | show_message(message) 300 | 301 | def edit_messages(self) -> None: 302 | """Edit messages""" 303 | if len(self.messages) == 0: 304 | printmd("**No message to edit.**") 305 | return 306 | # Show messages history with index to select, or iterate through them one by one? 307 | choose = input( 308 | "Show messages history with [i]ndex to select, or iterate [t]hrough them one by one? [i/t]: " 309 | ).strip() 310 | if choose.lower() == "i": 311 | # Show messages history with index to select 312 | printpnl("### Messages History", "Editing Messages", "yellow") 313 | self.show_history(index=True, panel=False) 314 | index = ( 315 | input( 316 | "Enter index of messages to edit, separate with comma (e.g. 0,1,2), leave blank to cancel: " 317 | ) 318 | .strip() 319 | .strip(",") 320 | ) 321 | if not index: 322 | printmd("**Editing cancelled.**") 323 | return 324 | try: 325 | index = [int(i.strip()) for i in index.split(",")] 326 | for i in index: 327 | if i >= len(self.messages): 328 | raise ValueError 329 | except ValueError: 330 | printmd("**Invalid index. Editing cancelled.**") 331 | return 332 | for i in index: 333 | self.__edit_message(i, prompt=True) 334 | else: # choose.lower() == "o": 335 | for i in range(len(self.messages)): 336 | self.__edit_message(i, prompt=True) 337 | 338 | def drop_messages(self) -> None: 339 | """Drop messages""" 340 | if len(self.messages) == 0: 341 | printmd("**No message to drop.**") 342 | return 343 | index = [] 344 | for i, msg in enumerate(self.messages): 345 | printpnl(f"### Message {i}", "Dropping Messages", "yellow") 346 | show_message(msg) 347 | user_input = input("Select this message to drop? [y/n]: ").strip() 348 | if user_input.lower() == "y": 349 | index.append(i) 350 | printmd("**Message selected.**") 351 | else: 352 | printmd("**Message not selected.**") 353 | if index: 354 | for i in reversed(index): 355 | printpnl(f"### Message {i}", "Dropping Messages", "red") 356 | show_message(self.messages[i]) 357 | user_input = input("Drop this message? [y/n]: ").strip() 358 | if user_input.lower() == "y": 359 | self.messages.pop(i) 360 | self.modified = True 361 | printmd("**Message dropped.**") 362 | else: 363 | printmd("**Message not dropped.**") 364 | printmd("**Selected messages dropped.**") 365 | else: 366 | printmd("**No message selected. Dropping cancelled.**") 367 | 368 | 369 | def execute_command( 370 | user_input: str, 371 | conv: Conversation, 372 | ) -> str: 373 | user_input = user_input.strip() 374 | if user_input in ["!help", "help"]: 375 | show_welcome_panel() 376 | elif user_input in ["!show", "show"]: 377 | conv.show_history() 378 | elif user_input in ["!save", "save"]: 379 | conv.save(False) 380 | elif user_input in ["!load", "load"]: 381 | conv.load() 382 | 383 | conv.show_history(panel=False) 384 | elif user_input in ["!new", "new", "reset", "!reset"]: 385 | conv.reset() 386 | conv.show_history(panel=False) 387 | elif user_input in ["!resend", "resend"]: 388 | conv.resend() 389 | elif user_input in ["!regen", "regen"]: 390 | conv.regen() 391 | elif user_input in ["!edit", "edit"]: 392 | conv.edit_messages() 393 | elif user_input in ["!drop", "drop"]: 394 | conv.drop_messages() 395 | elif user_input in ["!exit", "!quit", "quit", "exit"]: 396 | conv.save(True) 397 | print("Bye!") 398 | exit(0) 399 | elif user_input.startswith("!"): 400 | print("Invalid command, please try again") 401 | return user_input 402 | 403 | 404 | console = Console() 405 | 406 | 407 | def printmd(msg: str, newline=True) -> None: 408 | console.print(Markdown(msg)) 409 | if newline: 410 | print() 411 | 412 | 413 | def user_input(prompt="\nUser: ") -> str: 414 | """ 415 | Get user input with support for multiple lines without submitting the message. 416 | """ 417 | # Use readline to handle user input 418 | lines = [] 419 | while True: 420 | line = input(prompt) 421 | if line.strip() == "": 422 | break 423 | lines.append(line) 424 | # Update the prompt using readline 425 | prompt = "\r" + " " * len(prompt) + "\r" + " .... " + readline.get_line_buffer() 426 | # Print a message indicating that the input has been submitted 427 | msg = "\n".join(lines) 428 | printmd("**[Input Submitted]**") 429 | return msg 430 | 431 | 432 | def show_message(msg: Dict[str, str]) -> None: 433 | role = msg["role"] 434 | content = msg["content"] 435 | if role == "user": 436 | user_output(content) 437 | elif role == "assistant": 438 | assistant_output(content) 439 | elif role == "system": 440 | system_output(content) 441 | else: 442 | raise ValueError(f"Invalid role: {role}") 443 | 444 | 445 | def user_output(msg: str) -> None: 446 | printmd("**User:** {}".format(msg)) 447 | 448 | 449 | def assistant_output(msg: str) -> None: 450 | printmd("**ChatGPT:** {}".format(msg)) 451 | 452 | 453 | def system_output(msg: str) -> None: 454 | printmd("**System:** {}".format(msg)) 455 | 456 | 457 | def show_welcome_panel(): 458 | welcome_msg = """ 459 | Welcome to ChatGPT CLI! 460 | 461 | Greetings! Thank you for choosing this CLI tool. This tool is generally developed for personal use purpose. We use OpenAI's official API to interact with the ChatGPT, which would be more stable than the web interface. 462 | 463 | This tool is still under development, and we are working on improving the user experience. If you have any suggestions, please feel free to open an issue on our GitHub: https://github.com/efJerryYang/chatgpt-cli/issues 464 | 465 | Here are some useful commands you may want to use: 466 | 467 | - `!help`: show this message 468 | - `!show`: show current conversation messages 469 | - `!save`: save current conversation to a `JSON` file 470 | - `!load`: load a conversation from a `JSON` file 471 | - `!new` or `!reset`: start a new conversation 472 | - `!regen`: regenerate the last response 473 | - `!resend`: resend your last prompt to generate response 474 | - `!edit`: select messages to edit 475 | - `!drop`: select messages to drop 476 | - `!exit` or `!quit`: exit the program 477 | 478 | You can enter these commands at any time during a conversation when you are prompted with `User:`. 479 | 480 | For more detailed documentation, please visit or 481 | 482 | Enjoy your chat! 483 | """ 484 | printpnl(welcome_msg, title="Welcome") 485 | 486 | 487 | def generate_response(messages: List[Dict[str, str]]) -> str: 488 | try: 489 | response = openai.ChatCompletion.create( 490 | model="gpt-3.5-turbo", # or gpt-3.5-turbo-0301 491 | messages=messages, 492 | ) 493 | assistant_message = response["choices"][0]["message"]["content"].strip() 494 | return assistant_message 495 | except openai.error.APIConnectionError as api_conn_err: 496 | print(api_conn_err) 497 | printpnl( 498 | "**[API Connection Error]**\n. Please check your internet connection and try again." 499 | ) 500 | user_message = input("Do you want to retry now? (y/n): ") 501 | if user_message.strip().lower() == "y": 502 | return generate_response(messages) 503 | else: 504 | return "" 505 | except openai.error.InvalidRequestError as invalid_err: 506 | print(invalid_err) 507 | printpnl( 508 | "**[Invalid Request Error]**\nPlease revise your messages according to the error message above." 509 | ) 510 | return "" 511 | except openai.error.APIError as api_err: 512 | print(api_err) 513 | printpnl( 514 | "**[API Error]**\nThis might be caused by API outage. Please try again later." 515 | ) 516 | user_message = input("Do you want to retry now? (y/n): ") 517 | if user_message.strip().lower() == "y": 518 | return generate_response(messages) 519 | else: 520 | return "" 521 | except openai.error.RateLimitError as rate_err: 522 | print(rate_err) 523 | printpnl( 524 | "**[Rate Limit Error]**\nThis is caused by API outage. Please try again later." 525 | ) 526 | user_message = input("Do you want to retry now? (y/n): ") 527 | if user_message.strip().lower() == "y": 528 | return generate_response(messages) 529 | else: 530 | return "" 531 | except Exception as e: 532 | print(e) 533 | printpnl( 534 | "**[Unknown Error]**\nThis is an unknown error, please contact maintainer with error message to help handle it properly." 535 | ) 536 | return "" 537 | 538 | 539 | if __name__ == "__main__": 540 | config = setup_runtime_env() 541 | default_prompt = config["openai"]["default_prompt"] 542 | show_welcome_panel() 543 | 544 | conv = Conversation(default_prompt) 545 | conv.show_history() 546 | 547 | while True: 548 | user_message = user_input().strip() 549 | if is_command(user_message): 550 | execute_command(user_message, conv) 551 | continue 552 | else: 553 | conv.add_user_message(user_message) 554 | assistant_message = generate_response(conv.messages) 555 | if assistant_message: 556 | assistant_output(assistant_message) 557 | conv.add_assistant_message(assistant_message) 558 | continue 559 | else: 560 | conv.save(True) 561 | -------------------------------------------------------------------------------- /script/config.yaml.example: -------------------------------------------------------------------------------- 1 | # config.yaml.example 2 | openai: 3 | api_key: 4 | default_prompt: 5 | - role: system 6 | content: You are ChatGPT, a language model trained by OpenAI. Now you are responsible for answering any questions the user asks. 7 | proxy: 8 | http_proxy: http://127.0.0.1:7890 9 | https_proxy: http://127.0.0.1:7890 10 | chat: 11 | use_streaming: false 12 | -------------------------------------------------------------------------------- /src/chatgpt_cli/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | try: 4 | __version__ = importlib.metadata.version("chatgpt-cli-md") 5 | except importlib.metadata.PackageNotFoundError: 6 | __version__ = "0.0.0" 7 | -------------------------------------------------------------------------------- /src/chatgpt_cli/chat.py: -------------------------------------------------------------------------------- 1 | import openai 2 | import os 3 | 4 | from chatgpt_cli.conversation import generate_response 5 | from utils.cmd import * 6 | from utils.file import * 7 | from utils.io import * 8 | 9 | 10 | def get_script_dir() -> str: 11 | return os.path.dirname(os.path.abspath(__file__)) 12 | 13 | 14 | def setup_runtime_env() -> Dict: 15 | config = load_config() 16 | try: 17 | # set up openai API key and system prompt 18 | openai.api_key = config["openai"]["api_key"] 19 | # set proxy if defined 20 | if "proxy" in config: 21 | os.environ["http_proxy"] = config["proxy"].get("http_proxy", "") 22 | os.environ["https_proxy"] = config["proxy"].get("https_proxy", "") 23 | 24 | default_prompt = config.get("openai", {}).get("default_prompt", None) 25 | if default_prompt is None: 26 | raise (Exception("Error: the `default_prompt` is empty in `config.yaml`")) 27 | elif type(default_prompt) is not list: 28 | raise ( 29 | Exception("Error: the `default_prompt` is not a list in `config.yaml`") 30 | ) 31 | except Exception: 32 | print("Error in configuration file:", get_config_path()) 33 | exit(1) 34 | return config 35 | 36 | 37 | def read_message(conv, tmpl, use_streaming): 38 | user_message = user_input() 39 | 40 | if is_command(user_message): 41 | printmd("**[Command Executed]**") 42 | user_message = execute_command(user_message, conv, tmpl) 43 | user_message = post_command_process(user_message) 44 | 45 | if user_message == "": 46 | return 47 | 48 | conv.add_user_message(user_message) 49 | printmd("**[Input Submitted]**") 50 | 51 | if use_streaming == True: 52 | assistant_message = assistant_stream( 53 | generate_response(conv.messages, use_streaming) 54 | ) 55 | else: 56 | assistant_message = "".join( 57 | list(generate_response(conv.messages, use_streaming)) 58 | ) 59 | 60 | if assistant_message: 61 | if use_streaming == False: 62 | assistant_output(assistant_message) 63 | conv.add_assistant_message(assistant_message) 64 | else: 65 | conv.save(True) 66 | 67 | if use_streaming: 68 | conv.show_history() 69 | 70 | 71 | def loop(conv, tmpl, use_streaming): 72 | try: 73 | read_message(conv, tmpl, use_streaming) 74 | except KeyboardInterrupt as e: 75 | input_error_handler(conv.modified, e) 76 | except EOFError as e: 77 | input_error_handler(conv.modified, e) 78 | 79 | 80 | def main(): 81 | config = setup_runtime_env() 82 | use_streaming = config.get("chat", {}).get("use_streaming", False) 83 | 84 | default_prompt = config["openai"]["default_prompt"] 85 | show_welcome_panel() 86 | 87 | conv = Conversation(default_prompt, use_streaming) 88 | conv.show_history() 89 | 90 | tmpl = Template() 91 | while True: 92 | loop(conv, tmpl, use_streaming) 93 | 94 | 95 | if __name__ == "__main__": 96 | main() 97 | -------------------------------------------------------------------------------- /src/chatgpt_cli/conversation.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | 4 | import openai 5 | import os 6 | 7 | from utils.file import * 8 | 9 | 10 | def generate_response(messages: List[Dict[str, str]], use_streaming: bool) -> str: 11 | try: 12 | with console.status("[bold green]Preparing response..."): 13 | response = openai.ChatCompletion.create( 14 | model="gpt-3.5-turbo", # or gpt-3.5-turbo-0301 15 | messages=messages, 16 | stream=use_streaming, 17 | ) 18 | 19 | if use_streaming: 20 | for chunk in response: 21 | if "content" not in chunk["choices"][0]["delta"]: 22 | continue 23 | yield (chunk["choices"][0]["delta"]["content"]) 24 | else: 25 | yield response["choices"][0]["message"]["content"].strip() 26 | 27 | except openai.error.APIConnectionError as api_conn_err: 28 | print(api_conn_err) 29 | printpnl( 30 | "**[API Connection Error]**\n. Please check your internet connection and try again." 31 | ) 32 | user_message = input("Do you want to retry now? (y/n): ") 33 | if user_message.strip().lower() == "y": 34 | return generate_response(messages, use_streaming) 35 | else: 36 | return "" 37 | except openai.error.InvalidRequestError as invalid_err: 38 | print(invalid_err) 39 | printpnl( 40 | "**[Invalid Request Error]**\nPlease revise your messages according to the error message above." 41 | ) 42 | return "" 43 | except openai.error.APIError as api_err: 44 | print(api_err) 45 | printpnl( 46 | "**[API Error]**\nThis might be caused by API outage. Please try again later." 47 | ) 48 | user_message = input("Do you want to retry now? (y/n): ") 49 | if user_message.strip().lower() == "y": 50 | return generate_response(messages, use_streaming) 51 | else: 52 | return "" 53 | except openai.error.RateLimitError as rate_err: 54 | print(rate_err) 55 | printpnl( 56 | "**[Rate Limit Error]**\nThis is caused by API outage. Please try again later." 57 | ) 58 | user_message = input("Do you want to retry now? (y/n): ") 59 | if user_message.strip().lower() == "y": 60 | return generate_response(messages, use_streaming) 61 | else: 62 | return "" 63 | except Exception as e: 64 | print(e) 65 | printpnl( 66 | "**[Unknown Error]**\nThis is an unknown error, please contact maintainer with error message to help handle it properly." 67 | ) 68 | return "" 69 | 70 | 71 | class Conversation: 72 | def __init__( 73 | self, default_prompt: List[Dict[str, str]], use_streaming: bool 74 | ) -> None: 75 | self.messages = list(default_prompt) 76 | self.default_prompt = list(default_prompt) 77 | self.use_streaming = use_streaming 78 | self.filepath = "" 79 | self.modified = False 80 | self.template_object = Template() 81 | 82 | def __len__(self) -> int: 83 | return len(self.messages) 84 | 85 | def __add_message(self, message: Dict[str, str]) -> None: 86 | self.messages.append(message) 87 | self.modified = True 88 | 89 | def add_user_message(self, content: str) -> None: 90 | self.__add_message({"role": "user", "content": content}) 91 | 92 | def add_assistant_message(self, content: str) -> None: 93 | self.__add_message({"role": "assistant", "content": content}) 94 | 95 | def edit_system_message(self, content: str) -> None: 96 | if self.messages[0]["role"] == "system": 97 | self.messages[0]["content"] = content 98 | self.modified = True 99 | else: 100 | raise Exception("The first message is not a system message.") 101 | 102 | def save(self, enable_prompt: bool) -> None: 103 | if enable_prompt and self.modified: 104 | user_input = input("Save conversation? [y/n]: ").strip() 105 | if user_input.lower() != "y": 106 | printmd("**Conversation not saved.**") 107 | return 108 | 109 | if self.modified: 110 | if self.filepath: 111 | filename = os.path.basename(self.filepath) 112 | else: 113 | t = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 114 | tmp = f"conversation_{t}.json" 115 | filename = input(f"Enter filename to save to [{tmp}]: ").strip() 116 | if not filename: 117 | filename = tmp 118 | self.filepath = os.path.join(get_data_dir(), filename) 119 | printmd(f"**Conversation save to [{filename}].**") 120 | save_data(self.messages, filename) 121 | self.modified = False 122 | else: 123 | printmd("**Conversation not modified. Nothing to save.**") 124 | 125 | def load(self) -> None: 126 | if self.modified: 127 | user_input = input("Save conversation? [y/n]: ").strip() 128 | if user_input.lower() == "y": 129 | self.save(enable_prompt=False) 130 | self.messages = list(self.default_prompt) 131 | self.filepath = load_data(self.messages) 132 | self.modified = False 133 | printpnl("### Conversation loaded.", "ChatGPT CLI", "green", 120) 134 | 135 | def reset(self) -> None: 136 | if self.modified: 137 | user_input = input("Save conversation? [y/n]: ").strip() 138 | if user_input.lower() == "y": 139 | self.save(enable_prompt=False) 140 | self.filepath = "" 141 | self.messages = list(self.default_prompt) 142 | self.modified = False 143 | printpnl("### Conversation reset.", "ChatGPT CLI", "green", 120) 144 | 145 | def resend(self) -> None: 146 | self.resend_last_prompt() 147 | 148 | def resend_last_prompt(self) -> None: 149 | if len(self.messages) < 2: 150 | printmd("**No previous prompt to resend.**") 151 | return 152 | # Resend last prompt 153 | last_message = self.messages[-1] 154 | if last_message["role"] == "user": 155 | assistant_message_gen = generate_response(self.messages, self.use_streaming) 156 | 157 | if self.use_streaming == True: 158 | assistant_message = assistant_stream(assistant_message_gen) 159 | else: 160 | assistant_message = list(assistant_message_gen)[0] 161 | 162 | if not assistant_message: 163 | printmd("**Last response is empty. Resend failed.**") 164 | return 165 | 166 | if self.use_streaming == False: 167 | assistant_output(assistant_message) 168 | 169 | self.add_assistant_message(assistant_message) 170 | printmd("**Last prompt resent.**") 171 | 172 | else: 173 | printmd("**Last message is assistant message. Nothing to resend.**") 174 | 175 | def regen(self) -> None: 176 | self.regenerate_last_response() 177 | 178 | def regenerate_last_response(self) -> None: 179 | """Regenerate last response""" 180 | if len(self.messages) < 2: 181 | printmd("**No previous response to regenerate.**") 182 | return 183 | # Regenerate last response 184 | last_message = self.messages[-1] 185 | if last_message["role"] == "user": 186 | printmd( 187 | "**Last message is user message. Nothing to regenerate. You may want to use `!resend` instead.**" 188 | ) 189 | return 190 | 191 | content_gen = generate_response(self.messages[:-1], self.use_streaming) 192 | 193 | if self.use_streaming == True: 194 | content = assistant_stream(content_gen) 195 | else: 196 | content = list(content_gen)[0] 197 | 198 | if not content: 199 | printmd("**Last response is empty. Content not regenerated.**") 200 | return 201 | self.__fill_content(-1, content) 202 | if self.use_streaming == False: 203 | assistant_output(self.messages[-1]["content"]) 204 | printmd("**Last response regenerated.**") 205 | 206 | def __fill_content(self, index: int, content: str) -> None: 207 | """Fill content""" 208 | self.messages[index]["content"] = content 209 | self.modified = True 210 | 211 | def __edit_message(self, index: int, prompt=True) -> None: 212 | """Edit message""" 213 | if prompt: 214 | msg = self.messages[index] 215 | printpnl(f"### Editing Message {index}", "Editing Messages", "yellow") 216 | show_message(msg) 217 | new_content = user_input("\nEnter new content, leave blank to skip: ") 218 | if new_content: 219 | self.__fill_content(index, new_content) 220 | printmd("**Message edited.**") 221 | else: 222 | printmd("**Message not edited.**") 223 | 224 | def show_history(self, index=False, panel=True): 225 | """Show conversation history""" 226 | if panel: 227 | printpnl("### Messages History", "ChatGPT CLI", "green") 228 | if index: 229 | for i in range(len(self.messages)): 230 | printpnl(f"### Message {i}", "Messages History", "green") 231 | show_message(self.messages[i]) 232 | else: 233 | for message in self.messages: 234 | show_message(message) 235 | 236 | def edit_messages(self) -> None: 237 | """Edit messages""" 238 | if len(self.messages) == 0: 239 | printmd("**No message to edit.**") 240 | return 241 | # Show messages history with index to select, or iterate through them one by one? 242 | choose = input( 243 | "Show messages history with [i]ndex to select, or iterate [t]hrough them one by one? [i/t]: " 244 | ).strip() 245 | if choose.lower() == "i": 246 | # Show messages history with index to select 247 | printpnl("### Messages History", "Editing Messages", "yellow") 248 | self.show_history(index=True, panel=False) 249 | index = ( 250 | input( 251 | "Enter index of messages to edit, separate with comma (e.g. 0,1,2), leave blank to cancel: " 252 | ) 253 | .strip() 254 | .strip(",") 255 | ) 256 | if not index: 257 | printmd("**Editing cancelled.**") 258 | return 259 | try: 260 | index = [int(i.strip()) for i in index.split(",")] 261 | for i in index: 262 | if i >= len(self.messages): 263 | raise ValueError 264 | except ValueError: 265 | printmd("**Invalid index. Editing cancelled.**") 266 | return 267 | for i in index: 268 | self.__edit_message(i, prompt=True) 269 | else: # choose.lower() == "o": 270 | for i in range(len(self.messages)): 271 | self.__edit_message(i, prompt=True) 272 | 273 | def drop_messages(self) -> None: 274 | """Drop messages""" 275 | if len(self.messages) == 0: 276 | printmd("**No message to drop.**") 277 | return 278 | index = [] 279 | for i, msg in enumerate(self.messages): 280 | printpnl(f"### Message {i}", "Dropping Messages", "yellow") 281 | show_message(msg) 282 | confirm = input("Select this message to drop? [y/n]: ").strip() 283 | if confirm.lower() == "y": 284 | index.append(i) 285 | printmd("**Message selected.**") 286 | else: 287 | printmd("**Message not selected.**") 288 | if index: 289 | for i in reversed(index): 290 | printpnl(f"### Message {i}", "Dropping Messages", "red") 291 | show_message(self.messages[i]) 292 | confirm = input("Drop this message? [y/n]: ").strip() 293 | if confirm.lower() == "y": 294 | self.messages.pop(i) 295 | self.modified = True 296 | printmd("**Message dropped.**") 297 | else: 298 | printmd("**Message not dropped.**") 299 | printmd("**Selected messages dropped.**") 300 | else: 301 | printmd("**No message selected. Dropping cancelled.**") 302 | 303 | def switch_template(self, id): 304 | """Switch template""" 305 | self.template_object.id = id 306 | roles = [msg["role"] for msg in self.template_object.templates[id]["prompts"]] 307 | if "system" in roles: 308 | self.default_prompt = list(self.template_object.templates[id]["prompts"]) 309 | else: 310 | self.default_prompt.extend( 311 | list(self.template_object.templates[id]["prompts"]) 312 | ) 313 | printmd(f"**Template switched to {self.template_object.get_name()}.**") 314 | 315 | 316 | """ 317 | Features (under development): 318 | - `!tmpl`: select a template to use 319 | - `!tmpl show`: show all templates with complete information 320 | - `!tmpl create`: create a new template 321 | - `!tmpl edit`: edit an existing template 322 | - `!tmpl drop`: drop an existing template 323 | """ 324 | 325 | 326 | class Template: 327 | def __init__(self) -> None: 328 | self.templates = self.__load_templates() 329 | self.id = None 330 | 331 | def get_name(self) -> str: 332 | return self.templates[self.id]["name"] 333 | 334 | def __load_templates(self) -> List[Dict]: 335 | return load_templates() 336 | 337 | def __parse_command(self, cmd: str) -> List[str]: 338 | cmd = cmd.strip() 339 | if cmd.startswith("!tmpl"): 340 | cmd = cmd[5:].strip() 341 | else: 342 | raise ValueError(f"Invalid command {cmd} for template") 343 | return cmd.split() 344 | 345 | def execute_command(self, cmd: str, conv: Conversation): 346 | cmd = self.__parse_command(cmd) 347 | if not cmd or cmd[0] == "load": 348 | self.load(conv=conv) 349 | elif cmd[0] == "show": 350 | self.show() 351 | elif cmd[0] == "create": 352 | self.create() 353 | elif cmd[0] == "edit": 354 | self.edit() 355 | elif cmd[0] == "drop": 356 | self.drop() 357 | else: 358 | printmd("**[Error]: Invalid command.**") 359 | 360 | def show(self, only_name: bool = False): 361 | print("Config Directory:", get_config_dir()) 362 | print(f"Templates (in {get_patch_path()}):\n") 363 | if only_name: 364 | for i, t in enumerate(self.templates): 365 | print(f"{i + 1}. {t['name']} ({t['alias']})") 366 | return 367 | for i, t in enumerate(self.templates): 368 | print(f"{i + 1}. {t['name']} ({t['alias']})") 369 | print(f" Description: {t['description']}") 370 | print(f" Messages:") 371 | for j, p in enumerate(t["prompts"]): 372 | print(f" {j}. {p['role']}: {p['content']}") 373 | print(f" References:") 374 | for j, r in enumerate(t["references"]): 375 | print(f" {j}. {r['role']}: {r['content']}") 376 | print() 377 | 378 | def create(self): 379 | update_patch(create_template) 380 | 381 | def edit(self): 382 | update_patch(edit_template) 383 | 384 | def drop(self): 385 | update_patch(drop_template) 386 | 387 | def load(self, conv: Conversation): 388 | self.show(only_name=True) 389 | for i in range(3): 390 | try: 391 | selected_id = input( 392 | "\nPlease select a template (leave blank to skip): " 393 | ).strip() 394 | if not selected_id: 395 | printmd("**No template selected.**") 396 | return 397 | self.id = int(selected_id) - 1 398 | if self.id < 0 or self.id >= len(self.templates): 399 | raise ValueError 400 | conv.switch_template(self.id) 401 | conv.reset() 402 | conv.show_history() 403 | return 404 | except ValueError: 405 | print("Invalid template id, please try again") 406 | except KeyboardInterrupt: 407 | print() 408 | return 409 | except EOFError: 410 | printmd("**[EOF Error]: Aborting") 411 | exit(1) 412 | print("Too many invalid inputs, not switching template") 413 | return 414 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/efJerryYang/chatgpt-cli/45e6e55d5d3aed0bd407958b965d40c621796fc3/src/utils/__init__.py -------------------------------------------------------------------------------- /src/utils/cmd.py: -------------------------------------------------------------------------------- 1 | from chatgpt_cli.conversation import Conversation, Template 2 | from utils.io import * 3 | import re 4 | 5 | 6 | def is_command(user_msg: str) -> bool: 7 | """Check if user input is a command""" 8 | quit_words = ["quit", "exit"] 9 | return user_msg.startswith("!") or user_msg in quit_words 10 | 11 | 12 | def execute_command( 13 | user_msg: str, 14 | conv: Conversation, 15 | tmpl: Template, 16 | ) -> str: 17 | user_msg = user_msg.strip() 18 | if user_msg in ["!help", "help", "!h"]: 19 | show_welcome_panel() 20 | elif user_msg in ["!show", "show"]: 21 | conv.show_history() 22 | elif user_msg in ["!save", "save"]: 23 | conv.save(False) 24 | elif user_msg in ["!load", "load"]: 25 | conv.load() 26 | conv.show_history(panel=False) 27 | elif user_msg in ["!new", "new", "reset", "!reset"]: 28 | conv.reset() 29 | conv.show_history(panel=False) 30 | elif user_msg in ["!editor", "editor", "!e"]: 31 | message = input_from_editor() 32 | user_msg += " " + message 33 | elif user_msg in ["!resend", "resend"]: 34 | conv.resend() 35 | elif user_msg in ["!regen", "regen"]: 36 | conv.regen() 37 | elif user_msg in ["!edit", "edit"]: 38 | conv.edit_messages() 39 | elif user_msg in ["!drop", "drop"]: 40 | conv.drop_messages() 41 | elif user_msg in ["!exit", "!quit", "quit", "exit", "!q"]: 42 | conv.save(True) 43 | print("Bye!") 44 | exit(0) 45 | elif user_msg.startswith("!tmpl"): 46 | tmpl.execute_command(user_msg, conv) 47 | elif user_msg.startswith("!"): 48 | print("Invalid command, please try again") 49 | return user_msg 50 | 51 | 52 | def post_command_process(user_message): 53 | # handle the return string of execute_command 54 | pattern = re.compile(r"^!\w*\s*") 55 | if user_message.startswith("!e ") or user_message.startswith("!editor "): 56 | user_message = pattern.sub("", user_message) 57 | user_output(user_message) 58 | if not user_message: 59 | printmd("**[Empty Input Skipped]**") 60 | else: 61 | user_message = pattern.sub("", user_message) 62 | return user_message 63 | -------------------------------------------------------------------------------- /src/utils/file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import yaml 4 | from typing import Callable, Dict, List 5 | 6 | from chatgpt_cli import __version__ 7 | from utils.io import * 8 | 9 | 10 | def get_data_dir(create=True) -> str: 11 | """Data directory: `${HOME}/.config/chatgpt-cli/data`""" 12 | data_dir = os.path.join(get_config_dir(), "data") 13 | if create and not os.path.exists(data_dir): 14 | os.makedirs(data_dir) 15 | return data_dir 16 | 17 | 18 | def save_data(data: List[Dict[str, str]], filename: str) -> None: 19 | """Save list of dict to JSON file""" 20 | 21 | data_dir = get_data_dir() 22 | print("Data Directory: ", data_dir) 23 | 24 | if filename.endswith(".json"): 25 | filepath = os.path.join(data_dir, filename) 26 | else: 27 | filepath = os.path.join(data_dir, filename + ".json") 28 | with open(filepath, "w") as f: 29 | json.dump(data, f, indent=4) 30 | print(f"Data saved to {filepath}") 31 | 32 | 33 | def load_data(messages: List[Dict[str, str]]) -> str: 34 | """Load JSON file from 'data' directory to 'messages', and return the filepath""" 35 | 36 | data_dir = get_data_dir() 37 | print("Data Directory: ", data_dir) 38 | 39 | files = [f for f in os.listdir(data_dir) if f.endswith(".json")] 40 | if not files: 41 | print("No data files found in 'data' directory") 42 | return "" 43 | 44 | # prompt user to select a file to load 45 | print("Available data files:\n") 46 | for i, f in enumerate(files): 47 | print(f"{i + 1}. {f}") 48 | for a in range(3): 49 | try: 50 | selected_file = input( 51 | f"\nEnter file number to load (1-{len(files)}), or Enter to start a fresh one: " 52 | ) 53 | if not selected_file.strip(): 54 | return "" 55 | index = int(selected_file) - 1 56 | if not 0 <= index < len(files): 57 | raise ValueError() 58 | filepath = os.path.join(data_dir, files[index]) 59 | with open(filepath, "r") as f: 60 | data = json.load(f) 61 | messages.clear() 62 | messages.extend(data) 63 | print(f"Data loaded from {filepath}") 64 | return filepath 65 | except (ValueError, IndexError): 66 | print("Invalid input, please try again") 67 | except (KeyboardInterrupt, EOFError): 68 | print("Aborting") 69 | exit(1) 70 | printmd("**[Warning]**: Too many invalid inputs, starting a fresh one") 71 | return "" 72 | 73 | 74 | def import_data_directory(): 75 | data_dir = get_data_dir() # will create the data directory 76 | for i in range(3): 77 | try: 78 | old_data_dir = input( 79 | "Enter absolute path to the data directory containing *.json files (e.g., /absolute/path/to/data/): " 80 | ).strip() 81 | for file in os.listdir(old_data_dir): 82 | if file.endswith(".json"): 83 | with open(os.path.join(old_data_dir, file), "r") as f: 84 | data = json.load(f) 85 | with open(os.path.join(data_dir, file), "w") as f: 86 | json.dump(data, f) 87 | break 88 | except FileNotFoundError: 89 | printmd("**[File Not Found Error]**: Please check the path and try again") 90 | except Exception as e: 91 | printmd(f"**[Unknown Error]**: {e}") 92 | printmd(f"**[Success]**: Data files imported to `{data_dir}`") 93 | 94 | 95 | def create_data_directory(): 96 | data_dir = get_data_dir() # will create the data directory 97 | printmd(f"**[Success]**: Data directory created at `{data_dir}`") 98 | 99 | 100 | def get_config_dir() -> str: 101 | """Config directory: `${HOME}/.config/chatgpt-cli`""" 102 | config_dir = os.path.join(os.path.expanduser("~"), ".config", "chatgpt-cli") 103 | if not os.path.exists(config_dir): 104 | os.makedirs(config_dir) 105 | return config_dir 106 | 107 | 108 | def get_config_path() -> str: 109 | return os.path.join(get_config_dir(), "config.yaml") 110 | 111 | 112 | def save_config_yaml(config: Dict): 113 | config_path = get_config_path() 114 | with open(config_path, "w") as f: 115 | yaml.dump(config, f, indent=2) 116 | printmd(f"**[Success]**: `config.yaml` file saved to `{config_path}`") 117 | 118 | 119 | def import_config_yaml(): 120 | config = None 121 | for i in range(3): 122 | try: 123 | config_path = input("Enter absolute path to `config.yaml` file: ").strip() 124 | with open(config_path, "r") as f: 125 | config = yaml.safe_load(f) 126 | break 127 | except FileNotFoundError: 128 | printmd("**[File Not Found Error]**: Please check the path and try again") 129 | except yaml.YAMLError: 130 | printmd("**[YAML Error]**: Please check the file and try again") 131 | except Exception as e: 132 | printmd(f"**[Unknown Error]**: {e}") 133 | 134 | if config is None: 135 | printmd("**[Error]**: Failed to import `config.yaml` file after 3 attempts") 136 | exit(1) 137 | 138 | save_config_yaml(config) 139 | 140 | 141 | def create_config_yaml(): 142 | config = {} 143 | config["openai"] = {} 144 | config["openai"]["api_key"] = input("Enter your OpenAI API key: ").strip() 145 | config["proxy"] = {} 146 | config["proxy"]["http_proxy"] = input( 147 | "Enter your HTTP proxy (leave blank if not needed): " 148 | ).strip() 149 | config["proxy"]["https_proxy"] = input( 150 | "Enter your HTTPS proxy (leave blank if not needed): " 151 | ).strip() 152 | config["openai"]["default_prompt"] = [ 153 | { 154 | "role": "system", 155 | "content": "You are ChatGPT, a language model trained by OpenAI. Now you are responsible for answering any questions the user asks.", 156 | } 157 | ] 158 | config["chat"] = {} 159 | config["chat"]["use_streaming"] = False 160 | 161 | save_config_yaml(config) 162 | 163 | 164 | def load_config() -> Dict: 165 | # check setup 166 | config_path = get_config_path() 167 | if not os.path.exists(config_path): 168 | show_setup_error_panel(config_path) 169 | choose = input( 170 | "Do you want to create a new `config.yaml` file or import an existing one? [y/i]: " 171 | ).strip() 172 | if choose.lower() == "i": 173 | import_config_yaml() 174 | else: 175 | create_config_yaml() 176 | # load configurations from config.yaml 177 | with open(config_path, "r") as f: 178 | try: 179 | config = yaml.safe_load(f) 180 | except yaml.YAMLError: 181 | print("Error in configuration file:", config_path) 182 | exit(1) 183 | if not os.path.exists(get_data_dir(create=False)): 184 | choose = input( 185 | "Do you want to import previous data files [*.json]? [y/n]: " 186 | ).strip() 187 | if choose.lower() == "y": 188 | import_data_directory() 189 | else: 190 | create_data_directory() 191 | 192 | return config 193 | 194 | 195 | def load_patch() -> Dict: 196 | """Load the patch file""" 197 | patch_path = get_patch_path() 198 | with open(patch_path, "r") as f: 199 | try: 200 | patch = yaml.safe_load(f) 201 | except yaml.YAMLError: 202 | print("Error in patch file:", patch_path) 203 | exit(1) 204 | return patch 205 | 206 | 207 | def get_patch_path(): 208 | patch_path = os.path.join(get_config_dir(), "patch.yaml") 209 | if not os.path.exists(patch_path): 210 | with open(patch_path, "w") as f: 211 | yaml.dump({}, f, indent=2) 212 | return patch_path 213 | 214 | 215 | def save_patch(patch: Dict): 216 | """Save the patch file""" 217 | patch_path = os.path.join(get_config_dir(), "patch.yaml") 218 | with open(patch_path, "w") as f: 219 | yaml.dump(patch, f, indent=2) 220 | printmd(f"**[Success]**: `patch.yaml` file saved to `{patch_path}`") 221 | 222 | 223 | def update_patch(operation: Callable): 224 | patch = load_patch() 225 | operation(patch) 226 | save_patch(patch=patch) 227 | 228 | 229 | def create_template(patch: Dict): 230 | """Create a new template in the `patch.yaml` file""" 231 | template_name = input("Enter template name: ").strip() 232 | if template_name == "": 233 | printmd("**[Error]**: Template name cannot be empty") 234 | return 235 | template_alias = input("Enter template alias (leave blank to skip): ").strip() 236 | if template_alias == "": 237 | template_alias = None 238 | template_list = patch.get("templates", []) 239 | for template in template_list: 240 | if template["name"] == template_name: 241 | printmd( 242 | f"**[Error]**: Template `{template_name}` already exists, pick another name" 243 | ) 244 | return 245 | if template_alias is not None and template["alias"] == template_alias: 246 | printmd( 247 | f"**[Warning]**: Template alias `{template_alias}` already exists, leaving it blank..." 248 | ) 249 | template_alias = None 250 | 251 | template = {} 252 | template["name"] = template_name 253 | template["alias"] = template_alias if template_alias is not None else "" 254 | template["description"] = input( 255 | "Enter a simple template description (leave blank to skip): " 256 | ).strip() 257 | template["prompts"] = [] 258 | while True: 259 | try: 260 | printpnl("### Add a new prompt (leave blank to skip)") 261 | role = input("Enter prompt role system/user/assistant [s/u/a]: ").strip() 262 | if role == "": 263 | break 264 | r = role.lower() 265 | if r in ["s", "system"]: 266 | role = "system" 267 | elif r in ["u", "user"]: 268 | role = "user" 269 | elif r in ["a", "assistant"]: 270 | role = "assistant" 271 | else: 272 | printmd(f"**[Error]**: Invalid role `{role}`, please try again") 273 | continue 274 | message = {} 275 | message["role"] = role 276 | message["content"] = user_input( 277 | f"Enter prompt content for [{role}]: " 278 | ).strip() 279 | template["prompts"].append(message) 280 | except KeyboardInterrupt as e: 281 | input_error_handler(True, e) 282 | continue 283 | except EOFError as e: 284 | input_error_handler(True, e) 285 | continue 286 | if len(template["prompts"]) == 0: 287 | printmd("**[Warning]**: No prompts added, nothing to save") 288 | return 289 | # add reference 290 | check = input("Do you want to add references to the template? [y/n]: ").strip() 291 | if check.lower() == "y": 292 | template["references"] = [] 293 | while True: 294 | try: 295 | printpnl("### Add a new reference (leave blank to skip)") 296 | reference = {} 297 | reference["url"] = input("Enter reference url: ").strip() 298 | if reference["url"] == "": 299 | break 300 | reference["title"] = input("Enter reference title: ").strip() 301 | if reference["title"] == "": 302 | printmd("**[Warning]**: Reference title is empty, skipping...") 303 | template["references"].append(reference) 304 | except KeyboardInterrupt as e: 305 | input_error_handler(True, e) 306 | continue 307 | except EOFError as e: 308 | input_error_handler(True, e) 309 | continue 310 | else: 311 | template["references"] = [{"url": "", "title": ""}] 312 | # save to file 313 | template_list.append(template) 314 | patch["templates"] = template_list 315 | printmd(f"**[Success]**: Template `{template_name}` created") 316 | 317 | 318 | def load_templates() -> List[Dict]: 319 | """Load the templates from the `patch.yaml` file""" 320 | patch = load_patch() 321 | return patch.get("templates", []) 322 | 323 | 324 | def edit_template(patch: Dict): 325 | printpnl("**[Error]**: Not implemented yet") 326 | 327 | 328 | def drop_template(patch: Dict): 329 | printpnl("**[Error]**: Not implemented yet") 330 | -------------------------------------------------------------------------------- /src/utils/io.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import tempfile 4 | import readline 5 | import subprocess 6 | 7 | from typing import Dict 8 | from typing import Generator 9 | 10 | from rich import print 11 | from rich.console import Console 12 | from rich.live import Live 13 | from rich.markdown import Markdown 14 | from rich.panel import Panel 15 | 16 | from chatgpt_cli import __version__ 17 | 18 | console = Console() 19 | 20 | 21 | def print(*args, **kwargs) -> None: 22 | console.print(*args, **kwargs) 23 | 24 | 25 | def printpnl( 26 | msg: str, title="ChatGPT CLI", border_style="white", width=120, markdown=True 27 | ) -> None: 28 | print() 29 | if markdown: 30 | print(Panel(Markdown(msg), title=title, border_style=border_style, width=width)) 31 | else: 32 | print(Panel(msg, title=title, border_style=border_style, width=width)) 33 | print() 34 | 35 | 36 | def printmd(msg: str, newline=True) -> None: 37 | console.print(Markdown(msg)) 38 | if newline: 39 | print() 40 | 41 | 42 | def show_message(msg: Dict[str, str]) -> None: 43 | role = msg["role"] 44 | content = msg["content"] 45 | if role == "user": 46 | user_output(content) 47 | elif role == "assistant": 48 | assistant_output(content) 49 | elif role == "system": 50 | system_output(content) 51 | else: 52 | raise ValueError(f"Invalid role: {role}") 53 | 54 | 55 | def user_output(msg: str) -> None: 56 | printmd("**User:** {}".format(msg)) 57 | 58 | 59 | def assistant_output(msg: str) -> None: 60 | printmd("**ChatGPT:** {}".format(msg)) 61 | 62 | 63 | def assistant_stream(gen: Generator[str, None, None]) -> str: 64 | msg = "" 65 | 66 | with Live(vertical_overflow="visible") as live: 67 | for text in gen: 68 | msg += text 69 | live.update(Markdown("**ChatGPT:** " + msg)) 70 | 71 | return msg 72 | 73 | 74 | def system_output(msg: str) -> None: 75 | printmd("**System:** {}".format(msg)) 76 | 77 | 78 | def input_error_handler(is_modified: bool, e: Exception) -> None: 79 | initial_error = e 80 | for i in range(3): 81 | try: 82 | if isinstance(e, EOFError): 83 | printmd("**[EOF Error]**", newline=False) 84 | printmd("**Exiting...**", newline=False) 85 | exit(0) 86 | elif isinstance(e, KeyboardInterrupt): 87 | printmd("**[Keyboard Interrupted Error]**", newline=False) 88 | printpnl( 89 | "### You have interrupted the program with `Ctrl+C`. This is usually caused by pressing `Ctrl+C`.", 90 | "Exit Confirmation", 91 | "red", 92 | ) 93 | confirm_prompt = f"Are you sure you want to exit{' without saving' if is_modified else ''}? [Y/n]: " 94 | if input(confirm_prompt).lower() == "y": 95 | printmd("**Exiting...**", newline=False) 96 | exit(0) 97 | else: 98 | printmd("**[Resuming]**") 99 | return 100 | else: 101 | printmd( 102 | "**[Unknown Error]** This a an unhandled error. Please report this issue on GitHub: https://github.com/efJerryYang/chatgpt-cli/issues" 103 | ) 104 | raise e 105 | except Exception as e: 106 | continue 107 | 108 | 109 | def input_from_editor() -> str: 110 | editor = os.environ.get("EDITOR", "vim") 111 | initial_message = b"" 112 | 113 | with tempfile.NamedTemporaryFile(suffix=".tmp") as tf: 114 | tf.write(initial_message) 115 | tf.flush() 116 | 117 | subprocess.call(f"{editor} '{tf.name}'", shell=True) 118 | 119 | tf.seek(0) 120 | msg = tf.read().decode().strip() 121 | return msg 122 | 123 | 124 | def user_input(prompt="\nUser: ") -> str: 125 | """ 126 | Get user input with support for multiple lines without submitting the message. 127 | """ 128 | # Use readline to handle user input 129 | lines = [] 130 | while True: 131 | line = input(prompt).strip() 132 | if line == "": 133 | break 134 | lines.append(line) 135 | if lines[0].startswith("!"): 136 | break 137 | # Update the prompt using readline 138 | prompt = "\r" + " " * len(prompt) + "\r" + " .... " 139 | readline.get_line_buffer() 140 | # Print a message indicating that the input has been submitted 141 | msg = "\n\n".join(lines).strip() 142 | if not msg: 143 | printmd("**[Empty Input Skipped]**") 144 | return msg 145 | 146 | 147 | def show_welcome_panel(): 148 | welcome_msg = f""" 149 | Welcome to ChatGPT CLI v{__version__}! 150 | 151 | Greetings! Thank you for choosing this CLI tool. This tool is generally developed for personal use purpose. We use OpenAI's official API to interact with the ChatGPT, which would be more stable than the web interface. 152 | 153 | This tool is still under development, and we are working on improving the user experience. If you have any suggestions, please feel free to open an issue on our GitHub: https://github.com/efJerryYang/chatgpt-cli/issues 154 | 155 | Here are some useful commands you may want to use: 156 | 157 | - `!help` or `!h`: show this message 158 | - `!show`: show current conversation messages 159 | - `!save`: save current conversation to a `JSON` file 160 | - `!load`: load a conversation from a `JSON` file 161 | - `!new` or `!reset`: start a new conversation 162 | - `!editor` or `!e`: use your default editor (e.g. vim) to submit a message 163 | - `!regen`: regenerate the last response 164 | - `!resend`: resend your last prompt to generate response 165 | - `!edit`: select messages to edit 166 | - `!drop`: select messages to drop 167 | - `!exit` or `!quit` or `!q`: exit the program 168 | 169 | Features (under development): 170 | - `!tmpl` or `!tmpl load`: select a template to use 171 | - `!tmpl show`: show all templates with complete information 172 | - `!tmpl create`: create a new template 173 | - `!tmpl edit`: edit an existing template (not implemented yet) 174 | - `!tmpl drop`: drop an existing template (not implemented yet) 175 | 176 | You can enter these commands at any time during a conversation when you are prompted with `User:`. 177 | 178 | For more detailed documentation, please visit or 179 | 180 | Enjoy your chat! 181 | """ 182 | printpnl(welcome_msg, title="Welcome") 183 | 184 | 185 | def show_setup_error_panel(config_path: str): 186 | first_launch_msg = f""" 187 | Welcome to ChatGPT CLI v{__version__}! 188 | 189 | It looks like this is the first time you're using this tool. 190 | 191 | To use the ChatGPT API you need to provide your OpenAI API key in the `{config_path}` file. 192 | 193 | You can create it manually or let this tool help you create it interactively. 194 | 195 | You can also import an existing `config.yaml` file which is used in the script version of this tool. 196 | 197 | If you don't have an OpenAI API key, you can get one at https://platform.openai.com/account/api-keys 198 | """ 199 | printpnl(first_launch_msg, "ChatGPT CLI Setup", "red", 120) 200 | --------------------------------------------------------------------------------