mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +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)
|
# de l'image officielle llama.cpp (server-cuda)
|
||||||
```
|
```
|
||||||
|
|
||||||
Interface : http://localhost:8090 — télécharge un modèle depuis le catalogue
|
Interface : http://localhost:8090 — installe un modèle depuis la recherche
|
||||||
intégré (onglet modèles), il démarre tout seul.
|
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
|
## Installation sur Unraid
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +1,120 @@
|
|||||||
package loki
|
package loki
|
||||||
|
|
||||||
// backend_catalog.go — récupère la liste de modèles curatée servie par ajean.link et la
|
// backend_catalog.go — ce que la machine peut avaler, et si un modèle y tient.
|
||||||
// combine avec les infos matérielles locales, pour que l'écran d'accueil (à
|
|
||||||
// venir) propose « en un clic » un modèle adapté à la machine.
|
|
||||||
//
|
//
|
||||||
// La liste vit sur https://ajean.link/models.json (éditée par l'opérateur, voir
|
// Ce fichier servait autrefois un catalogue de modèles téléchargé depuis
|
||||||
// loki-relay/models_catalog.go). Si le réseau échoue, on retombe sur un
|
// ajean.link/models.json — le serveur de l'auteur du projet dont Loki est un
|
||||||
// catalogue minimal embarqué (fallbackCatalogJSON) pour ne jamais bloquer.
|
// 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 (
|
import (
|
||||||
"encoding/json"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const catalogURL = "https://ajean.link/models.json"
|
type gpuBrief struct {
|
||||||
|
Name string `json:"name"`
|
||||||
type catalogModel struct {
|
VRAMGB float64 `json:"vram_gb"`
|
||||||
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 hardwareInfo struct {
|
type hardwareInfo struct {
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
RAMGB float64 `json:"ram_gb"`
|
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é.
|
// detectHardware décrit la machine. La VRAM vient de detectGPUs
|
||||||
func fetchCatalog() catalog {
|
// (backend_gpu.go, via nvidia-smi) : sans elle, le verdict d'un serveur à GPU
|
||||||
var c catalog
|
// se prononçait sur la RAM système, donc à côté de la plaque — c'est le GPU
|
||||||
client := &http.Client{Timeout: 8 * time.Second}
|
// qui porte le modèle quand NGL l'y envoie.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectHardware() hardwareInfo {
|
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
|
// Verdicts rendus par fitVerdict.
|
||||||
// marquer chaque modèle « tient / trop lourd » et proposer le bon par défaut.
|
const (
|
||||||
func handleCatalog(w http.ResponseWriter, r *http.Request) {
|
fitOK = "ok" // confortable
|
||||||
resp := struct {
|
fitTight = "juste" // ça passe, sans marge
|
||||||
Hardware hardwareInfo `json:"hardware"`
|
fitOver = "trop" // ça ne rentre pas
|
||||||
Models []catalogModel `json:"models"`
|
)
|
||||||
}{Hardware: detectHardware(), Models: fetchCatalog().Models}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
// ctxOverheadGB estime ce que le cache KV va prendre en plus des poids.
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
//
|
||||||
|
// ⚠️ 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.
|
// fitVerdict dit si un modèle tient, et POURQUOI. La phrase compte autant que
|
||||||
const fallbackCatalogJSON = `{
|
// le verdict : « trop » sans explication laisse l'utilisateur deviner s'il doit
|
||||||
"version": 1,
|
// changer de quantification, baisser le contexte ou renoncer au projecteur.
|
||||||
"models": [
|
func fitVerdict(h hardwareInfo, weights, mmproj int64, ctxTokens int) (string, string) {
|
||||||
{"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."},
|
const gb = float64(1 << 30)
|
||||||
{"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."}
|
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:first-child{border-top:0}
|
||||||
.sidegroup>details[open]{padding-bottom:12px}
|
.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>
|
</style></head><body>
|
||||||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||||||
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
<!-- 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-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>
|
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
|
||||||
</div>
|
</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">
|
<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>
|
<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">
|
<div class="pe-inpbtn">
|
||||||
@@ -3292,6 +3333,7 @@ async function openItem(kind, key){
|
|||||||
settingsRow.style.display = 'flex';
|
settingsRow.style.display = 'flex';
|
||||||
document.getElementById('m-hf-url').value = '';
|
document.getElementById('m-hf-url').value = '';
|
||||||
resetDlUI();
|
resetDlUI();
|
||||||
|
resetHfUI();
|
||||||
// Preset : la config brute est une ligne repliable, fermée par défaut.
|
// Preset : la config brute est une ligne repliable, fermée par défaut.
|
||||||
rawHead.textContent = 'Configuration';
|
rawHead.textContent = 'Configuration';
|
||||||
rawToggle.style.display = '';
|
rawToggle.style.display = '';
|
||||||
@@ -4065,9 +4107,18 @@ async function populateDlDirs(){
|
|||||||
}
|
}
|
||||||
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
|
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(){
|
async function startDownload(){
|
||||||
const url = document.getElementById('m-hf-url').value.trim();
|
const url = document.getElementById('m-hf-url').value.trim();
|
||||||
if(!url){ toast('colle un lien .gguf'); return; }
|
if(!url){ toast('colle un lien .gguf'); return; }
|
||||||
|
return startDownloadURL(url);
|
||||||
|
}
|
||||||
|
async function startDownloadURL(url){
|
||||||
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
|
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
|
||||||
const e = dlEls();
|
const e = dlEls();
|
||||||
e.btn.disabled = true;
|
e.btn.disabled = true;
|
||||||
@@ -4082,6 +4133,7 @@ async function startDownload(){
|
|||||||
if(!p.ok || !p.enough){
|
if(!p.ok || !p.enough){
|
||||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
|
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
|
||||||
e.bar.style.display = 'none'; e.btn.disabled=false;
|
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();
|
await populateDlDirs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4092,7 +4144,7 @@ async function startDownload(){
|
|||||||
const r = await jpost('/api/models/download', {url, dir});
|
const r = await jpost('/api/models/download', {url, dir});
|
||||||
if(!r.ok){
|
if(!r.ok){
|
||||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
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);
|
watchDownload(r.filename);
|
||||||
}
|
}
|
||||||
@@ -4122,11 +4174,11 @@ function watchDownload(fname){
|
|||||||
if(!st) return;
|
if(!st) return;
|
||||||
if(st.canceled){
|
if(st.canceled){
|
||||||
e.prog.textContent = 'téléchargement annulé — '+fname;
|
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){
|
if(st.error){
|
||||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
|
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){
|
if(st.finished){
|
||||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
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);
|
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
|
||||||
else pick('m-model', onPickModel);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const pct = st.total>0 ? st.done*100/st.total : 0;
|
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);
|
const st = (list||[]).find(d=>!d.finished);
|
||||||
if(st) watchDownload(st.filename);
|
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
|
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
|
||||||
// up. Re-stick when they scroll back near bottom themselves.
|
// up. Re-stick when they scroll back near bottom themselves.
|
||||||
let stickyBottom = true;
|
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-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>
|
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
|
||||||
</div>
|
</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">
|
<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>
|
<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">
|
<div class="pe-inpbtn">
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ async function openItem(kind, key){
|
|||||||
settingsRow.style.display = 'flex';
|
settingsRow.style.display = 'flex';
|
||||||
document.getElementById('m-hf-url').value = '';
|
document.getElementById('m-hf-url').value = '';
|
||||||
resetDlUI();
|
resetDlUI();
|
||||||
|
resetHfUI();
|
||||||
// Preset : la config brute est une ligne repliable, fermée par défaut.
|
// Preset : la config brute est une ligne repliable, fermée par défaut.
|
||||||
rawHead.textContent = 'Configuration';
|
rawHead.textContent = 'Configuration';
|
||||||
rawToggle.style.display = '';
|
rawToggle.style.display = '';
|
||||||
@@ -883,9 +884,18 @@ async function populateDlDirs(){
|
|||||||
}
|
}
|
||||||
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
|
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(){
|
async function startDownload(){
|
||||||
const url = document.getElementById('m-hf-url').value.trim();
|
const url = document.getElementById('m-hf-url').value.trim();
|
||||||
if(!url){ toast('colle un lien .gguf'); return; }
|
if(!url){ toast('colle un lien .gguf'); return; }
|
||||||
|
return startDownloadURL(url);
|
||||||
|
}
|
||||||
|
async function startDownloadURL(url){
|
||||||
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
|
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
|
||||||
const e = dlEls();
|
const e = dlEls();
|
||||||
e.btn.disabled = true;
|
e.btn.disabled = true;
|
||||||
@@ -900,6 +910,7 @@ async function startDownload(){
|
|||||||
if(!p.ok || !p.enough){
|
if(!p.ok || !p.enough){
|
||||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
|
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
|
||||||
e.bar.style.display = 'none'; e.btn.disabled=false;
|
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();
|
await populateDlDirs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -910,7 +921,7 @@ async function startDownload(){
|
|||||||
const r = await jpost('/api/models/download', {url, dir});
|
const r = await jpost('/api/models/download', {url, dir});
|
||||||
if(!r.ok){
|
if(!r.ok){
|
||||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
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);
|
watchDownload(r.filename);
|
||||||
}
|
}
|
||||||
@@ -940,11 +951,11 @@ function watchDownload(fname){
|
|||||||
if(!st) return;
|
if(!st) return;
|
||||||
if(st.canceled){
|
if(st.canceled){
|
||||||
e.prog.textContent = 'téléchargement annulé — '+fname;
|
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){
|
if(st.error){
|
||||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
|
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){
|
if(st.finished){
|
||||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
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);
|
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
|
||||||
else pick('m-model', onPickModel);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const pct = st.total>0 ? st.done*100/st.total : 0;
|
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);
|
const st = (list||[]).find(d=>!d.finished);
|
||||||
if(st) watchDownload(st.filename);
|
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
|
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
|
||||||
// up. Re-stick when they scroll back near bottom themselves.
|
// 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:first-child{border-top:0}
|
||||||
.sidegroup>details[open]{padding-bottom:12px}
|
.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/vram", handleVram)
|
||||||
api("/api/ram", handleRam)
|
api("/api/ram", handleRam)
|
||||||
api("/api/config", handleConfigEnv)
|
api("/api/config", handleConfigEnv)
|
||||||
api("/api/catalog", handleCatalog)
|
|
||||||
api("/api/paths", handlePaths)
|
api("/api/paths", handlePaths)
|
||||||
api("/api/update", handleUpdateCheck)
|
api("/api/update", handleUpdateCheck)
|
||||||
api("/api/update/apply", handleUpdateApply)
|
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/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
||||||
api("/api/models/download/status", handleModelDownloadStatus)
|
api("/api/models/download/status", handleModelDownloadStatus)
|
||||||
api("/api/models/download/cancel", handleModelDownloadCancel)
|
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", handleBackends)
|
||||||
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
|
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
|
||||||
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)
|
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)
|
||||||
|
|||||||
Reference in new issue
Block a user