2023-03-08 01:00:06 -08:00
;;; gptel.el --- A simple ChatGPT client -*- lexical-binding: t; -*-
2023-03-05 16:59:44 -08:00
;; Copyright (C) 2023 Karthik Chikmagalur
;; Author: Karthik Chikmagalur
2023-03-08 19:25:14 -08:00
;; Version: 0.10
2023-03-05 16:59:44 -08:00
;; Package-Requires: ((emacs "27.1") (aio "1.0"))
;; Keywords: convenience
2023-03-05 17:46:38 -08:00
;; URL: https://github.com/karthink/gptel
2023-03-05 16:59:44 -08:00
2023-03-05 18:08:53 -08:00
;; SPDX-License-Identifier: GPL-3.0-or-later
2023-03-05 16:59:44 -08:00
;; 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 <https://www.gnu.org/licenses/>.
2023-03-05 18:08:53 -08:00
;; This file is NOT part of GNU Emacs.
2023-03-05 16:59:44 -08:00
;;; Commentary:
2023-03-05 17:46:38 -08:00
;; A ChatGPT client for Emacs.
;;
;; Requirements:
2023-03-05 16:59:44 -08:00
;; - 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.
;;
2023-03-05 17:46:38 -08:00
;; - If installing manually: Install the package `emacs-aio' using `M-x package-install'
;; or however you install packages.
2023-03-05 16:59:44 -08:00
;;
;; - Not required but recommended: Install `markdown-mode'.
;;
2023-03-05 17:46:38 -08:00
;; Usage:
2023-03-05 16:59:44 -08:00
;; - 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 " )
2023-03-08 19:17:14 -08:00
( declare-function gptel-curl-get-response " gptel-curl " )
2023-03-08 19:25:14 -08:00
( declare-function gptel-send-menu " gptel-transient " )
2023-03-09 22:01:28 -08:00
( declare-function pulse-momentary-highlight-region " pulse " )
2023-03-08 00:52:48 -08:00
2023-03-05 16:59:44 -08:00
( eval-when-compile
2023-03-08 00:52:48 -08:00
( require 'subr-x )
( require 'cl-lib ) )
2023-03-05 16:59:44 -08:00
( require 'aio )
( require 'json )
( require 'map )
2023-03-08 19:10:20 -08:00
( require 'text-property-search )
2023-03-05 16:59:44 -08:00
( defcustom gptel-api-key nil
2023-03-05 17:46:38 -08:00
" An OpenAI API key (string).
Can also be a function of no arguments that returns an API
key ( more secure ) . "
:group 'gptel
2023-03-05 16:59:44 -08:00
:type ' ( choice
( string :tag " API key " )
( function :tag " Function that retuns the API key " ) ) )
2023-03-08 00:52:48 -08:00
( defcustom gptel-playback nil
" Whether responses from ChatGPT be played back in chunks.
When set to nil , it is inserted all at once.
'tis a bit silly. "
:group 'gptel
:type 'boolean )
( defcustom gptel-use-curl ( and ( executable-find " curl " ) t )
" Whether gptel should prefer Curl when available. "
:group 'gptel
:type 'boolean )
2023-03-05 17:46:38 -08:00
( defvar gptel-default-session " *ChatGPT* " )
( defvar gptel-default-mode ( if ( featurep 'markdown-mode )
'markdown-mode
'text-mode ) )
( defvar gptel-prompt-string " ### " )
2023-03-05 16:59:44 -08:00
2023-03-08 19:20:00 -08:00
;; Model and interaction parameters
( defvar-local gptel--system-message
" You are a large language model living in Emacs and a helpful assistant. Respond concisely. " )
( defvar gptel--system-message-alist
` ( ( default . , gptel--system-message )
2023-03-10 03:55:43 -08:00
( programming . " You are a large language model and a careful programmer. Provide code and only code as output without any additional text, prompt or note. " )
2023-03-08 19:20:00 -08:00
( writing . " You are a large language model and a writing assistant. Respond concisely. " )
( chat . " You are a large language model and a conversation partner. Respond concisely. " ) )
" Prompt templates (directives). " )
( defvar-local gptel--max-tokens nil )
( defvar-local gptel--model " gpt-3.5-turbo " )
( defvar-local gptel--temperature 1.0 )
2023-03-08 19:10:20 -08:00
( defvar-local gptel--num-messages-to-send nil )
( defsubst gptel--numberize ( val )
" Ensure VAL is a number. "
( if ( stringp val ) ( string-to-number val ) val ) )
2023-03-08 19:25:14 -08:00
( aio-defun gptel-send ( &optional arg )
2023-03-05 16:59:44 -08:00
" Submit this prompt to ChatGPT. "
2023-03-08 19:25:14 -08:00
( interactive " P " )
( if ( and arg ( featurep 'gptel-transient ) )
( call-interactively #' gptel-send-menu )
2023-03-05 16:59:44 -08:00
( message " Querying ChatGPT... " )
2023-03-09 14:14:06 -08:00
( and header-line-format
( setf ( nth 1 header-line-format )
2023-03-10 03:55:43 -08:00
( propertize " Waiting... " 'face 'warning ) )
( force-mode-line-update ) )
2023-03-05 16:59:44 -08:00
( let* ( ( gptel-buffer ( current-buffer ) )
2023-03-08 19:10:20 -08:00
( full-prompt ( gptel--create-prompt ) )
2023-03-08 00:52:48 -08:00
( response ( aio-await
( funcall
2023-03-08 19:17:14 -08:00
( if gptel-use-curl
2023-03-10 03:55:43 -08:00
#' gptel-curl-get-response #' gptel--url-get-response )
2023-03-08 00:52:48 -08:00
full-prompt ) ) )
( content-str ( plist-get response :content ) )
( status-str ( plist-get response :status ) ) )
( if content-str
2023-03-09 14:14:06 -08:00
( with-current-buffer gptel-buffer
( save-excursion
( put-text-property 0 ( length content-str ) 'gptel 'response content-str )
( 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
( gptel--playback ( current-buffer ) content-str ( point ) )
2023-03-09 22:01:28 -08:00
( let ( ( p ( point ) ) )
( insert content-str )
( pulse-momentary-highlight-region p ( point ) ) ) )
2023-03-09 14:14:06 -08:00
( insert " \n \n " gptel-prompt-string )
( unless gptel-playback
( setf ( nth 1 header-line-format )
( propertize " Ready " 'face 'success ) ) ) ) )
( and header-line-format
( setf ( nth 1 header-line-format )
( propertize ( format " Response Error: %s " status-str )
'face 'error ) ) ) ) ) ) )
2023-03-08 19:10:20 -08:00
( defun gptel--create-prompt ( )
" Return a full conversation prompt from the contents of this buffer.
If ` gptel--num-messages-to-send ' is set, limit to that many
recent exchanges.
If the region is active limit the prompt to the region contents
instead. "
( save-excursion
( save-restriction
( when ( use-region-p )
( narrow-to-region ( region-beginning ) ( region-end ) ) )
( goto-char ( point-max ) )
( let ( ( max-entries ( and gptel--num-messages-to-send
( * 2 ( gptel--numberize
gptel--num-messages-to-send ) ) ) )
( prop ) ( prompts ) )
( while ( and
( or ( not max-entries ) ( >= max-entries 0 ) )
( setq prop ( text-property-search-backward
'gptel 'response
( when ( get-char-property ( max ( point-min ) ( 1- ( point ) ) )
'gptel )
t ) ) ) )
( push ( list :role ( if ( prop-match-value prop ) " assistant " " user " )
:content
( string-trim
( buffer-substring-no-properties ( prop-match-beginning prop )
( prop-match-end prop ) )
" [*# \t \n \r ]+ " ) )
prompts )
( and max-entries ( cl-decf max-entries ) ) )
( cons ( list :role " system "
:content
( concat
( when ( eq major-mode 'org-mode )
( concat
" In this conversation, format your responses as in an org-mode buffer in Emacs. "
" Do NOT use Markdown. I repeat, use org-mode markup and not markdown. \n " ) )
gptel--system-message ) )
prompts ) ) ) ) )
2023-03-08 19:20:00 -08:00
( defun gptel--request-data ( prompts )
" JSON encode PROMPTS for sending to ChatGPT. "
( let ( ( prompts-plist
` ( :model , gptel--model
:messages [ ,@ prompts ] ) ) )
( when gptel--temperature
( plist-put prompts-plist :temperature ( gptel--numberize gptel--temperature ) ) )
( when gptel--max-tokens
( plist-put prompts-plist :max_tokens ( gptel--numberize gptel--max-tokens ) ) )
prompts-plist ) )
2023-03-05 16:59:44 -08:00
2023-03-08 00:56:32 -08:00
( aio-defun gptel--get-response ( prompts )
2023-03-10 03:55:43 -08:00
( aio-defun gptel--url-get-response ( prompts )
2023-03-05 17:46:38 -08:00
" Fetch response for PROMPTS from ChatGPT.
2023-03-08 00:52:48 -08:00
Return the message received. "
( let* ( ( inhibit-message t )
( message-log-max nil )
( api-key
2023-03-05 16:59:44 -08:00
( 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
2023-03-08 19:20:00 -08:00
( encode-coding-string ( json-encode ( gptel--request-data prompts ) ) 'utf-8 ) ) )
2023-03-08 00:52:48 -08:00
( pcase-let ( ( ` ( , _ . , response-buffer )
( aio-await
( aio-url-retrieve " https://api.openai.com/v1/chat/completions " ) ) ) )
( prog1
2023-03-10 03:55:43 -08:00
( gptel--url-parse-response response-buffer )
2023-03-08 00:52:48 -08:00
( kill-buffer response-buffer ) ) ) ) )
2023-03-10 03:55:43 -08:00
( defun gptel--url-parse-response ( response-buffer )
2023-03-08 00:52:48 -08:00
" 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-p " 200 OK " status ) )
( response ( progn ( forward-paragraph )
( json-read ) ) )
( content ( map-nested-elt
response ' ( :choices 0 :message :content ) ) ) )
( list :content ( string-trim content )
:status status )
( list :content nil :status status ) ) ) ) )
2023-03-05 16:59:44 -08:00
( 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 ) )
2023-03-05 17:46:38 -08:00
;;;###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
2023-03-05 18:43:49 -08:00
( setq gptel-api-key
( read-passwd " OpenAI API key: " ) ) ) ) )
2023-03-05 17:46:38 -08:00
( unless api-key
( user-error " No API key available " ) )
( with-current-buffer ( get-buffer-create name )
2023-03-05 18:43:49 -08:00
( cond ;Set major mode
( ( eq major-mode gptel-default-mode ) )
( ( eq gptel-default-mode 'text-mode )
( text-mode )
( visual-line-mode 1 ) )
( t ( funcall gptel-default-mode ) ) )
2023-03-05 17:46:38 -08:00
( 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 " )
2023-03-06 00:58:32 -08:00
( or header-line-format
( setq header-line-format
( list ( concat ( propertize " " 'display ' ( space :align-to 0 ) )
( format " %s " ( buffer-name ) ) )
( propertize " Ready " 'face 'success ) ) ) )
2023-03-05 17:46:38 -08:00
( message " Send your query with %s! "
( substitute-command-keys " \\ [gptel-send] " ) ) ) )
2023-03-08 00:52:48 -08:00
( defun gptel--playback ( buf content-str start-pt )
" Playback CONTENT-STR in BUF.
2023-03-05 16:59:44 -08:00
2023-03-08 00:52:48 -08:00
Begin at START-PT. "
( let ( ( handle ( gensym " gptel-change-group-handle-- " ) )
( playback-timer ( gensym " gptel--playback- " ) )
( content-length ( length content-str ) )
( idx 0 ) ( pt ( make-marker ) ) )
( setf ( symbol-value handle ) ( prepare-change-group buf ) )
( activate-change-group ( symbol-value handle ) )
( setf ( symbol-value playback-timer )
( run-at-time
0 0.15
( lambda ( )
( with-current-buffer buf
( if ( >= content-length idx )
( progn
( when ( = idx 0 ) ( set-marker pt start-pt ) )
( goto-char pt )
( insert-before-markers-and-inherit
( cl-subseq
content-str idx
( min content-length ( + idx 16 ) ) ) )
( setq idx ( + idx 16 ) ) )
( when start-pt ( goto-char ( - start-pt 2 ) ) )
2023-03-09 14:14:06 -08:00
( and header-line-format
( setf ( nth 1 header-line-format )
( propertize " Ready " 'face 'success ) )
( force-mode-line-update ) )
2023-03-08 00:52:48 -08:00
( accept-change-group ( symbol-value handle ) )
( undo-amalgamate-change-group ( symbol-value handle ) )
( cancel-timer ( symbol-value playback-timer ) ) ) ) ) ) )
nil ) )
2023-03-05 16:59:44 -08:00
( provide 'gptel )
;;; gptel.el ends here