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}