mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Synchronisation avec l'amont AJEAN (v0.9.5 → v0.10.7)
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).
This commit is contained in:
12 files changed
+607
-20
No files matched your search
@@ -170,6 +170,13 @@ Héritées d'AJEAN :
|
|||||||
ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les
|
ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les
|
||||||
lancements suivants partent du cache en quelques secondes.
|
lancements suivants partent du cache en quelques secondes.
|
||||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
- **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
|
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
|
||||||
fork la sert autrement que l'amont).
|
fork la sert autrement que l'amont).
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func detectBuildPlan() buildPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vulkan (GPU générique) — utile sur Intel/AMD sans ROCm.
|
// 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.backend = "vulkan"
|
||||||
p.flags = append(p.flags, "-DGGML_VULKAN=ON")
|
p.flags = append(p.flags, "-DGGML_VULKAN=ON")
|
||||||
return p
|
return p
|
||||||
@@ -457,6 +457,33 @@ func hasTool(name string) bool {
|
|||||||
return err == nil
|
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 {
|
func requireTools(tools ...string) error {
|
||||||
missing := missingTools(tools)
|
missing := missingTools(tools)
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
|
|||||||
@@ -234,6 +234,61 @@ func splitArgs(s string) []string {
|
|||||||
return out
|
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é
|
// 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
|
// retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
|
||||||
// seul frein.
|
// seul frein.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,7 +173,11 @@ func machineSystemPrompt(caps Caps) string {
|
|||||||
}
|
}
|
||||||
b.WriteString(".")
|
b.WriteString(".")
|
||||||
if cwd != "" {
|
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()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,6 +269,54 @@ func steerSystem(msgs []Message, hint string) []Message {
|
|||||||
return append([]Message{{Role: "system", Content: hint}}, msgs...)
|
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.
|
// EnabledTools returns the tools to advertise on the next inference call.
|
||||||
func EnabledTools(caps Caps) []Tool {
|
func EnabledTools(caps Caps) []Tool {
|
||||||
tools := []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})
|
messages = append(messages, Message{Role: "user", Content: msg})
|
||||||
}
|
}
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"model": "loki",
|
"model": "loki",
|
||||||
"messages": messages,
|
// 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,
|
"stream": true,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
// 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é).
|
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
|
||||||
"stream_options": map[string]any{"include_usage": true},
|
"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 != "" {
|
if reasoningEffort != "" {
|
||||||
payload["reasoning_effort"] = reasoningEffort
|
payload["reasoning_effort"] = reasoningEffort
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -76,6 +76,12 @@ var configTemplate = []struct{ key, help string }{
|
|||||||
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
|
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
|
||||||
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
|
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
|
||||||
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
|
{"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)"},
|
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
|
||||||
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
|
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
|
||||||
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
|
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
|
||||||
|
|||||||
+144
-8
@@ -2919,6 +2919,42 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Échantillonnage (repris de l'amont AJEAN v0.9.5/v0.9.6) : ces valeurs
|
||||||
|
partent dans CHAQUE requête de complétion, pas sur la ligne de commande
|
||||||
|
du moteur — c'est le seul endroit où elles prennent effet. Un champ vide
|
||||||
|
n'envoie rien, llama-server garde son défaut. Les valeurs recommandées
|
||||||
|
dépendent du modèle (Qwen3.8 en réflexion : temp 1.0, top_p 0.95,
|
||||||
|
top_k 20, min_p 0). -->
|
||||||
|
<div id="m-sampling-row" class="pe-group" style="display:none">
|
||||||
|
<div class="pe-gh">Échantillonnage</div>
|
||||||
|
<div class="pe-list">
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">Température<span class="pe-sub">vide = défaut du modèle</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-temp" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('TEMP', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">top_p<span class="pe-sub">noyau de probabilité</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-topp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('TOP_P', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">top_k<span class="pe-sub">0 = désactivé</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-topk" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="cfgWriteKey('TOP_K', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">min_p<span class="pe-sub">seuil de probabilité</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-minp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('MIN_P', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">Pénalité de présence<span class="pe-sub">pousse vers de nouveaux mots (0 = off)</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-presp" class="pe-val" type="number" min="0" max="2" step="0.1" placeholder="—" oninput="cfgWriteKey('PRESENCE_PENALTY', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">Pénalité de répétition<span class="pe-sub">anti-répétition (1 = neutre)</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-reppen" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('REPEAT_PENALTY', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="m-raw" class="pe-group">
|
<div id="m-raw" class="pe-group">
|
||||||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||||||
<div class="pe-list">
|
<div class="pe-list">
|
||||||
@@ -4085,6 +4121,7 @@ async function openItem(kind, key){
|
|||||||
// Model picker is preset-only: it edits the MODEL= line of the preset.
|
// Model picker is preset-only: it edits the MODEL= line of the preset.
|
||||||
const modelRow = document.getElementById('m-model-row');
|
const modelRow = document.getElementById('m-model-row');
|
||||||
const settingsRow = document.getElementById('m-settings-row');
|
const settingsRow = document.getElementById('m-settings-row');
|
||||||
|
const samplingRow = document.getElementById('m-sampling-row');
|
||||||
const rawHead = document.getElementById('m-raw-head');
|
const rawHead = document.getElementById('m-raw-head');
|
||||||
const rawToggle = document.getElementById('m-raw-toggle');
|
const rawToggle = document.getElementById('m-raw-toggle');
|
||||||
const rawBody = document.getElementById('m-raw-body');
|
const rawBody = document.getElementById('m-raw-body');
|
||||||
@@ -4092,6 +4129,7 @@ async function openItem(kind, key){
|
|||||||
if(kind === 'preset'){
|
if(kind === 'preset'){
|
||||||
modelRow.style.display = 'flex';
|
modelRow.style.display = 'flex';
|
||||||
settingsRow.style.display = 'flex';
|
settingsRow.style.display = 'flex';
|
||||||
|
if(samplingRow) samplingRow.style.display = 'flex';
|
||||||
document.getElementById('m-hf-url').value = '';
|
document.getElementById('m-hf-url').value = '';
|
||||||
resetDlUI();
|
resetDlUI();
|
||||||
resetHfUI();
|
resetHfUI();
|
||||||
@@ -4106,6 +4144,7 @@ async function openItem(kind, key){
|
|||||||
} else {
|
} else {
|
||||||
modelRow.style.display = 'none';
|
modelRow.style.display = 'none';
|
||||||
settingsRow.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.
|
// Page mémoire : le contenu EST le champ principal — affiché en clair.
|
||||||
rawHead.textContent = 'Contenu';
|
rawHead.textContent = 'Contenu';
|
||||||
rawToggle.style.display = 'none';
|
rawToggle.style.display = 'none';
|
||||||
@@ -4721,6 +4760,14 @@ function populateSettings(){
|
|||||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
set('s-ubatch', cfgReadKey('UBATCH'));
|
||||||
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
|
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
|
||||||
set('s-kv', cfgReadKey('KV_TYPE'));
|
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-moe', eaGetValued('--n-cpu-moe'));
|
||||||
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
|
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
|
||||||
setSpecType(eaGetValued('--spec-type'));
|
setSpecType(eaGetValued('--spec-type'));
|
||||||
@@ -5929,6 +5976,89 @@ function syncSendBtn(){
|
|||||||
hint.classList.toggle('waiting', !ready);
|
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
|
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
|
||||||
// piloté par le serveur et rejouable à l'identique.
|
// piloté par le serveur et rejouable à l'identique.
|
||||||
function handleDelta(d){
|
function handleDelta(d){
|
||||||
@@ -5938,6 +6068,7 @@ function handleDelta(d){
|
|||||||
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
|
// 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(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
|
||||||
if(d.caught_up){
|
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
|
// 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).
|
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
|
||||||
setChatLoading(null);
|
setChatLoading(null);
|
||||||
@@ -5954,12 +6085,15 @@ function handleDelta(d){
|
|||||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
// 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
|
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
// 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) -----------------------------------------------
|
// --- Mode code (20-mode.js) -----------------------------------------------
|
||||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); 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.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.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
|
||||||
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
|
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
|
||||||
if(d.user!==undefined){
|
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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// le temps réellement passé et non continuer d'avancer.
|
||||||
T.doneTs = d.ts || T.doneTs || 0;
|
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
|
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
|
||||||
paintTurnClock(); // fige le total au-dessus de la carte
|
paintTurnClock(); // fige le total au-dessus de la carte
|
||||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
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.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
// 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(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(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
|
||||||
if(d.tool_used){
|
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;
|
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); }
|
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);
|
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(); }
|
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||||
return; }
|
return; }
|
||||||
if(d.drop_reasoning){
|
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=''; }
|
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; }
|
return; }
|
||||||
if(d.reasoning_content){
|
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à
|
// 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
|
// 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.
|
// au lieu de concaténer, sinon le texte apparaît en double.
|
||||||
if(d.replace){ T.fullReason=''; resetReasonStats(); }
|
if(d.replace){ smoothSnap(); T.fullReason=''; resetReasonStats(); }
|
||||||
showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason);
|
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
|
// d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs
|
||||||
// tokens d'un coup. Sinon (direct), 1 token, ts0=ts.
|
// 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);
|
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){
|
if(d.content){
|
||||||
removeTyping();
|
removeTyping();
|
||||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); }
|
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); }
|
||||||
if(d.replace){ T.fullContent=''; resetContentStats(); }
|
if(d.replace){ smoothSnap(); T.fullContent=''; resetContentStats(); }
|
||||||
T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent);
|
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);
|
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);
|
labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
|
||||||
return; }
|
return; }
|
||||||
|
|||||||
@@ -889,6 +889,42 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Échantillonnage (repris de l'amont AJEAN v0.9.5/v0.9.6) : ces valeurs
|
||||||
|
partent dans CHAQUE requête de complétion, pas sur la ligne de commande
|
||||||
|
du moteur — c'est le seul endroit où elles prennent effet. Un champ vide
|
||||||
|
n'envoie rien, llama-server garde son défaut. Les valeurs recommandées
|
||||||
|
dépendent du modèle (Qwen3.8 en réflexion : temp 1.0, top_p 0.95,
|
||||||
|
top_k 20, min_p 0). -->
|
||||||
|
<div id="m-sampling-row" class="pe-group" style="display:none">
|
||||||
|
<div class="pe-gh">Échantillonnage</div>
|
||||||
|
<div class="pe-list">
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">Température<span class="pe-sub">vide = défaut du modèle</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-temp" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('TEMP', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">top_p<span class="pe-sub">noyau de probabilité</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-topp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('TOP_P', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">top_k<span class="pe-sub">0 = désactivé</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-topk" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="cfgWriteKey('TOP_K', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">min_p<span class="pe-sub">seuil de probabilité</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-minp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('MIN_P', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">Pénalité de présence<span class="pe-sub">pousse vers de nouveaux mots (0 = off)</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-presp" class="pe-val" type="number" min="0" max="2" step="0.1" placeholder="—" oninput="cfgWriteKey('PRESENCE_PENALTY', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pe-row">
|
||||||
|
<span class="pe-row-l">Pénalité de répétition<span class="pe-sub">anti-répétition (1 = neutre)</span></span>
|
||||||
|
<span class="pe-row-c"><input id="s-reppen" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('REPEAT_PENALTY', this.value)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="m-raw" class="pe-group">
|
<div id="m-raw" class="pe-group">
|
||||||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||||||
<div class="pe-list">
|
<div class="pe-list">
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ async function openItem(kind, key){
|
|||||||
// Model picker is preset-only: it edits the MODEL= line of the preset.
|
// Model picker is preset-only: it edits the MODEL= line of the preset.
|
||||||
const modelRow = document.getElementById('m-model-row');
|
const modelRow = document.getElementById('m-model-row');
|
||||||
const settingsRow = document.getElementById('m-settings-row');
|
const settingsRow = document.getElementById('m-settings-row');
|
||||||
|
const samplingRow = document.getElementById('m-sampling-row');
|
||||||
const rawHead = document.getElementById('m-raw-head');
|
const rawHead = document.getElementById('m-raw-head');
|
||||||
const rawToggle = document.getElementById('m-raw-toggle');
|
const rawToggle = document.getElementById('m-raw-toggle');
|
||||||
const rawBody = document.getElementById('m-raw-body');
|
const rawBody = document.getElementById('m-raw-body');
|
||||||
@@ -108,6 +109,7 @@ async function openItem(kind, key){
|
|||||||
if(kind === 'preset'){
|
if(kind === 'preset'){
|
||||||
modelRow.style.display = 'flex';
|
modelRow.style.display = 'flex';
|
||||||
settingsRow.style.display = 'flex';
|
settingsRow.style.display = 'flex';
|
||||||
|
if(samplingRow) samplingRow.style.display = 'flex';
|
||||||
document.getElementById('m-hf-url').value = '';
|
document.getElementById('m-hf-url').value = '';
|
||||||
resetDlUI();
|
resetDlUI();
|
||||||
resetHfUI();
|
resetHfUI();
|
||||||
@@ -122,6 +124,7 @@ async function openItem(kind, key){
|
|||||||
} else {
|
} else {
|
||||||
modelRow.style.display = 'none';
|
modelRow.style.display = 'none';
|
||||||
settingsRow.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.
|
// Page mémoire : le contenu EST le champ principal — affiché en clair.
|
||||||
rawHead.textContent = 'Contenu';
|
rawHead.textContent = 'Contenu';
|
||||||
rawToggle.style.display = 'none';
|
rawToggle.style.display = 'none';
|
||||||
@@ -737,6 +740,14 @@ function populateSettings(){
|
|||||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
set('s-ubatch', cfgReadKey('UBATCH'));
|
||||||
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
|
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
|
||||||
set('s-kv', cfgReadKey('KV_TYPE'));
|
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-moe', eaGetValued('--n-cpu-moe'));
|
||||||
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
|
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
|
||||||
setSpecType(eaGetValued('--spec-type'));
|
setSpecType(eaGetValued('--spec-type'));
|
||||||
|
|||||||
@@ -257,6 +257,89 @@ function syncSendBtn(){
|
|||||||
hint.classList.toggle('waiting', !ready);
|
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
|
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
|
||||||
// piloté par le serveur et rejouable à l'identique.
|
// piloté par le serveur et rejouable à l'identique.
|
||||||
function handleDelta(d){
|
function handleDelta(d){
|
||||||
@@ -266,6 +349,7 @@ function handleDelta(d){
|
|||||||
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
|
// 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(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
|
||||||
if(d.caught_up){
|
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
|
// 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).
|
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
|
||||||
setChatLoading(null);
|
setChatLoading(null);
|
||||||
@@ -282,12 +366,15 @@ function handleDelta(d){
|
|||||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
// 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
|
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
// 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) -----------------------------------------------
|
// --- Mode code (20-mode.js) -----------------------------------------------
|
||||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); 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.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.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
|
||||||
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
|
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
|
||||||
if(d.user!==undefined){
|
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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// le temps réellement passé et non continuer d'avancer.
|
||||||
T.doneTs = d.ts || T.doneTs || 0;
|
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
|
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
|
||||||
paintTurnClock(); // fige le total au-dessus de la carte
|
paintTurnClock(); // fige le total au-dessus de la carte
|
||||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
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.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
// 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(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(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
|
||||||
if(d.tool_used){
|
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;
|
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); }
|
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);
|
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(); }
|
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||||
return; }
|
return; }
|
||||||
if(d.drop_reasoning){
|
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=''; }
|
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; }
|
return; }
|
||||||
if(d.reasoning_content){
|
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à
|
// 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
|
// 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.
|
// au lieu de concaténer, sinon le texte apparaît en double.
|
||||||
if(d.replace){ T.fullReason=''; resetReasonStats(); }
|
if(d.replace){ smoothSnap(); T.fullReason=''; resetReasonStats(); }
|
||||||
showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason);
|
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
|
// d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs
|
||||||
// tokens d'un coup. Sinon (direct), 1 token, ts0=ts.
|
// 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);
|
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){
|
if(d.content){
|
||||||
removeTyping();
|
removeTyping();
|
||||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); }
|
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); }
|
||||||
if(d.replace){ T.fullContent=''; resetContentStats(); }
|
if(d.replace){ smoothSnap(); T.fullContent=''; resetContentStats(); }
|
||||||
T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent);
|
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);
|
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);
|
labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
|
||||||
return; }
|
return; }
|
||||||
|
|||||||
Reference in new issue
Block a user