// web_llamacpp.go — pilotage du backend llama.cpp depuis l'UI web : // statut (commit, retard, binaire, plan de build), vérification des mises à // jour (git fetch), et jobs asynchrones install / update / rebuild dont la // progression et les logs sont pollés par le client. // // GET /api/llamacpp → statut complet (dépôt, commit, binaire, plan) // POST /api/llamacpp/check → git fetch + retard sur origin (réseau) // POST /api/llamacpp/install → job d'installation {force?} // POST /api/llamacpp/update → job de mise à jour {clean?} // GET /api/llamacpp/job?from= → progression du job (phase, lignes, erreur) package loki import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" ) // lcJob est LE job llama.cpp en cours (un seul à la fois). Les lignes de log // sont indexées de façon absolue (base = index de Lines[0]) pour que le client // puisse poller « à partir de N » même quand on tronque le début. type lcJob struct { Action string // "install" | "update" Phase string Compiled int // fichiers compilés (progression du build) Running bool Err string StartedAt int64 EndedAt int64 OldCommit string NewCommit string lines []string base int } var ( lcMu sync.Mutex lcCur *lcJob ) const lcMaxLines = 4000 // lcAppend ajoute une ligne au log du job courant et met à jour la phase / le // compteur de fichiers compilés à partir du contenu (mêmes heuristiques que le // spinner du CLI). Appelée par le sink de build (emitBuildLine). func lcAppend(line string) { lcMu.Lock() defer lcMu.Unlock() if lcCur == nil { return } lcCur.lines = append(lcCur.lines, line) if len(lcCur.lines) > lcMaxLines { drop := len(lcCur.lines) - lcMaxLines lcCur.lines = append([]string(nil), lcCur.lines[drop:]...) lcCur.base += drop } if f := compiledFile(line); f != "" { lcCur.Compiled++ lcCur.Phase = fmt.Sprintf("compilation… %d fichiers", lcCur.Compiled) } else if p := phaseLabel(line); p != "" { lcCur.Phase = p } lcSaveLine(line) lcSave(false) } // lcPhase pose une phase explicite (étapes hors build : clone, fetch, service…) // et la trace aussi dans le log. func lcPhase(phase string) { lcMu.Lock() if lcCur != nil { lcCur.Phase = phase lcCur.lines = append(lcCur.lines, "▶ "+phase) lcSaveLine("▶ " + phase) lcSave(true) } lcMu.Unlock() } // startLcJob démarre un job (install ou update) si aucun n'est en cours. func startLcJob(action string, run func()) error { // Compiler, installer ou changer de moteur l'arrête et le relance : pas pendant // une optimisation (backend_tune_lock.go). if err := tuneGuard(); err != nil { return err } lcMu.Lock() defer lcMu.Unlock() if lcCur != nil && lcCur.Running { return fmt.Errorf("un job %s est déjà en cours", lcCur.Action) } lcCur = &lcJob{Action: action, Running: true, Phase: "démarrage…", StartedAt: time.Now().Unix()} lcResetLog() lcSave(true) setBuildSink(lcAppend) go func() { defer func() { setBuildSink(nil) lcMu.Lock() lcCur.Running = false lcCur.EndedAt = time.Now().Unix() lcSave(true) lcMu.Unlock() }() run() }() return nil } func lcFail(err error) { lcMu.Lock() if lcCur != nil { lcCur.Err = err.Error() lcCur.Phase = "échec" lcCur.lines = append(lcCur.lines, "✗ "+err.Error()) lcSaveLine("✗ " + err.Error()) lcSave(true) } lcMu.Unlock() } func lcDone(msg string) { lcMu.Lock() if lcCur != nil { lcCur.Phase = msg lcCur.lines = append(lcCur.lines, "✓ "+msg) lcSaveLine("✓ " + msg) lcSave(true) } lcMu.Unlock() } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- // handleLlamacpp renvoie le statut complet du backend llama.cpp : dépôt, // commit courant, retard connu (sans fetch réseau), binaire compilé et son // usage dans config.env, plan de build détecté, et le job éventuel. func handleLlamacpp(w http.ResponseWriter, r *http.Request) { repo := llamacppRepoDir() out := map[string]any{ "repo": repo, "installed": isDir(filepath.Join(repo, ".git")), } if out["installed"].(bool) { out["branch"] = gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") out["commit"] = gitOutput(repo, "rev-parse", "--short", "HEAD") out["commit_date"] = gitOutput(repo, "log", "-1", "--format=%ci") out["commit_msg"] = gitOutput(repo, "log", "-1", "--format=%s") if br, _ := out["branch"].(string); br != "" && br != "HEAD" { if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+br); behind != "" { n, _ := strconv.Atoi(behind) out["behind"] = n } } } bin := llamaServerBin(repo) out["bin"] = bin cfgBin := ReadConfig()["BIN"] out["config_bin"] = cfgBin out["in_use"] = bin != "" && samePath(bin, cfgBin) // Moteur livré par l'image Docker : il n'y a rien à installer, et l'image // ne contient ni cmake ni compilateur. L'UI masque alors les trois modes, // et le sélecteur par modèle propose « Image » au lieu de « Précompilé / // Compilé », qui ne désignent rien ici. out["provided"] = engineProvided() out["provided_bin"] = providedEngineBin() // Moteur téléchargé depuis l'image officielle llama.cpp (web_engine.go) : ni // « précompilé », ni « compilé », ni « personnalisé ». Sans cette information, // le bandeau d'état le rangeait dans « personnalisé », ce qui laisse croire à // un bricolage alors que c'est la voie de mise à jour normale en conteneur. out["engine"] = map[string]any{ "dir": engineDir(), "in_use": engineOwns(cfgBin), "tag": engineTagOf(cfgBin), } plan := detectBuildPlan() out["plan"] = map[string]any{ "backend": plan.backend, "arch": plan.cudaArch, "jobs": plan.jobs, } // Mode « binaires précompilés » : installé ? utilisé par la config ? pbTag, _ := prebuiltVersion() pbBin := prebuiltServerBin() out["prebuilt"] = map[string]any{ "tag": pbTag, "bin": pbBin, "dir": prebuiltDir(), // Tout BIN vivant sous le dossier prebuilt EST le moteur précompilé, même // s'il porte le numéro d'une release remplacée depuis (chemin versionné). "in_use": pbBin != "" && prebuiltOwns(cfgBin), } out["backends_dir"] = filepath.Join(LokiHome(), "backends") out["reco"] = recommendedMode(plan.backend) out["job"] = lcJobSnapshot(0, false) sendJSON(w, 200, out) } // providedEngineBin renvoie le moteur livré par l'image Docker, ou "" hors // conteneur. L'image le déclare elle-même via LOKI_ENGINE_BIN (voir Dockerfile // et docker-entrypoint.sh) : pas de chemin en dur ici, et n'importe quelle image // de base convient tant qu'elle pose cette variable. // // Sans cette notion, l'UI proposait d'installer un moteur déjà présent et // l'utilisateur tombait sur « outils manquants : cmake » — l'image ne contient // volontairement ni cmake ni compilateur, puisqu'elle part de l'image officielle // llama.cpp où llama-server est déjà compilé. func providedEngineBin() string { p := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")) if p == "" { return "" } if st, err := os.Stat(prebuiltResolveBin(p)); err != nil || st.IsDir() { return "" } return p } // engineProvided : le moteur vient-il de l'image ? (raccourci de lecture) func engineProvided() bool { return providedEngineBin() != "" } // refuseIfProvided coupe court aux jobs d'installation/compilation quand le // moteur vient de l'image : rien à installer, et aucun compilateur disponible. // Renvoie true si la requête a été refusée (l'appelant doit sortir). func refuseIfProvided(w http.ResponseWriter) bool { bin := providedEngineBin() if bin == "" { return false } sendJSON(w, 409, map[string]any{"ok": false, "error": "Le moteur est fourni par l'image Docker (" + bin + ") : il n'y a rien à compiler ici, et l'image ne contient ni cmake ni compilateur. " + "Pour le mettre à jour, utilise « mettre à jour le moteur » dans Réglages → Moteur."}) return true } // samePath compare deux chemins en neutralisant séparateurs, symlinks et casse // (Windows). func samePath(a, b string) bool { if a == "" || b == "" { return false } norm := func(p string) string { if real, err := filepath.EvalSymlinks(p); err == nil { p = real } p = filepath.Clean(p) return strings.ToLower(filepath.ToSlash(p)) } return norm(a) == norm(b) } // handleLlamacppCheck fait un vrai git fetch puis renvoie le retard sur origin // et le dernier commit distant. Synchrone (quelques secondes réseau). func handleLlamacppCheck(w http.ResponseWriter, r *http.Request) { repo := llamacppRepoDir() if !isDir(filepath.Join(repo, ".git")) { sendJSON(w, 200, map[string]any{"ok": false, "error": "llama.cpp n'est pas installé"}) return } branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") if branch == "" || branch == "HEAD" { branch = "master" } if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { sendJSON(w, 200, map[string]any{"ok": false, "error": "git fetch a échoué : " + err.Error()}) return } behind := 0 if b := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); b != "" { behind, _ = strconv.Atoi(b) } sendJSON(w, 200, map[string]any{ "ok": true, "behind": behind, "local": gitOutput(repo, "rev-parse", "--short", "HEAD"), "remote": gitOutput(repo, "rev-parse", "--short", "origin/"+branch), "remote_date": gitOutput(repo, "log", "-1", "--format=%ci", "origin/"+branch), "remote_msg": gitOutput(repo, "log", "-1", "--format=%s", "origin/"+branch), "branch": branch, "has_binary": llamaServerBin(repo) != "", "needs_rebuild": behind > 0 || llamaServerBin(repo) == "", }) } // handleLlamacppInstall lance le job d'installation (clone + build + BIN). func handleLlamacppInstall(w http.ResponseWriter, r *http.Request) { if refuseIfProvided(w) { return } var req struct { Force bool `json:"force"` } _ = json.NewDecoder(r.Body).Decode(&req) if err := startLcJob("install", func() { lcRunInstall(req.Force) }); err != nil { sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true}) } // handleLlamacppInstallCustom lance un job d'installation d'un backend CUSTOM // (fork llama.cpp) depuis une URL de dépôt Git : cloné dans backends/ et // compilé pour la machine, SANS toucher au BIN global. Il apparaît ensuite dans // /api/backends et se choisit par modèle (éditeur de preset → Moteur). func handleLlamacppInstallCustom(w http.ResponseWriter, r *http.Request) { if refuseIfProvided(w) { return } var req struct { Repo string `json:"repo"` Name string `json:"name"` Ref string `json:"ref"` } _ = json.NewDecoder(r.Body).Decode(&req) if strings.TrimSpace(req.Repo) == "" { sendJSON(w, 400, map[string]any{"ok": false, "error": "URL du dépôt requise"}) return } if err := startLcJob("custom", func() { lcRunCustomInstall(req.Repo, req.Name, req.Ref) }); err != nil { sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true}) } // lcRunCustomInstall : corps du job d'install d'un backend custom (sans bascule // de BIN — c'est volontaire, il se rattache par preset). func lcRunCustomInstall(url, name, ref string) { bin, err := installCustomBackend(url, name, ref, lcPhase) if err != nil { lcFail(err) return } lcAppend("binaire compilé : " + bin) lcAppend("→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.") lcDone("backend custom installé") } // handleLlamacppUpdate lance le job de mise à jour (pull + rebuild + restart). // {clean:true} force une recompilation from scratch même sans nouveau commit. func handleLlamacppUpdate(w http.ResponseWriter, r *http.Request) { if refuseIfProvided(w) { return } var req struct { Clean bool `json:"clean"` } _ = json.NewDecoder(r.Body).Decode(&req) if err := startLcJob("update", func() { lcRunUpdate(req.Clean) }); err != nil { sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true}) } // handleLlamacppPrebuiltCheck interroge la dernière release officielle de // llama.cpp et la compare à la version précompilée installée. Synchrone. func handleLlamacppPrebuiltCheck(w http.ResponseWriter, r *http.Request) { tag, assets, err := fetchLlamaLatest() if err != nil { sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) return } main, _, label, _, err := pickPrebuilt(assets) if err != nil { sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) return } cur, _ := prebuiltVersion() sendJSON(w, 200, map[string]any{ "ok": true, "latest": tag, "current": cur, "variant": label, "size_mb": main.Size / 1_000_000, "update": cur != tag || prebuiltServerBin() == "", }) } // handleLlamacppPrebuilt lance le job de téléchargement / mise à jour des // binaires précompilés officiels (pas de compilation). func handleLlamacppPrebuilt(w http.ResponseWriter, r *http.Request) { if err := startLcJob("prebuilt", lcRunPrebuilt); err != nil { sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true}) } // lcRunPrebuilt : télécharge les binaires officiels, pointe BIN dessus, en // stoppant le service pendant le remplacement (binaire verrouillé en cours // d'exécution) puis en le relançant. func lcRunPrebuilt() { svcWasUp := serviceIsActive() if svcWasUp { lcPhase("arrêt du service le temps de l'installation…") if err := serviceAction("stop"); err != nil { lcAppend("[warn] impossible d'arrêter le service : " + err.Error()) } } bin, err := prebuiltInstall(lcAppend, lcPhase) if err == nil { if serr := SetConfigKey("BIN", bin); serr != nil { err = fmt.Errorf("binaires installés mais échec d'écriture de BIN : %w", serr) } else { lcAppend("BIN mis à jour") } } if svcWasUp { lcPhase("redémarrage du service…") if serr := serviceAction("start"); serr != nil { lcAppend("[warn] redémarrage du service échoué : " + serr.Error()) } } if err != nil { lcFail(err) return } tag, _ := prebuiltVersion() lcDone("binaires précompilés installés (" + tag + ")") } // handleLlamacppUse bascule BIN entre deux versions DÉJÀ installées, sans // rien recompiler : "fast" = binaires précompilés, "opt" = build local. Le // service redémarre pour prendre le nouveau binaire. L'UI n'appelle ceci que // quand la version cible existe déjà (sinon elle lance un job d'installation). func handleLlamacppUse(w http.ResponseWriter, r *http.Request) { if tuneDenyHTTP(w) { return } var req struct { Mode string `json:"mode"` } _ = json.NewDecoder(r.Body).Decode(&req) var bin string switch req.Mode { case "fast": bin = prebuiltServerBin() case "opt": bin = llamaServerBin(llamacppRepoDir()) default: sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu"}) return } if bin == "" { sendJSON(w, 400, map[string]any{"ok": false, "error": "cette version n'est pas installée"}) return } if err := SetConfigKey("BIN", bin); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } if serviceIsActive() { _ = serviceAction("restart") } sendJSON(w, 200, map[string]any{"ok": true, "bin": bin}) } // handleLlamacppJob renvoie l'état du job courant + les lignes de log depuis // l'offset absolu ?from=N (le client mémorise `next` et enchaîne). func handleLlamacppJob(w http.ResponseWriter, r *http.Request) { from, _ := strconv.Atoi(r.URL.Query().Get("from")) sendJSON(w, 200, lcJobSnapshot(from, true)) } // lcJobSnapshot construit la vue JSON du job courant. withLines=false renvoie // juste l'entête (imbriquée dans /api/llamacpp). func lcJobSnapshot(from int, withLines bool) map[string]any { lcMu.Lock() defer lcMu.Unlock() if lcCur == nil { return map[string]any{"exists": false} } j := lcCur out := map[string]any{ "exists": true, "action": j.Action, "running": j.Running, "phase": j.Phase, "compiled": j.Compiled, "error": j.Err, "started_at": j.StartedAt, "ended_at": j.EndedAt, "old": j.OldCommit, "new": j.NewCommit, } if withLines { start := from - j.base if start < 0 { start = 0 } if start > len(j.lines) { start = len(j.lines) } out["lines"] = append([]string(nil), j.lines[start:]...) out["next"] = j.base + len(j.lines) } return out } // --------------------------------------------------------------------------- // Corps des jobs (miroir web de llamacppInstall / llamacppUpdate, sans stdout // interactif : tout passe par lcPhase / le sink de build) // --------------------------------------------------------------------------- func lcRunInstall(force bool) { repo := defaultRepoDir() lcPhase("vérification des outils (git, cmake, compilateur)…") if err := requireTools("git", "cmake"); err != nil { lcFail(err) return } if err := ensureCompiler(); err != nil { lcFail(err) return } ensureAccelerator() if isDir(filepath.Join(repo, ".git")) { if !force { // Dépôt déjà là : on bascule sur une mise à jour (même intention). lcPhase("dépôt déjà présent — bascule en mise à jour") lcRunUpdate(false) return } lcPhase("suppression du dépôt existant (--force)…") if err := os.RemoveAll(repo); err != nil { lcFail(err) return } } if err := os.MkdirAll(filepath.Dir(repo), 0o755); err != nil { lcFail(err) return } lcPhase("clone de llama.cpp…") if err := runStep("git clone", "", "git", "clone", "--depth=1", llamacppRepoURL, repo); err != nil { lcFail(fmt.Errorf("git clone a échoué : %w", err)) return } if !lcBuildAndSwitch(repo, true) { return } lcDone("installation terminée") } func lcRunUpdate(clean bool) { lcPhase("vérification des outils (git, cmake, compilateur)…") if err := requireTools("git", "cmake"); err != nil { lcFail(err) return } if err := ensureCompiler(); err != nil { lcFail(err) return } ensureAccelerator() repo := llamacppRepoDir() if !isDir(filepath.Join(repo, ".git")) { lcFail(fmt.Errorf("aucun dépôt llama.cpp (%s) — lance d'abord l'installation", repo)) return } oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") lcMu.Lock() lcCur.OldCommit = oldCommit lcMu.Unlock() branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") if branch == "" || branch == "HEAD" { branch = "master" } lcPhase("git fetch origin…") if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { lcFail(fmt.Errorf("git fetch a échoué : %w", err)) return } localRev := gitOutput(repo, "rev-parse", "HEAD") remoteRev := gitOutput(repo, "rev-parse", "origin/"+branch) if localRev != "" && localRev == remoteRev && !clean && llamaServerBin(repo) != "" { lcDone("déjà à jour (" + oldCommit + ") — rien à faire") return } if localRev != remoteRev { lcPhase("git pull origin/" + branch + "…") if err := runStep("git pull --ff-only", repo, "git", "pull", "--ff-only", "origin", branch); err != nil { lcFail(fmt.Errorf("git pull a échoué (modifs locales ?) : %w", err)) return } } newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") lcMu.Lock() lcCur.NewCommit = newCommit lcMu.Unlock() // Le binaire en cours d'exécution ne peut pas être réécrit → stop du service // pendant le build, redémarrage après (même en échec). svcWasUp := serviceIsActive() if svcWasUp { lcPhase("arrêt du service le temps du build…") if err := serviceAction("stop"); err != nil { lcAppend("[warn] impossible d'arrêter le service : " + err.Error()) } } ok := lcBuildAndSwitch(repo, clean) if svcWasUp { lcPhase("redémarrage du service…") if err := serviceAction("start"); err != nil { lcAppend("[warn] redémarrage du service échoué : " + err.Error()) } } if !ok { return } if oldCommit == newCommit { lcDone("recompilé (" + newCommit + ")") } else { lcDone("mis à jour : " + oldCommit + " → " + newCommit) } } // lcBuildAndSwitch détecte le plan, compile llama-server et pointe BIN dessus. // Renvoie false (job en échec) si une étape casse. func lcBuildAndSwitch(repo string, clean bool) bool { plan := detectBuildPlan() lcAppend(fmt.Sprintf("plan de build : backend=%s arch=%s jobs=%d", plan.backend, plan.cudaArch, plan.jobs)) lcPhase("configuration CMake…") if err := buildLlamacpp(repo, plan, clean); err != nil { lcAppendLogTail(filepath.Join(repo, "configure.log"), filepath.Join(repo, "build.log")) lcFail(err) return false } bin := llamaServerBin(repo) if bin == "" { lcFail(fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build"))) return false } lcAppend("binaire compilé : " + bin) if err := SetConfigKey("BIN", bin); err != nil { lcFail(fmt.Errorf("build ok mais échec d'écriture de BIN : %w", err)) return false } lcAppend("BIN mis à jour") return true } // lcAppendLogTail remonte la fin des logs de build dans le job pour que // l'erreur réelle soit visible dans l'UI sans aller chercher les fichiers. func lcAppendLogTail(paths ...string) { for _, p := range paths { b, err := os.ReadFile(p) if err != nil { continue } lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") if len(lines) > 30 { lines = lines[len(lines)-30:] } lcAppend("--- fin de " + filepath.Base(p) + " ---") for _, l := range lines { lcAppend(l) } } }