diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d3209a1..32f37f6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,63 +1,49 @@ -Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base. +Une version de corrections, née de vos retours sur la 0.8.0 : un moteur qui refusait de démarrer sans jamais le dire, et des presets qui se mélangeaient. -## Mise à jour : il faut réinstaller +## « ajean start » ne ment plus -**Ne passez pas par le bouton « mettre à jour » de la 0.7.** Il ne trouvera d'ailleurs rien : les binaires de cette version portent de nouveaux noms, que la 0.7 ne sait pas chercher. C'est délibéré. La laisser installer ce binaire aurait remplacé l'exécutable sans migrer ni les données ni les unités : le service de lien serait reparti en boucle d'échec sur une sous-commande disparue, et le moteur n'aurait plus trouvé sa configuration. Une machine à réparer en SSH après un clic dans un navigateur. +Le cas le plus pénible remonté cette semaine : `ajean start` répondait `[ok] ajean-engine: activating`, puis `ajean test` répondait « /health ne répond pas ». Rien dans les deux messages ne permettait de comprendre. -La marche à suivre, sur une machine déjà installée : +L'explication tenait à une nuance de systemd. Un service qui meurt au lancement est relancé toutes les trois secondes, et pendant ces trois secondes il est en `activating`, exactement comme un moteur en train de charger un modèle. AJEAN prenait donc un échec en boucle pour un démarrage en cours. Il distingue désormais les deux : un vrai chargement reste annoncé comme tel (un gros .gguf prend des minutes, c'est normal), une boucle d'échec affiche l'erreur, les vingt dernières lignes du journal et quoi corriger. -```bash -curl -L -o ajean https://github.com/nathaninline/ajean/releases/latest/download/ajean-linux -chmod +x ajean && sudo mv ajean /usr/local/bin/ajean -sudo ajean install +Mieux : `ajean start` et `ajean restart` vérifient d'abord ce sans quoi le moteur ne peut pas démarrer, et refusent de lancer un service condamné. Moteur absent, modèle non renseigné, fichier .gguf introuvable : c'est dit tout de suite, avec la commande qui répare. + +## Un modèle peut vivre où vous voulez + +`MODEL=/home/moi/modeles/mon-modele.gguf` était refusé si le dossier n'avait pas été déclaré au préalable dans l'interface web. Le moteur mourait alors en boucle sur un « dossier non autorisé » que personne ne voyait passer, et le seul remède connu consistait à ouvrir l'interface pour y ajouter le dossier. Un utilisateur l'a trouvé tout seul, à tâtons ; ce n'était pas une découverte à lui imposer. + +Ce garde-fou existe pour empêcher l'interface web de supprimer un fichier n'importe où sur la machine. Il n'avait aucune raison de s'appliquer à « ouvrir ce modèle en lecture ». Un chemin absolu vers un .gguf qui existe est maintenant accepté tel quel, quel que soit le disque. La protection reste entière là où elle sert. + +## La configuration s'explique enfin + +Depuis que la configuration vit en base, `ajean edit` déroulait dans votre éditeur les seules clés déjà définies. Sur une installation neuve, cela donnait un fichier quasiment vide : impossible de deviner quoi écrire. + +L'éditeur propose désormais un squelette commenté. Chaque réglage utile y figure avec son rôle et son défaut (BIN, MODEL, CTX, NGL, batch, threads, cache KV, raisonnement, mémoire, EXTRA_ARGS). Les valeurs que vous avez déjà réglées sont en clair, les autres restent commentées, donc inactives, et toute clé que le squelette ne connaît pas est conservée en fin de fichier. + +Les étapes affichées à la fin de `ajean install` ont été corrigées dans la foulée : elles oubliaient `ajean llamacpp install`, sans quoi la clé BIN reste vide et le moteur ne peut pas démarrer. + +## Presets : un nouveau preset repart propre (issue #17) + +Créer un preset avec le bouton « + » recopiait la configuration active en entier. Les réglages du modèle précédent (les experts déportés sur le CPU, la répartition entre cartes, le contexte, jusqu'au modèle lui-même) se mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à tout nettoyer à la main. + +Un nouveau preset ne reprend plus que ce qui relève de la machine : le moteur, l'adresse et le port. Tout le reste part des valeurs par défaut, que vous ajustez pour ce modèle-là. + +## Presets : les guillemets ne sont plus mangés + +Le rapport parlait de contenu tronqué et de guillemets non appariés, et c'était exact, avec une cause plus profonde qu'il n'y paraissait. En relisant une ligne de configuration, AJEAN retirait tout guillemet en début et en fin de valeur, sans vérifier qu'il s'agissait d'une paire. Une valeur comme : + +``` +EXTRA_ARGS=--jinja --chat-template-file "/etc/ajean/gabarit.jinja" ``` -`install` fait la reprise complète : il arrête et désactive les anciens services, déplace `configs/` vers `presets/`, `MEMORY/` vers `memory/` et les `.gguf` vers `models/`, reprend en base la configuration, les préférences, la conversation, les clés, le jeton de liaison, les interrupteurs, les serveurs MCP et les benchmarks, puis installe les deux nouvelles unités. +ressortait donc amputée de son guillemet final, déséquilibrée, et repartait ainsi dans le preset enregistré. Le défaut existait des deux côtés, dans le moteur comme dans l'interface. Seule une paire entourante est maintenant retirée, et l'écriture est faite pour être relue à l'identique. Un test verrouille l'aller-retour. -Rien n'est supprimé. Les presets, la mémoire et les modèles sont déplacés, jamais copiés ni effacés. Les anciens fichiers d'état sont rangés dans `avant-0.8/`, que vous pourrez supprimer quand tout ira bien. La clé du chiffrement de bout en bout n'est pas touchée, donc l'empreinte confirmée dans le portail reste valable. +Dans la même zone : EXTRA_ARGS était découpé sur les espaces sans tenir compte des guillemets, si bien qu'un chemin contenant une espace devenait deux arguments et que llama-server refusait de démarrer. Le découpage respecte désormais les guillemets, comme le ferait un shell. -## Une base à la place des fichiers d'état +## Mise à jour -`config.env`, `webprefs.json`, `conversation.json`, `model_dirs.json`, `mcp.json`, `.api_key`, `.web_key`, `.link_token`, `.agent_enabled`, `.internet_enabled` et le reste ont disparu. Tout cela vit maintenant dans **`ajean.db`**, un fichier unique en [bbolt](https://github.com/etcd-io/bbolt), pur Go, transactionnel, sans dépendance système. +```bash +ajean update +``` -Ce n'est pas qu'un rangement. Chaque fichier avait sa façon d'être écrit, et donc sa façon de rater une écriture concurrente : l'interface et un tour de chat qui touchaient au même réglage au même instant pouvaient en perdre un. Une transaction remplace tout ça. Une bascule de preset, en particulier, remplace la configuration d'un bloc : il n'existe plus d'instant où elle serait à moitié l'ancienne et à moitié la nouvelle. - -Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardés à la main : les presets, les pages de mémoire, les modèles. `ajean edit` déroule donc la configuration au format `clé=valeur` dans votre éditeur, puis la relit ; le fichier n'existe que le temps de l'édition. - -## Six dossiers - -`$AJEAN_HOME` contient `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. Ne restent à côté que ce qui ne peut pas aller ailleurs : la clé privée du chiffrement de bout en bout, le dossier des certificats TLS, les journaux et les fichiers PID des services. - -## Deux services qui portent enfin leur nom - -`ajean.service` et `ajean-link.service` deviennent **`ajean-engine`** et **`ajean-ui`**. Le premier exécute le modèle, le second sert l'interface web, le tunnel d'accès distant et l'endpoint OpenAI. Le nom « link » cachait l'essentiel : ce service est d'abord le serveur web. - -Surtout, il n'y a plus **qu'une seule façon** de servir l'interface. Avant, `ajean web` et `ajean link serve` savaient tous deux le faire, ce qui posait un piège permanent : lancer les deux, c'était un conflit sur le port 8090 et surtout deux fils de conversation qui divergeaient (la conversation vit en mémoire, deux process qui la servent finissent par s'écraser l'un l'autre). La consigne « ne jamais lancer `ajean web` » circulait comme une règle à retenir ; elle n'existait que parce que le code offrait deux portes pour la même pièce. - -Désormais `ajean web` est cette porte unique : il sert l'interface et, si un jeton de liaison est enregistré, ouvre le tunnel dans le même process. `ajean link` ne s'occupe plus que du compte (jeton, code d'appairage, état), et `ajean ui start|stop|restart|status` pilote le service. `ajean link serve`, `link start`, `link stop` et `link restart` disparaissent. - -Séparer les deux services garde son intérêt : redémarrer l'interface est instantané, alors que redémarrer le moteur recharge des dizaines de gigaoctets. - -## Sur Windows - -**« Quitter » arrête vraiment tout.** Le moteur tourne dans un processus détaché, qui survit volontairement à la fermeture de l'interface pour garder le modèle chargé entre deux ouvertures. Mais après un « Quitter » depuis la zone de notification, plus rien ne le pilotait et il conservait des dizaines de gigaoctets de mémoire, sans la moindre fenêtre pour l'expliquer. Quitter décharge désormais le modèle. Ailleurs, rien ne change : sous Linux et macOS le moteur appartient à systemd ou launchd, et fermer une interface n'a pas à arrêter un service système. - -**AJEAN se comporte enfin comme une vraie commande.** Dans un terminal, `ajean` rendait la main avant d'avoir écrit quoi que ce soit : l'invite se réaffichait, et la sortie du programme arrivait par-dessus, comme si la commande avait été tapée deux fois. - -La même cause empêchait `ajean where > fichier.txt` d'écrire dans le fichier, `ajean help | findstr ...` de transmettre quoi que ce soit, et rendait `ajean chat` inutilisable depuis un terminal, le shell et AJEAN se disputant le clavier. Le binaire était compilé en sous-système graphique, et Windows n'attend jamais la fin d'un programme de ce type. - -Il est désormais en sous-système console. La contrepartie, une fenêtre noire au double-clic, est traitée par le programme lui-même : constatant qu'aucun terminal ne l'a lancé, il referme aussitôt la console que Windows lui a allouée. Les raccourcis posés à l'installation demandent en plus un démarrage minimisé, si bien qu'elle n'est jamais peinte à l'écran. - -**Plus de question au premier lancement.** Le double-clic demandait s'il fallait installer AJEAN ou seulement le lancer. La question n'avait qu'une réponse utile : rester à l'emplacement du fichier téléchargé ne donne pas une installation exploitable, sans raccourci, sans rien dans le PATH, et avec une application qui disparaît le jour où l'on vide ses téléchargements. L'installation se fait donc directement, et le message qui suit dit ce qui a été fait au lieu de demander une permission. - -## Ce qui a été retiré - -Tout le code écrit pour ménager les installations « jean » : la migration du dossier de données et ses reprises après échec, la migration de l'agencement système (unités, `/etc/default`, réécriture des chemins), l'élévation Windows qu'elle demandait, la résolution du nom d'unité réellement installée, la reprise des fichiers PID et des skills, les alias `jean` posés à l'installation, les variables `JEAN_*` lues en second. - -La CLI perd ses alias hérités (`skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama`) et son aide est réorganisée autour des deux services. Chaque commande a désormais un seul nom. `app` quitte l'aide : c'est le comportement du double-clic, qu'on n'atteint pas en tapant son nom. - -Les binaires publiés prennent des noms lisibles : `ajean-linux`, `ajean-linux-arm`, `ajean-macos`, `ajean-macos-arm`, `ajean-windows.exe`, `ajean-windows-arm.exe`. Le suffixe `-arm` désigne l'arm64, son absence l'x86-64. - -## Ce qui n'a pas été testé - -La reprise 0.7 vers 0.8 a été vérifiée sur Linux (un serveur réel, avec 9 presets, 24 pages de mémoire et 8 modèles) et sur Windows (dossier de test complet), plus par trois tests automatisés. **Elle n'a pas été essayée sur macOS**, faute de machine : le support macOS reste globalement non validé sur du matériel Apple. Sauvegardez `$AJEAN_HOME` avant de vous lancer. +Ou, depuis l'interface, le bandeau de mise à jour. Rien à migrer : les presets, la configuration et la mémoire sont inchangés. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index d1c8a07..2543f93 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index cf6c133..bb3017e 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index a2d3f51..5e356a1 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 8, - "Patch": 0, + "Patch": 1, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 8, - "Patch": 0, + "Patch": 1, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.8.0", + "ProductVersion": "0.8.1", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_config.go b/internal/ajean/backend_config.go index ec83097..5e6720c 100644 --- a/internal/ajean/backend_config.go +++ b/internal/ajean/backend_config.go @@ -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. diff --git a/internal/ajean/backend_config_quote_test.go b/internal/ajean/backend_config_quote_test.go new file mode 100644 index 0000000..6f23227 --- /dev/null +++ b/internal/ajean/backend_config_quote_test.go @@ -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 +} diff --git a/internal/ajean/backend_model_dirs.go b/internal/ajean/backend_model_dirs.go index e191d47..8f0534b 100644 --- a/internal/ajean/backend_model_dirs.go +++ b/internal/ajean/backend_model_dirs.go @@ -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 diff --git a/internal/ajean/backend_models.go b/internal/ajean/backend_models.go index ea68e91..3b1d56b 100644 --- a/internal/ajean/backend_models.go +++ b/internal/ajean/backend_models.go @@ -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 "" diff --git a/internal/ajean/backend_presets.go b/internal/ajean/backend_presets.go index 8b88298..c4bc437 100644 --- a/internal/ajean/backend_presets.go +++ b/internal/ajean/backend_presets.go @@ -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 — diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index ad40de5..43079aa 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -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. diff --git a/internal/ajean/run.go b/internal/ajean/run.go index deff654..f5237f4 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -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()) diff --git a/internal/ajean/sys_install_unix.go b/internal/ajean/sys_install_unix.go index 39c4f64..c65ed50 100644 --- a/internal/ajean/sys_install_unix.go +++ b/internal/ajean/sys_install_unix.go @@ -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 } diff --git a/internal/ajean/sys_service.go b/internal/ajean/sys_service.go index 2378848..bbfb1ec 100644 --- a/internal/ajean/sys_service.go +++ b/internal/ajean/sys_service.go @@ -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 } diff --git a/internal/ajean/sys_service_linux.go b/internal/ajean/sys_service_linux.go index 00c16b4..dbc4c6a 100644 --- a/internal/ajean/sys_service_linux.go +++ b/internal/ajean/sys_service_linux.go @@ -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) } diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 79ef7aa..cc7bdda 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -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'); diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 7baaff9..ac864ce 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -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'); diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 3d27069..859b160 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -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)