summaryrefslogtreecommitdiff
path: root/modules/ai-conversations.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
committerCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
commit092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch)
treeea81999b8442246c978b364dd90e8c752af50db5 /modules/ai-conversations.el
changing repositories
Diffstat (limited to 'modules/ai-conversations.el')
-rw-r--r--modules/ai-conversations.el277
1 files changed, 277 insertions, 0 deletions
diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el
new file mode 100644
index 00000000..92549176
--- /dev/null
+++ b/modules/ai-conversations.el
@@ -0,0 +1,277 @@
+;;; ai-conversations.el --- GPTel conversation persistence and autosave -*- lexical-binding: t; coding: utf-8; -*-
+;; Author: Craig Jennings <c@cjennings.net>
+;; Maintainer: Craig Jennings <c@cjennings.net>
+;; Version 0.1
+;; Package-Requires: ((emacs "27.1"))
+;; Keywords: convenience, tools
+;;
+;;; Commentary:
+;; Provides conversation save/load/delete, autosave after responses, and
+;; org-visibility headers for GPTel-powered assistant buffers.
+;;
+;; Loads lazily via autoloads for the interactive entry points.
+
+;;; Code:
+
+(defgroup cj/ai-conversations nil
+ "Conversation persistence for GPTel (save/load/delete, autosave)."
+ :group 'gptel
+ :prefix "cj/")
+
+(defcustom cj/gptel-conversations-directory
+ (expand-file-name "ai-conversations" user-emacs-directory)
+ "Directory where GPTel conversations are stored."
+ :type 'directory
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-window-side 'right
+ "Side to display the AI-Assistant buffer when loading a conversation."
+ :type '(choice (const :tag "Right" right)
+ (const :tag "Left" left)
+ (const :tag "Bottom" bottom)
+ (const :tag "Top" top))
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-window-width 0.4
+ "Set the side window width when loading a conversation.
+
+If displaying on the top or bottom, treat this value as a height fraction."
+ :type 'number
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-sort-order 'newest-first
+ "Sort order for conversation selection prompts."
+ :type '(choice (const :tag "Newest first" newest-first)
+ (const :tag "Oldest first" oldest-first))
+ :group 'cj/ai-conversations)
+
+(defvar-local cj/gptel-autosave-enabled nil
+ "Non-nil means auto-save after each AI response in GPTel buffers.")
+
+(defvar-local cj/gptel-autosave-filepath nil
+ "File path used for auto-saving the conversation buffer.")
+
+(defcustom cj/gptel-conversations-autosave-on-send t
+ "Non-nil means auto-save the conversation immediately after `gptel-send'."
+ :type 'boolean
+ :group 'cj/ai-conversations)
+
+(defun cj/gptel--autosave-after-send (&rest _args)
+ "Auto-save current GPTel buffer right after `gptel-send' if enabled."
+ (when (and cj/gptel-conversations-autosave-on-send
+ (bound-and-true-p gptel-mode)
+ cj/gptel-autosave-enabled
+ (stringp cj/gptel-autosave-filepath)
+ (> (length cj/gptel-autosave-filepath) 0))
+ (condition-case err
+ (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
+ (error (message "cj/gptel autosave-on-send failed: %s" (error-message-string err))))))
+
+(with-eval-after-load 'gptel
+ (unless (advice-member-p #'cj/gptel--autosave-after-send #'gptel-send)
+ (advice-add 'gptel-send :after #'cj/gptel--autosave-after-send)))
+
+(defun cj/gptel--slugify-topic (s)
+ "Return a filesystem-friendly slug for topic string S."
+ (let* ((down (downcase (or s "")))
+ (repl (replace-regexp-in-string "[^a-z0-9]+" "-" down))
+ (trim (replace-regexp-in-string "^-+\\|-+$" "" repl)))
+ (or (and (> (length trim) 0) trim) "conversation")))
+
+(defun cj/gptel--existing-topics ()
+ "Return topic slugs, without timestamps, present in the conversations directory."
+ (when (file-exists-p cj/gptel-conversations-directory)
+ (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$")))
+ (delete-dups
+ (mapcar
+ (lambda (f)
+ (replace-regexp-in-string "_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" "" f))
+ files)))))
+
+(defun cj/gptel--latest-file-for-topic (topic-slug)
+ "Return the newest saved conversation filename for TOPIC-SLUG, or nil."
+ (let* ((rx (format "^%s_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$"
+ (regexp-quote topic-slug)))
+ (files (and (file-exists-p cj/gptel-conversations-directory)
+ (directory-files cj/gptel-conversations-directory nil rx))))
+ (car (sort files #'string>))))
+
+(defun cj/gptel--timestamp-from-filename (filename)
+ "Return an Emacs timestamp extracted from FILENAME, or nil.
+
+Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel."
+ (when (string-match "_\\([0-9]\\{8\\}\\)-\\([0-9]\\{6\\}\\)\\.gptel\\'" filename)
+ (let* ((date (match-string 1 filename))
+ (time (match-string 2 filename))
+ (Y (string-to-number (substring date 0 4)))
+ (M (string-to-number (substring date 4 6)))
+ (D (string-to-number (substring date 6 8)))
+ (h (string-to-number (substring time 0 2)))
+ (m (string-to-number (substring time 2 4)))
+ (s (string-to-number (substring time 4 6))))
+ (encode-time s m h D M Y))))
+
+(defun cj/gptel--conversation-candidates ()
+ "Return conversation candidates sorted per `cj/gptel-conversations-sort-order'."
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$"))
+ (enriched
+ (mapcar
+ (lambda (f)
+ (let* ((full (expand-file-name f cj/gptel-conversations-directory))
+ (ptime (or (cj/gptel--timestamp-from-filename f)
+ (nth 5 (file-attributes full))))
+ (disp (format "%s [%s]" f (format-time-string "%Y-%m-%d %H:%M" ptime))))
+ (list :file f :time ptime :display disp)))
+ files))
+ (sorted
+ (sort enriched
+ (lambda (a b)
+ (let ((ta (plist-get a :time))
+ (tb (plist-get b :time)))
+ (if (eq cj/gptel-conversations-sort-order 'newest-first)
+ (time-less-p tb ta) ;; tb earlier than ta => a first
+ (time-less-p ta tb))))))
+ (cands (mapcar (lambda (pl)
+ (cons (plist-get pl :display)
+ (plist-get pl :file)))
+ sorted)))
+ cands))
+
+(defun cj/gptel--save-buffer-to-file (buffer filepath)
+ "Save BUFFER content to FILEPATH with Org visibility properties."
+ (with-current-buffer buffer
+ (let ((content (buffer-string)))
+ (with-temp-buffer
+ (insert "#+STARTUP: showeverything\n")
+ (insert "#+VISIBILITY: all\n\n")
+ (insert content)
+ (write-region (point-min) (point-max) filepath nil 'silent))))
+ filepath)
+
+(defun cj/gptel--ensure-ai-buffer ()
+ "Return the *AI-Assistant* buffer, creating it via `gptel' if needed."
+ (let* ((buf-name "*AI-Assistant*")
+ (buffer (get-buffer buf-name)))
+ (unless buffer
+ (gptel buf-name))
+ (or (get-buffer buf-name)
+ (user-error "Could not create or find *AI-Assistant* buffer"))))
+
+;;;###autoload
+(defun cj/gptel-save-conversation ()
+ "Save the current AI-Assistant buffer to a .gptel file.
+
+Enable autosave for subsequent AI responses to the same file."
+ (interactive)
+ (let ((buf (get-buffer "*AI-Assistant*")))
+ (unless buf
+ (user-error "No AI-Assistant buffer found"))
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (make-directory cj/gptel-conversations-directory t)
+ (message "Created directory: %s" cj/gptel-conversations-directory))
+ (let* ((topics (or (cj/gptel--existing-topics) '()))
+ (input (completing-read "Conversation topic: " topics nil nil))
+ (topic-slug (cj/gptel--slugify-topic input))
+ (latest (cj/gptel--latest-file-for-topic topic-slug))
+ (use-existing (and latest
+ (y-or-n-p (format "Update existing file %s? " latest))))
+ (filepath (if use-existing
+ (expand-file-name latest cj/gptel-conversations-directory)
+ (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S"))
+ (filename (format "%s_%s.gptel" topic-slug timestamp)))
+ (expand-file-name filename cj/gptel-conversations-directory)))))
+ (cj/gptel--save-buffer-to-file buf filepath)
+ (with-current-buffer buf
+ (setq-local cj/gptel-autosave-filepath filepath)
+ (setq-local cj/gptel-autosave-enabled t))
+ (message "Conversation saved to: %s" filepath))))
+
+;;;###autoload
+(defun cj/gptel-delete-conversation ()
+ "Delete a saved GPTel conversation file (chronologically sorted candidates)."
+ (interactive)
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((cands (cj/gptel--conversation-candidates)))
+ (unless cands
+ (user-error "No saved conversations found in %s" cj/gptel-conversations-directory))
+ (let* ((completion-extra-properties '(:display-sort-function identity
+ :cycle-sort-function identity))
+ (selection (completing-read "Delete conversation: " cands nil t))
+ (filename (cdr (assoc selection cands)))
+ (filepath (and filename
+ (expand-file-name filename cj/gptel-conversations-directory))))
+ (unless filename
+ (user-error "No conversation selected"))
+ (when (y-or-n-p (format "Really delete %s? " filename))
+ (delete-file filepath)
+ (message "Deleted conversation: %s" filename)))))
+
+(defun cj/gptel--strip-visibility-headers ()
+ "Strip org visibility headers at the top of the current buffer if present."
+ (save-excursion
+ (goto-char (point-min))
+ (while (looking-at "^#\\+\\(STARTUP\\|VISIBILITY\\):.*\n")
+ (delete-region (match-beginning 0) (match-end 0)))
+ (when (looking-at "^\n+")
+ (delete-region (point) (match-end 0)))))
+
+;;;###autoload
+(defun cj/gptel-load-conversation ()
+ "Load a saved GPTel conversation into the AI-Assistant buffer.
+
+Prompt to save the current conversation first when appropriate, then enable autosave."
+ (interactive)
+ (let ((ai-buffer (get-buffer-create "*AI-Assistant*")))
+ (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)))))
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((cands (cj/gptel--conversation-candidates)))
+ (unless cands
+ (user-error "No saved conversations found in %s" cj/gptel-conversations-directory))
+ (let* ((completion-extra-properties '(:display-sort-function identity
+ :cycle-sort-function identity))
+ (selection (completing-read "Load conversation: " cands nil t))
+ (filename (cdr (assoc selection cands)))
+ (filepath (and filename
+ (expand-file-name filename cj/gptel-conversations-directory))))
+ (unless filename
+ (user-error "No conversation selected"))
+ (with-current-buffer (cj/gptel--ensure-ai-buffer)
+ (erase-buffer)
+ (insert-file-contents filepath)
+ (cj/gptel--strip-visibility-headers)
+ (goto-char (point-max))
+ (set-buffer-modified-p t)
+ (setq-local cj/gptel-autosave-filepath filepath)
+ (setq-local cj/gptel-autosave-enabled t))
+ (let ((buf (get-buffer "*AI-Assistant*")))
+ (unless (get-buffer-window buf)
+ (display-buffer-in-side-window
+ buf `((side . ,cj/gptel-conversations-window-side)
+ (window-width . ,cj/gptel-conversations-window-width)))))
+ (select-window (get-buffer-window "*AI-Assistant*"))
+ (message "Loaded conversation from: %s" filepath))))
+
+(defun cj/gptel--autosave-after-response (&rest _args)
+ "Auto-save the current GPTel buffer when enabled."
+ (when (and (bound-and-true-p gptel-mode)
+ cj/gptel-autosave-enabled
+ (stringp cj/gptel-autosave-filepath)
+ (> (length cj/gptel-autosave-filepath) 0))
+ (condition-case err
+ (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
+ (error (message "cj/gptel autosave failed: %s" (error-message-string err))))))
+
+(with-eval-after-load 'gptel
+ (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions)
+ (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response)))
+
+(provide 'ai-conversations)
+;;; ai-conversations.el ends here