From 4a66b2722752ff73489234ff7f3d60cbf1b74321 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 20:56:11 +0200 Subject: [PATCH] Export : memes options quel que soit le format, curseur simple, etat vide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois choses ne tenaient pas debout dans la fenetre. Les options CHANGEAIENT selon le format : le JSON proposait un obscur « journal d'affichage » a la place des trois cases du Markdown. Personne ne pouvait deviner ce que ca recouvrait, et cocher une case n'avait pas le meme sens selon le format choisi juste au-dessus. Les trois memes options valent desormais pour les deux ; le format ne decide que du contenant. Cote JSON, le journal est filtre par les memes criteres. La taille du fichier annoncee sous le curseur ne servait a rien et justifiait a elle seule une sonde cote serveur. Supprimee, sonde comprise : le nombre d'echanges vient maintenant de /api/chat/state, qui est deja l'instantane leger de la conversation. La fenetre reprend le vocabulaire visuel de l'editeur de preset (modal-card, pe-group, pe-row, interrupteurs pe-switch) au lieu de son balisage bricole, et chaque option porte son sous-titre. Conversation vide : un simple « Rien a exporter » remplace les reglages, plutot que d'offrir de tailler un fichier qui serait vide de toute facon. --- internal/ajean/chat_conversation.go | 11 +- internal/ajean/chat_export.go | 100 +++++----- internal/ajean/chat_export_test.go | 76 +++----- internal/ajean/run.go | 4 +- internal/ajean/ui/index.html | 232 ++++++++++-------------- internal/ajean/ui/src/index.tmpl.html | 102 ++++++----- internal/ajean/ui/src/js/06-settings.js | 125 ++++--------- internal/ajean/ui/src/styles.css | 5 +- 8 files changed, 284 insertions(+), 371 deletions(-) diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index a42250e..cac150f 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -243,7 +243,16 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s func (c *Conversation) state() map[string]any { c.mu.Lock() defer c.mu.Unlock() - return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed} + // `turns` = nombre d'échanges, borne du curseur de portée de l'export (voir + // countTurns). Compté ici plutôt que par un appel dédié : c'est un balayage + // du journal déjà en main, et l'état est de toute façon relu à l'ouverture. + turns := 0 + for _, ev := range c.Log { + if _, ok := ev.Delta["user"]; ok { + turns++ + } + } + return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns} } // ErrBusy : une génération est déjà en cours (un seul tour à la fois). diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go index 98cdfc3..75de2c3 100644 --- a/internal/ajean/chat_export.go +++ b/internal/ajean/chat_export.go @@ -20,24 +20,24 @@ import ( // personne ne peut les récupérer. C'est une régression pour qui relisait ses // échanges, et ce fichier la répare. // -// Deux formats : le Markdown est fait pour être LU (et se taille avec des -// options — un fil relu pour retrouver une réponse n'a que faire des mille -// lignes de raisonnement qui l'ont produite), le JSON est fait pour être -// RETRAITÉ et reste fidèle par défaut. +// Deux formats pour un même contenu : le Markdown est fait pour être LU, le +// JSON pour être RETRAITÉ. Les options de contenu (raisonnements, outils, +// portée) sont les MÊMES pour les deux — le format ne choisit que le +// contenant. Un fil relu pour retrouver une réponse n'a que faire des mille +// lignes de raisonnement qui l'ont produite, et ça vaut dans les deux formats. // exportOpts décrit ce qu'on veut sortir. Le zéro de la structure n'est PAS un // défaut utilisable (tout serait à false) : passer par defaultExportOpts. type exportOpts struct { Format string // "md" | "json" - Reasoning bool // Markdown : inclure les blocs de raisonnement - Tools bool // Markdown : inclure les appels d'outils - Results bool // Markdown : inclure la sortie des outils (implique Tools) + Reasoning bool // inclure les blocs de raisonnement + Tools bool // inclure les appels d'outils + Results bool // inclure la sortie des outils (exige Tools) Turns int // 0 = tout le fil ; N = les N derniers échanges - JSONFull bool // JSON : joindre le journal d'affichage en plus des messages } func defaultExportOpts() exportOpts { - return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true, JSONFull: true} + return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true} } // exportOptsFromQuery lit les options d'une requête. Toute clé absente garde sa @@ -64,7 +64,6 @@ func exportOptsFromQuery(q map[string][]string) exportOpts { o.Reasoning = flag("reasoning", o.Reasoning) o.Tools = flag("tools", o.Tools) o.Results = flag("results", o.Results) - o.JSONFull = flag("full", o.JSONFull) if v, ok := get("turns"); ok { if n, err := strconv.Atoi(v); err == nil && n > 0 { o.Turns = n @@ -105,13 +104,52 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) { ExportedAt: time.Now().Format(time.RFC3339), CtxUsed: ctxUsed, Messages: msgs, - } - if o.JSONFull { - p.Log = log + Log: filterLog(log, o), } return json.MarshalIndent(p, "", " ") } +// filterLog applique au journal les MÊMES cases que le Markdown. Les deux +// formats avaient auparavant des options distinctes (le JSON proposait un +// obscur « journal d'affichage » à la place des trois autres) : personne ne +// pouvait deviner ce que ça changeait, et cocher une case n'avait pas le même +// sens selon le format choisi juste au-dessus. Le format ne décide plus que du +// contenant ; ce qu'on emporte se règle une fois pour toutes. +func filterLog(log []LogEvent, o exportOpts) []LogEvent { + if o.Reasoning && o.Tools && o.Results { + return log + } + out := make([]LogEvent, 0, len(log)) + for _, ev := range log { + if _, ok := ev.Delta["reasoning_content"]; ok && !o.Reasoning { + continue + } + if tu, ok := ev.Delta["tool_used"].(map[string]any); ok { + if !o.Tools { + continue + } + if !o.Results { + // Copie : l'événement appartient à la conversation vivante, le + // modifier en place amputerait le fil affiché dans les navigateurs. + cp := make(map[string]any, len(tu)) + for k, v := range tu { + if k != "result" { + cp[k] = v + } + } + d := make(map[string]any, len(ev.Delta)) + for k, v := range ev.Delta { + d[k] = v + } + d["tool_used"] = cp + ev.Delta = d + } + } + out = append(out, ev) + } + return out +} + // lastTurnsMessages garde les n derniers échanges de la vue modèle, un échange // commençant à un message `user`. func lastTurnsMessages(msgs []Message, n int) []Message { @@ -282,7 +320,7 @@ func exportBody(o exportOpts) ([]byte, string, string, error) { // // Options de contenu, les mêmes que la fenêtre d'export de l'interface : // -// --no-reasoning --no-tools --no-results --last N --messages-only +// --no-reasoning --no-tools --no-results --last N // // La conversation est relue depuis la base à chaque appel : la commande marche // pendant que le service tourne (bbolt n'est jamais gardé ouvert, voir store.go). @@ -302,8 +340,6 @@ func cmdExport(args []string) error { o.Tools, o.Results = false, false case "--no-results": o.Results = false - case "--messages-only": - o.JSONFull = false case "--last": if i+1 >= len(args) { return fmt.Errorf("--last attend un nombre d'échanges") @@ -352,40 +388,8 @@ func cmdExport(args []string) error { return nil } -// countTurns compte les échanges du journal d'affichage (un échange = un -// message utilisateur). C'est la borne du curseur de portée dans l'interface : -// proposer « 25 derniers échanges » sur un fil qui en compte trois n'avait -// aucun sens, et ne disait pas non plus combien il y en avait. -func (c *Conversation) countTurns() int { - c.mu.Lock() - defer c.mu.Unlock() - n := 0 - for _, ev := range c.Log { - if _, ok := ev.Delta["user"]; ok { - n++ - } - } - return n -} - -// handleChatExport sert la conversation en téléchargement. Options en paramètres -// d'URL (voir exportOptsFromQuery) ; sans aucun paramètre, c'est l'export complet. -// -// Avec `probe=1`, on ne renvoie PAS le fichier mais sa taille et le nombre -// d'échanges disponibles : l'interface annonce ainsi le poids réel avant de -// télécharger. Le rendu est fait puis jeté, ce qui coûte quelques millisecondes -// et évite d'entretenir un second calcul de taille qui finirait par mentir. func handleChatExport(w http.ResponseWriter, r *http.Request) { o := exportOptsFromQuery(r.URL.Query()) - if r.URL.Query().Get("probe") == "1" { - body, _, _, err := exportBody(o) - if err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - sendJSON(w, 200, map[string]any{"ok": true, "size": len(body), "turns": conv.countTurns()}) - return - } body, ctype, ext, err := exportBody(o) if err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) diff --git a/internal/ajean/chat_export_test.go b/internal/ajean/chat_export_test.go index cd56c49..5a446ad 100644 --- a/internal/ajean/chat_export_test.go +++ b/internal/ajean/chat_export_test.go @@ -138,8 +138,8 @@ func TestExportOptsFromQueryDefautComplet(t *testing.T) { if o != defaultExportOpts() { t.Fatalf("défauts modifiés : %+v", o) } - o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}, "full": {"0"}}) - if o.Format != "json" || o.Turns != 2 || o.JSONFull { + o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}}) + if o.Format != "json" || o.Turns != 2 { t.Fatalf("options mal lues : %+v", o) } // Une valeur de tours absurde ne doit pas vider l'export. @@ -187,60 +187,44 @@ func TestExportDerniersEchanges(t *testing.T) { if len(p.Messages) != 2 || p.Messages[0].Content != "et 3+3 ?" { t.Fatalf("messages mal découpés : %+v", p.Messages) } - // --messages-only laisse tomber le journal. ⚠️ Structure NEUVE : Unmarshal ne - // remet pas à zéro les champs absents du JSON, un `p` réutilisé garderait le - // journal de l'appel précédent et le test passerait pour de mauvaises raisons. - o.JSONFull = false + // Les cases de contenu valent AUSSI pour le JSON : le format ne choisit que le + // contenant. ⚠️ Structure NEUVE à chaque Unmarshal : les champs absents du JSON + // ne sont pas remis à zéro, un `p` réutilisé garderait le journal précédent et + // le test passerait pour de mauvaises raisons. + o = defaultExportOpts() + o.Reasoning = false b, _ = conv.ExportJSON(o) - var sansLog exportPayload - if err := json.Unmarshal(b, &sansLog); err != nil { + var sansRaison exportPayload + if err := json.Unmarshal(b, &sansRaison); err != nil { t.Fatal(err) } - if len(sansLog.Log) != 0 { - t.Fatalf("journal présent malgré --messages-only : %d événements", len(sansLog.Log)) + for _, ev := range sansRaison.Log { + if _, ok := ev.Delta["reasoning_content"]; ok { + t.Fatal("raisonnement présent dans le JSON malgré l'option coupée") + } } - if len(sansLog.Messages) == 0 { - t.Fatal("messages perdus avec --messages-only") + if len(sansRaison.Messages) == 0 || len(sansRaison.Log) == 0 { + t.Fatal("le JSON a été vidé alors que seule la réflexion était exclue") } } -// La sonde alimente le curseur de portée et la taille annoncée : elle doit -// rendre la taille EXACTE du fichier qui serait téléchargé avec les mêmes -// options, sinon l'interface annonce un poids qu'elle ne tient pas. -func TestHandleChatExportProbe(t *testing.T) { +// Le curseur de portée est borné par le nombre d'échanges publié dans l'état de +// la conversation : sans lui, l'interface ne saurait pas jusqu'où aller. +func TestChatStatePublieLeNombreDEchanges(t *testing.T) { convDeTest(t) - probe := func(q string) (size, turns int) { - rr := httptest.NewRecorder() - handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?probe=1&"+q, nil)) - if rr.Code != 200 { - t.Fatalf("%s : HTTP %d", q, rr.Code) - } - var out struct { - Size int `json:"size"` - Turns int `json:"turns"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { - t.Fatal(err) - } - return out.Size, out.Turns + if n, _ := conv.state()["turns"].(int); n != 1 { + t.Fatalf("échanges = %v ; le fil de test en a 1", conv.state()["turns"]) } - download := func(q string) int { - rr := httptest.NewRecorder() - handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?"+q, nil)) - return rr.Body.Len() + conv.mu.Lock() + conv.Log = append(conv.Log, LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}}) + conv.mu.Unlock() + if n, _ := conv.state()["turns"].(int); n != 2 { + t.Fatalf("échanges = %v après un second tour", conv.state()["turns"]) } - for _, q := range []string{"", "reasoning=0", "format=json", "turns=1"} { - if s, _ := probe(q); s != download(q) { - t.Errorf("q=%q : sonde %d o, téléchargement %d o", q, s, download(q)) - } - } - // Le compte d'échanges ne dépend PAS des options : c'est la borne du curseur, - // elle ne doit pas rétrécir quand on réduit la portée. - if _, n := probe("turns=1"); n != 1 { - t.Errorf("échanges comptés = %d ; le fil de test en a 1", n) - } - if got := conv.countTurns(); got != 1 { - t.Errorf("countTurns = %d", got) + // Conversation vide : zéro échange, c'est ce qui déclenche « rien à exporter ». + conv.Reset() + if n, _ := conv.state()["turns"].(int); n != 0 { + t.Fatalf("échanges = %v sur un fil vide", conv.state()["turns"]) } } diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 15fd425..c64b96e 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -146,8 +146,8 @@ Interaction: chat [system-prompt] chat terminal streamé export [options] [fichier] exporte la conversation de l'interface web (Markdown par défaut, « - » = sortie standard) - --json --last N --no-reasoning --no-tools - --no-results --messages-only + --json --last N --no-reasoning + --no-tools --no-results agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire) memory [off|ondemand|always|status] mode mémoire de l'IA internet [on|off|status|engine |url |key ] diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index d11f0e4..5f6fff8 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1091,8 +1091,9 @@ button:hover{border-color:var(--dim);color:var(--text)} .xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)} .xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)} .xr-range:disabled{cursor:default;opacity:.5} -.xr-legend{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-top:8px;font-size:12px} -.xr-legend .xr-size{color:var(--dim);font-size:11px;white-space:nowrap;font-variant-numeric:tabular-nums} +/* Conversation vide : un seul message centré à la place des réglages. */ +.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px} +.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7} -