gptel: saving and restoring state, and limiting context

* gptel.el (gptel-mode, gptel-set-topic, gptel--create-prompt,
gptel-set-topic, gptel--get-topic-start, gptel--get-bounds,
gptel--save-state, gptel--restore-state): Add support for saving
and restoring gptel state for Org buffers.  Support for Markdown
buffers is not yet implemented.

`gptel--save-state' and `gptel--restore-state' save and restores
state using Org properties.  With `gptel-mode' active, these are
run automatically when saving the buffer or enabling `gptel-mode'
respectively.

The command `gptel-set-topic' can be used to set a topic for the
current heading, which is stored as an Org property.  The topic
name is unused (as of now), but the presence of this property
limits the text context sent to ChatGPT to the heading text up to
the cursor position.

Autload `gptel-mode' since the user may want to enable this (to
restore sessions) without having loaded gptel.el.
This commit is contained in:
Karthik Chikmagalur 2023-07-19 20:09:54 -07:00
parent 4356f6fbec
commit cc6c5e7321

132
gptel.el
View file

@ -57,6 +57,16 @@
(declare-function gptel-menu "gptel-transient") (declare-function gptel-menu "gptel-transient")
(declare-function pulse-momentary-highlight-region "pulse") (declare-function pulse-momentary-highlight-region "pulse")
;; Functions used for saving/restoring gptel state in Org buffers
(defvar org-entry-property-inherited-from)
(declare-function org-entry-get "org")
(declare-function org-entry-put "org")
(declare-function org-with-wide-buffer "org-macs")
(declare-function org-set-property "org")
(declare-function org-property-values "org")
(declare-function org-open-line "org")
(declare-function org-at-heading-p "org")
(eval-when-compile (eval-when-compile
(require 'subr-x) (require 'subr-x)
(require 'cl-lib)) (require 'cl-lib))
@ -268,7 +278,85 @@ By default, `gptel-host' is used as HOST and \"apikey\" as USER."
(defun gptel-prompt-string () (defun gptel-prompt-string ()
(or (alist-get major-mode gptel-prompt-prefix-alist) "")) (or (alist-get major-mode gptel-prompt-prefix-alist) ""))
(defun gptel--restore-state ()
"Restore gptel state when turning on `gptel-mode'.
Currently saving and restoring state is implemented only for
`org-mode' buffers."
(when (buffer-file-name)
(pcase major-mode
('org-mode
(save-restriction
(widen)
(condition-case-unless-debug nil
(progn
(when-let ((bounds
(read (org-entry-get (point-min) "GPTEL_BOUNDS"))))
(mapc (pcase-lambda (`(,beg . ,end))
(put-text-property beg end 'gptel 'response))
bounds))
(when-let ((model (org-entry-get (point-min) "GPTEL_MODEL")))
(setq-local gptel-model model))
(when-let ((system (org-entry-get (point-min) "GPTEL_SYSTEM")))
(setq-local gptel--system-message system))
(when-let ((temp (org-entry-get (point-min) "GPTEL_TEMPERATURE")))
(setq-local gptel-temperature (gptel--numberize temp))))
(error (message "Could not restore gptel state, sorry!"))))))))
(defun gptel--save-state ()
"Write the gptel state to the buffer.
This enables saving the chat session when writing the buffer to
disk. To restore a chat session, turn on `gptel-mode' after
opening the file."
(pcase major-mode
('org-mode
(org-with-wide-buffer
(goto-char (point-min))
(when (org-at-heading-p)
(org-open-line 1))
(org-entry-put (point-min) "GPTEL_MODEL" gptel-model)
(org-entry-put (point-min) "GPTEL_TEMPERATURE"
(number-to-string gptel-temperature))
(unless (string=
(default-value 'gptel--system-message)
gptel--system-message)
(org-entry-put (point-min) "GPTEL_SYSTEM"
gptel--system-message))
(when gptel-max-tokens
(org-entry-put
(point-min) "GPTEL_MAX_TOKENS" gptel-max-tokens))
;; Save response boundaries
(letrec ((write-bounds
(lambda (attempts)
(let* ((bounds (gptel--get-bounds))
(offset (caar bounds))
(offset-marker (set-marker (make-marker) offset)))
(org-entry-put (point-min) "GPTEL_BOUNDS"
(prin1-to-string (gptel--get-bounds)))
(when (and (not (= (marker-position offset-marker) offset))
(> attempts 0))
(funcall write-bounds (1- attempts)))))))
(funcall write-bounds 6))))
('markdown-mode
(message "Saving gptel state is not implemented for `markdown-mode'."))))
(defun gptel--get-bounds ()
"Return the gptel response boundaries as an alist."
(save-excursion
(save-restriction
(widen)
(goto-char (point-max))
(let ((prop) (bounds))
(while (setq prop (text-property-search-backward
'gptel 'response t))
(push (cons (prop-match-beginning prop)
(prop-match-end prop))
bounds))
bounds))))
(defvar-local gptel--old-header-line nil) (defvar-local gptel--old-header-line nil)
;;;###autoload
(define-minor-mode gptel-mode (define-minor-mode gptel-mode
"Minor mode for interacting with ChatGPT." "Minor mode for interacting with ChatGPT."
:lighter " GPT" :lighter " GPT"
@ -277,6 +365,12 @@ By default, `gptel-host' is used as HOST and \"apikey\" as USER."
(define-key map (kbd "C-c RET") #'gptel-send) (define-key map (kbd "C-c RET") #'gptel-send)
map) map)
(if gptel-mode (if gptel-mode
(progn
(unless (memq major-mode '(org-mode markdown-mode text-mode))
(gptel-mode -1)
(user-error (format "`gptel-mode' is not supported in `%s'." major-mode)))
(add-hook 'before-save-hook #'gptel--save-state nil t)
(gptel--restore-state)
(setq gptel--old-header-line header-line-format (setq gptel--old-header-line header-line-format
header-line-format header-line-format
(list (concat (propertize " " 'display '(space :align-to 0)) (list (concat (propertize " " 'display '(space :align-to 0))
@ -303,7 +397,7 @@ By default, `gptel-host' is used as HOST and \"apikey\" as USER."
(button-buttonize (concat "[" gptel-model "]") (button-buttonize (concat "[" gptel-model "]")
(lambda (&rest _) (gptel-menu))) (lambda (&rest _) (gptel-menu)))
'mouse-face 'highlight 'mouse-face 'highlight
'help-echo "OpenAI GPT model in use")))))) 'help-echo "OpenAI GPT model in use")))))))
(setq header-line-format gptel--old-header-line))) (setq header-line-format gptel--old-header-line)))
(defun gptel--update-header-line (msg face) (defun gptel--update-header-line (msg face)
@ -488,6 +582,30 @@ See `gptel--url-get-response' for details."
status-str (plist-get info :error))) status-str (plist-get info :error)))
(run-hooks 'gptel-post-response-hook)))) (run-hooks 'gptel-post-response-hook))))
(defun gptel-set-topic ()
"Set a topic and limit this conversation to the current heading.
This limits the context sent to ChatGPT to the text between the
current heading and the cursor position."
(interactive)
(pcase major-mode
('org-mode
(org-set-property
"GPTEL_TOPIC"
(completing-read "Set topic as: "
(org-property-values "GPTEL_TOPIC"))))
('markdown-mode
(message
"Support for multiple topics per buffer is not implemented for `markdown-mode'."))))
(defun gptel--get-topic-start ()
"If a conversation topic is set, return it."
(pcase major-mode
('org-mode
(when (org-entry-get (point) "GPTEL_TOPIC" 'inherit)
(marker-position org-entry-property-inherited-from)))
('markdown-mode nil)))
(defun gptel--create-prompt (&optional prompt-end) (defun gptel--create-prompt (&optional prompt-end)
"Return a full conversation prompt from the contents of this buffer. "Return a full conversation prompt from the contents of this buffer.
@ -501,10 +619,16 @@ If PROMPT-END (a marker) is provided, end the prompt contents
there." there."
(save-excursion (save-excursion
(save-restriction (save-restriction
(if (use-region-p) (cond
(progn (narrow-to-region (region-beginning) (region-end)) ((use-region-p)
;; Narrow to region
(narrow-to-region (region-beginning) (region-end))
(goto-char (point-max))) (goto-char (point-max)))
(goto-char (or prompt-end (point-max)))) ((when-let ((topic-start (gptel--get-topic-start)))
;; Narrow to topic
(narrow-to-region topic-start (or prompt-end (point-max)))
(goto-char (point-max))))
(t (goto-char (or prompt-end (point-max)))))
(let ((max-entries (and gptel--num-messages-to-send (let ((max-entries (and gptel--num-messages-to-send
(* 2 (gptel--numberize (* 2 (gptel--numberize
gptel--num-messages-to-send)))) gptel--num-messages-to-send))))