Files
Loki/internal/ajean/web_llamacpp.go
T
nathaninline 110458c133 Renommage AJEAN (1/3) : module, chemins de donnees et auto-MAJ
Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc
installe. Trois briques :

- module github.com/nathaninline/ajean, cmd/ajean, internal/ajean,
  package ajean. Purement interne.

- AjeanHome() remplace JeanHome(). Les defauts deviennent
  %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant
  au premier lancement. La migration est un os.Rename dans le dossier
  parent de l'ancien chemin : intra-volume, donc atomique et instantane
  meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue
  (handle ouvert sous Windows, droits, volume en lecture seule) on
  CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais
  de suppression, le pire cas est "rien n'a bouge".
  $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que
  /etc/default/ajean et /etc/default/jean : un chemin impose a la main
  est un ordre, on ne le migre pas.

- l'auto-MAJ accepte desormais ajean-<os>-<arch> ET jean-<os>-<arch>,
  dans cet ordre. Indispensable dans les deux sens : les binaires deja
  installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir
  s'installer depuis une release anterieure au renommage.

Couvert par des tests, dont le repli quand un handle ouvert bloque le
rename sous Windows.
2026-08-04 13:59:25 +02:00

597 lines
18 KiB
Go

// 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 ajean
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
}
}
// 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)
}
lcMu.Unlock()
}
// startLcJob démarre un job (install ou update) si aucun n'est en cours.
func startLcJob(action string, run func()) error {
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()}
setBuildSink(lcAppend)
go func() {
defer func() {
setBuildSink(nil)
lcMu.Lock()
lcCur.Running = false
lcCur.EndedAt = time.Now().Unix()
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())
}
lcMu.Unlock()
}
func lcDone(msg string) {
lcMu.Lock()
if lcCur != nil {
lcCur.Phase = msg
lcCur.lines = append(lcCur.lines, "✓ "+msg)
}
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)
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,
"in_use": pbBin != "" && samePath(pbBin, cfgBin),
}
out["backends_dir"] = filepath.Join(AjeanHome(), "backends")
out["job"] = lcJobSnapshot(0, false)
sendJSON(w, 200, out)
}
// 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) {
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/<name> 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) {
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) {
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 écriture BIN dans config.env : %w", serr)
} else {
lcAppend("BIN mis à jour dans " + confPath())
}
}
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) {
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 écriture BIN dans config.env : %w", err))
return false
}
lcAppend("BIN mis à jour dans " + confPath())
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)
}
}
}