Files
Loki/internal/loki/backend_presets.go
T
Claude b5904907e8 Reprise réseau, presets externes, see_image
Trois apports repérés chez AJEAN (v0.13.8) et OpenFox (2.0.118), portés et
adaptés à Loki.

## Reprise réseau du tour (llm_retry_net.go)

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 le disaient eux-mêmes —
« réessaie dans quelques secondes » — autrement dit on demandait à
l'utilisateur de refaire à la main ce que le code pouvait faire seul. Une
tâche planifiée tombée pendant un redémarrage du moteur échouait pour de
bon, sans personne pour recliquer.

Trois reprises consécutives, 0,8 → 1,6 → 3,2 s, plafonnées, interruptibles
par un /stop. La règle de sûreté ne souffre pas d'exception : on ne rejoue
que TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ, sinon la moitié de la réponse
déjà chez l'utilisateur serait dupliquée. Le compteur repart à zéro dès
qu'une réponse arrive.

500 n'est pas un statut de reprise : c'est ce que llama.cpp rend pour un
appel d'outil malformé ou un prompt trop long, deux échecs déterministes
que les filets sémantiques traitent déjà. Restent les codes qui disent
« pas maintenant » : 429, 502, 503, 504.

## Presets externes (backend_external.go, web_external.go)

Un preset avec EXTERNAL=1 route le chat vers une API OpenAI-compatible
distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre machine) au lieu
du llama-server local. C'est un preset COMME UN AUTRE : même liste, même
bascule, même prompt système par preset. La différence ne vit qu'à deux
endroits — l'inférence (resolveChatEndpoint) et la bascule, qui arrête le
moteur local au lieu de le redémarrer.

La clé du serveur local ne part jamais chez un tiers : chaque endpoint
porte la sienne. La clé du preset n'est jamais renvoyée en clair à
l'interface, et un champ vide ne l'efface pas — il faut y avoir touché.
Une fenêtre dédiée plutôt que l'éditeur habituel : un modèle distant n'a
ni quantification, ni couches GPU, ni moteur. Un bouton teste la connexion
avant d'enregistrer, et rend le message de l'API plutôt que le JSON brut.

Le résumé de compactage part au même endroit que le chat : le laisser
taper le moteur local aurait cassé toute compaction sur un preset externe.

## see_image (chat_vision_tool.go)

Loki savait voir une pièce jointe et une capture qu'il venait de prendre,
mais pas un fichier qui dort sur le disque : « regarde ~/photos/bug.png »
n'avait aucune réponse, `read` rendant des octets binaires. L'outil charge
l'image et la réinjecte dans un message utilisateur multimodal — même
chemin que les pièces jointes.

Même règle ÉPHÉMÈRE que les captures (et non celle de l'amont, qui persiste
l'image) : l'image va dans le tour en cours, pas dans l'historique. Un
base64 persisté repartirait à chaque tour et finirait par dépasser la
fenêtre pour de bon.

Le marqueur de perte à la compaction existait déjà mais n'offrait qu'un
recours, « reprends la capture » — ce qui enverrait photographier une page
web alors que l'image perdue est un PNG du disque. Formulation généralisée.

## Au passage

toolCallLabel est extrait de runChat. Cette table nom d'outil → argument a
une double fonction — libellé affiché ET argument principal — donc un outil
absent s'exécute sur une chaîne vide : see_image répondait « chemin de
fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne bronche.
Une table pareille se teste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 21:05:19 +00:00

450 lines
15 KiB
Go

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=<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
}