v0.3.3 : mode application Windows (double-clic → UI + icône de notification + splash, icône sur l'exe), catalogue de modèles servi par ajean.link (/api/catalog + détection RAM), correctif des fenêtres de console qui clignotaient sous Windows, version affichée dans l'UI

This commit is contained in:
nathaninline committed 2026-07-20 17:20:50 +02:00
1 parent 0c4ff12a00
commit cd58382d73
23 files changed
+810 -30

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
package main
// app.go — expérience « application » de Jean.
//
// Quand on double-clique sur le binaire (aucun argument, console fraîche), au
// lieu d'afficher l'aide dans une console qui se ferme, Jean démarre son UI web,
// l'ouvre dans le navigateur ET pose une icône dans la zone de notification
// Windows (voir tray_windows.go) : on voit que Jean tourne et on le pilote
// (« Ouvrir Jean » / « Quitter »).
import (
"fmt"
"net"
"net/http"
"os"
"time"
)
const appPort = 8090
// cmdApp lance l'UI web, l'ouvre dans le navigateur et fait tourner l'icône de
// la zone de notification (Windows). Sur une machine vierge, il crée d'abord le
// dossier de données + config.env pour que l'interface s'ouvre quand même
// (l'écran d'accueil prendra le relais pour le choix du modèle).
func cmdApp(args []string) error {
url := fmt.Sprintf("http://localhost:%d", appPort)
if _, err := os.Stat(confPath()); err != nil {
// Première fois : installe le minimum (dossiers + config de départ).
_ = cmdInstall(nil)
}
addr := fmt.Sprintf("0.0.0.0:%d", appPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
// Jean tourne déjà sur ce port : on ouvre juste l'UI sur l'instance
// existante plutôt que d'échouer.
fmt.Printf("Jean est déjà lancé — ouverture de %s\n", url)
return openBrowser(url)
}
// Sert l'UI en tâche de fond ; l'icône tray tient le premier plan.
go func() { _ = http.Serve(ln, newWebMux()) }()
sp := showSplash("Lancement de Jean en cours…")
waitServerReady(url)
_ = openBrowser(url)
time.Sleep(900 * time.Millisecond) // laisse le navigateur s'afficher par-dessus le splash
sp.close()
runTray(url) // icône zone de notification ; bloque jusqu'à « Quitter »
return nil
}
// waitServerReady attend que l'UI réponde (jusqu'à ~4 s) pour n'ouvrir le
// navigateur qu'une fois le serveur prêt.
func waitServerReady(url string) {
c := &http.Client{Timeout: 400 * time.Millisecond}
for i := 0; i < 20; i++ {
if resp, err := c.Get(url); err == nil {
resp.Body.Close()
return
}
time.Sleep(150 * time.Millisecond)
}
}
+78
View File
@@ -0,0 +1,78 @@
package main
// catalog.go — récupère la liste de modèles curatée servie par ajean.link et la
// combine avec les infos matérielles locales, pour que l'écran d'accueil (à
// venir) propose « en un clic » un modèle adapté à la machine.
//
// La liste vit sur https://ajean.link/models.json (éditée par l'opérateur, voir
// jean-relay/models_catalog.go). Si le réseau échoue, on retombe sur un
// catalogue minimal embarqué (fallbackCatalogJSON) pour ne jamais bloquer.
import (
"encoding/json"
"net/http"
"runtime"
"time"
)
const catalogURL = "https://ajean.link/models.json"
type catalogModel struct {
ID string `json:"id"`
Name string `json:"name"`
Params string `json:"params"`
Quant string `json:"quant"`
SizeGB float64 `json:"size_gb"`
MinRAMGB float64 `json:"min_ram_gb"`
URL string `json:"url"`
Note string `json:"note"`
}
type catalog struct {
Version int `json:"version"`
Models []catalogModel `json:"models"`
}
type hardwareInfo struct {
OS string `json:"os"`
Arch string `json:"arch"`
RAMGB float64 `json:"ram_gb"`
}
// fetchCatalog récupère le catalogue distant, avec repli embarqué.
func fetchCatalog() catalog {
var c catalog
client := &http.Client{Timeout: 8 * time.Second}
if resp, err := client.Get(catalogURL); err == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 && json.NewDecoder(resp.Body).Decode(&c) == nil && len(c.Models) > 0 {
return c
}
}
_ = json.Unmarshal([]byte(fallbackCatalogJSON), &c)
return c
}
func detectHardware() hardwareInfo {
return hardwareInfo{OS: runtime.GOOS, Arch: runtime.GOARCH, RAMGB: totalRAMGB()}
}
// handleCatalog : renvoie le catalogue + le matériel local. L'UI s'en sert pour
// marquer chaque modèle « tient / trop lourd » et proposer le bon par défaut.
func handleCatalog(w http.ResponseWriter, r *http.Request) {
resp := struct {
Hardware hardwareInfo `json:"hardware"`
Models []catalogModel `json:"models"`
}{Hardware: detectHardware(), Models: fetchCatalog().Models}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// fallbackCatalogJSON — repli minimal si ajean.link/models.json est injoignable.
const fallbackCatalogJSON = `{
"version": 1,
"models": [
{"id":"qwen2.5-3b-instruct-q4","name":"Qwen2.5 3B Instruct","params":"3B","quant":"Q4_K_M","size_gb":2.1,"min_ram_gb":6,"url":"https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf","note":"Léger et rapide — idéal petites machines."},
{"id":"qwen2.5-7b-instruct-q4","name":"Qwen2.5 7B Instruct","params":"7B","quant":"Q4_K_M","size_gb":4.7,"min_ram_gb":10,"url":"https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf","note":"Plus capable — recommandé avec 16 Go de RAM ou un GPU."}
]
}`
+9
View File
@@ -5,16 +5,25 @@ go 1.25.0
require (
github.com/caddyserver/certmagic v0.25.4
github.com/coder/websocket v1.8.15
github.com/getlantern/systray v1.2.2
github.com/hashicorp/yamux v0.1.2
golang.org/x/mod v0.35.0
)
require (
github.com/caddyserver/zerossl v0.1.5 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
github.com/mholt/acmez/v3 v3.1.6 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
+27
View File
@@ -6,10 +6,27 @@ github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7l
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
@@ -22,12 +39,19 @@ github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYq
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
@@ -52,11 +76,14 @@ golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+2 -2
View File
@@ -131,9 +131,9 @@ func detectGPUs() ([]gpuInfo, error) {
if !hasTool("nvidia-smi") {
return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA")
}
out, err := exec.Command("nvidia-smi",
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=index,name,memory.total,memory.used,compute_cap",
"--format=csv,noheader,nounits").Output()
"--format=csv,noheader,nounits")).Output()
if err != nil {
return nil, fmt.Errorf("nvidia-smi a échoué: %w", err)
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+2 -2
View File
@@ -584,7 +584,7 @@ func hasNvidiaGPU() bool {
if !hasTool("nvidia-smi") {
return false
}
out, err := exec.Command("nvidia-smi", "-L").Output()
out, err := hideCmd(exec.Command("nvidia-smi", "-L")).Output()
return err == nil && strings.Contains(string(out), "GPU")
}
@@ -593,7 +593,7 @@ func hasNvidiaGPU() bool {
// joined with ';'. Empty when the driver is too old to report it (CMake then
// falls back to native detection).
func detectCudaArch() string {
out, err := exec.Command("nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader").Output()
out, err := hideCmd(exec.Command("nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader")).Output()
if err != nil {
return ""
}
+17 -3
View File
@@ -5,8 +5,8 @@
// les .syso après avoir bumpé la version : `go generate ./...`
// (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest)
//
//go:generate goversioninfo -64 -o resource_windows_amd64.syso versioninfo.json
//go:generate goversioninfo -64 -arm -o resource_windows_arm64.syso versioninfo.json
//go:generate goversioninfo -64 -icon=icon.ico -o resource_windows_amd64.syso versioninfo.json
//go:generate goversioninfo -64 -arm -icon=icon.ico -o resource_windows_arm64.syso versioninfo.json
package main
import (
@@ -16,7 +16,7 @@ import (
"strings"
)
const Version = "0.3.2"
const Version = "0.3.3"
func main() {
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
@@ -26,12 +26,23 @@ func main() {
cleanupOldBinary()
args := os.Args[1:]
noArgs := len(args) == 0
cmd := "help"
if len(args) > 0 {
cmd = args[0]
args = args[1:]
}
// Double-clic sur le binaire (aucun argument, console fraîche) → on lance
// l'expérience « application » (UI web + navigateur) plutôt que d'afficher
// l'aide dans une console qui se referme aussitôt. Lancé depuis un shell,
// `jean` sans argument garde son comportement d'aide.
if noArgs && launchedByDoubleClick() {
relaunchDetachedApp() // relance sans console (ne revient pas), puis quitte l'original
return
}
switch cmd {
case "app":
mustExit(cmdApp(args))
case "start", "stop", "restart", "status", "enable", "disable":
mustExit(serviceAction(cmd))
case "logs":
@@ -96,6 +107,9 @@ func printHelp() {
Usage: jean <commande> [args]
Application:
app lance l'UI web + ouvre le navigateur (auto au double-clic du binaire)
Service:
start | stop | restart gérer le service (systemd sous Linux, processus en arrière-plan sous Windows)
status | logs état / logs en direct
+50
View File
@@ -8,7 +8,9 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
)
@@ -20,6 +22,54 @@ func defaultJeanHome() string { return "/etc/jean" }
// defaultEditor is used by `jean edit` when $EDITOR is unset.
func defaultEditor() string { return "nano" }
// hideCmd : no-op sur Unix (pas de fenêtre de console à masquer).
func hideCmd(cmd *exec.Cmd) *exec.Cmd { return cmd }
// openBrowser ouvre l'URL dans le navigateur par défaut (macOS: open, Linux:
// xdg-open). Best-effort.
func openBrowser(url string) error {
bin := "xdg-open"
if runtime.GOOS == "darwin" {
bin = "open"
}
return exec.Command(bin, url).Start()
}
// totalRAMGB renvoie la RAM physique totale en Go (Linux: /proc/meminfo,
// macOS: sysctl hw.memsize).
func totalRAMGB() float64 {
if runtime.GOOS == "darwin" {
out, err := exec.Command("sysctl", "-n", "hw.memsize").Output()
if err != nil {
return 0
}
if v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64); err == nil {
return v / (1024 * 1024 * 1024)
}
return 0
}
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
f := strings.Fields(line)
if len(f) >= 2 {
if kb, err := strconv.ParseFloat(f[1], 64); err == nil {
return kb / (1024 * 1024) // kB → Go
}
}
}
}
return 0
}
// launchedByDoubleClick : sur Unix, jean tourne en service/CLI, on ne déclenche
// jamais le mode app sur un simple no-arg (éviterait de surprendre un serveur).
// L'expérience app double-clic est propre à Windows/macOS (coque native, phase 3).
func launchedByDoubleClick() bool { return false }
// setLibraryPath ensures llama-server can load shared libs bundled next to the
// binary by prepending dir to LD_LIBRARY_PATH. It also appends the CUDA runtime
// lib directories: a CUDA-enabled build links against libcudart/libcublas, which
+58 -2
View File
@@ -55,6 +55,61 @@ func defaultJeanHome() string {
// defaultEditor is used by `jean edit` when $EDITOR is unset.
func defaultEditor() string { return "notepad" }
// openBrowser ouvre l'URL dans le navigateur par défaut. rundll32 évite les
// pièges de quoting de `cmd /c start`.
func openBrowser(url string) error {
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}
// hideCmd empêche une commande externe d'ouvrir une fenêtre de console
// (CREATE_NO_WINDOW). Indispensable quand Jean tourne sans console (mode app) :
// sinon chaque `nvidia-smi`/`git`/… ferait clignoter une fenêtre noire. Fusionne
// avec les flags déjà présents pour ne pas écraser un éventuel détachement.
func hideCmd(cmd *exec.Cmd) *exec.Cmd {
const createNoWindow = 0x08000000
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.HideWindow = true
cmd.SysProcAttr.CreationFlags |= createNoWindow
return cmd
}
// totalRAMGB renvoie la RAM physique totale en Go (GlobalMemoryStatusEx).
func totalRAMGB() float64 {
var m struct {
dwLength uint32
dwMemoryLoad uint32
ullTotalPhys uint64
ullAvailPhys uint64
ullTotalPageFile uint64
ullAvailPageFile uint64
ullTotalVirtual uint64
ullAvailVirtual uint64
ullAvailExtendedVirtual uint64
}
m.dwLength = uint32(unsafe.Sizeof(m))
kernel32 := syscall.NewLazyDLL("kernel32.dll")
r, _, _ := kernel32.NewProc("GlobalMemoryStatusEx").Call(uintptr(unsafe.Pointer(&m)))
if r == 0 {
return 0
}
return float64(m.ullTotalPhys) / (1024 * 1024 * 1024)
}
// launchedByDoubleClick indique qu'on a été lancé par un double-clic (Explorer)
// et non depuis un shell existant. GetConsoleProcessList renvoie le nombre de
// process attachés à la console : 1 = on possède une console fraîche (double-
// clic), >1 = on a hérité de la console d'un shell (cmd/PowerShell), auquel cas
// l'utilisateur veut la CLI, pas l'app.
func launchedByDoubleClick() bool {
kernel32 := syscall.NewLazyDLL("kernel32.dll")
proc := kernel32.NewProc("GetConsoleProcessList")
var pids [4]uint32
r, _, _ := proc.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
return r == 1
}
// setLibraryPath ensures llama-server can load its dependent DLLs. Windows
// resolves them via PATH (and the binary's own directory), so we prepend dir.
// For a CUDA build we must also add the CUDA Toolkit's bin: ggml-cuda.dll links
@@ -256,7 +311,8 @@ func cudaPathEnv(toolkitDir string) []string {
return out
}
// newShellCmd builds the command used by the run_shell tool.
// newShellCmd builds the command used by the run_shell tool. hideCmd évite un
// flash de console à chaque commande d'agent quand Jean tourne en mode app.
func newShellCmd(ctx context.Context, command string) *exec.Cmd {
return exec.CommandContext(ctx, "cmd", "/C", command)
return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package main
// proc_other.go — hors Windows, pas de détachement (le mode app double-clic est
// propre à Windows). Défini pour la compilation ; non appelé en pratique car
// launchedByDoubleClick renvoie false sur Unix.
func relaunchDetachedApp() { _ = cmdApp(nil) }
+39
View File
@@ -0,0 +1,39 @@
//go:build windows
package main
// proc_windows.go — au double-clic, on relance Jean en processus DÉTACHÉ, sans
// console. L'original (qui, lui, possède la console fraîche créée par Explorer)
// se termine aussitôt, fermant sa console. Le nouveau process n'a aucune fenêtre
// de console : il n'y a donc plus de « terminal noir » et plus rien à fermer qui
// couperait le serveur. Seuls restent le splash puis l'icône de la zone de
// notification.
import (
"os"
"os/exec"
"syscall"
)
func relaunchDetachedApp() {
const (
detachedProcess = 0x00000008
createNoWindow = 0x08000000
createNewProcGrp = 0x00000200
)
exe, err := os.Executable()
if err != nil {
_ = cmdApp(nil) // repli : on lance quand même en place
return
}
cmd := exec.Command(exe, "app")
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: detachedProcess | createNoWindow | createNewProcGrp,
}
if err := cmd.Start(); err != nil {
_ = cmdApp(nil)
return
}
os.Exit(0) // ferme l'original → sa console disparaît ; le détaché continue
}
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -36,9 +36,9 @@ func editConfig() error {
// nvidia-smi is available on both Linux and Windows when an NVIDIA driver is
// installed, so this is platform-neutral.
func showVram() error {
out, err := exec.Command("nvidia-smi",
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits").Output()
"--format=csv,noheader,nounits")).Output()
if err != nil {
return fmt.Errorf("nvidia-smi indisponible: %w", err)
}
+22 -8
View File
@@ -12,6 +12,7 @@ import (
"strings"
"syscall"
"time"
"unsafe"
)
// On Windows there is no systemd, so "the service" is a background copy of
@@ -108,7 +109,7 @@ func svcStop(verbose bool) error {
return nil
}
// taskkill /T tue aussi le processus enfant llama-server.
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
cmd := hideCmd(exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F"))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("arrêt du PID %d: %w\n%s", pid, err, string(out))
@@ -175,18 +176,31 @@ func readServicePID() int {
return pid
}
// processAlive reports whether a PID is currently running, via tasklist (always
// present on Windows, needs no special privileges).
// processAlive reports whether a PID is currently running. On utilise l'API
// Win32 (OpenProcess + GetExitCodeProcess) plutôt que `tasklist` : l'ancien
// appel externe faisait CLIGNOTER une fenêtre de console à CHAQUE vérification,
// or l'UI web poll le statut en boucle → rafale de fenêtres noires. L'API ne
// lance aucun process.
func processAlive(pid int) bool {
out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH").Output()
if err != nil {
if pid <= 0 {
return false
}
s := string(out)
if strings.Contains(s, "No tasks") {
const (
queryLimitedInfo = 0x1000
stillActive = 259
)
k := syscall.NewLazyDLL("kernel32.dll")
h, _, _ := k.NewProc("OpenProcess").Call(queryLimitedInfo, 0, uintptr(pid))
if h == 0 {
return false
}
return strings.Contains(s, strconv.Itoa(pid))
defer k.NewProc("CloseHandle").Call(h)
var code uint32
r, _, _ := k.NewProc("GetExitCodeProcess").Call(h, uintptr(unsafe.Pointer(&code)))
if r == 0 {
return false
}
return code == stillActive
}
// tailFile returns the last n lines of the file at path (best-effort).
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package main
// splash_other.go — pas de fenêtre de démarrage hors Windows (no-op).
type splash struct{}
func showSplash(text string) *splash { return &splash{} }
func (s *splash) close() {}
+271
View File
@@ -0,0 +1,271 @@
//go:build windows
package main
// splash_windows.go — écran de démarrage « Lancement de Jean… ».
//
// Petite fenêtre sans bordure, à coins arrondis, aux couleurs de la marque
// (fond bleu #1f6feb, logo « j » blanc, texte blanc), affichée le temps que le
// serveur monte et que le navigateur s'ouvre. Rendu GDI (pur syscall, aucun
// CGO). Elle tourne sur son propre thread avec sa boucle de messages ; close()
// lui envoie WM_CLOSE.
import (
"runtime"
"sync"
"syscall"
"unsafe"
)
const (
splashW = 440
splashH = 150
)
var (
u32s = syscall.NewLazyDLL("user32.dll")
g32s = syscall.NewLazyDLL("gdi32.dll")
k32s = syscall.NewLazyDLL("kernel32.dll")
pRegisterClassExW = u32s.NewProc("RegisterClassExW")
pCreateWindowExW = u32s.NewProc("CreateWindowExW")
pDefWindowProcW = u32s.NewProc("DefWindowProcW")
pShowWindow = u32s.NewProc("ShowWindow")
pUpdateWindow = u32s.NewProc("UpdateWindow")
pGetMessageW = u32s.NewProc("GetMessageW")
pTranslateMessage = u32s.NewProc("TranslateMessage")
pDispatchMessageW = u32s.NewProc("DispatchMessageW")
pPostQuitMessage = u32s.NewProc("PostQuitMessage")
pDestroyWindow = u32s.NewProc("DestroyWindow")
pPostMessageW = u32s.NewProc("PostMessageW")
pBeginPaint = u32s.NewProc("BeginPaint")
pEndPaint = u32s.NewProc("EndPaint")
pFillRect = u32s.NewProc("FillRect")
pDrawTextW = u32s.NewProc("DrawTextW")
pGetSystemMetrics = u32s.NewProc("GetSystemMetrics")
pLoadCursorW = u32s.NewProc("LoadCursorW")
pSetWindowRgn = u32s.NewProc("SetWindowRgn")
pCreateSolidBrush = g32s.NewProc("CreateSolidBrush")
pDeleteObject = g32s.NewProc("DeleteObject")
pCreateFontW = g32s.NewProc("CreateFontW")
pSelectObject = g32s.NewProc("SelectObject")
pSetTextColor = g32s.NewProc("SetTextColor")
pSetBkMode = g32s.NewProc("SetBkMode")
pCreateRoundRectRgn = g32s.NewProc("CreateRoundRectRgn")
pGetModuleHandleW = k32s.NewProc("GetModuleHandleW")
splashClassOnce sync.Once
splashProc = syscall.NewCallback(splashWndProc)
)
type rect struct{ left, top, right, bottom int32 }
type point struct{ x, y int32 }
type msg struct {
hwnd uintptr
message uint32
wParam uintptr
lParam uintptr
time uint32
pt point
}
type paintStruct struct {
hdc uintptr
fErase int32
rcPaint rect
fRestore int32
fIncUpdate int32
rgbReserved [32]byte
}
type wndClassExW struct {
cbSize uint32
style uint32
lpfnWndProc uintptr
cbClsExtra int32
cbWndExtra int32
hInstance uintptr
hIcon uintptr
hCursor uintptr
hbrBackground uintptr
lpszMenuName *uint16
lpszClassName *uint16
hIconSm uintptr
}
const (
colBlue = 0x00EB6F1F // COLORREF = 0x00BBGGRR pour #1f6feb
colWhite = 0x00FFFFFF
)
type splash struct{ hwnd uintptr }
func showSplash(text string) *splash {
s := &splash{}
ready := make(chan uintptr, 1)
go func() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
hwnd := createSplashWindow()
ready <- hwnd
if hwnd != 0 {
pumpMessages()
}
}()
s.hwnd = <-ready
return s
}
func (s *splash) close() {
if s == nil || s.hwnd == 0 {
return
}
const wmClose = 0x0010
pPostMessageW.Call(s.hwnd, wmClose, 0, 0)
}
func createSplashWindow() uintptr {
className, _ := syscall.UTF16PtrFromString("JeanSplash")
hInst, _, _ := pGetModuleHandleW.Call(0)
splashClassOnce.Do(func() {
idcArrow := uintptr(32512)
cursor, _, _ := pLoadCursorW.Call(0, idcArrow)
wc := wndClassExW{
lpfnWndProc: splashProc,
hInstance: hInst,
hCursor: cursor,
lpszClassName: className,
}
wc.cbSize = uint32(unsafe.Sizeof(wc))
pRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc)))
})
const (
wsPopup = 0x80000000
exTopmost = 0x00000008
exToolwindow = 0x00000080
smCX = 0
smCY = 1
)
cx, _, _ := pGetSystemMetrics.Call(smCX)
cy, _, _ := pGetSystemMetrics.Call(smCY)
x := (int(cx) - splashW) / 2
y := (int(cy) - splashH) / 2
title, _ := syscall.UTF16PtrFromString("Jean")
hwnd, _, _ := pCreateWindowExW.Call(
uintptr(exTopmost|exToolwindow),
uintptr(unsafe.Pointer(className)),
uintptr(unsafe.Pointer(title)),
uintptr(uint32(wsPopup)),
uintptr(x), uintptr(y), uintptr(splashW), uintptr(splashH),
0, 0, hInst, 0,
)
if hwnd == 0 {
return 0
}
// Coins arrondis.
rgn, _, _ := pCreateRoundRectRgn.Call(0, 0, splashW+1, splashH+1, 22, 22)
pSetWindowRgn.Call(hwnd, rgn, 1)
const swShowNoActivate = 4
pShowWindow.Call(hwnd, swShowNoActivate)
pUpdateWindow.Call(hwnd)
return hwnd
}
func pumpMessages() {
var m msg
for {
r, _, _ := pGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
if int32(r) <= 0 { // 0 = WM_QUIT, -1 = erreur
return
}
pTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
pDispatchMessageW.Call(uintptr(unsafe.Pointer(&m)))
}
}
func splashWndProc(hwnd, message, wParam, lParam uintptr) uintptr {
const (
wmDestroy = 0x0002
wmClose = 0x0010
wmPaint = 0x000F
)
switch message {
case wmPaint:
var ps paintStruct
hdc, _, _ := pBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
paintSplash(hdc)
pEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
return 0
case wmClose:
pDestroyWindow.Call(hwnd)
return 0
case wmDestroy:
pPostQuitMessage.Call(0)
return 0
}
r, _, _ := pDefWindowProcW.Call(hwnd, message, wParam, lParam)
return r
}
func paintSplash(hdc uintptr) {
// Fond bleu.
blue, _, _ := pCreateSolidBrush.Call(colBlue)
full := rect{0, 0, splashW, splashH}
pFillRect.Call(hdc, uintptr(unsafe.Pointer(&full)), blue)
pDeleteObject.Call(blue)
// Logo « j » blanc (rects du favicon sur grille 12, mis à l'échelle).
white, _, _ := pCreateSolidBrush.Call(colWhite)
const logo = 58
ox, oy := int32(40), int32((splashH-logo)/2)
scale := float64(logo) / 12
for _, rc := range [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}} {
r := rect{
left: ox + int32(rc[0]*scale),
top: oy + int32(rc[1]*scale),
right: ox + int32((rc[0]+rc[2])*scale),
bottom: oy + int32((rc[1]+rc[3])*scale),
}
pFillRect.Call(hdc, uintptr(unsafe.Pointer(&r)), white)
}
pDeleteObject.Call(white)
// Textes (blanc, fond transparent).
const transparent = 1
pSetBkMode.Call(hdc, transparent)
pSetTextColor.Call(hdc, colWhite)
textX := ox + logo + 26
drawText(hdc, "Jean", -30, 700, rect{textX, 40, splashW - 20, 82})
drawText(hdc, "Lancement en cours…", -17, 400, rect{textX, 84, splashW - 20, 118})
}
func drawText(hdc uintptr, s string, height, weight int32, r rect) {
face, _ := syscall.UTF16PtrFromString("Segoe UI")
const (
defaultCharset = 1
cleartypeQuality = 5
)
font, _, _ := pCreateFontW.Call(
uintptr(height), 0, 0, 0, uintptr(weight),
0, 0, 0, defaultCharset, 0, 0, cleartypeQuality, 0,
uintptr(unsafe.Pointer(face)),
)
old, _, _ := pSelectObject.Call(hdc, font)
txt, _ := syscall.UTF16PtrFromString(s)
const (
dtLeft = 0x0000
dtSingleline = 0x0020
dtVcenter = 0x0004
dtNoclip = 0x0100
)
rc := r
pDrawTextW.Call(hdc, uintptr(unsafe.Pointer(txt)), ^uintptr(0),
uintptr(unsafe.Pointer(&rc)), dtLeft|dtSingleline|dtVcenter|dtNoclip)
pSelectObject.Call(hdc, old)
pDeleteObject.Call(font)
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package main
// tray_other.go — pas d'icône de zone de notification hors Windows (la demande
// vise le tray Windows). On se contente d'ouvrir l'UI dans le navigateur et de
// garder le serveur en vie. Aucun import systray ici → les builds Linux/macOS
// restent purs (CGO_ENABLED=0).
func runTray(url string) {
select {} // garde le serveur en vie (Ctrl-C pour quitter) ; le navigateur est déjà ouvert par cmdApp
}
+121
View File
@@ -0,0 +1,121 @@
//go:build windows
package main
// tray_windows.go — icône de Jean dans la zone de notification Windows (system
// tray). Quand on lance l'app (double-clic ou `jean app`), l'UI web s'ouvre dans
// le navigateur ET une petite icône apparaît près de l'horloge : elle montre que
// Jean tourne et offre un menu « Ouvrir Jean » / « Quitter ».
//
// getlantern/systray est pur Go sur Windows (Win32 via x/sys, aucun CGO) — le
// binaire reste statique. L'import est confiné à ce fichier windows pour que les
// builds Linux/macOS (CGO_ENABLED=0) ne le touchent jamais.
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
"os"
"github.com/getlantern/systray"
)
// runTray ouvre l'UI dans le navigateur puis fait tourner l'icône de la zone de
// notification. Bloque jusqu'à ce que l'utilisateur choisisse « Quitter ».
func runTray(url string) {
systray.Run(func() {
systray.SetIcon(trayIcon())
systray.SetTitle("Jean")
systray.SetTooltip("Jean — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir Jean", "Ouvrir l'interface")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quitter", "Arrêter Jean")
go func() {
for {
select {
case <-mOpen.ClickedCh:
_ = openBrowser(url)
case <-mQuit.ClickedCh:
systray.Quit()
return
}
}
}()
}, func() {
os.Exit(0) // « Quitter » → arrête tout le process (serveur inclus)
})
}
// trayIcon génère l'icône (.ico) de Jean, RÉPLIQUE EXACTE du favicon de l'UI
// web : carré bleu arrondi #1f6feb + « j » blanc (rects (6,3) (6,5) (4,7) sur
// une grille 12x12). Générée à la volée — pas d'asset binaire à embarquer.
// Windows accepte un PNG (avec alpha pour les coins arrondis) dans le .ico.
func trayIcon() []byte {
const n = 32
blue := color.RGBA{0x1f, 0x6f, 0xeb, 0xff}
white := color.RGBA{0xff, 0xff, 0xff, 0xff}
clear := color.RGBA{0, 0, 0, 0}
const r = 2.0 // rayon des coins, en unités de la grille 12
// rects blancs du favicon (x, y, w, h en unités 12)
rects := [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}}
// hors du rectangle à coins arrondis (grille 12) → pixel transparent
outside := func(gx, gy float64) bool {
corner := func(cx, cy float64) bool {
dx, dy := gx-cx, gy-cy
return dx*dx+dy*dy > r*r
}
switch {
case gx < r && gy < r:
return corner(r, r)
case gx > 12-r && gy < r:
return corner(12-r, r)
case gx < r && gy > 12-r:
return corner(r, 12-r)
case gx > 12-r && gy > 12-r:
return corner(12-r, 12-r)
}
return false
}
img := image.NewRGBA(image.Rect(0, 0, n, n))
scale := float64(n) / 12
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
gx := (float64(x) + 0.5) / scale
gy := (float64(y) + 0.5) / scale
if outside(gx, gy) {
img.Set(x, y, clear)
continue
}
c := blue
for _, rc := range rects {
if gx >= rc[0] && gx < rc[0]+rc[2] && gy >= rc[1] && gy < rc[1]+rc[3] {
c = white
}
}
img.Set(x, y, c)
}
}
var pngBuf bytes.Buffer
_ = png.Encode(&pngBuf, img)
p := pngBuf.Bytes()
// Conteneur ICO : ICONDIR (6) + 1 ICONDIRENTRY (16) + PNG.
var ico bytes.Buffer
binary.Write(&ico, binary.LittleEndian, uint16(0)) // réservé
binary.Write(&ico, binary.LittleEndian, uint16(1)) // type = icône
binary.Write(&ico, binary.LittleEndian, uint16(1)) // nombre d'images
ico.WriteByte(n) // largeur
ico.WriteByte(n) // hauteur
ico.WriteByte(0) // couleurs
ico.WriteByte(0) // réservé
binary.Write(&ico, binary.LittleEndian, uint16(1)) // plans
binary.Write(&ico, binary.LittleEndian, uint16(32)) // bits/pixel
binary.Write(&ico, binary.LittleEndian, uint32(len(p))) // taille données
binary.Write(&ico, binary.LittleEndian, uint32(6+16)) // offset données
ico.Write(p)
return ico.Bytes()
}
+3 -1
View File
@@ -59,6 +59,7 @@ body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-i
}
h1{margin:0;font-size:18px;color:var(--accent)}
#brand{display:inline-flex;align-items:baseline}
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:5px;letter-spacing:0}
.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:18px}
/* pastille d'état service, en haut à droite de la barre latérale */
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap}
@@ -288,7 +289,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div id="backdrop" onclick="toggleSide()"></div>
<div class="side" id="side">
<div class="sidehead">
<h1 id="brand"><span id="brand-a">a</span>jean</h1>
<h1 id="brand"><span id="brand-a">a</span>jean<span id="ver"></span></h1>
<span id="status-svc" class="statuspill">…</span>
</div>
<details open><summary>GPU</summary>
@@ -639,6 +640,7 @@ async function loadStatus(){
el.innerHTML='<span class="dot"></span>'+txt+' <span class="port">:'+s.port+'</span>';
MODEL_READY = !!(s.active && s.health);
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
if(s.version){ document.getElementById('ver').textContent='v'+s.version; }
}
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 3,
"Patch": 2,
"Patch": 3,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 3,
"Patch": 2,
"Patch": 3,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "Jean",
"ProductVersion": "0.3.2",
"ProductVersion": "0.3.3",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+9 -7
View File
@@ -74,6 +74,7 @@ func newWebMux() *http.ServeMux {
api("/api/status", handleStatus)
api("/api/vram", handleVram)
api("/api/config", handleConfigEnv)
api("/api/catalog", handleCatalog)
api("/api/models", handleModels)
api("/api/models/delete", handleModelDelete)
api("/api/models/download", handleModelDownload)
@@ -229,18 +230,19 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
}
}
sendJSON(w, 200, map[string]any{
"state": state,
"active": active,
"health": health,
"port": LLMPort(),
"ctx": ctx,
"state": state,
"active": active,
"health": health,
"port": LLMPort(),
"ctx": ctx,
"version": Version,
})
}
func handleVram(w http.ResponseWriter, r *http.Request) {
out, err := exec.Command("nvidia-smi",
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits").Output()
"--format=csv,noheader,nounits")).Output()
gpus := []map[string]any{}
if err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {