mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge branch 'claude/discussion-file-linking-nzgd0d'
This commit is contained in:
13 files changed
+1652
-24
No files matched your search
@@ -241,7 +241,7 @@ Retirées par ce fork :
|
||||
|---|---|---|
|
||||
| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` |
|
||||
| Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | image officielle llama.cpp (`server-cuda`), précompilée |
|
||||
| Panneau « Moteur » | propose d'installer/compiler | annonce le moteur fourni par l'image, sans proposer d'install |
|
||||
| Panneau « Moteur » | propose d'installer/compiler | affiche la version qui tourne et la met à jour en un clic (sans rebuild) |
|
||||
| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) |
|
||||
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
|
||||
| Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié |
|
||||
@@ -274,6 +274,44 @@ docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b
|
||||
Aucune compilation de llama.cpp n'a lieu : le moteur est maintenu et
|
||||
précompilé par l'équipe amont (toutes architectures GPU courantes).
|
||||
|
||||
## Mettre à jour llama.cpp sans reconstruire l'image
|
||||
|
||||
llama.cpp publie plusieurs versions par jour ; l'image de Loki, elle, ne se
|
||||
reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à la date
|
||||
du dernier build, et le rattraper imposait un rebuild complet (2,6 Go) pour un
|
||||
composant de 170 Mo.
|
||||
|
||||
**Réglages → Moteur** affiche désormais la version de llama.cpp qui tourne
|
||||
(`b10450`, avec son commit) et deux boutons :
|
||||
|
||||
- **vérifier la version** — interroge le registre et dit s'il existe plus récent ;
|
||||
- **mettre à jour le moteur** — télécharge `llama-server` et ses bibliothèques
|
||||
depuis l'image officielle, `ghcr.io/ggml-org/llama.cpp:server-cuda`.
|
||||
|
||||
Ce qui est téléchargé n'est **pas l'image** : un manifeste OCI liste ses couches,
|
||||
et seules celles qui portent `/app` sont récupérées (~170 Mo). Le runtime CUDA
|
||||
(2 Go) et la base système sont déjà dans l'image de Loki. Le moteur atterrit dans
|
||||
`/data/engine/<version>/`, donc sur le volume de données : il survit à un
|
||||
`docker compose pull`.
|
||||
|
||||
La variante est déduite du moteur en place (CUDA, Vulkan, SYCL, MUSA ou CPU) :
|
||||
une installation Vulkan ne se verra jamais proposer une image CUDA.
|
||||
|
||||
**Le garde-fou.** La mise à jour apporte llama.cpp, pas le runtime CUDA, qui
|
||||
reste celui de l'image. Un llama.cpp compilé pour un CUDA plus récent ne
|
||||
chargerait pas son backend GPU ici — et le symptôme serait silencieux : tout
|
||||
fonctionne, mais sur le processeur. Le nouveau moteur est donc lancé à blanc
|
||||
avant toute bascule ; s'il ne démarre pas, ou s'il ne voit plus aucune carte
|
||||
alors que le moteur courant en voyait, la mise à jour est refusée et le moteur
|
||||
courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intact :
|
||||
**revenir au moteur de l'image** y ramène en un clic, sans réseau.
|
||||
|
||||
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
|
||||
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
|
||||
|
||||
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
|
||||
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — © les contributeurs d'AJEAN (« Jean contributors ») pour le code amont,
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
// backend_engine_oci.go — mise à jour du moteur llama.cpp SANS reconstruire
|
||||
// l'image et sans compiler : on va chercher le moteur là où l'équipe llama.cpp
|
||||
// le publie déjà tout compilé — dans son image de conteneur officielle — et on
|
||||
// en extrait le seul dossier qui nous intéresse, /app.
|
||||
//
|
||||
// Pourquoi ce détour plutôt que les releases GitHub (backend_prebuilt.go) :
|
||||
// llama.cpp ne publie AUCUN binaire CUDA pour Linux dans ses releases. La seule
|
||||
// distribution CUDA/Linux officielle et précompilée, ce sont ces images
|
||||
// (ghcr.io/ggml-org/llama.cpp:server-cuda-b#####). C'est aussi exactement ce
|
||||
// dont l'image de Loki hérite : mettre à jour par ce chemin, c'est obtenir le
|
||||
// même moteur qu'une reconstruction complète de l'image, en ~170 Mo au lieu de
|
||||
// 2,6 Go et sans passer par un rebuild.
|
||||
//
|
||||
// Ce qu'on télécharge : PAS l'image. Un manifeste OCI liste ses couches ; le
|
||||
// runtime CUDA (2 Go) et les paquets système sont déjà présents dans l'image de
|
||||
// Loki, donc inutiles. Seules les couches du haut portent /app (llama-server et
|
||||
// ses .so, ~170 Mo). On les prend en partant du sommet et on s'arrête dès qu'on
|
||||
// tient un moteur complet.
|
||||
//
|
||||
// Ce que ça NE remplace PAS : le runtime CUDA, lui, vient de l'image. Un
|
||||
// llama.cpp compilé pour un CUDA majeur plus récent que celui de l'image ne
|
||||
// démarrera pas — d'où le test à blanc (--list-devices) avant toute bascule, et
|
||||
// la possibilité de revenir au moteur d'origine en un clic.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ociBase est une variable et non une constante pour que les tests puissent la
|
||||
// pointer sur un faux registre : tout ce qui suit se joue dans le dialogue avec
|
||||
// le registre, et le vérifier en frappant ghcr.io rendrait la CI dépendante du
|
||||
// réseau.
|
||||
// LOKI_OCI_REGISTRY le remplace : c'est la porte de sortie pour un réseau qui
|
||||
// n'atteint pas ghcr.io directement — miroir d'entreprise, cache local — sans
|
||||
// quoi la mise à jour du moteur y serait tout simplement impossible.
|
||||
var ociBase = "https://ghcr.io"
|
||||
|
||||
func init() {
|
||||
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("LOKI_OCI_REGISTRY")), "/"); v != "" {
|
||||
if !strings.Contains(v, "://") {
|
||||
v = "https://" + v
|
||||
}
|
||||
ociBase = v
|
||||
}
|
||||
}
|
||||
|
||||
// ociHost : l'hôte seul, tel que l'attend le paramètre `service` du jeton.
|
||||
func ociHost() string {
|
||||
h := strings.TrimPrefix(strings.TrimPrefix(ociBase, "https://"), "http://")
|
||||
return strings.TrimSuffix(h, "/")
|
||||
}
|
||||
|
||||
const (
|
||||
ociEngineRepo = "ggml-org/llama.cpp"
|
||||
|
||||
// Au-delà de cette taille, une couche n'est plus une couche « applicative » :
|
||||
// c'est le runtime CUDA (2 Go) ou une base système, qu'on ne veut à aucun
|
||||
// prix télécharger. On descend les couches du sommet vers la base ; croiser
|
||||
// une couche de cette taille signifie qu'on a dépassé /app.
|
||||
ociLayerCap = 600 << 20
|
||||
)
|
||||
|
||||
// ociLayer : une couche du manifeste (on n'a besoin que de ça).
|
||||
type ociLayer struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ociDoc couvre à la fois un index multi-architecture et un manifeste d'image :
|
||||
// les deux arrivent sur la même URL, distingués par le champ qui est rempli.
|
||||
type ociDoc struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Manifests []struct {
|
||||
Digest string `json:"digest"`
|
||||
Platform struct {
|
||||
Architecture string `json:"architecture"`
|
||||
OS string `json:"os"`
|
||||
} `json:"platform"`
|
||||
} `json:"manifests"`
|
||||
Layers []ociLayer `json:"layers"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}
|
||||
|
||||
// ociAccept : tous les types de manifeste qu'on sait lire. Sans cet en-tête, le
|
||||
// registre répond en schéma 1 (obsolète) et la lecture échoue sans expliquer.
|
||||
const ociAccept = "application/vnd.oci.image.index.v1+json," +
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json," +
|
||||
"application/vnd.oci.image.manifest.v1+json," +
|
||||
"application/vnd.docker.distribution.manifest.v2+json"
|
||||
|
||||
// ociToken obtient un jeton de lecture ANONYME. Les images publiques de ghcr.io
|
||||
// sont lisibles sans compte, mais le registre exige quand même un jeton porteur.
|
||||
func ociToken(repo string) (string, error) {
|
||||
u := fmt.Sprintf("%s/token?scope=repository:%s:pull&service=%s", ociBase, repo, ociHost())
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(u)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("registre %s injoignable : %w", ociHost(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("%s/token : HTTP %d", ociHost(), resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Token == "" {
|
||||
return "", fmt.Errorf("jeton de lecture illisible")
|
||||
}
|
||||
return out.Token, nil
|
||||
}
|
||||
|
||||
// ociFetch fait un GET authentifié sur le registre.
|
||||
func ociFetch(url, token, accept string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
return (&http.Client{Timeout: 0}).Do(req)
|
||||
}
|
||||
|
||||
// ociArch traduit GOARCH vers le nom de plateforme OCI.
|
||||
func ociArch() string {
|
||||
if runtime.GOARCH == "arm64" {
|
||||
return "arm64"
|
||||
}
|
||||
return "amd64"
|
||||
}
|
||||
|
||||
// ociResolve lit le manifeste d'un tag et renvoie les couches de l'image pour
|
||||
// CETTE architecture, plus le numéro de build annoncé par l'image
|
||||
// (org.opencontainers.image.version, ex. « b10450 »).
|
||||
func ociResolve(repo, tag string) (layers []ociLayer, version string, err error) {
|
||||
token, err := ociToken(repo)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
get := func(ref string) (*ociDoc, error) {
|
||||
resp, err := ociFetch(fmt.Sprintf("%s/v2/%s/manifests/%s", ociBase, repo, ref), token, ociAccept)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("version inconnue dans le registre : %s", ref)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("manifeste %s : HTTP %d", ref, resp.StatusCode)
|
||||
}
|
||||
var d ociDoc
|
||||
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
|
||||
return nil, fmt.Errorf("manifeste %s illisible : %w", ref, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
doc, err := get(tag)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
version = doc.Annotations["org.opencontainers.image.version"]
|
||||
if len(doc.Manifests) > 0 {
|
||||
want := ociArch()
|
||||
digest := ""
|
||||
for _, m := range doc.Manifests {
|
||||
if m.Platform.OS == "linux" && m.Platform.Architecture == want {
|
||||
digest = m.Digest
|
||||
break
|
||||
}
|
||||
}
|
||||
if digest == "" {
|
||||
return nil, "", fmt.Errorf("aucune image linux/%s pour %s", want, tag)
|
||||
}
|
||||
if doc, err = get(digest); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if v := doc.Annotations["org.opencontainers.image.version"]; v != "" {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
if len(doc.Layers) == 0 {
|
||||
return nil, "", fmt.Errorf("manifeste %s sans couche", tag)
|
||||
}
|
||||
return doc.Layers, version, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variante, version et emplacement du moteur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCISupported : ce mécanisme n'a de sens que là où ces images tournent.
|
||||
// Les images llama.cpp sont linux/amd64 et linux/arm64, rien d'autre.
|
||||
func engineOCISupported() bool {
|
||||
return runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
|
||||
}
|
||||
|
||||
// engineVariant déduit la variante d'image correspondant au moteur COURANT, en
|
||||
// regardant les backends ggml posés à côté du binaire. C'est la seule source
|
||||
// fiable : le conteneur ne sait pas de quelle image il a été bâti, et demander
|
||||
// à l'utilisateur de retenir « server-cuda » serait un piège à erreur.
|
||||
//
|
||||
// Renvoie un préfixe de tag : "server-cuda", "server-vulkan", "server-intel",
|
||||
// "server-musa", ou "server" (CPU) par défaut.
|
||||
func engineVariant() string {
|
||||
dir := ""
|
||||
if bin := currentEngineBin(); bin != "" {
|
||||
dir = filepath.Dir(bin)
|
||||
}
|
||||
if dir == "" {
|
||||
return "server"
|
||||
}
|
||||
has := func(prefix string) bool {
|
||||
m, _ := filepath.Glob(filepath.Join(dir, prefix+"*"))
|
||||
return len(m) > 0
|
||||
}
|
||||
switch {
|
||||
case has("libggml-cuda.so"):
|
||||
return "server-cuda"
|
||||
case has("libggml-musa.so"):
|
||||
return "server-musa"
|
||||
case has("libggml-sycl.so"):
|
||||
return "server-intel"
|
||||
case has("libggml-vulkan.so"):
|
||||
return "server-vulkan"
|
||||
}
|
||||
return "server"
|
||||
}
|
||||
|
||||
// currentEngineBin : le binaire réellement utilisé (BIN de la configuration,
|
||||
// avec le repli sur le moteur de l'image quand BIN n'est pas encore posé).
|
||||
func currentEngineBin() string {
|
||||
bin := prebuiltResolveBin(ReadConfig()["BIN"])
|
||||
if bin != "" && isFile(bin) {
|
||||
return bin
|
||||
}
|
||||
return providedEngineBin()
|
||||
}
|
||||
|
||||
// La bannière de version de llama.cpp a changé de forme plusieurs fois, et un
|
||||
// binaire ancien reste parfaitement utilisable : on lit les trois formes.
|
||||
//
|
||||
// version: 0.1.0-dev (build 10450, commit ece963f41) ← actuelle
|
||||
// version: 10450 (ece963f) ← précédente
|
||||
// build: 4589 (a1b2c3d) ← plus ancienne
|
||||
//
|
||||
// C'est le NUMÉRO DE BUILD qui compte : c'est lui qui figure dans les tags
|
||||
// d'image (server-cuda-b10450) et donc la seule chose comparable entre ce qui
|
||||
// tourne et ce qui est publié.
|
||||
var reEngineBuildLong = regexp.MustCompile(`build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)`)
|
||||
var reEngineBuildShort = regexp.MustCompile(`(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)`)
|
||||
|
||||
func parseEngineBuild(out string) (build int, commit string) {
|
||||
for _, re := range []*regexp.Regexp{reEngineBuildLong, reEngineBuildShort} {
|
||||
if m := re.FindStringSubmatch(out); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n, m[2]
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
|
||||
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
|
||||
// d'en installer un autre.
|
||||
func engineBuildOf(bin string) (int, string) {
|
||||
if bin == "" || !isFile(bin) {
|
||||
return 0, ""
|
||||
}
|
||||
cmd := hideCmd(exec.Command(bin, "--version"))
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
|
||||
return parseEngineBuild(string(out))
|
||||
}
|
||||
|
||||
// engineDir : racine des moteurs installés par ce mécanisme, sous LOKI_HOME —
|
||||
// c'est-à-dire sur le volume /data en conteneur, donc conservés d'un
|
||||
// redémarrage (et d'une mise à jour d'image) à l'autre.
|
||||
func engineDir() string { return filepath.Join(LokiHome(), "engine") }
|
||||
|
||||
// engineOwns dit si un chemin désigne un moteur installé ici.
|
||||
func engineOwns(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
norm := func(s string) string { return strings.ToLower(filepath.ToSlash(filepath.Clean(s))) }
|
||||
return strings.HasPrefix(norm(p), norm(engineDir())+"/")
|
||||
}
|
||||
|
||||
// engineInstalled liste les moteurs présents sous engineDir(), du plus ancien
|
||||
// au plus récent (ordre des numéros de build).
|
||||
func engineInstalled() []string {
|
||||
entries, err := os.ReadDir(engineDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var tags []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && isFile(filepath.Join(engineDir(), e.Name(), "llama-server")) {
|
||||
tags = append(tags, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Slice(tags, func(i, j int) bool { return engineTagBuild(tags[i]) < engineTagBuild(tags[j]) })
|
||||
return tags
|
||||
}
|
||||
|
||||
var reTagBuild = regexp.MustCompile(`-b(\d+)$`)
|
||||
|
||||
// engineTagBuild extrait le numéro de build d'un tag (« server-cuda-b10450 » →
|
||||
// 10450), 0 si le tag n'en porte pas (tag mouvant « server-cuda »).
|
||||
func engineTagBuild(tag string) int {
|
||||
m := reTagBuild.FindStringSubmatch(tag)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}
|
||||
|
||||
// engineTagOf : le tag d'un moteur téléchargé ici (« server-cuda-b10450 »), ""
|
||||
// pour tout autre chemin.
|
||||
func engineTagOf(bin string) string {
|
||||
if !engineOwns(bin) {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(filepath.Dir(bin))
|
||||
}
|
||||
|
||||
// engineBinFor : chemin du llama-server d'une version installée.
|
||||
func engineBinFor(tag string) string {
|
||||
p := filepath.Join(engineDir(), filepath.Base(tag), "llama-server")
|
||||
if isFile(p) {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enginePrune supprime les versions installées autres que celles à garder.
|
||||
// Chaque version pèse ~450 Mo une fois décompressée : en laisser traîner cinq
|
||||
// remplit le volume /data sans rien apporter.
|
||||
func enginePrune(keep map[string]bool, logf func(string)) {
|
||||
for _, tag := range engineInstalled() {
|
||||
if keep[tag] {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err == nil && logf != nil {
|
||||
logf("ancienne version supprimée : " + tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCIInstall télécharge le moteur du tag demandé et l'installe dans
|
||||
// engineDir()/<tag>. Renvoie le chemin du llama-server installé.
|
||||
//
|
||||
// Réentrant : une version déjà installée est renvoyée telle quelle, et une
|
||||
// installation interrompue laisse un dossier « .part » qui est repris de zéro.
|
||||
func engineOCIInstall(tag string, logf, phasef func(string)) (string, error) {
|
||||
tag = filepath.Base(strings.TrimSpace(tag)) // jamais de séparateur dans un nom de dossier
|
||||
if tag == "" || tag == "." {
|
||||
return "", fmt.Errorf("version du moteur non précisée")
|
||||
}
|
||||
if bin := engineBinFor(tag); bin != "" {
|
||||
logf("déjà installé : " + tag)
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
phasef("lecture du manifeste " + tag + "…")
|
||||
layers, version, err := ociResolve(ociEngineRepo, tag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if version != "" {
|
||||
logf(fmt.Sprintf("image %s:%s — llama.cpp %s, %d couches", ociEngineRepo, tag, version, len(layers)))
|
||||
}
|
||||
|
||||
dest := filepath.Join(engineDir(), tag)
|
||||
tmp := dest + ".part"
|
||||
if err := os.RemoveAll(tmp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(tmp, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmp) // no-op après le rename final
|
||||
|
||||
token, err := ociToken(ociEngineRepo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// On descend du SOMMET vers la base : /app est ajouté en dernier dans le
|
||||
// Dockerfile amont, et les couches du bas sont le runtime CUDA et le système,
|
||||
// déjà présents ici. Une couche du haut peut masquer un fichier d'une couche
|
||||
// plus basse : le premier extrait gagne, comme le ferait un runtime OCI.
|
||||
seen := map[string]bool{}
|
||||
var links []archiveLink
|
||||
for i := len(layers) - 1; i >= 0; i-- {
|
||||
l := layers[i]
|
||||
if l.Size > ociLayerCap {
|
||||
logf(fmt.Sprintf("couche %d ignorée (%d Mo) : c'est le runtime de l'image, pas le moteur", i, l.Size/1_000_000))
|
||||
break
|
||||
}
|
||||
part := filepath.Join(tmp, "layer.tgz")
|
||||
if l.Size > 4<<20 {
|
||||
phasef(fmt.Sprintf("téléchargement du moteur (%d Mo)…", l.Size/1_000_000))
|
||||
}
|
||||
if err := ociDownloadBlob(ociEngineRepo, l.Digest, token, part, l.Size, logf); err != nil {
|
||||
return "", fmt.Errorf("téléchargement de la couche %d : %w", i, err)
|
||||
}
|
||||
n, err := extractAppLayer(part, tmp, seen, &links)
|
||||
_ = os.Remove(part)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extraction de la couche %d : %w", i, err)
|
||||
}
|
||||
if n > 0 {
|
||||
logf(fmt.Sprintf("couche %d : %d fichiers du moteur", i, n))
|
||||
}
|
||||
if engineSetComplete(seen) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := applyLinks(links); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !seen["llama-server"] {
|
||||
return "", fmt.Errorf("llama-server absent de l'image %s — variante inattendue ?", tag)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bin := filepath.Join(dest, "llama-server")
|
||||
if err := os.Chmod(bin, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logf("moteur installé : " + bin)
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
// engineSetComplete : a-t-on de quoi lancer le moteur ? Le binaire seul ne
|
||||
// suffit pas — depuis que llama.cpp éclate son code en bibliothèques, l'exécutable
|
||||
// tient dans une couche et ses .so dans la précédente. S'arrêter au binaire
|
||||
// donnait un moteur qui meurt sur « libllama.so: cannot open shared object ».
|
||||
func engineSetComplete(seen map[string]bool) bool {
|
||||
if !seen["llama-server"] {
|
||||
return false
|
||||
}
|
||||
base, llama := false, false
|
||||
for name := range seen {
|
||||
switch {
|
||||
case strings.HasPrefix(name, "libggml-base.so"):
|
||||
base = true
|
||||
case strings.HasPrefix(name, "libllama.so"):
|
||||
llama = true
|
||||
}
|
||||
}
|
||||
return base && llama
|
||||
}
|
||||
|
||||
// ociDownloadBlob télécharge une couche vers dest, avec la progression du
|
||||
// téléchargement dans le journal du job.
|
||||
func ociDownloadBlob(repo, digest, token, dest string, size int64, logf func(string)) error {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/%s/blobs/%s", ociBase, repo, digest), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return downloadRequest(req, dest, size, logf)
|
||||
}
|
||||
|
||||
// extractAppLayer extrait les entrées « app/… » d'une couche (tar.gz) à PLAT
|
||||
// dans dir, en ignorant celles déjà vues (une couche supérieure fait foi) et
|
||||
// les marqueurs de suppression OCI (.wh.*). Renvoie le nombre de fichiers
|
||||
// extraits. Les liens sont accumulés dans links : leur cible n'est pas
|
||||
// forcément déjà extraite au moment où on croise l'entrée.
|
||||
func extractAppLayer(archive, dir string, seen map[string]bool, links *[]archiveLink) (int, error) {
|
||||
f, err := os.Open(archive)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
count := 0
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return count, nil
|
||||
}
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
name, ok := appEntryName(h.Name)
|
||||
if !ok || seen[name] {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeReg:
|
||||
w, err := os.Create(p)
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
if _, err := io.Copy(w, tr); err != nil {
|
||||
w.Close()
|
||||
return count, err
|
||||
}
|
||||
w.Close()
|
||||
_ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
|
||||
seen[name] = true
|
||||
count++
|
||||
case tar.TypeSymlink, tar.TypeLink:
|
||||
// Indispensable : les .so sont livrés sous leur nom versionné
|
||||
// (libllama.so.0.1.0) plus deux liens (libllama.so.0, libllama.so) —
|
||||
// c'est le nom court que l'éditeur de liens cherche au lancement.
|
||||
target, ok := appEntryName(h.Linkname)
|
||||
if !ok {
|
||||
target = filepath.Base(h.Linkname) // lien relatif au sein de /app
|
||||
}
|
||||
*links = append(*links, archiveLink{path: p, target: target})
|
||||
seen[name] = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// appEntryName ramène « app/libllama.so » à « libllama.so ». Renvoie ok=false
|
||||
// pour tout ce qui n'est pas un fichier direct de /app : autres dossiers,
|
||||
// sous-dossiers, et marqueurs de suppression OCI.
|
||||
func appEntryName(raw string) (string, bool) {
|
||||
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
|
||||
rest, ok := strings.CutPrefix(p, "app/")
|
||||
if !ok || rest == "" || strings.Contains(rest, "/") {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(rest, ".wh.") {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vérification et bascule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCILatest renvoie le dernier build publié pour la variante (numéro et
|
||||
// tag figé correspondant). On lit le tag MOUVANT (« server-cuda ») et son
|
||||
// annotation de version plutôt que de lister les 1000 tags du dépôt : une seule
|
||||
// requête, et c'est l'amont qui dit ce qu'est « la dernière ».
|
||||
func engineOCILatest(variant string) (build int, tag string, err error) {
|
||||
_, version, err := ociResolve(ociEngineRepo, variant)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if version == "" {
|
||||
// Pas d'annotation : on retombe sur le tag mouvant lui-même, qui pointe
|
||||
// bien sur la dernière image — on ne saura juste pas dire son numéro.
|
||||
return 0, variant, nil
|
||||
}
|
||||
// L'annotation vaut « b10450 » : le tag figé est « server-cuda-b10450 ».
|
||||
n, _ := strconv.Atoi(strings.TrimPrefix(version, "b"))
|
||||
return n, variant + "-" + version, nil
|
||||
}
|
||||
|
||||
// engineSmokeTest lance à blanc le moteur fraîchement installé — c'est là que
|
||||
// se joue le seul vrai risque de ce mécanisme. La mise à jour apporte llama.cpp,
|
||||
// PAS le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
|
||||
// CUDA plus récent ne chargera pas son backend GPU ici.
|
||||
//
|
||||
// Deux échecs distincts, et le second est le sournois :
|
||||
//
|
||||
// 1. le binaire ne démarre pas du tout (bibliothèque manquante) — bruyant ;
|
||||
// 2. il démarre mais ne voit plus AUCUNE carte, alors que le moteur courant en
|
||||
// voyait. Rien ne casse : le modèle se charge, tourne sur le processeur, et
|
||||
// on ne s'en aperçoit qu'au débit. C'est le pire résultat possible, donc on
|
||||
// le traite comme un échec de mise à jour.
|
||||
//
|
||||
// Ne voir aucune carte n'est un échec que si le moteur courant en voyait :
|
||||
// sur une machine sans GPU, « aucune carte » est le résultat normal.
|
||||
func engineSmokeTest(bin, ancien string) error {
|
||||
devs, err := engineListDevices(bin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(devs) > 0 {
|
||||
return nil
|
||||
}
|
||||
if ancien == "" || ancien == bin {
|
||||
return nil
|
||||
}
|
||||
avant, err := engineListDevices(ancien)
|
||||
if err != nil || len(avant) == 0 {
|
||||
return nil // le moteur courant n'en voyait pas plus : rien à reprocher au nouveau
|
||||
}
|
||||
return fmt.Errorf("le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit %d — "+
|
||||
"il tournerait sur le processeur, beaucoup plus lentement", len(avant))
|
||||
}
|
||||
|
||||
// engineListDevices lance `llama-server --list-devices` sur un binaire donné.
|
||||
func engineListDevices(bin string) ([]map[string]any, error) {
|
||||
cmd := hideCmd(exec.Command(bin, "--list-devices"))
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("le moteur ne démarre pas ici : %v\n%s", err, tailLines(string(out), 12))
|
||||
}
|
||||
return parseListDevices(string(out)), nil
|
||||
}
|
||||
|
||||
// tailLines garde les n dernières lignes non vides d'une sortie.
|
||||
func tailLines(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package loki
|
||||
|
||||
// Vérification contre le VRAI registre. Ignoré par défaut (la CI ne doit pas
|
||||
// dépendre du réseau ni télécharger 170 Mo) :
|
||||
//
|
||||
// LOKI_NET_TEST=1 go test ./internal/loki -run TestMoteurReelDepuisGhcr -v
|
||||
//
|
||||
// Ce test existe parce que le faux registre ne peut pas garantir une chose : que
|
||||
// la DISPOSITION de l'image amont est toujours celle qu'on suppose. Si llama.cpp
|
||||
// range un jour son moteur autrement — sous-dossier, couche gigantesque, autre
|
||||
// jeu de bibliothèques — c'est ici qu'on le verra, et pas chez l'utilisateur.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMoteurReelDepuisGhcr(t *testing.T) {
|
||||
if os.Getenv("LOKI_NET_TEST") == "" {
|
||||
t.Skip("test réseau — LOKI_NET_TEST=1 pour l'exécuter")
|
||||
}
|
||||
if os.Getenv("LOKI_HOME") == "" {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
build, tag, err := engineOCILatest("server-cuda")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("dernier build publié : %d → %s", build, tag)
|
||||
if build == 0 {
|
||||
t.Error("numéro de build absent de l'annotation de l'image")
|
||||
}
|
||||
|
||||
bin, err := engineOCIInstall(tag, func(s string) { t.Log(s) }, func(s string) { t.Log("▶ " + s) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(bin)
|
||||
// Ce que l'éditeur de liens réclamera au lancement. Un seul manquant et le
|
||||
// moteur meurt sur « cannot open shared object file ».
|
||||
for _, n := range []string{"libllama.so", "libggml.so", "libggml-base.so", "libggml-cuda.so", "libmtmd.so"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
|
||||
t.Errorf("%s : %v", n, err)
|
||||
}
|
||||
}
|
||||
// Et le binaire doit savoir dire sa version : c'est ce qui pilote la
|
||||
// comparaison « installé vs publié » de tout le panneau.
|
||||
if got, commit := engineBuildOf(bin); got != build {
|
||||
t.Errorf("le moteur installé annonce le build %d (%s), attendu %d", got, commit, build)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package loki
|
||||
|
||||
// Mise à jour du moteur depuis l'image officielle llama.cpp.
|
||||
//
|
||||
// Ce qui se joue ici n'est pas « est-ce qu'on télécharge » mais « est-ce qu'on
|
||||
// télécharge le MINIMUM et est-ce que ce qu'on pose est LANÇABLE ». Deux pièges
|
||||
// concrets, tous deux invisibles sans test :
|
||||
//
|
||||
// - l'image officielle range llama-server dans une couche et ses .so dans la
|
||||
// précédente ; s'arrêter au binaire donne un moteur qui meurt sur
|
||||
// « libllama.so: cannot open shared object file » ;
|
||||
// - la couche du dessous est le runtime CUDA (2 Go). La descendre par erreur,
|
||||
// c'est transformer une mise à jour de 170 Mo en un téléchargement de 2 Go.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Faux registre OCI ------------------------------------------------------
|
||||
|
||||
// fauxCouche fabrique une couche tar.gz. Les entrées sont décrites par leur nom
|
||||
// complet dans l'archive ; une valeur préfixée par « -> » crée un lien.
|
||||
func fauxCouche(t *testing.T, entrees map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for nom, contenu := range entrees {
|
||||
if cible, ok := strings.CutPrefix(contenu, "-> "); ok {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeSymlink, Linkname: cible, Mode: 0o777}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeReg, Size: int64(len(contenu)), Mode: 0o755}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(contenu)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// fauxRegistre sert un index multi-architecture, le manifeste amd64 et les
|
||||
// blobs. `tailles` permet d'annoncer une taille différente de la taille réelle
|
||||
// (c'est ce que lit le garde-fou des grosses couches). Renvoie le compteur des
|
||||
// blobs réellement téléchargés.
|
||||
func fauxRegistre(t *testing.T, version string, couches [][]byte, tailles []int64) (servis map[string]int) {
|
||||
t.Helper()
|
||||
servis = map[string]int{}
|
||||
digests := make([]string, len(couches))
|
||||
blobs := map[string][]byte{}
|
||||
for i, c := range couches {
|
||||
sum := sha256.Sum256(c)
|
||||
digests[i] = "sha256:" + hex.EncodeToString(sum[:])
|
||||
blobs[digests[i]] = c
|
||||
}
|
||||
manifeste := map[string]any{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"layers": func() []map[string]any {
|
||||
var out []map[string]any
|
||||
for i := range couches {
|
||||
taille := int64(len(couches[i]))
|
||||
if tailles != nil && tailles[i] > 0 {
|
||||
taille = tailles[i]
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"digest": digests[i], "size": taille,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
}
|
||||
manBytes, _ := json.Marshal(manifeste)
|
||||
manSum := sha256.Sum256(manBytes)
|
||||
manDigest := "sha256:" + hex.EncodeToString(manSum[:])
|
||||
|
||||
index := map[string]any{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": []map[string]any{
|
||||
// L'architecture qui ne nous concerne pas vient EN PREMIER : choisir
|
||||
// « le premier manifeste » au lieu de filtrer sur la plateforme
|
||||
// installerait un moteur d'une autre architecture.
|
||||
{"digest": "sha256:mauvaise", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}},
|
||||
{"digest": manDigest, "platform": map[string]string{"os": "linux", "architecture": ociArch()}},
|
||||
},
|
||||
"annotations": map[string]string{"org.opencontainers.image.version": version},
|
||||
}
|
||||
indexBytes, _ := json.Marshal(index)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/token"):
|
||||
_, _ = w.Write([]byte(`{"token":"jeton-de-test"}`))
|
||||
case strings.Contains(r.URL.Path, "/manifests/"):
|
||||
if r.Header.Get("Authorization") != "Bearer jeton-de-test" {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
if ref == manDigest {
|
||||
_, _ = w.Write(manBytes)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(indexBytes)
|
||||
case strings.Contains(r.URL.Path, "/blobs/"):
|
||||
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
b, ok := blobs[ref]
|
||||
if !ok {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
servis[ref]++
|
||||
_, _ = w.Write(b)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ancien := ociBase
|
||||
ociBase = srv.URL
|
||||
t.Cleanup(func() { ociBase = ancien })
|
||||
return servis
|
||||
}
|
||||
|
||||
// --- Tests ------------------------------------------------------------------
|
||||
|
||||
// Le cas réel : trois couches utiles au-dessus d'un runtime de 2 Go. On doit
|
||||
// prendre le binaire ET les bibliothèques, résoudre les liens de version, et
|
||||
// ne JAMAIS descendre jusqu'au runtime.
|
||||
func TestInstallationMoteurDepuisImage(t *testing.T) {
|
||||
testHome(t)
|
||||
couches := [][]byte{
|
||||
fauxCouche(t, map[string]string{"usr/local/cuda/lib64/libcudart.so": "runtime CUDA"}), // 2 Go annoncés
|
||||
fauxCouche(t, map[string]string{
|
||||
"app/libggml-base.so": "-> libggml-base.so.0",
|
||||
"app/libggml-base.so.0": "-> libggml-base.so.0.20.0",
|
||||
"app/libggml-base.so.0.20.0": "binaire ELF",
|
||||
"app/libllama.so": "-> libllama.so.0",
|
||||
"app/libllama.so.0": "-> libllama.so.0.1.0",
|
||||
"app/libllama.so.0.1.0": "binaire ELF",
|
||||
"app/libggml-cuda.so": "binaire ELF",
|
||||
}),
|
||||
fauxCouche(t, map[string]string{"app/llama-server": "#!/bin/true\n", "app/llama": "#!/bin/true\n"}),
|
||||
fauxCouche(t, map[string]string{"etc/passwd": "sans rapport"}),
|
||||
}
|
||||
servis := fauxRegistre(t, "b10450", couches, []int64{2 << 30, 0, 0, 0})
|
||||
|
||||
bin, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if veut := filepath.Join(engineDir(), "server-cuda-b10450", "llama-server"); bin != veut {
|
||||
t.Errorf("binaire installé en %s, attendu %s", bin, veut)
|
||||
}
|
||||
|
||||
// La couche du runtime CUDA ne doit pas avoir été demandée : c'est tout
|
||||
// l'intérêt (170 Mo au lieu de 2,6 Go).
|
||||
sum := sha256.Sum256(couches[0])
|
||||
if n := servis["sha256:"+hex.EncodeToString(sum[:])]; n != 0 {
|
||||
t.Errorf("la couche du runtime CUDA a été téléchargée %d fois", n)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(bin)
|
||||
for _, nom := range []string{"llama-server", "llama", "libggml-cuda.so", "libggml-base.so.0.20.0", "libllama.so.0.1.0"} {
|
||||
if !isFile(filepath.Join(dir, nom)) {
|
||||
t.Errorf("%s manquant", nom)
|
||||
}
|
||||
}
|
||||
// Les noms courts sont ceux que l'éditeur de liens cherche au lancement :
|
||||
// sans eux le moteur ne démarre pas, alors que tout « a l'air » installé.
|
||||
for _, lien := range []string{"libggml-base.so", "libggml-base.so.0", "libllama.so", "libllama.so.0"} {
|
||||
p := filepath.Join(dir, lien)
|
||||
if _, err := os.Stat(p); err != nil { // Stat suit le lien : vérifie aussi qu'il ne pend pas
|
||||
t.Errorf("%s : %v", lien, err)
|
||||
}
|
||||
}
|
||||
// /app à plat, et rien d'autre que /app.
|
||||
if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) {
|
||||
t.Error("des fichiers hors /app ont été extraits")
|
||||
}
|
||||
if st, err := os.Stat(bin); err != nil || st.Mode()&0o100 == 0 {
|
||||
t.Errorf("llama-server non exécutable : %v", err)
|
||||
}
|
||||
|
||||
// Réinstaller la même version ne redemande RIEN au registre : le job de mise
|
||||
// à jour repasse par là à chaque vérification.
|
||||
total := func() int {
|
||||
n := 0
|
||||
for _, v := range servis {
|
||||
n += v
|
||||
}
|
||||
return n
|
||||
}
|
||||
avant := total()
|
||||
if _, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total() != avant {
|
||||
t.Errorf("une version déjà installée a été re-téléchargée (%d → %d couches)", avant, total())
|
||||
}
|
||||
|
||||
// Et la version se retrouve dans la liste des moteurs installés.
|
||||
if got := engineInstalled(); len(got) != 1 || got[0] != "server-cuda-b10450" {
|
||||
t.Errorf("versions installées = %v", got)
|
||||
}
|
||||
if engineTagBuild("server-cuda-b10450") != 10450 {
|
||||
t.Error("numéro de build mal extrait du tag")
|
||||
}
|
||||
if !engineOwns(bin) {
|
||||
t.Error("le binaire installé n'est pas reconnu comme géré par Loki")
|
||||
}
|
||||
}
|
||||
|
||||
// Une variante dont l'image ne contient pas de llama-server (mauvais tag, image
|
||||
// « full » sans serveur) doit échouer CLAIREMENT, et ne rien laisser derrière
|
||||
// elle qui ressemblerait à un moteur installé.
|
||||
func TestInstallationRefuseUneImageSansMoteur(t *testing.T) {
|
||||
testHome(t)
|
||||
fauxRegistre(t, "b10450", [][]byte{
|
||||
fauxCouche(t, map[string]string{"app/README": "pas de moteur ici"}),
|
||||
}, nil)
|
||||
|
||||
if _, err := engineOCIInstall("server-b1", func(string) {}, func(string) {}); err == nil {
|
||||
t.Fatal("installation acceptée sans llama-server")
|
||||
}
|
||||
if got := engineInstalled(); len(got) != 0 {
|
||||
t.Errorf("versions installées après échec = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// engineOCILatest lit le numéro de build dans l'annotation de l'image, et en
|
||||
// déduit le tag figé — c'est lui qu'on installe, pour que « mis à jour vers »
|
||||
// désigne une version précise et reproductible plutôt qu'un tag mouvant.
|
||||
func TestDerniereVersionPubliee(t *testing.T) {
|
||||
fauxRegistre(t, "b10450", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
|
||||
build, tag, err := engineOCILatest("server-cuda")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if build != 10450 || tag != "server-cuda-b10450" {
|
||||
t.Errorf("build=%d tag=%q", build, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// La bannière du moteur est la seule source de vérité sur la version qui tourne
|
||||
// vraiment — le tag de l'image, lui, ment dès qu'on a basculé de moteur.
|
||||
func TestLectureVersionMoteur(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
nom, sortie string
|
||||
build int
|
||||
commit string
|
||||
}{
|
||||
// Relevée sur le binaire réel de l'image server-cuda-b10450.
|
||||
{"forme actuelle", "version: 0.1.0-dev (build 10450, commit ece963f41)\nbuilt with GNU 14.2.0 for Linux x86_64\n", 10450, "ece963f41"},
|
||||
{"forme précédente", "version: 10450 (ece963f)\nbuilt with cc\n", 10450, "ece963f"},
|
||||
{"forme ancienne", "build: 4589 (a1b2c3d)\n", 4589, "a1b2c3d"},
|
||||
{"bannière de démarrage", "ggml_cuda_init: found 1 device\nversion: 9001 (deadbee)\n", 9001, "deadbee"},
|
||||
{"rien d'exploitable", "usage: llama-server [options]\n", 0, ""},
|
||||
} {
|
||||
t.Run(c.nom, func(t *testing.T) {
|
||||
build, commit := parseEngineBuild(c.sortie)
|
||||
if build != c.build || commit != c.commit {
|
||||
t.Errorf("= (%d, %q), attendu (%d, %q)", build, commit, c.build, c.commit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Le filtre des entrées d'archive : on ne veut QUE les fichiers directs de /app.
|
||||
// Un sous-dossier ou un marqueur de suppression OCI recopié à plat produirait
|
||||
// des fichiers parasites à côté du binaire.
|
||||
func TestFiltreDesEntreesApp(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
entree, veut string
|
||||
ok bool
|
||||
}{
|
||||
{"app/llama-server", "llama-server", true},
|
||||
{"./app/libllama.so", "libllama.so", true},
|
||||
{"app/", "", false},
|
||||
{"app/tools/quantize", "", false},
|
||||
{"app/.wh.libggml-cuda.so", "", false},
|
||||
{"usr/local/cuda/lib64/libcudart.so", "", false},
|
||||
{"apparence/piege", "", false},
|
||||
} {
|
||||
got, ok := appEntryName(c.entree)
|
||||
if ok != c.ok || got != c.veut {
|
||||
t.Errorf("%q → (%q, %v), attendu (%q, %v)", c.entree, got, ok, c.veut, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le binaire seul ne fait pas un moteur : c'est la règle d'arrêt de la descente
|
||||
// des couches.
|
||||
func TestMoteurCompletExigeLesBibliotheques(t *testing.T) {
|
||||
vu := func(noms ...string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, n := range noms {
|
||||
m[n] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
if engineSetComplete(vu("llama-server")) {
|
||||
t.Error("binaire seul accepté comme moteur complet")
|
||||
}
|
||||
if engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0")) {
|
||||
t.Error("moteur sans libllama accepté")
|
||||
}
|
||||
if !engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0", "libllama.so.0.1.0")) {
|
||||
t.Error("moteur complet refusé")
|
||||
}
|
||||
}
|
||||
|
||||
// La variante se déduit des backends posés à côté du binaire : c'est ce qui
|
||||
// évite de proposer une image CUDA à une installation Vulkan (et l'inverse).
|
||||
func TestVarianteDeduiteDesBackends(t *testing.T) {
|
||||
for _, c := range []struct{ backend, veut string }{
|
||||
{"libggml-cuda.so", "server-cuda"},
|
||||
{"libggml-vulkan.so", "server-vulkan"},
|
||||
{"libggml-sycl.so", "server-intel"},
|
||||
{"libggml-musa.so", "server-musa"},
|
||||
{"", "server"},
|
||||
} {
|
||||
t.Run(c.veut, func(t *testing.T) {
|
||||
home := testHome(t)
|
||||
dir := filepath.Join(home, "faux-moteur")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(dir, "llama-server")
|
||||
if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.backend != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, c.backend), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := engineVariant(); got != c.veut {
|
||||
t.Errorf("variante = %q, attendu %q", got, c.veut)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Le ménage garde la version demandée et efface les autres : sans lui, chaque
|
||||
// mise à jour laisse ~450 Mo sur le volume de données.
|
||||
func TestMenageDesAnciennesVersions(t *testing.T) {
|
||||
testHome(t)
|
||||
for _, tag := range []string{"server-cuda-b1", "server-cuda-b2", "server-cuda-b3"} {
|
||||
d := filepath.Join(engineDir(), tag)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
enginePrune(map[string]bool{"server-cuda-b3": true}, nil)
|
||||
got := engineInstalled()
|
||||
if fmt.Sprint(got) != "[server-cuda-b3]" {
|
||||
t.Errorf("après ménage : %v", got)
|
||||
}
|
||||
}
|
||||
@@ -440,8 +440,18 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) {
|
||||
// downloadWithProgress télécharge url vers dest en journalisant la progression
|
||||
// par tranches de ~25 Mo.
|
||||
func downloadWithProgress(url, dest string, total int64, logf func(string)) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return downloadRequest(req, dest, total, logf)
|
||||
}
|
||||
|
||||
// downloadRequest est la même chose pour une requête déjà construite — un
|
||||
// registre OCI exige un en-tête d'autorisation, même en lecture anonyme.
|
||||
func downloadRequest(req *http.Request, dest string, total int64, logf func(string)) error {
|
||||
client := &http.Client{Timeout: 0}
|
||||
resp, err := client.Get(url)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+119
-10
@@ -1028,6 +1028,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px}
|
||||
.mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
/* Version du moteur : même pastille, mais SANS majuscules forcées — un numéro
|
||||
de build et un hash de commit se recopient et se comparent à l'œil, et
|
||||
« AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */
|
||||
.eng-ver{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--text)}
|
||||
.mcp-tools{font-size:10px;color:var(--accent)}
|
||||
.mcp-err{font-size:10px;color:var(--err)}
|
||||
.mcp-switch{flex:none}
|
||||
@@ -1849,7 +1853,15 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
de la colonne. */
|
||||
#side-scroll{padding:0 12px 12px}
|
||||
#side-scroll>details>summary,#side-settings>summary{padding:0 2px}
|
||||
#side-settings>.sidegroup{padding:0}
|
||||
/* ⚠️ La gouttière INTÉRIEURE de la carte n'est pas décorative. En la mettant à
|
||||
zéro (le retrait extérieur venant désormais de #side-scroll), les lignes
|
||||
allaient d'un bord à l'autre : le chevron de chaque section tombait dans le
|
||||
congé de 20 px, donc il en débordait visuellement sur la première et la
|
||||
dernière ligne, et les filets de séparation venaient buter dans la courbe.
|
||||
Le rayon paraissait « trop fort » alors que c'est le contenu qui n'avait plus
|
||||
de quoi s'en écarter. On reprend le retrait d'avant la refonte (16 px), qui
|
||||
laisse au congé la place de tourner à vide. */
|
||||
#side-settings>.sidegroup{padding:2px 16px}
|
||||
@media (max-width:720px){
|
||||
#side-scroll{padding-left:calc(12px + env(safe-area-inset-left))}
|
||||
}
|
||||
@@ -2041,13 +2053,24 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||
étaient des cartes dans une carte). -->
|
||||
<div class="slist">
|
||||
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
|
||||
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
|
||||
<!-- Moteur fourni par l'image Docker : aucun des trois modes d'installation
|
||||
ne s'applique (rien à compiler, et l'image n'embarque ni cmake ni
|
||||
compilateur). En revanche llama.cpp publie plusieurs versions par jour,
|
||||
et son moteur déjà compilé est téléchargeable seul (~170 Mo) : cette
|
||||
carte le met à jour sans reconstruire l'image. -->
|
||||
<div id="lc-provided" class="rowbody" style="display:none">
|
||||
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
|
||||
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
|
||||
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
|
||||
<div style="font-size:12px;line-height:1.5">
|
||||
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
|
||||
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
||||
<div id="eng-latest" style="margin-top:6px"></div>
|
||||
<div class="row" style="margin:8px 0 0">
|
||||
<button onclick="engCheck()">vérifier la version</button>
|
||||
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
||||
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px">Télécharge llama-server et ses bibliothèques depuis l'image officielle
|
||||
<code id="eng-repo"></code> (~170 Mo), sans reconstruire l'image de Loki. Le nouveau moteur est essayé
|
||||
avant d'être adopté ; celui de l'image reste intact et récupérable en un clic.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
|
||||
@@ -3252,6 +3275,12 @@ async function loadCfg(){
|
||||
// En conteneur, le moteur vient de l'image : le dire, plutôt que de le
|
||||
// ranger dans « personnalisé » qui laisse croire à un bricolage.
|
||||
if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image';
|
||||
// Moteur mis à jour depuis l'image officielle (Réglages → Moteur) : c'est la
|
||||
// voie normale en conteneur, pas un « personnalisé ».
|
||||
else if(lc && lc.engine && lc.engine.in_use){
|
||||
const b = (lc.engine.tag||'').match(/b\d+$/);
|
||||
v = b ? 'llama.cpp '+b[0]+' (mis à jour)' : 'llama.cpp mis à jour';
|
||||
}
|
||||
else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
||||
else if(lc && lc.in_use) v='llama.cpp compilé';
|
||||
else v='llama.cpp personnalisé : '+c.BIN;
|
||||
@@ -5712,7 +5741,8 @@ async function loadLlamacpp(){
|
||||
let lcSeenEnd = false, lcEndShown = false;
|
||||
function lcChipLabel(action){
|
||||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||||
prebuilt:'Téléchargement du moteur', custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
|
||||
custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
}
|
||||
function lcChipSync(j){
|
||||
const chip = document.getElementById('lc-chip');
|
||||
@@ -5753,7 +5783,7 @@ function lcRenderReco(reco){
|
||||
if(desc && reco && reco.why) desc.textContent = reco.why;
|
||||
}
|
||||
|
||||
// Bascule entre « moteur de l'image » (une ligne d'état) et les trois modes
|
||||
// Bascule entre la carte « moteur de l'image » et les trois modes
|
||||
// d'installation gérés par Loki.
|
||||
function lcRenderProvided(s){
|
||||
const box = document.getElementById('lc-provided');
|
||||
@@ -5764,8 +5794,87 @@ function lcRenderProvided(s){
|
||||
if(!s.provided) return;
|
||||
const bin = document.getElementById('lc-provided-bin');
|
||||
if(bin) bin.textContent = s.config_bin || '';
|
||||
const job = document.getElementById('lc-job');
|
||||
if(job) job.style.display = 'none';
|
||||
loadEngine();
|
||||
// Un job de mise à jour du moteur peut être en cours : le bloc de progression
|
||||
// était masqué ici tant que la carte ne servait qu'à dire « rien à faire ».
|
||||
if(s.job && s.job.exists && s.job.running && !lcPoll){
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
lcChipSync(s.job);
|
||||
}
|
||||
|
||||
// --- Moteur de l'image : le mettre à jour sans reconstruire l'image ---------
|
||||
// llama.cpp publie plusieurs versions par jour ; son moteur déjà compilé vit
|
||||
// dans l'image officielle, dont on n'extrait que /app (~170 Mo). Voir
|
||||
// web_engine.go pour le détail, et pourquoi ce n'est pas une release GitHub :
|
||||
// llama.cpp n'en publie aucune pour CUDA/Linux.
|
||||
let engState = null;
|
||||
|
||||
async function loadEngine(){
|
||||
let s;
|
||||
try{ s = await jget('/api/engine'); }catch(_){ return; }
|
||||
engState = s;
|
||||
const build = document.getElementById('eng-build');
|
||||
if(build) build.textContent = s.build ? ('b'+s.build+(s.commit ? ' · '+s.commit : '')) : 'version inconnue';
|
||||
const src = document.getElementById('eng-source');
|
||||
if(src) src.textContent = {image:'fourni par l\'image', downloaded:'mis à jour par Loki',
|
||||
prebuilt:'précompilé', custom:'personnalisé'}[s.source] || '';
|
||||
const repo = document.getElementById('eng-repo');
|
||||
if(repo) repo.textContent = s.repo + ':' + (s.variant || 'server');
|
||||
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
|
||||
const revert = document.getElementById('eng-revert');
|
||||
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
|
||||
engSay('');
|
||||
}
|
||||
|
||||
// engSay écrit sous la ligne d'état : c'est le résultat d'une vérification, qui
|
||||
// doit rester lisible après le toast (on ne se souvient pas d'un toast).
|
||||
function engSay(html, cls){
|
||||
const el = document.getElementById('eng-latest');
|
||||
if(!el) return;
|
||||
el.innerHTML = html;
|
||||
el.className = cls || 'muted';
|
||||
el.style.display = html ? '' : 'none';
|
||||
}
|
||||
|
||||
async function engCheck(){
|
||||
toast('vérification…');
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/check', {}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ engSay('✗ '+String(r.error||'').replace(/[<>&]/g,'')); toast('erreur'); return; }
|
||||
if(r.update){
|
||||
engSay('Version disponible : <b>b'+r.latest+'</b>'+(r.current ? ' (installée : b'+r.current+')' : '')
|
||||
+ ' — « mettre à jour le moteur » pour l\'installer.');
|
||||
toast('mise à jour disponible : b'+r.latest);
|
||||
} else {
|
||||
engSay('Moteur à jour ✓ (b'+r.latest+')');
|
||||
toast('moteur à jour ✓');
|
||||
}
|
||||
}
|
||||
|
||||
async function engUpdate(){
|
||||
if(!await askConfirm('Télécharger la dernière version de llama.cpp (~170 Mo) et l\'utiliser à la place du moteur de l\'image.\n\n'
|
||||
+ 'Le moteur est essayé avant d\'être adopté ; celui de l\'image reste intact. Le moteur redémarre à la fin — la génération en cours sera coupée.',
|
||||
{title:'Mettre à jour le moteur', okText:'Mettre à jour'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/update', {}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
|
||||
// Bascule vers une version déjà installée — « image » = celle d'origine.
|
||||
async function engUse(tag){
|
||||
const image = tag === 'image';
|
||||
if(!await askConfirm(image ? 'Repasser sur le moteur livré avec l\'image Docker. Le moteur redémarre.'
|
||||
: 'Utiliser la version « '+tag+' ». Le moteur redémarre.',
|
||||
{title:'Changer de moteur', okText:'Basculer'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/use', {tag}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
toast('moteur basculé — redémarrage en cours');
|
||||
loadAll();
|
||||
}
|
||||
|
||||
function lcRenderMode(mode, installed){
|
||||
|
||||
@@ -189,13 +189,24 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||
étaient des cartes dans une carte). -->
|
||||
<div class="slist">
|
||||
<!-- Moteur fourni par l'image Docker : aucun des trois modes ne s'applique
|
||||
(rien à installer, et l'image n'embarque ni cmake ni compilateur). -->
|
||||
<!-- Moteur fourni par l'image Docker : aucun des trois modes d'installation
|
||||
ne s'applique (rien à compiler, et l'image n'embarque ni cmake ni
|
||||
compilateur). En revanche llama.cpp publie plusieurs versions par jour,
|
||||
et son moteur déjà compilé est téléchargeable seul (~170 Mo) : cette
|
||||
carte le met à jour sans reconstruire l'image. -->
|
||||
<div id="lc-provided" class="rowbody" style="display:none">
|
||||
<div style="font-size:12px;line-height:1.5">Moteur <b>fourni par l'image Docker</b> — llama.cpp officiel, déjà compilé.
|
||||
<div class="muted" style="margin-top:4px">Rien à installer ni à mettre à jour ici : le moteur suit l'image.
|
||||
Pour en changer (version épinglée, CPU, Vulkan), reconstruis l'image avec le build-arg <code>LLAMACPP_IMAGE</code>.</div>
|
||||
<div style="font-size:12px;line-height:1.5">
|
||||
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
|
||||
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
||||
<div id="eng-latest" style="margin-top:6px"></div>
|
||||
<div class="row" style="margin:8px 0 0">
|
||||
<button onclick="engCheck()">vérifier la version</button>
|
||||
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
||||
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px">Télécharge llama-server et ses bibliothèques depuis l'image officielle
|
||||
<code id="eng-repo"></code> (~170 Mo), sans reconstruire l'image de Loki. Le nouveau moteur est essayé
|
||||
avant d'être adopté ; celui de l'image reste intact et récupérable en un clic.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
|
||||
|
||||
@@ -201,6 +201,12 @@ async function loadCfg(){
|
||||
// En conteneur, le moteur vient de l'image : le dire, plutôt que de le
|
||||
// ranger dans « personnalisé » qui laisse croire à un bricolage.
|
||||
if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image';
|
||||
// Moteur mis à jour depuis l'image officielle (Réglages → Moteur) : c'est la
|
||||
// voie normale en conteneur, pas un « personnalisé ».
|
||||
else if(lc && lc.engine && lc.engine.in_use){
|
||||
const b = (lc.engine.tag||'').match(/b\d+$/);
|
||||
v = b ? 'llama.cpp '+b[0]+' (mis à jour)' : 'llama.cpp mis à jour';
|
||||
}
|
||||
else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
||||
else if(lc && lc.in_use) v='llama.cpp compilé';
|
||||
else v='llama.cpp personnalisé : '+c.BIN;
|
||||
|
||||
@@ -56,7 +56,8 @@ async function loadLlamacpp(){
|
||||
let lcSeenEnd = false, lcEndShown = false;
|
||||
function lcChipLabel(action){
|
||||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||||
prebuilt:'Téléchargement du moteur', custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
|
||||
custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
}
|
||||
function lcChipSync(j){
|
||||
const chip = document.getElementById('lc-chip');
|
||||
@@ -97,7 +98,7 @@ function lcRenderReco(reco){
|
||||
if(desc && reco && reco.why) desc.textContent = reco.why;
|
||||
}
|
||||
|
||||
// Bascule entre « moteur de l'image » (une ligne d'état) et les trois modes
|
||||
// Bascule entre la carte « moteur de l'image » et les trois modes
|
||||
// d'installation gérés par Loki.
|
||||
function lcRenderProvided(s){
|
||||
const box = document.getElementById('lc-provided');
|
||||
@@ -108,8 +109,87 @@ function lcRenderProvided(s){
|
||||
if(!s.provided) return;
|
||||
const bin = document.getElementById('lc-provided-bin');
|
||||
if(bin) bin.textContent = s.config_bin || '';
|
||||
const job = document.getElementById('lc-job');
|
||||
if(job) job.style.display = 'none';
|
||||
loadEngine();
|
||||
// Un job de mise à jour du moteur peut être en cours : le bloc de progression
|
||||
// était masqué ici tant que la carte ne servait qu'à dire « rien à faire ».
|
||||
if(s.job && s.job.exists && s.job.running && !lcPoll){
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
lcChipSync(s.job);
|
||||
}
|
||||
|
||||
// --- Moteur de l'image : le mettre à jour sans reconstruire l'image ---------
|
||||
// llama.cpp publie plusieurs versions par jour ; son moteur déjà compilé vit
|
||||
// dans l'image officielle, dont on n'extrait que /app (~170 Mo). Voir
|
||||
// web_engine.go pour le détail, et pourquoi ce n'est pas une release GitHub :
|
||||
// llama.cpp n'en publie aucune pour CUDA/Linux.
|
||||
let engState = null;
|
||||
|
||||
async function loadEngine(){
|
||||
let s;
|
||||
try{ s = await jget('/api/engine'); }catch(_){ return; }
|
||||
engState = s;
|
||||
const build = document.getElementById('eng-build');
|
||||
if(build) build.textContent = s.build ? ('b'+s.build+(s.commit ? ' · '+s.commit : '')) : 'version inconnue';
|
||||
const src = document.getElementById('eng-source');
|
||||
if(src) src.textContent = {image:'fourni par l\'image', downloaded:'mis à jour par Loki',
|
||||
prebuilt:'précompilé', custom:'personnalisé'}[s.source] || '';
|
||||
const repo = document.getElementById('eng-repo');
|
||||
if(repo) repo.textContent = s.repo + ':' + (s.variant || 'server');
|
||||
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
|
||||
const revert = document.getElementById('eng-revert');
|
||||
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
|
||||
engSay('');
|
||||
}
|
||||
|
||||
// engSay écrit sous la ligne d'état : c'est le résultat d'une vérification, qui
|
||||
// doit rester lisible après le toast (on ne se souvient pas d'un toast).
|
||||
function engSay(html, cls){
|
||||
const el = document.getElementById('eng-latest');
|
||||
if(!el) return;
|
||||
el.innerHTML = html;
|
||||
el.className = cls || 'muted';
|
||||
el.style.display = html ? '' : 'none';
|
||||
}
|
||||
|
||||
async function engCheck(){
|
||||
toast('vérification…');
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/check', {}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ engSay('✗ '+String(r.error||'').replace(/[<>&]/g,'')); toast('erreur'); return; }
|
||||
if(r.update){
|
||||
engSay('Version disponible : <b>b'+r.latest+'</b>'+(r.current ? ' (installée : b'+r.current+')' : '')
|
||||
+ ' — « mettre à jour le moteur » pour l\'installer.');
|
||||
toast('mise à jour disponible : b'+r.latest);
|
||||
} else {
|
||||
engSay('Moteur à jour ✓ (b'+r.latest+')');
|
||||
toast('moteur à jour ✓');
|
||||
}
|
||||
}
|
||||
|
||||
async function engUpdate(){
|
||||
if(!await askConfirm('Télécharger la dernière version de llama.cpp (~170 Mo) et l\'utiliser à la place du moteur de l\'image.\n\n'
|
||||
+ 'Le moteur est essayé avant d\'être adopté ; celui de l\'image reste intact. Le moteur redémarre à la fin — la génération en cours sera coupée.',
|
||||
{title:'Mettre à jour le moteur', okText:'Mettre à jour'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/update', {}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
|
||||
// Bascule vers une version déjà installée — « image » = celle d'origine.
|
||||
async function engUse(tag){
|
||||
const image = tag === 'image';
|
||||
if(!await askConfirm(image ? 'Repasser sur le moteur livré avec l\'image Docker. Le moteur redémarre.'
|
||||
: 'Utiliser la version « '+tag+' ». Le moteur redémarre.',
|
||||
{title:'Changer de moteur', okText:'Basculer'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/use', {tag}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
toast('moteur basculé — redémarrage en cours');
|
||||
loadAll();
|
||||
}
|
||||
|
||||
function lcRenderMode(mode, installed){
|
||||
|
||||
@@ -997,6 +997,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px}
|
||||
.mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
/* Version du moteur : même pastille, mais SANS majuscules forcées — un numéro
|
||||
de build et un hash de commit se recopient et se comparent à l'œil, et
|
||||
« AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */
|
||||
.eng-ver{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--text)}
|
||||
.mcp-tools{font-size:10px;color:var(--accent)}
|
||||
.mcp-err{font-size:10px;color:var(--err)}
|
||||
.mcp-switch{flex:none}
|
||||
@@ -1818,7 +1822,15 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
de la colonne. */
|
||||
#side-scroll{padding:0 12px 12px}
|
||||
#side-scroll>details>summary,#side-settings>summary{padding:0 2px}
|
||||
#side-settings>.sidegroup{padding:0}
|
||||
/* ⚠️ La gouttière INTÉRIEURE de la carte n'est pas décorative. En la mettant à
|
||||
zéro (le retrait extérieur venant désormais de #side-scroll), les lignes
|
||||
allaient d'un bord à l'autre : le chevron de chaque section tombait dans le
|
||||
congé de 20 px, donc il en débordait visuellement sur la première et la
|
||||
dernière ligne, et les filets de séparation venaient buter dans la courbe.
|
||||
Le rayon paraissait « trop fort » alors que c'est le contenu qui n'avait plus
|
||||
de quoi s'en écarter. On reprend le retrait d'avant la refonte (16 px), qui
|
||||
laisse au congé la place de tourner à vide. */
|
||||
#side-settings>.sidegroup{padding:2px 16px}
|
||||
@media (max-width:720px){
|
||||
#side-scroll{padding-left:calc(12px + env(safe-area-inset-left))}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// web_engine.go — panneau « Moteur de l'image » : voir la version de llama.cpp
|
||||
// qui tourne, savoir s'il en existe une plus récente, l'installer, et revenir
|
||||
// en arrière si elle ne convient pas.
|
||||
//
|
||||
// GET /api/engine → version courante, versions installées, variante
|
||||
// POST /api/engine/check → dernier build publié vs celui qui tourne
|
||||
// POST /api/engine/update → job : téléchargement + test à blanc + bascule
|
||||
// POST /api/engine/use → bascule vers une version déjà installée
|
||||
// POST /api/engine/remove → supprime une version installée
|
||||
//
|
||||
// Jusqu'ici, mettre à jour llama.cpp imposait de reconstruire l'image de Loki —
|
||||
// donc un rebuild complet et un redéploiement pour un composant qui publie
|
||||
// plusieurs versions par jour. Le panneau ne le disait même pas : il annonçait
|
||||
// « rien à mettre à jour ici ». Ces quatre routes suppriment ce détour.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// engineUseImage est la valeur de `tag` qui désigne le moteur d'origine, celui
|
||||
// que l'image Docker fournit dans /app. C'est le retour arrière garanti : il ne
|
||||
// dépend d'aucun téléchargement et ne peut pas manquer.
|
||||
const engineUseImage = "image"
|
||||
|
||||
// handleEngineStatus décrit le moteur courant et ce qui est installé à côté.
|
||||
func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
|
||||
bin := currentEngineBin()
|
||||
build, commit := engineBuildOf(bin)
|
||||
imageBin := providedEngineBin()
|
||||
|
||||
source := "custom"
|
||||
switch {
|
||||
case imageBin != "" && samePath(bin, imageBin):
|
||||
source = "image"
|
||||
case engineOwns(bin):
|
||||
source = "downloaded"
|
||||
case prebuiltOwns(bin):
|
||||
source = "prebuilt"
|
||||
}
|
||||
|
||||
var installed []map[string]any
|
||||
for _, tag := range engineInstalled() {
|
||||
p := engineBinFor(tag)
|
||||
installed = append(installed, map[string]any{
|
||||
"tag": tag,
|
||||
"bin": p,
|
||||
"build": engineTagBuild(tag),
|
||||
"in_use": samePath(p, bin),
|
||||
})
|
||||
}
|
||||
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"supported": engineOCISupported(),
|
||||
"variant": engineVariant(),
|
||||
"repo": ociHost() + "/" + ociEngineRepo,
|
||||
"bin": bin,
|
||||
"build": build,
|
||||
"commit": commit,
|
||||
"source": source,
|
||||
"image_bin": imageBin,
|
||||
"dir": engineDir(),
|
||||
"installed": installed,
|
||||
"job": lcJobSnapshot(0, false),
|
||||
})
|
||||
}
|
||||
|
||||
// handleEngineCheck interroge le registre : quel est le dernier build publié
|
||||
// pour cette variante, et sommes-nous en retard ? Synchrone, quelques secondes.
|
||||
func handleEngineCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if !engineOCISupported() {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
|
||||
return
|
||||
}
|
||||
variant := engineVariant()
|
||||
build, tag, err := engineOCILatest(variant)
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cur, _ := engineBuildOf(currentEngineBin())
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"variant": variant,
|
||||
"latest": build,
|
||||
"tag": tag,
|
||||
"current": cur,
|
||||
// Un build courant inconnu (0) ne prouve pas qu'on est à jour : on
|
||||
// propose alors la mise à jour plutôt que d'affirmer le contraire.
|
||||
"update": build == 0 || cur == 0 || build > cur,
|
||||
})
|
||||
}
|
||||
|
||||
// handleEngineUpdate lance le job de mise à jour. {tag} permet d'épingler une
|
||||
// version précise ; sans lui, on prend la dernière publiée pour la variante.
|
||||
func handleEngineUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !engineOCISupported() {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
tag := strings.TrimSpace(req.Tag)
|
||||
if err := startLcJob("engine", func() { engineRunUpdate(tag) }); err != nil {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func engineUnsupportedWhy() string {
|
||||
return "les images officielles llama.cpp n'existent que pour Linux x86-64 et arm64 ; " +
|
||||
"sur cette machine, utilise « llama.cpp précompilé » ou « llama.cpp compilé »."
|
||||
}
|
||||
|
||||
// engineRunUpdate : corps du job. Télécharger, ESSAYER, puis seulement basculer.
|
||||
// L'ordre compte — le seul vrai risque ici est un moteur plus récent que le
|
||||
// runtime CUDA de l'image, et on le découvre au test à blanc, moteur courant
|
||||
// encore intact.
|
||||
func engineRunUpdate(tag string) {
|
||||
variant := engineVariant()
|
||||
ancien := currentEngineBin()
|
||||
if tag == "" {
|
||||
lcPhase("recherche de la dernière version publiée…")
|
||||
build, latest, err := engineOCILatest(variant)
|
||||
if err != nil {
|
||||
lcFail(err)
|
||||
return
|
||||
}
|
||||
tag = latest
|
||||
if build > 0 {
|
||||
if cur, _ := engineBuildOf(currentEngineBin()); cur == build {
|
||||
lcDone(fmt.Sprintf("déjà à jour (build %d)", build))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
lcAppend("variante : " + variant + " — version visée : " + tag)
|
||||
|
||||
bin, err := engineOCIInstall(tag, lcAppend, lcPhase)
|
||||
if err != nil {
|
||||
lcFail(err)
|
||||
return
|
||||
}
|
||||
|
||||
lcPhase("essai du nouveau moteur…")
|
||||
if err := engineSmokeTest(bin, ancien); err != nil {
|
||||
// On ne garde pas un moteur inutilisable : il encombrerait la liste et
|
||||
// inviterait à le sélectionner.
|
||||
_ = os.RemoveAll(filepath.Dir(bin))
|
||||
lcFail(fmt.Errorf("%w\n\nLe moteur courant n'a PAS été touché. Cette version demande sans doute un runtime CUDA plus récent que celui de l'image : reconstruis l'image avec un LLAMACPP_IMAGE récent, ou épingle un build antérieur", err))
|
||||
return
|
||||
}
|
||||
if build, commit := engineBuildOf(bin); build > 0 {
|
||||
lcAppend(fmt.Sprintf("moteur testé : build %d (%s)", build, commit))
|
||||
}
|
||||
|
||||
if err := engineSwitchTo(bin); err != nil {
|
||||
lcFail(err)
|
||||
return
|
||||
}
|
||||
// On garde la version qu'on vient de poser ; les précédentes ne servent plus
|
||||
// (le retour arrière se fait sur le moteur de l'image, qui, lui, est intact).
|
||||
enginePrune(map[string]bool{filepath.Base(filepath.Dir(bin)): true}, lcAppend)
|
||||
lcDone("moteur mis à jour (" + tag + ")")
|
||||
}
|
||||
|
||||
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
|
||||
func engineSwitchTo(bin string) error {
|
||||
wasUp := serviceIsActive()
|
||||
if wasUp {
|
||||
lcPhase("arrêt du moteur…")
|
||||
if err := serviceAction("stop"); err != nil {
|
||||
lcAppend("[warn] arrêt du moteur : " + err.Error())
|
||||
}
|
||||
}
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
if wasUp {
|
||||
_ = serviceAction("start")
|
||||
}
|
||||
return fmt.Errorf("moteur installé mais échec d'écriture de BIN : %w", err)
|
||||
}
|
||||
lcAppend("BIN → " + bin)
|
||||
if wasUp {
|
||||
lcPhase("redémarrage du moteur…")
|
||||
if err := serviceAction("start"); err != nil {
|
||||
lcAppend("[warn] redémarrage du moteur : " + err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleEngineUse bascule vers une version DÉJÀ installée, ou revient au moteur
|
||||
// de l'image ({tag:"image"}). Aucun réseau : c'est la sortie de secours quand une
|
||||
// mise à jour s'avère décevante.
|
||||
func handleEngineUse(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
tag := strings.TrimSpace(req.Tag)
|
||||
|
||||
var bin string
|
||||
if tag == engineUseImage {
|
||||
if bin = providedEngineBin(); bin == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "cette installation n'a pas de moteur d'image (LOKI_ENGINE_BIN)"})
|
||||
return
|
||||
}
|
||||
} else if bin = engineBinFor(tag); bin == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
|
||||
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})
|
||||
}
|
||||
|
||||
// handleEngineRemove supprime une version téléchargée. On refuse de retirer
|
||||
// celle qui tourne : ça laisserait une configuration pointant dans le vide, et
|
||||
// le moteur ne redémarrerait plus.
|
||||
func handleEngineRemove(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
tag := filepath.Base(strings.TrimSpace(req.Tag))
|
||||
bin := engineBinFor(tag)
|
||||
if bin == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
|
||||
return
|
||||
}
|
||||
if samePath(bin, currentEngineBin()) {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "c'est le moteur utilisé — bascule d'abord sur une autre version"})
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -168,6 +168,15 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) {
|
||||
// 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{
|
||||
@@ -224,8 +233,8 @@ func refuseIfProvided(w http.ResponseWriter) bool {
|
||||
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 à installer ici, et l'image ne contient ni cmake ni compilateur. " +
|
||||
"Pour changer de moteur, reconstruis l'image avec un autre build-arg LLAMACPP_IMAGE."})
|
||||
") : 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
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,13 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés
|
||||
api("/api/llamacpp/prebuilt/check", handleLlamacppPrebuiltCheck) // dernière release officielle vs installée
|
||||
api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées
|
||||
// Moteur livré par l'image : mise à jour SANS reconstruire l'image, en
|
||||
// extrayant /app de l'image officielle llama.cpp (voir web_engine.go).
|
||||
api("/api/engine", handleEngineStatus) // version qui tourne + versions installées
|
||||
api("/api/engine/check", handleEngineCheck) // dernier build publié vs courant
|
||||
api("/api/engine/update", handleEngineUpdate) // job : téléchargement + essai + bascule
|
||||
api("/api/engine/use", handleEngineUse) // bascule vers une version installée (ou "image")
|
||||
api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée
|
||||
api("/api/presets", handlePresets)
|
||||
api("/api/preset", handlePreset)
|
||||
api("/api/preset/save", handlePresetSave)
|
||||
|
||||
Reference in new issue
Block a user