mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 12:16:21 +02:00
Trois retours d'utilisateurs apres la 0.8.3. Export de la conversation (regression de la 0.8 : conversation.json a disparu dans ajean.db, verrouille et binaire). Bouton dans l'UI + commande 'ajean export [--json] [fichier]', endpoint /api/chat/export. Ecoute reseau du moteur : interrupteur 'joignable depuis le reseau local' dans le panneau Acces OpenAI (HOST + regle de pare-feu Windows), commande 'ajean network [on|off|status]'. HOST devient un reglage de machine preserve au changement de preset. Modeles GGUF decoupes en tranches -00001-of-000NN : une famille = un modele. Telechargement de toutes les tranches depuis n'importe quel lien, sonde d'espace disque sur le total, liste et suppression regroupees, demarrage refuse avec le nom du fichier manquant.
362 lines
11 KiB
Go
362 lines
11 KiB
Go
package ajean
|
|
|
|
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 (`ajean 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
|
|
}
|
|
return WriteConfig(next)
|
|
}
|
|
|
|
// 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))
|
|
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…).
|
|
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 {
|
|
if v := cur[k]; v != "" {
|
|
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)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
return os.Remove(p)
|
|
}
|
|
|
|
// 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
|
|
}
|