From 6aafeeea984b28254cb70b463f6eac5de19a20c2 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:08:31 +0200 Subject: [PATCH] =?UTF-8?q?Revert=20"R=C3=A9sultats=20d'outils=20:=20aper?= =?UTF-8?q?=C3=A7u=20+=20=C2=AB=20voir=20plus=20=C2=BB,=20vrai=20compteur,?= =?UTF-8?q?=20MCP=20complet"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 91d179661588775f3075e7f55adf46ec5b20f25f. --- internal/loki/chat_conversation.go | 10 --- internal/loki/chat_internet_tools.go | 3 +- internal/loki/llm_client.go | 56 ++++----------- internal/loki/mcp_client.go | 10 +-- internal/loki/ui/index.html | 86 ++--------------------- internal/loki/ui/src/js/08-chat-render.js | 72 ++----------------- internal/loki/ui/src/styles.css | 14 ---- internal/loki/web_chat.go | 29 -------- internal/loki/web_server.go | 13 ++-- 9 files changed, 37 insertions(+), 256 deletions(-) diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index cd65e72..0273f9c 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -616,16 +616,6 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa "name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label, "result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing, } - // Taille réelle du résultat (l'aperçu n'en montre qu'un début) : le - // compteur ~N tok de la bulle affiche la vraie valeur, pas celle de - // l'aperçu. - if ev.ToolUsed.ResultChars > 0 { - tu["result_chars"] = ev.ToolUsed.ResultChars - } - // Référence pour charger le reste à la demande (bouton « voir plus »). - if ev.ToolUsed.ResultID != "" { - tu["result_id"] = ev.ToolUsed.ResultID - } // Corps en cours de frappe : transitoire (l'état final est le diff), on // ne l'ajoute que quand il est là pour ne pas gonfler chaque événement. if ev.ToolUsed.Body != "" { diff --git a/internal/loki/chat_internet_tools.go b/internal/loki/chat_internet_tools.go index d0392dc..80719cb 100644 --- a/internal/loki/chat_internet_tools.go +++ b/internal/loki/chat_internet_tools.go @@ -97,8 +97,7 @@ func webGrepTool() Tool { // ─── exécution des outils (appelée par le dispatch de llm_client.go) ─────────────── // webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme -// toolMaxOutput (8000) pour le shell. (La mémoire et MCP, eux, renvoient tout -// au modèle et laissent l'UI charger l'aperçu.) Sans ce +// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce // plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000 // caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui // déclenchait des compactages en cascade au milieu du raisonnement. diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index f92ecfe..2d5121b 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -547,50 +547,24 @@ type ToolUsedEvent struct { // pensait à recopier la ligne markdown rendue par l'outil : un petit modèle // l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée. Image string - // ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en - // porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle, qui affichait - // sinon la taille de l'aperçu — toujours le même chiffre, quelle que soit la - // page lue. - ResultChars int - // ResultID : identifiant (tool_call_id) avec lequel l'UI CHARGE le résultat - // complet à la demande via /api/chat/tool-result. Le flux ne transporte que - // l'aperçu ; le reste ne descend qu'au clic sur « voir plus ». Vide = Result - // est déjà complet (rien à charger). - ResultID string } -// toolPreviewChars borne l'aperçu de résultat d'outil envoyé à l'UI. Le résultat -// COMPLET reste côté serveur (conv.Messages) et se charge à la demande. +// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI. +// Aligné sur le plus haut plafond côté modèle (mcpMaxOutput = 12000 ; shell et +// web = 8000) : le modèle et l'UI voient donc la même chose, et l'étiquette +// « ~N tok » de la bulle dit la VRAIE taille du résultat. // -// Avant, l'UI recevait tout le résultat tronqué à 12000 caractères : chaque tour -// poussait des dizaines de kilo-octets dans le flux SSE et dans le journal -// persisté, pour un bloc que personne ne déplie une fois sur dix. -const toolPreviewChars = 1600 +// Avant, cette borne était à 4000 : toute page web un peu longue s'affichait +// « ~1004 tok » — la valeur du plafond, pas celle de la page. Le compteur +// mentait, et il mentait toujours avec le même chiffre. +const shownDisplayMax = 12000 -// toolResultPreview renvoie (aperçu, taille réelle en runes, coupé ?). -func toolResultPreview(s string) (string, int, bool) { - r := []rune(s) - if len(r) <= toolPreviewChars { - return s, len(r), false +// shownResult prépare un résultat d'outil pour l'affichage. +func shownResult(s string) string { + if r := []rune(s); len(r) > shownDisplayMax { + return string(r[:shownDisplayMax]) + "\n…[tronqué]" } - return string(r[:toolPreviewChars]), len(r), true -} - -// fillToolResult pose sur l'événement l'aperçu envoyé à l'UI, la taille réelle -// (pour le compteur ~N tok) et, si le résultat est coupé et qu'on a un id, la -// référence pour charger le reste à la demande. Sans id utilisable, on envoie le -// résultat entier : mieux vaut un flux plus lourd qu'un bloc qu'on ne peut plus -// déplier. -func fillToolResult(ev *ToolUsedEvent, result, tcID string) *ToolUsedEvent { - prev, n, cut := toolResultPreview(result) - ev.ResultChars = n - if cut && tcID != "" { - ev.Result = prev - ev.ResultID = tcID - } else { - ev.Result = result - } - return ev + return s } // dedupableTool indique si un appel RIGOUREUSEMENT identique (même outil, mêmes @@ -1422,7 +1396,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if prev, seen := doneCalls[callKey]; seen && dedupableTool(tc.Function.Name) { repeatCount[callKey]++ result = repeatedCallResult(prev, repeatCount[callKey]) - cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result, tc.ID)}) + cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}}) toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} messages = append(messages, toolMsg) extra = append(extra, toolMsg) @@ -1616,7 +1590,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if tc.Function.Name == "web_screenshot" { shot = capturedRelPath(result) } - cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result, tc.ID)}) + cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}}) toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} messages = append(messages, toolMsg) extra = append(extra, toolMsg) diff --git a/internal/loki/mcp_client.go b/internal/loki/mcp_client.go index f55b54e..4d16c9e 100644 --- a/internal/loki/mcp_client.go +++ b/internal/loki/mcp_client.go @@ -48,6 +48,8 @@ const ( mcpConnectTimeoutCold = 3 * time.Minute // mcpCallTimeout borne un appel d'outil MCP. mcpCallTimeout = 120 * time.Second + // mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput). + mcpMaxOutput = 12000 ) // mcpSession est une connexion vivante à un serveur MCP. @@ -396,12 +398,10 @@ func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) { if err != nil { return "", err } - // Le résultat complet part au modèle (même règle que la lecture mémoire) : - // plus de troncature ici. Un serveur MCP est configuré EXPRÈS par le - // propriétaire de la machine pour ce qu'il rend ; le couper au milieu, c'est - // rendre la réponse inutilisable. L'UI, elle, n'affiche qu'un aperçu et - // charge le reste à la demande (voir fillToolResult et /api/chat/tool-result). out := flattenMCPContent(res) + if r := []rune(out); len(r) > mcpMaxOutput { + out = string(r[:mcpMaxOutput]) + "\n…[tronqué]" + } if res.IsError { return "[l'outil a renvoyé une erreur]\n" + out, nil } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 604cd2f..cab1352 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -479,20 +479,6 @@ button:disabled{opacity:.5;cursor:not-allowed} .diff-count .a{color:var(--add)} .diff-count .d{color:var(--del)} .msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px} -/* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre - ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre - scrolle (max-height), donc la barre est portée par le bloc-parent NON - scrollant (.tool-block) : elle reste au coin quoi qu'on scrolle. */ -.msg.tool .tool-block{position:relative} -.msg.tool .tool-block .tool-actions{position:absolute;bottom:7px;right:7px;display:flex;gap:6px;align-items:center;z-index:1} -.msg.tool .tool-actions .copybtn{position:static;bottom:auto;right:auto} -.msg.tool .tool-more{padding:3px 8px;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;opacity:.5;transition:opacity .15s,color .15s,border-color .15s} -.msg.tool .tool-block:hover .tool-actions .tool-more,.msg.tool .tool-block:hover .tool-actions .copybtn{opacity:1} -.msg.tool .tool-more:hover{border-color:var(--accent);color:var(--accent)} -.msg.tool .tool-more:disabled{cursor:default} -/* Déplié : on lève le plafond de hauteur pour voir VRAIMENT plus, au lieu de - rester enfermé dans une fenêtre de 280 px qu'il faut scroller. */ -.msg.tool pre.expanded{max-height:none} .msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px} .msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre} .msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite} @@ -6681,10 +6667,7 @@ function renderToolMsg(el, tu){ meta = meta || {lbl:'mémoire', head:'mémoire'}; let lbl = meta.lbl; // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). - // Taille RÉELLE (result_chars) quand le flux n'a envoyé qu'un aperçu — sinon - // la bulle affichait toujours le même chiffre, celui du plafond d'aperçu. - const realChars = tu.result_chars || (tu.result ? tu.result.length : 0); - if(realChars){ lbl += ' · ~' + Math.max(1, Math.round(realChars/4)) + ' tok'; } + if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; } setLabel(el, lbl); // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe) // reporté sur l'étiquette, pour rester lisible bulle repliée. @@ -6767,49 +6750,8 @@ function renderToolMsg(el, tu){ const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse'; body.appendChild(sub); const pre=document.createElement('pre'); - const code=document.createElement('code'); - const preview=tu.result, rid=tu.result_id, PREVIEW=1600; - // Deux cas : - // - result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé), - // « voir plus » CHARGE le reste à la demande via l'API ; - // - sinon : le résultat est déjà complet ; s'il est long, « voir plus » le - // déplie localement (rien à charger). - if(rid){ - code.textContent=preview+'…'; - pre.appendChild(code); - const more=document.createElement('button'); - more.className='tool-more'; more.type='button'; more.textContent='voir plus'; - let full=null, open=false; - more.onclick=async(e)=>{ e.stopPropagation(); - if(full===null){ - more.disabled=true; more.textContent='…'; - // Résultat introuvable côté serveur (compacté, autre discussion) : on - // garde l'aperçu plutôt que de vider la bulle. - try{ const j=await jget('/api/chat/tool-result?id='+encodeURIComponent(rid)); full=(j&&j.result)||preview; } - catch(_){ full=preview; } - more.disabled=false; - } - open=!open; - code.textContent = open ? full : preview+'…'; - pre.classList.toggle('expanded', open); - more.textContent = open ? 'voir moins' : 'voir plus'; - }; - body.appendChild(toolBlock(pre, more)); - } else if(preview.length>PREVIEW){ - code.textContent=preview.slice(0,PREVIEW)+'…'; - pre.appendChild(code); - const more=document.createElement('button'); - more.className='tool-more'; more.type='button'; more.textContent='voir plus'; - let open=false; - more.onclick=(e)=>{ e.stopPropagation(); open=!open; - code.textContent = open ? preview : preview.slice(0,PREVIEW)+'…'; - pre.classList.toggle('expanded', open); - more.textContent = open ? 'voir moins' : 'voir plus'; - }; - body.appendChild(toolBlock(pre, more)); - } else { - code.textContent=preview; pre.appendChild(code); body.appendChild(pre); - } + const code=document.createElement('code'); code.textContent=tu.result; + pre.appendChild(code); body.appendChild(pre); } else if(!tu.done && !tu.typing){ const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…'; body.appendChild(wait); @@ -6821,26 +6763,10 @@ function renderToolMsg(el, tu){ const bw = el.querySelector('.bodywrap'); if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; } } -// Bloc d'un résultat d'outil : le pre scrolle (max-height), donc la barre -// d'actions vit sur un bloc-parent NON scrollant, ancrée en bas à droite — elle -// reste au coin quoi qu'on scrolle dans le résultat. La barre réunit « voir -// plus » (passé ici) et, ajouté ensuite par addCopyButtons, « copier ». -function toolBlock(pre, more){ - const block=document.createElement('div'); block.className='tool-block'; - block.appendChild(pre); - const bar=document.createElement('div'); bar.className='tool-actions'; - bar.appendChild(more); - block.appendChild(bar); - return block; -} // Inject a "copier" button into every
 code block (idempotent).
 function addCopyButtons(root){
   root.querySelectorAll('pre').forEach(pre=>{
-    // Résultat d'outil : le pre est enrobé dans .tool-block et la barre
-    // d'actions est SŒUR du pre (pas dedans) ; on cherche donc le copier dans
-    // ce périmètre.
-    const scope = pre.closest('.tool-block') || pre;
-    if(scope.querySelector('.copybtn')) return;
+    if(pre.querySelector('.copybtn')) return;
     // Pas de bouton copier sur un diff : on copierait les préfixes + / - .
     if(pre.classList.contains('diff')) return;
     const btn=document.createElement('button');
@@ -6853,9 +6779,7 @@ function addCopyButtons(root){
       btn.textContent='copié ✓'; btn.classList.add('done');
       setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
     };
-    // S'il y a une barre d'actions (résultat d'outil avec « voir plus »), le
-    // bouton copier s'y range à côté ; sinon il se colle en bas à droite du bloc.
-    (scope.querySelector('.tool-actions')||pre).appendChild(btn);
+    pre.appendChild(btn);
   });
 }
 // Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js
