commit 99aa8dcc5ff17f0f275c3adbda160bafa44fa9bf Author: Karthik Chikmagalur Date: Sun Mar 5 16:59:44 2023 -0800 Add gptel.el and a README. diff --git a/README.org b/README.org new file mode 100644 index 0000000..4fd7d0a --- /dev/null +++ b/README.org @@ -0,0 +1,70 @@ +#+title: GPTel: A simple ChatGPT client for Emacs + +GPTel is a simple, no-frills ChatGPT client for Emacs. + +[[file:img/gptel.png]] + +- Requires an [[https://platform.openai.com/account/api-keys][OpenAI API key]]. +- No external dependencies, only Emacs. Also, it's async. +- Interaction is in a Markdown (or text) buffer. +- Supports conversations (not just one-off queries) and multiple independent sessions. +- You can go back and edit your previous prompts, or even ChatGPT's previous responses when continuing a conversation. These will be fed back to ChatGPT. + +** Installation + +*** Package.el +Clone this repository and run =M-x package-install-file=. + +Installing the =markdown-mode= package is optional. + +*** Straight +#+begin_src emacs-lisp + (straight-use-package '(gptel :host github :repo "karthink/gptel")) +#+end_src + +Installing the =markdown-mode= package is optional. + +*** Manual +Install =emacs-aio=, (=M-x package-install⏎= =emacs-aio⏎=), then clone this repository and load this file: +#+begin_src emacs-lisp +(add-to-list 'load-path "/path/to/gptel/") +(require 'gptel) +#+end_src + +Installing the =markdown-mode= package is optional. + +** Usage + +Procure an [[https://platform.openai.com/account/api-keys][OpenAI API key]]. + +(Optional: Set =gptel-api-key= to the key or to a function that returns the key.) + +Run =M-x gptel= to start or switch to the ChatGPT buffer. (It will ask you for the key if you skipped the previous step.) + +Run it with a prefix-arg (=C-u M-x gptel=) to start a new session. + +In the gptel buffer, send your prompt with =M-x gptel-send=, bound to =C-c RET=. + +That's it. You can go back and edit previous prompts and responses if you want. + +** Why another ChatGPT client? + +Existing Emacs clients don't /reliably/ let me use it the simple way I can in the browser. (They will get better, but I wanted something for now.) + +Also, AI-assisted work is a new way to use Emacs. It's not yet clear what the best Emacs interface to tools like it is. + +- Should it be part of CAPF (=completions-at-point-functions=)? +- A dispatch menu from anywhere that can act on selected regions? +- A comint/shell-style REPL? +- One-off queries in the minibuffer (like =shell-command=)? +- A refactoring tool in code buffers? +- An =org-babel= interface? + +Maybe all of these, I don't know yet. As a start, I wanted to replicate the web browser usage pattern so I can build from there -- and don't need to switch to the browser every time. As a minimal functional interface, the code is very small right now at ~170 lines. + +** Will you add feature X? + +Maybe, I'd like to experiment a bit first. + +- Support for Org Mode instead of Markdown, including source blocks etc, is planned. +- I'm experimenting with using it in code buffers. diff --git a/gptel.el b/gptel.el new file mode 100644 index 0000000..f2aa058 --- /dev/null +++ b/gptel.el @@ -0,0 +1,186 @@ +;;; gptel.el --- Interact with ChatGPT from Emacs -*- lexical-binding: t; -*- + +;; Copyright (C) 2023 Karthik Chikmagalur + +;; Author: Karthik Chikmagalur +;; Version: 0.05 +;; Package-Requires: ((emacs "27.1") (aio "1.0")) +;; Keywords: convenience + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; REQUIREMENTS: +;; - You need an OpenAI API key. Set the variable `gptel-api-key' to the key or to +;; a function of no arguments that returns the key. +;; +;; - Install the package `emacs-aio' using `M-x package-install' or however you install packages. +;; +;; - Not required but recommended: Install `markdown-mode'. +;; +;; USAGE: +;; - M-x gptel: Start a ChatGPT session +;; - C-u M-x gptel: Start another or multiple independent ChatGPT sessions +;; +;; - In the GPT session: Press `C-c RET' (control + c, followed by return) to send +;; your prompt. +;; - To jump between prompts, use `C-c C-n' and `C-c C-p'. + +;;; Code: +(declare-function markdown-mode "markdown-mode") +(eval-when-compile + (require 'subr-x)) + +(require 'aio) +(require 'json) +(require 'map) + +(defcustom gptel-api-key nil + "An OpenAI API key (string), or a function of no arguments that +returns an API key." + :type '(choice + (string :tag "API key") + (function :tag "Function that retuns the API key"))) + +;;;###autoload +(defun gptel (name &optional api-key) + "Switch to or start ChatGPT session with NAME. + +With a prefix arg, query for a (new) session name. + +Ask for API-KEY if `gptel-api-key' is unset." + (interactive (list (if current-prefix-arg + (read-string "Session name: " (generate-new-buffer-name gptel-default-session)) + gptel-default-session) + (or gptel-api-key + (read-string "OpenAI API key: ")))) + (unless gptel-api-key + (user-error "No API key available")) + (with-current-buffer (get-buffer-create name) + (unless (eq major-mode gptel-default-mode) (funcall gptel-default-mode)) + (unless gptel-mode (gptel-mode 1)) + (if (bobp) (insert gptel-prompt-string)) + (pop-to-buffer (current-buffer)) + (goto-char (point-max)) + (skip-chars-backward "\t\r\n") + (setq header-line-format + (concat (propertize " " 'display '(space :align-to 0)) + (format "ChatGPT session (%s)" (buffer-name)))) + (message "Send your query with %s!" + (substitute-command-keys "\\[gptel-send]")))) + +(aio-defun gptel-send () + "Submit this prompt to ChatGPT." + (interactive) + (message "Querying ChatGPT...") + (unless (and gptel--prompt-markers + (equal (marker-position (car gptel--prompt-markers)) + (point-max))) + (push (set-marker (make-marker) (point-max)) + gptel--prompt-markers)) + (let* ((gptel-buffer (current-buffer)) + (full-prompt + (save-excursion + (goto-char (point-min)) + (cl-loop with role = "user" + for (pm rm . _) on gptel--prompt-markers + collect + (list :role role + :content + (string-trim (buffer-substring-no-properties (or rm (point-min)) pm) + "[*# \t\n\r]+")) + into prompts + do (setq role (if (equal role "user") "assistant" "user")) + finally return (nreverse prompts)))) + (response-buffer (aio-await (gptel-get-response full-prompt))) + (json-object-type 'plist)) + (unwind-protect + (when-let* ((content-str (gptel-parse-response response-buffer))) + (with-current-buffer gptel-buffer + (save-excursion + (message "Querying ChatGPT... done.") + (goto-char (point-max)) + (display-buffer (current-buffer) + '((display-buffer-reuse-window + display-buffer-use-some-window))) + (unless (bobp) (insert "\n\n")) + ;; (if gptel-playback-response + ;; (aio-await (gptel--playback-print content-str)) + ;; (insert content-str)) + (insert content-str) + (push (set-marker (make-marker) (point)) + gptel--prompt-markers) + (insert "\n\n" gptel-prompt-string)))) + (kill-buffer response-buffer)))) + +(aio-defun gptel-get-response (prompts) + ";;TODO:" + (let* ((api-key + (cond + ((stringp gptel-api-key) gptel-api-key) + ((functionp gptel-api-key) (funcall gptel-api-key)))) + (url-request-method "POST") + (url-request-extra-headers + `(("Content-Type" . "application/json") + ("Authorization" . ,(concat "Bearer " api-key)))) + (url-request-data + (json-encode + `(:model "gpt-3.5-turbo" + ;; :temperature 1.0 + ;; :top_p 1.0 + :messages [,@prompts])))) + (pcase-let ((`(,status . ,buffer) + (aio-await + (aio-url-retrieve "https://api.openai.com/v1/chat/completions")))) + buffer))) + +(defvar-local gptel--prompt-markers nil) +(defvar gptel-default-session "*ChatGPT*") +(defvar gptel-default-mode (if (featurep 'markdown-mode) + 'markdown-mode + 'text-mode)) +(defvar gptel-prompt-string "### ") + +;;;###autoload +(define-minor-mode gptel-mode + "Minor mode for interacting with ChatGPT." + :glboal nil + :lighter " GPT" + :keymap + (let ((map (make-sparse-keymap))) + (define-key map (kbd "C-c RET") #'gptel-send) + map)) + +(defun gptel-parse-response (response-buffer) + "Parse response in RESPONSE-BUFFER." + (when (buffer-live-p response-buffer) + (with-current-buffer response-buffer + (if-let* ((status (buffer-substring (line-beginning-position) (line-end-position))) + ((string-match "200 OK" status)) + (response (progn (forward-paragraph) + (json-read)))) + (map-nested-elt response '(:choices 0 :message :content)) + (user-error "Chat failed with status: %S" status))))) + +(defvar gptel-playback-response t) + +(aio-defun gptel--playback-print (response) + (when response + (dolist (line (split-string response "\n" nil)) + (insert line "\n") + (aio-await (aio-sleep 0.3))))) + +(provide 'gptel) +;;; gptel.el ends here diff --git a/img/gptel.png b/img/gptel.png new file mode 100755 index 0000000..146caea Binary files /dev/null and b/img/gptel.png differ