diff options
| author | Craig Jennings <c@cjennings.net> | 2025-09-06 16:34:01 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-09-06 16:34:01 -0500 |
| commit | 06f31ba9a488c301406c6786a3522a4bda78a443 (patch) | |
| tree | bf05ecd82514ca09c5a835249fc0512096d1dc1c /modules | |
| parent | 0d0bbf0ea7c73b0e26060d9cf45967d406d96d30 (diff) | |
| download | dotemacs-06f31ba9a488c301406c6786a3522a4bda78a443.tar.gz dotemacs-06f31ba9a488c301406c6786a3522a4bda78a443.zip | |
feat(ai): Enhance GPTel workflow with save/load functionality
Add comprehensive conversation management with file persistence and
improved context handling. The update includes a dedicated directory
for storing conversations, new keybindings for save/load operations,
and better buffer/file context management. Also improves
documentation, cleans up the code structure, and enhances org-mode
formatting with proper hierarchical headings.
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/ai-config.el | 394 | ||||
| -rw-r--r-- | modules/ai-directives.el | 13 |
2 files changed, 314 insertions, 93 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el index a2c89cad..ba1c6ecc 100644 --- a/modules/ai-config.el +++ b/modules/ai-config.el @@ -2,60 +2,49 @@ ;; author Craig Jennings <c@cjennings.net> ;;; Commentary: - -;; Here is my basic workflow: - -;; - Launch GPTel via F9 or M-a t, and chat with the AI in the side window. -;; Remember that sending the message requires C-<return>. -;; ... or ... -;; - Select a region to rewrite, key M-a r, and add the directive in the menu. - -;; Note; -;; - you can save a file for a later session. Just open the file and change the buffer to gptel-mode to resume work. -;; - add files to the context with M-a f -;; - add buffers to the context with M-a b. +;; Configuration for AI integrations in Emacs, focused on GPTel. +;; +;; Main Features: +;; - Quick toggle for AI assistant window (F9 or M-a t) +;; - Custom keymap (M-a prefix) for all AI-related commands +;; - Enhanced org-mode conversation formatting with timestamps +;; allows switching models and easily compare and track responses. +;; - Various specialized AI directives (coder, reviewer, etc.) +;; - Context management for adding files/buffers to conversations +;; - Conversation persistence with save/load functionality +;; - Integration with Magit for code review +;; +;; Basic Workflow +;; +;; Using a side-chat window: +;; - Launch GPTel via F9 or M-a t, and chat in the AI-Assistant side window (C-<return> to send) +;; - Change system prompt (expertise, personalities) with M-a p +;; - Add context from files (M-a f) or buffers (M-a b) +;; - Save conversations with M-a s, load previous ones with M-a l +;; - Clear the conversation and start over with M-a x +;; Or in any buffer: +;; - Add directive as above, and select a region to rewrite with M-a r. +;; +;; Uses AI directives from ai-directives.el for specialized AI behaviors. ;;; Code: (add-to-list 'load-path (concat user-emacs-directory "modules/")) (require 'ai-directives) -;;; ------------------------------ Toggle GPTel -------------------------------- - -(defun cj/toggle-gptel () - "Toggle the visibility of the ChatGPT buffer, and place point at its end." - (interactive) - (let* ((buf-name "*AI-Assistant*") - (buffer (get-buffer buf-name)) - (win (and buffer (get-buffer-window buffer)))) - (if win - ;; If it's already visible, just close it - (delete-window win) - ;; Otherwise ensure the buffer exists - (unless buffer - (gptel buf-name gptel-model)) - (setq buffer (get-buffer buf-name)) - ;; Display in a side window, select it, and move point to end - (setq win - (display-buffer-in-side-window - buffer - '((side . right) - (window-width . 0.4)))) - (select-window win) - (with-current-buffer buffer - (goto-char (point-max)))))) - -;; ------------------------- GPTel Config And AI-Keymap ------------------------ +;;; ------------------------- GPTel Config And AI-Keymap ------------------------ (defvar ai-keymap (let ((map (make-sparse-keymap))) - (define-key map (kbd "t") #'cj/toggle-gptel) - (define-key map (kbd "x") #'cj/gptel-clear-buffer) - (define-key map (kbd "m") #'gptel-menu) - (define-key map (kbd "r") #'gptel-rewrite) - (define-key map (kbd "f") #'gptel-add-file) - (define-key map (kbd "b") #'gptel-add-buffer) - (define-key map (kbd "p") #'gptel-system-prompt) + (define-key map (kbd "t") #'cj/toggle-gptel) ;; toggles the ai-assistant window + (define-key map (kbd "x") #'cj/gptel-clear-buffer) ;; clears the assistant buffer + (define-key map (kbd "m") #'gptel-menu) ;; shows the full transient window + (define-key map (kbd "r") #'gptel-rewrite) ;; rewrite a region of code/text + (define-key map (kbd "f") #'cj/gptel-add-file) ;; add a file to context + (define-key map (kbd "b") #'cj/gptel-add-buffer) ;; add a buffer to context + (define-key map (kbd "p") #'gptel-system-prompt) ;; change prompt + (define-key map (kbd "s") #'cj/gptel-save-conversation) ;; save conversation + (define-key map (kbd "l") #'cj/gptel-load-conversation) ;; load and continue conversation map) "Keymap for AI-related commands (prefix \\<ai-keymap>).") (global-set-key (kbd "M-a") ai-keymap) @@ -68,7 +57,8 @@ (:map gptel-mode-map ("C-<return>" . gptel-send)) :custom - (gptel-default-directive 'default-directive) + ;; don't single quote directive as we want to send the content + (gptel-default-directive default-directive) (gptel-default-mode 'org-mode) (gptel-expert-commands t) (gptel-track-media t) @@ -84,8 +74,8 @@ (coder . ,coder-directive) (chat . ,chat-directive) (contractor . ,contractor-directive) - (emacs . ,emacs-directive) - (package-pm . ,package-pm-directive) + (emacs . ,emacs-directive) + (package-pm . ,package-pm-directive) (email . ,email-directive) (historian . ,historian-directive) (proofreader . ,proofreader-directive) @@ -93,9 +83,26 @@ (qa . ,qa-directive) (reviewer . ,reviewer-directive))) + (setq gptel-default-directive default-directive) + + ;; ---- Auth: pick the API key from your auth source + (setq auth-sources `((:source ,authinfo-file))) + (setq gptel-api-key (auth-source-pick-first-password :host "api.openai.com")) + + ;; Setup Anthropic's Claude + (setq anthropic-api-key (auth-source-pick-first-password :host "api.anthropic.com")) + (setq gptel-backend (gptel-make-anthropic "Claude" + :stream t :key anthropic-api-key)) + (setq gptel-model 'claude-3-opus-4-20250514) + ) ;; end use-package gptel + +;;; -------------------- User And Model Names In Org Headers -------------------- + +(with-eval-after-load 'gptel ;; Dynamic user prefix for org-mode heading (string, refreshed just before send) (defun cj/gptel--fresh-org-prefix () - (concat "*** " user-login-name " " (format-time-string "[%Y-%m-%d %H:%M:%S]") "\n")) + "Generate a fresh org-mode header with current timestamp for user messages." + (concat "* " user-login-name " " (format-time-string "[%Y-%m-%d %H:%M:%S]") "\n")) ;; Initialize as a string (GPTel expectation) (setf (alist-get 'org-mode gptel-prompt-prefix-alist) @@ -103,6 +110,7 @@ ;; Refresh immediately before each send for accurate timestamp (defun cj/gptel--refresh-org-prefix (&rest _) + "Update the org-mode prefix with fresh timestamp before sending message." (setf (alist-get 'org-mode gptel-prompt-prefix-alist) (cj/gptel--fresh-org-prefix))) (advice-add 'gptel-send :before #'cj/gptel--refresh-org-prefix) @@ -121,58 +129,270 @@ "Insert an Org heading for the AI reply at RESPONSE-BEGIN-POS." (save-excursion (goto-char response-begin-pos) - (insert (format "*** %s\n" (cj/gptel-backend-and-model))))) + (insert (format "* %s\n" (cj/gptel-backend-and-model))))) + ;; Hook is now at the proper level - will be executed when with-eval-after-load runs + (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading)) + +;;; ---------------------------- Toggle GPTel Window ---------------------------- + +(with-eval-after-load 'gptel + (defun cj/toggle-gptel () + "Toggle the visibility of the AI-Assistant buffer, and place point at its end." + (interactive) + (let* ((buf-name "*AI-Assistant*") + (buffer (get-buffer buf-name)) + (win (and buffer (get-buffer-window buffer)))) + (if win + ;; If it's already visible, just close it + (delete-window win) + ;; Otherwise ensure the buffer exists + (unless buffer + (gptel buf-name gptel-model)) + (setq buffer (get-buffer buf-name)) + ;; Display in a side window, select it, and move point to end + (setq win + (display-buffer-in-side-window + buffer + '((side . right) + (window-width . 0.4)))) + (select-window win) + (with-current-buffer buffer + (goto-char (point-max))))))) + +;;; ----------------------------- Clear Gptel Buffer ---------------------------- + +(with-eval-after-load 'gptel (defun cj/gptel-clear-buffer () - "Erase the contents of the *AI-Assistant* buffer leaving initial org heading." + "Erase the contents of the current GPTel buffer leaving initial org heading. +Only works in buffers with gptel-mode active." + (interactive) + (let ((is-gptel (bound-and-true-p gptel-mode)) + (is-org (derived-mode-p 'org-mode))) + ;; debug info to Messages + ;; (message "Debug: gptel-mode: %s, org-mode: %s, major-mode: %s" + ;; is-gptel is-org major-mode) + + (if (and is-gptel is-org) + (progn + (erase-buffer) + ;; re-insert the user heading with fresh timestamp + (insert (cj/gptel--fresh-org-prefix)) + (message "GPTel buffer cleared and heading reset")) + (message "Not a GPTel buffer in org-mode. Nothing cleared."))))) + +;;; ---------------------------- Context Manipulation --------------------------- + +(with-eval-after-load 'gptel + (defun cj/gptel-add-buffer () + "Add a buffer to the GPTel context. +Prompts for a buffer name and adds its entire content as context. +By default shows only regular buffers (not special buffers), but +allows access to all buffers via completion." + (interactive) + (let* ((buffers (mapcar #'buffer-name (buffer-list))) + ;; Filter out special buffers by default (those starting with space or *) + ;; But keep them in the collection for completion + (default-buffers (cl-remove-if (lambda (name) + (or (string-prefix-p " " name) + (string-prefix-p "*" name))) + buffers)) + (buffer-name (completing-read + "Buffer to add as context: " + buffers + nil ; No predicate + t ; Require match + nil ; No initial input + nil ; No history + (car default-buffers))) ; Default to first regular buffer + (buffer (get-buffer buffer-name))) + (when buffer + (if (and (buffer-file-name buffer) + (y-or-n-p "This buffer is associated with a file. Add file instead? ")) + ;; If it's a file buffer and user confirms, add the file instead + (gptel-add-file (buffer-file-name buffer)) + ;; Otherwise add the buffer content + (gptel-context--add-region + buffer + (with-current-buffer buffer (point-min)) + (with-current-buffer buffer (point-max)) + t) + (message "Buffer '%s' added as context." buffer-name)))))) + +(with-eval-after-load 'gptel + (with-eval-after-load 'projectile + (defun cj/gptel-add-file () + "Add a file to the GPTel context. +If inside a Projectile project, prompt from the project's file list; +otherwise use `read-file-name'." + (interactive) + (let* ((in-proj (and (fboundp 'projectile-project-p) + (projectile-project-p))) + (file-name (if in-proj + (projectile-completing-read + "GPTel add file: " + (projectile-current-project-files)) + (read-file-name "GPTel add file: "))) + ;; Ensure we have a full path when using projectile + (file-path (if in-proj + (expand-file-name file-name (projectile-project-root)) + file-name))) + ;; Debug output + (message "Adding file to context: %s" file-path) + + ;; Call the gptel built-in function directly + (gptel-add-file file-path) + + ;; Verify context was added + (message "Current context has %d sources" + (length gptel-context--alist)))))) + +;;; ----------------------- GPTel Conversation Management ----------------------- + +(defcustom cj/gptel-conversations-directory + (expand-file-name "ai-conversations" user-emacs-directory) + "Directory where GPTel conversations are stored. +Defaults to ~/.emacs.d/ai-conversations/" + :type 'directory + :group 'gptel) + +(defun cj/gptel--save-buffer-to-file (buffer filepath) + "Save the BUFFER content to FILEPATH with org visibility properties. +Adds org-mode startup properties to ensure content is visible when reopened." + (with-current-buffer buffer + (let ((content (buffer-string))) + ;; Create temp buffer to add properties + (with-temp-buffer + ;; Add org properties to ensure everything is shown on load + (insert "#+STARTUP: showeverything\n") + (insert "#+VISIBILITY: all\n\n") + (insert content) + (write-region (point-min) (point-max) filepath nil 'silent)))) + filepath) + +(with-eval-after-load 'gptel + (defun cj/gptel-save-conversation () + "Save the current AI-Assistant buffer to a file with .gptel extension. +Offers existing conversation topics as options but allows entering new topics." (interactive) (let ((buf (get-buffer "*AI-Assistant*"))) - (if (not buf) - (message "No AI buffer found") - (with-current-buffer buf - (erase-buffer) - ;; re-insert the user heading with fresh timestamp - (insert (cj/gptel--fresh-org-prefix)) - (message "AI buffer cleared and heading reset"))))) + (unless buf + (user-error "No AI-Assistant buffer found")) - ;; Hook is called with (BEG END); add our per-reply heading - (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading) + ;; Ensure directory exists + (unless (file-exists-p cj/gptel-conversations-directory) + (make-directory cj/gptel-conversations-directory t) + (message "Created directory: %s" cj/gptel-conversations-directory)) - ;; ---- Auth: pick the API key from your auth source - (setq auth-sources `((:source ,authinfo-file))) - (setq gptel-api-key (auth-source-pick-first-password :host "api.openai.com")) - (setq anthropic-api-key (auth-source-pick-first-password :host "api.anthropic.com")) + ;; Get existing topic names (without timestamps) + (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$")) + (topics (delete-dups + (mapcar (lambda (f) + (replace-regexp-in-string "_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" "" f)) + files))) + (topic (completing-read "Conversation topic: " topics nil nil)) + (clean-topic (replace-regexp-in-string "[^a-zA-Z0-9-_]" "-" topic)) + (existing-files (directory-files cj/gptel-conversations-directory nil + (format "^%s_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" + (regexp-quote clean-topic)))) + (newest-file (car (sort existing-files #'string>))) + (use-existing (and newest-file + (y-or-n-p (format "Update existing file %s? " newest-file)))) + (filepath (if use-existing + (expand-file-name newest-file cj/gptel-conversations-directory) + ;; Create new file with timestamp + (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S")) + (filename (format "%s_%s.gptel" clean-topic timestamp))) + (expand-file-name filename cj/gptel-conversations-directory))))) - ;; Setup Anthropic's Claude - (setq gptel-backend (gptel-make-anthropic "Claude" - :stream t :key anthropic-api-key)) - (setq gptel-model 'claude-3-opus-4-20250514) - ) ;; end use-package declaration + ;; Save the buffer + (cj/gptel--save-buffer-to-file buf filepath) + (message "Conversation saved to: %s" filepath))))) -(with-eval-after-load 'projectile - (defun cj/gptel-add-file () - "Add a file to the GPTel context. -If inside a Projectile project, prompt from the project's file list; -otherwise use =read-file-name'." +(with-eval-after-load 'gptel + (defun cj/gptel-load-conversation () + "Load a saved GPTel conversation into the AI-Assistant buffer. +If the current buffer has content, prompts to save it first. +Presents a list of .gptel files for selection and loads the chosen file." (interactive) - (let* ((in-proj (and (fboundp 'projectile-project-p) - (projectile-project-p))) - (file (if in-proj - (projectile-completing-read - "GPTel add file: " - (projectile-current-project-files)) - (read-file-name "GPTel add file: ")))) - (gptel-add-file file))) - (define-key ai-keymap (kbd "f") #'cj/gptel-add-file)) - -;; -------------------------------- GPTel-Magit -------------------------------- + + ;; Check if AI-Assistant buffer exists, create if needed + (let ((ai-buffer (get-buffer-create "*AI-Assistant*"))) + + ;; If buffer has content and gptel-mode is active, offer to save + (when (and (with-current-buffer ai-buffer + (> (buffer-size) 0)) + (with-current-buffer ai-buffer + (bound-and-true-p gptel-mode))) + (when (y-or-n-p "Save current conversation before loading new one? ") + (with-current-buffer ai-buffer + (call-interactively #'cj/gptel-save-conversation)))) + + ;; Check directory exists + (unless (file-exists-p cj/gptel-conversations-directory) + (user-error "Conversations directory doesn't exist: %s" + cj/gptel-conversations-directory)) + + ;; Get all .gptel files + (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$")) + (files-with-dates + (mapcar (lambda (f) + (let* ((full-path (expand-file-name f cj/gptel-conversations-directory)) + (mod-time (nth 5 (file-attributes full-path))) + (time-str (format-time-string "%Y-%m-%d %H:%M" mod-time))) + (cons (format "%-40s [%s]" f time-str) f))) + files))) + + (unless files + (user-error "No saved conversations found in %s" + cj/gptel-conversations-directory)) + + ;; Let user select a file + (let* ((selection (completing-read "Load conversation: " files-with-dates nil t)) + (filename (cdr (assoc selection files-with-dates))) + (filepath (expand-file-name filename cj/gptel-conversations-directory))) + + ;; Clear buffer and insert file contents + (with-current-buffer ai-buffer + ;; Ensure gptel-mode is active + (unless (bound-and-true-p gptel-mode) + (gptel "*AI-Assistant*") ;; Initialize gptel if not already active + (org-mode) + (gptel-mode 1)) + + ;; Clear and insert the conversation + (erase-buffer) + (insert-file-contents filepath) + + ;; Remove the org properties if present at the beginning + (goto-char (point-min)) + (when (looking-at "^#\\+STARTUP:.*\n#\\+VISIBILITY:.*\n\n") + (delete-region (point) (match-end 0))) + + ;; Position at end and mark as modified + (goto-char (point-max)) + (set-buffer-modified-p t)) + + ;; Show buffer in a side window if not already visible + (unless (get-buffer-window ai-buffer) + (if (fboundp 'cj/toggle-gptel) + (cj/toggle-gptel) + ;; Fallback to display in side window + (display-buffer-in-side-window + ai-buffer + '((side . right) + (window-width . 0.4))))) + + ;; Select the window + (select-window (get-buffer-window ai-buffer)) + (message "Loaded conversation from: %s" filepath)))))) + +;;; -------------------------------- GPTel-Magit -------------------------------- (use-package gptel-magit :defer t :hook (magit-mode . gptel-magit-install)) - - (provide 'ai-config) ;;; ai-config.el ends here diff --git a/modules/ai-directives.el b/modules/ai-directives.el index cc8dcb7f..68ec7d31 100644 --- a/modules/ai-directives.el +++ b/modules/ai-directives.el @@ -30,10 +30,10 @@ potential overcharges, you suggest specific follow-up questions I should ask the (defvar chat-directive "I want you to act as an old friend and highly intelligent person who is good at conversation. You are deeply -knowledgeable about academic philosophy and can discuss philosophical topics at a PhD level. When you do, you often -indicate the book or article relevant to the topic you discuss. You are very well educated in history. You have a kind -personality. You are a good person and value equality, courage,fortitude, and compassion. You ask very good questions. -You encourage people to improve themselves and you believe in them.") + knowledgeable about academic philosophy and can discuss philosophical topics at a PhD level. When you do, you often + indicate the book or article relevant to the topic you discuss. You are very well educated in history. You have a kind + personality. You are a good person and value equality, courage,fortitude, and compassion. You ask very good + questions. You encourage people to improve themselves and you believe in them.") (defvar coder-directive "You are an expert in emacs-lisp, Python, Golang, Shell scripting, and the git version control system. I want you @@ -60,8 +60,9 @@ motivational talk; just practical guidance.") (defvar default-directive "You are a large language model living in Emacs. You understand philosophy, critical theory, and comparative -literature at a university graduate student level. You have deep knowledge of the You are concise and always provide -references to source materia you refer to. You are a good-natured conversation partner and ask thoughtful questions.") + literature at a university graduate student level. You are also excellent at Emacs and Emacs configuration You have + strong knowledge of history and political science. You are concise and always provide references to source materia you refer to. You are a + good-natured conversation partner and ask thoughtful questions.") (defvar emacs-directive "You are an expert Emacs configuration assistant with complete knowledge of Emacs-Lisp, the latest packages, and |
