From b814862f40584a28f6ff502cd4d9bf8444c7f7a1 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 20 Aug 2026 15:50:51 +0200 Subject: [PATCH] =?UTF-8?q?Nom=20du=20mod=C3=A8le=20affich=C3=A9=20=C3=A0?= =?UTF-8?q?=20c=C3=B4t=C3=A9=20de=20=C2=AB=20Loki=20=C2=BB=20sur=20chaque?= =?UTF-8?q?=20r=C3=A9ponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le nom du modèle (fichier .gguf, sans dossier ni extension) est journalisé avec la borne de tour côté serveur : chaque réponse garde le modèle qui l'a réellement produite, y compris après un rechargement de page (rejeu du journal) et même si un autre modèle est chargé depuis. Côté UI, une pastille discrète s'affiche dans l'étiquette de la bulle de réponse. Elle vit dans dataset.model pour survivre au repeint des libellés lors d'un changement d'identité (applyIdentity). Vérifié en conditions réelles (moteur simulé, rechargement en pleine génération) : badge, tok/s des bulles et chrono « en cours » sont bien rejoués après un refresh. Co-Authored-By: Claude Fable 5 --- internal/loki/chat_conversation.go | 23 ++++++++++++++++ internal/loki/ui/index.html | 36 ++++++++++++++++++++++++-- internal/loki/ui/src/js/09-stream.js | 17 ++++++++++-- internal/loki/ui/src/js/17-identity.js | 15 +++++++++++ internal/loki/ui/src/styles.css | 4 +++ 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 5f0b403..1f82a02 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -376,6 +376,13 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem // si le process meurt en pleine génération (crash, restart après MAJ), le // message de l'utilisateur survit au lieu de disparaître avec le tour. delta := map[string]any{"user": text} + // Nom du modèle qui va produire la réponse : journalisé avec la borne de + // tour, donc rejoué au chargement — la carte de réponse garde son modèle + // après un rafraîchissement, et un vieux tour garde le modèle de l'époque, + // pas celui chargé aujourd'hui. + if m := chatModelName(); m != "" { + delta["model"] = m + } if len(files) > 0 { delta["files"] = files } @@ -393,6 +400,22 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem return nil } +// chatModelName renvoie le nom lisible du modèle configuré : fichier sans +// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun. +func chatModelName() string { + m := strings.TrimSpace(ReadConfig()["MODEL"]) + if m == "" { + return "" + } + if i := strings.LastIndexAny(m, "/\\"); i >= 0 { + m = m[i+1:] + } + if strings.HasSuffix(strings.ToLower(m), ".gguf") { + m = m[:len(m)-len(".gguf")] + } + return m +} + // generate exécute un tour complet et journalise chaque événement. Détaché : la // fermeture du navigateur n'a aucun effet ici, seul /stop (cancel) l'interrompt. // epoch est capturé au StartTurn : si un Reset survient pendant la génération, diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 732a233..204a216 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2052,6 +2052,10 @@ html[data-files="1"] #files-btn{color:var(--accent)} #criteria-list li.crit-pending{color:var(--dim)} .rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle} +/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris, + pas de capitales forcées) et borné : un nom de .gguf peut être très long, + il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */ +.msg .label .modelbadge{display:inline-block;margin-left:6px;padding:0 5px;border:1px solid var(--border);border-radius:3px;color:var(--dim);font-size:9px;letter-spacing:.03em;text-transform:none;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle} .msg[data-agent-role="verifier"]{border-left:2px solid var(--accent)} .askcard .body{display:flex;flex-direction:column;gap:8px} @@ -6064,7 +6068,7 @@ function confirmPending(text){ let REPLAYING=true; // État de rendu du tour courant, délimité par les événements user / turn_done. let T=null; -function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'' }; } +function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; } newTurn(); // --- Temps de travail du tour ------------------------------------------------ @@ -6240,6 +6244,15 @@ function labelTokens(el, role, n, firstTs, lastTs){ if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · ')); else paintStats(el, m); } +// Badge du modèle sur une bulle de réponse : le nom voyage avec la borne de +// tour (T.model), on le dépose dans dataset.model pour qu'applyIdentity — qui +// repeint les libellés de zéro — puisse le reposer (voir paintLabel). +function tagModel(el){ + if(!el || !T.model) return; + el.dataset.model = T.model; + const l = el.querySelector('.label'); + if(l && !l.querySelector('.modelbadge') && typeof modelBadge==='function') l.appendChild(modelBadge(T.model)); +} // Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons // (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible). // L'état final est appliqué une seule fois au caught_up via syncSendBtn(). @@ -6392,6 +6405,10 @@ function handleDelta(d){ if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; } if(d.user!==undefined){ newTurn(); + // Modèle du tour : posé par le serveur sur la borne de tour (journalisée), + // donc présent au rejeu comme en direct. Repris par tagModel sur chaque + // bulle de réponse du tour. + T.model = d.model || ''; let el=PENDING; if(!confirmPending(d.user)) el=addMsg('user', d.user); // Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en @@ -6459,7 +6476,7 @@ function handleDelta(d){ return; } if(d.content){ removeTyping(); - if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } + if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); tagModel(T.contentEl); T.fullContent=''; resetContentStats(); } 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); @@ -8163,6 +8180,21 @@ function paintLabel(label, role){ const who = document.createElement('span'); who.className = 'who'; who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki'; label.appendChild(av); label.appendChild(who); + // Badge du modèle : quelle IA a produit CETTE réponse. Le nom vit dans + // dataset.model (posé par tagModel, 09-stream.js) — c'est ce qui le fait + // survivre au repeint des libellés lors d'un changement d'identité. + if(role === 'assistant' && label.parentElement && label.parentElement.dataset.model){ + label.appendChild(modelBadge(label.parentElement.dataset.model)); + } +} + +// modelBadge fabrique la pastille du nom de modèle (partagée paintLabel/tagModel). +function modelBadge(name){ + const b = document.createElement('span'); + b.className = 'modelbadge'; + b.textContent = name; + b.title = 'modèle : ' + name; + return b; } async function loadIdentity(){ diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 12b11c7..7366772 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -51,7 +51,7 @@ function confirmPending(text){ let REPLAYING=true; // État de rendu du tour courant, délimité par les événements user / turn_done. let T=null; -function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'' }; } +function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; } newTurn(); // --- Temps de travail du tour ------------------------------------------------ @@ -227,6 +227,15 @@ function labelTokens(el, role, n, firstTs, lastTs){ if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · ')); else paintStats(el, m); } +// Badge du modèle sur une bulle de réponse : le nom voyage avec la borne de +// tour (T.model), on le dépose dans dataset.model pour qu'applyIdentity — qui +// repeint les libellés de zéro — puisse le reposer (voir paintLabel). +function tagModel(el){ + if(!el || !T.model) return; + el.dataset.model = T.model; + const l = el.querySelector('.label'); + if(l && !l.querySelector('.modelbadge') && typeof modelBadge==='function') l.appendChild(modelBadge(T.model)); +} // Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons // (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible). // L'état final est appliqué une seule fois au caught_up via syncSendBtn(). @@ -379,6 +388,10 @@ function handleDelta(d){ if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; } if(d.user!==undefined){ newTurn(); + // Modèle du tour : posé par le serveur sur la borne de tour (journalisée), + // donc présent au rejeu comme en direct. Repris par tagModel sur chaque + // bulle de réponse du tour. + T.model = d.model || ''; let el=PENDING; if(!confirmPending(d.user)) el=addMsg('user', d.user); // Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en @@ -446,7 +459,7 @@ function handleDelta(d){ return; } if(d.content){ removeTyping(); - if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } + if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); tagModel(T.contentEl); T.fullContent=''; resetContentStats(); } 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); diff --git a/internal/loki/ui/src/js/17-identity.js b/internal/loki/ui/src/js/17-identity.js index 996cb09..8ff40d5 100644 --- a/internal/loki/ui/src/js/17-identity.js +++ b/internal/loki/ui/src/js/17-identity.js @@ -25,6 +25,21 @@ function paintLabel(label, role){ const who = document.createElement('span'); who.className = 'who'; who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki'; label.appendChild(av); label.appendChild(who); + // Badge du modèle : quelle IA a produit CETTE réponse. Le nom vit dans + // dataset.model (posé par tagModel, 09-stream.js) — c'est ce qui le fait + // survivre au repeint des libellés lors d'un changement d'identité. + if(role === 'assistant' && label.parentElement && label.parentElement.dataset.model){ + label.appendChild(modelBadge(label.parentElement.dataset.model)); + } +} + +// modelBadge fabrique la pastille du nom de modèle (partagée paintLabel/tagModel). +function modelBadge(name){ + const b = document.createElement('span'); + b.className = 'modelbadge'; + b.textContent = name; + b.title = 'modèle : ' + name; + return b; } async function loadIdentity(){ diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index efd72b1..1c89e74 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -2021,6 +2021,10 @@ html[data-files="1"] #files-btn{color:var(--accent)} #criteria-list li.crit-pending{color:var(--dim)} .rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle} +/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris, + pas de capitales forcées) et borné : un nom de .gguf peut être très long, + il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */ +.msg .label .modelbadge{display:inline-block;margin-left:6px;padding:0 5px;border:1px solid var(--border);border-radius:3px;color:var(--dim);font-size:9px;letter-spacing:.03em;text-transform:none;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle} .msg[data-agent-role="verifier"]{border-left:2px solid var(--accent)} .askcard .body{display:flex;flex-direction:column;gap:8px}