Initial commit

This commit is contained in:
Jiri Jakes 2026-03-07 00:00:51 -03:00
commit 07e5e799ea
2 changed files with 588 additions and 0 deletions

399
nano-gpt-status.el Normal file
View file

@ -0,0 +1,399 @@
;;; nano-gpt-status.el --- Dashboard for Nano-GPT API -*- lexical-binding: t; -*-
;; Copyright (C) 2024 Nano-GPT Contributors
;; Author: Nano-GPT Contributors
;; Maintainer: Nano-GPT Contributors
;; URL: https://github.com/nano-gpt/emacs
;; Version: 0.1.0
;; Package-Requires: ((emacs "28.1") (qrencode "0.1"))
;; Keywords: convenience, api, dashboard
;; This file is not part of GNU Emacs.
;; This program is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; A dashboard for the Nano-GPT API showing models, usage, balance and deposits.
;; Setup:
;; (require 'nano-gpt-status)
;; M-x nano-gpt-status
;;
;; Configure API key in auth-sources:
;; (add-to-list 'auth-sources '("nano-gpt.gpg" :host "nano-gpt.com"))
;;; Code:
(require 'url)
(require 'json)
(require 'auth-source)
(require 'org)
(require 'tabulated-list)
(require 'hl-line)
(declare-function qrencode-string "qrencode")
(defgroup nano-gpt nil
"Dashboard for Nano-GPT API."
:group 'convenience
:prefix "nano-gpt-"
:link '(url-link :tag "GitHub" "https://github.com/nano-gpt/emacs"))
(defcustom nano-gpt-api-base-url "https://nano-gpt.com/api"
"Base URL for Nano-GPT API."
:type 'string
:group 'nano-gpt)
(defcustom nano-gpt-models-endpoint "/v1/models?detailed=true"
"Endpoint for fetching models."
:type 'string
:group 'nano-gpt)
(defvar nano-gpt--window-config nil
"Saved window configuration before dashboard was shown.")
(defun nano-gpt--get-api-key ()
"Get API key from auth-source for nano-gpt.com."
(let ((secret (auth-source-search :host "nano-gpt.com" :secret t)))
(when secret
(funcall (plist-get (car secret) :secret)))))
;; --- Table 1: Fruits ---
(defvar nano-gpt-fruits-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map tabulated-list-mode-map)
(define-key map "q" #'nano-gpt-quit)
map))
(define-derived-mode nano-gpt-fruits-mode tabulated-list-mode "Fruits"
"A table of fruits."
(setq tabulated-list-format [("Fruit" 15 t) ("Color" 10 t) ("Taste" 10 t)])
(setq tabulated-list-entries
'((1 ["Apple" "Red" "Sweet"])
(2 ["Banana" "Yellow" "Sweet"])
(3 ["Lemon" "Yellow" "Sour"])
(4 ["Grape" "Purple" "Sweet"])))
(tabulated-list-init-header)
(tabulated-list-print))
;; --- Table 2: Models ---
(defvar nano-gpt--models-data nil
"Cached models data from API.")
(defun nano-gpt--aget (alist key)
"Safely get value from ALIST for KEY, returns nil if null."
(let ((val (cdr (assq key alist))))
(unless (eq val :null) val)))
(defun nano-gpt--format-context-length (num)
"Format NUM as human-readable context length."
(if (numberp num)
(cond
((>= num 1000000) (format "%.1fM" (/ num 1000000.0)))
((>= num 1000) (format "%.0fK" (/ num 1000.0)))
(t (number-to-string num)))
"N/A"))
(defun nano-gpt--format-pricing (prompt completion)
"Format pricing from PROMPT and COMPLETION values."
(if (and (numberp prompt) (numberp completion)
(eq prompt 0) (eq completion 0))
"Free"
(format "$%.2f/$%.2f"
(or prompt 0)
(or completion 0))))
(defun nano-gpt--fetch-models-callback (status)
"Callback for async model fetching. STATUS is the url-retrieve status."
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "^$" nil t)
(let* ((json (json-parse-string (buffer-substring (point) (point-max)) :object-type 'alist))
(models (cdr (assq 'data json))))
(setq nano-gpt--models-data models)
(when (get-buffer "*Dashboard:Models*")
(with-current-buffer "*Dashboard:Models*"
(nano-gpt-models-mode)))
(message "Models loaded: %d entries" (length models)))))
(defun nano-gpt--fetch-models-async ()
"Fetch models from Nano-GPT API asynchronously."
(url-retrieve
(concat nano-gpt-api-base-url nano-gpt-models-endpoint)
#'nano-gpt--fetch-models-callback
nil t t))
;; --- Usage ---
(defun nano-gpt--format-usage-time (epoch-ms)
"Format EPOCH-MS as human-readable local time."
(when epoch-ms
(format-time-string "%Y-%m-%d %H:%M" (/ epoch-ms 1000))))
(defun nano-gpt--fetch-usage-callback (status)
"Callback for async usage fetching. STATUS is the url-retrieve status."
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "^$" nil t)
(let* ((json-str (buffer-substring (point) (point-max)))
(json (json-parse-string json-str :object-type 'alist))
(usage json)
(state (nano-gpt--aget usage 'state))
(active (nano-gpt--aget usage 'active))
(limits (nano-gpt--aget usage 'limits))
(weekly (nano-gpt--aget usage 'weeklyInputTokens))
(period (nano-gpt--aget usage 'period)))
(when (get-buffer "*Dashboard:Info*")
(with-current-buffer "*Dashboard:Info*"
(let ((inhibit-read-only t))
(goto-char (point-min))
(when (re-search-forward "^\\*\\* Usage" nil t)
(delete-region (match-beginning 0) (progn (forward-line) (point)))
(insert "** Usage\n")
(insert (format "- State: %s\n" (or state "N/A")))
(insert (format "- Active: %s\n\n" (if active "Yes" "No")))
(insert "*** Weekly Input Tokens\n")
(insert (format "- Used: %s / %s\n"
(nano-gpt--aget weekly 'used)
(nano-gpt--aget limits 'weeklyInputTokens)))
(insert (format "- Remaining: %s\n" (nano-gpt--aget weekly 'remaining)))
(insert (format "- Percent: %s%%\n"
(let ((pct (nano-gpt--aget weekly 'percentUsed)))
(when pct (format "%.2f" (* pct 100))))))
(insert (format "- Resets at: %s\n\n"
(nano-gpt--format-usage-time (nano-gpt--aget weekly 'resetAt))))
(insert "*** Billing Period\n")
(insert (format "- Ends: %s\n"
(let ((end (nano-gpt--aget period 'currentPeriodEnd)))
(when end
(format-time-string "%Y-%m-%d %H:%M"
(date-to-time end))))))))))
(message "Usage loaded"))))
(defun nano-gpt--fetch-usage-async ()
"Fetch usage from Nano-GPT API asynchronously."
(let ((api-key (nano-gpt--get-api-key)))
(when api-key
(let ((url-request-extra-headers (list (cons "Authorization" (format "Bearer %s" api-key)))))
(url-retrieve
(concat nano-gpt-api-base-url "/subscription/v1/usage")
#'nano-gpt--fetch-usage-callback
nil nil t)))))
;; --- Balance ---
(defun nano-gpt--fetch-balance-callback (status)
"Callback for async balance fetching. STATUS is the url-retrieve status."
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "^$" nil t)
(let* ((json (json-parse-string (buffer-substring (point) (point-max)) :object-type 'alist))
(usd (nano-gpt--aget json 'usd_balance)))
(when (get-buffer "*Dashboard:Info*")
(with-current-buffer "*Dashboard:Info*"
(let ((inhibit-read-only t))
(goto-char (point-min))
(when (re-search-forward "^\\*\\* Balance" nil t)
(delete-region (match-beginning 0) (progn (forward-line) (point)))
(insert "** Balance\n")
(insert (format "- USD: $%s\n" (or usd "N/A")))))))
(message "Balance loaded"))))
(defun nano-gpt--fetch-balance-async ()
"Fetch balance from Nano-GPT API asynchronously."
(let ((api-key (nano-gpt--get-api-key)))
(when api-key
(let ((url-request-method "POST")
(url-request-extra-headers (list (cons "x-api-key" api-key))))
(url-retrieve
(concat nano-gpt-api-base-url "/check-balance")
#'nano-gpt--fetch-balance-callback
nil nil t)))))
;; --- Deposit ---
(defun nano-gpt--create-deposit-callback (status)
"Callback for async deposit creation. STATUS is the url-retrieve status."
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "^$" nil t)
(let* ((json-str (buffer-substring (point) (point-max)))
(json (json-parse-string json-str :object-type 'alist))
(tx-id (nano-gpt--aget json 'txId))
(amount (nano-gpt--aget json 'amount))
(address (nano-gpt--aget json 'address)))
(when (get-buffer "*Dashboard:Info*")
(with-current-buffer "*Dashboard:Info*"
(let ((inhibit-read-only t))
(goto-char (point-max))
(insert (format "\n*** Deposit Created\n"))
(insert (format "- txId: %s\n" (or tx-id "N/A")))
(insert (format "- Amount: %s BTC\n" (or amount "N/A")))
(when address
(insert (format "- Invoice (raw):\n%s\n" address))
(insert (format "- Invoice (BOLT-11):\n"))
(insert (qrencode-string address))
(insert "\n"))
(insert "\n"))
(goto-char (point-min)))
(message "Deposit created: %s" tx-id)))))
(defun nano-gpt--create-deposit-async (amount)
"Create BTC-LN deposit for AMOUNT."
(let ((api-key (nano-gpt--get-api-key)))
(when api-key
(let ((url-request-method "POST")
(url-request-extra-headers (list (cons "Authorization" (format "Bearer %s" api-key))
(cons "Content-Type" "application/json")))
(url-request-data (json-encode (list (cons 'amount amount)))))
(url-retrieve
(concat nano-gpt-api-base-url "/transaction/create/btc-ln")
#'nano-gpt--create-deposit-callback
nil nil t)))))
(defun nano-gpt--deposit-button-action (_)
"Action for deposit button."
(let* ((satoshis (read-number "Enter amount in satoshis: "))
(amount (/ satoshis 100000000.0)))
(message "Creating BTC-LN deposit for %s satoshis (%s BTC)..." satoshis amount)
(nano-gpt--create-deposit-async amount)))
(defun nano-gpt--insert-deposit-button ()
"Insert a deposit button in the current buffer."
(insert-text-button "[Deposit BTC-LN]"
'action (lambda (_) (nano-gpt--deposit-button-action 0.00001))
'follow-link t))
;; --- Models Mode ---
(defvar nano-gpt-models-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map tabulated-list-mode-map)
(define-key map "q" #'nano-gpt-quit)
(define-key map "w" #'nano-gpt-models-copy-id)
map))
(defun nano-gpt-models-copy-id ()
"Copy the ID of the current model to clipboard."
(interactive)
(when tabulated-list-entries
(let* ((entry (tabulated-list-get-entry))
(id (aref entry 1)))
(kill-new id)
(message "Copied: %s" id))))
(define-derived-mode nano-gpt-models-mode tabulated-list-mode "Models"
"A table of Nano-GPT models."
(hl-line-mode 1)
(setq tabulated-list-format
[("Name" 30 t)
("ID" 40 t)
("Created" 10 t)
("Ctx" 6 t :right-align t :pad-right 3)
("Pricing" 18 t)])
(setq tabulated-list-sort-key (cons "Created" t))
(setq tabulated-list-entries
(mapcar
(lambda (model)
(let* ((pricing (nano-gpt--aget model 'pricing))
(subscription (nano-gpt--aget model 'subscription))
(has-sub (eq (nano-gpt--aget subscription 'included) t))
(face (if has-sub '(:foreground "green") nil)))
(list (nano-gpt--aget model 'id)
(vector
(propertize (or (nano-gpt--aget model 'name) "N/A") 'font-lock-face face)
(propertize (or (nano-gpt--aget model 'id) "N/A") 'font-lock-face face)
(propertize (format-time-string "%Y-%m-%d" (nano-gpt--aget model 'created)) 'font-lock-face face)
(propertize (nano-gpt--format-context-length (nano-gpt--aget model 'context_length)) 'font-lock-face face)
(propertize (nano-gpt--format-pricing
(nano-gpt--aget pricing 'prompt)
(nano-gpt--aget pricing 'completion)) 'font-lock-face face)))))
nano-gpt--models-data))
(tabulated-list-init-header)
(tabulated-list-print))
;; --- Quit function ---
(defun nano-gpt-quit ()
"Quit the dashboard, restore previous window configuration, and kill buffers."
(interactive)
(dolist (buf (list (get-buffer "*Dashboard:Fruits*")
(get-buffer "*Dashboard:Models*")
(get-buffer "*Dashboard:Info*")))
(when buf (kill-buffer buf)))
(setq nano-gpt--models-data nil)
(when nano-gpt--window-config
(set-window-configuration nano-gpt--window-config)
(setq nano-gpt--window-config nil)))
;; --- Main entry point ---
;;;###autoload
(defun nano-gpt-status ()
"Show a simple dashboard with two tables and an org buffer."
(interactive)
(setq nano-gpt--window-config (current-window-configuration))
(let ((fruits-buf (get-buffer-create "*Dashboard:Fruits*"))
(models-buf (get-buffer-create "*Dashboard:Models*"))
(org-buf (get-buffer-create "*Dashboard:Info*")))
(with-current-buffer fruits-buf
(nano-gpt-fruits-mode))
(with-current-buffer models-buf
(nano-gpt-models-mode))
(with-current-buffer org-buf
(let ((inhibit-read-only t))
(erase-buffer)
(org-mode)
(org-indent-mode 1)
(insert "* Dashboard\n\n")
(insert "Welcome to the *dashboard*!\n\n")
(insert "** Notes\n")
(insert "- Press ~q~ in any table to quit\n")
(insert "- Table 1 :: Fruits of the world\n")
(insert "- Table 2 :: Nano-GPT Models (loading...)\n\n")
(insert "** Usage\n\n")
(insert "** Balance\n\n")
(insert "** Deposit (BTC-LN)\n")
(nano-gpt--insert-deposit-button)
(insert "\n"))
(goto-char (point-min))
(use-local-map (let ((map (copy-keymap org-mode-map)))
(define-key map "q" #'nano-gpt-quit)
map)))
(delete-other-windows)
(let* ((top-left (selected-window))
(top-right (split-window-right))
(bottom (split-window-below (/ (window-height) 2))))
(set-window-buffer top-left fruits-buf)
(set-window-buffer top-right models-buf)
(set-window-buffer bottom org-buf))
(select-window (get-buffer-window fruits-buf)))
(nano-gpt--fetch-models-async)
(nano-gpt--fetch-usage-async)
(nano-gpt--fetch-balance-async))
(provide 'nano-gpt-status)
;;; nano-gpt-status.el ends here