dotfiles/.emacs.d/modules/sqrt-ai.el

430 lines
16 KiB
EmacsLisp
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;; -*- lexical-binding: t -*-
(use-package gptel
:straight t
:if (not my/is-termux)
:init
(my-leader-def
:infix "ai"
"" '(:wk "AI")
"i" #'gptel)
:commands (gptel gptel-send gptel-menu)
:config
(setq gptel-mode "llama3:latest")
(setq gptel-track-media t)
(setq gptel-backend
(gptel-make-ollama "Ollama"
:host "localhost:11434"
:stream t
:models '("llama3.1:8b" "deepseek-r1:32b"
"qwen2.5:32b" "qwen2.5-coder:32b"
"qwen3:30b" "qwen3:32b"
"eva-qwen2.5-q4_k_l-32b:latest"
"gpt-oss"
(gemma3:27b
:capabilities (media)
:mime-types ("image/jpeg" "image/png")))))
(gptel-make-openai "OpenRouter"
:host "openrouter.ai/api"
:key (lambda () (my/password-store-get-field
"My_Online/Accounts/openrouter" "api-key"))
:stream t
:models '("anthropic/claude-sonnet-4.5"
"qwen/qwen3-coder"
"qwen/qwen3-coder:free"))
(setq gptel--known-backends
(seq-filter
(lambda (cell)
(not (equal (car cell) "ChatGPT")))
gptel--known-backends))
(setq gptel-response-prefix-alist
'((markdown-mode . "[Response] ")
(org-mode . "*** Response: ")
(text-mode . "[Response]")))
(general-define-key
:keymaps '(gptel-mode-map)
:states '(insert normal)
"C-<return>" 'gptel-send
"M-o" #'gptel-menu))
(defun my/gptel-add-current-file ()
(interactive)
(gptel-context-add-file (buffer-file-name)))
(use-package ellama
:straight t
:init
(setq ellama-language "English")
:defer t
:config
(require 'llm-ollama)
;; I've looked for this option for 1.5 hours
(setq ellama-long-lines-length 100000)
(setq ellama-provider (make-llm-ollama
:chat-model "qwen2.5:32b"
:embedding-model "qwen2.5:32b"))
(setq ellama-coding-provider (make-llm-ollama
:chat-model "qwen2.5-coder:32b"
:embedding-model "qwen2.5-coder:32b"))
(setq ellama-providers
`(("llama3.1:8b" . ,(make-llm-ollama
:chat-model "llama3.1:latest"
:embedding-model "llama3.1:latest"))
("phi4:latest" . ,(make-llm-ollama
:chat-model "phi4:latest"
:embedding-model "phi4:latest"))
("qwen2.5:32b" . ,(make-llm-ollama
:chat-model "qwen2.5:32b"
:embedding-model "qwen2.5:32b"))
("qwen2.5-coder:32b" . ,(make-llm-ollama
:chat-model "qwen2.5-coder:32b"
:embedding-model "qwen2.5-coder:32b")))))
(with-eval-after-load 'ellama
(transient-define-prefix my/ellama-transient ()
"Ellama actions."
["General"
:class transient-row
("a" "Chat" ellama-chat)]
["Code"
:class transient-row
("ca" "Add" ellama-code-add)
("cc" "Complete" ellama-code-complete)
("ce" "Edit" ellama-code-edit)
("cr" "Review" ellama-code-review)
("ci" "Improve" ellama-code-improve)]
["Natural Language"
:class transient-row
("np" "Proof-read" my/ellama-proof-read)]
["Formatting"
:class transient-row
("ff" "Format" ellama-make-format)
("fm" "List" ellama-make-list)
("ft" "Table" ellama-make-table)]
["Explain & Summarize"
:class transient-row
("es" "Summarize" ellama-summarize)
("ea" "Ask about" ellama-ask-about)
("es" "Send to chat" ellama-ask-selection)
("ew" "Word definition" ellama-define-word)]
["Context"
:class transient-row
("xb" "Add buffer" ellama-context-add-buffer)
("xf" "Add file" ellama-context-add-file)
("xi" "Add info" ellama-context-add-info-node)
("xs" "Add selection" ellama-context-add-selection)]
["Settings & Sessions"
:class transient-row
("sp" "Provider" ellama-provider-select)
("ss" "Session" ellama-session-switch)
("sr" "Rename ression" ellama-session-rename)
("sd" "Delete session" ellama-session-remove)]))
(defun my/ellama ()
(interactive)
(require 'ellama)
(call-interactively #'my/ellama-transient))
(my-leader-def "aie" #'my/ellama)
(defun my/diff-strings (str1 str2)
(let ((file1 (make-temp-file "diff1"))
(file2 (make-temp-file "diff2")))
(unwind-protect
(progn
(with-temp-file file1
(insert str1))
(with-temp-file file2
(insert str2))
(with-temp-buffer
(diff-mode)
(diff-no-select file1 file2 (diff-switches) t (current-buffer))
(font-lock-fontify-buffer)
(buffer-string)))
(delete-file file1)
(delete-file file2))))
(defun my/ellama-proof-read--display (text is-org-mode prompt)
(llm-chat-async
ellama-provider
(llm-make-chat-prompt
(format prompt text))
(lambda (response)
(let* ((parts (split-string response "-FIXED TEXT ENDS-"))
(changed-text (nth 0 parts))
(comments (nth 1 parts))
(buffer (generate-new-buffer "*ellama-diff*")))
(when is-org-mode
(setq changed-text (ellama--translate-markdown-to-org-filter changed-text)))
(with-current-buffer buffer
(text-mode)
(insert
(propertize "Changed text:\n" 'face 'transient-heading)
(string-trim changed-text)
"\n\n"
(propertize "Comments:\n" 'face 'transient-heading)
(string-trim comments)
"\n\n"
(propertize "Diff:\n" 'face 'transient-heading)
(my/diff-strings text changed-text)))
(display-buffer buffer)))
(lambda (&rest err)
(message "Error: %s" err))))
(setq my/ellama-proof-read-prompt
"Proof-read the following text. Follow these rules:
- Fix all grammar errors
- Keep the original style and punctuation, including linebreaks.
- Use British spelling
- Do not replace ' with , and do not touch other such symbols
Output the following and nothing else:
- The fixed text
- The string -FIXED TEXT ENDS-
- List of found errors
- List of style suggestions
%s")
(defun my/ellama--text ()
(if (region-active-p)
(buffer-substring-no-properties (region-beginning) (region-end))
(buffer-substring-no-properties (point-min) (point-max))))
(defun my/ellama-proof-read (text is-org-mode)
(interactive (list (my/ellama--text) (derived-mode-p 'org-mode)))
(require 'ellama)
(my/ellama-proof-read--display text is-org-mode my/ellama-proof-read-prompt))
(defun my/whisper--format-vtt-seconds (seconds)
(if (numberp seconds)
(let* ((hours (/ (floor seconds) (* 60 60)))
(minutes (/ (- (floor seconds) (* hours 60 60)) 60))
(sec (% (floor seconds) 60))
(ms (floor (* 1000 (- seconds (floor seconds))))))
(format "%.2d:%.2d:%.2d.%.3d" hours minutes sec ms))
""))
(defun my/whisper--save-chucks-vtt (path data)
(with-temp-file path
(insert "WEBVTT\n\n")
(cl-loop for chunk across (alist-get 'chunks data)
for start = (my/whisper--format-vtt-seconds
(aref (alist-get 'timestamp chunk) 0))
for end = (my/whisper--format-vtt-seconds
(aref (alist-get 'timestamp chunk) 1))
do (insert (format "%s --> %s" start end) "\n")
do (insert (string-trim (alist-get 'text chunk)) "\n\n"))))
(defun my/whisper--save-speakers-vtt (path data)
(with-temp-file path
(insert "WEBVTT\n\n")
(cl-loop for chunk across (alist-get 'speakers data)
for start = (my/whisper--format-vtt-seconds
(aref (alist-get 'timestamp chunk) 0))
for end = (my/whisper--format-vtt-seconds
(aref (alist-get 'timestamp chunk) 1))
do (insert (format "%s --> %s" start end) "\n")
do (insert
(format "<v %s>" (alist-get 'speaker chunk))
(string-trim (alist-get 'text chunk)) "\n\n"))))
(defun my/whisper--save-speakers-txt (path data)
(with-temp-file path
(cl-loop with prev-speaker
for chunk across (alist-get 'speakers data)
for speaker = (alist-get 'speaker chunk)
if (not (equal speaker prev-speaker))
do (progn
(when prev-speaker
(fill-region
(line-beginning-position)
(line-end-position))
(insert "\n\n"))
(insert (format "[%s]" speaker) "\n")
(setq prev-speaker speaker))
do (insert (string-trim (alist-get 'text chunk)) " "))
(fill-region
(line-beginning-position)
(line-end-position))))
(defun my/whisper--process-output (transcript-path)
(let ((data (json-read-file transcript-path)))
(when (alist-get 'text data)
(with-temp-file (concat
(file-name-sans-extension transcript-path)
".txt")
(insert (string-trim (alist-get 'text data)))
(do-auto-fill)))
(unless (seq-empty-p (alist-get 'speakers data))
(my/whisper--save-speakers-vtt
(concat (file-name-sans-extension transcript-path) "-spk.vtt")
data)
(my/whisper--save-speakers-txt
(concat (file-name-sans-extension transcript-path) "-spk.txt")
data))
(my/whisper--save-chucks-vtt
(concat (file-name-sans-extension transcript-path) ".vtt")
data)))
(defvar my/whisper-path
"/home/pavel/micromamba/envs/insanely-fast-whisper/bin/insanely-fast-whisper")
(defun my/invoke-whisper (input output-dir &optional language num-speakers)
(interactive
(list
(read-file-name "Input file:" nil nil t)
(read-directory-name "Output-directory: ")
(let ((lang (read-string "Language (optional): ")))
(if (string-empty-p lang) nil lang))
(let ((num (read-number "Number of speakers (optional): " 0)))
(when (> num 0)
(number-to-string num)))))
(let* ((transcript-path (concat
(expand-file-name (file-name-as-directory output-dir))
(file-name-base input)
".json"))
(args
`("--file-name" ,(expand-file-name input)
"--transcript-path" ,transcript-path
"--hf-token" ,(my/password-store-get-field "My_Online/Accounts/huggingface.co" "token")
,@(when language
`("--language" ,language))
,@(when num-speakers
`("--num-speakers" ,num-speakers))))
(buffer (generate-new-buffer "*whisper*"))
(proc (apply #'start-process "whisper" buffer my/whisper-path args)))
(set-process-sentinel
proc
(lambda (process _msg)
(let ((status (process-status process))
(code (process-exit-status process)))
(cond ((and (eq status 'exit) (= code 0))
(my/whisper--process-output transcript-path)
(notifications-notify :body "Audio conversion completed"
:title "Whisper")
(kill-buffer (process-buffer process)))
((or (and (eq status 'exit) (> code 0))
(eq status 'signal))
(let ((err (with-current-buffer (process-buffer process)
(buffer-string))))
(user-error "Error in Whisper: %s" err)))))))))
(with-eval-after-load 'elfeed
(defvar my/elfeed-whisper-podcast-files-directory
(concat elfeed-db-directory "/podcast-files/")))
(defun my/elfeed-whisper-get-transcript-new (entry)
(interactive (list elfeed-show-entry))
(let* ((url (caar (elfeed-entry-enclosures entry)))
(file-name (concat
(elfeed-ref-id (elfeed-entry-content entry))
"."
(file-name-extension url)))
(file-path (expand-file-name
(concat
my/elfeed-whisper-podcast-files-directory
file-name))))
(message "Download started")
(unless (file-exists-p my/elfeed-whisper-podcast-files-directory)
(mkdir my/elfeed-whisper-podcast-files-directory))
(request url
:type "GET"
:encoding 'binary
:complete
(cl-function
(lambda (&key data &allow-other-keys)
(let ((coding-system-for-write 'binary)
(write-region-annotate-functions nil)
(write-region-post-annotation-function nil))
(write-region data nil file-path nil :silent))
(message "Conversion started")
(my/invoke-whisper file-path my/elfeed-srt-dir)))
:error
(cl-function
(lambda (&key error-thrown &allow-other-keys)
(message "Error!: %S" error-thrown))))))
(defun my/elfeed-show-related-files (entry)
(interactive (list elfeed-show-entry))
(let* ((files
(mapcar
(lambda (file) (cons (file-name-extension file) file))
(seq-filter
(lambda (file)
(string-match-p
(rx bos (literal (elfeed-ref-id (elfeed-entry-content entry))) ".")
file))
(directory-files my/elfeed-srt-dir))))
(buffer
(find-file-other-window
(concat
my/elfeed-srt-dir
(alist-get
(completing-read "File: " files)
files nil nil #'equal)))))
(with-current-buffer buffer
(setq-local elfeed-show-entry entry))))
(defun my/elfeed-whisper-get-transcript (entry)
"Retrieve transcript for the enclosure of the current elfeed ENTRY."
(interactive (list elfeed-show-entry))
(let ((enclosure (caar (elfeed-entry-enclosures entry))))
(unless enclosure
(user-error "No enclosure found!"))
(let ((srt-path (concat my/elfeed-srt-dir
(elfeed-ref-id (elfeed-entry-content entry))
".srt")))
(if (file-exists-p srt-path)
(let ((buffer (find-file-other-window srt-path)))
(with-current-buffer buffer
(setq-local elfeed-show-entry entry)))
(my/elfeed-whisper-get-transcript-new entry)))))
(defun my/elfeed-whisper-subed (entry)
"Run MPV for the current Whisper-generated subtitles file.
ENTRY is an instance of `elfeed-entry'."
(interactive (list elfeed-show-entry))
(unless entry
(user-error "No entry!"))
(unless (derived-mode-p 'subed-mode)
(user-error "Not subed mode!"))
(setq-local subed-mpv-video-file
(expand-file-name
(concat my/elfeed-whisper-podcast-files-directory
(my/get-file-name-from-url
(caar (elfeed-entry-enclosures entry))))))
(subed-mpv--play subed-mpv-video-file))
(defun my/whisper-url (url file-name output-dir &optional language num-speakers)
(interactive
(list (read-from-minibuffer "URL: ")
(read-from-minibuffer "File name: ")
(read-directory-name "Output directory: ")
(let ((lang (read-string "Language (optional): ")))
(if (string-empty-p lang) nil lang))
(let ((num (read-number "Number of speakers (optional): " 0)))
(when (> num 0)
(number-to-string num)))))
(let ((file-path
(concat output-dir file-name "." (file-name-extension url))))
(message "Download started")
(request url
:type "GET"
:encoding 'binary
:complete
(cl-function
(lambda (&key data &allow-other-keys)
(let ((coding-system-for-write 'binary)
(write-region-annotate-functions nil)
(write-region-post-annotation-function nil))
(write-region data nil file-path nil :silent))
(message "Conversion started")
(my/invoke-whisper file-path output-dir language num-speakers)))
:error
(cl-function
(lambda (&key error-thrown &allow-other-keys)
(message "Error!: %S" error-thrown))))))
(provide 'sqrt-ai)