package loki import ( "bufio" "crypto/sha1" "encoding/hex" "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" ) // A preset's IDENTITY is its filename (ID, without the .env suffix), which is // always unique. Its DISPLAY name lives in an optional `# NAME=` line inside the // file, so several presets can share the same display name without overwriting // each other (their filenames differ — see uniquePresetID). type Preset struct { ID string // filename without .env — stable, unique identity Name string // display name (# NAME= line, falls back to ID) Path string Active bool } var nameLineRe = regexp.MustCompile(`(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$`) // presetDisplayName extracts the `# NAME=` value from a preset body, falling // back to `fallback` (the filename id) when absent — keeps old presets working. func presetDisplayName(content, fallback string) string { for _, line := range strings.Split(content, "\n") { s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#")) i := strings.IndexByte(s, '=') if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "NAME") { if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" { return v } } } return fallback } // withDisplayName ensures the body carries a `# NAME=` line (replacing an // existing one, or prepended otherwise). func withDisplayName(content, name string) string { line := "# NAME=" + name if nameLineRe.MatchString(content) { return nameLineRe.ReplaceAllString(content, line) } return line + "\n" + content } // configFingerprint réduit une configuration à l'ENSEMBLE TRIÉ de ses // affectations effectives, puis le hache. Les clés « appareil » (preservedKeys, // réappliquées par SwitchToPreset) sont ignorées : sans ça, les MEM_MODE / // CRAWL4AI_URL injectés rendraient la config active différente de TOUS les // presets, et aucun ne serait jamais détecté comme actif. func configFingerprint(m map[string]string) string { pairs := make([]string, 0, len(m)) for k, v := range m { if isPreservedKey(k) { continue } pairs = append(pairs, k+"="+v) } sort.Strings(pairs) h := sha1.Sum([]byte(strings.Join(pairs, "\n"))) return hex.EncodeToString(h[:]) } // presetFingerprint est configFingerprint appliqué au contenu d'un preset. func presetFingerprint(content []byte) string { return configFingerprint(parseEnv(string(content))) } // isPreservedKey reports whether key is one of the device-level preservedKeys. func isPreservedKey(key string) bool { for _, k := range preservedKeys { if key == k { return true } } return false } // ListPresets renvoie tous les presets/*.env, en marquant celui qui correspond // à la configuration active (clés « appareil » ignorées), triés par nom. func ListPresets() ([]Preset, error) { dir := presetsDir() _ = os.MkdirAll(dir, 0o755) cur := configFingerprint(ReadConfig()) entries, err := os.ReadDir(dir) if err != nil { return nil, err } out := []Preset{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") { continue } p := filepath.Join(dir, e.Name()) b, err := os.ReadFile(p) if err != nil { continue } id := strings.TrimSuffix(e.Name(), ".env") out = append(out, Preset{ ID: id, Name: presetDisplayName(string(b), id), Path: p, Active: presetFingerprint(b) == cur, }) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } // uniquePresetID derives a unique filename id from a display name, appending // " (2)", " (3)"… on collision so duplicate names never overwrite. func uniquePresetID(name string) (string, error) { base := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(name)) if base == "" { base = "preset" } cand := base for n := 2; n < 10000; n++ { p, err := safePresetPath(cand) if err != nil { return "", err } if _, err := os.Stat(p); os.IsNotExist(err) { return cand, nil } cand = fmt.Sprintf("%s (%d)", base, n) } return "", fmt.Errorf("impossible de générer un nom de fichier unique") } // validPresetName accepts any name (spaces, accents, parentheses…) as long as it // stays a single safe filename: no path separators, no control chars, and not a // reserved directory entry. Path containment is double-checked in safePresetPath. func validPresetName(name string) error { if name == "" { return fmt.Errorf("nom vide") } if name == "." || name == ".." { return fmt.Errorf("nom réservé") } if strings.ContainsAny(name, `/\`+"\x00") { return fmt.Errorf(`le nom ne peut pas contenir / ni \`) } for _, r := range name { if r < 0x20 { return fmt.Errorf("le nom contient un caractère de contrôle invalide") } } return nil } // safePresetPath validates name and returns its resolved path inside presetsDir. func safePresetPath(name string) (string, error) { name = strings.TrimSpace(name) if err := validPresetName(name); err != nil { return "", err } root, err := filepath.Abs(presetsDir()) if err != nil { return "", err } p := filepath.Join(root, name+".env") abs, err := filepath.Abs(p) if err != nil { return "", err } if !strings.HasPrefix(abs, root+string(filepath.Separator)) { return "", fmt.Errorf("path invalide") } return abs, nil } // preservedKeys sont des réglages « appareil » (préférences utilisateur, pas des // paramètres de modèle) qui doivent survivre à un changement de preset. Sans ça, // écraser config.env avec le preset ré-imposerait le mode mémoire et effacerait // l'URL du serveur internet à chaque bascule — ce qui obligeait à tout remettre. // HOST y figure depuis la 0.8.4 : c'est un réglage de MACHINE (« le moteur // est-il joignable depuis le réseau ? », voir sys_network.go), pas un réglage de // modèle. Sans lui dans cette liste, basculer sur un preset écrit avant cette // version effaçait la clé, et le moteur repartait sur le défaut « toutes les // interfaces » : une machine volontairement fermée se rouvrait toute seule au // premier changement de preset. var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"} // softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit // pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un // choix de machine (`loki gpu`) qui doit survivre aux bascules, MAIS un preset // a le droit de l'imposer — « QWEN 3.6 27B FABLE 2 GPU » réclame `1,0` pour que // son `--tensor-split 0.965,0.035` ait bien deux cartes en face. Le préserver // inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le // modèle se retrouvait entièrement sur une seule carte et mourait sur // « cudaMalloc failed: out of memory ». Le preset explicite gagne. var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true} // applyPresetFile installe le preset comme configuration active, en réinjectant // les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être // testable sans redémarrer le service (donc sans lancer un vrai llama-server). func applyPresetFile(target string) error { src, err := os.ReadFile(target) if err != nil { return err } next := parseEnv(string(src)) // Ré-applique les réglages appareil par-dessus le preset — sauf ceux que le // preset revendique explicitement (softPreservedKeys). cur := ReadConfig() for _, k := range preservedKeys { v, ok := cur[k] if !ok { continue } if softPreservedKeys[k] { if _, claimed := next[k]; claimed { continue // le preset a son mot à dire sur cette clé : il gagne } } next[k] = v } // Le moteur suit la machine, pas le preset (voir presetImposesEngine). if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) { next["BIN"] = cur["BIN"] } return WriteConfig(next) } // presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ? // // Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du // moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque // encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de // modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et // un modèle qui chargeait la veille mourait sur « unknown model architecture » // sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur // téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un // preset : le courant reste. // // Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go : // « un backend custom se choisit par modèle »), lui, est un vrai choix par // modèle : le preset gagne. func presetImposesEngine(p string) bool { p = strings.TrimSpace(p) if p == "" || engineOwns(p) || prebuiltOwns(p) { return false } if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) { return false } return true } // SwitchToPreset installe le preset et redémarre le service. Les réglages // « appareil » (preservedKeys) sont conservés à travers la bascule. func SwitchToPreset(target string) error { if err := applyPresetFile(target); err != nil { 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") } func cmdSwitch(args []string) error { list, err := ListPresets() if err != nil { return err } if len(list) == 0 { return fmt.Errorf("aucun preset dans %s", presetsDir()) } fmt.Printf("\n %s (%s)\n\n", cyan("Presets disponibles"), presetsDir()) for i, p := range list { mark := " " if p.Active { mark = green("●") + " actif" } fmt.Printf(" %2d) %-30s %s\n", i+1, p.Name, mark) } fmt.Println() choice := "" if len(args) > 0 { choice = args[0] } else { fmt.Print("Numéro à activer (vide = annuler) : ") sc := bufio.NewScanner(os.Stdin) if sc.Scan() { choice = strings.TrimSpace(sc.Text()) } } if choice == "" { fmt.Println(dim("[info] annulé")) return nil } n, err := strconv.Atoi(choice) if err != nil || n < 1 || n > len(list) { return fmt.Errorf("choix invalide") } return SwitchToPreset(list[n-1].Path) } // newPresetSeedKeys : les SEULES clés reprises de la configuration active quand // on crée un preset. Ce sont des réglages de MACHINE (quel moteur, où il écoute), // pas des réglages de modèle. // // Issue #17 : le nouveau preset repartait d'une copie COMPLÈTE de la config // active. Les réglages du modèle précédent (EXTRA_ARGS — --n-cpu-moe, // --tensor-split, --flash-attn… — mais aussi CTX, NGL, KV_TYPE, MODEL) se // mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à // tout nettoyer à la main. On repart d'une base vide : les valeurs non // renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…). // // BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) : // figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le // moteur mis à jour à la première bascule. var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"} // newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI. func newPresetSeed() map[string]string { cur := ReadConfig() seed := map[string]string{} for _, k := range newPresetSeedKeys { v := cur[k] if v == "" || (k == "BIN" && !presetImposesEngine(v)) { continue } seed[k] = v } return seed } // SavePreset writes a preset. When id == "" it creates a NEW preset under a // freshly-generated unique filename (so duplicate display names never clash). // When id != "" it updates that existing preset in place (filename unchanged — // only the body and its `# NAME=` line change). Returns the resulting id. func SavePreset(id, name, content string) (string, error) { name = strings.TrimSpace(name) if name == "" { return "", fmt.Errorf("nom requis") } content = withDisplayName(content, name) if id == "" { newID, err := uniquePresetID(name) if err != nil { return "", err } p, err := safePresetPath(newID) if err != nil { return "", err } _ = os.MkdirAll(filepath.Dir(p), 0o755) return newID, os.WriteFile(p, []byte(content), 0o644) } p, err := safePresetPath(id) if err != nil { return "", err } _ = os.MkdirAll(filepath.Dir(p), 0o755) return id, os.WriteFile(p, []byte(content), 0o644) } // SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE, // installe aussitôt la nouvelle version comme configuration active (mêmes // règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas — // l'appelant redémarre alors le moteur. // // Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du // chat qui affiche toujours 32768. Seul le fichier du preset avait changé ; // config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun // preset n'était détecté actif (empreinte différente). Éditer le preset en // service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou // nouveau, lui, n'est qu'un fichier : rien ne bouge. // // « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui // porte l'empreinte de la configuration courante. func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) { wasActive := false if id != "" { if old, rerr := ReadPreset(id); rerr == nil { wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig()) } } newID, err = SavePreset(id, name, content) if err != nil || !wasActive { return newID, false, err } p, err := safePresetPath(newID) if err != nil { return newID, false, err } if err := applyPresetFile(p); err != nil { return newID, false, err } return newID, true, nil } // DeletePreset removes a preset by id; refuses if it is the active config. func DeletePreset(id string) error { p, err := safePresetPath(id) if err != nil { return err } target, err := os.ReadFile(p) if err != nil { return fmt.Errorf("introuvable") } if presetFingerprint(target) == configFingerprint(ReadConfig()) { return fmt.Errorf("preset actif, switche d'abord") } if err := os.Remove(p); err != nil { return err } // Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) : // sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé // sous le même id. _ = putStr(bkState, sysPromptKeyPrefix+id, "") return nil } // ReadPreset returns the contents of a preset by id (filename). func ReadPreset(id string) (string, error) { p, err := safePresetPath(id) if err != nil { return "", err } b, err := os.ReadFile(p) if err != nil { return "", err } return string(b), nil }