Fusion de main : la suppression des modèles y a atterri, le sélecteur reste

main a livré entre-temps « un .gguf gardé se réutilise et s'efface »
(9bdaf00), qui couvre la suppression des modèles depuis l'éditeur avec un
index des références plus complet que celui de cette branche :
buildModelRefIndex lit aussi le --mmproj resté dans EXTRA_ARGS et le
construit en une passe, et deleteModelFile porte lui-même le refus « en
service ». Cette branche cède donc tout ce qui doublonnait.

Abandonné (repris par main) :
- modelSizeOnDisk, modelIsLoaded, modelPresetRefs, presetsUsingModel →
  modelFamilySize, modelUsers, buildModelRefIndex ;
- la liste « Modèles installés » (#m-mdl-*) et son CSS → celle de main
  (#m-inst-*), qui sélectionne aussi un modèle déjà téléchargé ;
- deletePresetModel → main supprime le .gguf partagé mais NOMME les autres
  presets concernés, plutôt que de conserver le fichier ;
- backend_models_delete_test.go → backend_models_refs_test.go couvre les
  mêmes cas, y compris les octets libérés d'une famille de tranches.

Conservé, c'est ce qui reste propre à cette branche : le sélecteur de
l'en-tête ne liste QUE des presets. Charger un .gguf n'écrivait que MODEL=,
laissant en place le contexte et l'échantillonnage du modèle précédent — un
27B par-dessus les réglages d'un 4B mourait en OOM sans explication.
/api/models/use disparaît avec le groupe qu'elle servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
This commit is contained in:
Claude committed 2026-09-12 22:26:34 +00:00
commit 96ef1ac95b
26 files changed
+2773 -737

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
package loki
// backend_external.go — presets « externes » : au lieu de lancer un llama-server
// local, un tel preset route le chat vers une API OpenAI-compatible distante
// (OpenAI, Groq, OpenRouter, un autre Loki, un vLLM sur une autre machine…).
// Un preset externe se reconnaît à sa clé EXTERNAL=1 ; il porte l'URL, le nom du
// modèle et la clé d'accès.
//
// Choix d'archi, repris d'AJEAN : l'externe est un PRESET COMME UN AUTRE, un
// fichier .env dans presetsDir. Toute la mécanique existante s'applique sans
// rien changer — liste, nom d'affichage, empreinte d'activité, bascule, prompt
// système par preset, réglages d'échantillonnage. La différence ne vit qu'à
// DEUX endroits : au moment de l'inférence (resolveChatEndpoint) et au moment
// de la bascule (aucun moteur à redémarrer).
//
// Ce qui ne s'applique pas, et c'est voulu : un preset externe n'a ni MODEL, ni
// NGL, ni moteur. La machine distante décide de tout ça. CTX, en revanche,
// reste utile — c'est lui qui pilote la jauge de contexte et le seuil de
// compaction côté Loki.
import (
"fmt"
"strings"
)
const (
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
)
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
type chatEndpoint struct {
URL string // URL complète des complétions
Model string // nom de modèle envoyé dans le payload
Key string // Bearer ("" = aucun)
External bool // true = API distante (pas le llama-server local)
}
// isExternalConfig indique si une configuration décrit un endpoint externe.
func isExternalConfig(cfg map[string]string) bool {
return strings.TrimSpace(cfg[extKeyFlag]) == "1"
}
// externalActive : le preset actif est-il un endpoint externe ?
func externalActive() bool { return isExternalConfig(ReadConfig()) }
// completionsURL normalise l'URL saisie en une URL de complétions complète.
// Accepte les trois formes qu'on colle en pratique :
//
// - une URL déjà complète (…/chat/completions) — laissée telle quelle ;
// - une base OpenAI (…/v1, …/openai/v1, …/api/v1) — on ajoute /chat/completions ;
// - autre chose — on suppose une racine et on ajoute /v1/chat/completions.
//
// Exiger la forme exacte serait une source d'échecs muets : la page d'un
// fournisseur donne tantôt « https://api.groq.com/openai/v1 », tantôt l'URL
// complète, et se tromper ne produit qu'un 404 illisible.
func completionsURL(raw string) string {
u := strings.TrimRight(strings.TrimSpace(raw), "/")
if u == "" {
return ""
}
if strings.HasSuffix(u, "/chat/completions") {
return u
}
if strings.HasSuffix(u, "/v1") {
return u + "/chat/completions"
}
return u + "/v1/chat/completions"
}
// resolveChatEndpoint décide où envoyer les complétions : l'API externe si le
// preset actif en est un, sinon le llama-server local.
func resolveChatEndpoint() chatEndpoint {
cfg := ReadConfig()
if isExternalConfig(cfg) {
return chatEndpoint{
URL: completionsURL(cfg[extKeyURL]),
Model: strings.TrimSpace(cfg[extKeyModel]),
Key: strings.TrimSpace(cfg[extKeyToken]),
External: true,
}
}
return chatEndpoint{
URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()),
Model: "loki",
Key: readAPIKey(),
}
}
// auth pose l'en-tête d'autorisation de CET endpoint. Indispensable de le tenir
// ici plutôt que d'appeler authHeader : celui-ci envoie la clé du serveur LOCAL,
// et l'expédier à api.openai.com serait fuiter un secret chez un tiers en même
// temps qu'un 401 garanti.
func (e chatEndpoint) auth(set func(k, v string)) {
if e.Key != "" {
set("Authorization", "Bearer "+e.Key)
}
}
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
// compaction, exactement comme pour un preset local.
func externalPresetContent(url, model, key, ctx string) string {
m := map[string]string{
extKeyFlag: "1",
extKeyURL: strings.TrimSpace(url),
extKeyModel: strings.TrimSpace(model),
}
if k := strings.TrimSpace(key); k != "" {
m[extKeyToken] = k
}
if c := strings.TrimSpace(ctx); c != "" {
m["CTX"] = c
}
return formatEnv(m)
}
+196
View File
@@ -0,0 +1,196 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait
// une source d'échecs muets : la page d'un fournisseur donne tantôt la base,
// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible.
func TestNormalisationDeLURLDeComplétions(t *testing.T) {
cas := map[string]string{
"https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions",
"https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions",
"https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions",
"https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions",
"http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions",
"": "",
}
for in, want := range cas {
if got := completionsURL(in); got != want {
t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want)
}
}
}
// Un preset externe actif détourne les complétions ; sans lui, on reste sur le
// llama-server local avec la clé LOCALE.
func TestEndpointSuitLePresetActif(t *testing.T) {
testHome(t)
if err := SetConfigKey("PORT", "9999"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.External {
t.Fatal("aucun preset externe : l'endpoint doit rester local")
}
if ep.URL != "http://localhost:9999/v1/chat/completions" {
t.Fatalf("URL locale = %q", ep.URL)
}
if ep.Model != "loki" {
t.Fatalf("modèle local = %q, attendu loki", ep.Model)
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
if !ep.External {
t.Fatal("EXTERNAL=1 non reconnu")
}
if ep.URL != "https://api.groq.com/openai/v1/chat/completions" {
t.Fatalf("URL externe = %q", ep.URL)
}
if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" {
t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key)
}
// CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte
// et le seuil de compaction, exactement comme pour un preset local.
if got := ReadConfig()["CTX"]; got != "65536" {
t.Fatalf("CTX = %q, attendu 65536", got)
}
}
// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter
// un secret en même temps qu'un 401 garanti.
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.Key != "" {
t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key)
}
got := map[string]string{}
ep.auth(func(k, v string) { got[k] = v })
if len(got) != 0 {
t.Fatalf("en-tête posé sans clé de preset : %v", got)
}
}
// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe.
func TestReconnaissanceDunPresetExterne(t *testing.T) {
if !isExternalConfig(map[string]string{extKeyFlag: "1"}) {
t.Error("EXTERNAL=1 non reconnu")
}
for _, v := range []string{"", "0", "off", "false"} {
if isExternalConfig(map[string]string{extKeyFlag: v}) {
t.Errorf("EXTERNAL=%q pris pour un preset externe", v)
}
}
if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) {
t.Error("un preset local est pris pour un externe")
}
}
// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout
// l'intérêt de tester avant d'enregistrer.
func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer sk-bonne" {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{}})
}))
t.Cleanup(srv.Close)
post := func(body string) map[string]any {
w := httptest.NewRecorder()
handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return out
}
out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`)
if out["ok"] == true {
t.Fatal("clé refusée annoncée comme valide")
}
if msg, _ := out["error"].(string); msg != "Incorrect API key provided" {
t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg)
}
if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true {
t.Fatalf("clé valide refusée : %v", out)
}
}
// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale
// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, err := ReadPreset(id)
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(content)
if cfg[extKeyToken] != "sk-gardee" {
t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken])
}
if cfg[extKeyModel] != "llama-3.3" {
t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel])
}
// Et la clé ne ressort jamais en clair par la route de lecture.
w = httptest.NewRecorder()
handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil))
if strings.Contains(w.Body.String(), "sk-gardee") {
t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String())
}
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
if out["hasKey"] != true {
t.Fatal("hasKey doit signaler la présence d'une clé")
}
}
// Un preset externe demande explicitement d'écraser la clé par une vide.
func TestEffacementExpliciteDeLaCle(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, _ := ReadPreset(id)
if k := parseEnv(content)[extKeyToken]; k != "" {
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
}
}
+62 -109
View File
@@ -201,101 +201,45 @@ func modelFromPresetContent(content string) string {
// n'effacer que la première laissait des dizaines de Go de fichiers que plus // n'effacer que la première laissait des dizaines de Go de fichiers que plus
// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les // rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
// tranches suivantes n'y apparaissent pas). // tranches suivantes n'y apparaissent pas).
func deleteModelFile(name string) error { //
// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et
// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après
// un ménage de disque.
//
// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par
// llama-server (la place n'est donc même pas rendue) et le prochain démarrage
// meurt sur un .gguf absent, sans que rien n'ait prévenu.
func deleteModelFile(name string) (int64, error) {
p, err := resolveModelPath(name) p, err := resolveModelPath(name)
if err != nil { if err != nil {
return err return 0, err
} }
if _, active := modelUsers(name); active {
return 0, fmt.Errorf("%s", modelInServiceMsg)
}
freed := modelFamilySize(p)
if err := os.Remove(p); err != nil { if err := os.Remove(p); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p)) return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
} }
return err return 0, err
} }
dir := filepath.Dir(p) dir := filepath.Dir(p)
for _, n := range shardFamily(filepath.Base(p)) { for _, n := range shardFamily(filepath.Base(p)) {
_ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire _ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
} }
return nil return freed, nil
} }
// modelSizeOnDisk additionne les octets qu'un modèle occupe RÉELLEMENT — la // handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés.
// famille de tranches entière, pas le seul fichier nommé. C'est ce chiffre
// qu'on annonce après une suppression : « 42 Go libérés » pour un modèle
// découpé, et non les 15 Go de sa première tranche.
func modelSizeOnDisk(p string) int64 {
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
}
// modelIsLoaded dit si p est le modèle de la configuration ACTIVE, celui que
// llama-server a ouvert. L'effacer sous ses pieds ne libère rien tant que le
// processus tient le descripteur, et le prochain démarrage meurt sur un fichier
// introuvable : on refuse, et on renvoie vers un changement de preset.
func modelIsLoaded(p string) bool {
cur := strings.TrimSpace(ReadConfig()["MODEL"])
if cur == "" {
return false
}
q, err := resolveServeModelPath(cur)
if err != nil {
return false
}
return samePath(p, q)
}
// modelPresetRefs mappe chaque .gguf référencé par un preset (MODEL= ou
// MMPROJ=, chemin normalisé) vers les noms des presets qui le réclament. Calculé
// UNE fois par listage : sans ça, chercher les références d'un modèle à la fois
// relisait tous les presets pour chaque fichier du disque.
func modelPresetRefs() map[string][]string {
refs := map[string][]string{}
list, err := ListPresets()
if err != nil {
return refs
}
for _, pr := range list {
content, err := os.ReadFile(pr.Path)
if err != nil {
continue
}
env := parseEnv(string(content))
for _, ref := range []string{env["MODEL"], env["MMPROJ"]} {
if strings.TrimSpace(ref) == "" {
continue
}
q, err := resolveServeModelPath(ref)
if err != nil {
continue
}
k := normDir(q)
// Un preset qui pointe le même fichier en MODEL et en MMPROJ ne compte
// qu'une fois.
if n := len(refs[k]); n > 0 && refs[k][n-1] == pr.Name {
continue
}
refs[k] = append(refs[k], pr.Name)
}
}
return refs
}
// presetsUsingModel liste les noms d'affichage des presets dont MODEL= ou
// MMPROJ= désigne p. Supprimer un .gguf ne casse pas qu'un fichier : chaque
// preset qui le référence devient un moteur qui meurt au démarrage. On les
// nomme AVANT, dans la confirmation, plutôt que de laisser la découverte au
// prochain switch.
func presetsUsingModel(p string) []string {
return modelPresetRefs()[normDir(p)]
}
// handleModelDelete supprime un .gguf (et toutes ses tranches) d'un dossier de
// modèles déclaré.
// //
// Deux garde-fous, parce que rien n'est réversible ici et qu'un modèle pèse des // Deux garde-fous, et un seul est un refus définitif :
// dizaines de gigaoctets : //
// - le modèle CHARGÉ est intouchable (modelIsLoaded) ; // - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ;
// - un modèle référencé par des presets ne part qu'avec `force`, l'appelant // - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets
// ayant reçu leur liste dans la réponse 409 pour la poser à l'utilisateur. // concernés. L'interface la montre, l'utilisateur tranche, et renvoie la
// même requête avec force:true. Refuser tout court transformerait le
// nettoyage en impasse — c'est exactement le travers qu'on corrige ici.
func handleModelDelete(w http.ResponseWriter, r *http.Request) { func handleModelDelete(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
@@ -305,37 +249,22 @@ func handleModelDelete(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return return
} }
p, err := resolveModelPath(req.Name) users, active := modelUsers(req.Name)
if active {
sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true})
return
}
if len(users) > 0 && !req.Force {
sendJSON(w, 409, map[string]any{"ok": false, "used": users,
"error": "modèle utilisé par : " + strings.Join(users, ", ")})
return
}
freed, err := deleteModelFile(req.Name)
if err != nil { if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return return
} }
if st, err := os.Stat(p); err != nil || st.IsDir() { sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users})
sendJSON(w, 404, map[string]any{"ok": false, "error": "modèle introuvable : " + filepath.Base(p)})
return
}
if modelIsLoaded(p) {
sendJSON(w, 409, map[string]any{"ok": false, "loaded": true,
"error": "modèle chargé par le moteur — bascule d'abord sur un autre preset"})
return
}
used := presetsUsingModel(p)
if len(used) > 0 && !req.Force {
sendJSON(w, 409, map[string]any{"ok": false, "presets": used, "needsForce": true,
"error": "modèle utilisé par un preset"})
return
}
freed := modelSizeOnDisk(p)
if err := deleteModelFile(p); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
fmt.Printf("%s modèle supprimé : %s\n", green("[ok]"), p)
resp := map[string]any{"ok": true, "freed": freed}
if len(used) > 0 {
resp["presets"] = used // presets cassés par la suppression forcée
}
sendJSON(w, 200, resp)
} }
// ---- Hugging Face downloads ------------------------------------------------- // ---- Hugging Face downloads -------------------------------------------------
@@ -485,7 +414,16 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
} }
if len(todoURLs) == 0 { if len(todoURLs) == 0 {
dlMu.Unlock() dlMu.Unlock()
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name}) // Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur,
// c'était le résultat voulu — le modèle est installé. On répond ok avec la
// valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la
// file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge.
value, _, _ := modelPresence(name, req.Dir)
if value == "" {
value = modelPickerValue(filepath.Dir(dests[0]), name)
}
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true,
"value": value, "parts": 0})
return return
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -565,6 +503,21 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
// puis échouer au deux tiers du transfert. // puis échouer au deux tiers du transfert.
urls, names := shardURLSet(dlURL, name) urls, names := shardURLSet(dlURL, name)
name = names[0] name = names[0]
// Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui
// mettait l'interface dans une impasse — un modèle gardé après la suppression
// de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et
// le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a
// un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça.
// Cherché dans TOUS les dossiers déclarés, pas seulement la destination
// choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le
// disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve
// où qu'il soit.
if value, path, ok := modelPresence(name, ""); ok {
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path),
"exists": true, "value": value, "size": modelFamilySize(path),
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel() defer cancel()
var total int64 var total int64
-176
View File
@@ -1,176 +0,0 @@
package loki
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// writeModel pose un .gguf factice dans le dossier des modèles.
func writeModel(t *testing.T, name string) string {
t.Helper()
dir := modelsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte("gguf"), 0o644); err != nil {
t.Fatal(err)
}
return p
}
// postDelete joue /api/models/delete et renvoie le code HTTP + le corps décodé.
func postDelete(t *testing.T, body map[string]any) (int, map[string]any) {
t.Helper()
b, _ := json.Marshal(body)
rec := httptest.NewRecorder()
handleModelDelete(rec, httptest.NewRequest(http.MethodPost, "/api/models/delete", bytes.NewReader(b)))
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
}
return rec.Code, out
}
// Le modèle que le moteur a ouvert ne se supprime pas : l'espace ne serait pas
// rendu tant que le processus tient le descripteur, et le démarrage suivant
// mourrait sur un fichier introuvable.
func TestModelDeleteRefuseLeModeleCharge(t *testing.T) {
testHome(t)
writeModel(t, "charge.gguf")
if err := WriteConfig(map[string]string{"MODEL": "charge.gguf"}); err != nil {
t.Fatal(err)
}
code, out := postDelete(t, map[string]any{"name": "charge.gguf"})
if code != 409 || out["ok"] != false || out["loaded"] != true {
t.Fatalf("modèle chargé supprimable : %d %v", code, out)
}
if _, err := os.Stat(filepath.Join(modelsDir(), "charge.gguf")); err != nil {
t.Fatal("le modèle chargé a été supprimé")
}
}
// Un modèle réclamé par des presets part quand même, mais seulement après un
// second « oui » : le serveur refuse sans force et NOMME les presets, pour que
// la confirmation dise ce qu'on casse.
func TestModelDeleteExigeForceQuandUnPresetLUtilise(t *testing.T) {
testHome(t)
writeModel(t, "vieux.gguf")
if _, err := SavePreset("", "Mon preset", "MODEL=\"vieux.gguf\"\nCTX=8192\n"); err != nil {
t.Fatal(err)
}
code, out := postDelete(t, map[string]any{"name": "vieux.gguf"})
if code != 409 || out["needsForce"] != true {
t.Fatalf("suppression non protégée : %d %v", code, out)
}
names, _ := out["presets"].([]any)
if len(names) != 1 || names[0] != "Mon preset" {
t.Fatalf("presets référents non renvoyés : %v", out["presets"])
}
if _, err := os.Stat(filepath.Join(modelsDir(), "vieux.gguf")); err != nil {
t.Fatal("supprimé malgré le refus")
}
if code, out = postDelete(t, map[string]any{"name": "vieux.gguf", "force": true}); code != 200 || out["ok"] != true {
t.Fatalf("force refusé : %d %v", code, out)
}
if _, err := os.Stat(filepath.Join(modelsDir(), "vieux.gguf")); !os.IsNotExist(err) {
t.Fatal("le modèle n'a pas été supprimé")
}
}
// Un modèle que personne ne réclame part du premier coup, et l'espace annoncé
// couvre TOUTES les tranches — c'est le chiffre qu'on affiche à l'utilisateur.
func TestModelDeleteLibereLaFamilleEntiere(t *testing.T) {
testHome(t)
writeModel(t, "libre-00001-of-00002.gguf")
writeModel(t, "libre-00002-of-00002.gguf")
code, out := postDelete(t, map[string]any{"name": "libre-00001-of-00002.gguf"})
if code != 200 || out["ok"] != true {
t.Fatalf("suppression refusée : %d %v", code, out)
}
if freed, _ := out["freed"].(float64); freed != 8 {
t.Fatalf("espace libéré = %v ; attendu la famille entière (8 octets)", out["freed"])
}
for _, n := range []string{"libre-00001-of-00002.gguf", "libre-00002-of-00002.gguf"} {
if _, err := os.Stat(filepath.Join(modelsDir(), n)); !os.IsNotExist(err) {
t.Fatalf("tranche %s conservée", n)
}
}
}
// Un modèle absent ne doit pas répondre « supprimé » : sans ça, l'interface
// annonçait de la place libérée qui ne l'était pas.
func TestModelDeleteModeleAbsent(t *testing.T) {
testHome(t)
if code, out := postDelete(t, map[string]any{"name": "fantome.gguf"}); code != 404 || out["ok"] != false {
t.Fatalf("modèle absent : %d %v", code, out)
}
}
// /api/models annonce ce que le moteur a ouvert et qui réclame quoi : c'est ce
// qui permet à l'interface de griser le bouton « supprimer » du modèle chargé.
func TestModelsListeMarqueChargeEtPresets(t *testing.T) {
testHome(t)
writeModel(t, "a.gguf")
writeModel(t, "b.gguf")
if err := WriteConfig(map[string]string{"MODEL": "a.gguf"}); err != nil {
t.Fatal(err)
}
if _, err := SavePreset("", "Preset B", "MODEL=\"b.gguf\"\n"); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
handleModels(rec, httptest.NewRequest(http.MethodGet, "/api/models", nil))
var list []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
byName := map[string]map[string]any{}
for _, m := range list {
byName[m["name"].(string)] = m
}
if byName["a.gguf"]["loaded"] != true {
t.Fatalf("modèle chargé non marqué : %v", byName["a.gguf"])
}
refs, _ := byName["b.gguf"]["presets"].([]any)
if len(refs) != 1 || refs[0] != "Preset B" {
t.Fatalf("presets référents absents : %v", byName["b.gguf"])
}
}
// La case « supprimer aussi le .gguf » de l'éditeur de preset passe par les
// mêmes garde-fous : un modèle qu'un AUTRE preset réclame reste sur le disque,
// sinon supprimer un preset laissait son voisin avec un moteur qui meurt au
// démarrage.
func TestSuppressionPresetConserveUnModelePartage(t *testing.T) {
testHome(t)
writeModel(t, "partage.gguf")
if _, err := SavePreset("", "Preset A", "MODEL=\"partage.gguf\"\n"); err != nil {
t.Fatal(err)
}
if _, err := SavePreset("", "Preset B", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
t.Fatal(err)
}
deleted, errMsg := deletePresetModel("partage.gguf")
if deleted != "" || errMsg == "" {
t.Fatalf("modèle partagé supprimé : %q / %q", deleted, errMsg)
}
if _, err := os.Stat(filepath.Join(modelsDir(), "partage.gguf")); err != nil {
t.Fatal("le modèle partagé a disparu")
}
// Plus aucun preset ne le réclame : il part.
if err := DeletePreset("Preset A"); err != nil {
t.Fatal(err)
}
if err := DeletePreset("Preset B"); err != nil {
t.Fatal(err)
}
if deleted, errMsg = deletePresetModel("partage.gguf"); deleted == "" || errMsg != "" {
t.Fatalf("modèle orphelin conservé : %q / %q", deleted, errMsg)
}
}
+164
View File
@@ -0,0 +1,164 @@
package loki
// backend_models_refs.go — QUI référence un .gguf, et peut-on l'effacer ?
//
// Vécu qui a produit ce fichier : un modèle téléchargé depuis l'éditeur d'un
// preset, le preset supprimé (sans cocher « supprimer aussi le .gguf »), et
// 40 Go de fichier plus rien pour les reprendre — la route /api/models/delete
// existait mais AUCUN bouton ne l'appelait. Supprimer un modèle depuis
// l'interface demande deux choses : savoir qui s'en sert (le moteur en service,
// d'autres presets), et le dire AVANT d'effacer.
import (
"os"
"path/filepath"
"strings"
)
// modelRefIndex — l'index « ce fichier est référencé par… », construit en une
// seule passe. Demander « qui utilise ce modèle ? » fichier par fichier
// relisait tous les presets à chaque ligne de la liste des modèles.
//
// Les clés sont des chemins normalisés (normDir) : un preset peut nommer son
// modèle en chemin absolu et un autre en simple nom de fichier, c'est le même
// fichier sur le disque.
type modelRefIndex struct {
active map[string]bool // référencé par config.env — donc par le moteur qui tourne
presets map[string][]string // chemin -> noms d'affichage des presets
}
// modelRefKey normalise une valeur MODEL=/MMPROJ= en clé d'index, ou "" si elle
// ne désigne pas un .gguf. resolveModelPath accepte le nom simple comme le
// chemin absolu et les résout dans les dossiers déclarés — c'est exactement la
// règle qu'appliquera le moteur.
func modelRefKey(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
p, err := resolveServeModelPath(v)
if err != nil {
// Introuvable : on garde quand même une clé comparable, sinon deux
// presets qui nomment le même fichier absent paraîtraient distincts.
p = filepath.Join(modelsDir(), baseName(v))
}
return normDir(p)
}
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets
// d'avant la clé MMPROJ l'écrivaient là).
func modelKeysOf(env map[string]string) []string {
vals := []string{env["MODEL"], env["MMPROJ"]}
args := splitArgs(env["EXTRA_ARGS"])
for i, a := range args {
if a == "--mmproj" && i+1 < len(args) {
vals = append(vals, args[i+1])
}
}
out := []string{}
for _, v := range vals {
if k := modelRefKey(v); k != "" {
out = append(out, k)
}
}
return out
}
// buildModelRefIndex lit la configuration active et tous les presets.
func buildModelRefIndex() modelRefIndex {
ix := modelRefIndex{active: map[string]bool{}, presets: map[string][]string{}}
for _, k := range modelKeysOf(ReadConfig()) {
ix.active[k] = true
}
list, err := ListPresets()
if err != nil {
return ix
}
for _, p := range list {
content, err := ReadPreset(p.ID)
if err != nil {
continue
}
seen := map[string]bool{}
for _, k := range modelKeysOf(parseEnv(content)) {
if seen[k] {
continue // MODEL et MMPROJ identiques : un seul « utilisé par »
}
seen[k] = true
ix.presets[k] = append(ix.presets[k], p.Name)
}
}
return ix
}
// lookup renvoie les presets qui référencent path et s'il est en service.
func (ix modelRefIndex) lookup(path string) (presets []string, active bool) {
k := normDir(path)
return ix.presets[k], ix.active[k]
}
// modelUsers répond pour UN modèle (nom de fichier ou chemin).
func modelUsers(name string) (presets []string, active bool) {
k := modelRefKey(name)
if k == "" {
return nil, false
}
return buildModelRefIndex().lookup(k)
}
// modelInServiceErr est le refus opposé à la suppression d'un modèle que le
// moteur a ouvert. Effacer le .gguf sous un llama-server qui tourne ne libère
// même pas la place (le fichier reste mappé) et rend le prochain démarrage
// impossible sans rien dire.
const modelInServiceMsg = "modèle en service — bascule sur un autre preset avant de le supprimer"
// modelFamilySize additionne ce que la suppression libérera réellement
// (toutes les tranches présentes).
func modelFamilySize(p string) int64 {
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
}
// modelPresence dit si un modèle est DÉJÀ sur le disque, et sous quelle valeur
// l'écrire dans MODEL=. dir restreint la recherche à ce dossier (celui visé par
// un téléchargement) ; vide = tous les dossiers déclarés.
//
// C'est ce qui transforme « le modèle existe déjà » — un cul-de-sac rouge au
// bout d'un clic sur un quant — en « déjà là, je le sélectionne ».
func modelPresence(name, dir string) (value, path string, ok bool) {
base := baseName(strings.TrimSpace(name))
if base == "" || !strings.HasSuffix(strings.ToLower(base), ".gguf") {
return "", "", false
}
dirs := modelDirs()
if strings.TrimSpace(dir) != "" {
if d, err := resolveDownloadDir(dir); err == nil {
dirs = []string{d}
}
}
for _, d := range dirs {
p := filepath.Join(d, base)
if st, err := os.Stat(p); err != nil || st.IsDir() {
continue
}
// Famille incomplète = modèle inutilisable : on ne prétend pas qu'il est
// là, l'appelant doit pouvoir relancer le téléchargement des tranches
// manquantes (handleModelDownload les reprend une par une).
if len(shardFamilyMissing(d, base)) > 0 {
continue
}
return modelPickerValue(d, base), p, true
}
return "", "", false
}
// modelPickerValue est la valeur à écrire dans MODEL= pour un fichier donné :
// le simple nom quand il vit dans le dossier de téléchargement de Loki (le
// preset reste alors portable d'une machine à l'autre), le chemin complet
// ailleurs. Même règle que la liste /api/models.
func modelPickerValue(dir, base string) string {
if normDir(dir) == normDir(modelsDir()) {
return base
}
return filepath.Join(dir, base)
}
+246
View File
@@ -0,0 +1,246 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeModel pose un .gguf factice dans dir et renvoie son chemin.
func writeModel(t *testing.T, dir, name string, size int) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil {
t.Fatal(err)
}
return p
}
// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le
// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier.
// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable.
func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "modele.gguf", 1024)
// A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=.
value, path, ok := modelPresence("modele.gguf", "")
if !ok {
t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac")
}
if value != "modele.gguf" {
t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value)
}
if path != filepath.Join(modelsDir(), "modele.gguf") {
t.Fatalf("chemin = %q", path)
}
// B) Plus personne ne le référence : il s'efface sans force.
users, active := modelUsers("modele.gguf")
if active || len(users) != 0 {
t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users)
}
freed, err := deleteModelFile("modele.gguf")
if err != nil {
t.Fatalf("suppression refusée : %v", err)
}
if freed != 1024 {
t.Fatalf("octets libérés = %d, attendu 1024", freed)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("le fichier est toujours là")
}
}
// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas
// rendue, et le prochain démarrage mourrait sur un .gguf absent.
func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "servi.gguf", 8)
if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("servi.gguf"); !active {
t.Fatal("modèle de la configuration active non reconnu")
}
if _, err := deleteModelFile("servi.gguf"); err == nil {
t.Fatal("le modèle en service a été supprimé")
}
if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil {
t.Fatal("fichier supprimé malgré le refus")
}
}
// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et
// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige.
func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) {
testHome(t)
p := writeModel(t, modelsDir(), "partage.gguf", 16)
if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
t.Fatal(err)
}
if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil {
t.Fatal(err)
}
users, active := modelUsers("partage.gguf")
if active {
t.Fatal("aucune configuration active ne devrait référencer ce modèle")
}
if len(users) != 2 {
t.Fatalf("presets référents = %v, attendu les deux", users)
}
post := func(body string) (int, map[string]any) {
r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body))
w := httptest.NewRecorder()
handleModelDelete(w, r)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
code, out := post(`{"name":"partage.gguf"}`)
if code != 409 {
t.Fatalf("code = %d, attendu 409 (presets référents)", code)
}
if len(out["used"].([]any)) != 2 {
t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out)
}
if _, err := os.Stat(p); err != nil {
t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu")
}
if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 {
t.Fatalf("code = %d avec force:true — %v", code, out)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatal("force:true n'a pas supprimé le fichier")
}
}
// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le
// projecteur du modèle en service casse la vision sans rien dire.
func TestProjecteurCompteCommeReference(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "mmproj-F16.gguf", 4)
writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4)
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("mmproj-F16.gguf"); !active {
t.Fatal("MMPROJ= non compté comme référence")
}
if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil {
t.Fatal(err)
}
users, _ := modelUsers("vieux-mmproj.gguf")
if len(users) != 1 || users[0] != "Vision" {
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
}
}
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un
// tenseur introuvable.
func TestFamilleIncompleteNestPasPresente(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok {
t.Fatal("famille incomplète annoncée présente")
}
writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok {
t.Fatal("famille complète non détectée")
}
}
// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé
// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas.
func TestPresenceDansUnDossierDeclare(t *testing.T) {
home := testHome(t)
ext := filepath.Join(home, "externe")
writeModel(t, ext, "ailleurs.gguf", 32)
if _, _, ok := modelPresence("ailleurs.gguf", ""); ok {
t.Fatal("dossier non déclaré : le modèle ne doit pas être visible")
}
if err := saveExtraModelDirs([]string{ext}); err != nil {
t.Fatal(err)
}
value, _, ok := modelPresence("ailleurs.gguf", "")
if !ok {
t.Fatal("modèle d'un dossier déclaré non détecté")
}
if value != filepath.Join(ext, "ailleurs.gguf") {
t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value)
}
}
// /api/models doit marquer « dossier loki » le dossier de téléchargement et en
// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui
// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main.
func TestListeModelesNommeLeDossierDeLoki(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "local.gguf", 64)
if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest("GET", "/api/models", nil))
var out []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out) != 1 {
t.Fatalf("liste = %v", out)
}
if out[0]["home"] != true {
t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »")
}
if out[0]["value"] != "local.gguf" {
t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"])
}
if out[0]["active"] != true {
t.Fatal("le modèle en service doit être signalé comme tel")
}
}
// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le
// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur
// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle
// existe déjà » et il devient impossible de refaire un preset dessus.
func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "deja.gguf", 2048)
const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf"
call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) {
body := `{"url":"` + url + `","dir":""}`
w := httptest.NewRecorder()
h(w, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
// La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque.
code, out := call(handleModelDownloadProbe, "/api/models/download/probe")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("sonde : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"])
}
code, out = call(handleModelDownload, "/api/models/download")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("téléchargement : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("téléchargement : value = %v", out["value"])
}
}
+5 -1
View File
@@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil { freed, err := deleteModelFile("m-00001-of-00002.gguf")
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if freed != 2 {
t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed)
}
for _, n := range names[:2] { for _, n := range names[:2] {
if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
t.Fatalf("tranche %s non supprimée", n) t.Fatalf("tranche %s non supprimée", n)
+11
View File
@@ -264,6 +264,17 @@ func SwitchToPreset(target string) error {
return err return err
} }
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target)) fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
// Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de
// MODEL et llama-server partirait en boucle de crash. On arrête au contraire
// le moteur encore en vie — le chat part vers l'API distante, garder le
// modèle en VRAM ne sert plus à rien.
if isExternalConfig(ReadConfig()) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
if !serviceIsActive() {
return nil
}
return serviceAction("stop")
}
fmt.Println(dim("[info] redémarrage du service...")) fmt.Println(dim("[info] redémarrage du service..."))
return serviceAction("restart") return serviceAction("restart")
} }
+22 -7
View File
@@ -111,8 +111,15 @@ func compactSummaryBudget() int {
// //
// Ce marqueur ne rend pas l'image récupérable (le résumé est du texte), il rend // Ce marqueur ne rend pas l'image récupérable (le résumé est du texte), il rend
// sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il // sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il
// doit reprendre une capture plutôt que deviner. // doit REVOIR l'image plutôt que deviner.
const imageLostMarker = " [image — not kept in the conversation past this point; take the screenshot again if you still need to see it]" //
// Formulation volontairement générale depuis l'arrivée de see_image : une image
// peut désormais venir d'une capture (web_screenshot), d'une pièce jointe ou
// d'un fichier du disque. Dire « reprends la capture » enverrait le modèle
// photographier une page web alors que l'image perdue était un PNG sur le
// disque — le geste juste dépend de la provenance, que la légende conservée
// juste avant ce marqueur indique.
const imageLostMarker = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]"
// msgText extrait le texte d'un message (Content est `any`, en pratique string // msgText extrait le texte d'un message (Content est `any`, en pratique string
// ou nil quand l'assistant n'a que des tool_calls). Un message multimodal // ou nil quand l'assistant n'a que des tool_calls). Un message multimodal
@@ -577,8 +584,13 @@ Summarize densely and faithfully, keeping ONLY the essentials:
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference. Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
Write the summary in the SAME language as the conversation.` Write the summary in the SAME language as the conversation.`
// Le résumé part au MÊME endroit que le chat : sur un preset externe, il
// s'appuie sur l'API distante (backend_external.go). Le laisser taper le
// llama-server local ferait échouer toute compaction dès qu'aucun moteur
// local ne tourne — et la compaction, c'est ce qui empêche le fil de mourir.
ep := resolveChatEndpoint()
payload := map[string]any{ payload := map[string]any{
"model": "loki", "model": ep.Model,
"messages": []Message{ "messages": []Message{
{Role: "system", Content: sys}, {Role: "system", Content: sys},
{Role: "user", Content: transcript}, {Role: "user", Content: transcript},
@@ -594,13 +606,12 @@ Write the summary in the SAME language as the conversation.`
"chat_template_kwargs": map[string]any{"enable_thinking": false}, "chat_template_kwargs": map[string]any{"enable_thinking": false},
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
authHeader(req) ep.auth(req.Header.Set)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return "", friendlyLLMError(err) return "", friendlyLLMError(err)
@@ -608,7 +619,11 @@ Write the summary in the SAME language as the conversation.`
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) b, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
return "", fmt.Errorf("résumé: llama-server %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) who := "llama-server"
if ep.External {
who = "API externe"
}
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
} }
var out summarizeResp var out summarizeResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+20 -2
View File
@@ -8,6 +8,7 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
@@ -88,6 +89,18 @@ const (
loadConvRetryWait = 250 * time.Millisecond loadConvRetryWait = 250 * time.Millisecond
) )
// loadConvRetries compte les ATTENTES de reprise du dernier LoadConversation
// (0 = le premier essai a suffi). Il n'existe que pour être observable.
//
// Le test de non-régression veut vérifier qu'une absence légitime — première
// installation, rien d'enregistré — ne déclenche AUCUNE reprise. Il le mesurait
// au chronomètre (« moins de 250 ms »), ce qui confond « aucune reprise » avec
// « une seule tentative, mais lente » : sur un runner CI chargé, créer et
// ouvrir la base bbolt a pris 435 ms et le test a échoué alors qu'aucune reprise
// n'avait eu lieu. On compte donc les reprises au lieu de les déduire d'un
// temps de mur, qui ne dépend pas que de nous.
var loadConvRetries atomic.Int32
// LoadConversation recharge l'état persisté au démarrage du process. Sans état // LoadConversation recharge l'état persisté au démarrage du process. Sans état
// enregistré (première fois) on part d'une conversation vide. // enregistré (première fois) on part d'une conversation vide.
// //
@@ -101,11 +114,16 @@ const (
func LoadConversation() { func LoadConversation() {
var b []byte var b []byte
var err error var err error
loadConvRetries.Store(0)
retry := func() {
loadConvRetries.Add(1)
time.Sleep(loadConvRetryWait)
}
for attempt := 0; attempt < loadConvAttempts; attempt++ { for attempt := 0; attempt < loadConvAttempts; attempt++ {
// Sonde : lire la clé de la discussion active en gardant l'erreur. Tant // Sonde : lire la clé de la discussion active en gardant l'erreur. Tant
// qu'elle échoue, convEnsureActive ne doit surtout pas être appelée. // qu'elle échoue, convEnsureActive ne doit surtout pas être appelée.
if _, err = getBytesErr(bkChat, ckActive); err != nil { if _, err = getBytesErr(bkChat, ckActive); err != nil {
time.Sleep(loadConvRetryWait) retry()
continue continue
} }
// convEnsureActive reprend au passage le fil unique des versions // convEnsureActive reprend au passage le fil unique des versions
@@ -113,7 +131,7 @@ func LoadConversation() {
if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil { if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
break break
} }
time.Sleep(loadConvRetryWait) retry()
} }
if err != nil { if err != nil {
// Toujours en échec : on le DIT au lieu de repartir à vide en silence, et // Toujours en échec : on le DIT au lieu de repartir à vide en silence, et
+13 -4
View File
@@ -108,6 +108,12 @@ func TestLoadConversationBaseIllisibleNeForgePasDeDiscussion(t *testing.T) {
if max := time.Duration(loadConvAttempts)*loadConvRetryWait + 2*time.Second; elapsed > max { if max := time.Duration(loadConvAttempts)*loadConvRetryWait + 2*time.Second; elapsed > max {
t.Fatalf("démarrage retardé de %v par une base illisible (budget %v)", elapsed, max) t.Fatalf("démarrage retardé de %v par une base illisible (budget %v)", elapsed, max)
} }
// Contrepartie de l'assertion « aucune reprise » du test d'absence légitime :
// un compteur bloqué à zéro la rendrait vraie sans rien prouver. Ici les
// reprises ont bel et bien lieu, et on le vérifie.
if n := loadConvRetries.Load(); int(n) != loadConvAttempts {
t.Fatalf("%d reprise(s) comptée(s) sur une base illisible, attendu %d", n, loadConvAttempts)
}
after, err := os.ReadFile(path) after, err := os.ReadFile(path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -130,12 +136,15 @@ func TestLoadConversationAbsenceLegitimeResteRapide(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir()) t.Setenv("LOKI_HOME", t.TempDir())
resetConvForTest() resetConvForTest()
start := time.Now()
LoadConversation() LoadConversation()
elapsed := time.Since(start)
if elapsed >= loadConvRetryWait { // Mesuré sur le COMPTEUR de reprises, pas au chronomètre : une absence
t.Fatalf("absence légitime traitée comme une erreur : %v d'attente, aucune reprise ne devait être déclenchée", elapsed) // légitime doit sortir au premier essai. Le temps de mur ne disait pas ça —
// il mêlait le coût de l'essai lui-même (création de la base bbolt, fsync)
// à l'attente de reprise, et un runner CI chargé l'a fait échouer à 435 ms
// alors qu'aucune reprise n'avait été déclenchée.
if n := loadConvRetries.Load(); n != 0 {
t.Fatalf("absence légitime traitée comme une erreur : %d reprise(s) déclenchée(s), aucune ne devait l'être", n)
} }
conv.mu.Lock() conv.mu.Lock()
n := len(conv.Messages) n := len(conv.Messages)
+4 -3
View File
@@ -156,9 +156,10 @@ func engineSeesImages() bool {
// - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture. // - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture.
// //
// Le texte restant reçoit imageLostMarker : sans lui, une conversation rouverte // Le texte restant reçoit imageLostMarker : sans lui, une conversation rouverte
// ne garde que la légende de la capture (« Capture … ») et le modèle n'a plus // ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
// aucun indice qu'il A VU une image — il la redécrit de mémoire au lieu d'en // modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
// reprendre une. Même raison que dans msgText, autre bout de la chaîne. // au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
func stripImageParts(msgs []Message) []Message { func stripImageParts(msgs []Message) []Message {
for i, m := range msgs { for i, m := range msgs {
parts, ok := m.Content.([]any) parts, ok := m.Content.([]any)
+117
View File
@@ -0,0 +1,117 @@
package loki
// chat_vision_tool.go — l'outil see_image : le modèle charge lui-même une image
// du disque dans sa VISION, sans que l'utilisateur ait à la joindre au message.
//
// Loki savait déjà voir deux choses : une pièce jointe (web_upload.go) et une
// capture d'écran qu'il venait de prendre (chat_screenshot.go). Pas un fichier
// qui dort sur le disque. « Regarde la capture dans ~/photos/bug.png » n'avait
// donc aucune réponse : `read` rend des octets binaires, et le modèle finissait
// par décrire ce qu'il croyait deviner du nom de fichier.
//
// Mécanique : le résultat de l'outil reste un simple texte (un accusé). L'image,
// elle, est réinjectée juste après dans un message utilisateur multimodal
// (image_url) — le SEUL format que llama-server comprenne une fois --mmproj
// chargé, et exactement le chemin déjà emprunté par les pièces jointes et les
// captures.
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
)
// maxVisionBytes borne la taille d'une image chargée dans la vision. Au-delà,
// le base64 gonfle le contexte pour rien : le moteur redimensionne de toute
// façon avant l'encodeur d'images.
const maxVisionBytes = 12 << 20 // 12 Mio
// toolSeeImage lit un fichier image et renvoie (accusé texte, partie image_url).
// La partie image vaut nil en cas d'erreur : l'appelant n'injecte alors rien.
//
// Chaque refus dit CE QUI manque plutôt que « impossible » : sans projecteur, la
// réponse n'est pas la même que sur un poste distant ou un fichier trop lourd,
// et un modèle qui reçoit un motif clair peut corriger son geste lui-même.
func toolSeeImage(path string) (string, map[string]any) {
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image", nil
}
// Cible = un poste distant : le fichier est LÀ-BAS, pas lisible d'ici.
if agentTargetSlug() != "" {
return "[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
}
if path == "" {
return "[erreur] chemin de fichier manquant", nil
}
abs := resolveAgentPath(path)
mime := imageMime(abs)
if mime == "" {
return "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : " + path, nil
}
st, err := os.Stat(abs)
if err != nil {
return "[erreur] fichier introuvable : " + path, nil
}
if st.IsDir() {
return "[erreur] c'est un dossier, pas une image : " + path, nil
}
if st.Size() > maxVisionBytes {
return fmt.Sprintf("[erreur] image trop lourde (%s, max %s) : %s",
humanBytes(st.Size()), humanBytes(maxVisionBytes), path), nil
}
// Sonde du moteur EN DERNIER, juste avant le travail coûteux. Elle fait un
// appel réseau (/props, mis en cache 10 s) : la poser d'entrée, c'était payer
// un aller-retour pour répondre « chemin manquant », et surtout masquer les
// motifs précis ci-dessus derrière un « le moteur ne voit pas » qui n'apprend
// rien quand le vrai problème est une faute de frappe dans le chemin.
//
// Elle reste indispensable : un projecteur déclaré ne garantit pas que le
// moteur en service sache traiter une image (mauvais couple modèle/mmproj,
// moteur trop ancien). Autant le dire que d'envoyer 12 Mio de base64 que
// personne ne regardera.
if !engineSeesImages() {
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
}
b, err := os.ReadFile(abs)
if err != nil {
return "[erreur] lecture impossible : " + err.Error(), nil
}
if len(b) == 0 {
return "[erreur] fichier vide : " + path, nil
}
return "[ok] image chargée : " + filepath.Base(abs), map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
},
}
}
// seeImageMessage emballe la partie image dans le message utilisateur qui la
// porte jusqu'au modèle. La légende nomme le fichier : après compaction il ne
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
// dirait pas LAQUELLE rouvrir.
func seeImageMessage(label string, img map[string]any) Message {
return Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": "Image demandée (" + label + ") :"},
img,
}}
}
// seeImageTool — schéma envoyé au modèle. Description tenue au plus court : les
// schémas partent dans CHAQUE requête et le préambule a un budget
// (TestSystemPromptStaysLean).
func seeImageTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "see_image",
Description: "Ouvre un fichier image du disque pour le VOIR (png, jpg, gif, webp, bmp).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Chemin de l'image (relatif au dossier de travail, ou absolu)"},
},
"required": []string{"file"},
},
}}
}
+186
View File
@@ -0,0 +1,186 @@
package loki
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
// pngPixel : un PNG valide d'un pixel, assez pour qu'imageMime le reconnaisse.
var pngPixel, _ = base64.StdEncoding.DecodeString(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
// visionOn arme la vision (clé MMPROJ) et renvoie le dossier de travail.
func visionOn(t *testing.T) string {
t.Helper()
home := testHome(t)
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
return home
}
// Sans projecteur, l'outil DIT ce qui manque au lieu d'échouer vaguement — et
// il ne renvoie surtout aucune image.
func TestSeeImageSansVisionRefuseEtExplique(t *testing.T) {
testHome(t)
txt, img := toolSeeImage("photo.png")
if img != nil {
t.Fatal("une image a été chargée alors que la vision est inactive")
}
if !strings.Contains(txt, "MMPROJ") {
t.Fatalf("le refus ne dit pas ce qui manque : %q", txt)
}
}
// Les refus qui ne dépendent pas du moteur : format, absence, dossier, poids.
// Chacun doit nommer son motif — un modèle qui reçoit « impossible » ne peut
// pas corriger son geste, alors qu'un « ce n'est pas une image » se rattrape.
func TestSeeImageMotifsDeRefus(t *testing.T) {
home := visionOn(t)
write := func(name string, b []byte) string {
p := filepath.Join(home, name)
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
return p
}
write("notes.txt", []byte("bonjour"))
if err := os.MkdirAll(filepath.Join(home, "dossier.png"), 0o755); err != nil {
t.Fatal(err)
}
gros := write("gros.png", append(append([]byte(nil), pngPixel...), make([]byte, maxVisionBytes)...))
cas := []struct{ path, attendu string }{
{"", "chemin de fichier manquant"},
{filepath.Join(home, "notes.txt"), "format non reconnu"},
{filepath.Join(home, "absente.png"), "introuvable"},
{filepath.Join(home, "dossier.png"), "dossier"},
{gros, "trop lourde"},
}
for _, c := range cas {
txt, img := toolSeeImage(c.path)
if img != nil {
t.Errorf("%q : une image a été chargée malgré le refus", c.path)
}
if !strings.Contains(txt, c.attendu) {
t.Errorf("%q : motif = %q, attendu contenir %q", c.path, txt, c.attendu)
}
}
}
// Le message porteur nomme le fichier : après compactage il ne restera que sa
// légende et imageLostMarker, et « Image demandée : » tout court ne dirait pas
// LAQUELLE rouvrir.
func TestMessagePorteurNommeLeFichier(t *testing.T) {
img := map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}
m := seeImageMessage("captures/bug.png", img)
if m.Role != "user" {
t.Fatalf("rôle = %q, attendu user (un message tool ne porte que du texte)", m.Role)
}
parts, ok := m.Content.([]map[string]any)
if !ok || len(parts) != 2 {
t.Fatalf("contenu multimodal attendu, obtenu %#v", m.Content)
}
txt, _ := parts[0]["text"].(string)
if !strings.Contains(txt, "captures/bug.png") {
t.Fatalf("la légende ne nomme pas le fichier : %q", txt)
}
if parts[1]["type"] != "image_url" {
t.Fatalf("seconde partie = %v, attendu image_url", parts[1]["type"])
}
// Et cette légende survit au compactage, suivie du marqueur de perte.
got := msgText(m)
if !strings.Contains(got, "captures/bug.png") || !strings.Contains(got, imageLostMarker) {
t.Fatalf("après compaction : %q", got)
}
}
// Le marqueur ne doit plus parler QUE de captures d'écran : une image perdue
// peut venir du disque, et « reprends la capture » enverrait photographier une
// page web à la place.
func TestMarqueurDePerteNestPlusSpecifiqueAuxCaptures(t *testing.T) {
if strings.Contains(imageLostMarker, "take the screenshot again") {
t.Fatal("le marqueur impose encore la capture d'écran comme seul recours")
}
if !strings.Contains(imageLostMarker, "see_image") {
t.Fatal("le marqueur ne mentionne pas see_image : le modèle ne saura pas rouvrir un fichier")
}
}
// L'outil n'est proposé QUE si la vision est active : l'annoncer sans projecteur
// ferait promettre au modèle de regarder, puis se contredire.
func TestOutilProposeSeulementAvecLaVision(t *testing.T) {
testHome(t)
has := func() bool {
for _, tl := range EnabledTools(Caps{Agent: true}) {
if tl.Function.Name == "see_image" {
return true
}
}
return false
}
if has() {
t.Fatal("see_image proposé sans projecteur configuré")
}
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
if !has() {
t.Fatal("see_image absent alors que la vision est active")
}
// Hors mode agent, jamais : l'outil lit un fichier du disque.
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
for _, tl := range EnabledTools(Caps{}) {
if tl.Function.Name == "see_image" {
t.Fatal("see_image proposé hors mode agent")
}
}
}
// Le chemin doit remonter du JSON d'appel jusqu'à l'outil. Le libellé d'un appel
// est dérivé par une table nom d'outil → argument ; see_image n'y figurait pas,
// donc il recevait une chaîne vide et répondait invariablement « chemin de
// fichier manquant » — l'outil aurait été inutilisable sans rien casser ailleurs.
func TestLeCheminDeLImageRemonteJusquALOutil(t *testing.T) {
if got := toolCallLabel("see_image", map[string]any{"file": "captures/bug.png"}); got != "captures/bug.png" {
t.Fatalf("libellé de see_image = %q, attendu le chemin du fichier", got)
}
}
// Contre-épreuve de l'extraction : les libellés des autres outils n'ont pas
// bougé. Déplacer une table de 40 lignes hors d'une fonction de 700, c'est
// exactement le genre de geste qui casse un cas au passage sans bruit.
func TestLibellesDesAutresOutilsInchanges(t *testing.T) {
cas := []struct{ tool, key, val, want string }{
{"bash", "command", "ls -la", "ls -la"},
{"read", "file", "main.go", "main.go"},
{"web_search", "query", "météo", "météo"},
{"grep", "pattern", "TODO", "TODO"},
{"recall", "id", "b12", "b12"},
{"git_clone", "url", "https://x/y", "https://x/y"},
{"outil_inconnu", "file", "x", ""},
}
for _, c := range cas {
if got := toolCallLabel(c.tool, map[string]any{c.key: c.val}); got != c.want {
t.Errorf("toolCallLabel(%q) = %q, attendu %q", c.tool, got, c.want)
}
}
if got := toolCallLabel("web_grep", map[string]any{"url": "https://x", "pattern": "p"}); got != "p @ https://x" {
t.Errorf("web_grep : %q", got)
}
if got := toolCallLabel("tracker", map[string]any{"action": "add", "name": "poids"}); got != "add poids" {
t.Errorf("tracker : %q", got)
}
// Argument absent ou du mauvais type : chaîne vide, jamais de panique.
if got := toolCallLabel("read", map[string]any{"file": 42}); got != "" {
t.Errorf("argument non-chaîne : %q", got)
}
if got := toolCallLabel("bash", nil); got != "" {
t.Errorf("arguments nil : %q", got)
}
}
+130 -43
View File
@@ -359,6 +359,13 @@ func EnabledTools(caps Caps) []Tool {
// une sortie sur le web — et sans elle, le modèle croyait n'avoir aucun // une sortie sur le web — et sans elle, le modèle croyait n'avoir aucun
// navigateur et perdait des minutes à installer puppeteer. La borne aux // navigateur et perdait des minutes à installer puppeteer. La borne aux
// adresses locales vit dans toolWebScreenshot. // adresses locales vit dans toolWebScreenshot.
// Voir une image du DISQUE : seulement quand la vision est réellement active
// (projecteur MMPROJ déclaré). Sinon l'outil ne saurait que renvoyer une
// erreur, et l'annoncer ferait croire au modèle qu'il a des yeux qu'il n'a
// pas — il promettrait alors de regarder, puis se contredirait.
if caps.Agent && visionEnabled() {
tools = append(tools, seeImageTool())
}
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() { if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
tools = append(tools, webScreenshotTool()) tools = append(tools, webScreenshotTool())
} }
@@ -658,6 +665,55 @@ func isNetTimeout(err error) bool {
return errors.As(err, &ne) && ne.Timeout() return errors.As(err, &ne) && ne.Timeout()
} }
// toolCallLabel dérive le libellé HUMAIN d'un appel d'outil : la commande, le
// chemin, la requête… Il est annoncé à l'interface AVANT l'exécution — sans ça,
// une commande shell lente laisse l'écran figé sans rien dire — et il sert
// ensuite d'argument principal à plusieurs outils.
//
// Cette double fonction est un piège : un outil absent de cette table reçoit un
// libellé VIDE, et si son exécution lit ce libellé (see_image, read, bash…), il
// s'exécute sur une chaîne vide. Vécu à l'ajout de see_image : l'outil répondait
// « chemin de fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne
// bronche. D'où l'extraction : une table pareille se teste.
func toolCallLabel(name string, args map[string]any) string {
label := ""
switch name {
case "mem_search", "web_search", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
label, _ = args["file"].(string)
case "recall":
label, _ = args["id"].(string)
case "tracker":
// Libellé lisible : « nom » ou « action nom », pas un dump d'arguments.
label = strings.TrimSpace(str(args["action"]) + " " + str(args["name"]))
case "bash", "bash_bg":
label, _ = args["command"].(string)
case "grep", "glob":
label, _ = args["pattern"].(string)
case "ask":
label, _ = args["question"].(string)
case "criteria":
label, _ = args["action"].(string)
case "bash_tail":
label, _ = args["id"].(string)
case "git_clone":
label, _ = args["url"].(string)
case "web_open", "web_read":
label, _ = args["url"].(string)
case "web_grep":
u, _ := args["url"].(string)
p, _ := args["pattern"].(string)
label = p + " @ " + u
default:
// Outils MCP : libellé = un aperçu compact des arguments.
if isMCPTool(name) {
label = mcpArgLabel(args)
}
}
return label
}
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) { func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) {
var extra []Message var extra []Message
tools := EnabledTools(caps) tools := EnabledTools(caps)
@@ -715,6 +771,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule // Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
// relance par tour, comme le nudge. // relance par tour, comme le nudge.
patternRetried := false patternRetried := false
// Reprises RÉSEAU consécutives (llm_retry_net.go). Remis à zéro dès qu'une
// réponse arrive : une boucle d'outils longue retrouve son budget à chaque
// itération réussie, un moteur durablement mort finit par rendre la main.
netRetries := 0
// Destination des complétions : llama-server local, ou une API OpenAI-compatible
// externe si le preset actif en est un (backend_external.go). Résolu UNE FOIS
// par tour — une bascule de preset en plein tour est rare, et se rejoue de
// toute façon au message suivant.
ep := resolveChatEndpoint()
// Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond // Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond
// d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà // d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà
// d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui // d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui
@@ -730,7 +795,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
messages = append(messages, Message{Role: "user", Content: msg}) messages = append(messages, Message{Role: "user", Content: msg})
} }
payload := map[string]any{ payload := map[string]any{
"model": "loki", "model": ep.Model,
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits // Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0. // stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
"messages": normalizeSystemMessages(messages), "messages": normalizeSystemMessages(messages),
@@ -762,15 +827,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
payload["parallel_tool_calls"] = false payload["parallel_tool_calls"] = false
} }
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil { if err != nil {
return extra, err return extra, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
authHeader(req) // ⚠️ Surtout pas authHeader : celui-ci pose la clé du serveur LOCAL, et
// l'envoyer à api.openai.com serait fuiter un secret chez un tiers en même
// temps qu'un 401 garanti. Chaque endpoint porte la sienne.
ep.auth(req.Header.Set)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
// Rien n'est encore parti à l'écran : le tour est rejouable tel quel.
// C'est le cas du moteur qui redémarre (bascule de preset, rechargement
// de modèle) — quelques secondes de connexion refusée qui faisaient
// échouer pour de bon une tâche planifiée tombée pile là.
if netRetries < llmNetRetries && llmRetryableErr(ctx, err) {
logLLMRetry(netRetries+1, friendlyLLMError(err).Error())
if werr := llmNetBackoff(ctx, netRetries); werr == nil {
netRetries++
continue
}
}
err = friendlyLLMError(err) err = friendlyLLMError(err)
cb(StreamEvent{Err: err}) cb(StreamEvent{Err: err})
return extra, err return extra, err
@@ -832,10 +910,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
messages = steerSystem(messages, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.") messages = steerSystem(messages, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.")
continue continue
} }
err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg) // Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut
// qui dit « pas maintenant » (passerelle, service indisponible, trop de
// requêtes) et non « ta requête est fautive ». Le corps n'a pas été
// diffusé, le tour est rejouable.
if netRetries < llmNetRetries && llmRetryableStatus(resp.StatusCode) && ctx.Err() == nil {
logLLMRetry(netRetries+1, fmt.Sprintf("le moteur a renvoyé %d", resp.StatusCode))
if werr := llmNetBackoff(ctx, netRetries); werr == nil {
netRetries++
continue
}
}
// Nommer la BONNE machine : « llama-server a renvoyé 401 » sur un preset
// externe envoie chercher la panne du mauvais côté.
who := "llama-server"
if ep.External {
who = "l'API externe"
}
err := fmt.Errorf("%s a renvoyé %d : %s", who, resp.StatusCode, msg)
cb(StreamEvent{Err: err}) cb(StreamEvent{Err: err})
return extra, err return extra, err
} }
// Une réponse est arrivée : le budget de reprise réseau repart à neuf pour
// la suite de la boucle d'outils.
netRetries = 0
toolCalls := map[int]*ToolCall{} toolCalls := map[int]*ToolCall{}
assistantContent := strings.Builder{} assistantContent := strings.Builder{}
finishReason := "" finishReason := ""
@@ -1112,50 +1210,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
toolRuns++ // alimente le budget souple (voir budgetNudge) toolRuns++ // alimente le budget souple (voir budgetNudge)
var args map[string]any var args map[string]any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args) _ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
// Derive the human label (command / skill name) up front so we can // Libellé humain, annoncé AVANT l'exécution (voir toolCallLabel) : sans
// announce the call BEFORE running it — otherwise the UI shows // ça l'interface reste muette pendant une commande lente. Il sert
// nothing while a slow shell command runs and looks frozen. // aussi d'argument principal à plusieurs outils.
label := "" label := toolCallLabel(tc.Function.Name, args)
switch tc.Function.Name {
case "mem_search", "web_search", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
label, _ = args["file"].(string)
case "recall":
label, _ = args["id"].(string)
case "tracker":
// Libellé lisible : « nom » ou « action nom », pas un dump d'arguments.
label = strings.TrimSpace(str(args["action"]) + " " + str(args["name"]))
case "bash", "bash_bg":
label, _ = args["command"].(string)
case "grep", "glob":
label, _ = args["pattern"].(string)
case "ask":
label, _ = args["question"].(string)
case "criteria":
label, _ = args["action"].(string)
case "bash_tail":
label, _ = args["id"].(string)
case "git_clone":
label, _ = args["url"].(string)
case "web_open", "web_read":
label, _ = args["url"].(string)
case "web_grep":
u, _ := args["url"].(string)
p, _ := args["pattern"].(string)
label = p + " @ " + u
default:
// Outils MCP : libellé = un aperçu compact des arguments.
if isMCPTool(tc.Function.Name) {
label = mcpArgLabel(args)
}
}
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}}) cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
result := "" result := ""
// diff : rempli par les outils d'écriture (edit / mémoire) pour que // diff : rempli par les outils d'écriture (edit / mémoire) pour que
// l'UI montre les lignes ajoutées et retirées. // l'UI montre les lignes ajoutées et retirées.
var diff []DiffLine var diff []DiffLine
// visionImg : partie image_url rendue par see_image, réinjectée après
// le résultat de l'outil (un message `tool` ne porte que du texte).
var visionImg map[string]any
// Appel rigoureusement identique déjà exécuté dans ce tour : on ne le // Appel rigoureusement identique déjà exécuté dans ce tour : on ne le
// rejoue pas. Les petits modèles réémettent volontiers deux fois la // rejoue pas. Les petits modèles réémettent volontiers deux fois la
// même écriture ; la rejouer produisait une fausse erreur (« old // même écriture ; la rejouer produisait une fausse erreur (« old
@@ -1171,6 +1238,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
continue continue
} }
switch tc.Function.Name { switch tc.Function.Name {
case "see_image":
result, visionImg = toolSeeImage(label)
case "recall": case "recall":
result = toolRecall(args) result = toolRecall(args)
case "recall_search": case "recall_search":
@@ -1352,6 +1421,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
} }
} }
} }
// see_image a réussi : même relais, et surtout même règle ÉPHÉMÈRE
// que la capture — l'image va dans `messages` (le tour en cours)
// mais PAS dans `extra` (l'historique persistant). Un base64
// persisté repartirait à CHAQUE tour suivant et finirait par
// dépasser la fenêtre pour de bon. Le modèle regarde l'image
// maintenant ; ce qu'il en dit, lui, reste.
if visionImg != nil {
messages = append(messages, seeImageMessage(label, visionImg))
}
} }
// Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour : // Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour :
// une boucle d'outils peut à elle seule remplir la fenêtre (résultats // une boucle d'outils peut à elle seule remplir la fenêtre (résultats
@@ -1426,7 +1504,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
var healthClient = &http.Client{Timeout: 3 * time.Second} var healthClient = &http.Client{Timeout: 3 * time.Second}
// healthCheck pings llama.cpp's /health endpoint. // healthCheck pings llama.cpp's /health endpoint.
//
// Preset externe : il n'y a pas de llama-server local à sonder. On répond
// « prêt » sans latence — la vraie joignabilité de l'API distante se révèle à
// l'appel de complétion, avec un message d'erreur explicite si elle échoue.
// Sans ce court-circuit, la saisie resterait bloquée sur un moteur éteint que
// personne n'allumera jamais.
func healthCheck() bool { func healthCheck() bool {
if externalActive() {
return true
}
resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort())) resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort()))
if err != nil { if err != nil {
return false return false
+124
View File
@@ -0,0 +1,124 @@
package loki
// llm_retry_net.go — réessayer un tour que RIEN n'a encore diffusé.
//
// Jusqu'ici, la requête de complétion partait une fois : un `Do()` qui échoue
// ou un statut d'erreur tuait le tour. Les messages d'erreur eux-mêmes le
// disaient — « réessaie dans quelques secondes », « réessaie dans un
// instant » — c'est-à-dire qu'on demandait à l'utilisateur de refaire à la main
// ce que le code pouvait faire seul.
//
// Le cas courant chez Loki n'a rien d'exotique : le moteur redémarre (bascule de
// preset, rechargement de modèle, redémarrage du conteneur) et la connexion est
// refusée pendant quelques secondes. Une tâche planifiée qui tombe pile là
// échouait pour de bon, sans personne pour cliquer.
//
// LA règle de sûreté, et elle ne souffre pas d'exception : on ne rejoue que
// TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ. Une fois le flux commencé, la moitié de
// la réponse est déjà chez l'utilisateur ; la rejouer la dupliquerait. Les deux
// points de reprise (échec de `Do`, statut d'erreur avant lecture du corps)
// sont tous deux situés avant la première ligne SSE.
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"syscall"
"time"
)
const (
// llmNetRetries : tentatives CONSÉCUTIVES par tour. Le compteur repart à zéro
// dès qu'une réponse arrive, donc une boucle d'outils longue retrouve son
// budget à chaque itération réussie — mais un moteur durablement mort rend la
// main au lieu de tourner sans fin.
llmNetRetries = 3
// Attente de la 1re reprise, doublée ensuite (0,8 s → 1,6 s → 3,2 s). Un
// llama-server qui redémarre met quelques secondes à rouvrir son port : plus
// court ne sert à rien, plus long fait passer une panne franche pour un gel.
llmNetWait = 800 * time.Millisecond
llmNetMaxWait = 5 * time.Second
)
// llmRetryableErr : cette erreur de transport vaut-elle une nouvelle tentative ?
//
// Un ctx déjà fini n'en vaut JAMAIS une : c'est le /stop de l'utilisateur ou la
// limite de l'appelant, et réessayer serait passer outre. On le vérifie donc
// avant de regarder l'erreur elle-même — une échéance dépassée remonte sinon
// comme un simple timeout réseau, indiscernable d'une surcharge du moteur.
func llmRetryableErr(ctx context.Context, err error) bool {
if err == nil || ctx.Err() != nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
switch {
case errors.Is(err, syscall.ECONNREFUSED): // moteur en cours de démarrage
return true
case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
return true
case isNetTimeout(err): // moteur surchargé ou en plein chargement du modèle
return true
}
return false
}
// llmRetryableStatus : ce code HTTP vaut-il une nouvelle tentative ?
//
// Volontairement étroit. 500 en est ABSENT : c'est le code que llama.cpp rend
// pour un appel d'outil malformé ou un prompt qui dépasse le contexte, deux
// échecs déterministes que les filets au-dessus traitent déjà (retrait des
// outils, compaction en vol). Le rejouer trois fois ne ferait que retarder
// l'erreur de plusieurs secondes. Restent les codes qui disent « pas
// maintenant » : passerelle, service indisponible, trop de requêtes.
func llmRetryableStatus(code int) bool {
switch code {
case http.StatusTooManyRequests, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
}
return false
}
// llmNetDelay : combien attendre avant la tentative n (0 = la première reprise).
// Doublement puis plafond. Séparé de l'attente pour être vérifiable sans dormir
// cinq secondes dans la suite de tests — et parce qu'un décalage de bits sur un
// n non borné déborderait en silence (0,8 s << 64 revient à zéro).
func llmNetDelay(n int) time.Duration {
if n < 0 {
n = 0
}
if n > 16 { // au-delà, le plafond s'applique de toute façon
n = 16
}
if d := llmNetWait << n; d < llmNetMaxWait {
return d
}
return llmNetMaxWait
}
// llmNetBackoff attend avant la tentative n (0 = la première reprise).
// Interruptible : un /stop pendant l'attente rend la main tout de suite au lieu
// de faire patienter l'utilisateur pour une requête qu'il vient d'annuler.
func llmNetBackoff(ctx context.Context, n int) error {
t := time.NewTimer(llmNetDelay(n))
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// logLLMRetry trace la reprise. Elle n'est PAS envoyée à l'interface : le tour
// n'a encore rien produit, et faire clignoter une erreur qu'on est en train de
// rattraper inquiéterait pour rien. Le journal du conteneur, lui, doit la voir —
// c'est là qu'on cherche quand « ça a mis huit secondes ».
func logLLMRetry(attempt int, cause string) {
fmt.Fprintf(os.Stderr, "[llm] %s — nouvelle tentative %d/%d\n", cause, attempt, llmNetRetries)
}
+199
View File
@@ -0,0 +1,199 @@
package loki
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
)
// flakyServer répond `code` aux `fails` premières requêtes, puis sert un flux
// SSE normal. Renvoie le port (à mettre dans PORT) et le compteur d'appels.
func flakyServer(t *testing.T, fails int, code int, body string) (string, *atomic.Int32) {
t.Helper()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if int(calls.Add(1)) <= fails {
http.Error(w, "moteur occupé", code)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(body))
w.(http.Flusher).Flush()
}))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
return u.Port(), &calls
}
// Le cas qui motive tout le fichier : le moteur redémarre, la connexion est
// refusée quelques secondes, et le tour mourait pour de bon — une tâche
// planifiée tombée pile là échouait sans personne pour recliquer.
func TestConnexionRefuseeEstRejouee(t *testing.T) {
testHome(t)
// Un port fermé : on ouvre puis on referme aussitôt pour en obtenir un dont on
// est sûr que personne n'écoute.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
if err := SetConfigKey("PORT", itoa(port)); err != nil {
t.Fatal(err)
}
start := time.Now()
_, err = runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true })
elapsed := time.Since(start)
if err == nil {
t.Fatal("moteur injoignable : aucune erreur remontée")
}
// 3 reprises espacées de 0,8 / 1,6 / 3,2 s : le tour ne peut pas avoir rendu
// la main tout de suite. Sans ce délai, aucune reprise n'a eu lieu.
if min := llmNetWait + 2*llmNetWait; elapsed < min {
t.Fatalf("rendu la main en %v : aucune reprise n'a eu lieu (attendu au moins %v)", elapsed, min)
}
}
// Un statut « pas maintenant » (503) est rejoué, et le tour aboutit.
func TestStatutTransitoireEstRejoueEtAboutit(t *testing.T) {
testHome(t)
port, calls := flakyServer(t, 2, http.StatusServiceUnavailable,
sseChunk("ça a fini par passer")+"data: [DONE]\n\n")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
var content strings.Builder
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
return true
})
if err != nil {
t.Fatalf("503 transitoire non rattrapé : %v", err)
}
if n := calls.Load(); n != 3 {
t.Fatalf("%d appel(s) au moteur, attendu 3 (deux échecs puis la bonne)", n)
}
if !strings.Contains(content.String(), "ça a fini par passer") {
t.Fatalf("réponse perdue : %q", content.String())
}
}
// Contre-épreuve : un 500 n'est PAS un statut de reprise RÉSEAU. C'est ce que
// llama.cpp rend pour un appel d'outil malformé ou un prompt trop long — deux
// échecs déterministes ; attendre puis rejouer à l'identique ne ferait que
// retarder l'erreur de plusieurs secondes.
//
// Le tour peut malgré tout repartir UNE fois : le filet de compaction en vol,
// antérieur à ce fichier, retente le tour avec un historique résumé (le 500 est
// souvent un dépassement de contexte). C'est un rejeu SÉMANTIQUE — il change la
// requête et n'attend pas. La preuve qu'aucune reprise réseau n'a eu lieu est
// donc le CHRONO : trois reprises coûteraient au bas mot 0,8 + 1,6 s d'attente.
func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) {
testHome(t)
port, calls := flakyServer(t, 99, http.StatusInternalServerError, "")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
start := time.Now()
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true })
elapsed := time.Since(start)
if err == nil {
t.Fatal("500 : aucune erreur remontée")
}
if elapsed >= llmNetWait {
t.Fatalf("rendu la main en %v : une attente de reprise réseau a eu lieu sur un 500", elapsed)
}
// Borne haute large : ce qui compte est qu'on ne soit pas allé au bout du
// budget réseau (1 + 3 = 4 appels).
if n := calls.Load(); n > 2 {
t.Fatalf("%d appels au moteur sur un 500 : le budget de reprise réseau a été consommé", n)
}
}
// Un /stop pendant le tour ne doit surtout pas être rattrapé : réessayer, c'est
// passer outre l'annulation de l'utilisateur.
func TestAnnulationNestJamaisRejouee(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if llmRetryableErr(ctx, syscall.ECONNREFUSED) {
t.Fatal("ctx annulé : la reprise doit être refusée")
}
if llmRetryableErr(context.Background(), context.Canceled) {
t.Fatal("context.Canceled : la reprise doit être refusée")
}
if llmRetryableErr(context.Background(), context.DeadlineExceeded) {
t.Fatal("échéance dépassée : c'est la limite de l'appelant, pas une panne réseau")
}
// L'attente elle-même rend la main tout de suite sur un ctx fini.
if err := llmNetBackoff(ctx, 2); err == nil {
t.Fatal("l'attente doit être interrompue par le ctx")
}
}
// Les erreurs de transport qui méritent une reprise, et celles qui n'en méritent
// pas. Une faute de frappe dans l'URL ne se répare pas en attendant.
func TestErreursDeTransportRejouables(t *testing.T) {
ctx := context.Background()
for _, e := range []error{syscall.ECONNREFUSED, syscall.ECONNRESET, io.EOF, io.ErrUnexpectedEOF} {
if !llmRetryableErr(ctx, e) {
t.Errorf("%v devrait être rejouable (moteur qui redémarre ou connexion perdue)", e)
}
}
if llmRetryableErr(ctx, errors.New("unsupported protocol scheme")) {
t.Error("une erreur de configuration ne se répare pas en réessayant")
}
if llmRetryableErr(ctx, nil) {
t.Error("pas d'erreur, pas de reprise")
}
}
// Les codes de reprise, et ceux qui n'en sont pas.
func TestStatutsRejouables(t *testing.T) {
for _, c := range []int{http.StatusTooManyRequests, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout} {
if !llmRetryableStatus(c) {
t.Errorf("%d dit « pas maintenant » : rejouable", c)
}
}
for _, c := range []int{200, 400, 401, 404, 500} {
if llmRetryableStatus(c) {
t.Errorf("%d ne doit pas déclencher de reprise", c)
}
}
}
// L'attente double à chaque tentative, se plafonne, et ne déborde pas : un
// décalage de bits sur un n non borné repasserait à zéro en silence — la reprise
// deviendrait une rafale.
func TestAttenteCroissantePuisPlafonnee(t *testing.T) {
if got := llmNetDelay(0); got != llmNetWait {
t.Errorf("1re attente = %v, attendu %v", got, llmNetWait)
}
if got := llmNetDelay(1); got != 2*llmNetWait {
t.Errorf("2e attente = %v, attendu le double", got)
}
for _, n := range []int{10, 20, 64, 1000} {
if got := llmNetDelay(n); got != llmNetMaxWait {
t.Errorf("llmNetDelay(%d) = %v, attendu le plafond %v", n, got, llmNetMaxWait)
}
}
if got := llmNetDelay(-1); got != llmNetWait {
t.Errorf("llmNetDelay(-1) = %v, attendu la 1re attente", got)
}
}
+446 -170
View File
@@ -201,8 +201,8 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){ @media (min-width:721px){
.side{width:380px} .side{width:380px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:4px} .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px} .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
} }
h1{margin:0;font-size:18px;color:var(--accent)} h1{margin:0;font-size:18px;color:var(--accent)}
/* Logo : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A » /* Logo : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A »
@@ -394,6 +394,15 @@ button:disabled{opacity:.5;cursor:not-allowed}
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap} .preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase} .qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} .rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
min-width:0 + ellipsis : un nom de modèle distant peut être très long
(« llama-3.3-70b-versatile ») et ne doit pas pousser le reste hors de la ligne. */
.xtag{flex-shrink:1;min-width:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;
padding:1px 6px;border:1px solid var(--accent);color:var(--accent);border-radius:4px;letter-spacing:.03em;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.xtag svg{flex:none}
.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} .btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */ /* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */
.help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle} .help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle}
@@ -886,14 +895,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none} .pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none}
.pe-bar.done>i{width:100%} .pe-bar.done>i{width:100%}
@keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}} @keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}}
/* Liste des modèles installés (suppression des .gguf). Le nom prend toute la
place restante et se tronque : un fichier de modèle mesure facilement 60
caractères, et c'est la taille + le bouton qu'on vient chercher ici. */
.mdl-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border)}
.mdl-row:last-child{border-bottom:none}
.mdl-name{flex:1;min-width:0;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mdl-size{flex:none;color:var(--dim);font-variant-numeric:tabular-nums}
.mdl-del{align-self:center;flex:none}
/* Action discrète en texte (annuler un téléchargement…). */ /* Action discrète en texte (annuler un téléchargement…). */
.pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer} .pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer}
.pe-link:hover{color:var(--text)} .pe-link:hover{color:var(--text)}
@@ -1504,7 +1505,28 @@ button:hover{border-color:var(--dim);color:var(--text)}
endroit et ça décide autant de la suite — un dépôt gated ne se télécharge endroit et ça décide autant de la suite — un dépôt gated ne se télécharge
pas sans jeton. */ pas sans jeton. */
.hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none} .hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none}
/* Déjà sur le disque : ce n'est pas un verdict mémoire non plus, mais c'est
l'information qui décide du clic — cliquer sélectionne au lieu de télécharger. */
.hf-fit.here{color:var(--accent);border-color:var(--accent);text-transform:none}
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px} .hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
/* --- Modèles installés (liste + suppression) --------------------------------
Même allure que la liste Hugging Face juste au-dessus, mais la LIGNE n'est
pas cliquable : ici c'est le bouton « supprimer » qui agit, et un curseur
main sur toute la ligne laisserait croire le contraire. */
.mdl-list{display:flex;flex-direction:column;max-height:240px;overflow-y:auto;
border:1px solid var(--border);border-radius:3px;margin-top:6px}
.mdl-row{display:flex;align-items:center;gap:10px;padding:7px 12px;
border-top:1px solid var(--border);font-size:12px}
.mdl-row:first-child{border-top:0}
.mdl-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.mdl-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap}
.mdl-tag{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em;
border:1px solid var(--border);color:var(--dim)}
.mdl-tag.on{color:var(--accent);border-color:var(--accent)}
.mdl-tag.warn{color:var(--warn);border-color:var(--warn)}
.mdl-row .pe-link{flex:none}
.mdl-row .pe-link:disabled{opacity:.4;cursor:not-allowed;text-decoration:none}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px; .hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer} color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default} .hf-mm.muted{cursor:default}
@@ -2404,6 +2426,54 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div> </div>
</div> </div>
</div> </div>
<!-- Preset « externe » : le chat part vers une API OpenAI-compatible distante
(OpenAI, Groq, OpenRouter, un vLLM sur une autre machine…) au lieu du
llama-server local. C'est un preset comme un autre — il apparaît dans la
même liste, se bascule pareil — mais ses réglages n'ont rien à voir avec
ceux d'un modèle local : ni quantification, ni couches GPU, ni moteur.
D'où une fenêtre à part plutôt que des champs grisés dans l'éditeur. -->
<div id="ext-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeExternal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:520px;display:flex;flex-direction:column;max-height:90vh">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong id="ext-title">API externe</strong>
<button onclick="closeExternal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div class="pe-body" style="padding:0">
<div class="pe-group">
<div class="pe-gh">Connexion</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="ext-name" class="pe-inp" placeholder="Nom du preset (ex. Groq 70B)"></div>
<div class="pe-row stack">
<span class="pe-row-l">URL<span class="pe-sub">base (…/v1) ou URL complète des complétions</span></span>
<input id="ext-url" class="pe-inp" placeholder="https://api.groq.com/openai/v1" spellcheck="false">
</div>
<div class="pe-row stack">
<span class="pe-row-l">Modèle<span class="pe-sub">le nom exact attendu par l'API</span></span>
<input id="ext-model" class="pe-inp" placeholder="llama-3.3-70b-versatile" spellcheck="false">
</div>
<div class="pe-row stack">
<span class="pe-row-l">Clé d'API<span class="pe-sub" id="ext-key-sub">vide = aucune authentification</span></span>
<input id="ext-key" class="pe-inp" type="password" autocomplete="off" spellcheck="false" placeholder="sk-…" oninput="extKeyTouched=true">
</div>
<div class="pe-row">
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row stack">
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
<div id="ext-test-out" class="pe-note"></div>
</div>
</div>
</div>
</div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:6px">
<button id="ext-del" onclick="deleteExternal()" class="danger" style="display:none">supprimer</button>
<span style="flex:1"></span>
<button onclick="closeExternal()">annuler</button>
<button id="ext-save" onclick="saveExternal()" style="border-color:var(--accent);color:var(--accent)">enregistrer</button>
</div>
</div>
</div>
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des <!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
@@ -2471,7 +2541,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div id="cfg" class="slist">…</div> <div id="cfg" class="slist">…</div>
</section> </section>
<section class="set-pane" data-pane="presets"> <section class="set-pane" data-pane="presets">
<div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div> <div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openExternal('')" title="ajouter une API externe" aria-label="ajouter une API externe"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/></svg></button><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div>
<div class="slist"><div id="presets" class="rowlist"></div></div> <div class="slist"><div id="presets" class="rowlist"></div></div>
</section> </section>
<section class="set-pane" data-pane="agent"> <section class="set-pane" data-pane="agent">
@@ -3146,18 +3216,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-l">Fichier</span> <span class="pe-row-l">Fichier</span>
<span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span> <span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span>
</div> </div>
<!-- Gestion des .gguf du disque. Un modèle ne se supprimait que « en
même temps que son preset » : les fichiers téléchargés puis
abandonnés (mauvaise quantization, essai) restaient là pour des
dizaines de Go, sans aucun moyen de les effacer depuis Loki.
Repliée par défaut — on n'y va que le jour où le disque sature. -->
<div id="m-mdl-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalledModels()">
<span class="pe-row-l">Modèles installés<span class="pe-sub">supprimer un .gguf pour libérer de la place</span></span>
<span class="pe-row-c"><span id="m-mdl-state" class="pe-sub"></span><span id="m-mdl-caret" class="pe-caret"></span></span>
</div>
<div id="m-mdl-body" class="pe-row stack" style="display:none">
<div id="m-mdl-list" class="pe-note"></div>
</div>
<div class="pe-row"> <div class="pe-row">
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span> <span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span> <span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
@@ -3205,6 +3263,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div> </div>
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div> <div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div> </div>
<!-- Modèles installés. Un .gguf survit à la suppression de son preset
(c'est voulu : on le réutilise dans un autre). Encore faut-il
pouvoir le REPRENDRE et, un jour, l'effacer — sans ça les
fichiers gardés devenaient invisibles et indéboulonnables. -->
<div id="m-inst-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalled()">
<span class="pe-row-l">Modèles installés<span class="pe-sub">les .gguf présents sur le disque — et de quoi en effacer un</span></span>
<span class="pe-row-c"><span id="m-inst-state" class="pe-sub"></span><span id="m-inst-caret" class="pe-caret"></span></span>
</div>
<div id="m-inst-body" class="pe-row stack" style="display:none">
<div id="m-inst-list"></div>
</div>
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y <!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
touche qu'une fois, le jour où un dépôt à accès restreint refuse touche qu'une fois, le jour où un dépôt à accès restreint refuse
le téléchargement. Il vaut aussi pour la recherche, d'où sa place le téléchargement. Il vaut aussi pour la recherche, d'où sa place
@@ -4141,6 +4210,16 @@ async function loadPresets(){
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name; nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
// Second row: quant tag + bench perf, so the title row stays full-width. // Second row: quant tag + bench perf, so the title row stays full-width.
const meta=document.createElement('div'); meta.className='preset-meta'; const meta=document.createElement('div'); meta.className='preset-meta';
// Preset externe : ni quant ni bench à montrer — le modèle tourne ailleurs.
// On affiche à la place d'où vient la réponse, sans quoi rien à l'écran ne
// distinguerait un preset local d'un appel qui part sur internet.
if(x.external){
const et=document.createElement('span'); et.className='xtag';
et.title='API externe — le chat part vers un serveur distant';
et.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/></svg>';
et.appendChild(document.createTextNode(x.model || 'externe'));
meta.appendChild(et);
}
if(x.quant){ if(x.quant){
const q=document.createElement('span'); q.className='qtag'; const q=document.createElement('span'); q.className='qtag';
q.textContent=x.quant; q.title='quantization'; q.textContent=x.quant; q.title='quantization';
@@ -4166,7 +4245,10 @@ async function loadPresets(){
// lit comme centrée dans son coin, là où le crayon penché tirait de travers. // lit comme centrée dans son coin, là où le crayon penché tirait de travers.
edit.className='preset-edit'; edit.title='réglages du preset'; edit.className='preset-edit'; edit.title='réglages du preset';
edit.innerHTML='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'; edit.innerHTML='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); }; // Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
row.appendChild(info); row.appendChild(edit); row.appendChild(info); row.appendChild(edit);
cont.appendChild(row); cont.appendChild(row);
}); });
@@ -4659,6 +4741,9 @@ async function openItem(kind, key){
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture. // Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = ''; document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false); setModelDirsOpen(false);
// Modèles installés : replié aussi, mais son RÉSUMÉ (« 4 modèles · 87 Go »)
// est lu tout de suite — c'est lui qui s'affiche sur la ligne repliée.
setInstalledOpen(false);
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite — // Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »). // c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
setHFTokenOpen(false); setHFTokenOpen(false);
@@ -4699,8 +4784,8 @@ async function openItem(kind, key){
document.getElementById('m-quant').value = currentQuantInTextarea(); document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings(); populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ? attachDownload(); // téléchargement encore en cours côté serveur ?
setInstalledModelsOpen(false); await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(),
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs(), loadInstalledModels()]); populateDlDirs(), refreshInstalled()]);
if(seq !== openSeq) return; if(seq !== openSeq) return;
} }
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend // On lève le voile SANS transition (voir la note dans styles.css), puis on rend
@@ -4749,12 +4834,31 @@ function samePath(a, b){
return !!a && !!b && n(a) === n(b); return !!a && !!b && n(a) === n(b);
} }
function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); } function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); }
// Retrouve dans `list` (/api/models) l'entrée que désigne la valeur MODEL= d'un
// preset. Trois écritures possibles et toutes légitimes : le chemin complet, la
// valeur produite par le sélecteur, ou le SIMPLE NOM DE FICHIER (config écrite à
// la main, preset repris d'une autre machine, ancien preset). Le nom simple se
// résout comme le fait le moteur — dossier de loki d'abord, puis les autres.
//
// Sans ce dernier cas, un MODEL=modele.gguf parfaitement valide s'affichait
// « introuvable ; ajoute son dossier ci-dessous » alors que le fichier était là,
// et le sélecteur ne proposait rien : impossible de refaire un preset sur un
// modèle déjà présent sans le retélécharger.
function matchModel(cur, list){
if(!cur) return null;
const all = list || [];
const hit = all.find(x => samePath(cur, x.value) || samePath(cur, x.path));
if(hit) return hit;
const b = baseName(cur);
return all.find(x => x.home && samePath(b, x.name)) || all.find(x => samePath(b, x.name)) || null;
}
async function populateModelPicker(){ async function populateModelPicker(){
const sel = document.getElementById('m-model'); const sel = document.getElementById('m-model');
const list = await jget('/api/models'); const list = await jget('/api/models');
const cur = currentModelInTextarea(); const cur = currentModelInTextarea();
const hit = matchModel(cur, list);
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre). // regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
const groups = []; const groups = [];
for(const m of (list||[])){ for(const m of (list||[])){
let g = groups.find(x => x.dir === m.dir); let g = groups.find(x => x.dir === m.dir);
@@ -4766,9 +4870,7 @@ async function populateModelPicker(){
for(const g of groups){ for(const g of groups){
let opts = ''; let opts = '';
for(const m of g.items){ for(const m of g.items){
// Le preset peut référencer le chemin complet OU le simple nom de fichier. const on = (m === hit) ? ' selected' : '';
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
if(on) matched = true; if(on) matched = true;
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche // Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
// (les autres ne se lancent pas seules) et la taille de la famille entière. // (les autres ne se lancent pas seules) et la taille de la famille entière.
@@ -4791,123 +4893,6 @@ async function populateModelPicker(){
sel.innerHTML = html; sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true); if(cur && !matched) setModelDirsOpen(true);
} }
// ---- Modèles installés : lister et SUPPRIMER les .gguf du disque ------------
// Un modèle ne pouvait s'effacer qu'en même temps que son preset (case « supprimer
// aussi le fichier .gguf »). Tout ce qu'on avait téléchargé sans preset — une
// quantization essayée puis abandonnée, un projecteur du mauvais dépôt — restait
// donc sur le disque pour des dizaines de gigaoctets, sans aucun moyen de partir
// depuis Loki. La liste vit ici, à côté du sélecteur de fichier : c'est là qu'on
// découvre qu'un modèle ne sert plus.
//
// Repliée par défaut : c'est un ménage, pas un réglage. La ligne fermée porte
// déjà l'essentiel — combien de modèles, et la place qu'ils prennent.
let installedModels = [];
function setInstalledModelsOpen(open){
const b = document.getElementById('m-mdl-body');
const c = document.getElementById('m-mdl-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
}
function toggleInstalledModels(){
setInstalledModelsOpen(document.getElementById('m-mdl-body').style.display === 'none');
}
async function loadInstalledModels(){
try{ installedModels = (await jget('/api/models')) || []; }catch(_){ installedModels = []; }
const st = document.getElementById('m-mdl-state');
if(st){
const total = installedModels.reduce((a, m) => a + (m.size || 0), 0);
st.textContent = installedModels.length ? installedModels.length + ' · ' + fmtSize(total) : 'aucun';
}
renderInstalledModels();
}
function renderInstalledModels(){
const box = document.getElementById('m-mdl-list');
if(!box) return;
// Construit en DOM : ces noms de fichiers viennent du disque, ils n'ont rien à
// faire dans du HTML concaténé ni dans un attribut onclick.
box.textContent = '';
if(!installedModels.length){
box.textContent = 'Aucun .gguf dans les dossiers de modèles.';
return;
}
const multi = new Set(installedModels.map(m => m.dir)).size > 1;
for(const m of installedModels){
const row = document.createElement('div');
row.className = 'mdl-row';
const nm = document.createElement('span');
nm.className = 'mdl-name';
nm.textContent = m.name;
nm.title = m.path;
const info = document.createElement('span');
info.className = 'mdl-size';
let t = fmtSize(m.size);
if(m.shards > 1) t += ' · ' + m.shards + ' fichiers';
if(m.missing && m.missing.length) t += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
if(multi) t += ' · ' + (m.home ? 'dossier loki' : m.dir);
info.textContent = t;
row.append(nm, info);
// Chargé = intouchable (le moteur tient le fichier ouvert) ; référencé par des
// presets = supprimable, mais on le dit avant, pas au prochain démarrage raté.
if(m.loaded || (m.presets && m.presets.length)){
const tag = document.createElement('span');
tag.className = 'qtag';
if(m.loaded){
tag.textContent = 'chargé';
tag.title = 'modèle ouvert par le moteur';
} else {
tag.textContent = m.presets.length + ' preset' + (m.presets.length > 1 ? 's' : '');
tag.title = m.presets.join(', ');
}
row.append(tag);
}
const del = document.createElement('button');
del.className = 'pe-link mdl-del';
del.textContent = 'supprimer';
del.disabled = !!m.loaded;
del.title = m.loaded ? 'modèle chargé — bascule d’abord sur un autre preset' : 'supprimer ce fichier du disque';
del.onclick = () => deleteInstalledModel(m);
row.append(del);
box.append(row);
}
}
// Suppression d'un .gguf : irréversible, et de l'ordre de la dizaine de Go. Le
// serveur refuse tout seul le modèle CHARGÉ, et refuse SANS `force` un modèle
// que des presets réclament — en renvoyant leurs noms, qu'on pose alors dans une
// seconde confirmation. Le premier « oui » porte sur le fichier, le second sur
// les presets qu'on casse : ce ne sont pas la même décision.
async function deleteInstalledModel(m){
const shards = m.shards > 1 ? '\n\n' + m.shards + ' fichiers seront effacés (modèle découpé).' : '';
if(!await askConfirm('Supprimer « ' + m.name + ' » (' + fmtSize(m.size) + ') du disque ? Il n’y a pas de corbeille.' + shards,
{title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
const del = async force => { try{ return await jpost('/api/models/delete', {name: m.value, force}); }catch(_){ return null; } };
let r = await del(false);
if(r && !r.ok && r.needsForce){
if(!await askConfirm('Utilisé par : ' + (r.presets || []).join(', ')
+ '.\n\nCes presets ne démarreront plus tant qu’un autre modèle ne leur aura pas été choisi.',
{title:'Modèle utilisé par un preset', okText:'Supprimer quand même', danger:true})) return;
r = await del(true);
}
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); return; }
toast('supprimé — ' + fmtSize(r.freed || m.size) + ' libérés');
await reloadModelsAndPreset();
}
// Après un changement dans les modèles du disque (suppression, téléchargement
// terminé, dossier ajouté), on recharge le PRESET COMPLET, pas seulement la
// liste déroulante : le fichier choisi, le projecteur vision, la quantization
// déduite du nom, les réglages dérivés et l'espace libre des destinations
// dépendent tous de ce qui est réellement sur le disque. N'en rafraîchir qu'une
// partie laissait l'éditeur afficher un modèle qui n'existait plus.
async function reloadModelsAndPreset(){
await Promise.all([
loadInstalledModels(), populateModelPicker(), populateMmproj(),
populateModelDirs(), populateDlDirs(),
]);
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
if(typeof loadPresets === 'function') loadPresets();
}
// ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) --- // ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. // Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){ function setModelDirsOpen(open){
@@ -5024,13 +5009,148 @@ async function addModelDir(){
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
inp.value = ''; inp.value = '';
toast('dossier ajouté'); toast('dossier ajouté');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs(), loadInstalledModels()]); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
} }
async function removeModelDir(p){ async function removeModelDir(p){
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('dossier retiré'); toast('dossier retiré');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs(), loadInstalledModels()]); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
}
// ---- Modèles installés : ce qu'il y a sur le disque, et comment l'effacer ---
// La route /api/models/delete existait depuis longtemps ; AUCUN bouton ne
// l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en cochant
// « supprimer aussi le fichier » à la suppression de son preset — la case pas
// cochée, le fichier devenait inatteignable : 40 Go sur le disque que plus rien
// dans l'interface ne savait ni nommer ni reprendre. D'où cette liste.
//
// Elle dit aussi QUI se sert de chaque fichier : le modèle en service ne
// s'efface pas (le moteur l'a ouvert), et un modèle nommé par d'autres presets
// prévient avant, en les nommant.
let installedList = [];
function setInstalledOpen(open){
const b = document.getElementById('m-inst-body');
const c = document.getElementById('m-inst-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
// Relu à chaque dépliage : un modèle a pu arriver (téléchargement) ou partir
// depuis l'ouverture de la fenêtre, et une liste périmée ferait cliquer
// « supprimer » sur un fichier qui n'est plus là.
if(open) refreshInstalled();
}
function toggleInstalled(){
setInstalledOpen(document.getElementById('m-inst-body').style.display === 'none');
}
// Recharge la liste ET le résumé de la ligne repliée (« 4 modèles · 87 Go »),
// qui reste juste même quand la section n'est pas dépliée.
async function refreshInstalled(){
try{ installedList = await jget('/api/models') || []; }catch(_){ return; }
const st = document.getElementById('m-inst-state');
if(st){
const total = installedList.reduce((a, m) => a + (m.size||0), 0);
st.textContent = installedList.length
? installedList.length + ' modèle' + (installedList.length>1?'s':'') + ' · ' + fmtSize(total)
: 'aucun';
}
const b = document.getElementById('m-inst-body');
if(b && b.style.display !== 'none') renderInstalled();
}
// Construit en DOM et jamais en innerHTML : ces noms de fichiers viennent du
// disque de l'utilisateur, pas de nous.
function renderInstalled(){
const box = document.getElementById('m-inst-list');
if(!box) return;
box.textContent = '';
if(!installedList.length){
const d = document.createElement('div');
d.className = 'pe-note muted';
d.textContent = 'Aucun .gguf dans les dossiers déclarés.';
box.append(d);
return;
}
const list = document.createElement('div');
list.className = 'mdl-list';
for(const m of installedList){
const row = document.createElement('div');
row.className = 'mdl-row';
const n = document.createElement('span');
n.className = 'mdl-name';
n.textContent = m.name;
n.title = m.path;
const meta = document.createElement('span');
meta.className = 'mdl-meta';
meta.textContent = fmtSize(m.size||0)
+ (m.shards > 1 ? ' · ' + m.shards + ' fichiers' : '')
+ (m.home ? '' : ' · ' + m.dir);
row.append(n, meta);
// Ce qui EMPÊCHE ou ce qui COÛTE : le modèle en service ne s'efface pas, et
// celui que des presets nomment se signale avant le clic, pas après.
if(m.active){
const b = document.createElement('span');
b.className = 'mdl-tag on';
b.textContent = 'en service';
b.title = 'Ce modèle est celui de la configuration en cours : bascule sur un autre preset avant de l’effacer.';
row.append(b);
} else if((m.used||[]).length){
const b = document.createElement('span');
b.className = 'mdl-tag';
b.textContent = m.used.length + ' preset' + (m.used.length>1?'s':'');
b.title = 'Utilisé par : ' + m.used.join(', ');
row.append(b);
}
if((m.missing||[]).length){
const b = document.createElement('span');
b.className = 'mdl-tag warn';
b.textContent = m.missing.length + ' tranche' + (m.missing.length>1?'s':'') + ' manquante' + (m.missing.length>1?'s':'');
b.title = 'Téléchargement incomplet : ' + m.missing.join(', ');
row.append(b);
}
const del = document.createElement('button');
del.className = 'pe-link';
del.textContent = 'supprimer';
del.disabled = !!m.active;
del.onclick = () => deleteInstalled(m);
row.append(del);
list.append(row);
}
box.append(list);
}
async function deleteInstalled(m){
if(m.active){
toast('modèle en service — bascule sur un autre preset d’abord');
return;
}
const known = m.used || [];
const why = known.length
? '\n\nIl est référencé par : ' + known.join(', ') + '. Ces presets ne démarreront plus tant qu’un autre modèle n’y sera pas choisi.'
: '';
const what = (m.shards > 1 ? ' et ses ' + m.shards + ' fichiers' : '');
if(!await askConfirm('Effacer « ' + m.name + ' » (' + fmtSize(m.size||0) + ')' + what + ' du disque ?' + why
+ '\n\nIrréversible : il faudra le retélécharger.',
{title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
// force dès le premier envoi quand les presets concernés étaient DÉJÀ dans la
// confirmation : redemander la même chose une seconde fois ne protège de rien.
let r = await jpost('/api/models/delete', {name: m.path, force: known.length > 0});
// Le serveur connaît des références que la liste affichée ignorait (preset créé
// entre-temps, depuis un autre onglet) : là, la question se pose vraiment.
if(!r.ok && (r.used||[]).length){
if(!await askConfirm('Ce modèle est référencé par : ' + r.used.join(', ') + '.',
{title:'Supprimer quand même ?', okText:'Supprimer', danger:true})) return;
r = await jpost('/api/models/delete', {name: m.path, force: true});
}
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('modèle supprimé — ' + fmtSize(r.freed||0) + ' libérés');
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(),
populateModelDirs(), refreshInstalled()]);
}
// Un .gguf donné est-il déjà sur le disque, complet ? Sert avant de proposer un
// téléchargement (recherche Hugging Face).
async function modelIsOnDisk(name){
let list = [];
try{ list = await jget('/api/models') || []; }catch(_){ return false; }
return !!list.find(m => samePath(m.name, baseName(name)) && !(m.missing||[]).length);
} }
function onPickModel(){ function onPickModel(){
const val = document.getElementById('m-model').value; const val = document.getElementById('m-model').value;
@@ -5073,11 +5193,11 @@ async function populateMmproj(){
const list = await jget('/api/models'); const list = await jget('/api/models');
const cur = currentMmprojInTextarea(); const cur = currentMmprojInTextarea();
const items = (list||[]).filter(m => isMmprojName(m.name)); const items = (list||[]).filter(m => isMmprojName(m.name));
const hit = matchModel(cur, items);
let html = '<option value="">— aucune —</option>'; let html = '<option value="">— aucune —</option>';
let matched = false; let matched = false;
for(const m of items){ for(const m of items){
const on = samePath(cur, m.value) || samePath(cur, m.path) || const on = (m === hit) ? ' selected' : '';
samePath(baseName(cur), m.name) ? ' selected' : '';
if(on) matched = true; if(on) matched = true;
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>'; html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
} }
@@ -5533,8 +5653,11 @@ async function delItem(){
const r = await jpost(K.delUrl, payload); const r = await jpost(K.delUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
if(delModel){ if(delModel){
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError); // modelError est renseigné même quand le fichier A ÉTÉ supprimé (il servait
else if(r.modelDeleted) toast('preset + modèle supprimés'); // aussi à d'autres presets) : on regarde donc modelDeleted d'abord.
if(r.modelDeleted) toast('preset + modèle supprimés — ' + fmtSize(r.freed||0) + ' libérés'
+ (r.modelError ? ' (' + r.modelError + ')' : ''));
else if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
else toast('supprimé (aucun modèle référencé)'); else toast('supprimé (aucun modèle référencé)');
} else { toast('supprimé'); } } else { toast('supprimé'); }
closeModal(); K.reload(); closeModal(); K.reload();
@@ -5588,6 +5711,22 @@ async function populateDlDirs(){
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus // ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée. // vite et brouillent la progression affichée.
let dlQueue = []; let dlQueue = [];
// Un .gguf est disponible (il vient d'arriver, ou il était déjà là) : on le
// sélectionne dans le bon champ du preset. Un projecteur (mmproj) va dans
// « Vision », un modèle dans le sélecteur de modèle — d'après son nom de
// fichier. Le fichier a pu atterrir hors du dossier loki : l'option porte alors
// le chemin complet, pas le simple nom.
async function selectInstalled(fname){
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), refreshInstalled()]);
const pick = (id, cb)=>{
const s = document.getElementById(id);
if(!s) return false;
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(!o) return false;
s.value = o.value; cb(); return true;
};
return isMmprojName(fname) ? pick('m-mmproj', onPickMmproj) : pick('m-model', onPickModel);
}
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; }
@@ -5605,6 +5744,20 @@ async function startDownloadURL(url){
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant // Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert. // AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
const p = await jpost('/api/models/download/probe', {url, dir}); const p = await jpost('/api/models/download/probe', {url, dir});
// Modèle DÉJÀ sur le disque : il n'y a rien à télécharger, il y a un modèle à
// choisir. C'était le cul-de-sac — un .gguf gardé après la suppression de son
// preset ne pouvait plus servir à en refaire un : le téléchargement répondait
// « le modèle existe déjà » en rouge et s'arrêtait là.
if(p.ok && p.exists){
e.bar.style.display = 'none';
e.btn.disabled = false;
const done = await selectInstalled(p.filename);
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(p.filename)
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '')
+ (p.size > 0 ? ' (' + fmtSize(p.size) + ')' : '') + '</span>';
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
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;
@@ -5620,9 +5773,19 @@ async function startDownloadURL(url){
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : ''); + (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
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 : '+escHtml(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return; e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
} }
// Le serveur peut constater lui aussi que tout est déjà là (fichier apparu
// entre la sonde et l'envoi, ou lien collé à la main sans passer par la sonde).
if(r.exists){
e.bar.style.display = 'none'; e.btn.disabled = false;
const done = await selectInstalled(r.filename);
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(r.filename)
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + '</span>';
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
watchDownload(r.filename); watchDownload(r.filename);
} }
// Annule le téléchargement en cours : le serveur coupe les connexions et // Annule le téléchargement en cours : le serveur coupe les connexions et
@@ -5661,18 +5824,7 @@ function watchDownload(fname){
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>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop(); stop();
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), loadInstalledModels()]); await selectInstalled(fname);
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
// complet, pas le simple nom de fichier.
const pick = (id, cb)=>{
const s = document.getElementById(id);
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(o){ s.value = o.value; cb(); }
};
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 // 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 // arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné. // est déjà sélectionné.
@@ -5789,10 +5941,19 @@ function fmtCount(n){
async function hfPickRepo(repo){ async function hfPickRepo(repo){
hfMsg('lecture du dépôt…'); hfMsg('lecture du dépôt…');
let r; let r, onDisk = [];
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); } try{
// Les .gguf déjà présents sont demandés EN MÊME TEMPS que l'arborescence :
// c'est ce qui permet de dire « déjà installé » sur la ligne d'un quant au
// lieu de le découvrir après le clic, par un refus.
[r, onDisk] = await Promise.all([
jget('/api/hf/files?repo='+encodeURIComponent(repo)),
jget('/api/models').catch(()=>[]),
]);
}
catch(_){ hfMsg('Hugging Face injoignable', true); return; } catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; } if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
const installed = n => (onDisk||[]).find(m => samePath(m.name, baseName(n)) && !(m.missing||[]).length);
hfRepoFiles = r; hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; } if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
@@ -5847,7 +6008,8 @@ async function hfPickRepo(repo){
mmChk = document.createElement('input'); mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true; mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span'); const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')'; t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')'
+ (installed(mm.name) ? ' — déjà sur le disque' : '');
note.append(mmChk, t); note.append(mmChk, t);
} else { } else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.'; note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
@@ -5865,6 +6027,17 @@ async function hfPickRepo(repo){
const meta = document.createElement('span'); meta.className = 'hf-meta'; const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : ''); meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta); row.append(n, meta);
// Déjà sur le disque : la pastille le dit AVANT le clic, et le clic ne
// télécharge alors rien — il sélectionne le fichier présent. Le verdict
// mémoire n'a plus grand-chose à arbitrer dans ce cas, mais il reste
// affiché : « juste » vaut d'être su même sur un modèle déjà installé.
if(installed(m.name)){
const here = document.createElement('span');
here.className = 'hf-fit here';
here.textContent = 'déjà installé';
here.title = 'Ce fichier est déjà dans un de tes dossiers de modèles : le choisir le sélectionne, sans rien retélécharger.';
row.appendChild(here);
}
if(m.verdict){ if(m.verdict){
const fit = document.createElement('span'); const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict; fit.className = 'hf-fit ' + m.verdict;
@@ -5896,7 +6069,10 @@ function hfBestProjector(list){
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le // 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. // champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){ async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm( // Le fichier est là : rien à télécharger, donc rien à confirmer — la sonde le
// constate côté serveur et startDownloadURL le sélectionne.
const here = await modelIsOnDisk(model.name);
if(!here && 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.', (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; {title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : []; dlQueue = projector ? [projector.url] : [];
@@ -7426,6 +7602,106 @@ function lcToggleLog(){
if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; } if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; }
else { pre.setAttribute('hidden',''); bar.classList.remove('open'); } else { pre.setAttribute('hidden',''); bar.classList.remove('open'); }
} }
// 11-external.js — presets « externes » : le chat part vers une API
// OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre
// machine…) au lieu du llama-server local.
//
// Un preset externe vit dans la même liste que les autres et se bascule pareil.
// Mais ses réglages n'ont rien de commun avec ceux d'un modèle local — ni
// quantification, ni couches GPU, ni moteur, ni projecteur vision : la machine
// distante décide de tout ça. D'où cette fenêtre à part, plutôt qu'un éditeur de
// preset aux trois quarts grisé.
// La clé n'est JAMAIS renvoyée en clair par le serveur : le champ s'affiche
// vide même quand une clé est enregistrée. Un champ vide ne vaut donc pas
// « efface la clé » — il faut y avoir touché. Sans ce drapeau, rouvrir la
// fenêtre pour corriger une faute de frappe dans l'URL effaçait la clé au
// passage, et le preset tombait en 401 au message suivant.
let extKeyTouched = false;
let extEditing = '';
function closeExternal(){ hideModal('ext-modal'); }
async function openExternal(id){
extEditing = id || '';
extKeyTouched = false;
const set = (el, v) => { const e = document.getElementById(el); if(e) e.value = v; };
document.getElementById('ext-title').textContent = id ? 'API externe' : 'Nouvelle API externe';
document.getElementById('ext-del').style.display = id ? '' : 'none';
document.getElementById('ext-test-out').textContent = '';
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
showModal('ext-modal');
if(!id) return;
let d = {};
try{ d = await jget('/api/preset/external?id=' + encodeURIComponent(id)); }catch(_){ return; }
if(extEditing !== id) return; // une autre ouverture a pris la main
set('ext-name', d.name || ''); set('ext-url', d.url || '');
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
if(d.hasKey){
document.getElementById('ext-key-sub').textContent =
'une clé est enregistrée — laisse vide pour la conserver';
}
}
// Le corps commun aux deux routes (enregistrement et test).
function extPayload(){
return {
id: extEditing,
name: (document.getElementById('ext-name').value || '').trim(),
url: (document.getElementById('ext-url').value || '').trim(),
model: (document.getElementById('ext-model').value || '').trim(),
key: document.getElementById('ext-key').value || '',
ctx: (document.getElementById('ext-ctx').value || '').trim(),
keyTouched: extKeyTouched,
};
}
// Tester AVANT d'enregistrer n'est pas un confort : une URL mal collée ou une
// clé expirée ne se voit sinon qu'au premier message, sous la forme d'un code
// HTTP nu au milieu d'une conversation.
async function testExternal(){
const out = document.getElementById('ext-test-out');
const btn = document.getElementById('ext-test');
const p = extPayload();
if(!p.url || !p.model){ out.innerHTML = '<span style="color:var(--err)">URL et modèle requis</span>'; return; }
btn.disabled = true;
out.textContent = 'appel de test en cours…';
let r = {};
try{ r = await jpost('/api/preset/external/test', p); }
catch(_){ r = {ok:false, error:'réseau'}; }
btn.disabled = false;
out.innerHTML = r.ok
? '<span style="color:var(--ok)">✓ l’API répond</span>'
: '<span style="color:var(--err)">' + escHtml((r.status ? r.status + ' — ' : '') + (r.error || 'échec')) + '</span>';
}
async function saveExternal(){
const p = extPayload();
if(!p.name){ toast('nom requis'); return; }
if(!p.url){ toast('URL requise'); return; }
if(!p.model){ toast('modèle requis'); return; }
const r = await jpost('/api/preset/external/save', p);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('enregistré');
closeExternal();
loadPresets();
}
// Même route de suppression que n'importe quel preset — et même refus si c'est
// celui en service. Pas de case « supprimer aussi le .gguf » : il n'y a pas de
// fichier, le modèle est chez quelqu'un d'autre.
async function deleteExternal(){
if(!extEditing) return;
const name = (document.getElementById('ext-name').value || '').trim() || extEditing;
if(!await askConfirm('Supprimer le preset « ' + name + ' » ?',
{title:'Suppression', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/preset/delete', {id: extEditing});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('supprimé');
closeExternal();
loadPresets();
}
// ─── Serveurs MCP ──────────────────────────────────────────────────────────── // ─── Serveurs MCP ────────────────────────────────────────────────────────────
// Gestion des serveurs MCP (Model Context Protocol) : liste, ajout/édition via // Gestion des serveurs MCP (Model Context Protocol) : liste, ajout/édition via
// une modale, activation/désactivation, test de connexion. Sous-réglage du mode // une modale, activation/désactivation, test de connexion. Sous-réglage du mode
+60 -13
View File
@@ -239,6 +239,54 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div> </div>
</div> </div>
</div> </div>
<!-- Preset « externe » : le chat part vers une API OpenAI-compatible distante
(OpenAI, Groq, OpenRouter, un vLLM sur une autre machine…) au lieu du
llama-server local. C'est un preset comme un autre — il apparaît dans la
même liste, se bascule pareil — mais ses réglages n'ont rien à voir avec
ceux d'un modèle local : ni quantification, ni couches GPU, ni moteur.
D'où une fenêtre à part plutôt que des champs grisés dans l'éditeur. -->
<div id="ext-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeExternal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:520px;display:flex;flex-direction:column;max-height:90vh">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong id="ext-title">API externe</strong>
<button onclick="closeExternal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div class="pe-body" style="padding:0">
<div class="pe-group">
<div class="pe-gh">Connexion</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="ext-name" class="pe-inp" placeholder="Nom du preset (ex. Groq 70B)"></div>
<div class="pe-row stack">
<span class="pe-row-l">URL<span class="pe-sub">base (…/v1) ou URL complète des complétions</span></span>
<input id="ext-url" class="pe-inp" placeholder="https://api.groq.com/openai/v1" spellcheck="false">
</div>
<div class="pe-row stack">
<span class="pe-row-l">Modèle<span class="pe-sub">le nom exact attendu par l'API</span></span>
<input id="ext-model" class="pe-inp" placeholder="llama-3.3-70b-versatile" spellcheck="false">
</div>
<div class="pe-row stack">
<span class="pe-row-l">Clé d'API<span class="pe-sub" id="ext-key-sub">vide = aucune authentification</span></span>
<input id="ext-key" class="pe-inp" type="password" autocomplete="off" spellcheck="false" placeholder="sk-…" oninput="extKeyTouched=true">
</div>
<div class="pe-row">
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row stack">
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
<div id="ext-test-out" class="pe-note"></div>
</div>
</div>
</div>
</div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:6px">
<button id="ext-del" onclick="deleteExternal()" class="danger" style="display:none">supprimer</button>
<span style="flex:1"></span>
<button onclick="closeExternal()">annuler</button>
<button id="ext-save" onclick="saveExternal()" style="border-color:var(--accent);color:var(--accent)">enregistrer</button>
</div>
</div>
</div>
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des <!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
@@ -306,7 +354,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="cfg" class="slist">…</div> <div id="cfg" class="slist">…</div>
</section> </section>
<section class="set-pane" data-pane="presets"> <section class="set-pane" data-pane="presets">
<div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div> <div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openExternal('')" title="ajouter une API externe" aria-label="ajouter une API externe"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/></svg></button><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div>
<div class="slist"><div id="presets" class="rowlist"></div></div> <div class="slist"><div id="presets" class="rowlist"></div></div>
</section> </section>
<section class="set-pane" data-pane="agent"> <section class="set-pane" data-pane="agent">
@@ -981,18 +1029,6 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-l">Fichier</span> <span class="pe-row-l">Fichier</span>
<span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span> <span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span>
</div> </div>
<!-- Gestion des .gguf du disque. Un modèle ne se supprimait que « en
même temps que son preset » : les fichiers téléchargés puis
abandonnés (mauvaise quantization, essai) restaient là pour des
dizaines de Go, sans aucun moyen de les effacer depuis Loki.
Repliée par défaut — on n'y va que le jour où le disque sature. -->
<div id="m-mdl-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalledModels()">
<span class="pe-row-l">Modèles installés<span class="pe-sub">supprimer un .gguf pour libérer de la place</span></span>
<span class="pe-row-c"><span id="m-mdl-state" class="pe-sub"></span><span id="m-mdl-caret" class="pe-caret"></span></span>
</div>
<div id="m-mdl-body" class="pe-row stack" style="display:none">
<div id="m-mdl-list" class="pe-note"></div>
</div>
<div class="pe-row"> <div class="pe-row">
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span> <span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span> <span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
@@ -1040,6 +1076,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div> </div>
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div> <div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div> </div>
<!-- Modèles installés. Un .gguf survit à la suppression de son preset
(c'est voulu : on le réutilise dans un autre). Encore faut-il
pouvoir le REPRENDRE et, un jour, l'effacer — sans ça les
fichiers gardés devenaient invisibles et indéboulonnables. -->
<div id="m-inst-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalled()">
<span class="pe-row-l">Modèles installés<span class="pe-sub">les .gguf présents sur le disque — et de quoi en effacer un</span></span>
<span class="pe-row-c"><span id="m-inst-state" class="pe-sub"></span><span id="m-inst-caret" class="pe-caret"></span></span>
</div>
<div id="m-inst-body" class="pe-row stack" style="display:none">
<div id="m-inst-list"></div>
</div>
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y <!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
touche qu'une fois, le jour où un dépôt à accès restreint refuse touche qu'une fois, le jour où un dépôt à accès restreint refuse
le téléchargement. Il vaut aussi pour la recherche, d'où sa place le téléchargement. Il vaut aussi pour la recherche, d'où sa place
+14 -1
View File
@@ -69,6 +69,16 @@ async function loadPresets(){
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name; nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
// Second row: quant tag + bench perf, so the title row stays full-width. // Second row: quant tag + bench perf, so the title row stays full-width.
const meta=document.createElement('div'); meta.className='preset-meta'; const meta=document.createElement('div'); meta.className='preset-meta';
// Preset externe : ni quant ni bench à montrer — le modèle tourne ailleurs.
// On affiche à la place d'où vient la réponse, sans quoi rien à l'écran ne
// distinguerait un preset local d'un appel qui part sur internet.
if(x.external){
const et=document.createElement('span'); et.className='xtag';
et.title='API externe — le chat part vers un serveur distant';
et.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/></svg>';
et.appendChild(document.createTextNode(x.model || 'externe'));
meta.appendChild(et);
}
if(x.quant){ if(x.quant){
const q=document.createElement('span'); q.className='qtag'; const q=document.createElement('span'); q.className='qtag';
q.textContent=x.quant; q.title='quantization'; q.textContent=x.quant; q.title='quantization';
@@ -94,7 +104,10 @@ async function loadPresets(){
// lit comme centrée dans son coin, là où le crayon penché tirait de travers. // lit comme centrée dans son coin, là où le crayon penché tirait de travers.
edit.className='preset-edit'; edit.title='réglages du preset'; edit.className='preset-edit'; edit.title='réglages du preset';
edit.innerHTML='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'; edit.innerHTML='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); }; // Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
row.appendChild(info); row.appendChild(edit); row.appendChild(info); row.appendChild(edit);
cont.appendChild(row); cont.appendChild(row);
}); });
+240 -146
View File
@@ -121,6 +121,9 @@ async function openItem(kind, key){
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture. // Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = ''; document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false); setModelDirsOpen(false);
// Modèles installés : replié aussi, mais son RÉSUMÉ (« 4 modèles · 87 Go »)
// est lu tout de suite — c'est lui qui s'affiche sur la ligne repliée.
setInstalledOpen(false);
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite — // Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »). // c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
setHFTokenOpen(false); setHFTokenOpen(false);
@@ -161,8 +164,8 @@ async function openItem(kind, key){
document.getElementById('m-quant').value = currentQuantInTextarea(); document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings(); populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ? attachDownload(); // téléchargement encore en cours côté serveur ?
setInstalledModelsOpen(false); await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(),
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs(), loadInstalledModels()]); populateDlDirs(), refreshInstalled()]);
if(seq !== openSeq) return; if(seq !== openSeq) return;
} }
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend // On lève le voile SANS transition (voir la note dans styles.css), puis on rend
@@ -211,12 +214,31 @@ function samePath(a, b){
return !!a && !!b && n(a) === n(b); return !!a && !!b && n(a) === n(b);
} }
function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); } function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); }
// Retrouve dans `list` (/api/models) l'entrée que désigne la valeur MODEL= d'un
// preset. Trois écritures possibles et toutes légitimes : le chemin complet, la
// valeur produite par le sélecteur, ou le SIMPLE NOM DE FICHIER (config écrite à
// la main, preset repris d'une autre machine, ancien preset). Le nom simple se
// résout comme le fait le moteur — dossier de loki d'abord, puis les autres.
//
// Sans ce dernier cas, un MODEL=modele.gguf parfaitement valide s'affichait
// « introuvable ; ajoute son dossier ci-dessous » alors que le fichier était là,
// et le sélecteur ne proposait rien : impossible de refaire un preset sur un
// modèle déjà présent sans le retélécharger.
function matchModel(cur, list){
if(!cur) return null;
const all = list || [];
const hit = all.find(x => samePath(cur, x.value) || samePath(cur, x.path));
if(hit) return hit;
const b = baseName(cur);
return all.find(x => x.home && samePath(b, x.name)) || all.find(x => samePath(b, x.name)) || null;
}
async function populateModelPicker(){ async function populateModelPicker(){
const sel = document.getElementById('m-model'); const sel = document.getElementById('m-model');
const list = await jget('/api/models'); const list = await jget('/api/models');
const cur = currentModelInTextarea(); const cur = currentModelInTextarea();
const hit = matchModel(cur, list);
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre). // regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
const groups = []; const groups = [];
for(const m of (list||[])){ for(const m of (list||[])){
let g = groups.find(x => x.dir === m.dir); let g = groups.find(x => x.dir === m.dir);
@@ -228,9 +250,7 @@ async function populateModelPicker(){
for(const g of groups){ for(const g of groups){
let opts = ''; let opts = '';
for(const m of g.items){ for(const m of g.items){
// Le preset peut référencer le chemin complet OU le simple nom de fichier. const on = (m === hit) ? ' selected' : '';
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
if(on) matched = true; if(on) matched = true;
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche // Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
// (les autres ne se lancent pas seules) et la taille de la famille entière. // (les autres ne se lancent pas seules) et la taille de la famille entière.
@@ -253,123 +273,6 @@ async function populateModelPicker(){
sel.innerHTML = html; sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true); if(cur && !matched) setModelDirsOpen(true);
} }
// ---- Modèles installés : lister et SUPPRIMER les .gguf du disque ------------
// Un modèle ne pouvait s'effacer qu'en même temps que son preset (case « supprimer
// aussi le fichier .gguf »). Tout ce qu'on avait téléchargé sans preset — une
// quantization essayée puis abandonnée, un projecteur du mauvais dépôt — restait
// donc sur le disque pour des dizaines de gigaoctets, sans aucun moyen de partir
// depuis Loki. La liste vit ici, à côté du sélecteur de fichier : c'est là qu'on
// découvre qu'un modèle ne sert plus.
//
// Repliée par défaut : c'est un ménage, pas un réglage. La ligne fermée porte
// déjà l'essentiel — combien de modèles, et la place qu'ils prennent.
let installedModels = [];
function setInstalledModelsOpen(open){
const b = document.getElementById('m-mdl-body');
const c = document.getElementById('m-mdl-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
}
function toggleInstalledModels(){
setInstalledModelsOpen(document.getElementById('m-mdl-body').style.display === 'none');
}
async function loadInstalledModels(){
try{ installedModels = (await jget('/api/models')) || []; }catch(_){ installedModels = []; }
const st = document.getElementById('m-mdl-state');
if(st){
const total = installedModels.reduce((a, m) => a + (m.size || 0), 0);
st.textContent = installedModels.length ? installedModels.length + ' · ' + fmtSize(total) : 'aucun';
}
renderInstalledModels();
}
function renderInstalledModels(){
const box = document.getElementById('m-mdl-list');
if(!box) return;
// Construit en DOM : ces noms de fichiers viennent du disque, ils n'ont rien à
// faire dans du HTML concaténé ni dans un attribut onclick.
box.textContent = '';
if(!installedModels.length){
box.textContent = 'Aucun .gguf dans les dossiers de modèles.';
return;
}
const multi = new Set(installedModels.map(m => m.dir)).size > 1;
for(const m of installedModels){
const row = document.createElement('div');
row.className = 'mdl-row';
const nm = document.createElement('span');
nm.className = 'mdl-name';
nm.textContent = m.name;
nm.title = m.path;
const info = document.createElement('span');
info.className = 'mdl-size';
let t = fmtSize(m.size);
if(m.shards > 1) t += ' · ' + m.shards + ' fichiers';
if(m.missing && m.missing.length) t += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
if(multi) t += ' · ' + (m.home ? 'dossier loki' : m.dir);
info.textContent = t;
row.append(nm, info);
// Chargé = intouchable (le moteur tient le fichier ouvert) ; référencé par des
// presets = supprimable, mais on le dit avant, pas au prochain démarrage raté.
if(m.loaded || (m.presets && m.presets.length)){
const tag = document.createElement('span');
tag.className = 'qtag';
if(m.loaded){
tag.textContent = 'chargé';
tag.title = 'modèle ouvert par le moteur';
} else {
tag.textContent = m.presets.length + ' preset' + (m.presets.length > 1 ? 's' : '');
tag.title = m.presets.join(', ');
}
row.append(tag);
}
const del = document.createElement('button');
del.className = 'pe-link mdl-del';
del.textContent = 'supprimer';
del.disabled = !!m.loaded;
del.title = m.loaded ? 'modèle chargé — bascule d’abord sur un autre preset' : 'supprimer ce fichier du disque';
del.onclick = () => deleteInstalledModel(m);
row.append(del);
box.append(row);
}
}
// Suppression d'un .gguf : irréversible, et de l'ordre de la dizaine de Go. Le
// serveur refuse tout seul le modèle CHARGÉ, et refuse SANS `force` un modèle
// que des presets réclament — en renvoyant leurs noms, qu'on pose alors dans une
// seconde confirmation. Le premier « oui » porte sur le fichier, le second sur
// les presets qu'on casse : ce ne sont pas la même décision.
async function deleteInstalledModel(m){
const shards = m.shards > 1 ? '\n\n' + m.shards + ' fichiers seront effacés (modèle découpé).' : '';
if(!await askConfirm('Supprimer « ' + m.name + ' » (' + fmtSize(m.size) + ') du disque ? Il n’y a pas de corbeille.' + shards,
{title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
const del = async force => { try{ return await jpost('/api/models/delete', {name: m.value, force}); }catch(_){ return null; } };
let r = await del(false);
if(r && !r.ok && r.needsForce){
if(!await askConfirm('Utilisé par : ' + (r.presets || []).join(', ')
+ '.\n\nCes presets ne démarreront plus tant qu’un autre modèle ne leur aura pas été choisi.',
{title:'Modèle utilisé par un preset', okText:'Supprimer quand même', danger:true})) return;
r = await del(true);
}
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); return; }
toast('supprimé — ' + fmtSize(r.freed || m.size) + ' libérés');
await reloadModelsAndPreset();
}
// Après un changement dans les modèles du disque (suppression, téléchargement
// terminé, dossier ajouté), on recharge le PRESET COMPLET, pas seulement la
// liste déroulante : le fichier choisi, le projecteur vision, la quantization
// déduite du nom, les réglages dérivés et l'espace libre des destinations
// dépendent tous de ce qui est réellement sur le disque. N'en rafraîchir qu'une
// partie laissait l'éditeur afficher un modèle qui n'existait plus.
async function reloadModelsAndPreset(){
await Promise.all([
loadInstalledModels(), populateModelPicker(), populateMmproj(),
populateModelDirs(), populateDlDirs(),
]);
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
if(typeof loadPresets === 'function') loadPresets();
}
// ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) --- // ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. // Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){ function setModelDirsOpen(open){
@@ -486,13 +389,148 @@ async function addModelDir(){
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
inp.value = ''; inp.value = '';
toast('dossier ajouté'); toast('dossier ajouté');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs(), loadInstalledModels()]); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
} }
async function removeModelDir(p){ async function removeModelDir(p){
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('dossier retiré'); toast('dossier retiré');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs(), loadInstalledModels()]); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
}
// ---- Modèles installés : ce qu'il y a sur le disque, et comment l'effacer ---
// La route /api/models/delete existait depuis longtemps ; AUCUN bouton ne
// l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en cochant
// « supprimer aussi le fichier » à la suppression de son preset — la case pas
// cochée, le fichier devenait inatteignable : 40 Go sur le disque que plus rien
// dans l'interface ne savait ni nommer ni reprendre. D'où cette liste.
//
// Elle dit aussi QUI se sert de chaque fichier : le modèle en service ne
// s'efface pas (le moteur l'a ouvert), et un modèle nommé par d'autres presets
// prévient avant, en les nommant.
let installedList = [];
function setInstalledOpen(open){
const b = document.getElementById('m-inst-body');
const c = document.getElementById('m-inst-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
// Relu à chaque dépliage : un modèle a pu arriver (téléchargement) ou partir
// depuis l'ouverture de la fenêtre, et une liste périmée ferait cliquer
// « supprimer » sur un fichier qui n'est plus là.
if(open) refreshInstalled();
}
function toggleInstalled(){
setInstalledOpen(document.getElementById('m-inst-body').style.display === 'none');
}
// Recharge la liste ET le résumé de la ligne repliée (« 4 modèles · 87 Go »),
// qui reste juste même quand la section n'est pas dépliée.
async function refreshInstalled(){
try{ installedList = await jget('/api/models') || []; }catch(_){ return; }
const st = document.getElementById('m-inst-state');
if(st){
const total = installedList.reduce((a, m) => a + (m.size||0), 0);
st.textContent = installedList.length
? installedList.length + ' modèle' + (installedList.length>1?'s':'') + ' · ' + fmtSize(total)
: 'aucun';
}
const b = document.getElementById('m-inst-body');
if(b && b.style.display !== 'none') renderInstalled();
}
// Construit en DOM et jamais en innerHTML : ces noms de fichiers viennent du
// disque de l'utilisateur, pas de nous.
function renderInstalled(){
const box = document.getElementById('m-inst-list');
if(!box) return;
box.textContent = '';
if(!installedList.length){
const d = document.createElement('div');
d.className = 'pe-note muted';
d.textContent = 'Aucun .gguf dans les dossiers déclarés.';
box.append(d);
return;
}
const list = document.createElement('div');
list.className = 'mdl-list';
for(const m of installedList){
const row = document.createElement('div');
row.className = 'mdl-row';
const n = document.createElement('span');
n.className = 'mdl-name';
n.textContent = m.name;
n.title = m.path;
const meta = document.createElement('span');
meta.className = 'mdl-meta';
meta.textContent = fmtSize(m.size||0)
+ (m.shards > 1 ? ' · ' + m.shards + ' fichiers' : '')
+ (m.home ? '' : ' · ' + m.dir);
row.append(n, meta);
// Ce qui EMPÊCHE ou ce qui COÛTE : le modèle en service ne s'efface pas, et
// celui que des presets nomment se signale avant le clic, pas après.
if(m.active){
const b = document.createElement('span');
b.className = 'mdl-tag on';
b.textContent = 'en service';
b.title = 'Ce modèle est celui de la configuration en cours : bascule sur un autre preset avant de l’effacer.';
row.append(b);
} else if((m.used||[]).length){
const b = document.createElement('span');
b.className = 'mdl-tag';
b.textContent = m.used.length + ' preset' + (m.used.length>1?'s':'');
b.title = 'Utilisé par : ' + m.used.join(', ');
row.append(b);
}
if((m.missing||[]).length){
const b = document.createElement('span');
b.className = 'mdl-tag warn';
b.textContent = m.missing.length + ' tranche' + (m.missing.length>1?'s':'') + ' manquante' + (m.missing.length>1?'s':'');
b.title = 'Téléchargement incomplet : ' + m.missing.join(', ');
row.append(b);
}
const del = document.createElement('button');
del.className = 'pe-link';
del.textContent = 'supprimer';
del.disabled = !!m.active;
del.onclick = () => deleteInstalled(m);
row.append(del);
list.append(row);
}
box.append(list);
}
async function deleteInstalled(m){
if(m.active){
toast('modèle en service — bascule sur un autre preset d’abord');
return;
}
const known = m.used || [];
const why = known.length
? '\n\nIl est référencé par : ' + known.join(', ') + '. Ces presets ne démarreront plus tant qu’un autre modèle n’y sera pas choisi.'
: '';
const what = (m.shards > 1 ? ' et ses ' + m.shards + ' fichiers' : '');
if(!await askConfirm('Effacer « ' + m.name + ' » (' + fmtSize(m.size||0) + ')' + what + ' du disque ?' + why
+ '\n\nIrréversible : il faudra le retélécharger.',
{title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
// force dès le premier envoi quand les presets concernés étaient DÉJÀ dans la
// confirmation : redemander la même chose une seconde fois ne protège de rien.
let r = await jpost('/api/models/delete', {name: m.path, force: known.length > 0});
// Le serveur connaît des références que la liste affichée ignorait (preset créé
// entre-temps, depuis un autre onglet) : là, la question se pose vraiment.
if(!r.ok && (r.used||[]).length){
if(!await askConfirm('Ce modèle est référencé par : ' + r.used.join(', ') + '.',
{title:'Supprimer quand même ?', okText:'Supprimer', danger:true})) return;
r = await jpost('/api/models/delete', {name: m.path, force: true});
}
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('modèle supprimé — ' + fmtSize(r.freed||0) + ' libérés');
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(),
populateModelDirs(), refreshInstalled()]);
}
// Un .gguf donné est-il déjà sur le disque, complet ? Sert avant de proposer un
// téléchargement (recherche Hugging Face).
async function modelIsOnDisk(name){
let list = [];
try{ list = await jget('/api/models') || []; }catch(_){ return false; }
return !!list.find(m => samePath(m.name, baseName(name)) && !(m.missing||[]).length);
} }
function onPickModel(){ function onPickModel(){
const val = document.getElementById('m-model').value; const val = document.getElementById('m-model').value;
@@ -535,11 +573,11 @@ async function populateMmproj(){
const list = await jget('/api/models'); const list = await jget('/api/models');
const cur = currentMmprojInTextarea(); const cur = currentMmprojInTextarea();
const items = (list||[]).filter(m => isMmprojName(m.name)); const items = (list||[]).filter(m => isMmprojName(m.name));
const hit = matchModel(cur, items);
let html = '<option value="">— aucune —</option>'; let html = '<option value="">— aucune —</option>';
let matched = false; let matched = false;
for(const m of items){ for(const m of items){
const on = samePath(cur, m.value) || samePath(cur, m.path) || const on = (m === hit) ? ' selected' : '';
samePath(baseName(cur), m.name) ? ' selected' : '';
if(on) matched = true; if(on) matched = true;
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>'; html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
} }
@@ -995,8 +1033,11 @@ async function delItem(){
const r = await jpost(K.delUrl, payload); const r = await jpost(K.delUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
if(delModel){ if(delModel){
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError); // modelError est renseigné même quand le fichier A ÉTÉ supprimé (il servait
else if(r.modelDeleted) toast('preset + modèle supprimés'); // aussi à d'autres presets) : on regarde donc modelDeleted d'abord.
if(r.modelDeleted) toast('preset + modèle supprimés — ' + fmtSize(r.freed||0) + ' libérés'
+ (r.modelError ? ' (' + r.modelError + ')' : ''));
else if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
else toast('supprimé (aucun modèle référencé)'); else toast('supprimé (aucun modèle référencé)');
} else { toast('supprimé'); } } else { toast('supprimé'); }
closeModal(); K.reload(); closeModal(); K.reload();
@@ -1050,6 +1091,22 @@ async function populateDlDirs(){
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus // ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée. // vite et brouillent la progression affichée.
let dlQueue = []; let dlQueue = [];
// Un .gguf est disponible (il vient d'arriver, ou il était déjà là) : on le
// sélectionne dans le bon champ du preset. Un projecteur (mmproj) va dans
// « Vision », un modèle dans le sélecteur de modèle — d'après son nom de
// fichier. Le fichier a pu atterrir hors du dossier loki : l'option porte alors
// le chemin complet, pas le simple nom.
async function selectInstalled(fname){
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), refreshInstalled()]);
const pick = (id, cb)=>{
const s = document.getElementById(id);
if(!s) return false;
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(!o) return false;
s.value = o.value; cb(); return true;
};
return isMmprojName(fname) ? pick('m-mmproj', onPickMmproj) : pick('m-model', onPickModel);
}
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; }
@@ -1067,6 +1124,20 @@ async function startDownloadURL(url){
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant // Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert. // AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
const p = await jpost('/api/models/download/probe', {url, dir}); const p = await jpost('/api/models/download/probe', {url, dir});
// Modèle DÉJÀ sur le disque : il n'y a rien à télécharger, il y a un modèle à
// choisir. C'était le cul-de-sac — un .gguf gardé après la suppression de son
// preset ne pouvait plus servir à en refaire un : le téléchargement répondait
// « le modèle existe déjà » en rouge et s'arrêtait là.
if(p.ok && p.exists){
e.bar.style.display = 'none';
e.btn.disabled = false;
const done = await selectInstalled(p.filename);
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(p.filename)
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '')
+ (p.size > 0 ? ' (' + fmtSize(p.size) + ')' : '') + '</span>';
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
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;
@@ -1082,9 +1153,19 @@ async function startDownloadURL(url){
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : ''); + (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
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 : '+escHtml(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return; e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
} }
// Le serveur peut constater lui aussi que tout est déjà là (fichier apparu
// entre la sonde et l'envoi, ou lien collé à la main sans passer par la sonde).
if(r.exists){
e.bar.style.display = 'none'; e.btn.disabled = false;
const done = await selectInstalled(r.filename);
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(r.filename)
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + '</span>';
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
watchDownload(r.filename); watchDownload(r.filename);
} }
// Annule le téléchargement en cours : le serveur coupe les connexions et // Annule le téléchargement en cours : le serveur coupe les connexions et
@@ -1123,18 +1204,7 @@ function watchDownload(fname){
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>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop(); stop();
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), loadInstalledModels()]); await selectInstalled(fname);
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
// complet, pas le simple nom de fichier.
const pick = (id, cb)=>{
const s = document.getElementById(id);
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(o){ s.value = o.value; cb(); }
};
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 // 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 // arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné. // est déjà sélectionné.
@@ -1251,10 +1321,19 @@ function fmtCount(n){
async function hfPickRepo(repo){ async function hfPickRepo(repo){
hfMsg('lecture du dépôt…'); hfMsg('lecture du dépôt…');
let r; let r, onDisk = [];
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); } try{
// Les .gguf déjà présents sont demandés EN MÊME TEMPS que l'arborescence :
// c'est ce qui permet de dire « déjà installé » sur la ligne d'un quant au
// lieu de le découvrir après le clic, par un refus.
[r, onDisk] = await Promise.all([
jget('/api/hf/files?repo='+encodeURIComponent(repo)),
jget('/api/models').catch(()=>[]),
]);
}
catch(_){ hfMsg('Hugging Face injoignable', true); return; } catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; } if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
const installed = n => (onDisk||[]).find(m => samePath(m.name, baseName(n)) && !(m.missing||[]).length);
hfRepoFiles = r; hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; } if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
@@ -1309,7 +1388,8 @@ async function hfPickRepo(repo){
mmChk = document.createElement('input'); mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true; mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span'); const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')'; t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')'
+ (installed(mm.name) ? ' — déjà sur le disque' : '');
note.append(mmChk, t); note.append(mmChk, t);
} else { } else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.'; note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
@@ -1327,6 +1407,17 @@ async function hfPickRepo(repo){
const meta = document.createElement('span'); meta.className = 'hf-meta'; const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : ''); meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta); row.append(n, meta);
// Déjà sur le disque : la pastille le dit AVANT le clic, et le clic ne
// télécharge alors rien — il sélectionne le fichier présent. Le verdict
// mémoire n'a plus grand-chose à arbitrer dans ce cas, mais il reste
// affiché : « juste » vaut d'être su même sur un modèle déjà installé.
if(installed(m.name)){
const here = document.createElement('span');
here.className = 'hf-fit here';
here.textContent = 'déjà installé';
here.title = 'Ce fichier est déjà dans un de tes dossiers de modèles : le choisir le sélectionne, sans rien retélécharger.';
row.appendChild(here);
}
if(m.verdict){ if(m.verdict){
const fit = document.createElement('span'); const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict; fit.className = 'hf-fit ' + m.verdict;
@@ -1358,7 +1449,10 @@ function hfBestProjector(list){
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le // 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. // champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){ async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm( // Le fichier est là : rien à télécharger, donc rien à confirmer — la sonde le
// constate côté serveur et startDownloadURL le sélectionne.
const here = await modelIsOnDisk(model.name);
if(!here && 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.', (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; {title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : []; dlQueue = projector ? [projector.url] : [];
+100
View File
@@ -0,0 +1,100 @@
// 11-external.js — presets « externes » : le chat part vers une API
// OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre
// machine…) au lieu du llama-server local.
//
// Un preset externe vit dans la même liste que les autres et se bascule pareil.
// Mais ses réglages n'ont rien de commun avec ceux d'un modèle local — ni
// quantification, ni couches GPU, ni moteur, ni projecteur vision : la machine
// distante décide de tout ça. D'où cette fenêtre à part, plutôt qu'un éditeur de
// preset aux trois quarts grisé.
// La clé n'est JAMAIS renvoyée en clair par le serveur : le champ s'affiche
// vide même quand une clé est enregistrée. Un champ vide ne vaut donc pas
// « efface la clé » — il faut y avoir touché. Sans ce drapeau, rouvrir la
// fenêtre pour corriger une faute de frappe dans l'URL effaçait la clé au
// passage, et le preset tombait en 401 au message suivant.
let extKeyTouched = false;
let extEditing = '';
function closeExternal(){ hideModal('ext-modal'); }
async function openExternal(id){
extEditing = id || '';
extKeyTouched = false;
const set = (el, v) => { const e = document.getElementById(el); if(e) e.value = v; };
document.getElementById('ext-title').textContent = id ? 'API externe' : 'Nouvelle API externe';
document.getElementById('ext-del').style.display = id ? '' : 'none';
document.getElementById('ext-test-out').textContent = '';
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
showModal('ext-modal');
if(!id) return;
let d = {};
try{ d = await jget('/api/preset/external?id=' + encodeURIComponent(id)); }catch(_){ return; }
if(extEditing !== id) return; // une autre ouverture a pris la main
set('ext-name', d.name || ''); set('ext-url', d.url || '');
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
if(d.hasKey){
document.getElementById('ext-key-sub').textContent =
'une clé est enregistrée — laisse vide pour la conserver';
}
}
// Le corps commun aux deux routes (enregistrement et test).
function extPayload(){
return {
id: extEditing,
name: (document.getElementById('ext-name').value || '').trim(),
url: (document.getElementById('ext-url').value || '').trim(),
model: (document.getElementById('ext-model').value || '').trim(),
key: document.getElementById('ext-key').value || '',
ctx: (document.getElementById('ext-ctx').value || '').trim(),
keyTouched: extKeyTouched,
};
}
// Tester AVANT d'enregistrer n'est pas un confort : une URL mal collée ou une
// clé expirée ne se voit sinon qu'au premier message, sous la forme d'un code
// HTTP nu au milieu d'une conversation.
async function testExternal(){
const out = document.getElementById('ext-test-out');
const btn = document.getElementById('ext-test');
const p = extPayload();
if(!p.url || !p.model){ out.innerHTML = '<span style="color:var(--err)">URL et modèle requis</span>'; return; }
btn.disabled = true;
out.textContent = 'appel de test en cours…';
let r = {};
try{ r = await jpost('/api/preset/external/test', p); }
catch(_){ r = {ok:false, error:'réseau'}; }
btn.disabled = false;
out.innerHTML = r.ok
? '<span style="color:var(--ok)">✓ l’API répond</span>'
: '<span style="color:var(--err)">' + escHtml((r.status ? r.status + ' — ' : '') + (r.error || 'échec')) + '</span>';
}
async function saveExternal(){
const p = extPayload();
if(!p.name){ toast('nom requis'); return; }
if(!p.url){ toast('URL requise'); return; }
if(!p.model){ toast('modèle requis'); return; }
const r = await jpost('/api/preset/external/save', p);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('enregistré');
closeExternal();
loadPresets();
}
// Même route de suppression que n'importe quel preset — et même refus si c'est
// celui en service. Pas de case « supprimer aussi le .gguf » : il n'y a pas de
// fichier, le modèle est chez quelqu'un d'autre.
async function deleteExternal(){
if(!extEditing) return;
const name = (document.getElementById('ext-name').value || '').trim() || extEditing;
if(!await askConfirm('Supprimer le preset « ' + name + ' » ?',
{title:'Suppression', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/preset/delete', {id: extEditing});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('supprimé');
closeExternal();
loadPresets();
}
+32 -10
View File
@@ -170,8 +170,8 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){ @media (min-width:721px){
.side{width:380px} .side{width:380px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:4px} .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px} .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
} }
h1{margin:0;font-size:18px;color:var(--accent)} h1{margin:0;font-size:18px;color:var(--accent)}
/* Logo : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A » /* Logo : « Loki » dessiné en pixels (SVG 4x5 par lettre, trait d'un module). Le « A »
@@ -363,6 +363,15 @@ button:disabled{opacity:.5;cursor:not-allowed}
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap} .preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase} .qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} .rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
min-width:0 + ellipsis : un nom de modèle distant peut être très long
(« llama-3.3-70b-versatile ») et ne doit pas pousser le reste hors de la ligne. */
.xtag{flex-shrink:1;min-width:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;
padding:1px 6px;border:1px solid var(--accent);color:var(--accent);border-radius:4px;letter-spacing:.03em;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.xtag svg{flex:none}
.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} .btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */ /* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */
.help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle} .help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle}
@@ -855,14 +864,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none} .pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none}
.pe-bar.done>i{width:100%} .pe-bar.done>i{width:100%}
@keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}} @keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}}
/* Liste des modèles installés (suppression des .gguf). Le nom prend toute la
place restante et se tronque : un fichier de modèle mesure facilement 60
caractères, et c'est la taille + le bouton qu'on vient chercher ici. */
.mdl-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border)}
.mdl-row:last-child{border-bottom:none}
.mdl-name{flex:1;min-width:0;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mdl-size{flex:none;color:var(--dim);font-variant-numeric:tabular-nums}
.mdl-del{align-self:center;flex:none}
/* Action discrète en texte (annuler un téléchargement…). */ /* Action discrète en texte (annuler un téléchargement…). */
.pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer} .pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer}
.pe-link:hover{color:var(--text)} .pe-link:hover{color:var(--text)}
@@ -1473,7 +1474,28 @@ button:hover{border-color:var(--dim);color:var(--text)}
endroit et ça décide autant de la suite — un dépôt gated ne se télécharge endroit et ça décide autant de la suite — un dépôt gated ne se télécharge
pas sans jeton. */ pas sans jeton. */
.hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none} .hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none}
/* Déjà sur le disque : ce n'est pas un verdict mémoire non plus, mais c'est
l'information qui décide du clic — cliquer sélectionne au lieu de télécharger. */
.hf-fit.here{color:var(--accent);border-color:var(--accent);text-transform:none}
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px} .hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
/* --- Modèles installés (liste + suppression) --------------------------------
Même allure que la liste Hugging Face juste au-dessus, mais la LIGNE n'est
pas cliquable : ici c'est le bouton « supprimer » qui agit, et un curseur
main sur toute la ligne laisserait croire le contraire. */
.mdl-list{display:flex;flex-direction:column;max-height:240px;overflow-y:auto;
border:1px solid var(--border);border-radius:3px;margin-top:6px}
.mdl-row{display:flex;align-items:center;gap:10px;padding:7px 12px;
border-top:1px solid var(--border);font-size:12px}
.mdl-row:first-child{border-top:0}
.mdl-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.mdl-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap}
.mdl-tag{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em;
border:1px solid var(--border);color:var(--dim)}
.mdl-tag.on{color:var(--accent);border-color:var(--accent)}
.mdl-tag.warn{color:var(--warn);border-color:var(--warn)}
.mdl-row .pe-link{flex:none}
.mdl-row .pe-link:disabled{opacity:.4;cursor:not-allowed;text-decoration:none}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px; .hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer} color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default} .hf-mm.muted{cursor:default}
+79 -52
View File
@@ -29,6 +29,13 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
if active { if active {
health = healthCheck() health = healthCheck()
} }
// Preset externe : aucun service llama-server local, mais le chat EST prêt
// (il tape vers l'API distante). Sans ça l'interface afficherait un moteur
// éteint pour toujours et laisserait la saisie bloquée.
external := externalActive()
if external {
active, state, health = true, "active", true
}
ctx := 32768 ctx := 32768
if v := ReadConfig()["CTX"]; v != "" { if v := ReadConfig()["CTX"]; v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 { if n, err := strconv.Atoi(v); err == nil && n > 0 {
@@ -67,9 +74,10 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
"port": LLMPort(), "port": LLMPort(),
"ctx": ctx, "ctx": ctx,
"version": Version, "version": Version,
"warn": warn, // App Translocation macOS, /data non monté… — vide si tout va bien "warn": warn, // App Translocation macOS, /data non monté… — vide si tout va bien
"load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon "load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon
"load_pct": loadPct, // % estimé du chargement en cours (-1 = non mesurable) "load_pct": loadPct, // % estimé du chargement en cours (-1 = non mesurable)
"external": external, // preset externe : le chat part vers une API distante
}) })
} }
@@ -299,23 +307,30 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) {
} }
// handleModels lists *.gguf files (size in bytes) for the preset editor's model // handleModels lists *.gguf files (size in bytes) for the preset editor's model
// picker, dans LOKI_HOME ET dans les dossiers supplémentaires déclarés (disque // picker, dans le dossier de modèles de Loki ET dans les dossiers
// externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de // supplémentaires déclarés (disque externe…). "value" est ce qu'il faut écrire
// fichier pour LOKI_HOME (compatibilité avec l'existant), le chemin complet // dans MODEL= : un simple nom de fichier pour le dossier de Loki
// pour un dossier externe. // (compatibilité avec l'existant, et preset portable d'une machine à l'autre),
// le chemin complet pour un dossier externe.
//
// ⚠️ Le dossier « maison » est modelsDir() — $LOKI_HOME/models — et non
// LokiHome() : c'est là que les téléchargements atterrissent (downloadDestPath)
// et c'est lui que resolveModelPath essaie en premier pour un nom simple. Le
// comparer à LokiHome() ne pouvait JAMAIS être vrai : chaque modèle sortait en
// chemin absolu, l'étiquette « dossier loki » ne s'affichait nulle part, et un
// preset écrit à la main avec MODEL=modele.gguf ne correspondait à aucune
// option du sélecteur — il s'affichait « introuvable » alors que le fichier
// était bien là.
//
// Chaque entrée porte aussi QUI la référence (« used » : noms de presets,
// « active » : la configuration en service). C'est ce qui permet à l'interface
// de proposer la suppression d'un .gguf sans faire disparaître le modèle sous
// le moteur qui tourne.
func handleModels(w http.ResponseWriter, r *http.Request) { func handleModels(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{} out := []map[string]any{}
seen := map[string]bool{} seen := map[string]bool{}
home := LokiHome() home := modelsDir()
// Ce que le moteur a ouvert, et ce que les presets réclament : l'interface en refs := buildModelRefIndex()
// a besoin pour dire lesquels sont supprimables sans rien casser.
refs := modelPresetRefs()
loaded := ""
if cur := strings.TrimSpace(ReadConfig()["MODEL"]); cur != "" {
if q, err := resolveServeModelPath(cur); err == nil {
loaded = normDir(q)
}
}
for _, dir := range modelDirs() { for _, dir := range modelDirs() {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
@@ -350,12 +365,11 @@ func handleModels(w http.ResponseWriter, r *http.Request) {
"name": e.Name(), "size": size, "value": value, "name": e.Name(), "size": size, "value": value,
"path": full, "dir": dir, "home": isHome, "path": full, "dir": dir, "home": isHome,
} }
key := normDir(full) if users, active := refs.lookup(full); len(users) > 0 || active {
if key == loaded { if len(users) > 0 {
m["loaded"] = true m["used"] = users
} }
if len(refs[key]) > 0 { m["active"] = active
m["presets"] = refs[key]
} }
// Taille annoncée = la famille entière, et on signale les tranches // Taille annoncée = la famille entière, et on signale les tranches
// manquantes : un modèle incomplet démarre puis meurt sur un tenseur // manquantes : un modèle incomplet démarre puis meurt sur un tenseur
@@ -384,6 +398,16 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
for _, p := range list { for _, p := range list {
item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active} item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active}
if content, err := ReadPreset(p.ID); err == nil { if content, err := ReadPreset(p.ID); err == nil {
// Preset externe : ni quant, ni raisonnement local à annoncer — on
// l'étiquette et on donne le nom du modèle distant à afficher.
if cfg := parseEnv(content); isExternalConfig(cfg) {
item["external"] = true
if m := strings.TrimSpace(cfg[extKeyModel]); m != "" {
item["model"] = m
}
out = append(out, item)
continue
}
if q := detectQuant(content); q != "" { if q := detectQuant(content); q != "" {
item["quant"] = q item["quant"] = q
} }
@@ -478,37 +502,24 @@ func handlePresetDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
modelDeleted, modelErr := "", "" modelDeleted, modelErr := "", ""
freed := int64(0)
if req.DeleteModel && model != "" { if req.DeleteModel && model != "" {
modelDeleted, modelErr = deletePresetModel(model) // Le preset vient de disparaître : les « utilisé par » comptés ici sont
// donc les AUTRES presets, et le refus « en service » vise bien le modèle
// que le moteur a ouvert. On ne bloque pas sur un autre preset — c'est un
// geste explicite, coché dans la confirmation — mais on le DIT.
others, _ := modelUsers(model)
if n, err := deleteModelFile(model); err != nil {
modelErr = err.Error()
} else {
modelDeleted, freed = model, n
if len(others) > 0 {
modelErr = "supprimé, mais il était aussi référencé par : " + strings.Join(others, ", ")
}
}
} }
sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr}) sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted,
} "modelError": modelErr, "freed": freed})
// deletePresetModel efface le .gguf d'un preset qu'on vient de supprimer, avec
// les MÊMES garde-fous que la suppression directe (/api/models/delete) : la case
// « supprimer aussi le fichier » ne doit pas être un chemin détourné pour
// effacer un modèle qu'on protège ailleurs.
//
// Le modèle CHARGÉ est conservé (le preset supprimé n'était pas forcément celui
// en service : deux presets peuvent désigner le même .gguf), et un modèle encore
// réclamé par un AUTRE preset aussi — sinon supprimer un preset laissait son
// voisin avec un moteur qui meurt au démarrage. Le refus est renvoyé à
// l'interface, qui le dit ; la suppression du preset, elle, a déjà eu lieu.
func deletePresetModel(model string) (deleted, errMsg string) {
p, err := resolveModelPath(model)
if err != nil {
return "", err.Error()
}
if modelIsLoaded(p) {
return "", "modèle chargé par le moteur — fichier conservé"
}
if used := presetsUsingModel(p); len(used) > 0 {
return "", "encore utilisé par : " + strings.Join(used, ", ") + " — fichier conservé"
}
if err := deleteModelFile(p); err != nil {
return "", err.Error()
}
return model, ""
} }
// handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que // handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que
@@ -1005,6 +1016,22 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
return return
} }
fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target.Path)) fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target.Path))
// Preset externe : aucun moteur local à (re)démarrer — il n'a pas de MODEL et
// llama-server partirait en boucle de crash. On ARRÊTE au contraire celui qui
// tourne encore : le chat part désormais vers l'API distante, garder le modèle
// en VRAM ne sert plus à rien.
if isExternalConfig(ReadConfig()) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
go func() {
if serviceIsActive() {
if err := serviceAction("stop"); err != nil {
fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err)
}
}
}()
sendJSON(w, 200, map[string]any{"ok": true, "preset": target.Name})
return
}
fmt.Println(dim("[info] redémarrage du service...")) fmt.Println(dim("[info] redémarrage du service..."))
go func() { go func() {
if err := serviceAction("restart"); err != nil { if err := serviceAction("restart"); err != nil {
+179
View File
@@ -0,0 +1,179 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"time"
)
// web_external.go — endpoints /api/preset/external/* : lire, enregistrer et
// tester un preset « externe » (API OpenAI-compatible distante).
//
// GET /api/preset/external?id=… champs du preset (jamais la clé en clair)
// POST /api/preset/external/save enregistre (création si id vide)
// POST /api/preset/external/test appel de complétion minimal, avant d'enregistrer
//
// Le test AVANT enregistrement n'est pas un confort : une URL mal collée ou une
// clé expirée ne se voit sinon qu'au premier message, sous la forme d'un code
// HTTP nu au milieu d'une conversation.
// handlePresetExternal renvoie les champs d'un preset externe pour préremplir la
// modale d'édition. id vide = nouveau preset (champs vides).
func handlePresetExternal(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
sendJSON(w, 200, map[string]any{"id": "", "name": "", "url": "", "model": "", "hasKey": false})
return
}
content, err := ReadPreset(id)
if err != nil {
sendJSON(w, 404, map[string]any{"error": "not found"})
return
}
cfg := parseEnv(content)
if !isExternalConfig(cfg) {
sendJSON(w, 400, map[string]any{"error": "pas un preset externe"})
return
}
// La clé n'est PAS renvoyée en clair : on signale seulement sa présence, pour
// que la modale montre « clé enregistrée » sans l'exposer. Un ré-enregistrement
// sans toucher au champ clé la conserve (voir handlePresetExternalSave).
sendJSON(w, 200, map[string]any{
"id": id,
"name": presetDisplayName(content, id),
"url": strings.TrimSpace(cfg[extKeyURL]),
"model": strings.TrimSpace(cfg[extKeyModel]),
"ctx": strings.TrimSpace(cfg["CTX"]),
"hasKey": strings.TrimSpace(cfg[extKeyToken]) != "",
})
}
// externalSaveReq : payload de la modale externe. `key` vide sur une ÉDITION
// conserve la clé existante (voir keyTouched) ; sur une création, absence de clé
// = pas d'authentification.
type externalSaveReq struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Model string `json:"model"`
Key string `json:"key"`
Ctx string `json:"ctx"` // taille de contexte (clé CTX), optionnelle
KeyTouched bool `json:"keyTouched"` // l'utilisateur a modifié le champ clé
}
func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
var req externalSaveReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
req.URL = strings.TrimSpace(req.URL)
req.Model = strings.TrimSpace(req.Model)
if req.Name == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom requis"})
return
}
if req.URL == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "URL requise"})
return
}
if req.Model == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle requis"})
return
}
// Clé : sur une édition sans toucher au champ, on relit la clé déjà stockée
// pour ne pas l'effacer (la modale ne la reçoit jamais en clair).
key := req.Key
if req.ID != "" && !req.KeyTouched {
if content, err := ReadPreset(req.ID); err == nil {
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
}
}
content := externalPresetContent(req.URL, req.Model, key, req.Ctx)
newID, err := SavePreset(req.ID, req.Name, content)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name})
}
// handlePresetExternalTest tente un appel de complétion minimal vers l'endpoint
// saisi, pour valider URL + modèle + clé avant d'enregistrer. Sur une édition
// sans clé fournie, on réutilise la clé stockée.
func handlePresetExternalTest(w http.ResponseWriter, r *http.Request) {
var req externalSaveReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
url := completionsURL(req.URL)
model := strings.TrimSpace(req.Model)
if url == "" || model == "" {
sendJSON(w, 200, map[string]any{"ok": false, "error": "URL et modèle requis"})
return
}
key := strings.TrimSpace(req.Key)
if key == "" && req.ID != "" && !req.KeyTouched {
if content, err := ReadPreset(req.ID); err == nil {
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
}
}
payload := map[string]any{
"model": model,
"messages": []map[string]any{{"role": "user", "content": "ping"}},
"max_tokens": 1,
"stream": false,
}
body, _ := json.Marshal(payload)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
hreq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
hreq.Header.Set("Content-Type", "application/json")
if key != "" {
hreq.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(hreq)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": "injoignable : " + err.Error()})
return
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 2000))
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(rb))
if m := extractAPIError(rb); m != "" {
msg = m
}
if msg == "" {
msg = resp.Status
}
sendJSON(w, 200, map[string]any{"ok": false, "status": resp.StatusCode, "error": msg})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// extractAPIError sort le champ error.message d'une réponse d'erreur au format
// OpenAI ({"error":{"message":"…"}}), pour un message lisible plutôt que le JSON
// brut. Renvoie "" si le format ne correspond pas.
func extractAPIError(b []byte) string {
var e struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(b, &e) == nil {
return strings.TrimSpace(e.Error.Message)
}
return ""
}
+5
View File
@@ -222,6 +222,11 @@ func newWebMux() *http.ServeMux {
api("/api/preset", handlePreset) api("/api/preset", handlePreset)
api("/api/preset/save", handlePresetSave) api("/api/preset/save", handlePresetSave)
api("/api/preset/delete", handlePresetDelete) api("/api/preset/delete", handlePresetDelete)
// Presets externes : le chat part vers une API OpenAI-compatible distante au
// lieu du llama-server local (backend_external.go).
api("/api/preset/external", handlePresetExternal)
api("/api/preset/external/save", handlePresetExternalSave)
api("/api/preset/external/test", handlePresetExternalTest)
api("/api/agent", handleAgent) api("/api/agent", handleAgent)
api("/api/agent/toggle", handleAgentToggle) api("/api/agent/toggle", handleAgentToggle)
api("/api/agent/compact", handleCompactToggle) api("/api/agent/compact", handleCompactToggle)