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; }