branch: externals/ellama commit 559f1c4590fdc79bb90a2fdfde443f889008a5e7 Merge: 3059baa169 fca903ac13 Author: Sergey Kostyaev <s-kosty...@users.noreply.github.com> Commit: GitHub <nore...@github.com>
Merge pull request #291 from s-kostyaev/refactor-ellama-stream Refactor text insertion and handling in `ellama.el` --- NEWS.org | 11 +++ README.org | 7 +- ellama-blueprint.el | 16 ++-- ellama-context.el | 10 +- ellama.el | 258 +++++++++++++++++++++++++++++++++------------------ tests/test-ellama.el | 37 +++++++- 6 files changed, 236 insertions(+), 103 deletions(-) diff --git a/NEWS.org b/NEWS.org index 7e5358b56b..230f57b470 100644 --- a/NEWS.org +++ b/NEWS.org @@ -1,3 +1,14 @@ +* Version 1.6.0 +- Refactored the text insertion and handling logic in ~ellama.el~. +- Added new customization variables ~ellama-show-reasoning~ and + ~ellama-reasoning-display-action-function~ to control the display of + reasoning. Updated ~ellama.el~ to use these new variables when displaying + reasoning buffers. +- Added ~ellama-disable-scroll~ and ~ellama-enable-scroll~ functions to control + auto-scroll behavior. +- Added a new face ~ellama-key-face~ to style the context line keys in both + ~ellama-blueprint.el~ and ~ellama-context.el~. Updated header line formats to + use this new face for better visual distinction. * Version 1.5.6 - Fix support for translating inline code from markdown to org format by handling backticks. diff --git a/README.org b/README.org index 865b783f51..31dfb247d0 100644 --- a/README.org +++ b/README.org @@ -113,7 +113,10 @@ More sofisticated configuration example: (setopt ellama-instant-display-action-function #'display-buffer-at-bottom) :config ;; show ellama context in header line in all buffers - (ellama-context-header-line-global-mode +1)) + (ellama-context-header-line-global-mode +1) + ;; handle scrolling events + (advice-add 'pixel-scroll-precision :before #'ellama-disable-scroll) + (advice-add 'end-of-buffer :after #'ellama-enable-scroll)) #+END_SRC ** Commands @@ -477,6 +480,8 @@ argument generated text string. - ~ellama-community-prompts-file~: Path to the CSV file containing community prompts. This file is expected to be located inside an ~ellama~ subdirectory within your ~user-emacs-directory~. +- ~ellama-show-reasoning~: Show reasoning in separate buffer if enabled. Enabled by default. +- ~ellama-reasoning-display-action-function~: Display action function for reasoning. ** Minor modes diff --git a/ellama-blueprint.el b/ellama-blueprint.el index 83d6bbfe31..10d1c58fd2 100644 --- a/ellama-blueprint.el +++ b/ellama-blueprint.el @@ -73,22 +73,26 @@ (setq header-line-format (concat (propertize - (substitute-command-keys - "`\\[ellama-transient-blueprint-mode-menu]' to continue") + (concat (propertize + (substitute-command-keys + "`\\[ellama-transient-blueprint-mode-menu]'") + 'face 'ellama-key-face) + " to continue") 'help-echo "mouse-1: show menu" 'mouse-face 'header-line-format - 'face 'ellama-context-line-face 'keymap (let ((m (make-sparse-keymap))) (define-key m [header-line mouse-1] #'ellama-transient-blueprint-mode-menu) (define-key m [mode-line mouse-1] #'ellama-transient-blueprint-mode-menu) m)) " " (propertize - (substitute-command-keys - "`\\[ellama-kill-current-buffer]' to cancel") + (concat (propertize + (substitute-command-keys + "`\\[ellama-kill-current-buffer]'") + 'face 'ellama-key-face) + " to cancel") 'help-echo "mouse-1: kill buffer" 'mouse-face 'header-line-format - 'face 'ellama-context-line-face 'keymap (let ((m (make-sparse-keymap))) (define-key m [header-line mouse-1] #'ellama-kill-current-buffer) (define-key m [mode-line mouse-1] #'ellama-kill-current-buffer) diff --git a/ellama-context.el b/ellama-context.el index 5d3390120e..76dc55f318 100644 --- a/ellama-context.el +++ b/ellama-context.el @@ -55,6 +55,10 @@ "Face for ellama context line." :group 'ellama) +(defface ellama-key-face '((t (:inherit help-key-binding))) + "Face for ellama context line." + :group 'ellama) + (defvar ellama-context-global nil "Global context.") @@ -242,8 +246,10 @@ the context." :keymap ellama-context-preview-mode-map :group 'ellama (setq header-line-format - (substitute-command-keys - "`\\[ellama-kill-current-buffer]' to quit"))) + (concat (propertize (substitute-command-keys + "`\\[ellama-kill-current-buffer]'") + 'face 'ellama-key-face) + " to quit"))) (defcustom ellama-context-preview-element-display-action-function nil "Display action function for `ellama-context-preview-element'." diff --git a/ellama.el b/ellama.el index 653b76cd4a..4d086f31a4 100644 --- a/ellama.el +++ b/ellama.el @@ -5,8 +5,8 @@ ;; Author: Sergey Kostyaev <sskosty...@gmail.com> ;; URL: http://github.com/s-kostyaev/ellama ;; Keywords: help local tools -;; Package-Requires: ((emacs "28.1") (llm "0.22.0") (plz "0.8") (transient "0.7") (compat "29.1")) -;; Version: 1.5.6 +;; Package-Requires: ((emacs "28.1") (llm "0.24.0") (plz "0.8") (transient "0.7") (compat "29.1")) +;; Version: 1.6.0 ;; SPDX-License-Identifier: GPL-3.0-or-later ;; Created: 8th Oct 2023 @@ -502,6 +502,16 @@ It should be a function with single argument generated text string." :group 'ellama :type 'function) +(defcustom ellama-reasoning-display-action-function nil + "Display action function for reasoning." + :group 'ellama + :type 'function) + +(defcustom ellama-show-reasoning t + "Show reasoning in separate buffer if enabled." + :group 'ellama + :type 'boolean) + (define-minor-mode ellama-session-mode "Minor mode for ellama session buffers." :interactive nil @@ -1159,6 +1169,120 @@ Otherwire return current active session." (defvar ellama-global-system nil) +(defvar-local ellama--stop-scroll nil) + +;;;###autoload +(defun ellama-disable-scroll (&rest event) + "Disable auto scroll. +EVENT is an argument for mweel scroll." + (declare-function mwheel-event-window "mwheel") + (with-current-buffer + (window-buffer + (if (windowp (caadar event)) + (caadar event) + (mwheel-event-window event))) + (setq ellama--stop-scroll t))) + +;;;###autoload +(defun ellama-enable-scroll (&rest _) + "Enable auto scroll." + (setq ellama--stop-scroll nil)) + +(defun ellama-max-common-prefix (s1 s2) + "Return the maximum common prefix of strings S1 and S2." + (let ((i 0) + (min-length (min (length s1) (length s2)))) + (while (and (< i min-length) + (eq (aref s1 i) (aref s2 i))) + (setq i (1+ i))) + (substring s1 0 i))) + +(defun ellama--string-without-last-line (s) + "Remove last line from string S." + (string-join + (reverse (cdr (reverse (string-lines + s)))) + "\n")) + +(defun ellama--insert (buffer point filter) + "Insert text during streaming. + +Works inside BUFFER starting at POINT. +If POINT is nil, current point will be used. +FILTER is a function for text transformation." + (with-current-buffer + buffer + (let* ((end-marker (make-marker)) + (previous-filtered-text "") + (safe-common-prefix "")) + (set-marker end-marker (or point (point))) + (set-marker-insertion-type end-marker t) + (lambda + (text) + (with-current-buffer buffer + (save-excursion + (goto-char end-marker) + (let* ((filtered-text + (funcall filter text)) + (common-prefix (concat + safe-common-prefix + (ellama-max-common-prefix + (string-remove-prefix + safe-common-prefix + filtered-text) + (string-remove-prefix + safe-common-prefix + previous-filtered-text)))) + (wrong-chars-cnt (- (length previous-filtered-text) + (length common-prefix))) + (delta (string-remove-prefix common-prefix filtered-text))) + (delete-char (- wrong-chars-cnt)) + (insert delta) + (when (and + (not (eq major-mode 'org-mode)) + ellama-fill-paragraphs + (pcase ellama-fill-paragraphs + ((cl-type function) (funcall ellama-fill-paragraphs)) + ((cl-type boolean) ellama-fill-paragraphs) + ((cl-type list) (and (apply #'derived-mode-p + ellama-fill-paragraphs))))) + (fill-paragraph)) + (set-marker end-marker (point)) + (when (and ellama-auto-scroll (not ellama--stop-scroll)) + (ellama--scroll buffer end-marker)) + (setq safe-common-prefix (ellama--string-without-last-line common-prefix)) + (setq previous-filtered-text filtered-text)))))))) + +(defun ellama--handle-partial (insert-text insert-reasoning reasoning-buffer) + "Handle partial llm callback. +INSERT-TEXT is a function for text insertion. +INSERT-REASONING is a function for reasoning insertion. +REASONING-BUFFER is a buffer for reasoning." + (lambda (response) + (let ((text (plist-get response :text)) + (reasoning (plist-get response :reasoning))) + (funcall + insert-text + (concat + (when reasoning + (if + (or (not ellama-output-remove-reasoning) + ellama--current-session) + (concat "<think>\n" reasoning) + (progn + (with-current-buffer reasoning-buffer + (funcall insert-reasoning reasoning) + (when ellama-show-reasoning + (display-buffer + reasoning-buffer + (when ellama-reasoning-display-action-function + `((ignore . (,ellama-reasoning-display-action-function))))))) + nil))) + (when text + (if (and reasoning ellama--current-session) + (concat "</think>\n" (string-trim text)) + (string-trim text)))))))) + (defun ellama-stream (prompt &rest args) "Query ellama for PROMPT. ARGS contains keys for fine control. @@ -1204,6 +1328,8 @@ failure (with BUFFER current). (when (ellama-session-p session) (ellama-get-session-buffer (ellama-session-id session))) (current-buffer))) + (reasoning-buffer (get-buffer-create + (concat (make-temp-name "*ellama-reasoning-") "*"))) (point (or (plist-get args :point) (with-current-buffer buffer (point)))) (filter (or (plist-get args :filter) #'identity)) @@ -1227,103 +1353,57 @@ failure (with BUFFER current). (ellama-session-prompt session)) (setf (ellama-session-prompt session) (llm-make-chat-prompt prompt-with-ctx :context system))) - (llm-make-chat-prompt prompt-with-ctx :context system))) - (stop-scroll)) + (llm-make-chat-prompt prompt-with-ctx :context system)))) + (with-current-buffer reasoning-buffer + (org-mode)) (with-current-buffer buffer (ellama-request-mode +1) - (let* ((start (make-marker)) - (end (make-marker)) - (distance-to-end (- (point-max) (point))) - (new-pt) - (insert-text - (lambda (text) - ;; Erase and insert the new text between the marker cons. - (with-current-buffer buffer - ;; Manually save/restore point as save-excursion doesn't - ;; restore the point into the middle of replaced text. - (let* ((pt (point)) - (new-distance-to-end (- (point-max) (point)))) - (save-excursion - (if (and (eq (window-buffer (selected-window)) - buffer) - (not (equal distance-to-end new-distance-to-end))) - (setq stop-scroll t) - (setq stop-scroll nil)) - (goto-char start) - (delete-region start end) - (insert (funcall filter text)) - (when (and ellama-fill-paragraphs - (pcase ellama-fill-paragraphs - ((cl-type function) (funcall ellama-fill-paragraphs)) - ((cl-type boolean) ellama-fill-paragraphs) - ((cl-type list) (and (apply #'derived-mode-p - ellama-fill-paragraphs) - (not (equal major-mode 'org-mode)))))) - (fill-region start (point))) - (setq new-pt (point))) - (if (and ellama-auto-scroll (not stop-scroll)) - (ellama--scroll buffer new-pt) - (goto-char pt))) - (undo-amalgamate-change-group ellama--change-group))))) + (let* ((insert-text + (ellama--insert buffer point filter)) + (insert-reasoning + (ellama--insert reasoning-buffer nil #'ellama--translate-markdown-to-org-filter))) (setq ellama--change-group (prepare-change-group)) (activate-change-group ellama--change-group) - (ellama-set-markers start end point) (when ellama-spinner-enabled (require 'spinner) (spinner-start ellama-spinner-type)) - (let ((request (llm-chat-streaming - provider - llm-prompt - insert-text - (lambda (text) - (funcall insert-text - (string-trim - (if (and ellama-output-remove-reasoning - (not session)) - (ellama-remove-reasoning text) - text))) - (with-current-buffer buffer - (accept-change-group ellama--change-group) - (when ellama-spinner-enabled - (spinner-stop)) - (if (and (listp donecb) - (functionp (car donecb))) - (mapc (lambda (fn) (funcall fn text)) - donecb) - (funcall donecb text)) - (when ellama-session-hide-org-quotes - (ellama-collapse-org-quotes)) - (when (and ellama--current-session - ellama-session-remove-reasoning) - (mapc (lambda (interaction) - (setf (llm-chat-prompt-interaction-content - interaction) - (ellama-remove-reasoning - (llm-chat-prompt-interaction-content - interaction)))) - (llm-chat-prompt-interactions - (ellama-session-prompt - ellama--current-session)))) - (setq ellama--current-request nil) - (ellama-request-mode -1))) - (lambda (_ msg) - (with-current-buffer buffer - (cancel-change-group ellama--change-group) - (when ellama-spinner-enabled - (spinner-stop)) - (funcall errcb msg) - (setq ellama--current-request nil) - (ellama-request-mode -1)))))) + (let* ((handler (ellama--handle-partial insert-text insert-reasoning reasoning-buffer)) + (request (llm-chat-streaming + provider + llm-prompt + handler + (lambda (response) + (let ((text (plist-get response :text)) + (reasoning (plist-get response :reasoning))) + (funcall handler response) + (when (or ellama--current-session + (not reasoning)) + (kill-buffer reasoning-buffer)) + (with-current-buffer buffer + (accept-change-group ellama--change-group) + (when ellama-spinner-enabled + (spinner-stop)) + (if (and (listp donecb) + (functionp (car donecb))) + (mapc (lambda (fn) (funcall fn text)) + donecb) + (funcall donecb text)) + (when ellama-session-hide-org-quotes + (ellama-collapse-org-quotes)) + (setq ellama--current-request nil) + (ellama-request-mode -1)))) + (lambda (_ msg) + (with-current-buffer buffer + (cancel-change-group ellama--change-group) + (when ellama-spinner-enabled + (spinner-stop)) + (funcall errcb msg) + (setq ellama--current-request nil) + (ellama-request-mode -1))) + t))) (with-current-buffer buffer (setq ellama--current-request request))))))) -(defun ellama-set-markers (start end point) - "Set markers for START and END positions at POINT." - (set-marker start point) - (set-marker end point) - (set-marker-insertion-type start nil) - (set-marker-insertion-type end t)) - (defun ellama-chain (initial-prompt forms &optional acc) "Call chain of FORMS on INITIAL-PROMPT. ACC will collect responses in reverse order (previous answer will be on top). diff --git a/tests/test-ellama.el b/tests/test-ellama.el index 022c0605b0..ddb1a04555 100644 --- a/tests/test-ellama.el +++ b/tests/test-ellama.el @@ -37,15 +37,17 @@ (ert-deftest test-ellama-code-improve () (let ((original "(hello)\n") - (improved "```lisp\n(hello)\n```")) + (improved "```lisp\n(hello)\n```") + prev-lines) (with-temp-buffer (insert original) (cl-letf (((symbol-function 'llm-chat-streaming) - (lambda (_provider prompt partial-callback response-callback _error-callback) + (lambda (_provider prompt partial-callback response-callback _error-callback _multi-output) (should (string-match original (llm-chat-prompt-to-text prompt))) - (cl-loop for i from 0 to (- (length improved) 1) - do (funcall partial-callback (substring improved 0 i))) - (funcall response-callback improved)))) + (dolist (s (string-lines improved)) + (funcall partial-callback `(:text ,(concat prev-lines s))) + (setq prev-lines (concat prev-lines s))) + (funcall response-callback `(:text ,improved))))) (ellama-code-improve) (should (equal original (buffer-string))))))) @@ -435,6 +437,31 @@ _more italic_"))) $P_\\theta$ /more italic/")))) +(defun ellama-test-max-common-prefix () + "Test the `ellama-max-common-prefix` function." + (should (equal (ellama-max-common-prefix "" "") "")) + (should (equal (ellama-max-common-prefix "abc" "abcd") "abc")) + (should (equal (ellama-max-common-prefix "abcd" "abc") "abc")) + (should (equal (ellama-max-common-prefix "abcdef" "abcefg") "abc")) + (should (equal (ellama-max-common-prefix "a" "b") "")) + (should (equal (ellama-max-common-prefix "a" "") "")) + (should (equal (ellama-max-common-prefix "" "b") ""))) + +(ert-deftest ellama-test-max-common-prefix () + "Run the tests for `ellama-max-common-prefix`." + (ellama-test-max-common-prefix)) + +(ert-deftest ellama--string-without-last-line-test () + "Test `ellama--string-without-last-line` function." + (should (equal (ellama--string-without-last-line "Line1\nLine2\nLine3") + "Line1\nLine2")) + (should (equal (ellama--string-without-last-line "SingleLine") + "")) + (should (equal (ellama--string-without-last-line "") + "")) + (should (equal (ellama--string-without-last-line "Line1\nLine2") + "Line1"))) + (provide 'test-ellama) ;;; test-ellama.el ends here