diff --git a/internal/loki/backend_external.go b/internal/loki/backend_external.go new file mode 100644 index 0000000..990abe1 --- /dev/null +++ b/internal/loki/backend_external.go @@ -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) +} diff --git a/internal/loki/backend_external_test.go b/internal/loki/backend_external_test.go new file mode 100644 index 0000000..a489281 --- /dev/null +++ b/internal/loki/backend_external_test.go @@ -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) + } +} diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go index 7d93e19..e226007 100644 --- a/internal/loki/backend_models.go +++ b/internal/loki/backend_models.go @@ -201,101 +201,45 @@ func modelFromPresetContent(content string) string { // 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 // 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) 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 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) for _, n := range shardFamily(filepath.Base(p)) { _ = 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 -// 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é. +// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés. // -// Deux garde-fous, parce que rien n'est réversible ici et qu'un modèle pèse des -// dizaines de gigaoctets : -// - le modèle CHARGÉ est intouchable (modelIsLoaded) ; -// - un modèle référencé par des presets ne part qu'avec `force`, l'appelant -// ayant reçu leur liste dans la réponse 409 pour la poser à l'utilisateur. +// Deux garde-fous, et un seul est un refus définitif : +// +// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ; +// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets +// 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) { var req struct { 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()}) 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 { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } - if st, err := os.Stat(p); err != nil || st.IsDir() { - 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) + sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users}) } // ---- Hugging Face downloads ------------------------------------------------- @@ -485,7 +414,16 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) { } if len(todoURLs) == 0 { 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 } 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. urls, names := shardURLSet(dlURL, name) 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) defer cancel() var total int64 diff --git a/internal/loki/backend_models_delete_test.go b/internal/loki/backend_models_delete_test.go deleted file mode 100644 index 06742aa..0000000 --- a/internal/loki/backend_models_delete_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/loki/backend_models_refs.go b/internal/loki/backend_models_refs.go new file mode 100644 index 0000000..e8f914d --- /dev/null +++ b/internal/loki/backend_models_refs.go @@ -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) +} diff --git a/internal/loki/backend_models_refs_test.go b/internal/loki/backend_models_refs_test.go new file mode 100644 index 0000000..818c549 --- /dev/null +++ b/internal/loki/backend_models_refs_test.go @@ -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"]) + } +} diff --git a/internal/loki/backend_models_shards_test.go b/internal/loki/backend_models_shards_test.go index f9f10bc..12ecbd9 100644 --- a/internal/loki/backend_models_shards_test.go +++ b/internal/loki/backend_models_shards_test.go @@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) { 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) } + if freed != 2 { + t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed) + } for _, n := range names[:2] { if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) { t.Fatalf("tranche %s non supprimée", n) diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go index 97fdfcb..15670ee 100644 --- a/internal/loki/backend_presets.go +++ b/internal/loki/backend_presets.go @@ -264,6 +264,17 @@ func SwitchToPreset(target string) error { return err } 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...")) return serviceAction("restart") } diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index f2050c6..ef6ceef 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -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 // sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il -// doit reprendre une capture 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]" +// doit REVOIR l'image plutôt que deviner. +// +// 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 // 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. 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{ - "model": "loki", + "model": ep.Model, "messages": []Message{ {Role: "system", Content: sys}, {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}, } body, _ := json.Marshal(payload) - url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") - authHeader(req) + ep.auth(req.Header.Set) resp, err := http.DefaultClient.Do(req) if err != nil { return "", friendlyLLMError(err) @@ -608,7 +619,11 @@ Write the summary in the SAME language as the conversation.` defer resp.Body.Close() if resp.StatusCode != http.StatusOK { 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 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 945354a..5468cbf 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -8,6 +8,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" ) @@ -88,6 +89,18 @@ const ( 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 // enregistré (première fois) on part d'une conversation vide. // @@ -101,11 +114,16 @@ const ( func LoadConversation() { var b []byte var err error + loadConvRetries.Store(0) + retry := func() { + loadConvRetries.Add(1) + time.Sleep(loadConvRetryWait) + } for attempt := 0; attempt < loadConvAttempts; attempt++ { // Sonde : lire la clé de la discussion active en gardant l'erreur. Tant // qu'elle échoue, convEnsureActive ne doit surtout pas être appelée. if _, err = getBytesErr(bkChat, ckActive); err != nil { - time.Sleep(loadConvRetryWait) + retry() continue } // convEnsureActive reprend au passage le fil unique des versions @@ -113,7 +131,7 @@ func LoadConversation() { if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil { break } - time.Sleep(loadConvRetryWait) + retry() } if err != nil { // Toujours en échec : on le DIT au lieu de repartir à vide en silence, et diff --git a/internal/loki/chat_conversation_load_test.go b/internal/loki/chat_conversation_load_test.go index b606bc4..70b5788 100644 --- a/internal/loki/chat_conversation_load_test.go +++ b/internal/loki/chat_conversation_load_test.go @@ -108,6 +108,12 @@ func TestLoadConversationBaseIllisibleNeForgePasDeDiscussion(t *testing.T) { 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) } + // 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) if err != nil { t.Fatal(err) @@ -130,12 +136,15 @@ func TestLoadConversationAbsenceLegitimeResteRapide(t *testing.T) { t.Setenv("LOKI_HOME", t.TempDir()) resetConvForTest() - start := time.Now() LoadConversation() - elapsed := time.Since(start) - if elapsed >= loadConvRetryWait { - t.Fatalf("absence légitime traitée comme une erreur : %v d'attente, aucune reprise ne devait être déclenchée", elapsed) + // Mesuré sur le COMPTEUR de reprises, pas au chronomètre : une absence + // 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() n := len(conv.Messages) diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index f3e963e..2159b35 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -156,9 +156,10 @@ func engineSeesImages() bool { // - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture. // // 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 -// aucun indice qu'il A VU une image — il la redécrit de mémoire au lieu d'en -// reprendre une. Même raison que dans msgText, autre bout de la chaîne. +// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le +// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire +// 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 { for i, m := range msgs { parts, ok := m.Content.([]any) diff --git a/internal/loki/chat_vision_tool.go b/internal/loki/chat_vision_tool.go new file mode 100644 index 0000000..3fe2c8d --- /dev/null +++ b/internal/loki/chat_vision_tool.go @@ -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"}, + }, + }} +} diff --git a/internal/loki/chat_vision_tool_test.go b/internal/loki/chat_vision_tool_test.go new file mode 100644 index 0000000..cdb0664 --- /dev/null +++ b/internal/loki/chat_vision_tool_test.go @@ -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) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 3524cf7..ac8277b 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -359,6 +359,13 @@ func EnabledTools(caps Caps) []Tool { // 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 // 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() { tools = append(tools, webScreenshotTool()) } @@ -658,6 +665,55 @@ func isNetTimeout(err error) bool { 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) { var extra []Message 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 // relance par tour, comme le nudge. 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 // 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 @@ -730,7 +795,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps messages = append(messages, Message{Role: "user", Content: msg}) } payload := map[string]any{ - "model": "loki", + "model": ep.Model, // 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. "messages": normalizeSystemMessages(messages), @@ -762,15 +827,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps payload["parallel_tool_calls"] = false } body, _ := json.Marshal(payload) - url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body)) if err != nil { return extra, err } 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) 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) cb(StreamEvent{Err: 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.") 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}) 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{} assistantContent := strings.Builder{} finishReason := "" @@ -1112,50 +1210,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps toolRuns++ // alimente le budget souple (voir budgetNudge) var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) - // Derive the human label (command / skill name) up front so we can - // announce the call BEFORE running it — otherwise the UI shows - // nothing while a slow shell command runs and looks frozen. - label := "" - 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) - } - } + // Libellé humain, annoncé AVANT l'exécution (voir toolCallLabel) : sans + // ça l'interface reste muette pendant une commande lente. Il sert + // aussi d'argument principal à plusieurs outils. + label := toolCallLabel(tc.Function.Name, args) cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}}) result := "" // diff : rempli par les outils d'écriture (edit / mémoire) pour que // l'UI montre les lignes ajoutées et retirées. 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 // rejoue pas. Les petits modèles réémettent volontiers deux fois la // 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 } switch tc.Function.Name { + case "see_image": + result, visionImg = toolSeeImage(label) case "recall": result = toolRecall(args) 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 : // 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} // 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 { + if externalActive() { + return true + } resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort())) if err != nil { return false diff --git a/internal/loki/llm_retry_net.go b/internal/loki/llm_retry_net.go new file mode 100644 index 0000000..47c90fc --- /dev/null +++ b/internal/loki/llm_retry_net.go @@ -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) +} diff --git a/internal/loki/llm_retry_net_test.go b/internal/loki/llm_retry_net_test.go new file mode 100644 index 0000000..22d90db --- /dev/null +++ b/internal/loki/llm_retry_net_test.go @@ -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) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 09ac944..1b0818a 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -201,8 +201,8 @@ option,optgroup{background:var(--panel);color:var(--text)} /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ @media (min-width:721px){ .side{width:380px} - .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{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,.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,.preset-meta .xtag svg{width:11px;height:11px} } h1{margin:0;font-size:18px;color:var(--accent)} /* 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} .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} +/* 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} /* 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} @@ -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.done>i{width: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…). */ .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)} @@ -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 pas sans jeton. */ .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} + +/* --- 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; color:var(--dim);cursor:pointer} .hf-mm.muted{cursor:default} @@ -2404,6 +2426,54 @@ html[data-files="1"] #files-btn{color:var(--accent)} + +
-