From cc6c5e7321db3e66581e42ac5a4a8df54aa618a0 Mon Sep 17 00:00:00 2001 From: Karthik Chikmagalur Date: Wed, 19 Jul 2023 20:09:54 -0700 Subject: [PATCH] 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. --- gptel.el | 186 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 155 insertions(+), 31 deletions(-) diff --git a/gptel.el b/gptel.el index a5687e1..26fbcc0 100644 --- a/gptel.el +++ b/gptel.el @@ -57,6 +57,16 @@ (declare-function gptel-menu "gptel-transient") (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 (require 'subr-x) (require 'cl-lib)) @@ -268,7 +278,85 @@ By default, `gptel-host' is used as HOST and \"apikey\" as USER." (defun gptel-prompt-string () (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) +;;;###autoload (define-minor-mode gptel-mode "Minor mode for interacting with ChatGPT." :lighter " GPT" @@ -277,33 +365,39 @@ By default, `gptel-host' is used as HOST and \"apikey\" as USER." (define-key map (kbd "C-c RET") #'gptel-send) map) (if gptel-mode - (setq gptel--old-header-line header-line-format - header-line-format - (list (concat (propertize " " 'display '(space :align-to 0)) - (format "%s" (buffer-name))) - (propertize " Ready" 'face 'success) - '(:eval - (let* ((l1 (length gptel-model)) - (num-exchanges - (if gptel--num-messages-to-send - (format "[Send: %s exchanges]" gptel--num-messages-to-send) - "[Send: buffer]")) - (l2 (length num-exchanges))) - (concat - (propertize - " " 'display `(space :align-to ,(max 1 (- (window-width) (+ 2 l1 l2))))) - (propertize - (button-buttonize num-exchanges - (lambda (&rest _) (gptel-menu))) - 'mouse-face 'highlight - 'help-echo - "Number of past exchanges to include with each request") - " " - (propertize - (button-buttonize (concat "[" gptel-model "]") - (lambda (&rest _) (gptel-menu))) - 'mouse-face 'highlight - 'help-echo "OpenAI GPT model in use")))))) + (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 + header-line-format + (list (concat (propertize " " 'display '(space :align-to 0)) + (format "%s" (buffer-name))) + (propertize " Ready" 'face 'success) + '(:eval + (let* ((l1 (length gptel-model)) + (num-exchanges + (if gptel--num-messages-to-send + (format "[Send: %s exchanges]" gptel--num-messages-to-send) + "[Send: buffer]")) + (l2 (length num-exchanges))) + (concat + (propertize + " " 'display `(space :align-to ,(max 1 (- (window-width) (+ 2 l1 l2))))) + (propertize + (button-buttonize num-exchanges + (lambda (&rest _) (gptel-menu))) + 'mouse-face 'highlight + 'help-echo + "Number of past exchanges to include with each request") + " " + (propertize + (button-buttonize (concat "[" gptel-model "]") + (lambda (&rest _) (gptel-menu))) + 'mouse-face 'highlight + 'help-echo "OpenAI GPT model in use"))))))) (setq header-line-format gptel--old-header-line))) (defun gptel--update-header-line (msg face) @@ -488,6 +582,30 @@ See `gptel--url-get-response' for details." status-str (plist-get info :error))) (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) "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." (save-excursion (save-restriction - (if (use-region-p) - (progn (narrow-to-region (region-beginning) (region-end)) - (goto-char (point-max))) - (goto-char (or prompt-end (point-max)))) + (cond + ((use-region-p) + ;; Narrow to region + (narrow-to-region (region-beginning) (region-end)) + (goto-char (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 (* 2 (gptel--numberize gptel--num-messages-to-send))))