v0.8.1 : le moteur dit enfin pourquoi il ne demarre pas, et les presets repartent propres

- start/restart : preflight (BIN, MODEL, .gguf) et fin du faux « [ok] activating »
  sur un service qui redemarre en boucle (SubState auto-restart).
- MODEL en chemin absolu accepte hors des dossiers declares (lecture seule) ;
  l'allowlist reste sur la suppression de fichiers.
- ajean edit : squelette commente des cles au lieu d'un fichier quasi vide.
- install : les prochaines etapes n'oublient plus « ajean llamacpp install ».
- Presets : un nouveau preset ne recopie plus la config active (issue #17),
  seulement BIN/HOST/PORT.
- Presets : plus de guillemet interne mange a la relecture (Go et interface),
  et EXTRA_ARGS decoupe en respectant les guillemets.
This commit is contained in:
nathaninline committed 2026-08-08 09:16:16 +02:00
1 parent 68d6d2032a
commit db47c72240
17 files changed
+439 -102

No files matched your search

+71 -3
View File
@@ -99,8 +99,24 @@ func SetConfigKey(key, value string) error { return putStr(bkConfig, key, value)
// qu'exige l'application d'un preset : jamais un état mi-ancien mi-nouveau.
func WriteConfig(m map[string]string) error { return replaceKV(bkConfig, m) }
// unquoteValue retire les guillemets ENTOURANTS d'une valeur, et seulement
// eux : une paire ouvrante/fermante du même caractère.
//
// Un simple Trim(v, `"`) mangeait le guillemet d'un argument interne — par
// exemple EXTRA_ARGS=--chat-template-file "/etc/ajean/tpl.jinja" perdait son
// guillemet final et repartait déséquilibré dans le preset (issue #17 : contenu
// tronqué / guillemets non appariés à la création d'un preset).
func unquoteValue(v string) string {
if len(v) >= 2 {
if q := v[0]; (q == '"' || q == '\'') && v[len(v)-1] == q {
return v[1 : len(v)-1]
}
}
return v
}
// parseEnv lit un fichier au format clé=valeur (les presets). Les lignes vides
// et les commentaires sont ignorés, les guillemets retirés.
// et les commentaires sont ignorés, les guillemets entourants retirés.
func parseEnv(text string) map[string]string {
m := map[string]string{}
for _, line := range strings.Split(text, "\n") {
@@ -112,11 +128,25 @@ func parseEnv(text string) map[string]string {
if !ok {
continue
}
m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"")
m[strings.TrimSpace(k)] = unquoteValue(strings.TrimSpace(v))
}
return m
}
// quoteValue rend une valeur telle que parseEnv la relise à l'identique. On
// n'entoure de guillemets que ce qui en a besoin (espaces), et jamais une valeur
// qui contient déjà un guillemet : elle est écrite telle quelle, unquoteValue ne
// touchant qu'à une paire entourante.
func quoteValue(v string) string {
if v == "" || strings.ContainsRune(v, '"') {
return v
}
if strings.ContainsAny(v, " \t") || unquoteValue(v) != v {
return `"` + v + `"`
}
return v
}
// formatEnv rend une configuration au format des presets, clés triées pour que
// deux écritures du même contenu donnent le même fichier.
func formatEnv(m map[string]string) string {
@@ -127,11 +157,49 @@ func formatEnv(m map[string]string) string {
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
fmt.Fprintf(&b, "%s=%s\n", k, m[k])
fmt.Fprintf(&b, "%s=%s\n", k, quoteValue(m[k]))
}
return b.String()
}
// splitArgs découpe EXTRA_ARGS comme le ferait un shell : sur les espaces, mais
// en respectant les guillemets, pour qu'un chemin qui en contient reste UN seul
// argument (--chat-template-file "/mes modèles/tpl.jinja"). trimSplit sur " "
// le coupait en deux et llama-server refusait de démarrer.
func splitArgs(s string) []string {
out := []string{}
var cur strings.Builder
var quote rune
flush := func() {
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
}
for _, r := range s {
switch {
case quote != 0:
if r == quote {
quote = 0
// Guillemets vides ("" ) : on garde l'argument vide explicite.
if cur.Len() == 0 {
out = append(out, "")
}
} else {
cur.WriteRune(r)
}
case r == '"' || r == '\'':
quote = r
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
flush()
default:
cur.WriteRune(r)
}
}
flush()
return out
}
// Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été
// retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
// seul frein.
@@ -0,0 +1,77 @@
package ajean
import (
"reflect"
"testing"
)
// Un guillemet INTERNE (argument cité dans EXTRA_ARGS) ne doit pas être mangé :
// l'ancien Trim retirait le guillemet final et renvoyait une valeur déséquilibrée
// dans le preset (issue #17).
func TestParseEnvKeepsInnerQuotes(t *testing.T) {
cases := map[string]string{
`EXTRA_ARGS=--jinja --chat-template-file "/etc/ajean/tpl.jinja"`: `--jinja --chat-template-file "/etc/ajean/tpl.jinja"`,
`EXTRA_ARGS="--jinja --flash-attn"`: `--jinja --flash-attn`,
`MODEL='/mnt/d/x.gguf'`: `/mnt/d/x.gguf`,
`MODEL=/mnt/d/x.gguf`: `/mnt/d/x.gguf`,
}
for line, want := range cases {
m := parseEnv(line)
var got string
for _, v := range m {
got = v
}
if got != want {
t.Errorf("parseEnv(%q) = %q, veut %q", line, got, want)
}
}
}
// Écrire puis relire doit rendre exactement la même configuration.
func TestFormatEnvRoundTrip(t *testing.T) {
in := map[string]string{
"MODEL": "/mnt/mes modèles/x.gguf",
"EXTRA_ARGS": `--jinja --chat-template-file "/etc/ajean/tpl.jinja"`,
"CTX": "32768",
}
out := parseEnv(formatEnv(in))
if !reflect.DeepEqual(in, out) {
t.Fatalf("aller-retour cassé :\n%v\n%v", in, out)
}
}
func TestSplitArgs(t *testing.T) {
got := splitArgs(`--jinja --chat-template-file "/etc/mes modèles/tpl.jinja" --flash-attn`)
want := []string{"--jinja", "--chat-template-file", "/etc/mes modèles/tpl.jinja", "--flash-attn"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("splitArgs = %q, veut %q", got, want)
}
if len(splitArgs("")) != 0 {
t.Fatal("EXTRA_ARGS vide doit donner zéro argument")
}
}
// L'éditeur doit proposer les clés utiles même quand la configuration est vide,
// et ne jamais perdre une clé déjà définie.
func TestConfigEditorText(t *testing.T) {
txt := configEditorText(map[string]string{"MODEL": "x.gguf", "INCONNUE": "1"})
round := parseEnv(txt)
if round["MODEL"] != "x.gguf" || round["INCONNUE"] != "1" {
t.Fatalf("clés perdues : %v", round)
}
if len(round) != 2 {
t.Fatalf("les clés non renseignées doivent rester commentées : %v", round)
}
if !contains(txt, "#BIN=") {
t.Fatal("le squelette doit documenter BIN")
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+23
View File
@@ -101,6 +101,29 @@ func baseName(p string) string {
return p
}
// resolveServeModelPath résout MODEL= pour LANCER le moteur. Différence avec
// resolveModelPath : un chemin absolu qui pointe sur un .gguf existant est
// accepté même hors des dossiers déclarés. L'allowlist protège des opérations
// destructrices pilotées depuis l'UI (suppression d'un fichier) ; l'appliquer à
// « ouvrir ce modèle en lecture » rendait la config éditée à la main
// inutilisable : MODEL=/home/moi/models/x.gguf renvoyait « dossier non
// autorisé », le moteur mourait en boucle, et le seul remède connu était
// d'aller déclarer le dossier dans l'interface web.
func resolveServeModelPath(name string) (string, error) {
p, err := resolveModelPath(name)
if err == nil {
return p, nil
}
s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`))
if filepath.IsAbs(s) && strings.HasSuffix(strings.ToLower(s), ".gguf") {
abs := filepath.Clean(s)
if st, e := os.Stat(abs); e == nil && !st.IsDir() {
return abs, nil
}
}
return "", err
}
// resolveModelPath transforme une valeur MODEL= (nom de fichier OU chemin
// absolu) en chemin absolu vers un .gguf. Un chemin absolu n'est accepté que
// s'il est dans un des dossiers déclarés — sinon l'UI web deviendrait un moyen
+3 -3
View File
@@ -53,7 +53,7 @@ func presetReasoning(content string) string {
continue
}
if strings.EqualFold(strings.TrimSpace(s[:i]), "REASONING") {
return strings.Trim(strings.TrimSpace(s[i+1:]), `"`)
return unquoteValue(strings.TrimSpace(s[i+1:]))
}
}
return ""
@@ -78,7 +78,7 @@ func detectQuant(content string) string {
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
i := strings.IndexByte(s, '=')
if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "QUANT") {
if v := strings.Trim(strings.TrimSpace(s[i+1:]), `"`); v != "" {
if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" {
return strings.ToUpper(v)
}
}
@@ -117,7 +117,7 @@ func modelFromPresetContent(content string) string {
continue
}
if strings.TrimSpace(s[:i]) == "MODEL" {
return strings.Trim(strings.TrimSpace(s[i+1:]), "\"")
return unquoteValue(strings.TrimSpace(s[i+1:]))
}
}
return ""
+25 -1
View File
@@ -33,7 +33,7 @@ func presetDisplayName(content, fallback string) string {
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
i := strings.IndexByte(s, '=')
if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "NAME") {
if v := strings.Trim(strings.TrimSpace(s[i+1:]), `"`); v != "" {
if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" {
return v
}
}
@@ -271,6 +271,30 @@ func cmdSwitch(args []string) error {
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 —
+4 -3
View File
@@ -25,7 +25,7 @@ func cmdServe(args []string) error {
// juste ; lancé depuis une app de bureau, le répertoire courant est « / » et
// llama-server ne trouvait rien. On résout donc explicitement, quel que soit
// le contexte de lancement.
resolved, err := resolveModelPath(model)
resolved, err := resolveServeModelPath(model)
if err != nil {
return err
}
@@ -99,8 +99,9 @@ func cmdServe(args []string) error {
} else if k := cfg["API_KEY"]; k != "" {
llmArgs = append(llmArgs, "--api-key", k)
}
// EXTRA_ARGS is appended verbatim, split on whitespace like the shell would.
llmArgs = append(llmArgs, trimSplit(cfg["EXTRA_ARGS"], " ")...)
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
// together so a path with spaces stays one argument.
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
// Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
+8 -2
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.8.0"
const Version = "0.8.1"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
@@ -42,7 +42,13 @@ func Main() {
switch cmd {
case "app":
mustExit(cmdApp(args))
case "start", "stop", "restart", "status", "enable", "disable":
case "start", "restart":
// Un moteur sans BIN ni MODEL démarre, meurt, et systemd le relance en
// boucle : l'utilisateur ne voyait qu'un « activating » rassurant et un
// /health muet. On le dit AVANT de lancer le service.
mustExit(preflightEngine())
mustExit(serviceAction(cmd))
case "stop", "status", "enable", "disable":
mustExit(serviceAction(cmd))
case "logs":
mustExit(serviceLogs())
+5 -3
View File
@@ -87,9 +87,11 @@ func cmdInstall(args []string) error {
fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]"))
fmt.Printf("\nProchaines étapes :\n")
fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("sudo -u "+targetUser+" ajean edit"))
fmt.Printf(" 2. démarre le moteur : %s\n", bold("sudo ajean start"))
fmt.Printf(" 3. démarre l'interface: %s\n", bold("sudo ajean ui start"))
fmt.Printf(" 1. installe le moteur : %s (compile llama.cpp, renseigne BIN)\n", bold("sudo -u "+targetUser+" ajean llamacpp install"))
fmt.Printf(" 2. choisis le modèle : %s (renseigne MODEL, un fichier .gguf)\n", bold("sudo -u "+targetUser+" ajean edit"))
fmt.Printf(" 3. démarre le moteur : %s\n", bold("sudo ajean start"))
fmt.Printf(" 4. vérifie : %s\n", bold("ajean test"))
fmt.Printf(" 5. démarre l'interface : %s (http://localhost:8090)\n", bold("sudo ajean ui start"))
return nil
}
+90 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
@@ -16,6 +17,94 @@ import (
//
// editConfig and showVram live here because they work the same everywhere.
// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de
// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait
// toutes les 3 s, et `ajean start` affichait un « activating » rassurant pendant
// que `ajean test` répondait « /health ne répond pas ». Le diagnostic n'était
// visible que dans le journal.
func preflightEngine() error {
cfg := ReadConfig()
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
bold("ajean llamacpp install"), bold("ajean edit"))
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(AjeanHome(), bin)
}
if _, err := os.Stat(prebuiltResolveBin(bin)); err != nil {
return fmt.Errorf("moteur introuvable : %s — relance %s", bin, bold("ajean llamacpp install"))
}
model := strings.TrimSpace(cfg["MODEL"])
if model == "" {
return fmt.Errorf("MODEL non défini — indique un .gguf avec %s (dossier des modèles : %s)",
bold("ajean edit"), modelsDir())
}
p, err := resolveServeModelPath(model)
if err != nil {
return fmt.Errorf("MODEL=%s : %w", model, err)
}
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("ajean edit"))
}
return nil
}
// configTemplate est le squelette commenté proposé par `ajean edit` : la
// configuration vit en base, donc sur une installation neuve le fichier
// temporaire était QUASI VIDE — impossible de deviner quoi écrire. On y déroule
// donc les clés utiles avec leur rôle, les valeurs déjà définies telles quelles,
// les autres commentées.
var configTemplate = []struct{ key, help string }{
{"BIN", "chemin de llama-server (posé par « ajean llamacpp install »)"},
{"MODEL", "nom de fichier .gguf ou chemin complet"},
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
{"PORT", "port du moteur (défaut 8080)"},
{"CTX", "taille du contexte (défaut 32768)"},
{"NGL", "couches déportées sur le GPU (défaut 999 = tout)"},
{"BATCH", "batch (défaut 2048)"},
{"UBATCH", "micro-batch (défaut 512)"},
{"THREADS", "threads CPU, 0 = auto"},
{"THREADS_BATCH", "threads CPU du prefill, 0 = auto"},
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
}
// configEditorText rend la configuration au format présenté dans $EDITOR.
func configEditorText(cfg map[string]string) string {
var b strings.Builder
b.WriteString("# Configuration du moteur AJEAN (ajean-engine).\n")
b.WriteString("# Une clé par ligne : CLE=valeur. Les lignes commentées (#) sont ignorées :\n")
b.WriteString("# décommente celles dont tu as besoin. « ajean restart » applique.\n\n")
seen := map[string]bool{}
for _, f := range configTemplate {
seen[f.key] = true
fmt.Fprintf(&b, "# %s\n", f.help)
if v, ok := cfg[f.key]; ok && v != "" {
fmt.Fprintf(&b, "%s=%s\n\n", f.key, quoteValue(v))
} else {
fmt.Fprintf(&b, "#%s=\n\n", f.key)
}
}
// Tout ce que le squelette ne connaît pas (clés d'une version plus récente,
// réglages posés par l'UI) : conservé tel quel, en fin de fichier.
rest := map[string]string{}
for k, v := range cfg {
if !seen[k] {
rest[k] = v
}
}
if len(rest) > 0 {
b.WriteString("# --- autres clés déjà définies ---\n")
b.WriteString(formatEnv(rest))
}
return b.String()
}
// editConfig ouvre la configuration dans $EDITOR. La configuration vit en base
// (voir store.go) : on la déroule dans un fichier temporaire au format clé=valeur,
// on laisse l'éditeur faire son travail, puis on relit. Le contenu n'est réécrit
@@ -31,7 +120,7 @@ func editConfig() error {
}
path := tmp.Name()
defer os.Remove(path)
if _, err := tmp.WriteString(formatEnv(ReadConfig())); err != nil {
if _, err := tmp.WriteString(configEditorText(ReadConfig())); err != nil {
tmp.Close()
return err
}
+50 -7
View File
@@ -42,20 +42,63 @@ func serviceAction(action string) error {
return nil
}
// unitState renvoie ActiveState et SubState de l'unité (« activating » /
// « auto-restart » par exemple).
func unitState(svc string) (active, sub string) {
out, _ := exec.Command("systemctl", "show", svc, "-p", "ActiveState", "-p", "SubState").Output()
for _, line := range strings.Split(string(out), "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch k {
case "ActiveState":
active = v
case "SubState":
sub = v
}
}
return
}
// checkStarted attend que l'unité se stabilise, puis dit la vérité.
//
// ⚠️ « activating » n'est PAS un succès : avec Restart=on-failure, un moteur qui
// meurt au démarrage repasse en boucle par activating (SubState auto-restart).
// L'ancien test l'acceptait, et `ajean start` répondait « [ok] activating » sur
// un service qui ne démarrerait jamais — d'où des rapports « ajean start dit ok
// mais ajean test ne répond pas ». On distingue donc le SubState, et on laisse
// au moteur le temps de charger un gros modèle (il reste en activating, mais
// PAS en auto-restart).
func checkStarted(svc string) error {
time.Sleep(2 * time.Second)
out, _ := exec.Command("systemctl", "is-active", svc).Output()
state := strings.TrimSpace(string(out))
if state == "active" || state == "activating" {
fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state)
var state, sub string
for i := 0; i < 5; i++ {
time.Sleep(2 * time.Second)
state, sub = unitState(svc)
if state == "active" {
fmt.Printf("%s %s: actif\n", green("[ok]"), svc)
return nil
}
if state == "failed" || sub == "auto-restart" {
break // inutile d'attendre : il boucle sur un échec
}
}
if state == "activating" && sub != "auto-restart" {
// Chargement en cours (un gros .gguf prend des minutes) : légitime.
fmt.Printf("%s %s: démarrage en cours (chargement du modèle) — %s pour suivre\n",
green("[ok]"), svc, bold("ajean logs"))
return nil
}
fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state)
what := state
if sub == "auto-restart" {
what = "redémarre en boucle (le moteur meurt au lancement)"
}
fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, what)
fmt.Println("------------------------------------------------")
logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output()
fmt.Print(string(logs))
fmt.Println("------------------------------------------------")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger la configuration (BIN, MODEL…)\n")
return fmt.Errorf("service %s non démarré", svc)
}
+20 -10
View File
@@ -2663,11 +2663,24 @@ function fmtSize(b){
if(b > 1e3) return (b/1e3).toFixed(0)+' KB';
return b+' B';
}
// Retire les guillemets ENTOURANTS d'une valeur, et seulement eux. Les anciens
// motifs `"?([^"\n]*)"?` s'arrêtaient au premier guillemet INTERNE : une valeur
// comme `--chat-template-file "/etc/ajean/tpl.jinja"` était lue tronquée, puis
// réécrite avec des guillemets non appariés (issue #17).
function unquoteVal(v){
v = String(v==null?'':v).trim();
const q = v[0];
if(v.length >= 2 && (q === '"' || q === "'") && v[v.length-1] === q) return v.slice(1,-1);
return v;
}
// Lit la valeur d'une clé KEY= dans un texte au format .env.
function readEnvKey(txt, key){
const m = String(txt).match(new RegExp('^[ \\t]*'+key+'[ \\t]*=(.*)$','m'));
return m ? unquoteVal(m[1]) : '';
}
// Read the current MODEL= value out of the textarea, handling quotes / spaces.
function currentModelInTextarea(){
const txt = document.getElementById('m-content').value;
const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m);
return m ? m[1].trim() : '';
return readEnvKey(document.getElementById('m-content').value, 'MODEL');
}
// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers :
// on ne contrôle pas ce que l'utilisateur y met).
@@ -2791,9 +2804,7 @@ function onPickModel(){
// qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel
// backend fait tourner ce modèle — la barre latérale ne fait qu'installer.
function currentBinInTextarea(){
const txt = document.getElementById('m-content').value;
const m = txt.match(/^\s*BIN\s*=\s*"?([^"\n]*)"?\s*$/m);
return m ? m[1].trim() : '';
return readEnvKey(document.getElementById('m-content').value, 'BIN');
}
// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows).
function sameBinPath(a, b){
@@ -3079,8 +3090,8 @@ function onGpuPick(){
// Read/write the QUANT= override line in the preset textarea.
function currentQuantInTextarea(){
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=\s*"?([^"\n]*)"?\s*$/mi);
return m ? m[1].trim() : '';
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=(.*)$/mi);
return m ? unquoteVal(m[1]) : '';
}
function applyQuant(){
const val = document.getElementById('m-quant').value.trim();
@@ -3097,8 +3108,7 @@ function applyQuant(){
// Le fichier de config (textarea m-content) reste la source de vérité : chaque
// champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT.
function cfgReadKey(key){
const m = document.getElementById('m-content').value.match(new RegExp('^[ \\t]*'+key+'[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$','m'));
return m ? m[1].trim() : '';
return readEnvKey(document.getElementById('m-content').value, key);
}
function cfgWriteKey(key, val){
const ta = document.getElementById('m-content');
+20 -10
View File
@@ -171,11 +171,24 @@ function fmtSize(b){
if(b > 1e3) return (b/1e3).toFixed(0)+' KB';
return b+' B';
}
// Retire les guillemets ENTOURANTS d'une valeur, et seulement eux. Les anciens
// motifs `"?([^"\n]*)"?` s'arrêtaient au premier guillemet INTERNE : une valeur
// comme `--chat-template-file "/etc/ajean/tpl.jinja"` était lue tronquée, puis
// réécrite avec des guillemets non appariés (issue #17).
function unquoteVal(v){
v = String(v==null?'':v).trim();
const q = v[0];
if(v.length >= 2 && (q === '"' || q === "'") && v[v.length-1] === q) return v.slice(1,-1);
return v;
}
// Lit la valeur d'une clé KEY= dans un texte au format .env.
function readEnvKey(txt, key){
const m = String(txt).match(new RegExp('^[ \\t]*'+key+'[ \\t]*=(.*)$','m'));
return m ? unquoteVal(m[1]) : '';
}
// Read the current MODEL= value out of the textarea, handling quotes / spaces.
function currentModelInTextarea(){
const txt = document.getElementById('m-content').value;
const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m);
return m ? m[1].trim() : '';
return readEnvKey(document.getElementById('m-content').value, 'MODEL');
}
// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers :
// on ne contrôle pas ce que l'utilisateur y met).
@@ -299,9 +312,7 @@ function onPickModel(){
// qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel
// backend fait tourner ce modèle — la barre latérale ne fait qu'installer.
function currentBinInTextarea(){
const txt = document.getElementById('m-content').value;
const m = txt.match(/^\s*BIN\s*=\s*"?([^"\n]*)"?\s*$/m);
return m ? m[1].trim() : '';
return readEnvKey(document.getElementById('m-content').value, 'BIN');
}
// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows).
function sameBinPath(a, b){
@@ -587,8 +598,8 @@ function onGpuPick(){
// Read/write the QUANT= override line in the preset textarea.
function currentQuantInTextarea(){
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=\s*"?([^"\n]*)"?\s*$/mi);
return m ? m[1].trim() : '';
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=(.*)$/mi);
return m ? unquoteVal(m[1]) : '';
}
function applyQuant(){
const val = document.getElementById('m-quant').value.trim();
@@ -605,8 +616,7 @@ function applyQuant(){
// Le fichier de config (textarea m-content) reste la source de vérité : chaque
// champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT.
function cfgReadKey(key){
const m = document.getElementById('m-content').value.match(new RegExp('^[ \\t]*'+key+'[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$','m'));
return m ? m[1].trim() : '';
return readEnvKey(document.getElementById('m-content').value, key);
}
function cfgWriteKey(key, val){
const ta = document.getElementById('m-content');
+1 -3
View File
@@ -337,9 +337,7 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
func handlePreset(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
// nouveau preset → pré-rempli avec la configuration active, pour ajuster
// plutôt que partir d'une page blanche
sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(ReadConfig())})
sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(newPresetSeed())})
return
}
content, err := ReadPreset(id)