mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
0c4ff12a00
commit
cd58382d73
23 files changed
+810
-30
No files matched your search
@@ -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
@@ -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."}
|
||||
]
|
||||
}`
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user