mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 14:08:11 +02:00
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:
1 parent
68d6d2032a
commit
db47c72240
17 files changed
+439
-102
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user