mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge pull request #4 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Chercher et installer un modèle depuis Loki
This commit is contained in:
10 files changed
+1152
-66
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user