gptel-curl: Add curl module and playback feature.
Conditionally solves #2. gptel.el (gptel-use-curl, gptel-parse-response, gptel--playback, gptel-send, gptel-playback): New user options `gptel-playback', `gptel-use-curl`. The former controls whether the response is played back in chunks, which is done by the function `gptel--playback'. The response returned by `gptel-get-response' and `gptel--curl-get-response' is now a plist with the content and status. gptel-curl.el (gptel--curl-get-args, gptel--curl-get-response, gptel--curl-sentinel): Add support for curl when available. Set it to the default. `url-retrieve' is full of fangs that multibyte you.
This commit is contained in:
parent
33d8434f3e
commit
88995a6436
2 changed files with 246 additions and 53 deletions
141
gptel-curl.el
Normal file
141
gptel-curl.el
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
;;; gptel-curl.el --- Curl support for GPTel -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;; Copyright (C) 2023 Karthik Chikmagalur
|
||||||
|
|
||||||
|
;; Author: Karthik Chikmagalur;; <karthikchikmagalur@gmail.com>
|
||||||
|
;; 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
;;; Commentary:
|
||||||
|
|
||||||
|
;; Curl support for GPTel. Utility functions.
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(eval-when-compile
|
||||||
|
(require 'subr-x))
|
||||||
|
(require 'map)
|
||||||
|
(require 'json)
|
||||||
|
(require 'aio)
|
||||||
|
|
||||||
|
(defvar gptel-api-key)
|
||||||
|
(defvar gptel--curl-process-alist nil
|
||||||
|
"Alist of active GPTel curl requests.")
|
||||||
|
|
||||||
|
(defun gptel--curl-get-args (prompts token)
|
||||||
|
"Produce list of arguments for calling Curl.
|
||||||
|
|
||||||
|
PROMPTS is the data to send, TOKEN is a unique identifier."
|
||||||
|
(let* ((args
|
||||||
|
(list "--location" "--silent" "--compressed" "--disable"))
|
||||||
|
(url "https://api.openai.com/v1/chat/completions")
|
||||||
|
(data (encode-coding-string
|
||||||
|
(json-encode
|
||||||
|
`(:model "gpt-3.5-turbo"
|
||||||
|
;; :temperature 1.0
|
||||||
|
;; :top_p 1.0
|
||||||
|
:messages [,@prompts]))
|
||||||
|
'utf-8))
|
||||||
|
(api-key
|
||||||
|
(cond
|
||||||
|
((stringp gptel-api-key) gptel-api-key)
|
||||||
|
((functionp gptel-api-key) (funcall gptel-api-key))))
|
||||||
|
(headers
|
||||||
|
`(("Content-Type" . "application/json")
|
||||||
|
("Authorization" . ,(concat "Bearer " api-key)))))
|
||||||
|
|
||||||
|
(push (format "-X%s" "POST") args)
|
||||||
|
(push (format "-w(%s . %%{size_header})" token) args)
|
||||||
|
;; (push (format "--keepalive-time %s" 240) args)
|
||||||
|
(push (format "-m%s" 60) args)
|
||||||
|
(push "-D-" args)
|
||||||
|
(pcase-dolist (`(,key . ,val) headers)
|
||||||
|
(push (format "-H%s: %s" key val) args))
|
||||||
|
(push (format "-d%s" data) args)
|
||||||
|
(nreverse (cons url args))))
|
||||||
|
|
||||||
|
(defun gptel--curl-get-response (prompts)
|
||||||
|
"Retrieve response to PROMPTS."
|
||||||
|
(with-current-buffer (generate-new-buffer "*gptel-curl*")
|
||||||
|
(let* ((token (md5 (format "%s%s%s%s"
|
||||||
|
(random) (emacs-pid) (user-full-name)
|
||||||
|
(recent-keys))))
|
||||||
|
(args (gptel--curl-get-args prompts token))
|
||||||
|
(process (apply #'start-process "gptel-curl" (current-buffer)
|
||||||
|
"curl" args))
|
||||||
|
(promise (aio-promise))
|
||||||
|
(cb (lambda (result)
|
||||||
|
(aio-resolve promise (lambda () result))
|
||||||
|
(setf (alist-get process
|
||||||
|
gptel--curl-process-alist nil 'remove)
|
||||||
|
nil))))
|
||||||
|
(prog1 promise
|
||||||
|
(set-process-query-on-exit-flag process nil)
|
||||||
|
(setf (alist-get process gptel--curl-process-alist)
|
||||||
|
(list :callback cb :token token))
|
||||||
|
(set-process-sentinel process #'gptel--curl-sentinel)))))
|
||||||
|
|
||||||
|
(defun gptel--curl-sentinel (process status)
|
||||||
|
"Process sentinel for GPTel curl requests.
|
||||||
|
|
||||||
|
PROCESS and STATUS are process parameters."
|
||||||
|
(let ((proc-buf (process-buffer process)))
|
||||||
|
(if-let* ((ok-p (equal status "finished\n"))
|
||||||
|
(proc-info (alist-get process gptel--curl-process-alist))
|
||||||
|
(proc-token (plist-get proc-info :token))
|
||||||
|
(content (gptel--curl-parse-response proc-buf proc-token)))
|
||||||
|
(funcall (plist-get proc-info :callback) content)
|
||||||
|
;; Failed
|
||||||
|
(funcall (plist-get proc-info :callback) nil))
|
||||||
|
(kill-buffer proc-buf)))
|
||||||
|
|
||||||
|
(defun gptel--curl-parse-response (buf token)
|
||||||
|
"Parse the buffer BUF with curl's response.
|
||||||
|
|
||||||
|
TOKEN is used to disambiguate multiple requests in a single
|
||||||
|
buffer."
|
||||||
|
(with-current-buffer buf
|
||||||
|
(progn
|
||||||
|
(goto-char (point-max))
|
||||||
|
(search-backward token)
|
||||||
|
(backward-char)
|
||||||
|
(pcase-let* ((`(,_ . ,header-size) (read (current-buffer))))
|
||||||
|
;; (if (search-backward token nil t)
|
||||||
|
;; (search-forward ")" nil t)
|
||||||
|
;; (goto-char (point-min)))
|
||||||
|
(goto-char (point-min))
|
||||||
|
|
||||||
|
(if-let* ((http-msg (buffer-substring (line-beginning-position)
|
||||||
|
(line-end-position)))
|
||||||
|
(http-status
|
||||||
|
(save-match-data
|
||||||
|
(and (string-match "HTTP/[.0-9]+ +\\([0-9]+\\)" http-msg)
|
||||||
|
(match-string 1 http-msg))))
|
||||||
|
(json-object-type 'plist)
|
||||||
|
(response (progn (goto-char header-size)
|
||||||
|
(json-read)))
|
||||||
|
(content (map-nested-elt
|
||||||
|
response '(:choices 0 :message :content))))
|
||||||
|
(cond
|
||||||
|
((not (equal http-status "200"))
|
||||||
|
(message "GPTChat request failed with code %s" http-status)
|
||||||
|
(list :content nil :status http-msg))
|
||||||
|
(content
|
||||||
|
(list :content (string-trim content) :status http-msg))
|
||||||
|
(t (message "Could not parse response from ChatGPT.")
|
||||||
|
(list :content nil :status http-msg))))))))
|
||||||
|
|
||||||
|
(provide 'gptel-curl)
|
||||||
|
;;; gptel-curl.el ends here
|
158
gptel.el
158
gptel.el
|
@ -48,8 +48,11 @@
|
||||||
|
|
||||||
;;; Code:
|
;;; Code:
|
||||||
(declare-function markdown-mode "markdown-mode")
|
(declare-function markdown-mode "markdown-mode")
|
||||||
|
(declare-function gptel--curl-get-response "gptel-curl")
|
||||||
|
|
||||||
(eval-when-compile
|
(eval-when-compile
|
||||||
(require 'subr-x))
|
(require 'subr-x)
|
||||||
|
(require 'cl-lib))
|
||||||
|
|
||||||
(require 'aio)
|
(require 'aio)
|
||||||
(require 'json)
|
(require 'json)
|
||||||
|
@ -65,6 +68,20 @@ key (more secure)."
|
||||||
(string :tag "API key")
|
(string :tag "API key")
|
||||||
(function :tag "Function that retuns the API key")))
|
(function :tag "Function that retuns the API key")))
|
||||||
|
|
||||||
|
(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)
|
||||||
|
|
||||||
(defvar-local gptel--prompt-markers nil)
|
(defvar-local gptel--prompt-markers nil)
|
||||||
(defvar gptel-default-session "*ChatGPT*")
|
(defvar gptel-default-session "*ChatGPT*")
|
||||||
(defvar gptel-default-mode (if (featurep 'markdown-mode)
|
(defvar gptel-default-mode (if (featurep 'markdown-mode)
|
||||||
|
@ -97,34 +114,42 @@ key (more secure)."
|
||||||
into prompts
|
into prompts
|
||||||
do (setq role (if (equal role "user") "assistant" "user"))
|
do (setq role (if (equal role "user") "assistant" "user"))
|
||||||
finally return (nreverse prompts))))
|
finally return (nreverse prompts))))
|
||||||
(response-buffer (aio-await (gptel-get-response full-prompt)))
|
(response (aio-await
|
||||||
(json-object-type 'plist))
|
(funcall
|
||||||
(unwind-protect
|
(if (and gptel-use-curl (require 'gptel-curl nil t))
|
||||||
(when-let* ((content-str (gptel-parse-response response-buffer)))
|
#'gptel--curl-get-response #'gptel-get-response)
|
||||||
(with-current-buffer gptel-buffer
|
full-prompt)))
|
||||||
(save-excursion
|
(content-str (plist-get response :content))
|
||||||
(message "Querying ChatGPT... done.")
|
(status-str (plist-get response :status)))
|
||||||
(goto-char (point-max))
|
(if content-str
|
||||||
(display-buffer (current-buffer)
|
(with-current-buffer gptel-buffer
|
||||||
'((display-buffer-reuse-window
|
(save-excursion
|
||||||
display-buffer-use-some-window)))
|
(message "Querying ChatGPT... done.")
|
||||||
(unless (bobp) (insert "\n\n"))
|
(goto-char (point-max))
|
||||||
;; (if gptel-playback-response
|
(display-buffer (current-buffer)
|
||||||
;; (aio-await (gptel--playback-print content-str))
|
'((display-buffer-reuse-window
|
||||||
;; (insert content-str))
|
display-buffer-use-some-window)))
|
||||||
(insert content-str)
|
(unless (bobp) (insert "\n\n"))
|
||||||
(push (set-marker (make-marker) (point))
|
(if gptel-playback
|
||||||
gptel--prompt-markers)
|
(gptel--playback (current-buffer) content-str (point))
|
||||||
(insert "\n\n" gptel-prompt-string)
|
(insert content-str))
|
||||||
(setf (nth 1 header-line-format)
|
(push (set-marker (make-marker) (point))
|
||||||
(propertize " Ready" 'face 'success)))))
|
gptel--prompt-markers)
|
||||||
(kill-buffer response-buffer))))
|
(insert "\n\n" gptel-prompt-string)
|
||||||
|
(unless gptel-playback
|
||||||
|
(setf (nth 1 header-line-format)
|
||||||
|
(propertize " Ready" 'face 'success)))))
|
||||||
|
(setf (nth 1 header-line-format)
|
||||||
|
(propertize (format " Response Error: %s" status-str)
|
||||||
|
'face 'error)))))
|
||||||
|
|
||||||
(aio-defun gptel-get-response (prompts)
|
(aio-defun gptel-get-response (prompts)
|
||||||
"Fetch response for PROMPTS from ChatGPT.
|
"Fetch response for PROMPTS from ChatGPT.
|
||||||
|
|
||||||
Return the response buffer."
|
Return the message received."
|
||||||
(let* ((api-key
|
(let* ((inhibit-message t)
|
||||||
|
(message-log-max nil)
|
||||||
|
(api-key
|
||||||
(cond
|
(cond
|
||||||
((stringp gptel-api-key) gptel-api-key)
|
((stringp gptel-api-key) gptel-api-key)
|
||||||
((functionp gptel-api-key) (funcall gptel-api-key))))
|
((functionp gptel-api-key) (funcall gptel-api-key))))
|
||||||
|
@ -133,19 +158,34 @@ Return the response buffer."
|
||||||
`(("Content-Type" . "application/json")
|
`(("Content-Type" . "application/json")
|
||||||
("Authorization" . ,(concat "Bearer " api-key))))
|
("Authorization" . ,(concat "Bearer " api-key))))
|
||||||
(url-request-data
|
(url-request-data
|
||||||
(json-encode
|
(encode-coding-string
|
||||||
|
(json-encode
|
||||||
`(:model "gpt-3.5-turbo"
|
`(:model "gpt-3.5-turbo"
|
||||||
;; :temperature 1.0
|
;; :temperature 1.0
|
||||||
;; :top_p 1.0
|
;; :top_p 1.0
|
||||||
:messages [,@prompts]))))
|
:messages [,@prompts]))
|
||||||
(let ((inhibit-message t)
|
'utf-8)))
|
||||||
(message-log-max nil))
|
(pcase-let ((`(,_ . ,response-buffer)
|
||||||
(pcase-let ((`(,_ . ,buffer)
|
(aio-await
|
||||||
(aio-await
|
(aio-url-retrieve "https://api.openai.com/v1/chat/completions"))))
|
||||||
(aio-url-retrieve "https://api.openai.com/v1/chat/completions"))))
|
(prog1
|
||||||
buffer))))
|
(gptel-parse-response response-buffer)
|
||||||
|
(kill-buffer response-buffer)))))
|
||||||
|
|
||||||
|
(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-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)))))
|
||||||
|
|
||||||
;;;###autoload
|
|
||||||
(define-minor-mode gptel-mode
|
(define-minor-mode gptel-mode
|
||||||
"Minor mode for interacting with ChatGPT."
|
"Minor mode for interacting with ChatGPT."
|
||||||
:glboal nil
|
:glboal nil
|
||||||
|
@ -190,26 +230,38 @@ Ask for API-KEY if `gptel-api-key' is unset."
|
||||||
(message "Send your query with %s!"
|
(message "Send your query with %s!"
|
||||||
(substitute-command-keys "\\[gptel-send]"))))
|
(substitute-command-keys "\\[gptel-send]"))))
|
||||||
|
|
||||||
(defun gptel-parse-response (response-buffer)
|
(defun gptel--playback (buf content-str start-pt)
|
||||||
"Parse response in RESPONSE-BUFFER."
|
"Playback CONTENT-STR in BUF.
|
||||||
(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)))
|
|
||||||
(content (map-nested-elt
|
|
||||||
response '(:choices 0 :message :content))))
|
|
||||||
(string-trim content)
|
|
||||||
(user-error "Chat failed with status: %S" status)))))
|
|
||||||
|
|
||||||
(defvar gptel-playback-response t)
|
Begin at START-PT."
|
||||||
|
(let ((handle (gensym "gptel-change-group-handle--"))
|
||||||
(aio-defun gptel--playback-print (response)
|
(playback-timer (gensym "gptel--playback-"))
|
||||||
(when response
|
(content-length (length content-str))
|
||||||
(dolist (line (split-string response "\n" nil))
|
(idx 0) (pt (make-marker)))
|
||||||
(insert line "\n")
|
(setf (symbol-value handle) (prepare-change-group buf))
|
||||||
(aio-await (aio-sleep 0.3)))))
|
(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)))
|
||||||
|
(setf (nth 1 header-line-format)
|
||||||
|
(propertize " Ready" 'face 'success))
|
||||||
|
(force-mode-line-update)
|
||||||
|
(accept-change-group (symbol-value handle))
|
||||||
|
(undo-amalgamate-change-group (symbol-value handle))
|
||||||
|
(cancel-timer (symbol-value playback-timer)))))))
|
||||||
|
nil))
|
||||||
|
|
||||||
(provide 'gptel)
|
(provide 'gptel)
|
||||||
;;; gptel.el ends here
|
;;; gptel.el ends here
|
||||||
|
|
Loading…
Add table
Reference in a new issue