index ebae521..bcbd787 100644
--- a/internal/loki/ui/src/js/08-chat-render.js
+++ b/internal/loki/ui/src/js/08-chat-render.js
@@ -306,10 +306,7 @@ function renderToolMsg(el, tu){
   meta = meta || {lbl:'mémoire', head:'mémoire'};
   let lbl = meta.lbl;
   // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
-  // Taille RÉELLE (result_chars) quand le flux n'a envoyé qu'un aperçu — sinon
-  // la bulle affichait toujours le même chiffre, celui du plafond d'aperçu.
-  const realChars = tu.result_chars || (tu.result ? tu.result.length : 0);
-  if(realChars){ lbl += '  ·  ~' + Math.max(1, Math.round(realChars/4)) + ' tok'; }
+  if(tu.result){ lbl += '  ·  ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
   setLabel(el, lbl);
   // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
   // reporté sur l'étiquette, pour rester lisible bulle repliée.
@@ -392,49 +389,8 @@ function renderToolMsg(el, tu){
     const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
     body.appendChild(sub);
     const pre=document.createElement('pre');
-    const code=document.createElement('code');
-    const preview=tu.result, rid=tu.result_id, PREVIEW=1600;
-    // Deux cas :
-    //  - result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
-    //    « voir plus » CHARGE le reste à la demande via l'API ;
-    //  - sinon : le résultat est déjà complet ; s'il est long, « voir plus » le
-    //    déplie localement (rien à charger).
-    if(rid){
-      code.textContent=preview+'…';
-      pre.appendChild(code);
-      const more=document.createElement('button');
-      more.className='tool-more'; more.type='button'; more.textContent='voir plus';
-      let full=null, open=false;
-      more.onclick=async(e)=>{ e.stopPropagation();
-        if(full===null){
-          more.disabled=true; more.textContent='…';
-          // Résultat introuvable côté serveur (compacté, autre discussion) : on
-          // garde l'aperçu plutôt que de vider la bulle.
-          try{ const j=await jget('/api/chat/tool-result?id='+encodeURIComponent(rid)); full=(j&&j.result)||preview; }
-          catch(_){ full=preview; }
-          more.disabled=false;
-        }
-        open=!open;
-        code.textContent = open ? full : preview+'…';
-        pre.classList.toggle('expanded', open);
-        more.textContent = open ? 'voir moins' : 'voir plus';
-      };
-      body.appendChild(toolBlock(pre, more));
-    } else if(preview.length>PREVIEW){
-      code.textContent=preview.slice(0,PREVIEW)+'…';
-      pre.appendChild(code);
-      const more=document.createElement('button');
-      more.className='tool-more'; more.type='button'; more.textContent='voir plus';
-      let open=false;
-      more.onclick=(e)=>{ e.stopPropagation(); open=!open;
-        code.textContent = open ? preview : preview.slice(0,PREVIEW)+'…';
-        pre.classList.toggle('expanded', open);
-        more.textContent = open ? 'voir moins' : 'voir plus';
-      };
-      body.appendChild(toolBlock(pre, more));
-    } else {
-      code.textContent=preview; pre.appendChild(code); body.appendChild(pre);
-    }
+    const code=document.createElement('code'); code.textContent=tu.result;
+    pre.appendChild(code); body.appendChild(pre);
   } else if(!tu.done && !tu.typing){
     const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…';
     body.appendChild(wait);
@@ -446,26 +402,10 @@ function renderToolMsg(el, tu){
   const bw = el.querySelector('.bodywrap');
   if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
 }
-// Bloc d'un résultat d'outil : le pre scrolle (max-height), donc la barre
-// d'actions vit sur un bloc-parent NON scrollant, ancrée en bas à droite — elle
-// reste au coin quoi qu'on scrolle dans le résultat. La barre réunit « voir
-// plus » (passé ici) et, ajouté ensuite par addCopyButtons, « copier ».
-function toolBlock(pre, more){
-  const block=document.createElement('div'); block.className='tool-block';
-  block.appendChild(pre);
-  const bar=document.createElement('div'); bar.className='tool-actions';
-  bar.appendChild(more);
-  block.appendChild(bar);
-  return block;
-}
 // Inject a "copier" button into every 
 code block (idempotent).
 function addCopyButtons(root){
   root.querySelectorAll('pre').forEach(pre=>{
-    // Résultat d'outil : le pre est enrobé dans .tool-block et la barre
-    // d'actions est SŒUR du pre (pas dedans) ; on cherche donc le copier dans
-    // ce périmètre.
-    const scope = pre.closest('.tool-block') || pre;
-    if(scope.querySelector('.copybtn')) return;
+    if(pre.querySelector('.copybtn')) return;
     // Pas de bouton copier sur un diff : on copierait les préfixes + / - .
     if(pre.classList.contains('diff')) return;
     const btn=document.createElement('button');
@@ -478,9 +418,7 @@ function addCopyButtons(root){
       btn.textContent='copié ✓'; btn.classList.add('done');
       setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
     };
-    // S'il y a une barre d'actions (résultat d'outil avec « voir plus »), le
-    // bouton copier s'y range à côté ; sinon il se colle en bas à droite du bloc.
-    (scope.querySelector('.tool-actions')||pre).appendChild(btn);
+    pre.appendChild(btn);
   });
 }
 // Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index 9c3d7b2..c7e34ad 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -444,20 +444,6 @@ button:disabled{opacity:.5;cursor:not-allowed}
 .diff-count .a{color:var(--add)}
 .diff-count .d{color:var(--del)}
 .msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
-/* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre
-   ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre
-   scrolle (max-height), donc la barre est portée par le bloc-parent NON
-   scrollant (.tool-block) : elle reste au coin quoi qu'on scrolle. */
-.msg.tool .tool-block{position:relative}
-.msg.tool .tool-block .tool-actions{position:absolute;bottom:7px;right:7px;display:flex;gap:6px;align-items:center;z-index:1}
-.msg.tool .tool-actions .copybtn{position:static;bottom:auto;right:auto}
-.msg.tool .tool-more{padding:3px 8px;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;opacity:.5;transition:opacity .15s,color .15s,border-color .15s}
-.msg.tool .tool-block:hover .tool-actions .tool-more,.msg.tool .tool-block:hover .tool-actions .copybtn{opacity:1}
-.msg.tool .tool-more:hover{border-color:var(--accent);color:var(--accent)}
-.msg.tool .tool-more:disabled{cursor:default}
-/* Déplié : on lève le plafond de hauteur pour voir VRAIMENT plus, au lieu de
-   rester enfermé dans une fenêtre de 280 px qu'il faut scroller. */
-.msg.tool pre.expanded{max-height:none}
 .msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
 .msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
 .msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go
index 858e14f..158b6fc 100644
--- a/internal/loki/web_chat.go
+++ b/internal/loki/web_chat.go
@@ -206,35 +206,6 @@ func handleChatState(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, 200, conv.state())
 }
 
-// handleToolResult renvoie le résultat COMPLET d'un appel d'outil, chargé à la
-// demande par le bouton « voir plus » de la bulle (le flux ne transporte qu'un
-// aperçu). Le résultat complet vit dans la vue « modèle » (conv.Messages), repéré
-// par son tool_call_id. Absent (compacté, archivé, discussion changée) → 404, et
-// l'UI garde son aperçu plutôt que d'afficher une erreur.
-func handleToolResult(w http.ResponseWriter, r *http.Request) {
-	id := strings.TrimSpace(r.URL.Query().Get("id"))
-	if id == "" {
-		sendJSON(w, 400, map[string]any{"error": "id manquant"})
-		return
-	}
-	conv.mu.Lock()
-	found, ok := "", false
-	for _, m := range conv.Messages {
-		if m.Role == "tool" && m.ToolCallID == id {
-			if s, isStr := m.Content.(string); isStr {
-				found, ok = s, true
-			}
-			break
-		}
-	}
-	conv.mu.Unlock()
-	if !ok {
-		sendJSON(w, 404, map[string]any{"error": "résultat non disponible"})
-		return
-	}
-	sendJSON(w, 200, map[string]any{"result": found})
-}
-
 func handleChat(w http.ResponseWriter, r *http.Request) {
 	var body chatReq
 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go
index a3c28d4..61b03ac 100644
--- a/internal/loki/web_server.go
+++ b/internal/loki/web_server.go
@@ -339,13 +339,12 @@ func newWebMux() *http.ServeMux {
 	api("/api/conversations/switch", handleConvSwitch)
 	api("/api/conversations/rename", handleConvRename)
 	api("/api/conversations/delete", handleConvDelete)
-	api("/api/chat/mode", handleChatMode)          // GET mode+critères / POST bascule Chat|Code
-	api("/api/chat/criteria", handleChatCriteria)  // édition manuelle des critères (mode code)
-	api("/api/chat/compact", handleChatCompact)    // compaction manuelle du contexte
-	api("/api/chat/state", handleChatState)        // instantané léger {seq, generating, ctx_used}
-	api("/api/chat/tool-result", handleToolResult) // résultat complet d'un outil, chargé à la demande (bouton « voir plus »)
-	api("/api/chat/export", handleChatExport)      // téléchargement du fil (?format=md|json)
-	api("/api/e2e/chat", handleE2EChat)            // même flux mais chiffré E2E (boîte noire via le relais)
+	api("/api/chat/mode", handleChatMode)         // GET mode+critères / POST bascule Chat|Code
+	api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
+	api("/api/chat/compact", handleChatCompact)   // compaction manuelle du contexte
+	api("/api/chat/state", handleChatState)       // instantané léger {seq, generating, ctx_used}
+	api("/api/chat/export", handleChatExport)     // téléchargement du fil (?format=md|json)
+	api("/api/e2e/chat", handleE2EChat)           // même flux mais chiffré E2E (boîte noire via le relais)
 	return mux
 }