diff --git a/app.go b/app.go new file mode 100644 index 0000000..dff9c0d --- /dev/null +++ b/app.go @@ -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) + } +} diff --git a/catalog.go b/catalog.go new file mode 100644 index 0000000..cde8fbb --- /dev/null +++ b/catalog.go @@ -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."} + ] +}` diff --git a/go.mod b/go.mod index 4c88191..dedb52b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 992e78b..8769d1f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/gpu.go b/gpu.go index ccf0586..bbb454c 100644 --- a/gpu.go +++ b/gpu.go @@ -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) } diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..59a2572 Binary files /dev/null and b/icon.ico differ diff --git a/llamacpp.go b/llamacpp.go index 67e46ce..2e67cbd 100644 --- a/llamacpp.go +++ b/llamacpp.go @@ -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 "" } diff --git a/main.go b/main.go index b423287..c3228e5 100644 --- a/main.go +++ b/main.go @@ -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//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 [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 diff --git a/platform_unix.go b/platform_unix.go index 612c727..c6921f3 100644 --- a/platform_unix.go +++ b/platform_unix.go @@ -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 diff --git a/platform_windows.go b/platform_windows.go index cff125f..f099aa3 100644 --- a/platform_windows.go +++ b/platform_windows.go @@ -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)) } diff --git a/proc_other.go b/proc_other.go new file mode 100644 index 0000000..66d67db --- /dev/null +++ b/proc_other.go @@ -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) } diff --git a/proc_windows.go b/proc_windows.go new file mode 100644 index 0000000..402d3b2 --- /dev/null +++ b/proc_windows.go @@ -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 +} diff --git a/resource_windows_amd64.syso b/resource_windows_amd64.syso index 478aa84..d41d113 100644 Binary files a/resource_windows_amd64.syso and b/resource_windows_amd64.syso differ diff --git a/resource_windows_arm64.syso b/resource_windows_arm64.syso index 9069474..8cf1576 100644 Binary files a/resource_windows_arm64.syso and b/resource_windows_arm64.syso differ diff --git a/service.go b/service.go index 1f5cd7b..e93e27c 100644 --- a/service.go +++ b/service.go @@ -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) } diff --git a/service_windows.go b/service_windows.go index 0d2b81b..8666c3a 100644 --- a/service_windows.go +++ b/service_windows.go @@ -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). diff --git a/splash_other.go b/splash_other.go new file mode 100644 index 0000000..57bf642 --- /dev/null +++ b/splash_other.go @@ -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() {} diff --git a/splash_windows.go b/splash_windows.go new file mode 100644 index 0000000..7fa139f --- /dev/null +++ b/splash_windows.go @@ -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) +} diff --git a/tray_other.go b/tray_other.go new file mode 100644 index 0000000..4b7c67e --- /dev/null +++ b/tray_other.go @@ -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 +} diff --git a/tray_windows.go b/tray_windows.go new file mode 100644 index 0000000..4f944ca --- /dev/null +++ b/tray_windows.go @@ -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() +} diff --git a/ui/index.html b/ui/index.html index aaa1a85..ce004a3 100644 --- a/ui/index.html +++ b/ui/index.html @@ -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}
-

ajean

+

ajean

…
GPU @@ -639,6 +640,7 @@ async function loadStatus(){ el.innerHTML=''+txt+' :'+s.port+''; 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. diff --git a/versioninfo.json b/versioninfo.json index 2b75489..496dcbd 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -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": { diff --git a/web.go b/web.go index eb19cce..42a3b0d 100644 --- a/web.go +++ b/web.go @@ -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") {