diff --git a/internal/ajean/backend_cloud.go b/internal/ajean/backend_cloud.go index 3a9e814..35b1cc4 100644 --- a/internal/ajean/backend_cloud.go +++ b/internal/ajean/backend_cloud.go @@ -471,6 +471,10 @@ func cloudEndpoint(cfg map[string]string) chatEndpoint { // doLLM envoie la requête ; pour un GPU cloud, attend le réveil du conteneur // (llama-server répond 503 « Loading model » tant que le modèle charge). func doLLM(ctx context.Context, req *http.Request, body []byte, ep chatEndpoint) (*http.Response, error) { + // Vu par le pré-chauffage de prompt (llm_prewarm.go) : une génération occupe + // le moteur, il ne faut pas s'insérer devant elle. + llmInFlight.Add(1) + defer llmInFlight.Add(-1) if !ep.Cloud { return http.DefaultClient.Do(req) } diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index bf62714..31432d0 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -505,6 +505,9 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem } c.Generating = true c.genStart = time.Now() + // Le message de l'utilisateur passe devant tout pré-chauffage interne : le + // tour va de toute façon traiter le préfixe et garder le cache KV. + cancelLLMPrewarmForTurn() jeanReflectAbort() // le moteur est à l'utilisateur, la révision attendra ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel @@ -579,6 +582,10 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem if temperature == 0 { temperature = 0.7 } + // Un tour utilisateur est en cours : le pré-chauffage s'efface, y compris + // pendant les trous entre deux appels d'outil (llmTurnsActive, voir + // llm_prewarm.go). + llmTurnsActive.Add(1) go c.generate(ctx, caps, temperature, epoch) return nil } @@ -685,6 +692,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // donc les vieux tours affichaient le preset du moment, pas celui qui a répondu). turnPreset := activePresetName() defer func() { + llmTurnsActive.Add(-1) c.mu.Lock() stale := c.epoch != epoch // ⚠️ Un tour périmé ne touche PAS à l'état courant. Depuis que Reset diff --git a/internal/ajean/llm_prewarm.go b/internal/ajean/llm_prewarm.go new file mode 100644 index 0000000..4aa6046 --- /dev/null +++ b/internal/ajean/llm_prewarm.go @@ -0,0 +1,410 @@ +package ajean + +// llm_prewarm.go — pré-chauffage du PRÉFIXE de prompt auprès du moteur. +// +// Le premier message d'une conversation paie le préfill de tout ce qui précède +// le texte de l'utilisateur : préambule système, briefing machine, prompt du +// preset et surtout les schémas d'outils — plusieurs milliers de jetons. Sur un +// modèle local, c'est l'essentiel du délai avant le premier jeton, et ce préfixe +// est IDENTIQUE d'une conversation à l'autre d'un même mode (le contexte projet, +// lui, part dans le premier message utilisateur, voir normalizeSystemMessages). +// +// Dès que le moteur répond, on envoie donc une complétion minuscule +// (max_tokens=1) avec EXACTEMENT ce préfixe. llama-server garde le cache KV du +// créneau (--cache-ram / --cache-idle-slots sont actifs par défaut) : le premier +// vrai message ne traite plus que son propre texte, et le premier jeton arrive +// tout de suite. Rien n'est affiché : la requête est interne, sa réponse jetée. +// +// Deux règles tiennent ce pré-chauffage honnête : +// +// 1. Il ne part QUE si la conversation active est vide. Avec un seul créneau de +// cache (-np 1, le cas courant), chauffer pendant qu'une conversation est en +// cours écraserait son cache KV : le message suivant repaierait tout +// l'historique. Une conversation commencée (ou en génération) n'est jamais +// touchée, quoi qu'il arrive au prompt, aux outils ou à l'interface. +// 2. Le préfixe est celui du MODE qui recevra le premier message (Jean, Projet, +// Rapide, Modèle de base), construit par les mêmes capacités que le vrai envoi +// (capsFromBody), pas par les réglages globaux de la machine : sinon le +// préfixe chauffé ne correspond pas au vrai premier tour et le cache ne se +// raccorde pas. Voir prewarmTarget pour la façon dont le mode est connu. +// +// Le pré-chauffage est BÉNIN : jamais pendant une génération (le compteur +// llmInFlight le dit), annulé dès qu'un tour utilisateur démarre (le tour +// chauffera le cache lui-même), deux tentatives au plus, signature mémorisée +// pour ne pas rejouer le même préfixe à chaque sondage de /api/status. + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + // Texte du faux message utilisateur : il ne sert qu'à rendre le prompt + // valide pour tous les gabarits de chat. Seul le préfixe qui le précède + // nous intéresse. + llmPrewarmUserText = "ok" + // Borne : sur un gros modèle CPU le préfill peut être long, mais au-delà le + // pré-chauffage retarderait le premier vrai message pour rien. + llmPrewarmTimeout = 30 * time.Second + // Deux essais suffisent (moteur ancien qui refuserait le corps, coupure…). + // Ensuite on abandonne jusqu'au prochain redémarrage du moteur ou changement + // de préfixe. + llmPrewarmMaxTries = 2 + // Une fois le préfixe chauffé, on revérifie quand même de temps en temps + // qu'il n'a pas changé (prompt du preset édité, outils activés…) — sans + // reconstruire le préfixe à chaque sondage de /api/status (toutes les 5 s). + llmPrewarmRecheck = 15 * time.Second + // Clé (bucket state) du dernier mode de chat réellement utilisé à l'envoi. + // Sert à deviner le mode d'une conversation encore vide (voir prewarmTarget). + lastChatModeKey = "last_chat_mode" +) + +// llmInFlight compte les complétions réellement en cours vers le moteur (chat, +// tâches, Jean, apps…). Sert au pré-chauffage : inutile — et nuisible — de +// s'insérer devant une génération déjà lancée. +var llmInFlight atomic.Int32 + +// llmTurnsActive couvre les TROUS entre deux requêtes d'un même tour (exécution +// d'un outil, lecture de fichier…) : llmInFlight retombe à zéro alors que le tour +// n'est pas fini et qu'un pré-chauffage s'intercalerait devant l'étape suivante. +// Compteur (pas un booléen) : un Reset peut lancer le tour suivant avant que le +// tour abandonné ne se termine ; sa décrémentation ne doit pas libérer le tour +// vivant. +var llmTurnsActive atomic.Int32 + +// llmPrewarmState : un seul pré-chauffage à la fois, et un souvenir de ce qui a +// déjà été chauffé pour ne pas le refaire à chaque sondage. +// +// ⚠️ Ordre des verrous : conv.mu PUIS llmWarm.mu (StartTurn appelle +// cancelLLMPrewarmForTurn en tenant conv.mu). Ne jamais lire la conversation +// (prewarmTarget) en tenant llmWarm.mu. +var llmWarm struct { + mu sync.Mutex + healthy bool // dernier état de santé observé (détecte un redémarrage moteur) + sig string + warmed bool + inflight bool + attempts int + nextTry time.Time + lastCheck time.Time + cancel context.CancelFunc +} + +// prewarmModeValid : modes de chat que le serveur connaît (voir requestedMode). +func prewarmModeValid(mode string) bool { + switch mode { + case "project", "fast", "base", "jean": + return true + } + return false +} + +// rememberChatMode retient le mode du dernier message envoyé. Le navigateur ne +// dit son mode au serveur qu'avec le premier message d'une conversation +// (Conversation.lockMode) : tant qu'elle est vide, le dernier mode utilisé est la +// meilleure information disponible — on ne change guère de mode d'un message à +// l'autre. N'écrit que si la valeur change. +func rememberChatMode(mode string) { + if prewarmModeValid(mode) && getStr(bkState, lastChatModeKey) != mode { + _ = putStr(bkState, lastChatModeKey, mode) + } +} + +// prewarmCaps : capacités du mode, construites comme pour un vrai envoi +// (handleChatSend → applyChatMode + capsFromBody). Ce sont elles — et non +// globalCaps() — qui décident du préambule, du briefing machine, du prompt du +// preset et des outils du premier tour : Rapide n'a ni mémoire ni prompt de +// preset, Jean a ses outils et son préambule, Modèle de base n'a rien. +func prewarmCaps(mode string) Caps { + var b chatReq + applyChatMode(&b, mode) + return capsFromBody(b) +} + +// prewarmTarget dit si le pré-chauffage a le droit de partir, et pour quel mode. +// +// ok=false (rien à faire) quand : +// - la conversation active n'est pas vide ou génère : son cache KV est à elle, +// et le créneau est unique avec -np 1 ; +// - le mode du prochain message est inconnu : on ne devine pas, un préfixe +// chauffé pour rien coûte un préfill et ne raccorde pas le vrai tour ; +// - le mode ne correspond pas à la conversation ouverte : Jean est une +// conversation unique et persistante (jeanConvID), les autres modes n'y +// écrivent jamais. Dans ce cas, le premier message ouvrira une AUTRE +// conversation (alignConvForMode) dont on ne sait pas si elle est vide. +// +// Le mode est celui de la conversation s'il est déjà fixé (Jean), sinon celui du +// dernier message envoyé (rememberChatMode). +func prewarmTarget() (mode string, ok bool) { + conv.mu.Lock() + id, cmode := conv.ID, conv.Mode + idle := len(conv.Messages) == 0 && !conv.Generating + conv.mu.Unlock() + if !idle { + return "", false + } + mode = cmode + if mode == "" { + mode = getStr(bkState, lastChatModeKey) + } + if !prewarmModeValid(mode) { + return "", false + } + if (mode == "jean") != (id == jeanConvID) { + return "", false + } + return mode, true +} + +// prewarmPrefix construit le préfixe commun à toute conversation du mode décrit +// par caps : le bloc système tel qu'InjectSkills + normalizeSystemMessages le +// produisent, et les outils tels que runChat les annonce. Le contexte projet est +// volontairement absent (il vit dans le premier message utilisateur). +// +// Renvoie ok=false quand il n'y a rien à chauffer (modèle brut, sans système ni +// outil) : envoyer une requête ne gagnerait rien. +func prewarmPrefix(caps Caps) (system string, tools []Tool, ok bool) { + var parts []string + if bp := baseSystemPrompt(caps); bp != "" { + parts = append(parts, bp) + } + if mp := machineSystemPrompt(caps); mp != "" { + parts = append(parts, mp) + } + // Même ordre que generate : le prompt du preset est le premier message + // système, InjectSkills le fusionne APRÈS le préambule. + if sp := readSysPrompt(); sp != "" && caps.Agent && !caps.Terminal { + parts = append(parts, sp) + } + system = strings.Join(parts, "\n\n") + // Même liste que runChat : withoutRecallTools retire recall/recall_search + // tant que le fil n'a pas été compacté (c'est le cas d'une conversation + // neuve, celle qu'on pré-chauffe). + tools = withRecallTools(EnabledTools(caps), nil) + // Mode Jean : runChat ajoute à chaque outil le champ « description » + // (chat_tool_desc.go). Sans lui, les schémas chauffés diffèrent de ceux du + // vrai tour et le cache ne se raccorde pas au-delà du bloc système. + if caps.Jean { + tools, _ = withToolDesc(tools) + } + return system, tools, system != "" || len(tools) > 0 +} + +// prewarmSignature identifie le préfixe chauffé. Un changement de preset, de +// modèle, d'outil ou de prompt système doit relancer le pré-chauffage. +func prewarmSignature(ep chatEndpoint, system string, tools []Tool) string { + h := sha256.New() + h.Write([]byte(ep.URL)) + h.Write([]byte{0}) + h.Write([]byte(ep.Model)) + h.Write([]byte{0}) + h.Write([]byte(system)) + for _, t := range tools { + b, _ := json.Marshal(t) // ordre des champs fixe, clés de map triées + h.Write([]byte{0}) + h.Write(b) + } + return hex.EncodeToString(h.Sum(nil)[:12]) +} + +// prewarmPayload est le corps envoyé au moteur. Volontairement minimal : on ne +// veut qu'un préfill, pas une génération. +func prewarmPayload(ep chatEndpoint, system string, tools []Tool) map[string]any { + msgs := make([]Message, 0, 2) + if system != "" { + msgs = append(msgs, Message{Role: "system", Content: system}) + } + msgs = append(msgs, Message{Role: "user", Content: llmPrewarmUserText}) + p := map[string]any{ + "model": ep.Model, + "messages": msgs, + "max_tokens": 1, + "temperature": 0, + "stream": false, + "cache_prompt": true, + } + if len(tools) > 0 { + p["tools"] = tools + p["parallel_tool_calls"] = false + } + return p +} + +// LLMPrewarm doit être appelé à chaque fois que l'état du moteur est connu au +// service d'interface (démarrage de l'UI/link, sondage /api/status). Il est non +// bloquant : le pré-chauffage part dans sa propre goroutine. +// +// healthy=false signale un moteur qui ne répond pas encore : au prochain +// passage à true, le cache KV est forcément froid, on ré-arme. +func LLMPrewarm(healthy bool) { + // La conversation est lue AVANT de prendre llmWarm.mu (ordre des verrous, voir + // llmWarm). Elle décide de tout : sans conversation active vide, on ne touche + // pas au créneau de cache — mais l'état de santé, lui, est suivi quand même + // (un redémarrage du moteur doit ré-armer, même pendant une conversation). + mode, canWarm := prewarmTarget() + + // Phase 1 : état rapide, sans travail lourd sous le verrou. + llmWarm.mu.Lock() + if !healthy { + llmWarm.healthy = false + llmWarm.mu.Unlock() + return + } + if !llmWarm.healthy { + llmWarm.healthy = true + llmWarm.sig, llmWarm.warmed, llmWarm.attempts = "", false, 0 + llmWarm.lastCheck = time.Time{} + } + if !canWarm { + llmWarm.mu.Unlock() + return + } + // Une génération réelle occupe le moteur : on ne se met pas devant elle. + // (Si le pré-chauffage est déjà parti, le tour le coupera — voir StartTurn.) + if llmInFlight.Load() > 0 || llmTurnsActive.Load() > 0 || llmWarm.inflight { + llmWarm.mu.Unlock() + return + } + if llmWarm.warmed && time.Since(llmWarm.lastCheck) < llmPrewarmRecheck { + llmWarm.mu.Unlock() + return + } + // Après un échec, on laisse passer le recul avant de reconstruire le préfixe + // (connexions MCP, sondage Crawl4AI) : rien ne doit tourner en boucle. + if !llmWarm.warmed && time.Now().Before(llmWarm.nextTry) { + llmWarm.mu.Unlock() + return + } + llmWarm.lastCheck = time.Now() + llmWarm.mu.Unlock() + + // Phase 2 : construction du préfixe HORS verrou. Elle peut être lente (les + // serveurs MCP se connectent, Crawl4AI est sondé) et ne doit jamais retarder + // cancelLLMPrewarmForTurn, appelé au démarrage d'un tour utilisateur. + cfg := ReadConfig() + if isExternalConfig(cfg) || isCloudConfig(cfg) { + return + } + ep := resolveChatEndpoint() + if ep.URL == "" { + return + } + system, tools, ok := prewarmPrefix(prewarmCaps(mode)) + if !ok { + return + } + sig := prewarmSignature(ep, system, tools) + + // Phase 3 : décision et lancement, sous verrou, en revérifiant l'état (un + // tour a pu démarrer pendant la phase 2). + llmWarm.mu.Lock() + if llmWarm.sig != sig { + llmWarm.sig, llmWarm.warmed, llmWarm.attempts = sig, false, 0 + } + if llmWarm.warmed || llmWarm.inflight || llmInFlight.Load() > 0 || llmTurnsActive.Load() > 0 || + llmWarm.attempts >= llmPrewarmMaxTries || time.Now().Before(llmWarm.nextTry) { + llmWarm.mu.Unlock() + return + } + ctx, cancel := context.WithTimeout(context.Background(), llmPrewarmTimeout) + llmWarm.cancel = cancel + llmWarm.inflight = true + llmWarm.mu.Unlock() + + payload := prewarmPayload(ep, system, tools) + go func(sig string) { + // Dernier garde-fou, APRÈS l'enregistrement de cancel ci-dessus : la + // conversation a pu recevoir un message (ou un autre mode a pu s'ouvrir) + // depuis la lecture du début. StartTurn pose Generating sous conv.mu AVANT + // d'appeler cancelLLMPrewarmForTurn, donc deux cas seulement : soit le + // tour a démarré avant, on le voit ici et on n'envoie rien ; soit il + // démarre après, et son cancelLLMPrewarmForTurn coupe cette requête. + skipped := false + var err error + if m, ok := prewarmTarget(); !ok || m != mode { + skipped = true + } else { + err = sendLLMPrewarm(ctx, ep, payload) + } + cancel() + llmWarm.mu.Lock() + llmWarm.inflight = false + llmWarm.cancel = nil + // Un autre préfixe a pu prendre la main pendant l'envoi (changement de + // preset, de prompt système…) : son état ne doit pas être écrasé par le + // résultat de l'ancien. Une requête non envoyée (conversation devenue + // active) ne compte ni comme essai ni comme succès : on réessaiera quand + // une conversation vide sera de nouveau ouverte. + if llmWarm.sig == sig && !skipped { + if err != nil { + llmWarm.attempts++ + // Recul : un moteur qui refuse ce corps n'a pas besoin d'être + // resondé toutes les deux secondes. + llmWarm.nextTry = time.Now().Add(time.Duration(llmWarm.attempts) * 15 * time.Second) + } else { + llmWarm.warmed = true + llmWarm.attempts = 0 + } + } + llmWarm.mu.Unlock() + }(sig) +} + +// cancelLLMPrewarmForTurn coupe un pré-chauffage en cours quand un tour +// utilisateur démarre : sa demande passe devant tout travail interne. On note le +// cache comme chauffé — le tour va de toute façon traiter (et garder) le préfixe. +func cancelLLMPrewarmForTurn() { + llmWarm.mu.Lock() + cancel := llmWarm.cancel + if cancel != nil { + llmWarm.cancel = nil + llmWarm.warmed = true + } + llmWarm.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// sendLLMPrewarm envoie la requête de pré-chauffage et draine la réponse (pour +// laisser la connexion réutilisable par le vrai message). +func sendLLMPrewarm(ctx context.Context, ep chatEndpoint, payload map[string]any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ep.URL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if ep.External { + if ep.Key != "" { + req.Header.Set("Authorization", "Bearer "+ep.Key) + } + } else { + authHeader(req) + } + // Client par défaut : même pool de connexions que le chat, donc le premier + // message réutilise la connexion déjà ouverte. + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pré-chauffage : HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/internal/ajean/llm_prewarm_test.go b/internal/ajean/llm_prewarm_test.go new file mode 100644 index 0000000..439f45b --- /dev/null +++ b/internal/ajean/llm_prewarm_test.go @@ -0,0 +1,424 @@ +package ajean + +import ( + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// engineSpy est un faux llama-server : /health répond, et chaque complétion reçue +// est transmise au test. Une complétion en STREAMING (un vrai tour) reste ouverte +// jusqu'à ce que le test interrompe le tour (conv.Stop) ; une complétion non +// streamée (le pré-chauffage) reçoit une réponse vide tout de suite. +type engineSpy struct { + got chan map[string]any +} + +func newEngineSpy(t *testing.T) *engineSpy { + t.Helper() + s := &engineSpy{got: make(chan map[string]any, 16)} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/v1/chat/completions": + var body map[string]any + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + select { + case s.got <- body: + default: + } + if stream, _ := body["stream"].(bool); stream { + select { + case <-r.Context().Done(): + case <-time.After(10 * time.Second): + } + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + return s +} + +// next attend la prochaine complétion reçue par le faux moteur. +func (s *engineSpy) next(t *testing.T) map[string]any { + t.Helper() + select { + case b := <-s.got: + return b + case <-time.After(5 * time.Second): + t.Fatal("aucune requête reçue par le moteur") + return nil + } +} + +// none vérifie qu'aucune complétion n'arrive pendant d. +func (s *engineSpy) none(t *testing.T, d time.Duration, why string) { + t.Helper() + select { + case b := <-s.got: + t.Fatalf("%s : le moteur a reçu une requête (%v)", why, b["max_tokens"]) + case <-time.After(d): + } +} + +// waitConvIdle attend la fin du tour en cours sur la conversation vive. +func waitConvIdle(t *testing.T) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for conv.isGenerating() { + if time.Now().After(deadline) { + t.Fatal("le tour ne s'est pas terminé") + } + time.Sleep(10 * time.Millisecond) + } +} + +// resetConvEmpty remet la conversation vive (globale au process) dans l'état d'une +// conversation neuve. mode "jean" = la conversation unique de Jean. +func resetConvEmpty(mode string) { + conv.mu.Lock() + defer conv.mu.Unlock() + conv.Messages, conv.Log, conv.queued = nil, nil, nil + conv.Seq, conv.CtxUsed = 0, 0 + conv.Generating = false + conv.cancel = nil + conv.epoch++ + conv.ID, conv.Mode = newSessionID(), "" + if mode == "jean" { + conv.ID, conv.Mode = jeanConvID, "jean" + } +} + +// setConvEmpty : conversation neuve pour ce test, et remise à zéro à sa fin (les +// tests partagent le process, donc la conversation globale). +func setConvEmpty(t *testing.T, mode string) { + t.Helper() + resetConvEmpty(mode) + t.Cleanup(func() { + conv.Stop() + waitConvIdle(t) + resetConvEmpty("") + }) +} + +// resetWarmState réinitialise l'état global du pré-chauffage (idem : partagé). +func resetWarmState(t *testing.T) { + t.Helper() + reset := func() { + llmWarm.mu.Lock() + llmWarm.healthy, llmWarm.sig, llmWarm.warmed, llmWarm.inflight = false, "", false, false + llmWarm.attempts, llmWarm.nextTry, llmWarm.lastCheck, llmWarm.cancel = 0, time.Time{}, time.Time{}, nil + llmWarm.mu.Unlock() + } + reset() + t.Cleanup(reset) +} + +// canon normalise une valeur JSON (clés triées) pour comparer des corps de requête +// venus de deux chemins différents. +func canon(v any) string { + b, _ := json.Marshal(v) + var x any + _ = json.Unmarshal(b, &x) + b, _ = json.Marshal(x) + return string(b) +} + +// Le pré-chauffage ne vaut que si son préfixe est EXACTEMENT celui du vrai tour. +// Sinon le cache KV de llama-server ne se raccorde pas et on paie un préfill pour +// rien. Ce test ne compare donc PAS le pré-chauffage à ses propres fonctions : il +// lance un vrai premier message (StartTurn → generate → runChat) contre un faux +// moteur, relève le corps réellement envoyé, et exige que le bloc système et les +// outils du pré-chauffage du même mode lui soient identiques — pour chaque mode. +func TestPrewarmPrefixMatchesGenerate(t *testing.T) { + // Prompt de preset : c'est le cas le plus courant en Projet et Jean, et il est + // fusionné APRÈS le préambule par InjectSkills. Rapide ne l'utilise pas. + const presetPrompt = "Réponds en français, sans préambule." + + for _, mode := range []string{"project", "fast", "jean"} { + t.Run(mode, func(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "always"); err != nil { + t.Fatal(err) + } + if err := putStr(bkState, "sysprompt", presetPrompt); err != nil { + t.Fatal(err) + } + spy := newEngineSpy(t) + setConvEmpty(t, mode) + rememberChatMode(mode) + + // Ce que le pré-chauffage enverrait pour la conversation vide ouverte. + target, ok := prewarmTarget() + if !ok || target != mode { + t.Fatalf("prewarmTarget = %q, %v ; attendu %q", target, ok, mode) + } + system, tools, ok := prewarmPrefix(prewarmCaps(target)) + if !ok || system == "" || len(tools) == 0 { + t.Fatalf("préfixe vide en mode %s : system=%q tools=%d", mode, system, len(tools)) + } + want := prewarmPayload(resolveChatEndpoint(), system, tools) + + // Le vrai premier message, tel qu'handleChatSend le lance. + body := chatReq{Mode: mode} + m, _ := conv.lockMode(requestedMode(body)) + applyChatMode(&body, m) + if err := conv.StartTurn("bonjour", nil, capsFromBody(body), 0.7); err != nil { + t.Fatal(err) + } + sent := spy.next(t) + conv.Stop() // on a le corps : le tour n'a plus rien à faire + waitConvIdle(t) + + msgs, _ := sent["messages"].([]any) + if len(msgs) < 2 { + t.Fatalf("messages du vrai tour : %v", sent["messages"]) + } + first, _ := msgs[0].(map[string]any) + if first["role"] != "system" { + t.Fatalf("le vrai tour ne commence pas par un system : %v", first) + } + wantMsgs, _ := want["messages"].([]Message) + if got, exp := first["content"], wantMsgs[0].Content; got != exp { + t.Fatalf("bloc système différent du vrai tour\n--- pré-chauffé ---\n%v\n--- réel ---\n%v", exp, got) + } + if got, exp := canon(sent["tools"]), canon(want["tools"]); got != exp { + t.Fatalf("outils différents du vrai tour\n--- pré-chauffés ---\n%s\n--- réels ---\n%s", exp, got) + } + }) + } +} + +// Les modes ne partagent pas leur préfixe : c'est ce qu'ignorait un préfixe bâti +// avec les réglages globaux de la machine. Rapide n'a ni mémoire ni prompt de +// preset, Jean a ses outils et son préambule. +func TestPrewarmPrefixDiffersByMode(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "always"); err != nil { + t.Fatal(err) + } + if err := putStr(bkState, "sysprompt", "Prompt de preset."); err != nil { + t.Fatal(err) + } + sig := map[string]string{} + for _, mode := range []string{"project", "fast", "jean"} { + system, tools, ok := prewarmPrefix(prewarmCaps(mode)) + if !ok { + t.Fatalf("mode %s : rien à chauffer", mode) + } + sig[mode] = prewarmSignature(chatEndpoint{URL: "u", Model: "m"}, system, tools) + } + if sig["project"] == sig["fast"] || sig["project"] == sig["jean"] || sig["fast"] == sig["jean"] { + t.Fatalf("des modes différents donnent le même préfixe : %v", sig) + } +} + +// Modèle de base (ni agent ni mémoire) : aucun préfixe à chauffer, on ne doit pas +// envoyer de requête inutile au moteur — même si l'agent de la machine est actif. +func TestPrewarmPrefixEmptyForRawModel(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if _, _, ok := prewarmPrefix(prewarmCaps("base")); ok { + t.Fatal("pré-chauffage proposé pour le modèle de base, sans système ni outil") + } + // Agent coupé sur la machine : plus rien à chauffer non plus, quel que soit le mode. + if err := setAgentEnabled(false); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "off"); err != nil { + t.Fatal(err) + } + if _, _, ok := prewarmPrefix(prewarmCaps("project")); ok { + t.Fatal("pré-chauffage proposé alors que l'agent est coupé") + } +} + +// La requête envoyée doit être minimale (un seul jeton) et porter le préfixe. +func TestPrewarmPayload(t *testing.T) { + ep := chatEndpoint{URL: "http://127.0.0.1:1/v1/chat/completions", Model: "ajean"} + tools := []Tool{bashTool()} + p := prewarmPayload(ep, "SYSTEM", tools) + if p["max_tokens"] != 1 { + t.Fatalf("max_tokens = %v, attendu 1", p["max_tokens"]) + } + if p["stream"] != false { + t.Fatal("le pré-chauffage doit être non streamé") + } + if p["cache_prompt"] != true { + t.Fatal("cache_prompt doit être vrai pour réutiliser le préfixe") + } + msgs, _ := p["messages"].([]Message) + if len(msgs) != 2 || msgs[0].Role != "system" || msgs[0].Content != "SYSTEM" || msgs[1].Role != "user" { + t.Fatalf("messages inattendus : %+v", msgs) + } + if _, ok := p["tools"].([]Tool); !ok { + t.Fatalf("outils absents du corps : %T", p["tools"]) + } + + // Sans système ni outils, on n'envoie que le message utilisateur minimal. + p = prewarmPayload(ep, "", nil) + msgs, _ = p["messages"].([]Message) + if len(msgs) != 1 || msgs[0].Role != "user" { + t.Fatalf("messages inattendus sans préfixe : %+v", msgs) + } + if _, ok := p["tools"]; ok { + t.Fatal("la clé tools ne doit pas être envoyée quand il n'y a aucun outil") + } +} + +// Seule une conversation active VIDE peut être pré-chauffée : avec un seul créneau +// de cache (-np 1), chauffer pendant une conversation écraserait son cache et le +// message suivant repaierait tout l'historique. +func TestPrewarmTarget(t *testing.T) { + testHome(t) + rememberChatMode("fast") + + // Conversation neuve, dernier mode connu : OK. + setConvEmpty(t, "") + if m, ok := prewarmTarget(); !ok || m != "fast" { + t.Fatalf("conversation vide : %q, %v", m, ok) + } + + // Conversation commencée : jamais. + conv.mu.Lock() + conv.Messages = []Message{{Role: "user", Content: "salut"}} + conv.mu.Unlock() + if m, ok := prewarmTarget(); ok { + t.Fatalf("conversation en cours pré-chauffée (%q)", m) + } + + // Tour en cours (ou tâche de fond qui tient le verrou de génération) : jamais. + resetConvEmpty("") + conv.mu.Lock() + conv.Generating = true + conv.mu.Unlock() + if m, ok := prewarmTarget(); ok { + t.Fatalf("génération en cours pré-chauffée (%q)", m) + } + + // Le mode de la conversation prime sur le dernier mode envoyé. + resetConvEmpty("") + conv.mu.Lock() + conv.Mode = "project" + conv.mu.Unlock() + if m, ok := prewarmTarget(); !ok || m != "project" { + t.Fatalf("mode de la conversation : %q, %v", m, ok) + } + + // Jean : sa conversation, vide, est chauffée avec SON préfixe… + resetConvEmpty("jean") + if m, ok := prewarmTarget(); !ok || m != "jean" { + t.Fatalf("Jean vide : %q, %v", m, ok) + } + // …mais pas quand Jean est le dernier mode et qu'une conversation ordinaire est + // ouverte (le premier message ouvrira la conversation de Jean, d'historique inconnu)… + rememberChatMode("jean") + resetConvEmpty("") + if m, ok := prewarmTarget(); ok { + t.Fatalf("conversation ordinaire pré-chauffée avec le mode Jean (%q)", m) + } + // …ni l'inverse : la conversation de Jean ouverte, un autre mode demandé. + rememberChatMode("fast") + resetConvEmpty("jean") + conv.mu.Lock() + conv.Mode = "" + conv.mu.Unlock() + if m, ok := prewarmTarget(); ok { + t.Fatalf("conversation de Jean pré-chauffée avec le mode %q", m) + } + + // Mode inconnu (rien envoyé encore) : on ne devine pas. + if err := putStr(bkState, lastChatModeKey, ""); err != nil { + t.Fatal(err) + } + resetConvEmpty("") + if m, ok := prewarmTarget(); ok { + t.Fatalf("mode inconnu pré-chauffé (%q)", m) + } +} + +// Bout en bout : LLMPrewarm n'envoie RIEN tant que la conversation est en cours, +// puis une requête quand une conversation vide est ouverte — et ne la rejoue pas +// au sondage suivant. +func TestLLMPrewarmWaitsForEmptyConversation(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "on"); err != nil { + t.Fatal(err) + } + spy := newEngineSpy(t) + resetWarmState(t) + rememberChatMode("project") + setConvEmpty(t, "") + + // Conversation en cours : le moteur ne doit rien recevoir, même moteur sain. + conv.mu.Lock() + conv.Messages = []Message{{Role: "user", Content: "salut"}, {Role: "assistant", Content: "bonjour"}} + conv.mu.Unlock() + LLMPrewarm(true) + spy.none(t, 300*time.Millisecond, "conversation en cours") + + // Redémarrage de l'UI / du moteur pendant la conversation : toujours rien. + LLMPrewarm(false) + LLMPrewarm(true) + spy.none(t, 300*time.Millisecond, "conversation en cours, moteur redémarré") + + // Nouvelle conversation, vide : cette fois on chauffe, avec le préfixe du mode. + resetConvEmpty("") + LLMPrewarm(true) + body := spy.next(t) + if body["max_tokens"] != float64(1) { + t.Fatalf("max_tokens = %v, attendu 1", body["max_tokens"]) + } + system, tools, _ := prewarmPrefix(prewarmCaps("project")) + msgs, _ := body["messages"].([]any) + first, _ := msgs[0].(map[string]any) + if first["role"] != "system" || first["content"] != system { + t.Fatalf("préfixe chauffé inattendu : %v", first) + } + if canon(body["tools"]) != canon(tools) { + t.Fatal("outils chauffés différents de ceux du mode") + } + + // Laisse la goroutine refermer son état (inflight) avant le second appel. + deadline := time.Now().Add(2 * time.Second) + for { + llmWarm.mu.Lock() + inflight := llmWarm.inflight + llmWarm.mu.Unlock() + if !inflight || time.Now().After(deadline) { + break + } + time.Sleep(10 * time.Millisecond) + } + LLMPrewarm(true) + spy.none(t, 300*time.Millisecond, "même préfixe déjà chauffé") +} diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 396e731..96b7e09 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -38,6 +38,11 @@ func handleStatus(w http.ResponseWriter, r *http.Request) { health := false if active { health = healthCheck() + // Moteur prêt : on pré-chauffe le préfixe (système + outils) pour que le + // premier message d'une conversation ne paie plus son préfill. Détaché : + // la construction du préfixe peut sonder MCP/Crawl4AI, elle n'a rien à + // faire dans le temps de réponse de /api/status. + go LLMPrewarm(health) } // Preset externe : pas de service llama-server local, mais le chat est prêt // (il tape vers l'API distante). On l'annonce « actif/prêt » pour que l'UI diff --git a/internal/ajean/web_chat.go b/internal/ajean/web_chat.go index 05f9e4d..0ba5004 100644 --- a/internal/ajean/web_chat.go +++ b/internal/ajean/web_chat.go @@ -155,6 +155,9 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) { } // Mode : celui de la conversation s'il est déjà fixé, sinon celui demandé. mode, fresh := conv.lockMode(requestedMode(body)) + // Dernier mode utilisé : le pré-chauffage s'en sert tant que la conversation + // suivante est vide (voir prewarmTarget). + rememberChatMode(mode) applyChatMode(&body, mode) if mode == "jean" { files = moveUploadsToJean(files) diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index b7f3e09..25823f2 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -351,6 +351,26 @@ func newWebMux() *http.ServeMux { api("/api/tasks/pause", handleTasksPause) api("/api/tasks/run", handleTaskRun) api("/api/tasks/stop", handleTaskStop) + // Pré-chauffe le préfixe dès que le moteur répond, sans attendre le premier + // sondage du navigateur (ui/link peut tourner sans page ouverte). + // • déjà lancé (redémarrage de l'UI seule) : tout de suite ; + // • en cours de chargement (démarrage de la machine) : dès qu'il répond, + // pendant 10 min au plus. Un moteur volontairement arrêté ne coûte que + // des connexions refusées, sans journal. + go func() { + if healthCheck() { + LLMPrewarm(true) + return + } + deadline := time.Now().Add(10 * time.Minute) + for time.Now().Before(deadline) { + time.Sleep(3 * time.Second) + if healthCheck() { + LLMPrewarm(true) + return + } + } + }() StartTaskScheduler() return mux }