diff options
| author | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
| commit | 092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch) | |
| tree | ea81999b8442246c978b364dd90e8c752af50db5 /modules/ai-config.el | |
changing repositories
Diffstat (limited to 'modules/ai-config.el')
| -rw-r--r-- | modules/ai-config.el | 419 |
1 files changed, 419 insertions, 0 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el new file mode 100644 index 00000000..ef574412 --- /dev/null +++ b/modules/ai-config.el @@ -0,0 +1,419 @@ +;;; ai-config.el --- Configuration for AI Integrations -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; 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, overrides 'backwards-sentence') for 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. +;; + +;;; Code: + +(autoload 'cj/gptel-save-conversation "ai-conversations" "Save the AI conversation to a file." t) +(autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t) +(autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t) + +(with-eval-after-load 'gptel + (require 'ai-conversations)) + +;;; ------------------------- AI Config Helper Functions ------------------------ + +;; Define all our variables upfront +(defvar cj/anthropic-api-key-cached nil "Cached Anthropic API key.") +(defvar cj/openai-api-key-cached nil "Cached OpenAI API key.") +(defvar gptel-claude-backend nil "Claude backend, lazy-initialized.") +(defvar gptel-chatgpt-backend nil "ChatGPT backend, lazy-initialized.") + + +(defun cj/auth-source-secret (host user) + "Fetch a secret from auth-source for HOST and USER. + +HOST and USER must be strings that identify the credential to return." + (let* ((found (auth-source-search :host host :user user :require '(:secret) :max 1)) + (secret (plist-get (car found) :secret))) + (cond + ((functionp secret) (funcall secret)) + ((stringp secret) secret) + (t (error "No usable secret found for host %s and user %s" host user))))) + +(defun cj/anthropic-api-key () + "Return the Anthropic API key, caching the result after first retrieval." + (or cj/anthropic-api-key-cached + (setq cj/anthropic-api-key-cached + (cj/auth-source-secret "api.anthropic.com" "apikey")))) + +(defun cj/openai-api-key () + "Return the OpenAI API key, caching the result after first retrieval." + (or cj/openai-api-key-cached + (setq cj/openai-api-key-cached + (cj/auth-source-secret "api.openai.com" "apikey")))) + +(defun cj/ensure-gptel-backends () + "Initialize GPTel backends if they are not already available. + +Call this only after loading 'gptel' so the backend constructors exist." + (unless gptel-claude-backend + (setq gptel-claude-backend + (gptel-make-anthropic + "Claude" + :key (cj/anthropic-api-key) + :models '( + "claude-opus-4-1-20250805" + "claude-3-5-sonnet-20241022" + "claude-3-opus-20240229" + "claude-3-5-haiku-20241022" + ) + :stream t))) + (unless gptel-chatgpt-backend + (setq gptel-chatgpt-backend + (gptel-make-openai + "ChatGPT" + :key (cj/openai-api-key) + :models '( + "gpt-4o" + "gpt-5" + "gpt-4.1" + "o1" + ) + :stream t))) + ;; Set default backend + (unless gptel-backend + (setq gptel-backend (or gptel-chatgpt-backend gptel-claude-backend)))) + +(autoload 'cj/toggle-gptel "ai-config" "Toggle the AI-Assistant window" t) + +;; ------------------ Gptel Conversation And Utility Commands ------------------ + +(defun cj/gptel--available-backends () + "Return an alist of (NAME . BACKEND), ensuring gptel and backends are initialized." + (unless (featurep 'gptel) + (require 'gptel)) + (cj/ensure-gptel-backends) + (delq nil + (list (and (bound-and-true-p gptel-claude-backend) + (cons "Anthropic - Claude" gptel-claude-backend)) + (and (bound-and-true-p gptel-chatgpt-backend) + (cons "OpenAI - ChatGPT" gptel-chatgpt-backend))))) + +(defun cj/gptel--model->string (m) + (cond + ((stringp m) m) + ((symbolp m) (symbol-name m)) + (t (format "%s" m)))) + +;; Backend/model switching commands (moved out of use-package so they are commandp) +(defun cj/gptel-change-model () + "Change the GPTel backend and select a model from that backend. + +Present all available models from every backend, switching backends when +necessary. Prompt for whether to apply the selection globally or buffer-locally." + (interactive) + (let* ((backends (cj/gptel--available-backends)) + (all-models + (mapcan + (lambda (pair) + (let* ((backend-name (car pair)) + (backend (cdr pair)) + (models (when (fboundp 'gptel-backend-models) + (gptel-backend-models backend)))) + (mapcar (lambda (m) + (list (format "%s: %s" backend-name (cj/gptel--model->string m)) + backend + (cj/gptel--model->string m) + backend-name)) + models))) + backends)) + (current-backend-name (car (rassoc (bound-and-true-p gptel-backend) backends))) + (current-selection (format "%s: %s" + (or current-backend-name "AI") + (cj/gptel--model->string (bound-and-true-p gptel-model)))) + (scope (completing-read "Set model for: " '("buffer" "global") nil t)) + (selected (completing-read + (format "Select model (current: %s): " current-selection) + (mapcar #'car all-models) nil t nil nil current-selection))) + (let* ((model-info (assoc selected all-models)) + (backend (nth 1 model-info)) + (model (intern (nth 2 model-info))) ;; Convert string to symbol + (backend-name (nth 3 model-info))) + (if (string= scope "global") + (progn + (setq gptel-backend backend) + (setq gptel-model model) + (message "Changed to %s model: %s (global)" backend-name model)) + (setq-local gptel-backend backend) + (setq-local gptel-model (if (stringp model) (intern model) model)) + (message "Changed to %s model: %s (buffer-local)" backend-name model))))) + +(defun cj/gptel-switch-backend () + "Switch the GPTel backend and then choose one of its models." + (interactive) + (let* ((backends (cj/gptel--available-backends)) + (choice (completing-read "Select GPTel backend: " (mapcar #'car backends) nil t)) + (backend (cdr (assoc choice backends)))) + (unless backend + (user-error "Invalid GPTel backend: %s" choice)) + (let* ((models (when (fboundp 'gptel-backend-models) + (gptel-backend-models backend))) + (model (completing-read (format "Select %s model: " choice) + (mapcar #'cj/gptel--model->string models) + nil t nil nil (cj/gptel--model->string (bound-and-true-p gptel-model))))) + (setq gptel-backend backend + gptel-model model) + (message "Switched to %s with model: %s" choice model)))) + +;; Clear assistant buffer (moved out so it's always available) +(defun cj/gptel-clear-buffer () + "Erase the current GPTel buffer while preserving the initial Org heading. + +Operate only when `gptel-mode' is active in an Org buffer so the heading +can be reinserted." + (interactive) + (let ((is-gptel (bound-and-true-p gptel-mode)) + (is-org (derived-mode-p 'org-mode))) + (if (and is-gptel is-org) + (progn + (erase-buffer) + (when (fboundp 'cj/gptel--fresh-org-prefix) + (insert (cj/gptel--fresh-org-prefix))) + (message "GPTel buffer cleared and heading reset")) + (message "Not a GPTel buffer in org-mode. Nothing cleared.")))) + +;; ----------------------------- Context Management ---------------------------- + +(defun cj/gptel--add-file-to-context (file-path) + "Add FILE-PATH to the GPTel context. + +Returns t on success, nil on failure. +Provides consistent user feedback about the context state." + (when (and file-path (file-exists-p file-path)) + (gptel-add-file file-path) + (let ((context-count (if (boundp 'gptel-context--alist) + (length gptel-context--alist) + 0))) + (message "Added %s to GPTel context (%d sources total)" + (file-name-nondirectory file-path) + context-count)) + t)) + +(defun cj/gptel-add-file () + "Add a file to the GPTel context. + +If inside a Projectile project, prompt from that project's file list. +Otherwise, prompt with `read-file-name'." + (interactive) + (let* ((in-proj (and (featurep 'projectile) + (fboundp 'projectile-project-p) + (projectile-project-p))) + (file-name (if in-proj + (let ((cands (projectile-current-project-files))) + (if (fboundp 'projectile-completing-read) + (projectile-completing-read "GPTel add file: " cands) + (completing-read "GPTel add file: " cands nil t))) + (read-file-name "GPTel add file: "))) + (file-path (if in-proj + (expand-file-name file-name (projectile-project-root)) + file-name))) + (unless (cj/gptel--add-file-to-context file-path) + (error "Failed to add file: %s" file-path)))) + +(defun cj/gptel-add-buffer-file () + "Select a buffer and add its associated file to the GPTel context. + +Lists all open buffers for selection. If the selected buffer is visiting +a file, that file is added to the GPTel context. Otherwise, an error +message is displayed." + (interactive) + (let* ((buffers (mapcar #'buffer-name (buffer-list))) + (selected-buffer-name (completing-read "Add file from buffer: " buffers nil t)) + (selected-buffer (get-buffer selected-buffer-name)) + (file-path (and selected-buffer + (buffer-file-name selected-buffer)))) + (if file-path + (cj/gptel--add-file-to-context file-path) + (message "Buffer '%s' is not visiting a file" selected-buffer-name)))) + +(defun cj/gptel-add-this-buffer () + "Add the current buffer to the GPTel context. + +Works for any buffer, whether it's visiting a file or not." + (interactive) + ;; Load gptel-context if needed + (unless (featurep 'gptel-context) + (require 'gptel-context)) + ;; Use gptel-add with prefix arg '(4) to add current buffer + (gptel-add '(4)) + (message "Added buffer '%s' to GPTel context" (buffer-name))) + +;;; ---------------------------- GPTel Configuration ---------------------------- + +(use-package gptel + :defer t + :commands (gptel gptel-send gptel-menu) + :bind + (("<f9>" . cj/toggle-gptel) + :map gptel-mode-map + ("C-<return>" . gptel-send)) + :custom + (gptel-default-mode 'org-mode) + (gptel-expert-commands t) + (gptel-track-media t) + ;; TODO: add reasoning to a buffer. See docstring. + (gptel-include-reasoning 'ignore) + (gptel-log-level 'info) + (gptel--debug nil) + :config + (cj/ensure-gptel-backends) + ;; Set ChatGPT as default after initialization + (setq gptel-backend gptel-chatgpt-backend) + + ;; Named backend list for switching + (defvar cj/gptel-backends + `(("Anthropic - Claude" . ,gptel-claude-backend) + ("OpenAI - ChatGPT" . ,gptel-chatgpt-backend)) + "Alist of GPTel backends for interactive switching.") + + (setq gptel-confirm-tool-calls nil) ;; allow tool access by default + ;;; ---------------------------- Backend Management --------------------------- + + (setq gptel-backend gptel-chatgpt-backend) ;; use ChatGPT as default + ;; (setq gptel-backend gptel-claude-backend) ;; use Claude as default + +;;; -------------------------- Org Header Construction -------------------------- + + ;; Dynamic user prefix for org-mode heading (string, refreshed just before send) + (defun cj/gptel--fresh-org-prefix () + "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) + (cj/gptel--fresh-org-prefix)) + + ;; 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) + + ;; AI header on each reply: (e.g. "*** AI: <model> [timestamp]") + (defun cj/gptel-backend-and-model () + "Return backend, model, and timestamp as a single string." + (let* ((backend (pcase (bound-and-true-p gptel-backend) + ((and v (pred vectorp)) (aref v 1)) ;; display name if vector + (_ "AI"))) + (model (format "%s" (or (bound-and-true-p gptel-model) ""))) + (ts (format-time-string "[%Y-%m-%d %H:%M:%S]"))) + (format "%s: %s %s" backend model ts))) + + (defun cj/gptel-insert-model-heading (response-begin-pos _response-end-pos) + "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))))) + + (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading)) + +;;; ---------------------------- Toggle GPTel Window ---------------------------- + +(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 + (delete-window win) + ;; Ensure GPTel and our backends are initialized before creating the buffer + (unless (featurep 'gptel) + (require 'gptel)) + (cj/ensure-gptel-backends) + (unless buffer + ;; Pass backend, not model + (gptel buf-name gptel-backend)) + (setq buffer (get-buffer buf-name)) + (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 Context ------------------------------- + +(defun cj/gptel-context-clear () + "Clear all GPTel context sources, with compatibility across GPTel versions." + (interactive) + (cond + ((fboundp 'gptel-context-remove-all) + (call-interactively 'gptel-context-remove-all) + (message "GPTel context cleared")) + ((fboundp 'gptel-context-clear) + (call-interactively 'gptel-context-clear) + (message "GPTel context cleared")) + ((boundp 'gptel-context--alist) + (setq gptel-context--alist nil) + (message "GPTel context cleared")) + (t + (message "No known GPTel context clearing function available")))) + +;;; -------------------------------- GPTel-Magit -------------------------------- + +(use-package gptel-magit + :defer t + :hook (magit-mode . gptel-magit-install)) + +;; ------------------------------ GPTel Directives ----------------------------- + +(use-package gptel-prompts + :load-path (lambda () (expand-file-name "custom/" user-emacs-directory)) + :after gptel + :if (file-exists-p (expand-file-name "custom/gptel-prompts.el" user-emacs-directory)) + :custom + (gptel-prompts-directory (concat user-emacs-directory "ai-prompts")) + :config + (gptel-prompts-update) + (gptel-prompts-add-update-watchers)) + +;;; --------------------------------- AI Keymap --------------------------------- + +(define-prefix-command 'cj/ai-keymap nil + "Keymap for AI operations.") +(define-key cj/custom-keymap "a" 'cj/ai-keymap) +(define-key cj/ai-keymap "B" #'cj/gptel-switch-backend) ;; change the backend (OpenAI, Anthropic, etc.) +(define-key cj/ai-keymap "M" #'gptel-menu) ;; gptel's transient menu +(define-key cj/ai-keymap "d" #'cj/gptel-delete-conversation) ;; delete conversation +(define-key cj/ai-keymap "." #'cj/gptel-add-this-buffer) ;; add buffer to context +(define-key cj/ai-keymap "f" #'cj/gptel-add-file) ;; add a file to context +(define-key cj/ai-keymap "l" #'cj/gptel-load-conversation) ;; load and continue conversation +(define-key cj/ai-keymap "m" #'cj/gptel-change-model) ;; change the LLM model +(define-key cj/ai-keymap "p" #'gptel-system-prompt) ;; change prompt +(define-key cj/ai-keymap "&" #'gptel-rewrite) ;; rewrite a region of code/text +(define-key cj/ai-keymap "r" #'cj/gptel-context-clear) ;; remove all context +(define-key cj/ai-keymap "s" #'cj/gptel-save-conversation) ;; save conversation +(define-key cj/ai-keymap "t" #'cj/toggle-gptel) ;; toggles the ai-assistant window +(define-key cj/ai-keymap "x" #'cj/gptel-clear-buffer) ;; clears the assistant buffer + +(provide 'ai-config) +;;; ai-config.el ends here. |
