mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-13 18:28:03 +02:00
Outils : aperçu dans le flux, « voir plus » à la demande, coupes annoncées
Repris d'AJEAN 0.15.1 → 0.15.7 (tool_results.go adapté : pas de chiffrement chez nous). VOIR PLUS Chaque résultat d'outil partait en entier dans le flux (jusqu'à 12 000 caractères) et dans le journal rejoué à chaque ouverture : un long fil d'agent en transportait des centaines. Le flux ne porte plus qu'un aperçu de 1 600 caractères, la taille réelle (le « ~N tok » de la bulle reste juste) et un id. Le résultat complet est rangé dans le bucket `toolres`, sous « <discussion>.<aléa> » — un id propre, pas le tool_call_id que certains parseurs recyclent. « voir plus » le charge au clic via /api/chat/tool-result et lève le plafond de hauteur du bloc ; il se range avec « copier » dans une barre au coin du résultat. Les résultats partent avec leur discussion (convDelete) ; les orphelins sont nettoyés toutes les 200 écritures. COUPES ANNONCÉES - Terminal : une sortie de plus de 8 000 caractères était coupée en silence ; le modèle croyait tout voir. Elle commence désormais par « [sortie tronquée : N caractères au total, seuls les 8000 derniers sont gardés] ». - MCP : l'amont transmet désormais la réponse en entier. On s'en approche sans renoncer au garde-fou (65k de contexte) : le plafond suit la fenêtre (CTX caractères, ≈ un quart de la fenêtre, jamais sous les 12 000 d'avant), et la coupe dit la taille réelle. Vérifié dans le navigateur avec un faux moteur qui appelle bash : aperçu de 1 600 caractères, « voir plus » charge les 8 099, mention de coupe en tête. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6aafeeea98
commit
ae0e181711
14 files changed
+378
-34
No files matched your search
@@ -631,6 +631,13 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction faite en cours de tour : elle remplace la base au lieu de
|
||||
|
||||
@@ -97,7 +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 et mcpMaxOutput (12000) pour MCP. Sans ce
|
||||
// toolMaxOutput (8000) pour le shell et mcpOutputCap 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.
|
||||
|
||||
@@ -318,6 +318,7 @@ func convDelete(id string) error {
|
||||
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
|
||||
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
|
||||
dropConvFiles(id)
|
||||
deleteToolResultsFor(id) // ses résultats « voir plus » partent avec elle
|
||||
if id != getStr(bkChat, ckActive) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -268,8 +268,8 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
return fmt.Sprintf("[erreur: %v]", err)
|
||||
}
|
||||
}
|
||||
out := tailRunes(stdout.String(), toolMaxOutput)
|
||||
errOut := tailRunes(stderr.String(), toolMaxOutput)
|
||||
out := tailOutput(stdout.String())
|
||||
errOut := tailOutput(stderr.String())
|
||||
parts := []string{fmt.Sprintf("exit: %d", exit)}
|
||||
if out != "" {
|
||||
parts = append(parts, "stdout:\n"+out)
|
||||
@@ -391,6 +391,18 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
|
||||
}
|
||||
|
||||
// tailOutput garde la FIN d'une sortie de commande (toolMaxOutput runes) et dit
|
||||
// explicitement ce qui a été coupé (AJEAN 0.15.5). La coupe était silencieuse :
|
||||
// le modèle croyait voir toute la sortie, et la bulle affichait toujours
|
||||
// ~2004 tok sans qu'on sache pourquoi.
|
||||
func tailOutput(s string) string {
|
||||
n := len([]rune(s))
|
||||
if n <= toolMaxOutput {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("[sortie tronquée : %d caractères au total, seuls les %d derniers sont gardés]\n", n, toolMaxOutput) + tailRunes(s, toolMaxOutput)
|
||||
}
|
||||
|
||||
// tailRunes returns the last n runes of s (used to cap tool output).
|
||||
func tailRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
|
||||
@@ -160,6 +160,12 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.Ask != nil:
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
|
||||
+39
-17
@@ -547,24 +547,46 @@ 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.
|
||||
ResultChars int
|
||||
// ResultID : identifiant (voir saveToolResult) permettant à l'UI de CHARGER
|
||||
// le résultat complet à la demande via /api/chat/tool-result — le flux ne
|
||||
// transporte que l'aperçu. Vide = Result est déjà complet.
|
||||
ResultID string
|
||||
}
|
||||
|
||||
// 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, 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
|
||||
// toolPreviewChars borne l'aperçu de résultat d'outil envoyé à l'UI (AJEAN
|
||||
// 0.15.1). Le flux — et son rejeu au rechargement — ne transporte plus que
|
||||
// ça ; le reste se charge au clic sur « voir plus » (tool_results.go).
|
||||
// Avant, chaque résultat partait en entier (jusqu'à 12 000 caractères), et un
|
||||
// long fil d'agent en rejouait des centaines à chaque ouverture.
|
||||
const toolPreviewChars = 1600
|
||||
|
||||
// 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é]"
|
||||
// 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
|
||||
}
|
||||
return s
|
||||
return string(r[:toolPreviewChars]), len(r), true
|
||||
}
|
||||
|
||||
// fillToolResult pose sur l'événement l'aperçu envoyé à l'UI, la taille réelle
|
||||
// et, si le résultat est coupé, l'id qui permet de charger le reste. Si
|
||||
// l'enregistrement échoue, on envoie le résultat entier (il reste borné par les
|
||||
// plafonds propres à chaque outil).
|
||||
func fillToolResult(ev *ToolUsedEvent, result string) *ToolUsedEvent {
|
||||
prev, n, cut := toolResultPreview(result)
|
||||
ev.ResultChars = n
|
||||
ev.Result = result
|
||||
if cut {
|
||||
if id := saveToolResult(result); id != "" {
|
||||
ev.Result = prev
|
||||
ev.ResultID = id
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// dedupableTool indique si un appel RIGOUREUSEMENT identique (même outil, mêmes
|
||||
@@ -1386,7 +1408,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// tel quel au modèle, pour qu'il renvoie un appel complet.
|
||||
if badArgs[tc.ID] {
|
||||
result = "[erreur] arguments de l'appel illisibles (JSON invalide ou tronqué) : l'outil n'a PAS été exécuté. Renvoie l'appel avec des arguments JSON complets et valides."
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: result, Done: true}})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
@@ -1396,7 +1418,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: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
@@ -1590,7 +1612,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if tc.Function.Name == "web_screenshot" {
|
||||
shot = capturedRelPath(result)
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
|
||||
@@ -48,10 +48,20 @@ 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
|
||||
)
|
||||
|
||||
// mcpOutputCap borne la sortie d'un outil MCP renvoyée au modèle. L'amont
|
||||
// (AJEAN 0.15.2) la transmet désormais EN ENTIER, comme une page mémoire : une
|
||||
// réponse MCP (un ticket, un document, une requête) coupée à 12 000 caractères
|
||||
// laissait le modèle travailler sur un début. On s'en rapproche sans renoncer
|
||||
// à tout garde-fou — sur 65k de contexte, une seule réponse géante noierait la
|
||||
// fenêtre et déclencherait un compactage en plein raisonnement : le plafond
|
||||
// suit la fenêtre, ~1 caractère par token de contexte, soit environ un quart
|
||||
// de celle-ci (≈4 caractères par token). Jamais sous l'ancien plafond.
|
||||
func mcpOutputCap() int {
|
||||
return max(12000, ctxWindow())
|
||||
}
|
||||
|
||||
// mcpSession est une connexion vivante à un serveur MCP.
|
||||
//
|
||||
// L'entrée est publiée dans le pool AVANT que la connexion aboutisse, pour qu'un
|
||||
@@ -399,8 +409,8 @@ func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
out := flattenMCPContent(res)
|
||||
if r := []rune(out); len(r) > mcpMaxOutput {
|
||||
out = string(r[:mcpMaxOutput]) + "\n…[tronqué]"
|
||||
if r, lim := []rune(out), mcpOutputCap(); len(r) > lim {
|
||||
out = string(r[:lim]) + fmt.Sprintf("\n…[tronqué : %d caractères au total, %d gardés]", len(r), lim)
|
||||
}
|
||||
if res.IsError {
|
||||
return "[l'outil a renvoyé une erreur]\n" + out, nil
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// tool_results.go — résultats COMPLETS des outils, pour le bouton « voir plus ».
|
||||
// Repris d'AJEAN 0.15.5 / 0.15.7, sans le chiffrement (Loki n'en a pas).
|
||||
//
|
||||
// Le flux n'envoie à l'UI qu'un aperçu (toolPreviewChars) ; le reste se charge
|
||||
// au clic. Il ne peut pas être cherché dans conv.Messages : pendant un tour
|
||||
// d'agent, les messages tool n'y sont versés qu'à la FIN du tour, et après un
|
||||
// compactage les anciens ont disparu de la vue modèle. On garde donc chaque
|
||||
// résultat coupé dans un bucket dédié, sous un id PROPRE (pas le tool_call_id,
|
||||
// que certains parseurs réutilisent d'un tour à l'autre).
|
||||
//
|
||||
// Rangement PAR DISCUSSION : la clé est « <id de discussion>.<aléa> ». Les
|
||||
// résultats vivent aussi longtemps que leur discussion (supprimés avec elle,
|
||||
// voir deleteToolResultsFor) ; les orphelins sont nettoyés de temps en temps.
|
||||
|
||||
const (
|
||||
bkToolRes = "toolres"
|
||||
toolResPruneGap = 200 // nettoyage tenté toutes les N écritures
|
||||
)
|
||||
|
||||
var toolResWrites atomic.Int64
|
||||
|
||||
func toolResID(sid string) string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
if sid = strings.TrimSpace(sid); sid == "" {
|
||||
sid = "nosession"
|
||||
}
|
||||
return sid + "." + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// saveToolResult enregistre un résultat complet de la discussion ACTIVE et
|
||||
// renvoie son id ("" en cas d'échec : l'appelant envoie alors le résultat
|
||||
// entier dans le flux).
|
||||
func saveToolResult(result string) string {
|
||||
id := toolResID(getStr(bkChat, ckActive))
|
||||
if id == "" || putBytes(bkToolRes, id, []byte(result)) != nil {
|
||||
return ""
|
||||
}
|
||||
if toolResWrites.Add(1)%toolResPruneGap == 0 {
|
||||
go pruneToolResults()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// loadToolResult relit un résultat enregistré.
|
||||
func loadToolResult(id string) (string, bool) {
|
||||
if strings.ContainsAny(id, "/\\") {
|
||||
return "", false
|
||||
}
|
||||
b, err := getBytesErr(bkToolRes, id)
|
||||
if err != nil || b == nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// deleteToolResultsFor supprime les résultats d'une discussion supprimée.
|
||||
func deleteToolResultsFor(sid string) {
|
||||
if sid == "" {
|
||||
return
|
||||
}
|
||||
prefix := sid + "."
|
||||
_ = update(bkToolRes, func(b *bolt.Bucket) error {
|
||||
c := b.Cursor()
|
||||
var keys [][]byte
|
||||
for k, _ := c.Seek([]byte(prefix)); k != nil && strings.HasPrefix(string(k), prefix); k, _ = c.Next() {
|
||||
keys = append(keys, append([]byte(nil), k...))
|
||||
}
|
||||
for _, k := range keys {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// pruneToolResults supprime les résultats de discussions qui n'existent plus.
|
||||
func pruneToolResults() {
|
||||
alive := map[string]bool{getStr(bkChat, ckActive): true}
|
||||
for _, m := range convIndex() {
|
||||
alive[m.ID] = true
|
||||
}
|
||||
_ = update(bkToolRes, func(b *bolt.Bucket) error {
|
||||
var orphans []string
|
||||
_ = b.ForEach(func(k, _ []byte) error {
|
||||
key := string(k)
|
||||
if i := strings.LastIndexByte(key, '.'); i > 0 && key[:i] != "nosession" && !alive[key[:i]] {
|
||||
orphans = append(orphans, key)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for _, k := range orphans {
|
||||
if err := b.Delete([]byte(k)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un résultat coupé est rangé à part, relu en entier, et part avec sa discussion.
|
||||
func TestToolResultsSaveLoadDelete(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = putStr(bkChat, ckActive, "c42")
|
||||
long := strings.Repeat("ligne de sortie\n", 500)
|
||||
ev := fillToolResult(&ToolUsedEvent{Name: "bash", Done: true}, long)
|
||||
if ev.ResultID == "" || !strings.HasPrefix(ev.ResultID, "c42.") {
|
||||
t.Fatalf("id attendu préfixé par la discussion, got %q", ev.ResultID)
|
||||
}
|
||||
if len([]rune(ev.Result)) != toolPreviewChars || ev.ResultChars != len([]rune(long)) {
|
||||
t.Fatalf("aperçu %d car., taille %d — attendu %d et %d", len([]rune(ev.Result)), ev.ResultChars, toolPreviewChars, len([]rune(long)))
|
||||
}
|
||||
if got, ok := loadToolResult(ev.ResultID); !ok || got != long {
|
||||
t.Fatal("le résultat complet doit être relisible")
|
||||
}
|
||||
short := fillToolResult(&ToolUsedEvent{}, "ok")
|
||||
if short.ResultID != "" || short.Result != "ok" {
|
||||
t.Fatal("un résultat court part entier, sans id")
|
||||
}
|
||||
deleteToolResultsFor("c42")
|
||||
if _, ok := loadToolResult(ev.ResultID); ok {
|
||||
t.Fatal("les résultats doivent partir avec leur discussion")
|
||||
}
|
||||
}
|
||||
|
||||
// Une sortie de commande coupée le DIT, avec sa taille réelle.
|
||||
func TestTailOutputAnnouncesCut(t *testing.T) {
|
||||
if tailOutput("court") != "court" {
|
||||
t.Fatal("une sortie courte reste intacte")
|
||||
}
|
||||
out := tailOutput(strings.Repeat("x", toolMaxOutput+10))
|
||||
if !strings.HasPrefix(out, "[sortie tronquée : ") || !strings.Contains(out, "au total") {
|
||||
t.Fatalf("mention de coupe absente : %q", out[:60])
|
||||
}
|
||||
}
|
||||
@@ -480,6 +480,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.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}
|
||||
.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}
|
||||
/* Résultat d'outil : « voir plus » + « copier » réunis dans une barre ancrée en
|
||||
bas à droite. Le pre scrolle (max-height), donc la barre est portée par le
|
||||
bloc-parent NON scrollant (.tool-block) : elle reste au coin. Déplié, le pre
|
||||
lève son plafond pour montrer VRAIMENT plus (AJEAN 0.15.4). */
|
||||
.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-actions .tool-more{margin:0;padding:3px 8px;font:inherit;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;cursor:pointer;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-actions .tool-more:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.msg.tool pre.expanded{max-height:none}
|
||||
.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}
|
||||
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
|
||||
@@ -6667,7 +6678,8 @@ 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).
|
||||
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
|
||||
// Taille RÉELLE (result_chars) : le flux ne porte plus qu'un aperçu.
|
||||
if(tu.result){ const n = tu.result_chars || tu.result.length; lbl += ' · ~' + Math.max(1, Math.round(n/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.
|
||||
@@ -6750,8 +6762,36 @@ 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'); code.textContent=tu.result;
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
const code=document.createElement('code');
|
||||
const preview=tu.result, rid=tu.result_id;
|
||||
// result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
|
||||
// « voir plus » CHARGE le reste à la demande (AJEAN 0.15.1 / 0.15.4).
|
||||
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){
|
||||
// Un échec n'est PAS mémorisé : on prévient et on laisse réessayer.
|
||||
more.disabled=true; const prevTxt=more.textContent; more.textContent='…';
|
||||
let got=null;
|
||||
try{ const r=await jfetch('/api/chat/tool-result?id='+encodeURIComponent(rid)); const j=await r.json(); if(r.ok && j && typeof j.result==='string') got=j.result; }
|
||||
catch(_){}
|
||||
more.disabled=false; more.textContent=prevTxt;
|
||||
if(got===null){ toast('résultat complet indisponible'); return; }
|
||||
full=got;
|
||||
}
|
||||
open=!open;
|
||||
code.textContent = open ? full : preview+'…';
|
||||
pre.classList.toggle('expanded', open);
|
||||
more.textContent = open ? 'réduire' : 'voir plus';
|
||||
};
|
||||
body.appendChild(toolBlock(pre, more));
|
||||
} else {
|
||||
code.textContent=preview; 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);
|
||||
@@ -6763,10 +6803,25 @@ 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 même quand on scrolle dans le résultat. Elle 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 <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
root.querySelectorAll('pre').forEach(pre=>{
|
||||
if(pre.querySelector('.copybtn')) return;
|
||||
// Résultat d'outil : la barre d'actions est SŒUR du pre (pas dedans) ; on
|
||||
// cherche donc le copier dans le bloc qui les réunit.
|
||||
const scope = pre.closest('.tool-block') || pre;
|
||||
if(scope.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');
|
||||
@@ -6779,7 +6834,9 @@ function addCopyButtons(root){
|
||||
btn.textContent='copié ✓'; btn.classList.add('done');
|
||||
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
|
||||
};
|
||||
pre.appendChild(btn);
|
||||
// Barre d'actions (résultat d'outil avec « voir plus ») : le copier s'y
|
||||
// range à côté ; sinon il se colle en bas à droite du bloc.
|
||||
(scope.querySelector('.tool-actions')||pre).appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||||
|
||||
@@ -306,7 +306,8 @@ 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).
|
||||
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
|
||||
// Taille RÉELLE (result_chars) : le flux ne porte plus qu'un aperçu.
|
||||
if(tu.result){ const n = tu.result_chars || tu.result.length; lbl += ' · ~' + Math.max(1, Math.round(n/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.
|
||||
@@ -389,8 +390,36 @@ 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'); code.textContent=tu.result;
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
const code=document.createElement('code');
|
||||
const preview=tu.result, rid=tu.result_id;
|
||||
// result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
|
||||
// « voir plus » CHARGE le reste à la demande (AJEAN 0.15.1 / 0.15.4).
|
||||
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){
|
||||
// Un échec n'est PAS mémorisé : on prévient et on laisse réessayer.
|
||||
more.disabled=true; const prevTxt=more.textContent; more.textContent='…';
|
||||
let got=null;
|
||||
try{ const r=await jfetch('/api/chat/tool-result?id='+encodeURIComponent(rid)); const j=await r.json(); if(r.ok && j && typeof j.result==='string') got=j.result; }
|
||||
catch(_){}
|
||||
more.disabled=false; more.textContent=prevTxt;
|
||||
if(got===null){ toast('résultat complet indisponible'); return; }
|
||||
full=got;
|
||||
}
|
||||
open=!open;
|
||||
code.textContent = open ? full : preview+'…';
|
||||
pre.classList.toggle('expanded', open);
|
||||
more.textContent = open ? 'réduire' : 'voir plus';
|
||||
};
|
||||
body.appendChild(toolBlock(pre, more));
|
||||
} else {
|
||||
code.textContent=preview; 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);
|
||||
@@ -402,10 +431,25 @@ 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 même quand on scrolle dans le résultat. Elle 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 <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
root.querySelectorAll('pre').forEach(pre=>{
|
||||
if(pre.querySelector('.copybtn')) return;
|
||||
// Résultat d'outil : la barre d'actions est SŒUR du pre (pas dedans) ; on
|
||||
// cherche donc le copier dans le bloc qui les réunit.
|
||||
const scope = pre.closest('.tool-block') || pre;
|
||||
if(scope.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');
|
||||
@@ -418,7 +462,9 @@ function addCopyButtons(root){
|
||||
btn.textContent='copié ✓'; btn.classList.add('done');
|
||||
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
|
||||
};
|
||||
pre.appendChild(btn);
|
||||
// Barre d'actions (résultat d'outil avec « voir plus ») : le copier s'y
|
||||
// range à côté ; sinon il se colle en bas à droite du bloc.
|
||||
(scope.querySelector('.tool-actions')||pre).appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||||
|
||||
@@ -445,6 +445,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.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}
|
||||
.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}
|
||||
/* Résultat d'outil : « voir plus » + « copier » réunis dans une barre ancrée en
|
||||
bas à droite. Le pre scrolle (max-height), donc la barre est portée par le
|
||||
bloc-parent NON scrollant (.tool-block) : elle reste au coin. Déplié, le pre
|
||||
lève son plafond pour montrer VRAIMENT plus (AJEAN 0.15.4). */
|
||||
.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-actions .tool-more{margin:0;padding:3px 8px;font:inherit;font-size:11px;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;cursor:pointer;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-actions .tool-more:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.msg.tool pre.expanded{max-height:none}
|
||||
.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}
|
||||
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
|
||||
|
||||
@@ -119,6 +119,22 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleToolResult renvoie le résultat COMPLET d'un outil dont le flux n'a
|
||||
// transporté qu'un aperçu (bouton « voir plus », voir tool_results.go).
|
||||
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
|
||||
}
|
||||
s, ok := loadToolResult(id)
|
||||
if !ok {
|
||||
sendJSON(w, 404, map[string]any{"error": "résultat non disponible"})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"result": s})
|
||||
}
|
||||
|
||||
func handleChatStop(w http.ResponseWriter, r *http.Request) {
|
||||
conv.Stop()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
|
||||
@@ -332,6 +332,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
||||
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||
api("/api/chat/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
|
||||
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
||||
// Discussions : liste, création, bascule, renommage, suppression.
|
||||
api("/api/conversations", handleConvList)
|
||||
|
||||
Reference in new issue
Block a user