From 03ae361ade20d7789a932da85f4a212143d82900 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 09:03:37 +0000 Subject: [PATCH] =?UTF-8?q?Synchronisation=20avec=20l'amont=20AJEAN=20(v0.?= =?UTF-8?q?9.5=20=E2=86=92=20v0.10.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le fork est parti de la v0.9.4 ; l'amont en est à la v0.10.7. Reprise de ce qui manque VRAIMENT ici, en laissant de côté ce que loki a déjà résolu à sa façon (contexte MTP via --parallel 1, jauge de contexte, chrono de tour, vignettes d'images, ligne d'état de génération). Rendu du chat cadencé puis lissé (amont v0.9.5 issue #24, v0.10.5). Chaque token re-parsait le Markdown du bloc ENTIER : du O(n²) qui faisait ramer l'interface sur un long raisonnement — le moteur débitait toujours autant, mais les tokens semblaient arriver au ralenti et un simple rafraîchissement « réparait » tout. Le texte s'accumule désormais et n'est re-rendu qu'à intervalle adaptatif (16 ms sur un petit bloc, jusqu'à 500 ms sur un énorme), soldé à chaque frontière (outil, bascule de rôle, fin de tour, erreur, rejeu). Par-dessus, un lissage d'apparition découple l'arrivée de l'affichage : le décodage spéculatif rend les tokens par rafales, le texte sautait par paquets ; il s'écoule maintenant à cadence régulière. Rejeu exclu — relire un fil ne doit pas être une lente réécriture. Échantillonnage réglable par preset (amont v0.9.5/v0.9.6) : TEMP, TOP_P, TOP_K, MIN_P, PRESENCE_PENALTY, REPEAT_PENALTY, injectés dans chaque requête (donc sans redémarrage du moteur), vide = défaut du serveur. Sans ça seule la température voyageait et le reste retombait sur les défauts de llama.cpp, rarement ceux que recommande le modèle. Différence avec l'amont : REASONING_EFFORT n'est PAS traité là — loki lui réserve un chemin plus riche, et l'écrire ici écraserait `chat_template_kwargs`, donc la consigne « aucune ». Un seul message système, en tête, à l'envoi (amont v0.9.8, issue #26). steerSystem ne couvrait que les consignes de loki ; un historique venu d'ailleurs peut encore en porter deux, et Qwen3.x en --jinja répond alors « System message must be at the beginning ». Copie normalisée : l'historique affiché et persisté garde sa forme. Détection Vulkan multi-distro (amont issues #28, #29) : le chemin Debian codé en dur est invisible sur Fedora/RHEL/Atomic, où le plan de build retombait sur le CPU. ldconfig d'abord, puis les chemins connus. Dossier de travail (amont v0.10.2) : la consigne dit maintenant ce que le dossier EST — l'endroit par défaut de tout ce que le modèle produit — et nomme les dossiers système à ne pas toucher, au lieu d'interdire vaguement d'en sortir. Non repris : les tâches planifiées (~1200 lignes + interface, à décider), et le quoting cmd.exe par .bat temporaire (loki tourne en conteneur Linux). --- README.md | 7 ++ internal/loki/backend_build.go | 29 ++++- internal/loki/backend_config.go | 55 +++++++++ internal/loki/backend_sampling_test.go | 56 +++++++++ internal/loki/chat_tools.go | 6 +- internal/loki/llm_client.go | 59 +++++++++- internal/loki/normalize_system_test.go | 105 +++++++++++++++++ internal/loki/sys_service.go | 6 + internal/loki/ui/index.html | 152 +++++++++++++++++++++++-- internal/loki/ui/src/index.tmpl.html | 36 ++++++ internal/loki/ui/src/js/07-models.js | 11 ++ internal/loki/ui/src/js/09-stream.js | 105 +++++++++++++++-- 12 files changed, 607 insertions(+), 20 deletions(-) create mode 100644 internal/loki/backend_sampling_test.go create mode 100644 internal/loki/normalize_system_test.go diff --git a/README.md b/README.md index 44fad51..c0daa71 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,13 @@ Héritées d'AJEAN : ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les lancements suivants partent du cache en quelques secondes. - **Presets** de configuration par modèle, bench, auto-détection GPU. +- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`, + `min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces + valeurs partent dans **chaque requête** au moteur, pas sur sa ligne de commande : + les changer ne demande donc aucun redémarrage. Un champ laissé vide n'envoie + rien et llama-server garde son défaut — utile parce que le défaut de llama.cpp + (top_k 40, min_p 0.05) est rarement celui que recommande le modèle (Qwen3.8 en + réflexion veut top_k 20, min_p 0, temp 1.0, top_p 0.95). - **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce fork la sert autrement que l'amont). diff --git a/internal/loki/backend_build.go b/internal/loki/backend_build.go index 7dc999e..cb8ca79 100644 --- a/internal/loki/backend_build.go +++ b/internal/loki/backend_build.go @@ -134,7 +134,7 @@ func detectBuildPlan() buildPlan { } // Vulkan (GPU générique) — utile sur Intel/AMD sans ROCm. - if hasTool("glslc") && (isFile("/usr/lib/x86_64-linux-gnu/libvulkan.so.1") || hasTool("vulkaninfo")) { + if hasTool("glslc") && (hasVulkanLib() || hasTool("vulkaninfo")) { p.backend = "vulkan" p.flags = append(p.flags, "-DGGML_VULKAN=ON") return p @@ -457,6 +457,33 @@ func hasTool(name string) bool { return err == nil } +// hasVulkanLib détecte le chargeur Vulkan (libvulkan) sans supposer un unique +// chemin de distribution. Repris de l'amont AJEAN (issues #28, #29) : l'ancienne +// détection testait en dur le chemin Debian multiarch +// (/usr/lib/x86_64-linux-gnu/libvulkan.so.1), invisible sur Fedora/RHEL/Atomic où +// la bibliothèque vit dans /usr/lib64 — le plan de build retombait alors sur le +// CPU sur une machine parfaitement capable. On interroge d'abord ldconfig (la +// source de vérité du linker dynamique), puis une liste de chemins connus. +func hasVulkanLib() bool { + // ldconfig -p liste les bibliothèques du cache, tous chemins confondus. + if out, err := exec.Command("ldconfig", "-p").Output(); err == nil { + if strings.Contains(string(out), "libvulkan.so") { + return true + } + } + for _, p := range []string{ + "/usr/lib/x86_64-linux-gnu/libvulkan.so.1", // Debian/Ubuntu multiarch + "/usr/lib64/libvulkan.so.1", // Fedora/RHEL/Bazzite + "/usr/lib/libvulkan.so.1", // Arch et divers + "/lib64/libvulkan.so.1", + } { + if isFile(p) { + return true + } + } + return false +} + func requireTools(tools ...string) error { missing := missingTools(tools) if len(missing) == 0 { diff --git a/internal/loki/backend_config.go b/internal/loki/backend_config.go index 99d582b..7784fc8 100644 --- a/internal/loki/backend_config.go +++ b/internal/loki/backend_config.go @@ -234,6 +234,61 @@ func splitArgs(s string) []string { return out } +// samplingKeys : les paramètres d'échantillonnage réglables par preset. Repris +// de l'amont AJEAN (v0.9.5/v0.9.6). +// +// Ils sont injectés dans CHAQUE requête de complétion (applySampling) et non +// passés à llama-server au lancement : une valeur présente dans la requête +// l'emporte de toute façon sur le défaut du serveur, donc c'est le seul endroit +// où le réglage prend vraiment effet. Sans ça, seule la température voyageait, et +// top_k/min_p/… retombaient sur les défauts de llama.cpp — rarement ceux que +// recommande le modèle (Qwen3.8 veut top_k 20 / min_p 0, pas 40 / 0.05). +// +// Correspondance clé de config → champ du corps OpenAI. Une valeur vide vaut +// « clé absente » : llama-server garde alors son propre défaut pour ce champ. +var samplingKeys = []struct { + cfg, field string + integer bool +}{ + {"TEMP", "temperature", false}, + {"TOP_P", "top_p", false}, + {"TOP_K", "top_k", true}, + {"MIN_P", "min_p", false}, + {"PRESENCE_PENALTY", "presence_penalty", false}, + {"REPEAT_PENALTY", "repeat_penalty", false}, +} + +// applySampling superpose les paramètres d'échantillonnage du preset au corps +// d'une requête de complétion. Les clés vides sont ignorées (le serveur garde son +// défaut) ; TEMP, s'il est défini, l'emporte sur la température déjà posée dans le +// payload — c'est le réglage recommandé du modèle, et l'interface n'a pas de +// curseur de température qui pourrait le contredire. +// +// Contrairement à l'amont, REASONING_EFFORT n'est PAS traité ici : loki lui +// réserve un chemin plus riche (llm_client.go + llm_effort.go — champ de haut +// niveau, kwargs du gabarit, repli quand le gabarit refuse le niveau). L'écrire +// depuis ici écraserait `chat_template_kwargs`, donc la consigne « aucune » avec. +func applySampling(payload map[string]any) { applySamplingFrom(payload, ReadConfig()) } + +// applySamplingFrom est la partie pure d'applySampling : testable sans base. +func applySamplingFrom(payload map[string]any, cfg map[string]string) { + for _, s := range samplingKeys { + v := strings.TrimSpace(cfg[s.cfg]) + if v == "" { + continue + } + if s.integer { + if n, err := strconv.Atoi(v); err == nil { + payload[s.field] = n + } + continue + } + if f, err := strconv.ParseFloat(v, 64); err == nil { + payload[s.field] = f + } + } +} + // 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/loki/backend_sampling_test.go b/internal/loki/backend_sampling_test.go new file mode 100644 index 0000000..345e9d2 --- /dev/null +++ b/internal/loki/backend_sampling_test.go @@ -0,0 +1,56 @@ +package loki + +import "testing" + +// Le sampling du preset doit se retrouver dans le corps de la requête, avec les +// bons types (top_k entier, le reste flottant), et TEMP écrase la température +// déjà posée. Une clé vide n'ajoute rien : le serveur garde son défaut. +func TestApplySamplingFrom(t *testing.T) { + payload := map[string]any{"temperature": 0.7} + applySamplingFrom(payload, map[string]string{ + "TEMP": "1.0", + "TOP_P": "0.95", + "TOP_K": "20", + "MIN_P": "0", + "REPEAT_PENALTY": "1.05", + "PRESENCE_PENALTY": "", // vide → ignoré + }) + if payload["temperature"] != 1.0 { + t.Errorf("TEMP doit écraser la température : %v", payload["temperature"]) + } + if payload["top_p"] != 0.95 || payload["min_p"] != 0.0 || payload["repeat_penalty"] != 1.05 { + t.Errorf("flottants attendus : %v %v %v", payload["top_p"], payload["min_p"], payload["repeat_penalty"]) + } + if payload["top_k"] != 20 { + t.Errorf("top_k doit être un entier 20, pas %#v", payload["top_k"]) + } + if _, ok := payload["presence_penalty"]; ok { + t.Error("une clé vide ne doit rien envoyer") + } +} + +// L'échantillonnage ne touche PAS au raisonnement : ce chemin appartient à +// llm_client.go/llm_effort.go, qui y met aussi `enable_thinking`. L'amont écrit +// `chat_template_kwargs` ici ; le faire chez nous effacerait la consigne +// « aucune » — donc le modèle réfléchirait alors que l'interface dit l'inverse. +func TestApplySamplingNeTouchePasAuRaisonnement(t *testing.T) { + payload := map[string]any{"chat_template_kwargs": map[string]any{"enable_thinking": false}} + applySamplingFrom(payload, map[string]string{"REASONING_EFFORT": "medium", "TOP_K": "20"}) + kw, ok := payload["chat_template_kwargs"].(map[string]any) + if !ok || kw["enable_thinking"] != false || len(kw) != 1 { + t.Errorf("chat_template_kwargs modifié : %v", payload["chat_template_kwargs"]) + } + if _, ok := payload["reasoning_effort"]; ok { + t.Error("reasoning_effort ne doit pas être posé par l'échantillonnage") + } +} + +// Sans aucune clé de sampling, le payload n'est pas modifié : on ne force pas de +// valeurs qui contrediraient le défaut du modèle. +func TestApplySamplingEmpty(t *testing.T) { + payload := map[string]any{"temperature": 0.7} + applySamplingFrom(payload, map[string]string{}) + if len(payload) != 1 || payload["temperature"] != 0.7 { + t.Errorf("payload modifié à tort : %v", payload) + } +} diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index e236ea2..c6921d7 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -173,7 +173,11 @@ func machineSystemPrompt(caps Caps) string { } b.WriteString(".") if cwd != "" { - b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder loki was launched from.") + // Formulation reprise de l'amont AJEAN (v0.10.2) : dire ce que le dossier + // EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux + // que d'interdire d'en sortir — et nommer les dossiers système coupe court + // aux « installations » en /usr/local/bin qui échouent faute de root. + b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder when the user explicitly named that location.") } return b.String() } diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 0bb10a5..3927941 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -269,6 +269,54 @@ func steerSystem(msgs []Message, hint string) []Message { return append([]Message{{Role: "system", Content: hint}}, msgs...) } +// normalizeSystemMessages garantit un UNIQUE message système, en tête, dans la +// séquence ENVOYÉE au moteur. Repris de l'amont AJEAN (v0.9.8, issue #26). +// +// steerSystem (ci-dessus) empêche déjà loki d'en poser un en cours de route, mais +// il ne couvre que SES propres consignes : un historique venu d'ailleurs (import, +// preset, conversation migrée, agent de code qui compose sa propre séquence) peut +// encore porter deux systèmes, ou un système ailleurs qu'en position 0. Les +// gabarits stricts — Qwen3.x en --jinja — répondent alors « System message must +// be at the beginning », un 500 qui tue le tour sans rien expliquer. +// +// La séquence renvoyée est une COPIE : l'historique d'origine garde sa forme pour +// l'affichage, la persistance et la compaction. Un modèle qui accepte le système +// n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc +// sans risque pour tous, et sans effet sur une conversation déjà normale (payload +// identique, garanti par test). +func normalizeSystemMessages(msgs []Message) []Message { + var sys []string + sawSystem := false + rest := make([]Message, 0, len(msgs)) + for _, m := range msgs { + if m.Role == "system" { + if s, ok := m.Content.(string); ok { + sawSystem = true + if strings.TrimSpace(s) != "" { + sys = append(sys, s) + } + continue + } + // Contenu système non textuel (cas théorique) : on le préserve tel quel + // plutôt que de le perdre. + } + rest = append(rest, m) + } + // Aucun message système : rien à réordonner, on rend l'entrée telle quelle — + // pas de copie inutile sur le chemin le plus fréquent. + if !sawSystem { + return msgs + } + // Des systèmes existaient mais tous vides : on les a retirés (un système vide + // hors position 0 casserait tout autant), sans en réinsérer. + if len(sys) == 0 { + return rest + } + out := make([]Message, 0, len(rest)+1) + out = append(out, Message{Role: "system", Content: strings.Join(sys, "\n\n")}) + return append(out, rest...) +} + // EnabledTools returns the tools to advertise on the next inference call. func EnabledTools(caps Caps) []Tool { tools := []Tool{} @@ -670,8 +718,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps messages = append(messages, Message{Role: "user", Content: msg}) } payload := map[string]any{ - "model": "loki", - "messages": messages, + "model": "loki", + // Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits + // stricts (Qwen3.x) refusent un système ailleurs qu'en position 0. + "messages": normalizeSystemMessages(messages), "stream": true, "temperature": temperature, // include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE @@ -680,6 +730,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // contexte (sinon le system prompt déjà en cache n'est pas recompté). "stream_options": map[string]any{"include_usage": true}, } + // Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui + // l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les + // deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que + // c'est bien loki qui a le dernier mot sur `chat_template_kwargs`. + applySampling(payload) if reasoningEffort != "" { payload["reasoning_effort"] = reasoningEffort } diff --git a/internal/loki/normalize_system_test.go b/internal/loki/normalize_system_test.go new file mode 100644 index 0000000..d65ff14 --- /dev/null +++ b/internal/loki/normalize_system_test.go @@ -0,0 +1,105 @@ +package loki + +import ( + "reflect" + "testing" +) + +// Repris de l'amont AJEAN (v0.9.8) avec le correctif : les tests valent tels +// quels, la fonction étant portée à l'identique. +// +// LE test qui rassure : sur une conversation NORMALE (un seul system en tête, +// suivi de user/assistant/tool, appels d'outils et contenu multimodal compris), +// la normalisation ne doit RIEN changer — même contenu, même ordre. C'est la +// garantie que le fix est invisible pour les 99 % de cas qui marchaient déjà. +func TestNormalizeSystemMessagesNoOpOnNormalConversation(t *testing.T) { + in := []Message{ + {Role: "system", Content: "Tu es Loki."}, + {Role: "user", Content: "cherche la météo"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_1", Type: "function"}}}, + {Role: "tool", Content: "résultat brut", ToolCallID: "call_1"}, + {Role: "assistant", Content: "Il fait beau."}, + // contenu multimodal (image) : Content n'est pas une string + {Role: "user", Content: []any{map[string]any{"type": "text", "text": "et demain ?"}}}, + } + // copie profonde de référence pour détecter toute mutation de l'entrée + ref := append([]Message(nil), in...) + out := normalizeSystemMessages(in) + if !reflect.DeepEqual(out, in) { + t.Errorf("séquence normale modifiée !\navant: %#v\naprès: %#v", in, out) + } + if !reflect.DeepEqual(in, ref) { + t.Errorf("l'entrée a été mutée (interdit) : %#v", in) + } +} + +// L'ordre relatif de tous les messages NON-system (et le lien assistant→tool via +// ToolCallID) doit être préservé quand on ramène un system égaré en tête. +func TestNormalizeSystemMessagesPreservesOrderAndToolLink(t *testing.T) { + in := []Message{ + {Role: "system", Content: "S1"}, + {Role: "user", Content: "q"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_9"}}}, + {Role: "tool", Content: "r", ToolCallID: "call_9"}, + {Role: "system", Content: "n'appelle plus d'outil"}, // égaré, en fin + } + out := normalizeSystemMessages(in) + want := []Message{ + {Role: "system", Content: "S1\n\nn'appelle plus d'outil"}, + {Role: "user", Content: "q"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_9"}}}, + {Role: "tool", Content: "r", ToolCallID: "call_9"}, + } + if !reflect.DeepEqual(out, want) { + t.Errorf("ordre/lien outil cassé :\n%#v", out) + } +} + +// Un system inséré en fin de séquence (relance anti-boucle) ou en double doit +// être ramené en un seul bloc, en tête : sinon les gabarits Qwen3.x renvoient +// « System message must be at the beginning » (issue #26). +func TestNormalizeSystemMessages(t *testing.T) { + sys := func(c string) Message { return Message{Role: "system", Content: c} } + usr := func(c string) Message { return Message{Role: "user", Content: c} } + + firstRoleSystemOnce := func(t *testing.T, out []Message) { + count := 0 + for i, m := range out { + if m.Role == "system" { + count++ + if i != 0 { + t.Errorf("system trouvé en position %d, doit être en 0", i) + } + } + } + if count > 1 { + t.Errorf("%d messages system, doit être fusionné en un seul", count) + } + } + + t.Run("system en fin", func(t *testing.T) { + out := normalizeSystemMessages([]Message{sys("A"), usr("q"), sys("stop")}) + firstRoleSystemOnce(t, out) + if out[0].Content != "A\n\nstop" { + t.Errorf("fusion attendue 'A\\n\\nstop', obtenu %q", out[0].Content) + } + if len(out) != 2 { + t.Fatalf("attendu 2 messages, obtenu %d", len(out)) + } + }) + + t.Run("sans system, inchangé", func(t *testing.T) { + in := []Message{usr("a"), usr("b")} + out := normalizeSystemMessages(in) + if len(out) != 2 || out[0].Role != "user" { + t.Fatalf("séquence sans system ne doit pas changer : %#v", out) + } + }) + + t.Run("system vide ignoré", func(t *testing.T) { + out := normalizeSystemMessages([]Message{sys(" "), usr("q")}) + if len(out) != 1 || out[0].Role != "user" { + t.Fatalf("un system vide ne doit rien ajouter : %#v", out) + } + }) +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 07674b0..2101a77 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -76,6 +76,12 @@ var configTemplate = []struct{ key, help string }{ {"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"}, {"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"}, {"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"}, + {"TEMP", "température d'échantillonnage ; vide = défaut du moteur"}, + {"TOP_P", "noyau de probabilité (top_p) ; vide = défaut du moteur"}, + {"TOP_K", "top_k ; 0 = désactivé, vide = défaut du moteur"}, + {"MIN_P", "seuil de probabilité (min_p) ; vide = défaut du moteur"}, + {"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"}, + {"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"}, {"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"}, diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 2a0c6b1..50c83f3 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2919,6 +2919,42 @@ html[data-files="1"] #files-btn{color:var(--accent)} + + +
Configuration
@@ -4085,6 +4121,7 @@ async function openItem(kind, key){ // Model picker is preset-only: it edits the MODEL= line of the preset. const modelRow = document.getElementById('m-model-row'); const settingsRow = document.getElementById('m-settings-row'); + const samplingRow = document.getElementById('m-sampling-row'); const rawHead = document.getElementById('m-raw-head'); const rawToggle = document.getElementById('m-raw-toggle'); const rawBody = document.getElementById('m-raw-body'); @@ -4092,6 +4129,7 @@ async function openItem(kind, key){ if(kind === 'preset'){ modelRow.style.display = 'flex'; settingsRow.style.display = 'flex'; + if(samplingRow) samplingRow.style.display = 'flex'; document.getElementById('m-hf-url').value = ''; resetDlUI(); resetHfUI(); @@ -4106,6 +4144,7 @@ async function openItem(kind, key){ } else { modelRow.style.display = 'none'; settingsRow.style.display = 'none'; + if(samplingRow) samplingRow.style.display = 'none'; // Page mémoire : le contenu EST le champ principal — affiché en clair. rawHead.textContent = 'Contenu'; rawToggle.style.display = 'none'; @@ -4721,6 +4760,14 @@ function populateSettings(){ set('s-ubatch', cfgReadKey('UBATCH')); set('s-tbatch', cfgReadKey('THREADS_BATCH')); set('s-kv', cfgReadKey('KV_TYPE')); + // Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas + // besoin de redémarrer le moteur pour qu'un changement prenne effet. + set('s-temp', cfgReadKey('TEMP')); + set('s-topp', cfgReadKey('TOP_P')); + set('s-topk', cfgReadKey('TOP_K')); + set('s-minp', cfgReadKey('MIN_P')); + set('s-presp', cfgReadKey('PRESENCE_PENALTY')); + set('s-reppen', cfgReadKey('REPEAT_PENALTY')); set('s-moe', eaGetValued('--n-cpu-moe')); set('s-spec-n', eaGetValued('--spec-draft-n-max')); setSpecType(eaGetValued('--spec-type')); @@ -5929,6 +5976,89 @@ function syncSendBtn(){ hint.classList.toggle('waiting', !ready); } } +// ─── Rendu du bloc en cours : cadencé, puis lissé ──────────────────────────── +// Repris de l'amont AJEAN (v0.9.5 issue #24, puis v0.10.5), adapté. +// +// Re-parser le Markdown du bloc ENTIER à chaque token est en O(n²) : sur un long +// raisonnement (des milliers de tokens) l'interface se met à ramer, les tokens +// semblent arriver au ralenti — alors que le moteur, lui, débite toujours autant +// — et un simple rafraîchissement « répare » tout, puisqu'il rend le bloc une +// seule fois. On coalesce donc : le texte s'accumule (concaténation, quasi +// gratuite) et on ne re-rend qu'à intervalle borné. Le rendu final exact est +// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de +// tour, erreur, caught_up). +let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text} +function scheduleRender(el, text){ + // Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière + // valeur, sinon son ultime bout de texte serait perdu. + if(renderPending && renderPending.el!==el) flushRender(); + renderPending={el, text}; + if(renderTimer) return; + // Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser + // le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher + // 16 ms ≈ 60 img/s, l'apparition reste fluide token par token. Quand le bloc + // devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms : + // le O(n²) est cassé sans jamais figer l'interface. + const delay=Math.min(500, Math.max(16, lastRenderMs*6)); + renderTimer=setTimeout(flushRender, delay); +} +function flushRender(){ + if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; } + const p=renderPending; renderPending=null; + if(!p) return; + const t0=performance.now(); + renderBody(p.el, p.text); + lastRenderMs=performance.now()-t0; +} +// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé, +// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché. +function cancelRender(){ if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; } renderPending=null; } +// Lissage d'apparition (« machine à écrire »). Le décodage spéculatif (MTP) rend +// les tokens PAR RAFALES — plusieurs acceptés d'un coup, puis une pause : le +// texte grandit par paquets et saute à l'écran. On découple donc l'ARRIVÉE (les +// rafales du moteur) de l'AFFICHAGE : `target` = tout ce qui est reçu, `shown` +// avance à cadence régulière (requestAnimationFrame), et on n'affiche que le +// préfixe révélé. Le rendu passe TOUJOURS par scheduleRender pour conserver le +// garde-fou anti-O(n²) ci-dessus. +// UNIQUEMENT EN DIRECT : au rejeu tout est posé d'un bloc, sinon relire un fil +// deviendrait une lente réécriture caractère par caractère. +let smooth=null, smoothLast=0; // {el, target, shown, raf} +function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf); smooth=null; smoothLast=0; } +// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque +// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de +// texte resterait en retard derrière le curseur de révélation. +function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target); } +// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse +// de révélation est proportionnelle au RETARD accumulé, avec une constante de +// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui +// donne un écoulement CONTINU malgré les rafales, et se vide en douceur quand la +// génération s'arrête. Un plancher garantit un progrès même sur un retard +// minuscule, sans jamais figer. +const SMOOTH_TAU=260; // ms — plus grand = plus lisse mais traîne davantage +function smoothStep(ts){ + if(!smooth) return; + if(!smoothLast) smoothLast=ts; + const dt=Math.min(120, ts-smoothLast); smoothLast=ts; + const remaining=smooth.target.length - smooth.shown; + if(remaining<=0){ smooth.raf=null; smoothLast=0; return; } + let adv=remaining*dt/SMOOTH_TAU; // vitesse ∝ retard + if(adv<0.4) adv=0.4; // progrès minimal + smooth.shown=Math.min(smooth.target.length, smooth.shown+Math.ceil(adv)); + scheduleRender(smooth.el, smooth.target.slice(0, smooth.shown)); + smooth.raf=requestAnimationFrame(smoothStep); +} +function smoothFeed(el, target){ + if(smooth && smooth.el!==el) smoothSnap(); // changement de bloc : solder l'ancien + if(!smooth) smooth={el, target, shown:0, raf:null}; + smooth.target=target; + if(!smooth.raf) smooth.raf=requestAnimationFrame(smoothStep); +} +// Rend un bloc en streaming : lissé en direct, instantané au rejeu. +function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoothFeed(el, full); } +// Solde tout ce qui est en vol : le bloc est terminé, la suite (stats, repli, +// bulle d'outil) doit voir le texte complet. +function settleBlocks(){ smoothSnap(); flushRender(); } + // Traite UN événement du flux — même sémantique que l'ancien switch inline, mais // piloté par le serveur et rejouable à l'identique. function handleDelta(d){ @@ -5938,6 +6068,7 @@ function handleDelta(d){ // pendant le rejeu, où les `ts` sont vieux de plusieurs heures. if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts; if(d.caught_up){ + settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). setChatLoading(null); @@ -5954,12 +6085,15 @@ function handleDelta(d){ // serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule // a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils // appartiennent à la discussion, le panneau doit changer avec elle. - if(d.reset!==undefined){ stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; } + if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; } // --- Mode code (20-mode.js) ----------------------------------------------- if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; } if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; } if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; } - if(d.role!==undefined){ ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; } + // Bascule de rôle (mode code) : les blocs en cours appartiennent au rôle + // précédent — on les solde avant de les oublier, sinon leur dernier fragment + // resterait en vol et ne s'afficherait jamais. + if(d.role!==undefined){ settleBlocks(); ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; } if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; } if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; } if(d.user!==undefined){ @@ -5977,7 +6111,7 @@ function handleDelta(d){ // Fin de tour : la discussion vient d'être enregistrée côté serveur — son // titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au // replay, qui rejoue tous les tours passés d'un bloc. - if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer(); + if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer(); // La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer // le temps réellement passé et non continuer d'avancer. T.doneTs = d.ts || T.doneTs || 0; @@ -5985,7 +6119,7 @@ function handleDelta(d){ else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste paintTurnClock(); // fige le total au-dessus de la carte setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; } - if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } + if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } if(d.compacting!==undefined){ setCompacting(d.compacting); return; } if(d.compacted){ setCompacting(false); addCompactMark(); return; } // Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page, @@ -6003,6 +6137,7 @@ function handleDelta(d){ if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); } if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; } if(d.tool_used){ + settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used; if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); } renderToolMsg(T.pendingToolEl, tu); @@ -6012,6 +6147,7 @@ function handleDelta(d){ if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); } return; } if(d.drop_reasoning){ + smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; } return; } if(d.reasoning_content){ @@ -6020,8 +6156,8 @@ function handleDelta(d){ // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. - if(d.replace){ T.fullReason=''; resetReasonStats(); } - showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); + if(d.replace){ smoothSnap(); T.fullReason=''; resetReasonStats(); } + showTyping('reasoning'); T.fullReason+=d.reasoning_content; feedBlock(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1); @@ -6030,8 +6166,8 @@ function handleDelta(d){ if(d.content){ removeTyping(); if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } - if(d.replace){ T.fullContent=''; resetContentStats(); } - T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); + if(d.replace){ smoothSnap(); T.fullContent=''; resetContentStats(); } + T.fullContent+=d.content; feedBlock(T.contentEl, T.fullContent); if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1); labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs); return; } diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 869426c..33127b5 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -889,6 +889,42 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+ + +
Configuration
diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index e1ae994..593370a 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -101,6 +101,7 @@ async function openItem(kind, key){ // Model picker is preset-only: it edits the MODEL= line of the preset. const modelRow = document.getElementById('m-model-row'); const settingsRow = document.getElementById('m-settings-row'); + const samplingRow = document.getElementById('m-sampling-row'); const rawHead = document.getElementById('m-raw-head'); const rawToggle = document.getElementById('m-raw-toggle'); const rawBody = document.getElementById('m-raw-body'); @@ -108,6 +109,7 @@ async function openItem(kind, key){ if(kind === 'preset'){ modelRow.style.display = 'flex'; settingsRow.style.display = 'flex'; + if(samplingRow) samplingRow.style.display = 'flex'; document.getElementById('m-hf-url').value = ''; resetDlUI(); resetHfUI(); @@ -122,6 +124,7 @@ async function openItem(kind, key){ } else { modelRow.style.display = 'none'; settingsRow.style.display = 'none'; + if(samplingRow) samplingRow.style.display = 'none'; // Page mémoire : le contenu EST le champ principal — affiché en clair. rawHead.textContent = 'Contenu'; rawToggle.style.display = 'none'; @@ -737,6 +740,14 @@ function populateSettings(){ set('s-ubatch', cfgReadKey('UBATCH')); set('s-tbatch', cfgReadKey('THREADS_BATCH')); set('s-kv', cfgReadKey('KV_TYPE')); + // Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas + // besoin de redémarrer le moteur pour qu'un changement prenne effet. + set('s-temp', cfgReadKey('TEMP')); + set('s-topp', cfgReadKey('TOP_P')); + set('s-topk', cfgReadKey('TOP_K')); + set('s-minp', cfgReadKey('MIN_P')); + set('s-presp', cfgReadKey('PRESENCE_PENALTY')); + set('s-reppen', cfgReadKey('REPEAT_PENALTY')); set('s-moe', eaGetValued('--n-cpu-moe')); set('s-spec-n', eaGetValued('--spec-draft-n-max')); setSpecType(eaGetValued('--spec-type')); diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 0a051e9..12b11c7 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -257,6 +257,89 @@ function syncSendBtn(){ hint.classList.toggle('waiting', !ready); } } +// ─── Rendu du bloc en cours : cadencé, puis lissé ──────────────────────────── +// Repris de l'amont AJEAN (v0.9.5 issue #24, puis v0.10.5), adapté. +// +// Re-parser le Markdown du bloc ENTIER à chaque token est en O(n²) : sur un long +// raisonnement (des milliers de tokens) l'interface se met à ramer, les tokens +// semblent arriver au ralenti — alors que le moteur, lui, débite toujours autant +// — et un simple rafraîchissement « répare » tout, puisqu'il rend le bloc une +// seule fois. On coalesce donc : le texte s'accumule (concaténation, quasi +// gratuite) et on ne re-rend qu'à intervalle borné. Le rendu final exact est +// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de +// tour, erreur, caught_up). +let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text} +function scheduleRender(el, text){ + // Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière + // valeur, sinon son ultime bout de texte serait perdu. + if(renderPending && renderPending.el!==el) flushRender(); + renderPending={el, text}; + if(renderTimer) return; + // Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser + // le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher + // 16 ms ≈ 60 img/s, l'apparition reste fluide token par token. Quand le bloc + // devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms : + // le O(n²) est cassé sans jamais figer l'interface. + const delay=Math.min(500, Math.max(16, lastRenderMs*6)); + renderTimer=setTimeout(flushRender, delay); +} +function flushRender(){ + if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; } + const p=renderPending; renderPending=null; + if(!p) return; + const t0=performance.now(); + renderBody(p.el, p.text); + lastRenderMs=performance.now()-t0; +} +// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé, +// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché. +function cancelRender(){ if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; } renderPending=null; } +// Lissage d'apparition (« machine à écrire »). Le décodage spéculatif (MTP) rend +// les tokens PAR RAFALES — plusieurs acceptés d'un coup, puis une pause : le +// texte grandit par paquets et saute à l'écran. On découple donc l'ARRIVÉE (les +// rafales du moteur) de l'AFFICHAGE : `target` = tout ce qui est reçu, `shown` +// avance à cadence régulière (requestAnimationFrame), et on n'affiche que le +// préfixe révélé. Le rendu passe TOUJOURS par scheduleRender pour conserver le +// garde-fou anti-O(n²) ci-dessus. +// UNIQUEMENT EN DIRECT : au rejeu tout est posé d'un bloc, sinon relire un fil +// deviendrait une lente réécriture caractère par caractère. +let smooth=null, smoothLast=0; // {el, target, shown, raf} +function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf); smooth=null; smoothLast=0; } +// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque +// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de +// texte resterait en retard derrière le curseur de révélation. +function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target); } +// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse +// de révélation est proportionnelle au RETARD accumulé, avec une constante de +// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui +// donne un écoulement CONTINU malgré les rafales, et se vide en douceur quand la +// génération s'arrête. Un plancher garantit un progrès même sur un retard +// minuscule, sans jamais figer. +const SMOOTH_TAU=260; // ms — plus grand = plus lisse mais traîne davantage +function smoothStep(ts){ + if(!smooth) return; + if(!smoothLast) smoothLast=ts; + const dt=Math.min(120, ts-smoothLast); smoothLast=ts; + const remaining=smooth.target.length - smooth.shown; + if(remaining<=0){ smooth.raf=null; smoothLast=0; return; } + let adv=remaining*dt/SMOOTH_TAU; // vitesse ∝ retard + if(adv<0.4) adv=0.4; // progrès minimal + smooth.shown=Math.min(smooth.target.length, smooth.shown+Math.ceil(adv)); + scheduleRender(smooth.el, smooth.target.slice(0, smooth.shown)); + smooth.raf=requestAnimationFrame(smoothStep); +} +function smoothFeed(el, target){ + if(smooth && smooth.el!==el) smoothSnap(); // changement de bloc : solder l'ancien + if(!smooth) smooth={el, target, shown:0, raf:null}; + smooth.target=target; + if(!smooth.raf) smooth.raf=requestAnimationFrame(smoothStep); +} +// Rend un bloc en streaming : lissé en direct, instantané au rejeu. +function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoothFeed(el, full); } +// Solde tout ce qui est en vol : le bloc est terminé, la suite (stats, repli, +// bulle d'outil) doit voir le texte complet. +function settleBlocks(){ smoothSnap(); flushRender(); } + // Traite UN événement du flux — même sémantique que l'ancien switch inline, mais // piloté par le serveur et rejouable à l'identique. function handleDelta(d){ @@ -266,6 +349,7 @@ function handleDelta(d){ // pendant le rejeu, où les `ts` sont vieux de plusieurs heures. if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts; if(d.caught_up){ + settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). setChatLoading(null); @@ -282,12 +366,15 @@ function handleDelta(d){ // serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule // a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils // appartiennent à la discussion, le panneau doit changer avec elle. - if(d.reset!==undefined){ stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; } + if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; } // --- Mode code (20-mode.js) ----------------------------------------------- if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; } if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; } if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; } - if(d.role!==undefined){ ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; } + // Bascule de rôle (mode code) : les blocs en cours appartiennent au rôle + // précédent — on les solde avant de les oublier, sinon leur dernier fragment + // resterait en vol et ne s'afficherait jamais. + if(d.role!==undefined){ settleBlocks(); ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; } if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; } if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; } if(d.user!==undefined){ @@ -305,7 +392,7 @@ function handleDelta(d){ // Fin de tour : la discussion vient d'être enregistrée côté serveur — son // titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au // replay, qui rejoue tous les tours passés d'un bloc. - if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer(); + if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer(); // La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer // le temps réellement passé et non continuer d'avancer. T.doneTs = d.ts || T.doneTs || 0; @@ -313,7 +400,7 @@ function handleDelta(d){ else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste paintTurnClock(); // fige le total au-dessus de la carte setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; } - if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } + if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } if(d.compacting!==undefined){ setCompacting(d.compacting); return; } if(d.compacted){ setCompacting(false); addCompactMark(); return; } // Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page, @@ -331,6 +418,7 @@ function handleDelta(d){ if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); } if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; } if(d.tool_used){ + settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used; if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); } renderToolMsg(T.pendingToolEl, tu); @@ -340,6 +428,7 @@ function handleDelta(d){ if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); } return; } if(d.drop_reasoning){ + smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; } return; } if(d.reasoning_content){ @@ -348,8 +437,8 @@ function handleDelta(d){ // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. - if(d.replace){ T.fullReason=''; resetReasonStats(); } - showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); + if(d.replace){ smoothSnap(); T.fullReason=''; resetReasonStats(); } + showTyping('reasoning'); T.fullReason+=d.reasoning_content; feedBlock(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1); @@ -358,8 +447,8 @@ function handleDelta(d){ if(d.content){ removeTyping(); if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } - if(d.replace){ T.fullContent=''; resetContentStats(); } - T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); + if(d.replace){ smoothSnap(); T.fullContent=''; resetContentStats(); } + T.fullContent+=d.content; feedBlock(T.contentEl, T.fullContent); if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1); labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs); return; }