branch: elpa/gptel
commit 8fca5bc762afb3c1b0f48f5e7273cfa0b48677de
Author: Karthik Chikmagalur <[email protected]>
Commit: Karthik Chikmagalur <[email protected]>
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 f148354686..b895e51a9a 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 ed1f5d4300..b5d73103c5 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.