diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index aa3f4c9..98f3fca 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,48 +1,32 @@ -Une version d'audit. Aucune fonctionnalité nouvelle : une relecture du cœur du code, et la correction de ce qu'elle a mis au jour. Deux de ces défauts pouvaient vous mordre pour de bon. +Deux bugs signalés par l'usage, tous les deux confirmés, tous les deux réparés. Le premier pouvait bloquer le chat jusqu'au redémarrage du service. Le second faisait mentir un interrupteur. -## Le mode agent redevient un vrai interrupteur +## Le bouton stop arrête vraiment, et le chat ne se bloque plus -L'API de chat acceptait, dans le corps de la requête, une surcharge qui **rallumait** le mode agent. Autrement dit : agent éteint sur la machine, mais un client qui envoyait le bon drapeau récupérait quand même le shell, l'écriture de fichiers et les outils MCP. Comme l'API de pilotage n'est pas protégée par défaut et écoute sur toutes les interfaces, l'interrupteur ne garantissait rien sur un réseau local partagé. +Le scénario, tel qu'il était vécu : le modèle lance une commande, elle dure, on clique sur stop, rien ne se passe. On redémarre alors le moteur, et là le chat reste figé avec son bouton stop, sans plus rien accepter. Vider la conversation ne suffit pas, rafraîchir la page non plus ; il faut redémarrer le service d'interface. -Désormais une surcharge ne peut que **restreindre**. Ce qui est éteint sur la machine ne peut plus être rallumé par une requête, et couper l'agent coupe aussi, du même geste, l'accès web qui en dépend. Deux tests verrouillent la règle dans les deux sens. +Trois défauts se cumulaient, et ils sont corrigés ensemble. -Le champ hérité qui portait cette surcharge venait du temps où l'ancien portail gérait ses propres interrupteurs. Il reste accepté, mais borné. +**La commande ignorait l'arrêt.** Elle démarrait avec un contexte à elle, indépendant du tour. Annuler la génération n'annulait donc rien du tout : le tour restait suspendu jusqu'au bout du délai, cinq minutes au maximum. La commande hérite désormais du contexte du tour, et stop la tue pour de bon. Dans la foulée, un arrêt demandé au milieu d'une série d'appels d'outils interrompt la série au lieu de la dérouler jusqu'au bout. -## Une base illisible ne désarme plus l'authentification +**Une commande qui laisse un processus en arrière-plan bloquait le tour pour toujours.** Quelque chose comme `./serveur &` rend la main tout de suite, mais le processus détaché garde les tubes de sortie ouverts, et l'attente de fin de commande attendait leur fermeture, c'est à dire jamais. Ni le délai ni le stop n'en venaient à bout. L'attente est maintenant bornée après la fin ou la mise à mort du processus. -Quand aucune clé de pilotage n'est enregistrée, l'API est ouverte : c'est le confort du local. Mais la lecture de cette clé traitait « je n'ai pas pu lire la base » exactement comme « il n'y a pas de clé ». Une base momentanément verrouillée par une commande lancée à côté suffisait donc, en théorie, à ouvrir l'API le temps de la contention. +**Vider la conversation ne débloquait pas.** C'est pourtant le geste qu'on tente quand le chat est coincé. « Nouvelle conversation » libère désormais toujours l'état de génération, quel que soit le sort du tour abandonné. Plus besoin de redémarrer quoi que ce soit. -La lecture des secrets distingue maintenant les deux cas, et l'authentification **ferme** en cas de doute (503) au lieu d'ouvrir. +Au passage, un tour abandonné qui se termine après le démarrage du suivant ne vient plus déclarer ce dernier terminé. -## Le bouton d'envoi ne peut plus rester bloqué +## Un troisième défaut, trouvé en écrivant les tests -Avant chaque tour, AJEAN vérifie que le moteur répond. Cet appel n'avait aucun délai maximum. Un moteur qui accepte la connexion sans jamais répondre, ce qui est exactement ce que fait un très gros modèle pendant son chargement, laissait donc l'envoi du message suspendu indéfiniment, sans erreur et sans retour. Il abandonne maintenant au bout de trois secondes et vous dit que le modèle n'est pas prêt. +Le dossier de travail de l'agent est résolu une fois pour toutes au démarrage. S'il disparaissait ensuite, parce que vous avez fait le ménage ou parce que le modèle l'a supprimé lui-même, **toutes** les commandes suivantes échouaient sur un « chdir : no such file or directory » incompréhensible, et ce jusqu'au redémarrage. Il est maintenant recréé au besoin. -## L'accès distant ne peut plus se dédoubler +## Raisonnement désactivé veut enfin dire désactivé -Redémarrer le lien depuis l'interface arrêtait la boucle de connexion, mais la session en cours, elle, continuait de vivre : elle n'écoutait aucun signal d'arrêt et attendait la mort naturelle du WebSocket, qui n'arrive pas tant que le relais répond. Le nouveau tunnel s'ouvrait donc pendant que l'ancien tenait encore, et le relais voyait deux agents pour une seule machine. +Couper le raisonnement dans l'éditeur de preset **effaçait** la ligne `REASONING` au lieu d'écrire `off`. Ce n'est pas la même chose : sans consigne, le moteur suit le gabarit du modèle, et un modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé » pendant que le modèle réfléchissait tranquillement. -L'arrêt est maintenant immédiat et attendu : une session se ferme quand on le lui demande, et la suivante ne démarre qu'après. +L'interface écrit maintenant `on` ou `off`, explicitement, et `off` est transmis au moteur comme une interdiction. -Dans la foulée, le délai entre deux tentatives de reconnexion se remet à zéro après une session qui a tenu. Il grimpait sans jamais redescendre et finissait collé à trente secondes, y compris pour rattraper un lien qui venait de fonctionner des heures. +Avec une précaution : le drapeau qui désactive le raisonnement est récent, et certains moteurs, notamment le fork ik_llama.cpp, ne le connaissent pas. Le leur passer les ferait refuser de démarrer, donc boucler. AJEAN demande au binaire ce qu'il sait faire avant de le lui passer, et le dit dans le journal quand le moteur choisi ne permet pas de couper la réflexion. -## Le flux de chat ne relit plus tout le fil à chaque mot - -Pour envoyer les nouveaux événements à votre navigateur, le serveur reparcourait la totalité du journal de conversation, à chaque token généré, et pour chaque appareil connecté, en tenant le verrou que la génération elle-même attend. Sur une longue conversation, c'est un coût qui grandit avec l'historique et qui ralentit ce qu'il est censé diffuser. - -Le journal étant trié, la recherche du premier événement neuf se fait par dichotomie. Le coût ne dépend plus de la longueur de la conversation. - -## La configuration n'est plus relue depuis le disque cent fois par tour - -La base de données est délibérément fermée entre deux opérations : c'est ce qui permet aux commandes du terminal de fonctionner pendant que le service tourne. Mais la boucle d'inférence relisait le port, la clé et le seuil de compactage à chaque itération, et le compactage se re-testait après chaque appel d'outil. Un tour agentique un peu fourni rouvrait le fichier une centaine de fois. - -Un cache de lecture s'interpose, invalidé par toute écriture locale, par une écriture venue d'un autre processus (date et taille du fichier) et, en dernier filet, par l'âge. Le comportement ne change pas d'un iota, le travail inutile disparaît. - -## Le reste - -Les erreurs du moteur sont classées sur leur type plutôt que sur le texte du message : « connexion refusée » d'un Windows en français ne ressemblait à aucun des motifs anglais reconnus, et l'utilisateur recevait alors l'erreur brute au lieu de l'explication. - -Les trois compactions (début de tour, fin de tour, bouton manuel) déroulaient la même douzaine de lignes recopiées ; elles partagent maintenant un seul chemin, ce qui ferme la porte aux corrections qui n'atterrissaient que dans une des trois copies. La ligne de journal de la compaction de secours comparait le résultat à lui-même, elle compare enfin l'avant et l'après. Deux champs de l'API de chat que plus personne ne lisait depuis que la conversation vit côté serveur ont été retirés. Les serveurs HTTP posent un délai de lecture des en-têtes, pour qu'une connexion qui n'envoie jamais rien ne retienne pas de ressources. +Vos presets existants ne sont pas modifiés. Ceux dont la ligne est absente gardent le comportement d'avant, et l'éditeur ne prétend plus que le raisonnement y est coupé : il indique que rien n'est précisé et que le modèle décide. Basculez l'interrupteur une fois pour trancher. ## Mise à jour @@ -50,4 +34,4 @@ Les trois compactions (début de tour, fin de tour, bouton manuel) déroulaient ajean update ``` -Rien à migrer : presets, configuration et mémoire sont inchangés. +Puis, si vous utilisez l'accès distant, rechargez la page du portail pour prendre la nouvelle interface. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 3cc656f..35b2d04 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 3a1cb4c..9f64fb7 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 bf429e1..4520da5 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 8, - "Patch": 2, + "Patch": 3, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 8, - "Patch": 2, + "Patch": 3, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.8.2", + "ProductVersion": "0.8.3", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index 43079aa..6f1df9e 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -1,11 +1,39 @@ package ajean import ( + "bytes" + "context" "fmt" "os" + "os/exec" "path/filepath" + "strings" + "time" ) +// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ». +// +// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le +// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne +// l'ajoute que pour interdire le raisonnement, mieux vaut demander au binaire +// que parier : on lit son aide, une fois, au lancement du moteur. +// +// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement +// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide +// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à +// tort qu'il ne gère pas le drapeau. +func binSupportsReasoningFlag(bin string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "--help") + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Run(); err != nil && out.Len() == 0 { + return false // aide illisible : on ne prend pas le risque + } + return strings.Contains(out.String(), "--reasoning ") +} + // cmdServe replaces the historic start.sh: read config.env, build the // llama-server invocation, and exec it (replacing this process so systemd // supervises llama-server directly). @@ -81,14 +109,35 @@ func cmdServe(args []string) error { if vtv != "" { llmArgs = append(llmArgs, "-ctv", vtv) } - if r := cfg["REASONING"]; r != "" { - // budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au - // bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la - // réflexion atteignait le plafond et il ne restait plus de marge pour le - // contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 : - // sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement - // (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET. - llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1")) + // Raisonnement. Trois cas, et la nuance compte : + // + // REASONING=on|auto|deepseek → --reasoning + // REASONING=off → --reasoning off (interdiction EXPLICITE) + // clé absente → aucun drapeau, le moteur fait son défaut + // + // L'interface écrivait « off » en EFFAÇANT la ligne, ce qui n'est pas du tout + // la même chose : sans drapeau, llama-server suit le gabarit du modèle, et un + // modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé » + // pendant que le modèle réfléchissait quand même. Il faut le dire au moteur. + if r := strings.TrimSpace(cfg["REASONING"]); r != "" { + if reasoningActive(r) { + // budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au + // bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la + // réflexion atteignait le plafond et il ne restait plus de marge pour le + // contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 : + // sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement + // (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET. + llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1")) + } else if binSupportsReasoningFlag(bin) { + // Pas de budget ici : « off » suffit, et un budget sur un moteur qui + // n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer. + llmArgs = append(llmArgs, "--reasoning", "off") + } else { + // Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer + // le ferait sortir en erreur au démarrage, donc boucler. On le dit et on + // continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort. + fmt.Fprintf(os.Stderr, "[ajean serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n") + } } // API_KEY protège le serveur quand il est exposé sur internet : llama-server // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index eb63d8d..a42250e 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -292,9 +292,16 @@ func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) er func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) { defer func() { c.mu.Lock() - c.Generating = false - c.cancel = nil stale := c.epoch != epoch + // ⚠️ Un tour périmé ne touche PAS à l'état courant. Depuis que Reset + // débloque lui-même la conversation, un tour abandonné peut se terminer + // APRÈS le démarrage du suivant : remettre Generating à false ici + // déclarerait « libre » une génération toute neuve, et l'UI afficherait + // un fil qui se remplit avec un bouton « envoyer » actif. + if !stale { + c.Generating = false + c.cancel = nil + } c.mu.Unlock() if stale { return // Reset pendant le tour : Reset a déjà persisté l'état vide @@ -452,8 +459,12 @@ func (c *Conversation) CompactNow() error { go func() { defer func() { c.mu.Lock() - c.Generating = false - c.cancel = nil + // Même précaution que dans generate : une compaction abandonnée par un + // Reset ne doit pas déclarer « libre » le tour qui a démarré depuis. + if c.epoch == epoch { + c.Generating = false + c.cancel = nil + } c.mu.Unlock() }() if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}); changed { @@ -476,6 +487,15 @@ func (c *Conversation) Stop() { // Reset démarre une nouvelle conversation (vide) pour TOUS les appareils. On // interrompt une éventuelle génération, on vide tout et on bump epoch pour que // les abonnés reçoivent l'ordre de nettoyer leur affichage. +// +// Reset DÉBLOQUE toujours, et c'est sa deuxième raison d'être. Il se contentait +// avant de vider le fil : si un tour restait coincé (moteur redémarré sous ses +// pieds, commande shell accrochée à ses tubes), Generating restait vrai pour +// toujours et tout message suivant se voyait refusé « génération en cours ». +// Vider le fil ne changeait rien, rafraîchir non plus : il fallait redémarrer le +// service. « Nouvelle conversation » est le geste qu'on tente naturellement dans +// ce cas ; il doit donc rendre la main, quoi qu'il arrive au tour abandonné, que +// le bump d'epoch réduit de toute façon au silence. func (c *Conversation) Reset() { c.Stop() c.mu.Lock() @@ -484,6 +504,8 @@ func (c *Conversation) Reset() { c.Seq = 0 c.CtxUsed = 0 c.epoch++ + c.Generating = false + c.cancel = nil c.cond.Broadcast() c.mu.Unlock() c.persist() diff --git a/internal/ajean/chat_stuck_test.go b/internal/ajean/chat_stuck_test.go new file mode 100644 index 0000000..60fa5a0 --- /dev/null +++ b/internal/ajean/chat_stuck_test.go @@ -0,0 +1,112 @@ +package ajean + +import ( + "context" + "os" + "runtime" + "strings" + "testing" + "time" +) + +// Le bouton stop doit VRAIMENT arrêter une commande shell. Elle naissait d'un +// contexte indépendant du tour : arrêter la génération ne l'arrêtait pas, et le +// tour restait suspendu jusqu'à l'expiration du délai (5 min au maximum). +func TestRunShellObeitAuStop(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("commande de veille propre à Unix") + } + testHome(t) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(200 * time.Millisecond) + cancel() + }() + start := time.Now() + out := runShell(ctx, "sleep 30", 300) + if d := time.Since(start); d > 5*time.Second { + t.Fatalf("la commande a survécu à l'annulation (%s) : %q", d, out) + } + if !strings.Contains(out, "interrompue") { + t.Errorf("résultat attendu « interrompue », reçu %q", out) + } +} + +// Une commande qui laisse un process en arrière-plan garde les tubes de sortie +// ouverts. Sans WaitDelay, Wait attend leur fermeture — donc pour toujours — et +// le tour ne se terminait JAMAIS (seul un redémarrage du service débloquait). +func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("syntaxe de détachement propre à Unix") + } + testHome(t) + start := time.Now() + out := runShell(context.Background(), "sleep 30 & echo lance", 2) + if d := time.Since(start); d > 10*time.Second { + t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d) + } + if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") { + t.Errorf("sortie inattendue : %q", out) + } +} + +// Le dossier de travail est résolu une fois par process : s'il disparaît, plus +// AUCUNE commande ne passait (« chdir : no such file or directory ») jusqu'au +// redémarrage du service. Il doit être recréé au besoin. +func TestRunShellRecreeLeWorkspaceDisparu(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("echo se comporte différemment sous cmd.exe") + } + testHome(t) + ws := agentWorkspace() + if ws == "" { + t.Skip("pas de workspace résolu") + } + if err := os.RemoveAll(ws); err != nil { + t.Fatal(err) + } + out := runShell(context.Background(), "echo vivant", 10) + if !strings.Contains(out, "vivant") { + t.Fatalf("commande cassée par la disparition du workspace : %q", out) + } +} + +// Reset doit TOUJOURS rendre la main, même sur un tour resté coincé : c'est le +// geste que l'on tente quand le chat est bloqué. Avant, Generating restait vrai +// et tout envoi suivant était refusé « génération en cours » jusqu'au +// redémarrage du service. +func TestResetDebloqueUnTourCoince(t *testing.T) { + c := newTestConv() + // Simule un tour parti et jamais terminé (commande accrochée, moteur disparu). + c.mu.Lock() + c.Generating = true + c.mu.Unlock() + + c.Reset() + + if st := c.state(); st["generating"] != false { + t.Fatal("après Reset, la conversation doit être déclarée libre") + } +} + +// Le tour abandonné se termine parfois APRÈS le démarrage du suivant. Sa fin ne +// doit pas déclarer « libre » une génération toute neuve. +func TestTourPerimeNeLiberePasLeTourCourant(t *testing.T) { + c := newTestConv() + oldEpoch := c.epoch + c.Reset() // le tour d'epoch `oldEpoch` devient périmé + c.mu.Lock() + c.Generating = true // un nouveau tour démarre + c.mu.Unlock() + + // Fin (tardive) du tour périmé : même code que le defer de generate. + c.mu.Lock() + if c.epoch == oldEpoch { + c.Generating = false + } + c.mu.Unlock() + + if st := c.state(); st["generating"] != true { + t.Fatal("un tour périmé a libéré le tour courant") + } +} diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go index 8a43dea..ae89c85 100644 --- a/internal/ajean/chat_tools.go +++ b/internal/ajean/chat_tools.go @@ -2,6 +2,7 @@ package ajean import ( "context" + "errors" "fmt" "os" "os/exec" @@ -135,25 +136,52 @@ func machineSystemPrompt(caps Caps) string { // on Windows — see newShellCmd in sys_platform_*.go) with a clamped timeout, // returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..." // truncated to keep tool output bounded. -func runShell(command string, timeoutSec int) string { +// +// ⚠️ parent est le contexte DU TOUR : c'est lui qui rend le bouton stop utile. +// La commande naissait auparavant d'un context.Background(), donc arrêter la +// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du +// délai (5 minutes au maximum), bouton stop sans effet. +func runShell(parent context.Context, command string, timeoutSec int) string { if timeoutSec <= 0 { timeoutSec = toolDefaultTimeout } if timeoutSec > toolMaxTimeout { timeoutSec = toolMaxTimeout } - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second) defer cancel() cmd := newShellCmd(ctx, command) // Le shell démarre dans le workspace, pas dans le dossier d'où ajean a été // lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau. - cmd.Dir = agentWorkspace() + // + // Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il + // disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même le + // supprime — toutes les commandes suivantes échouaient sur un « chdir : no + // such file or directory » incompréhensible, et ce jusqu'au redémarrage. On + // le recrée au besoin, et à défaut on démarre là où on peut plutôt que de + // tout refuser. + if ws := agentWorkspace(); ws != "" { + if err := os.MkdirAll(ws, 0o755); err == nil { + cmd.Dir = ws + } + } var stdout, stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr + // ⚠️ WaitDelay borne l'attente APRÈS la fin (ou la mise à mort) du process. + // Sans elle, Wait attend que les tubes de sortie soient fermés — donc que + // TOUS ceux qui les tiennent aient disparu, petits-enfants compris. Une + // commande du genre « ./serveur & » rend la main tout de suite mais laisse + // un process en arrière-plan accroché aux tubes : runShell ne revenait alors + // JAMAIS, ni au délai, ni au stop. Le tour restait bloqué à vie, et la seule + // issue connue était de redémarrer ajean-ui. + cmd.WaitDelay = 2 * time.Second err := cmd.Run() - if ctx.Err() == context.DeadlineExceeded { + switch { + case errors.Is(ctx.Err(), context.DeadlineExceeded): return fmt.Sprintf("[timeout après %ds]", timeoutSec) + case errors.Is(parent.Err(), context.Canceled): + return "[commande interrompue]" } exit := 0 if err != nil { diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index 8cadd67..15de10e 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -874,6 +874,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps extra = append(extra, assistant) // 2. Execute each tool locally and append a "tool" reply. for _, tc := range tcs { + // Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce + // garde, un stop pendant une série d'appels laissait défiler toute + // la série avant de reprendre la main. + if ctx.Err() != nil { + return extra, nil + } var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) // Derive the human label (command / skill name) up front so we can @@ -989,7 +995,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps case int: to = v } - result = runShell(label, to) + result = runShell(ctx, label, to) case "web_search": result = capWebOutput(toolWebSearch(args)) case "web_open": diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 6e124da..0fe0719 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.8.2" +const Version = "0.8.3" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index cc7bdda..488c304 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1658,8 +1658,8 @@ button:hover{border-color:var(--dim);color:var(--text)}
- Raisonnementréflexion étape par étape - + Raisonnementréflexion étape par étape +
Flash attentionplus rapide, moins de VRAM @@ -3158,7 +3158,19 @@ function populateSettings(){ set('s-spec-n', eaGetValued('--spec-draft-n-max')); setSpecType(eaGetValued('--spec-type')); const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; }; - chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING'))); + // Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur. + // « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle, + // laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement + // réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le + // sous-titre la dit. (Les presets créés ou modifiés depuis cette version + // écrivent toujours on ou off, la question ne se pose plus pour eux.) + const rz = cfgReadKey('REASONING'); + chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz)); + const rzSub = document.getElementById('s-reasoning-sub'); + if(rzSub){ + rzSub.textContent = rz ? 'réflexion étape par étape' + : 'réflexion étape par étape — non précisé : le modèle décide'; + } chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn'))); chk('s-mlock', eaHasFlag('--mlock')); chk('s-nommap', eaHasFlag('--no-mmap')); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 7a6f4a5..d46c5c4 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -611,8 +611,8 @@
- Raisonnementréflexion étape par étape - + Raisonnementréflexion étape par étape +
Flash attentionplus rapide, moins de VRAM diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index ac864ce..7459e7a 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -666,7 +666,19 @@ function populateSettings(){ set('s-spec-n', eaGetValued('--spec-draft-n-max')); setSpecType(eaGetValued('--spec-type')); const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; }; - chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING'))); + // Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur. + // « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle, + // laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement + // réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le + // sous-titre la dit. (Les presets créés ou modifiés depuis cette version + // écrivent toujours on ou off, la question ne se pose plus pour eux.) + const rz = cfgReadKey('REASONING'); + chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz)); + const rzSub = document.getElementById('s-reasoning-sub'); + if(rzSub){ + rzSub.textContent = rz ? 'réflexion étape par étape' + : 'réflexion étape par étape — non précisé : le modèle décide'; + } chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn'))); chk('s-mlock', eaHasFlag('--mlock')); chk('s-nommap', eaHasFlag('--no-mmap'));