From 8fca5bc762afb3c1b0f48f5e7273cfa0b48677de Mon Sep 17 00:00:00 2001 From: Karthik Chikmagalur Date: Fri, 10 Mar 2023 04:12:32 -0800 Subject: [PATCH] gptel: Add org-mode support and update README gptel.el (gptel-response-filter-functions, gptel-send, gptel--create-prompt, gptel--transform-response, gptel--convert-org, gptel--convert-markdown->org): Add support for org-mode by transforming the response manually. (Note: Asking ChatGPT to format its results in org-mode markup produces inconsistent results.) Additionally, the abnormal hook `gptel-resposne-filter-functions' is added for arbitrary transformations of the response. Its implementation seems needlessly complex, and in the future we should change it to use `run-hook-wrapped' with a local accumulator. --- README.org | 15 ++++++--- gptel.el | 91 +++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/README.org b/README.org index f148354..b895e51 100644 --- a/README.org +++ b/README.org @@ -6,7 +6,7 @@ GPTel is a simple, no-frills ChatGPT client for Emacs. - Requires an [[https://platform.openai.com/account/api-keys][OpenAI API key]]. - No external dependencies, only Emacs. Also, it's async. -- Interaction is in a Markdown (or text) buffer. +- Interaction is in a Markdown, Org or text buffer. - Supports conversations (not just one-off queries) and multiple independent sessions. - 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. @@ -39,6 +39,8 @@ Procure an [[https://platform.openai.com/account/api-keys][OpenAI API key]]. Optional: Set =gptel-api-key= to the key or to a function that returns the key (more secure). +*** In a dedicated buffer: + Run =M-x gptel= to start or switch to the ChatGPT buffer. It will ask you for the key if you skipped the previous step. Run it with a prefix-arg (=C-u M-x gptel=) to start a new session. @@ -47,6 +49,14 @@ In the gptel buffer, send your prompt with =M-x gptel-send=, bound to =C-c RET=. That's it. You can go back and edit previous prompts and responses if you want. +The default mode is =markdown-mode= if available, else =text-mode=. You can set =gptel-default-mode= to =org-mode= if desired. + +*** In any buffer: + +Select a region of text, call =M-x gptel-send=. + +The response will be inserted below your region. You can select both the original prompt and the resposne and call =M-x gptel-send= again to continue the conversation. + ** Why another ChatGPT client? Existing Emacs clients don't /reliably/ let me use it the simple way I can in the browser. They will get better, but I wanted something for now. @@ -65,6 +75,3 @@ Maybe all of these, I don't know yet. As a start, I wanted to replicate the web ** Will you add feature X? Maybe, I'd like to experiment a bit first. - -- Support for Org Mode instead of Markdown, including source blocks etc, is planned. -- I'm experimenting with using it in code buffers. diff --git a/gptel.el b/gptel.el index ed1f5d4..b5d7310 100644 --- a/gptel.el +++ b/gptel.el @@ -85,6 +85,20 @@ When set to nil, it is inserted all at once. :group 'gptel :type 'boolean) +(defcustom gptel-response-filter-functions + '(gptel--convert-org) + "Abnormal hook for transforming the response from ChatGPT. + +This is useful if you want to format the response in some way, +such as filling paragraphs, adding annotations or recording +information in the response like links. + +Each function in this hook receives two arguments, the response +string to transform and the ChatGPT interaction buffer. It should +return the transformed string." + :group 'gptel + :type 'hook) + (defvar gptel-default-session "*ChatGPT*") (defvar gptel-default-mode (if (featurep 'markdown-mode) 'markdown-mode @@ -130,6 +144,8 @@ When set to nil, it is inserted all at once. (status-str (plist-get response :status))) (if content-str (with-current-buffer gptel-buffer + (setq content-str (gptel--transform-response + content-str gptel-buffer)) (save-excursion (put-text-property 0 (length content-str) 'gptel 'response content-str) (message "Querying ChatGPT... done.") @@ -185,13 +201,7 @@ instead." 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)) + :content gptel--system-message) prompts))))) (defun gptel--request-data (prompts) @@ -205,7 +215,30 @@ instead." (plist-put prompts-plist :max_tokens (gptel--numberize gptel--max-tokens))) prompts-plist)) -(aio-defun gptel--get-response (prompts) +;; TODO: Use `run-hook-wrapped' with an accumulator instead to handle +;; buffer-local hooks, etc. +(defun gptel--transform-response (content-str buffer) + (let ((filtered-str content-str)) + (dolist (filter-func gptel-response-filter-functions filtered-str) + (condition-case nil + (when (functionp filter-func) + (setq filtered-str + (funcall filter-func filtered-str buffer))) + (error + (display-warning '(gptel filter-functions) + (format "Function %S returned an error" + filter-func))))))) + +(defun gptel--convert-org (content buffer) + "Transform CONTENT according to required major-mode. + +Currently only org-mode is handled. + +BUFFER is the interaction buffer for ChatGPT." + (pcase (buffer-local-value 'major-mode buffer) + ('org-mode (gptel--convert-markdown->org content)) + (_ content))) + (aio-defun gptel--url-get-response (prompts) "Fetch response for PROMPTS from ChatGPT. @@ -287,6 +320,48 @@ Ask for API-KEY if `gptel-api-key' is unset." (message "Send your query with %s!" (substitute-command-keys "\\[gptel-send]")))) +(defun gptel--convert-markdown->org (str) + "Convert string STR from markdown to org markup. + +This is a very basic converter that handles only a few markup +elements." + (interactive) + (with-temp-buffer + (insert str) + (goto-char (point-min)) + (while (re-search-forward "`\\|\\*\\{1,2\\}\\|_" nil t) + (pcase (match-string 0) + ("`" (if (looking-at "``") + (progn (backward-char) + (delete-char 3) + (insert "#+begin_src ") + (when (re-search-forward "^```" nil t) + (replace-match "#+end_src"))) + (replace-match "="))) + ("**" (cond + ((looking-at "\\*\\(?:[[:word:]]\\|\s\\)") + (delete-char 1)) + ((looking-back "\\(?:[[:word:]]\\|\s\\)\\*\\{2\\}" + (max (- (point) 3) (point-min))) + (backward-delete-char 1)))) + ((or "_" "*") + (if (save-match-data + (and (looking-back "\\(?:[[:space:]]\\|\s\\)\\(?:_\\|\\*\\)" + (max (- (point) 2) (point-min))) + (not (looking-at "[[:space:]]\\|\s")))) + ;; Possible beginning of italics + (and + (save-excursion + (when (and (re-search-forward (regexp-quote (match-string 0)) nil t) + (looking-at "[[:space]]\\|\s") + (not (looking-back "\\(?:[[:space]]\\|\s\\)\\(?:_\\|\\*\\)" + (max (- (point) 2) (point-min))))) + (backward-delete-char 1) + (insert "/") t)) + (progn (backward-delete-char 1) + (insert "/"))))))) + (buffer-string))) + (defun gptel--playback (buf content-str start-pt) "Playback CONTENT-STR in BUF.