From 0f161a466b880cde864d8aa709ec04e1a86f445a Mon Sep 17 00:00:00 2001 From: Karthik Chikmagalur Date: Fri, 28 Jul 2023 15:57:08 -0700 Subject: [PATCH] gptel: saving and restoring state for Markdown/Text * gptel.el (gptel--save-state, gptel--restore-state, gptel-temperature, gptel-model, gptel-max-tokens, gptel-directives, gptel--always, gptel--button-buttonize, gptel--system-message, gptel--bounds): Write gptel parameters as file-local variables when saving chats in Markdown or text files. The local variable gptel--bounds stores the locations of the responses from the LLM. This is not a great solution, but the best I can think to do without adding more syntax to the document. Chats can be restored by turning on `gptel-mode'. One of the problem with this approach is that if the buffer is modified before `gptel-mode' is turned on, the state data is out of date. Another problem is that this metadata block as printed in the buffer can become quite long. A better approach is needed. Define helper functions `gptel--always' and `gptel--button-buttonize' to work around Emacs 27.1 support. * README.org: Mention saving and restoring chats where appropriate. --- README.org | 8 +++++-- gptel.el | 62 ++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/README.org b/README.org index b2f514f..be7c9d5 100644 --- a/README.org +++ b/README.org @@ -13,6 +13,7 @@ https://user-images.githubusercontent.com/8607532/230516816-ae4a613a-4d01-4073-a - Interact with ChatGPT from anywhere in Emacs (any buffer, shell, minibuffer, wherever) - ChatGPT's responses are in Markdown or Org markup. - Supports conversations and multiple independent sessions. +- Save chats as regular Markdown/Org/Text files and resume them later. - 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. GPTel uses Curl if available, but falls back to url-retrieve to work without external dependencies. @@ -122,6 +123,8 @@ With a region selected, you can also rewrite prose or refactor code from here: That's it. You can go back and edit previous prompts and responses if you want. +4. Save the chat to a file. To resume, open the file and turn on =gptel-mode=. + The default mode is =markdown-mode= if available, else =text-mode=. You can set =gptel-default-mode= to =org-mode= if desired. ** Using it your way @@ -167,10 +170,11 @@ Maybe, I'd like to experiment a bit more first. Features added since the incept - GPT-4 support - Response redirection (to the echo area, another buffer, etc) - A built-in refactor/rewrite prompt +- Limiting conversation context to Org headings using properties (#58) +- Saving and restoring chats (#17) Features being considered or in the pipeline: -- Limiting conversation context to Org headings using properties (#58) -- Stateless design (#17) +- Fully stateless design (#17) ** Alternatives diff --git a/gptel.el b/gptel.el index 9d241ad..4602625 100644 --- a/gptel.el +++ b/gptel.el @@ -186,9 +186,34 @@ transient menu interface provided by `gptel-menu'." :group 'gptel :type 'file) +;; FIXME This is convoluted, but it's not worth adding the `compat' dependency +;; just for a couple of helper functions either. +(cl-macrolet + ((gptel--compat + () (if (version< "28.1" emacs-version) + (macroexp-progn + `((defalias 'gptel--button-buttonize #'button-buttonize) + (defalias 'gptel--always #'always))) + (macroexp-progn + `((defun gptel--always (&rest _) + "Always return t." t) + (defun gptel--button-buttonize (string callback) + "Make STRING into a button and return it. +When clicked, CALLBACK will be called." + (propertize string + 'face 'button + 'button t + 'follow-link t + 'category t + 'button-data nil + 'keymap button-map + 'action callback))))))) + (gptel--compat)) + ;; Model and interaction parameters (defvar-local gptel--system-message "You are a large language model living in Emacs and a helpful assistant. Respond concisely.") +(put 'gptel--system-message 'safe-local-variable #'gptel--always) (defcustom gptel-directives `((default . ,gptel--system-message) @@ -204,6 +229,7 @@ Each entry in this alist maps a symbol naming the directive to the string that is sent. To set the directive for a chat session interactively call `gptel-send' with a prefix argument." :group 'gptel + :safe #'gptel--always :type '(alist :key-type symbol :value-type string)) (defcustom gptel-max-tokens nil @@ -220,6 +246,7 @@ If left unset, ChatGPT will target about 40% of the total token count of the conversation so far in each message, so messages will get progressively longer!" :local t + :safe #'gptel--always :group 'gptel :type '(choice (integer :tag "Specify Token count") (const :tag "Default" nil))) @@ -232,10 +259,11 @@ The current options are - \"gpt-3.5-turbo-16k\" - \"gpt-4\" (experimental) - \"gpt-4-32k\" (experimental) - + To set the model for a chat session interactively call `gptel-send' with a prefix argument." :local t + :safe #'gptel--always :group 'gptel :type '(choice (const :tag "GPT 3.5 turbo" "gpt-3.5-turbo") @@ -252,10 +280,15 @@ of the response, with 2.0 being the most random. To set the temperature for a chat session interactively call `gptel-send' with a prefix argument." :local t + :safe #'gptel--always :group 'gptel :type 'number) +(defvar-local gptel--bounds nil) +(put 'gptel--bounds 'safe-local-variable #'gptel--always) + (defvar-local gptel--num-messages-to-send nil) +(put 'gptel--num-messages-to-send 'safe-local-variable #'gptel--always) (defvar gptel--debug nil) (defun gptel-api-key-from-auth-source (&optional host user) @@ -302,14 +335,20 @@ Currently saving and restoring state is implemented only for (read (org-entry-get (point-min) "GPTEL_BOUNDS")))) (mapc (pcase-lambda (`(,beg . ,end)) (put-text-property beg end 'gptel 'response)) - bounds)) + bounds) + (message "gptel chat restored.")) (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!")))))))) + (error (message "Could not restore gptel state, sorry!"))))) + (_ (when gptel--bounds + (mapc (pcase-lambda (`(,beg . ,end)) + (put-text-property beg end 'gptel 'response)) + gptel--bounds) + (message "gptel chat restored.")))))) (defun gptel--save-state () "Write the gptel state to the buffer. @@ -346,8 +385,17 @@ opening the file." (> attempts 0)) (funcall write-bounds (1- attempts))))))) (funcall write-bounds 6)))) - ('markdown-mode - (message "Saving gptel state is not implemented for `markdown-mode'.")))) + (_ (save-excursion + (save-restriction + (add-file-local-variable 'gptel-model gptel-model) + (unless (equal (default-value 'gptel-temperature) gptel-temperature) + (add-file-local-variable 'gptel-temperature gptel-temperature)) + (unless (string= (default-value 'gptel--system-message) + gptel--system-message) + (add-file-local-variable 'gptel--system-message gptel--system-message)) + (when gptel-max-tokens + (add-file-local-variable 'gptel-max-tokens gptel-max-tokens)) + (add-file-local-variable 'gptel--bounds (gptel--get-bounds))))))) (defun gptel--get-bounds () "Return the gptel response boundaries as an alist." @@ -395,14 +443,14 @@ opening the file." (propertize " " 'display `(space :align-to ,(max 1 (- (window-width) (+ 2 l1 l2))))) (propertize - (button-buttonize num-exchanges + (gptel--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 "]") + (gptel--button-buttonize (concat "[" gptel-model "]") (lambda (&rest _) (gptel-menu))) 'mouse-face 'highlight 'help-echo "OpenAI GPT model in use")))))))