;; -*- lexical-binding: t; -*- (require 'json) (require 'url) (require 'subr-x) (require 'cl-lib) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; configuration ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defvar lab-ai-url "https://amorphous.tf.chiba-u.jp/lab-ai/chat") (defvar lab-ai-image-url "https://amorphous.tf.chiba-u.jp/lab-ai/image") (defvar lab-ai-image-edit-url "https://amorphous.tf.chiba-u.jp/lab-ai/image-edit") (defvar lab-ai-image-size "1024x1024") (defvar lab-ai-models '("gpt-5-mini" "gpt-5" "gpt-5.5")) (defvar lab-ai-model "gpt-5.5") (defvar lab-ai-timeout "600") (defvar lab-ai-max-attachment-size (* 50 1024 1024)) (defvar lab-ai-chat-history-max-messages 200) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defun lab-ai-read-token () (with-temp-buffer (insert-file-contents (expand-file-name "~/.config/lab-ai/token")) (string-trim (buffer-string)))) (defun lab-ai--parse-response-buffer (buf) (with-current-buffer buf (goto-char (point-min)) (let* ((json-object-type 'alist) (json-response (json-read)) (answer (cdr (assoc 'answer json-response))) (detail (cdr (assoc 'detail json-response)))) (cond (answer answer) (detail (error "lab-ai error: %s" detail)) (t (error "Invalid response: %s" (buffer-string))))))) (defun lab-ai-request-async (prompt on-success &optional on-error model attachments) (let* ((token (lab-ai-read-token)) (payload (json-encode `(("prompt" . ,prompt) ("model" . ,(or model lab-ai-model)) ("attachments" . ,(vconcat (or attachments nil)))))) (payload-bytes (encode-coding-string payload 'utf-8-unix)) (buf (generate-new-buffer " *lab-ai-curl*")) proc) (setq proc (make-process :name "lab-ai-curl" :buffer buf :connection-type 'pipe :command (list "curl" "-sS" "-f" "--max-time" lab-ai-timeout "-X" "POST" lab-ai-url "-H" "Content-Type: application/json; charset=utf-8" "-H" (concat "Authorization: Bearer " token) "--data-binary" "@-") :noquery t :sentinel (lambda (proc _event) (when (memq (process-status proc) '(exit signal)) (let ((buf (process-buffer proc))) (unwind-protect (condition-case err (if (= (process-exit-status proc) 0) (funcall on-success (lab-ai--parse-response-buffer buf)) (with-current-buffer buf (error "curl failed (%s): %s" (process-exit-status proc) (buffer-string)))) (error (if on-error (funcall on-error err) (message "Lab-AI: error: %s" (error-message-string err))))) (when (buffer-live-p buf) (kill-buffer buf)))))))) (set-process-coding-system proc 'utf-8-unix 'binary) (message "Lab-AI: payload bytes=%d" (string-bytes payload-bytes)) (process-send-string proc payload-bytes) (process-send-eof proc) proc)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; attachment helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defun lab-ai--guess-mime-type (file) (let ((ext (downcase (or (file-name-extension file) "")))) (cond ((string= ext "png") "image/png") ((member ext '("jpg" "jpeg")) "image/jpeg") ((string= ext "webp") "image/webp") ((string= ext "gif") "image/gif") ((string= ext "pdf") "application/pdf") (t "application/octet-stream")))) (defun lab-ai--file-to-base64 (file) (with-temp-buffer (set-buffer-multibyte nil) (insert-file-contents-literally file) (base64-encode-string (buffer-string) t))) ; pkg install pngpaste (defun lab-ai-chat-attach-clipboard-image () "Attach image in macOS clipboard as PNG." (interactive) (unless (derived-mode-p 'lab-ai-chat-mode) (user-error "This command is for lab-ai-chat-mode")) (unless (executable-find "pngpaste") (user-error "pngpaste not found. Install: sudo port install pngpaste")) (let* ((file (expand-file-name (format-time-string "lab-ai-clipboard-%Y%m%d-%H%M%S.png") temporary-file-directory)) (status (call-process "pngpaste" nil nil nil file))) (unless (= status 0) (when (file-exists-p file) (delete-file file)) (user-error "Clipboardに画像がありません")) (unwind-protect (let* ((mime "image/png") (name (file-name-nondirectory file)) (size (file-attribute-size (file-attributes file)))) (when (> size lab-ai-max-attachment-size) (user-error "Clipboard image is too large: %.1f MB" (/ size 1024.0 1024.0))) (push `(("filename" . ,name) ("mime_type" . ,mime) ("data_base64" . ,(lab-ai--file-to-base64 file))) lab-ai-chat--attachments) (goto-char (point-max)) (insert (format "\n[Attached clipboard image: %s]\n" name)) (lab-ai-chat-update-mode-line) (message "Lab-AI: attached clipboard image %s" name)) (when (file-exists-p file) (delete-file file))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; region helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defun lab-ai--bounds () (if (use-region-p) (cons (region-beginning) (region-end)) (save-excursion (let (b e) (backward-paragraph) (setq b (point)) (forward-paragraph) (setq e (point)) (cons b e))))) (defun lab-ai--replace-region (beg end text) (delete-region beg end) (goto-char beg) (insert text)) (defun lab-ai--dwim (system user) (let* ((bnds (lab-ai--bounds)) (beg (copy-marker (car bnds))) (end (copy-marker (cdr bnds) t)) (origin-buffer (current-buffer)) (txt (buffer-substring-no-properties beg end)) (prompt (concat system "\n\n" user "\n\n" txt))) (message "Lab-AI: sending request... model=%s" lab-ai-model) (lab-ai-request-async prompt (lambda (answer) (when (buffer-live-p origin-buffer) (with-current-buffer origin-buffer (save-excursion (lab-ai--replace-region beg end answer)) (message "Lab-AI: done. model=%s" lab-ai-model)))) (lambda (err) (message "Lab-AI: error: %s" (error-message-string err)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; translation/editing commands ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defun lab-ai--japanese-p (text) "Return non-nil if TEXT contains Japanese characters." (string-match-p "[ぁ-んァ-ン一-龥]" text)) (defun lab-ai-translate-dwim () "Translate region automatically." (interactive) (let* ((bnds (lab-ai--bounds)) (txt (buffer-substring-no-properties (car bnds) (cdr bnds)))) (cond ((string-empty-p (string-trim txt)) (user-error "No text")) ((lab-ai--japanese-p txt) (message "Lab-AI: JA -> EN") (lab-ai-ja->en-academic)) (t (message "Lab-AI: EN -> JA") (lab-ai-en->ja))))) (defun lab-ai--dwim (system user) (let* ((bnds (lab-ai--bounds)) (beg (copy-marker (car bnds))) (end (copy-marker (cdr bnds) t)) (origin-buffer (current-buffer)) (txt (buffer-substring-no-properties beg end)) (prompt (concat system "\n\n" user "\n\n" txt))) (message "Lab-AI: sending request... model=%s" lab-ai-model) (lab-ai-request-async prompt (lambda (answer) (when (buffer-live-p origin-buffer) (with-current-buffer origin-buffer (save-excursion (if buffer-read-only (progn (kill-new answer) (message "Lab-AI: done. Result copied to kill-ring.")) (lab-ai--replace-region beg end answer) (message "Lab-AI: done. model=%s" lab-ai-model)))))) (lambda (err) (message "Lab-AI: error: %s" (error-message-string err)))))) (defun lab-ai-ja->en-academic () (interactive) (lab-ai--dwim "You are a professional translator specializing in academic writing." "Translate the following Japanese into natural academic English. Output only the translation. Do not modify LaTeX or mathematics.")) (defun lab-ai-ja->ja-academic () (interactive) (lab-ai--dwim "あなたは熟練した科学技術文章の校正者です。" "次の日本語を自然で読みやすい科学技術文章に校正してください。LaTeXと数式は変更しないでください。")) (defun lab-ai-en->ja () (interactive) (lab-ai--dwim "あなたは優秀な翻訳者です。" "次の英語を自然な日本語に翻訳してください。LaTeXと数式は変更しないでください。")) (defun lab-ai-en->en () (interactive) (lab-ai--dwim "You are a professional academic editor." "Polish the following English for clarity and readability. Keep LaTeX and mathematics unchanged.")) (defun lab-ai-python-code () (interactive) (lab-ai--dwim "You are an expert Python programmer." "次の説明または未完成コードをもとに、Pythonコードを生成・修正してください。 出力はPythonコードのみ。説明文やMarkdownの```は付けないでください。 既存コードの意図を保ち、必要なら簡潔なコメントを入れてください。 1行はスペースを入れて80文字未満にすることを厳守してください。")) (defun lab-ai-mail () (interactive) (lab-ai--dwim "あなたは優秀な秘書です。" "次のメール文を自然で読みやすく校正してください。")) (defun lab-ai-bullets->paper-ja () (interactive) (lab-ai--dwim "あなたは材料科学・計算化学分野の論文執筆に精通した研究者です。" "次の実験結果またはシミュレーション結果の箇条書きをもとに、科学技術論文の本文として自然な日本語の文章にしてください。 結果を過度に誇張せず、因果関係が不明な点は断定しないでください。 LaTeX、化学式、数式、図表番号は変更しないでください。 出力は本文のみとし、説明や箇条書きは付けないでください。")) (defun lab-ai-prompt () (interactive) (let ((instruction (read-string "Prompt: "))) (lab-ai--dwim "You are a helpful assistant." instruction))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; latex preview in lab-ai-chat ;; English/math only version with file cache ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defvar-local lab-ai-chat--latex-overlays nil) (defvar lab-ai-cache-dir (expand-file-name "cache/lab-ai" user-emacs-directory)) (defvar lab-ai-image-dir (expand-file-name "images" lab-ai-cache-dir)) (unless (file-directory-p lab-ai-image-dir) (make-directory lab-ai-image-dir t)) (defvar lab-ai-chat-latex-preview-dir (expand-file-name "latex" lab-ai-cache-dir)) (unless (file-directory-p lab-ai-chat-latex-preview-dir) (make-directory lab-ai-chat-latex-preview-dir t)) (defvar lab-ai-chat-latex-preview-dpi 120) (defvar lab-ai-chat-latex-scale 1.0) (defun lab-ai-chat--call-process-to-file (program outfile &rest args) "Run PROGRAM with ARGS and save output to OUTFILE without leaving buffers." (with-temp-buffer (let ((status (apply #'call-process program nil (current-buffer) nil args))) (write-region (point-min) (point-max) outfile nil 'silent) status))) (defun lab-ai-chat-clear-latex-preview () (interactive) (mapc #'delete-overlay lab-ai-chat--latex-overlays) (setq lab-ai-chat--latex-overlays nil) (message "Lab-AI: latex previews cleared")) (defun lab-ai-chat-clear-latex-cache () (interactive) (when (file-directory-p lab-ai-chat-latex-preview-dir) (delete-directory lab-ai-chat-latex-preview-dir t)) (make-directory lab-ai-chat-latex-preview-dir t) (message "Lab-AI: latex cache cleared")) (defun lab-ai-chat--latex-cache-key (latex) (secure-hash 'sha1 (format "%s\nDPI=%s" latex lab-ai-chat-latex-preview-dpi))) (defun lab-ai-chat--latex-to-png (latex) (let* ((key (lab-ai-chat--latex-cache-key latex)) (png (expand-file-name (concat key ".png") lab-ai-chat-latex-preview-dir))) (if (file-exists-p png) png (let* ((dir (expand-file-name key lab-ai-chat-latex-preview-dir)) (_dummy (make-directory dir t)) (tex (expand-file-name "eq.tex" dir)) (dvi (expand-file-name "eq.dvi" dir)) (log (expand-file-name "latex.log" dir)) (dvipng-log (expand-file-name "dvipng.log" dir)) (default-directory dir)) (with-temp-file tex (insert "\\documentclass{article}\n" "\\usepackage[paperwidth=16cm,paperheight=3cm,margin=2mm]{geometry}\n" "\\usepackage{amsmath,amssymb}\n" "\\usepackage[version=4]{mhchem}\n" "\\usepackage{xcolor}\n" "\\pagestyle{empty}\n" "\\begin{document}\n" "\\color{white}\n" latex "\n\\end{document}\n")) (unless (= 0 (lab-ai-chat--call-process-to-file "latex" log "-interaction=nonstopmode" "eq.tex")) (error "latex failed. See %s" log)) (unless (file-exists-p dvi) (error "DVI was not generated. See %s" log)) (unless (= 0 (lab-ai-chat--call-process-to-file "dvipng" dvipng-log "-T" "tight" "-D" (number-to-string lab-ai-chat-latex-preview-dpi) "-bg" "Transparent" "-fg" "White" "-o" png dvi)) (error "dvipng failed. See %s" dvipng-log)) (unless (file-exists-p png) (error "PNG was not generated. See %s" dvipng-log)) png)))) (defun lab-ai-chat--preview-region (beg end latex) (condition-case err (let* ((png (lab-ai-chat--latex-to-png latex)) (img (create-image png 'png nil :scale lab-ai-chat-latex-scale :ascent 'center)) (ov (make-overlay beg end))) (overlay-put ov 'display img) (overlay-put ov 'lab-ai-latex-preview t) (push ov lab-ai-chat--latex-overlays)) (error (message "Lab-AI latex preview error: %s" (error-message-string err))))) (defun lab-ai-chat--preview-display-math () ;; \[ ... \] (goto-char (point-min)) (while (search-forward "\\[" nil t) (let ((beg (match-beginning 0))) (when (search-forward "\\]" nil t) (let ((end (match-end 0))) (lab-ai-chat--preview-region beg end (buffer-substring-no-properties beg end))))))) (defun lab-ai-chat-preview-latex () "Preview LaTeX equations in *Lab-AI* buffer. Run again to clear previews." (interactive) (if lab-ai-chat--latex-overlays (lab-ai-chat-clear-latex-preview) (save-excursion ;; \[ ... \] (lab-ai-chat--preview-display-math) ;; $$ ... $$ (goto-char (point-min)) (while (re-search-forward "\\$\\$\\(.\\|\n\\)*?\\$\\$" nil t) (lab-ai-chat--preview-region (match-beginning 0) (match-end 0) (match-string-no-properties 0))) ;; \( ... \) (goto-char (point-min)) (while (re-search-forward "\\\\(\\(.\\|\n\\)*?\\\\)" nil t) (lab-ai-chat--preview-region (match-beginning 0) (match-end 0) (match-string-no-properties 0)))) (message "Lab-AI: latex previews created"))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; chat mode ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defvar lab-ai-chat-buffer "*Lab-AI*") (defvar-local lab-ai-chat--busy nil) (defvar-local lab-ai-chat--timer nil) (defvar-local lab-ai-chat--start-time nil) (defvar-local lab-ai-chat--status-marker nil) (defvar-local lab-ai-chat-mode-line "") (defvar-local lab-ai-chat--attachments nil) (defvar-local lab-ai-chat--messages nil) (defvar lab-ai-chat-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "C-c C-c") #'lab-ai-chat-send) (define-key map (kbd "C-c C-a") #'lab-ai-chat-attach-file) (define-key map (kbd "C-c C-v") #'lab-ai-chat-attach-clipboard-image) (define-key map (kbd "C-c C-l") #'lab-ai-chat-preview-latex) (define-key map (kbd "C-c C-k") #'lab-ai-chat-clear-attachments) (define-key map (kbd "C-c C-h") #'lab-ai-chat-clear-history) map)) (defun lab-ai-chat--format-time (sec) (if (< sec 60) (format "%ds" sec) (format "%d:%02d" (/ sec 60) (% sec 60)))) (defun lab-ai-chat--attachment-count () (length lab-ai-chat--attachments)) (defun lab-ai-chat--add-message (role content) (setq lab-ai-chat--messages (append lab-ai-chat--messages (list `((role . ,role) (content . ,content)))))) (defun lab-ai-chat--make-history-prompt (current-prompt) (let* ((msgs (last lab-ai-chat--messages lab-ai-chat-history-max-messages)) (history (mapconcat (lambda (m) (format "%s:\n%s" (cdr (assoc 'role m)) (cdr (assoc 'content m)))) msgs "\n\n"))) (if (string-empty-p history) current-prompt (concat "以下はこれまでの会話履歴です。" "文脈を踏まえて最後のUserに答えてください。\n\n" history "\n\nUser:\n" current-prompt)))) (defun lab-ai-chat--current-input () (save-excursion (goto-char (point-max)) (unless (re-search-backward "^You:[ \t]*$" nil t) (user-error "No You: prompt found")) (forward-line 1) (string-trim (buffer-substring-no-properties (point) (point-max))))) (defun lab-ai-chat-clear-history () (interactive) (setq lab-ai-chat--messages nil) (message "Lab-AI: chat history cleared")) (defun lab-ai-chat-update-mode-line () (setq lab-ai-chat-mode-line (let ((att (lab-ai-chat--attachment-count)) (hist (length lab-ai-chat--messages))) (cond ((and lab-ai-chat--busy (numberp lab-ai-chat--start-time)) (format " LabAI[%s] ⏳ %s Attach:%d Hist:%d" lab-ai-model (lab-ai-chat--format-time (truncate (- (float-time) lab-ai-chat--start-time))) att hist)) (t (format " LabAI[%s] Attach:%d Hist:%d" lab-ai-model att hist))))) (force-mode-line-update)) (define-derived-mode lab-ai-chat-mode text-mode "Lab-AI" "Simple chat buffer for Lab-AI." (setq-local mode-line-format (append mode-line-format '((:eval lab-ai-chat-mode-line)))) (lab-ai-chat-update-mode-line)) (defun lab-ai-chat-attach-file () (interactive) (let* ((file (read-file-name "Attach image/PDF: ")) (mime (lab-ai--guess-mime-type file)) (name (file-name-nondirectory file)) (size (file-attribute-size (file-attributes file)))) (unless (or (string-prefix-p "image/" mime) (string= mime "application/pdf")) (user-error "Unsupported file type: %s" mime)) (when (> size lab-ai-max-attachment-size) (user-error "File is too large: %.1f MB" (/ size 1024.0 1024.0))) (push `(("filename" . ,name) ("mime_type" . ,mime) ("data_base64" . ,(lab-ai--file-to-base64 file))) lab-ai-chat--attachments) (goto-char (point-max)) (insert (format "\n[Attached file: %s (%s)]\n" name mime)) (lab-ai-chat-update-mode-line) (message "Lab-AI: attached file %s" name))) (defun lab-ai-chat-clear-attachments () (interactive) (setq lab-ai-chat--attachments nil) (lab-ai-chat-update-mode-line) (message "Lab-AI: attachments cleared")) (defun lab-ai-chat-update-status (buffer) (when (buffer-live-p buffer) (with-current-buffer buffer (when (and lab-ai-chat--busy lab-ai-chat--status-marker lab-ai-chat--start-time) (let* ((sec (truncate (- (float-time) lab-ai-chat--start-time))) (text (format "⏳ %s %s" lab-ai-model (lab-ai-chat--format-time sec)))) (save-excursion (goto-char lab-ai-chat--status-marker) (delete-region (line-beginning-position) (line-end-position)) (insert text)) (lab-ai-chat-update-mode-line)))))) (defun lab-ai-chat--start-timer () (setq lab-ai-chat--start-time (float-time)) (setq lab-ai-chat--status-marker (copy-marker (save-excursion (forward-line -1) (line-beginning-position)))) (lab-ai-chat-update-mode-line) (setq lab-ai-chat--timer (run-at-time 1 1 #'lab-ai-chat-update-status (current-buffer)))) (defun lab-ai-chat--stop-timer () (when lab-ai-chat--timer (cancel-timer lab-ai-chat--timer) (setq lab-ai-chat--timer nil)) (setq lab-ai-chat--start-time nil) (lab-ai-chat-update-mode-line)) (defun lab-ai-chat () (interactive) (switch-to-buffer (get-buffer-create lab-ai-chat-buffer)) (unless (derived-mode-p 'lab-ai-chat-mode) (lab-ai-chat-mode)) (lab-ai-chat-update-mode-line) (goto-char (point-max)) (when (= (point-max) 1) (insert (format "Lab-AI Chat (%s)\n\n" lab-ai-model)) (insert "[Chat]\n") (insert "C-c C-c Send prompt\n") (insert "C-c C-a Attach image/PDF file\n") (insert "C-c C-v Attach clipboard image\n") (insert "C-c C-k Clear attachments\n") (insert "C-c C-h Clear history\n") (insert "C-c C-m Switch model\n") (insert "C-c C-l Toggle LaTeX preview\n\n") (insert "[Image Generation]\n") (insert "M-x lab-ai-image\n\n") (insert "[Image Editing]\n") (insert "M-x lab-ai-image-edit\n\n") (insert "You:\n"))) (defun lab-ai-chat-send () (interactive) (when lab-ai-chat--busy (user-error "Lab-AI is already running")) (let* ((chat-buffer (current-buffer)) (user-prompt (lab-ai-chat--current-input)) (prompt (lab-ai-chat--make-history-prompt user-prompt)) (attachments (reverse lab-ai-chat--attachments))) (when (string-empty-p user-prompt) (user-error "Prompt is empty")) (setq lab-ai-chat--busy t) (goto-char (point-max)) (insert "\n\n⏳ ") (insert lab-ai-model) (insert " 0s") (when attachments (insert (format " / attachments=%d" (length attachments)))) (insert "\n") (lab-ai-chat--start-timer) (lab-ai-chat-update-mode-line) (message "Lab-AI: sending request... model=%s chars=%d attachments=%d history=%d" lab-ai-model (length user-prompt) (length attachments) (length lab-ai-chat--messages)) (lab-ai-request-async prompt (lambda (answer) (when (buffer-live-p chat-buffer) (with-current-buffer chat-buffer (lab-ai-chat--stop-timer) (setq lab-ai-chat--busy nil) (setq lab-ai-chat--attachments nil) (lab-ai-chat--add-message "User" user-prompt) (lab-ai-chat--add-message "Assistant" answer) (lab-ai-chat-update-mode-line) (goto-char (point-max)) (insert "\nAssistant:\n") (insert answer) (insert "\n\nYou:\n") (message "Lab-AI: done. model=%s history=%d" lab-ai-model (length lab-ai-chat--messages))))) (lambda (err) (when (buffer-live-p chat-buffer) (with-current-buffer chat-buffer (lab-ai-chat--stop-timer) (setq lab-ai-chat--busy nil) (setq lab-ai-chat--attachments nil) (lab-ai-chat-update-mode-line) (goto-char (point-max)) (insert (format "\n--- error: %s ---\n" (error-message-string err))))) (message "Lab-AI: error: %s" (error-message-string err))) lab-ai-model attachments))) (defun lab-ai--parse-image-response-buffer (buf) (with-current-buffer buf (goto-char (point-min)) (let* ((json-object-type 'alist) (json-response (json-read)) (image-base64 (cdr (assoc 'image_base64 json-response))) (detail (cdr (assoc 'detail json-response)))) (cond (image-base64 image-base64) (detail (error "lab-ai image error: %s" detail)) (t (error "Invalid image response: %s" (buffer-string))))))) (defun lab-ai-image-request-async (url payload on-success &optional on-error) (let* ((token (lab-ai-read-token)) (payload-json (json-encode payload)) (payload-bytes (encode-coding-string payload-json 'utf-8-unix)) (buf (generate-new-buffer " *lab-ai-image-curl*")) proc) (setq proc (make-process :name "lab-ai-image-curl" :buffer buf :connection-type 'pipe :command (list "curl" "-sS" "-f" "--max-time" lab-ai-timeout "-X" "POST" url "-H" "Content-Type: application/json; charset=utf-8" "-H" (concat "Authorization: Bearer " token) "--data-binary" "@-") :noquery t :sentinel (lambda (proc _event) (when (memq (process-status proc) '(exit signal)) (let ((buf (process-buffer proc))) (unwind-protect (condition-case err (if (= (process-exit-status proc) 0) (funcall on-success (lab-ai--parse-image-response-buffer buf)) (with-current-buffer buf (error "curl failed (%s): %s" (process-exit-status proc) (buffer-string)))) (error (if on-error (funcall on-error err) (message "Lab-AI image: error: %s" (error-message-string err))))) (when (buffer-live-p buf) (kill-buffer buf)))))))) (set-process-coding-system proc 'utf-8-unix 'binary) (process-send-string proc payload-bytes) (process-send-eof proc) proc)) (defun lab-ai--save-image-base64 (image-base64) (unless (file-directory-p lab-ai-image-dir) (make-directory lab-ai-image-dir t)) (let ((file (expand-file-name (format-time-string "lab-ai-image-%Y%m%d-%H%M%S.png") lab-ai-image-dir))) (with-temp-buffer (set-buffer-multibyte nil) (insert image-base64) (base64-decode-region (point-min) (point-max)) (write-region (point-min) (point-max) file nil 'silent)) file)) (defun lab-ai--open-image-file (file) (find-file file) (image-mode) (message "Lab-AI image saved: %s" file)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defun lab-ai-image () "Generate an image using Lab-AI Gateway." (interactive) (let ((prompt (read-string "Image prompt: "))) (when (string-empty-p (string-trim prompt)) (user-error "Prompt is empty")) (message "Lab-AI image: generating...") (lab-ai-image-request-async lab-ai-image-url `(("prompt" . ,prompt) ("size" . ,lab-ai-image-size)) (lambda (image-base64) (let ((file (lab-ai--save-image-base64 image-base64))) (lab-ai--open-image-file file))) (lambda (err) (message "Lab-AI image: error: %s" (error-message-string err)))))) (defun lab-ai-image-edit () "Edit an image file using Lab-AI Gateway." (interactive) (let* ((file (read-file-name "Image file to edit: ")) (mime (lab-ai--guess-mime-type file)) (prompt (read-string "Edit instruction: ")) (name (file-name-nondirectory file))) (unless (member mime '("image/png" "image/jpeg" "image/webp")) (user-error "Unsupported image type for editing: %s" mime)) (when (string-empty-p (string-trim prompt)) (user-error "Prompt is empty")) (message "Lab-AI image-edit: sending %s..." name) (lab-ai-image-request-async lab-ai-image-edit-url `(("prompt" . ,prompt) ("filename" . ,name) ("mime_type" . ,mime) ("data_base64" . ,(lab-ai--file-to-base64 file)) ("size" . ,lab-ai-image-size)) (lambda (image-base64) (let ((out (lab-ai--save-image-base64 image-base64))) (lab-ai--open-image-file out))) (lambda (err) (message "Lab-AI image-edit: error: %s" (error-message-string err)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; misc ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defun lab-ai-switch-model () (interactive) (setq lab-ai-model (completing-read "Model: " lab-ai-models nil t lab-ai-model)) (when (get-buffer lab-ai-chat-buffer) (with-current-buffer lab-ai-chat-buffer (lab-ai-chat-update-mode-line))) (message "lab-ai model => %s" lab-ai-model)) (defun lab-ai-show-model () (interactive) (message "Lab-AI model: %s" lab-ai-model)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Keys ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define-prefix-command 'lab-ai-map) (global-unset-key (kbd "C-c r")) (global-set-key (kbd "C-c r") 'lab-ai-map) (global-set-key (kbd "C-x t") #'lab-ai-translate-dwim) (define-key lab-ai-map (kbd "a") #'lab-ai-ja->en-academic) (define-key lab-ai-map (kbd "j") #'lab-ai-ja->ja-academic) (define-key lab-ai-map (kbd "e") #'lab-ai-en->en) (define-key lab-ai-map (kbd "t") #'lab-ai-en->ja) (define-key lab-ai-map (kbd "m") #'lab-ai-mail) (define-key lab-ai-map (kbd "i") #'lab-ai-prompt) (define-key lab-ai-map (kbd "p") #'lab-ai-python-code) (define-key lab-ai-map (kbd "s") #'lab-ai-switch-model) (define-key lab-ai-map (kbd "c") #'lab-ai-chat) (define-key lab-ai-map (kbd "b") #'lab-ai-bullets->paper-ja) (define-key lab-ai-map (kbd "M") #'lab-ai-show-model) (define-key lab-ai-map (kbd "g") #'lab-ai-image) (define-key lab-ai-map (kbd "G") #'lab-ai-image-edit) (message "lab-ai loaded: model=%s" lab-ai-model)