Chercher et installer un modèle depuis Loki

Installer un modèle demandait d'aller sur huggingface.co, de naviguer dans
l'arborescence d'un dépôt et de coller un lien à la main. Rien ne disait si le
fichier tiendrait en mémoire, et surtout rien ne reliait un modèle à SON
projecteur vision : c'est ainsi qu'un mmproj-Qwen3VL-8B s'est retrouvé
configuré pour un Qwen3.8-27B — deux modèles sans rapport, moteur qui démarre
et ne voit rien.

La recherche Hugging Face arrive dans l'éditeur de preset. Elle ne remonte que
les dépôts GGUF ; déplier un dépôt montre ses quantifications avec leur taille
et un verdict mémoire, et propose le projecteur vision DU MÊME DÉPÔT — le seul
qui corresponde. Quand le dépôt n'en publie pas, Loki le dit au lieu d'aller en
chercher un ailleurs.

Le nouveau code ne télécharge rien : il produit des URL que le chemin existant
consomme tel quel (normalizeHFURL, shardURLSet, sonde d'espace disque, reprise
et annulation). Une file d'attente enchaîne modèle puis projecteur — le serveur
ne mène qu'un transfert à la fois, et le champ Vision ne se remplit que si le
modèle est déjà sélectionné.

Trois familles de .gguf cohabitent dans un dépôt et ne veulent pas dire la même
chose : mmproj-* (projecteur), mtp-* (poids de décodage spéculatif) et le
modèle. Les deux premières ressemblent à un modèle ; les proposer en vrac, ce
serait offrir de lancer llama-server sur un encodeur d'images. Les tranches
d'une famille sont repliées en une entrée de taille TOTALE : annoncer 15 Go
pour un modèle qui en occupe 45 promet une place qui n'existe pas.

Le verdict mémoire s'appuie enfin sur le GPU. detectHardware() ne renvoyait que
la RAM système alors que detectGPUs() existait déjà : sur un serveur à carte
NVIDIA, le verdict se prononçait sur la mauvaise grandeur. Le coût du cache KV
reste une estimation assumée — l'exact demanderait de parser l'en-tête GGUF —
et l'interface l'annonce comme telle plutôt que d'afficher un chiffre
faussement sûr.

Le catalogue ajean.link disparaît. Sa route n'avait aucun consommateur (l'écran
d'accueil qu'elle attendait n'a jamais existé), son repli embarqué proposait du
Qwen2.5 de 2024, et un fork qui laisse le serveur de l'amont décider de ce
qu'il propose n'est pas vraiment un fork.

Une piste écartée en cours de route : marquer les dépôts « vision » d'après les
tags Hugging Face. Ils mentent — des deux dépôts GGUF de Qwen3.8-27B qui
publient tous deux un mmproj, seul ggml-org est taggé image-text-to-text. Une
pastille sur l'un et pas sur l'autre aurait été pire que rien. La vision est
donc déduite de la seule source qui ne se trompe pas : la présence d'un
mmproj-*.gguf dans l'arborescence.

Vérifié : 8 tests unitaires sur les arborescences réelles des deux dépôts, puis
au navigateur contre le vrai Hugging Face — 25 dépôts trouvés, 3 quants listés
sans aucun mtp ni mmproj, projecteur Q8_0 proposé et coché, verdicts affichés
avec leur explication, modale dans l'écran. L'ordre de la file (modèle puis
projecteur) est testé en interceptant les appels, sans transfert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
This commit is contained in:
Claude committed 2026-08-16 11:02:35 +00:00
1 parent 1fa8452e44
commit 26e93f43ef
10 files changed
+1152 -66

No files matched your search

