diff --git a/README.md b/README.md index 59ac534..5f77c3a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/loki/backend_catalog.go b/internal/loki/backend_catalog.go index 1d123a1..fcbbaf3 100644 --- a/internal/loki/backend_catalog.go +++ b/internal/loki/backend_catalog.go @@ -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 + } +} diff --git a/internal/loki/backend_hf.go b/internal/loki/backend_hf.go new file mode 100644 index 0000000..fd23ae7 --- /dev/null +++ b/internal/loki/backend_hf.go @@ -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 +} diff --git a/internal/loki/backend_hf_test.go b/internal/loki/backend_hf_test.go new file mode 100644 index 0000000..456763e --- /dev/null +++ b/internal/loki/backend_hf_test.go @@ -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) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 4a6ad23..ccfc238 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -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} +
+ Chercher un modèlesur Hugging Face — dépôts GGUF uniquement +
+ + +
+
+
Télécharger un modèle ou un projecteur mmproj — lien direct vers un .gguf
@@ -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 = 'erreur : '+escHtml(p.error||'espace insuffisant')+''; 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 = 'erreur : '+(r.error||'')+''; - 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 = 'erreur : '+st.error+''; - e.bar.style.display = 'none'; stop(); return; + e.bar.style.display = 'none'; dlQueue = []; stop(); return; } if(st.finished){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; @@ -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; diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index f0ab32e..0a62988 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -656,6 +656,19 @@ Visionprojecteur mmproj
+ +
+ Chercher un modèlesur Hugging Face — dépôts GGUF uniquement +
+ + +
+
+
Télécharger un modèle ou un projecteur mmproj — lien direct vers un .gguf
diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index 56ef2c2..d191407 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -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 = 'erreur : '+escHtml(p.error||'espace insuffisant')+''; 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 = 'erreur : '+(r.error||'')+''; - 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 = 'erreur : '+st.error+''; - e.bar.style.display = 'none'; stop(); return; + e.bar.style.display = 'none'; dlQueue = []; stop(); return; } if(st.finished){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; @@ -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. diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 3ee84d5..59c27c9 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -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} diff --git a/internal/loki/web_hf.go b/internal/loki/web_hf.go new file mode 100644 index 0000000..0ca5474 --- /dev/null +++ b/internal/loki/web_hf.go @@ -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, + }) +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 5121d95..dbb9843 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -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)