+30 -2
View File
@@ -43,8 +43,36 @@ docker compose up --build # quelques minutes : llama-server vient précompilé
# de l'image officielle llama.cpp (server-cuda)
```
Interface : http://localhost:8090 — télécharge un modèle depuis le catalogue
intégré (onglet modèles), il démarre tout seul.
Interface : http://localhost:8090 — installe un modèle depuis la recherche
Hugging Face intégrée (voir ci-dessous), il démarre tout seul.
## Installer un modèle
Dans l'éditeur de preset, **Chercher un modèle** interroge Hugging Face et ne
remonte que les dépôts GGUF. Choisir un dépôt déplie ses quantifications avec
leur taille et un verdict mémoire (`ok` / `juste` / `trop`) calculé sur la VRAM
réellement détectée — ou sur la RAM système s'il n'y a pas de GPU. C'est une
estimation : le coût exact du cache KV dépend de l'architecture du modèle, que
la seule liste des fichiers ne révèle pas.
Si le dépôt publie un projecteur vision (`mmproj-*.gguf`), Loki propose de
l'installer avec le modèle et remplit le champ **Vision** du preset. C'est le
seul moyen fiable d'avoir la vision : un projecteur encode dans l'espace latent
de **son** modèle, donc un `mmproj` pris dans un autre dépôt donne un moteur qui
démarre et ne voit rien. Quand le dépôt n'en publie pas, Loki le dit plutôt que
d'aller en chercher un ailleurs.
Deux repères pour choisir un dépôt :
- `unsloth/*` publie des quantifications **Dynamic** (`UD-Q4_K_XL`,
`UD-IQ3_XXS`…) qui gardent en plus haute précision les tenseurs sensibles :
à taille égale, elles se tiennent mieux qu'un `Q4_K_M` classique.
- `ggml-org/*` est le dépôt de référence de l'équipe llama.cpp — c'est en
général là que le projecteur vision est publié en premier.
Le champ **Télécharger un modèle** reste disponible pour coller un lien direct
(dépôt privé, fichier hors des conventions). Un dépôt à accès restreint demande
la variable d'environnement `HF_TOKEN`.
## Installation sur Unraid
+99 -57
View File
@@ -1,78 +1,120 @@
package loki
// backend_catalog.go — récupère la liste de modèles curatée servie par ajean.link et la
// combine avec les infos matérielles locales, pour que l'écran d'accueil (à
// venir) propose « en un clic » un modèle adapté à la machine.
// backend_catalog.go — ce que la machine peut avaler, et si un modèle y tient.
//
// La liste vit sur https://ajean.link/models.json (éditée par l'opérateur, voir
// loki-relay/models_catalog.go). Si le réseau échoue, on retombe sur un
// catalogue minimal embarqué (fallbackCatalogJSON) pour ne jamais bloquer.
// Ce fichier servait autrefois un catalogue de modèles téléchargé depuis
// ajean.link/models.json — le serveur de l'auteur du projet dont Loki est un
// fork. Deux raisons de l'avoir retiré : sa route n'avait AUCUN consommateur
// (l'« écran d'accueil » qu'elle attendait n'a jamais existé), et un fork qui
// laisse un tiers décider de ce qu'il propose n'est pas vraiment un fork. Les
// modèles se cherchent maintenant directement sur Hugging Face
// (backend_hf.go).
//
// Reste ici la seule question qui vaille avant de lancer un téléchargement de
// 20 Go : est-ce que ça tiendra ?
import (
"encoding/json"
"net/http"
"fmt"
"runtime"
"time"
"strconv"
"strings"
)
const catalogURL = "https://ajean.link/models.json"
type catalogModel struct {
ID string `json:"id"`
Name string `json:"name"`
Params string `json:"params"`
Quant string `json:"quant"`
SizeGB float64 `json:"size_gb"`
MinRAMGB float64 `json:"min_ram_gb"`
URL string `json:"url"`
Note string `json:"note"`
}
type catalog struct {
Version int `json:"version"`
Models []catalogModel `json:"models"`
type gpuBrief struct {
Name string `json:"name"`
VRAMGB float64 `json:"vram_gb"`
}
type hardwareInfo struct {
OS string `json:"os"`
Arch string `json:"arch"`
RAMGB float64 `json:"ram_gb"`
// VRAMGB : somme de la mémoire des GPU détectés. 0 = pas de GPU visible
// (ni nvidia-smi, ni carte) — le verdict retombe alors sur la RAM système,
// ce qui est le bon repère pour une inférence CPU.
VRAMGB float64 `json:"vram_gb"`
GPUs []gpuBrief `json:"gpus"`
}
// fetchCatalog récupère le catalogue distant, avec repli embarqué.
func fetchCatalog() catalog {
var c catalog
client := &http.Client{Timeout: 8 * time.Second}
if resp, err := client.Get(catalogURL); err == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 && json.NewDecoder(resp.Body).Decode(&c) == nil && len(c.Models) > 0 {
return c
}
}
_ = json.Unmarshal([]byte(fallbackCatalogJSON), &c)
return c
}
// detectHardware décrit la machine. La VRAM vient de detectGPUs
// (backend_gpu.go, via nvidia-smi) : sans elle, le verdict d'un serveur à GPU
// se prononçait sur la RAM système, donc à côté de la plaque — c'est le GPU
// qui porte le modèle quand NGL l'y envoie.
func detectHardware() hardwareInfo {
return hardwareInfo{OS: runtime.GOOS, Arch: runtime.GOARCH, RAMGB: totalRAMGB()}
h := hardwareInfo{OS: runtime.GOOS, Arch: runtime.GOARCH, RAMGB: totalRAMGB()}
gpus, err := detectGPUs()
if err != nil {
return h // pas de GPU visible : VRAMGB reste à 0, et c'est une info
}
for _, g := range gpus {
// nvidia-smi rend des MiB en --format=nounits.
mib, convErr := strconv.ParseFloat(strings.TrimSpace(g.MemTotal), 64)
if convErr != nil {
continue
}
gb := mib / 1024
h.VRAMGB += gb
h.GPUs = append(h.GPUs, gpuBrief{Name: g.Name, VRAMGB: gb})
}
return h
}
// handleCatalog : renvoie le catalogue + le matériel local. L'UI s'en sert pour
// marquer chaque modèle « tient / trop lourd » et proposer le bon par défaut.
func handleCatalog(w http.ResponseWriter, r *http.Request) {
resp := struct {
Hardware hardwareInfo `json:"hardware"`
Models []catalogModel `json:"models"`
}{Hardware: detectHardware(), Models: fetchCatalog().Models}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
// Verdicts rendus par fitVerdict.
const (
fitOK = "ok" // confortable
fitTight = "juste" // ça passe, sans marge
fitOver = "trop" // ça ne rentre pas
)
// ctxOverheadGB estime ce que le cache KV va prendre en plus des poids.
//
// ⚠️ C'est une RÈGLE GROSSIÈRE, et l'interface doit le dire. Le coût exact d'un
// cache KV dépend de l'architecture du modèle (nombre de couches, têtes KV,
// dimension des têtes), qu'une simple liste de fichiers ne révèle pas. Le
// connaître demanderait de lire l'en-tête GGUF par requête Range et d'en parser
// les métadonnées — faisable, mais ce n'est pas ce que fait ce code.
//
// L'ordre de grandeur retenu : ~1 Go par tranche de 32k de contexte pour un
// modèle de 20 Go, proportionnel à la taille des poids. Mieux vaut une
// fourchette annoncée comme telle qu'un chiffre faussement précis.
func ctxOverheadGB(weightsGB float64, ctxTokens int) float64 {
if ctxTokens <= 0 {
ctxTokens = 32768
}
return weightsGB / 20 * (float64(ctxTokens) / 32768)
}
// fallbackCatalogJSON — repli minimal si ajean.link/models.json est injoignable.
const fallbackCatalogJSON = `{
"version": 1,
"models": [
{"id":"qwen2.5-3b-instruct-q4","name":"Qwen2.5 3B Instruct","params":"3B","quant":"Q4_K_M","size_gb":2.1,"min_ram_gb":6,"url":"https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf","note":"Léger et rapide — idéal petites machines."},
{"id":"qwen2.5-7b-instruct-q4","name":"Qwen2.5 7B Instruct","params":"7B","quant":"Q4_K_M","size_gb":4.7,"min_ram_gb":10,"url":"https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf","note":"Plus capable — recommandé avec 16 Go de RAM ou un GPU."}
]
}`
// fitVerdict dit si un modèle tient, et POURQUOI. La phrase compte autant que
// le verdict : « trop » sans explication laisse l'utilisateur deviner s'il doit
// changer de quantification, baisser le contexte ou renoncer au projecteur.
func fitVerdict(h hardwareInfo, weights, mmproj int64, ctxTokens int) (string, string) {
const gb = float64(1 << 30)
wGB := float64(weights) / gb
mGB := float64(mmproj) / gb
kvGB := ctxOverheadGB(wGB, ctxTokens)
need := wGB + mGB + kvGB
budget, where := h.VRAMGB, "VRAM"
if budget <= 0 {
budget, where = h.RAMGB, "RAM"
}
if budget <= 0 {
return "", "" // machine non mesurable : pas de verdict inventé
}
detail := fmt.Sprintf("%.1f Go de poids", wGB)
if mGB > 0 {
detail += fmt.Sprintf(" + %.1f Go de projecteur", mGB)
}
detail += fmt.Sprintf(" + ~%.1f Go de contexte (estimation) = ~%.1f Go pour %.1f Go de %s",
kvGB, need, budget, where)
switch {
case need > budget:
return fitOver, "ne tient pas : " + detail
case need > budget*0.9:
return fitTight, "ça passe de justesse : " + detail
default:
return fitOK, detail
}
}
+328
View File
@@ -0,0 +1,328 @@
package loki
// backend_hf.go — chercher un modèle GGUF sur Hugging Face depuis Loki.
//
// Avant ce fichier, installer un modèle voulait dire aller sur huggingface.co,
// naviguer dans l'arborescence d'un dépôt, copier le lien d'un .gguf et le
// coller dans Loki. Rien ne disait si le fichier tenait en mémoire, et surtout
// rien ne reliait un modèle à SON projecteur vision : coller un mmproj pris
// dans un autre dépôt donne un moteur qui démarre et ne voit rien.
//
// Ce fichier ne télécharge rien. Il PRODUIT des URL directes, que le chemin
// existant consomme tel quel — normalizeHFURL, shardURLSet, la sonde d'espace
// disque et la reprise de téléchargement (backend_models.go) n'ont pas bougé.
//
// Trois familles de .gguf cohabitent dans un même dépôt et ne veulent pas dire
// la même chose :
//
// mmproj-*.gguf le projecteur vision, à passer en --mmproj
// mtp-*.gguf les poids de multi-token prediction (décodage spéculatif)
// le reste le modèle lui-même
//
// Les deux premiers ressemblent à s'y méprendre à un modèle : les proposer en
// vrac, c'est offrir de lancer llama-server sur un projecteur.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const (
hfHost = "https://huggingface.co"
// Les deux durées de cache disent la même chose : ces routes tapent un hôte
// externe à chaque appel, et l'UI en déclenche une par recherche. Une
// arborescence de dépôt ne bouge quasiment jamais, une liste de résultats un
// peu plus.
hfSearchTTL = 60 * time.Second
hfFilesTTL = 10 * time.Minute
hfMaxRepos = 25
hfMaxQuery = 100
hfTimeout = 10 * time.Second
)
// hfRepoRe borne ce qu'on accepte comme identifiant de dépôt. La valeur vient
// du navigateur et part dans un chemin d'URL : sans ce filtre, un « ../.. »
// permettrait d'atteindre n'importe quelle route de l'API Hugging Face.
var hfRepoRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
// hfRepo — un dépôt trouvé par la recherche.
//
// Volontairement pauvre. On a essayé d'y porter un indicateur « vision » tiré
// des tags Hugging Face : il ment. Sur les deux dépôts GGUF de Qwen3.8-27B qui
// publient TOUS DEUX un mmproj, ggml-org est taggé image-text-to-text et
// unsloth ne l'est pas. Une pastille « vision » sur l'un et pas sur l'autre
// aurait été pire que rien. La seule source qui ne se trompe pas, c'est la
// présence d'un mmproj-*.gguf dans l'arborescence — donc hfFiles, pas ici.
type hfRepo struct {
ID string `json:"id"`
Downloads int `json:"downloads"`
Likes int `json:"likes"`
}
// hfEntry — un .gguf installable. Shards > 1 signale une famille de tranches :
// Size est alors le total de la famille, pas le poids du fichier pointé.
type hfEntry struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
Quant string `json:"quant"`
Shards int `json:"shards"`
// Renseignés par le handler, qui seul connaît le matériel et le contexte.
Verdict string `json:"verdict,omitempty"`
Why string `json:"why,omitempty"`
}
// hfListing — le contenu utile d'un dépôt, trié.
type hfListing struct {
Repo string `json:"repo"`
Models []hfEntry `json:"models"`
Projectors []hfEntry `json:"projectors"`
Drafts []hfEntry `json:"drafts"`
}
// hfAuth pose les en-têtes communs à tous les appels Hugging Face. Le jeton
// compte aussi pour la RECHERCHE et l'arborescence, pas seulement pour le
// transfert : sans lui un dépôt gated répond 401 et Loki afficherait « aucun
// résultat » là où il faudrait dire « dépôt à accès restreint ».
func hfAuth(req *http.Request) {
if k := os.Getenv("HF_TOKEN"); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept", "application/json")
}
// hfCache : mémo commun à la recherche et aux arborescences, clé = l'URL
// appelée. Sans lui, chaque frappe au clavier dans le champ de recherche
// produit une requête sortante.
var (
hfCacheMu sync.Mutex
hfCache = map[string]hfCacheItem{}
)
type hfCacheItem struct {
at time.Time
raw []byte
}
// hfGetJSON appelle l'API Hugging Face et décode la réponse dans out, avec
// cache. Le corps brut est mémorisé plutôt que la valeur décodée : deux
// appelants attendent des types différents et le partage d'une structure
// décodée les exposerait à se modifier mutuellement.
func hfGetJSON(ctx context.Context, endpoint string, ttl time.Duration, out any) error {
hfCacheMu.Lock()
if it, ok := hfCache[endpoint]; ok && time.Since(it.at) < ttl {
raw := it.raw
hfCacheMu.Unlock()
return json.Unmarshal(raw, out)
}
hfCacheMu.Unlock()
ctx, cancel := context.WithTimeout(ctx, hfTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return err
}
hfAuth(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("Hugging Face injoignable : %v", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case 200:
case 401, 403:
return fmt.Errorf("dépôt à accès restreint — renseigne HF_TOKEN pour y accéder")
case 404:
return fmt.Errorf("dépôt introuvable sur Hugging Face")
default:
return fmt.Errorf("Hugging Face a répondu %d", resp.StatusCode)
}
// Borne de lecture : la réponse vient d'un tiers, on ne lui laisse pas
// décider de la mémoire qu'on lui consacre.
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return err
}
hfCacheMu.Lock()
hfCache[endpoint] = hfCacheItem{at: time.Now(), raw: raw}
hfCacheMu.Unlock()
return json.Unmarshal(raw, out)
}
// hfSearch cherche des dépôts GGUF. Le filtre `gguf` est essentiel : sans lui la
// recherche remonte surtout des dépôts de poids safetensors, inutilisables par
// llama-server.
func hfSearch(ctx context.Context, q string) ([]hfRepo, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, fmt.Errorf("recherche vide")
}
if len(q) > hfMaxQuery {
q = q[:hfMaxQuery]
}
endpoint := hfHost + "/api/models?" + url.Values{
"search": {q},
"filter": {"gguf"},
"limit": {fmt.Sprint(hfMaxRepos)},
"sort": {"downloads"},
"direction": {"-1"},
}.Encode()
var raw []struct {
ID string `json:"id"`
Downloads int `json:"downloads"`
Likes int `json:"likes"`
}
if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil {
return nil, err
}
out := make([]hfRepo, 0, len(raw))
for _, r := range raw {
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes})
}
return out, nil
}
// hfFiles liste les .gguf d'un dépôt et les range par famille.
//
// `recursive=1` n'est pas un confort : les quants volumineux vivent dans des
// sous-dossiers (UD-IQ4_XS/…), et sans lui l'arborescence ne renvoie que les
// dossiers, donc aucun modèle.
func hfFiles(ctx context.Context, repo string) (hfListing, error) {
repo = strings.Trim(strings.TrimSpace(repo), "/")
if !hfRepoRe.MatchString(repo) {
return hfListing{}, fmt.Errorf("nom de dépôt invalide (attendu « auteur/dépôt »)")
}
endpoint := hfHost + "/api/models/" + repo + "/tree/main?recursive=1"
var raw []struct {
Type string `json:"type"`
Path string `json:"path"`
Size int64 `json:"size"`
LFS struct {
Size int64 `json:"size"`
} `json:"lfs"`
}
if err := hfGetJSON(ctx, endpoint, hfFilesTTL, &raw); err != nil {
return hfListing{}, err
}
files := make([]hfFile, 0, len(raw))
for _, f := range raw {
if f.Type == "directory" {
continue
}
n := f.LFS.Size
if n == 0 {
n = f.Size
}
files = append(files, hfFile{Path: f.Path, Size: n})
}
return hfClassify(repo, files), nil
}
// hfFile — une entrée d'arborescence, réduite à ce dont le classement a besoin.
type hfFile struct {
Path string
Size int64
}
// hfClassify range les fichiers d'un dépôt en modèles, projecteurs et poids de
// décodage spéculatif. Séparé de l'appel réseau pour être testable : c'est ici
// que se jouent les erreurs qui coûtent cher (proposer un mmproj comme modèle,
// annoncer 15 Go pour une famille qui en pèse 45).
func hfClassify(repo string, files []hfFile) hfListing {
// Taille par chemin complet : une famille de tranches partage son dossier,
// et deux dossiers de quantification différents contiennent des fichiers de
// même nom de base.
size := map[string]int64{}
var paths []string
for _, f := range files {
if !strings.HasSuffix(strings.ToLower(f.Path), ".gguf") {
continue
}
size[f.Path] = f.Size
paths = append(paths, f.Path)
}
out := hfListing{Repo: repo}
for _, p := range paths {
base := baseName(p)
// Tranche 2..N : elle appartient à une famille déjà représentée par sa
// première, et ne démarre pas seule. On ne la propose jamais.
if isFollowerShard(base) {
continue
}
fam := shardFamily(base)
dir := ""
if i := strings.LastIndexByte(p, '/'); i >= 0 {
dir = p[:i+1]
}
total := int64(0)
for _, n := range fam {
total += size[dir+n]
}
e := hfEntry{
Name: base, URL: hfResolveURL(repo, p), Size: total,
Quant: quantFromName(base), Shards: len(fam),
}
switch {
case strings.HasPrefix(strings.ToLower(base), "mmproj"):
out.Projectors = append(out.Projectors, e)
case strings.HasPrefix(strings.ToLower(base), "mtp-"):
out.Drafts = append(out.Drafts, e)
default:
out.Models = append(out.Models, e)
}
}
bySize := func(s []hfEntry) { sort.Slice(s, func(i, j int) bool { return s[i].Size < s[j].Size }) }
bySize(out.Models)
bySize(out.Projectors)
bySize(out.Drafts)
return out
}
// hfResolveURL construit le lien de téléchargement direct. Chaque segment est
// échappé séparément : un nom de fichier peut contenir un espace, et les « / »
// du chemin doivent rester des séparateurs.
func hfResolveURL(repo, filePath string) string {
segs := strings.Split(filePath, "/")
for i, s := range segs {
segs[i] = url.PathEscape(s)
}
return hfHost + "/" + repo + "/resolve/main/" + strings.Join(segs, "/")
}
// hfPickProjector choisit le projecteur à proposer avec un modèle : le Q8_0
// d'abord (629 Mo contre 931 pour le BF16, sans différence perceptible sur un
// encodeur d'images), sinon le plus léger. Renvoie false si le dépôt n'en
// publie aucun — auquel cas l'UI doit le DIRE, pas aller en chercher un
// ailleurs : un projecteur d'un autre modèle ne correspond jamais.
func hfPickProjector(list []hfEntry) (hfEntry, bool) {
if len(list) == 0 {
return hfEntry{}, false
}
for _, e := range list {
if strings.EqualFold(e.Quant, "Q8_0") {
return e, true
}
}
best := list[0]
for _, e := range list[1:] {
if e.Size < best.Size {
best = e
}
}
return best, true
}
+207
View File
@@ -0,0 +1,207 @@
package loki
import (
"strings"
"testing"
)
const gb = int64(1) << 30
// Arborescence réelle de ggml-org/Qwen3.8-27B-GGUF, relevée sur l'API Hugging
// Face. Trois familles de .gguf y cohabitent, et deux d'entre elles ne sont PAS
// des modèles : c'est tout l'enjeu du classement.
func ggmlOrgTree() []hfFile {
return []hfFile{
{Path: ".gitattributes", Size: 2011},
{Path: "README.md", Size: 397},
{Path: "convert.log", Size: 485915},
{Path: "Qwen3.8-27B-BF16.gguf", Size: 53808281952},
{Path: "Qwen3.8-27B-Q4_K_M.gguf", Size: 18973870432},
{Path: "Qwen3.8-27B-Q8_0.gguf", Size: 28595763552},
{Path: "mmproj-Qwen3.8-27B-BF16.gguf", Size: 931145888},
{Path: "mmproj-Qwen3.8-27B-Q8_0.gguf", Size: 629247008},
{Path: "mtp-Qwen3.8-27B-BF16.gguf", Size: 5946009888},
{Path: "mtp-Qwen3.8-27B-Q4_0.gguf", Size: 1680271648},
{Path: "mtp-Qwen3.8-27B-Q8_0.gguf", Size: 3164006688},
}
}
func names(list []hfEntry) []string {
out := make([]string, len(list))
for i, e := range list {
out[i] = e.Name
}
return out
}
func TestHFClassifySeparatesProjectorsAndDrafts(t *testing.T) {
got := hfClassify("ggml-org/Qwen3.8-27B-GGUF", ggmlOrgTree())
if len(got.Models) != 3 {
t.Fatalf("modèles = %v, attendu 3 entrées", names(got.Models))
}
// Un mmproj proposé comme modèle, c'est un llama-server lancé sur un
// encodeur d'images : il démarre et ne répond rien de sensé.
for _, m := range got.Models {
if strings.HasPrefix(m.Name, "mmproj") || strings.HasPrefix(m.Name, "mtp-") {
t.Errorf("%q classé comme modèle", m.Name)
}
}
if len(got.Projectors) != 2 {
t.Errorf("projecteurs = %v, attendu 2", names(got.Projectors))
}
if len(got.Drafts) != 3 {
t.Errorf("drafts = %v, attendu 3", names(got.Drafts))
}
// Les fichiers non-.gguf n'ont rien à faire dans une liste installable.
for _, e := range append(append(got.Models, got.Projectors...), got.Drafts...) {
if !strings.HasSuffix(e.Name, ".gguf") {
t.Errorf("%q n'est pas un .gguf", e.Name)
}
}
}
func TestHFClassifyBuildsUsableURLs(t *testing.T) {
got := hfClassify("ggml-org/Qwen3.8-27B-GGUF", ggmlOrgTree())
want := "https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-Q4_K_M.gguf"
found := false
for _, m := range got.Models {
if m.URL == want {
found = true
}
}
if !found {
t.Fatalf("URL attendue absente ; obtenu %v", got.Models)
}
// L'URL doit traverser normalizeHFURL sans retouche : c'est elle que
// /api/models/download/probe recevra.
for _, m := range got.Models {
if _, name, err := normalizeHFURL(m.URL); err != nil {
t.Errorf("normalizeHFURL(%q) : %v", m.URL, err)
} else if name != m.Name {
t.Errorf("normalizeHFURL(%q) → %q, attendu %q", m.URL, name, m.Name)
}
}
}
func TestHFClassifyQuantAndSort(t *testing.T) {
got := hfClassify("unsloth/Qwen3.8-27B-GGUF", []hfFile{
{Path: "Qwen3.8-27B-UD-Q4_K_XL.gguf", Size: 17923394624},
{Path: "Qwen3.8-27B-Q8_0.gguf", Size: 29047086048},
{Path: "Qwen3.8-27B-UD-IQ2_XXS.gguf", Size: 9010048064},
})
// Du plus léger au plus lourd : la liste sert à choisir ce qui tient.
if n := names(got.Models); n[0] != "Qwen3.8-27B-UD-IQ2_XXS.gguf" || n[2] != "Qwen3.8-27B-Q8_0.gguf" {
t.Fatalf("tri par taille cassé : %v", n)
}
want := map[string]string{
"Qwen3.8-27B-UD-Q4_K_XL.gguf": "Q4_K_XL",
"Qwen3.8-27B-Q8_0.gguf": "Q8_0",
"Qwen3.8-27B-UD-IQ2_XXS.gguf": "IQ2_XXS",
}
for _, m := range got.Models {
if want[m.Name] != m.Quant {
t.Errorf("quant de %q = %q, attendu %q", m.Name, m.Quant, want[m.Name])
}
}
}
// Une famille de tranches doit donner UNE entrée, de taille totale. Annoncer le
// poids de la première tranche promet 15 Go là où le disque en verra 45.
func TestHFClassifyFoldsShards(t *testing.T) {
got := hfClassify("unsloth/Big-GGUF", []hfFile{
{Path: "UD-IQ4_XS/Big-UD-IQ4_XS-00001-of-00003.gguf", Size: 15 * gb},
{Path: "UD-IQ4_XS/Big-UD-IQ4_XS-00002-of-00003.gguf", Size: 15 * gb},
{Path: "UD-IQ4_XS/Big-UD-IQ4_XS-00003-of-00003.gguf", Size: 12 * gb},
})
if len(got.Models) != 1 {
t.Fatalf("modèles = %v, attendu 1 famille repliée", names(got.Models))
}
m := got.Models[0]
if m.Shards != 3 {
t.Errorf("shards = %d, attendu 3", m.Shards)
}
if m.Size != 42*gb {
t.Errorf("taille = %d, attendu %d (total de la famille)", m.Size, 42*gb)
}
if !strings.HasSuffix(m.Name, "-00001-of-00003.gguf") {
t.Errorf("l'entrée doit désigner la PREMIÈRE tranche, obtenu %q", m.Name)
}
}
// Deux dossiers de quantification portant des tranches distinctes ne doivent pas
// mélanger leurs tailles : le repli se fait par dossier, pas par nom de base.
func TestHFClassifyShardsStayInTheirDirectory(t *testing.T) {
got := hfClassify("x/y", []hfFile{
{Path: "Q4/M-00001-of-00002.gguf", Size: 4 * gb},
{Path: "Q4/M-00002-of-00002.gguf", Size: 4 * gb},
{Path: "Q8/M-00001-of-00002.gguf", Size: 9 * gb},
{Path: "Q8/M-00002-of-00002.gguf", Size: 9 * gb},
})
if len(got.Models) != 2 {
t.Fatalf("attendu 2 familles, obtenu %d", len(got.Models))
}
if got.Models[0].Size != 8*gb || got.Models[1].Size != 18*gb {
t.Errorf("tailles = %d et %d, attendu %d et %d",
got.Models[0].Size, got.Models[1].Size, 8*gb, 18*gb)
}
}
func TestHFPickProjectorPrefersQ8(t *testing.T) {
list := hfClassify("ggml-org/Qwen3.8-27B-GGUF", ggmlOrgTree()).Projectors
p, ok := hfPickProjector(list)
if !ok {
t.Fatal("aucun projecteur trouvé alors que le dépôt en publie deux")
}
if p.Name != "mmproj-Qwen3.8-27B-Q8_0.gguf" {
t.Errorf("projecteur choisi = %q, attendu le Q8_0", p.Name)
}
// Dépôt sans vision : il faut le DIRE, pas aller chercher ailleurs. Un
// projecteur d'un autre modèle ne correspond jamais.
if _, ok := hfPickProjector(nil); ok {
t.Error("un dépôt sans projecteur ne doit rien proposer")
}
}
func TestHFRepoValidation(t *testing.T) {
for _, bad := range []string{"", "pasdeslash", "../../api/whoami", "/leading", "a//b", "x/y/z"} {
if _, err := hfFiles(t.Context(), bad); err == nil {
t.Errorf("dépôt %q accepté alors qu'il est invalide", bad)
}
}
}
func TestFitVerdict(t *testing.T) {
gpu24 := hardwareInfo{VRAMGB: 24, RAMGB: 64}
// Q8_0 de 29 Go : ne rentre pas dans 24 Go, quoi qu'il arrive.
if v, why := fitVerdict(gpu24, 29*gb, 0, 32768); v != fitOver {
t.Errorf("Q8_0 29 Go sur 24 Go de VRAM → %q (%s), attendu %q", v, why, fitOver)
}
// UD-Q4_K_XL de 17 Go + projecteur + contexte : ça tient.
if v, why := fitVerdict(gpu24, 17*gb, 629<<20, 32768); v != fitOK {
t.Errorf("Q4 17 Go sur 24 Go de VRAM → %q (%s), attendu %q", v, why, fitOK)
}
// Le projecteur doit peser dans la balance : à la limite, l'ajouter fait
// basculer le verdict. C'est exactement le cas qu'on veut voir venir avant
// de lancer 20 Go de téléchargement.
sans, _ := fitVerdict(hardwareInfo{VRAMGB: 20}, 18*gb, 0, 32768)
avec, _ := fitVerdict(hardwareInfo{VRAMGB: 20}, 18*gb, 3*gb, 32768)
if sans == avec {
t.Errorf("le projecteur ne change rien au verdict (%q dans les deux cas)", sans)
}
// Sans GPU, le verdict se prononce sur la RAM et le dit.
if v, why := fitVerdict(hardwareInfo{RAMGB: 64}, 17*gb, 0, 32768); v != fitOK || !strings.Contains(why, "RAM") {
t.Errorf("sans GPU → %q (%s), attendu %q mesuré en RAM", v, why, fitOK)
}
// Machine non mesurable : pas de verdict inventé.
if v, _ := fitVerdict(hardwareInfo{}, 17*gb, 0, 32768); v != "" {
t.Errorf("sans mesure mémoire → %q, attendu aucun verdict", v)
}
// Un contexte plus large coûte plus cher, et doit pouvoir faire basculer.
court, _ := fitVerdict(hardwareInfo{VRAMGB: 24}, 21*gb, 0, 8192)
long, _ := fitVerdict(hardwareInfo{VRAMGB: 24}, 21*gb, 0, 262144)
if court == long {
t.Errorf("le contexte ne change rien au verdict (%q dans les deux cas)", court)
}
}
+205 -3
View File
@@ -1392,6 +1392,34 @@ button:hover{border-color:var(--dim);color:var(--text)}
.sidegroup>details:first-child{border-top:0}
.sidegroup>details[open]{padding-bottom:12px}
}
/* --- Recherche Hugging Face -------------------------------------------
Deux listes successives dans la même zone (dépôts, puis fichiers du dépôt
choisi) : une seule mise en forme sert aux deux, sinon elles divergent. La
liste est bornée en hauteur et défile — un dépôt publie facilement vingt
quantifications, et la modale ne doit pas s'étirer jusqu'à sortir de
l'écran. */
.hf-list{display:flex;flex-direction:column;max-height:220px;overflow-y:auto;
border:1px solid var(--border);border-radius:12px;margin-top:6px}
.hf-row{display:flex;align-items:center;gap:10px;padding:7px 12px;cursor:pointer;
border-top:1px solid var(--border);font-size:12px}
.hf-row:first-child{border-top:0}
.hf-row:hover{background:var(--row-bg-on)}
/* Le nom prend la place restante et se tronque : un identifiant de dépôt long
ne doit pas pousser la taille et le verdict hors de la ligne. */
.hf-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.hf-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap}
/* Verdict mémoire. La couleur seule ne suffit pas — le mot est écrit dedans,
et l'explication complète est dans l'infobulle. */
.hf-fit{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em;
text-transform:uppercase;border:1px solid transparent}
.hf-fit.ok{color:var(--ok);border-color:var(--ok)}
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
.hf-fit.trop{color:var(--err);border-color:var(--err)}
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -2023,6 +2051,19 @@ button:hover{border-color:var(--dim);color:var(--text)}
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div>
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
reste là : c'est le mode expert (dépôt privé, fichier exotique,
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
surtout il propose le projecteur vision DU MÊME DÉPÔT — le seul
qui corresponde au modèle choisi. -->
<div class="pe-row stack">
<span class="pe-row-l">Chercher un modèle<span class="pe-sub">sur Hugging Face — dépôts GGUF uniquement</span></span>
<div class="pe-inpbtn">
<input id="hf-q" class="pe-inp" placeholder="qwen3.8, mistral, gemma…" onkeydown="if(event.key==='Enter'){event.preventDefault();hfSearch();}">
<button id="hf-go" onclick="hfSearch()" title="Chercher">⌕</button>
</div>
<div id="hf-out"></div>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">ou un projecteur mmproj — lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
@@ -3292,6 +3333,7 @@ async function openItem(kind, key){
settingsRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
resetDlUI();
resetHfUI();
// Preset : la config brute est une ligne repliable, fermée par défaut.
rawHead.textContent = 'Configuration';
rawToggle.style.display = '';
@@ -4065,9 +4107,18 @@ async function populateDlDirs(){
}
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
}
// File d'attente : installer un modèle avec son projecteur, c'est DEUX
// téléchargements, et le serveur n'en mène qu'un à la fois (une seule barre,
// un seul poll). On enchaîne donc à la fin de chacun plutôt que de les lancer
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée.
let dlQueue = [];
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
return startDownloadURL(url);
}
async function startDownloadURL(url){
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
const e = dlEls();
e.btn.disabled = true;
@@ -4082,6 +4133,7 @@ async function startDownload(){
if(!p.ok || !p.enough){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
dlQueue = []; // le reste de la file dépendait de ce fichier : on n'enchaîne pas
await populateDlDirs();
return;
}
@@ -4092,7 +4144,7 @@ async function startDownload(){
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return;
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
}
watchDownload(r.filename);
}
@@ -4122,11 +4174,11 @@ function watchDownload(fname){
if(!st) return;
if(st.canceled){
e.prog.textContent = 'téléchargement annulé — '+fname;
e.bar.style.display = 'none'; stop(); return;
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
}
if(st.error){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
e.bar.style.display = 'none'; stop(); return;
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
}
if(st.finished){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
@@ -4144,6 +4196,10 @@ function watchDownload(fname){
};
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
else pick('m-model', onPickModel);
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné.
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
const pct = st.total>0 ? st.done*100/st.total : 0;
@@ -4178,6 +4234,152 @@ async function attachDownload(){
const st = (list||[]).find(d=>!d.finished);
if(st) watchDownload(st.filename);
}
// --- Recherche Hugging Face -------------------------------------------
// Deux écrans : la liste des dépôts, puis les fichiers d'un dépôt. Tout est
// construit en DOM et JAMAIS en innerHTML — noms de dépôts et de fichiers
// viennent d'un tiers, c'est la même règle que pour les titres de discussions.
let hfRepoFiles = null; // dernier dépôt déplié, pour l'installation
function hfOut(){ return document.getElementById('hf-out'); }
function hfMsg(txt, err){
const o = hfOut(); if(!o) return;
o.textContent = '';
const d = document.createElement('div');
d.className = 'pe-note';
if(err) d.style.color = 'var(--err)';
d.textContent = txt;
o.appendChild(d);
}
// Remet la zone de recherche à zéro (réouverture de la modale).
function resetHfUI(){
hfRepoFiles = null;
const o = hfOut(); if(o) o.textContent = '';
const q = document.getElementById('hf-q'); if(q) q.value = '';
}
async function hfSearch(){
const q = (document.getElementById('hf-q').value || '').trim();
if(!q){ toast('tape un nom de modèle'); return; }
hfMsg('recherche…');
let r;
try{ r = await jget('/api/hf/search?q='+encodeURIComponent(q)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'recherche impossible', true); return; }
if(!r.repos || !r.repos.length){ hfMsg('aucun dépôt GGUF pour « '+q+' »'); return; }
const o = hfOut(); o.textContent = '';
const list = document.createElement('div'); list.className = 'hf-list';
for(const rep of r.repos){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfPickRepo(rep.id);
const n = document.createElement('span'); n.className = 'hf-name'; n.textContent = rep.id;
const m = document.createElement('span'); m.className = 'hf-meta';
m.textContent = fmtCount(rep.downloads)+' ↓';
row.append(n, m);
list.appendChild(row);
}
o.appendChild(list);
}
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
// « celui-là est le plus utilisé », pas son compte exact.
function fmtCount(n){
n = n || 0;
if(n >= 1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+' M';
if(n >= 1e3) return Math.round(n/1e3)+' k';
return String(n);
}
async function hfPickRepo(repo){
hfMsg('lecture du dépôt…');
let r;
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
const o = hfOut(); o.textContent = '';
const head = document.createElement('div');
head.className = 'hf-head';
const back = document.createElement('button');
back.className = 'pe-link'; back.textContent = '← résultats';
back.onclick = hfSearch;
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
head.append(back, title);
o.appendChild(head);
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
// dit — on ne va pas en chercher un.
let mmChk = null;
const mm = (r.projectors||[])[0] && hfBestProjector(r.projectors);
const note = document.createElement('label');
note.className = 'hf-mm';
if(mm){
mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
note.append(mmChk, t);
} else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
note.classList.add('muted');
}
o.appendChild(note);
const list = document.createElement('div'); list.className = 'hf-list';
for(const m of r.models){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null);
const n = document.createElement('span'); n.className = 'hf-name';
n.textContent = m.quant || m.name;
const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta);
if(m.verdict){
const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict;
fit.textContent = m.verdict;
fit.title = m.why || '';
row.appendChild(fit);
}
list.appendChild(row);
}
o.appendChild(list);
if(r.hardware){
const hw = document.createElement('div');
hw.className = 'pe-note muted';
hw.textContent = r.hardware.vram_gb > 0
? 'Verdict calculé sur '+r.hardware.vram_gb.toFixed(1)+' Go de VRAM et un contexte de '+r.ctx+' jetons (estimation).'
: 'Aucun GPU détecté : verdict calculé sur '+r.hardware.ram_gb.toFixed(1)+' Go de RAM (estimation).';
o.appendChild(hw);
}
}
// Q8_0 d'abord (629 Mo contre 931 pour le BF16, sans différence perceptible sur
// un encodeur d'images), sinon le plus léger. Même règle que hfPickProjector
// côté serveur — l'UI choisit pour ne pas imposer un menu de plus.
function hfBestProjector(list){
return list.find(p => /^Q8_0$/i.test(p.quant || '')) || list.slice().sort((a,b)=>a.size-b.size)[0];
}
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
document.getElementById('m-hf-url').value = model.url;
await startDownloadURL(model.url);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
// up. Re-stick when they scroll back near bottom themselves.
let stickyBottom = true;
+13
View File
@@ -656,6 +656,19 @@
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div>
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
reste là : c'est le mode expert (dépôt privé, fichier exotique,
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
surtout il propose le projecteur vision DU MÊME DÉPÔT — le seul
qui corresponde au modèle choisi. -->
<div class="pe-row stack">
<span class="pe-row-l">Chercher un modèle<span class="pe-sub">sur Hugging Face — dépôts GGUF uniquement</span></span>
<div class="pe-inpbtn">
<input id="hf-q" class="pe-inp" placeholder="qwen3.8, mistral, gemma…" onkeydown="if(event.key==='Enter'){event.preventDefault();hfSearch();}">
<button id="hf-go" onclick="hfSearch()" title="Chercher">⌕</button>
</div>
<div id="hf-out"></div>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">ou un projecteur mmproj — lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
+164 -3
View File
@@ -110,6 +110,7 @@ async function openItem(kind, key){
settingsRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
resetDlUI();
resetHfUI();
// Preset : la config brute est une ligne repliable, fermée par défaut.
rawHead.textContent = 'Configuration';
rawToggle.style.display = '';
@@ -883,9 +884,18 @@ async function populateDlDirs(){
}
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
}
// File d'attente : installer un modèle avec son projecteur, c'est DEUX
// téléchargements, et le serveur n'en mène qu'un à la fois (une seule barre,
// un seul poll). On enchaîne donc à la fin de chacun plutôt que de les lancer
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée.
let dlQueue = [];
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
return startDownloadURL(url);
}
async function startDownloadURL(url){
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
const e = dlEls();
e.btn.disabled = true;
@@ -900,6 +910,7 @@ async function startDownload(){
if(!p.ok || !p.enough){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
dlQueue = []; // le reste de la file dépendait de ce fichier : on n'enchaîne pas
await populateDlDirs();
return;
}
@@ -910,7 +921,7 @@ async function startDownload(){
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return;
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
}
watchDownload(r.filename);
}
@@ -940,11 +951,11 @@ function watchDownload(fname){
if(!st) return;
if(st.canceled){
e.prog.textContent = 'téléchargement annulé — '+fname;
e.bar.style.display = 'none'; stop(); return;
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
}
if(st.error){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
e.bar.style.display = 'none'; stop(); return;
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
}
if(st.finished){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
@@ -962,6 +973,10 @@ function watchDownload(fname){
};
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
else pick('m-model', onPickModel);
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné.
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
const pct = st.total>0 ? st.done*100/st.total : 0;
@@ -996,5 +1011,151 @@ async function attachDownload(){
const st = (list||[]).find(d=>!d.finished);
if(st) watchDownload(st.filename);
}
// --- Recherche Hugging Face -------------------------------------------
// Deux écrans : la liste des dépôts, puis les fichiers d'un dépôt. Tout est
// construit en DOM et JAMAIS en innerHTML — noms de dépôts et de fichiers
// viennent d'un tiers, c'est la même règle que pour les titres de discussions.
let hfRepoFiles = null; // dernier dépôt déplié, pour l'installation
function hfOut(){ return document.getElementById('hf-out'); }
function hfMsg(txt, err){
const o = hfOut(); if(!o) return;
o.textContent = '';
const d = document.createElement('div');
d.className = 'pe-note';
if(err) d.style.color = 'var(--err)';
d.textContent = txt;
o.appendChild(d);
}
// Remet la zone de recherche à zéro (réouverture de la modale).
function resetHfUI(){
hfRepoFiles = null;
const o = hfOut(); if(o) o.textContent = '';
const q = document.getElementById('hf-q'); if(q) q.value = '';
}
async function hfSearch(){
const q = (document.getElementById('hf-q').value || '').trim();
if(!q){ toast('tape un nom de modèle'); return; }
hfMsg('recherche…');
let r;
try{ r = await jget('/api/hf/search?q='+encodeURIComponent(q)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'recherche impossible', true); return; }
if(!r.repos || !r.repos.length){ hfMsg('aucun dépôt GGUF pour « '+q+' »'); return; }
const o = hfOut(); o.textContent = '';
const list = document.createElement('div'); list.className = 'hf-list';
for(const rep of r.repos){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfPickRepo(rep.id);
const n = document.createElement('span'); n.className = 'hf-name'; n.textContent = rep.id;
const m = document.createElement('span'); m.className = 'hf-meta';
m.textContent = fmtCount(rep.downloads)+' ↓';
row.append(n, m);
list.appendChild(row);
}
o.appendChild(list);
}
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
// « celui-là est le plus utilisé », pas son compte exact.
function fmtCount(n){
n = n || 0;
if(n >= 1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+' M';
if(n >= 1e3) return Math.round(n/1e3)+' k';
return String(n);
}
async function hfPickRepo(repo){
hfMsg('lecture du dépôt…');
let r;
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
const o = hfOut(); o.textContent = '';
const head = document.createElement('div');
head.className = 'hf-head';
const back = document.createElement('button');
back.className = 'pe-link'; back.textContent = '← résultats';
back.onclick = hfSearch;
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
head.append(back, title);
o.appendChild(head);
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
// dit — on ne va pas en chercher un.
let mmChk = null;
const mm = (r.projectors||[])[0] && hfBestProjector(r.projectors);
const note = document.createElement('label');
note.className = 'hf-mm';
if(mm){
mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
note.append(mmChk, t);
} else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
note.classList.add('muted');
}
o.appendChild(note);
const list = document.createElement('div'); list.className = 'hf-list';
for(const m of r.models){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null);
const n = document.createElement('span'); n.className = 'hf-name';
n.textContent = m.quant || m.name;
const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta);
if(m.verdict){
const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict;
fit.textContent = m.verdict;
fit.title = m.why || '';
row.appendChild(fit);
}
list.appendChild(row);
}
o.appendChild(list);
if(r.hardware){
const hw = document.createElement('div');
hw.className = 'pe-note muted';
hw.textContent = r.hardware.vram_gb > 0
? 'Verdict calculé sur '+r.hardware.vram_gb.toFixed(1)+' Go de VRAM et un contexte de '+r.ctx+' jetons (estimation).'
: 'Aucun GPU détecté : verdict calculé sur '+r.hardware.ram_gb.toFixed(1)+' Go de RAM (estimation).';
o.appendChild(hw);
}
}
// Q8_0 d'abord (629 Mo contre 931 pour le BF16, sans différence perceptible sur
// un encodeur d'images), sinon le plus léger. Même règle que hfPickProjector
// côté serveur — l'UI choisit pour ne pas imposer un menu de plus.
function hfBestProjector(list){
return list.find(p => /^Q8_0$/i.test(p.quant || '')) || list.slice().sort((a,b)=>a.size-b.size)[0];
}
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
document.getElementById('m-hf-url').value = model.url;
await startDownloadURL(model.url);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
// up. Re-stick when they scroll back near bottom themselves.
+28
View File
@@ -1366,3 +1366,31 @@ button:hover{border-color:var(--dim);color:var(--text)}
.sidegroup>details:first-child{border-top:0}
.sidegroup>details[open]{padding-bottom:12px}
}
/* --- Recherche Hugging Face -------------------------------------------
Deux listes successives dans la même zone (dépôts, puis fichiers du dépôt
choisi) : une seule mise en forme sert aux deux, sinon elles divergent. La
liste est bornée en hauteur et défile — un dépôt publie facilement vingt
quantifications, et la modale ne doit pas s'étirer jusqu'à sortir de
l'écran. */
.hf-list{display:flex;flex-direction:column;max-height:220px;overflow-y:auto;
border:1px solid var(--border);border-radius:12px;margin-top:6px}
.hf-row{display:flex;align-items:center;gap:10px;padding:7px 12px;cursor:pointer;
border-top:1px solid var(--border);font-size:12px}
.hf-row:first-child{border-top:0}
.hf-row:hover{background:var(--row-bg-on)}
/* Le nom prend la place restante et se tronque : un identifiant de dépôt long
ne doit pas pousser la taille et le verdict hors de la ligne. */
.hf-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.hf-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap}
/* Verdict mémoire. La couleur seule ne suffit pas — le mot est écrit dedans,
et l'explication complète est dans l'infobulle. */
.hf-fit{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em;
text-transform:uppercase;border:1px solid transparent}
.hf-fit.ok{color:var(--ok);border-color:var(--ok)}
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
.hf-fit.trop{color:var(--err);border-color:var(--err)}
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default}
+76
View File
@@ -0,0 +1,76 @@
package loki
// web_hf.go — routes de recherche de modèles sur Hugging Face.
//
// GET /api/hf/search?q=… dépôts GGUF correspondants
// GET /api/hf/files?repo=auteur/dépôt modèles, projecteurs et drafts du dépôt
//
// Les deux renvoient aussi le matériel local, pour que l'interface n'ait pas à
// le demander séparément et n'affiche jamais une liste de tailles sans le
// budget en face.
import (
"net/http"
"strconv"
"strings"
)
func handleHFSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "précise ce que tu cherches"})
return
}
repos, err := hfSearch(r.Context(), q)
if err != nil {
sendJSON(w, 502, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "hardware": detectHardware(), "repos": repos})
}
// handleHFFiles liste les .gguf d'un dépôt, chaque modèle portant son verdict
// « ça tient / ça ne tient pas ».
//
// Le verdict d'un modèle inclut le projecteur QUE si le dépôt en publie un : sur
// un dépôt sans vision, compter un projecteur inexistant ferait basculer à tort
// des modèles en « trop ». Le contexte pris en compte est celui du preset actif
// (CTX), pas une constante : c'est lui qui sera réellement lancé.
func handleHFFiles(w http.ResponseWriter, r *http.Request) {
repo := strings.TrimSpace(r.URL.Query().Get("repo"))
if repo == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "dépôt manquant"})
return
}
list, err := hfFiles(r.Context(), repo)
if err != nil {
sendJSON(w, 502, map[string]any{"ok": false, "error": err.Error()})
return
}
hw := detectHardware()
// Contexte du preset actif, avec le même défaut que backend_serve.go — sinon
// le verdict est calculé sur un contexte que personne n'utilisera.
ctxTokens := 32768
if v, convErr := strconv.Atoi(strings.TrimSpace(ReadConfig()["CTX"])); convErr == nil && v > 0 {
ctxTokens = v
}
var mmSize int64
if p, ok := hfPickProjector(list.Projectors); ok {
mmSize = p.Size
}
for i := range list.Models {
list.Models[i].Verdict, list.Models[i].Why = fitVerdict(hw, list.Models[i].Size, mmSize, ctxTokens)
}
// `vision` sort d'ici et de nulle part ailleurs : c'est la présence d'un
// mmproj dans CE dépôt, pas un tag Hugging Face — lequel manque sur des
// dépôts qui en publient pourtant un.
// Un projecteur ne reçoit pas de verdict : il ne se charge jamais seul.
sendJSON(w, 200, map[string]any{
"ok": true, "hardware": hw, "ctx": ctxTokens,
"repo": list.Repo, "models": list.Models,
"projectors": list.Projectors, "drafts": list.Drafts,
"vision": len(list.Projectors) > 0,
})
}
+2 -1
View File
@@ -145,7 +145,6 @@ func newWebMux() *http.ServeMux {
api("/api/vram", handleVram)
api("/api/ram", handleRam)
api("/api/config", handleConfigEnv)
api("/api/catalog", handleCatalog)
api("/api/paths", handlePaths)
api("/api/update", handleUpdateCheck)
api("/api/update/apply", handleUpdateApply)
@@ -156,6 +155,8 @@ func newWebMux() *http.ServeMux {
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
api("/api/models/download/status", handleModelDownloadStatus)
api("/api/models/download/cancel", handleModelDownloadCancel)
api("/api/hf/search", handleHFSearch) // chercher un modèle GGUF sur Hugging Face
api("/api/hf/files", handleHFFiles) // quants et projecteurs d'un dépôt
api("/api/backends", handleBackends)
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)