From 7a27dbf3be40e2becdb9f3f086e1560f0775a92c Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 20:25:18 +0200 Subject: [PATCH] Export : une fenetre d'options, et le vidage du chat passe en dernier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux boutons d'export cote a cote ne laissaient aucun choix : tout ou rien. Un bouton unique ouvre desormais une fenetre ou l'on choisit le format (Markdown a lire / JSON a retraiter), ce qu'on emporte (raisonnements, appels d'outils, sorties d'outils, ou journal d'affichage cote JSON) et la portee (tout le fil ou les N derniers echanges). Une ligne dit ce que l'export contiendra, et l'en-tete du fichier signale un export partiel : relu plus tard, il ne doit pas passer pour le fil complet. Memes options en ligne de commande : --last N, --no-reasoning, --no-tools, --no-results, --messages-only. Sans option, l'export reste complet, endpoint compris. « clear chat » descend en derniere position et seul sur sa ligne : c'est la seule action destructive du panneau, elle n'a rien a faire sous le doigt qui visait « refresh ». Au passage, issue #20 : le prompt systeme et la description de l'outil mem_search annoncaient encore la memoire sous MEMORY/, nom d'avant la 0.8. Le modele lisait donc un chemin qui n'existe plus. Corrige, avec les deux commentaires restes au meme nom. --- internal/ajean/chat_export.go | 254 +++++++++++++++++++----- internal/ajean/chat_export_test.go | 112 ++++++++++- internal/ajean/chat_memory.go | 2 +- internal/ajean/chat_tools.go | 2 +- internal/ajean/llm_client.go | 2 +- internal/ajean/run.go | 4 +- internal/ajean/ui/index.html | 143 +++++++++++-- internal/ajean/ui/src/index.tmpl.html | 61 +++++- internal/ajean/ui/src/js/06-settings.js | 82 +++++++- internal/ajean/web_api.go | 2 +- 10 files changed, 575 insertions(+), 89 deletions(-) diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go index 03e04c8..96c33ab 100644 --- a/internal/ajean/chat_export.go +++ b/internal/ajean/chat_export.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "os" + "strconv" "strings" "time" ) @@ -19,8 +20,62 @@ 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, un même contenu : le JSON est fidèle (vue modèle + journal -// d'affichage complet, rejouable), le Markdown est fait pour être lu. +// 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. + +// 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) + 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} +} + +// exportOptsFromQuery lit les options d'une requête. Toute clé absente garde sa +// valeur par défaut : `/api/chat/export` tout court reste l'export complet. +func exportOptsFromQuery(q map[string][]string) exportOpts { + o := defaultExportOpts() + get := func(k string) (string, bool) { + v, ok := q[k] + if !ok || len(v) == 0 { + return "", false + } + return v[0], true + } + flag := func(k string, cur bool) bool { + v, ok := get(k) + if !ok { + return cur + } + return v == "1" || v == "true" || v == "on" + } + if v, ok := get("format"); ok && v == "json" { + o.Format = "json" + } + 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 + } + } + // Sans les bulles d'outils, leur sortie n'a nulle part où aller. + if !o.Tools { + o.Results = false + } + return o +} // exportPayload est la forme du fichier JSON exporté. `messages` est la vue // envoyée au modèle, `log` le journal d'affichage (celui qui porte les @@ -30,23 +85,65 @@ type exportPayload struct { ExportedAt string `json:"exported_at"` CtxUsed int `json:"ctx_used"` Messages []Message `json:"messages"` - Log []LogEvent `json:"log"` + Log []LogEvent `json:"log,omitempty"` } -// ExportJSON rend la conversation entière en JSON indenté. -func (c *Conversation) ExportJSON() ([]byte, error) { +// ExportJSON rend la conversation en JSON indenté. +func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) { c.mu.Lock() + msgs := append([]Message(nil), c.Messages...) + log := append([]LogEvent(nil), c.Log...) + ctxUsed := c.CtxUsed + c.mu.Unlock() + + if o.Turns > 0 { + msgs = lastTurnsMessages(msgs, o.Turns) + log = lastTurnsLog(log, o.Turns) + } p := exportPayload{ Version: Version, ExportedAt: time.Now().Format(time.RFC3339), - CtxUsed: c.CtxUsed, - Messages: append([]Message(nil), c.Messages...), - Log: append([]LogEvent(nil), c.Log...), + CtxUsed: ctxUsed, + Messages: msgs, + } + if o.JSONFull { + p.Log = log } - c.mu.Unlock() return json.MarshalIndent(p, "", " ") } +// 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 { + seen := 0 + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role != "user" { + continue + } + seen++ + if seen == n { + return msgs[i:] + } + } + return msgs +} + +// lastTurnsLog fait de même sur le journal d'affichage, où un échange commence à +// un événement `user`. +func lastTurnsLog(log []LogEvent, n int) []LogEvent { + seen := 0 + for i := len(log) - 1; i >= 0; i-- { + if _, ok := log[i].Delta["user"]; !ok { + continue + } + seen++ + if seen == n { + return log[i:] + } + } + return log +} + // ExportMarkdown rend la conversation en Markdown lisible : un bloc par tour, // le raisonnement replié dans un
(il est souvent plus long que la // réponse), et les outils appelés en liste. @@ -55,53 +152,64 @@ func (c *Conversation) ExportJSON() ([]byte, error) { // les raisonnements et la trace des outils, précisément ce qu'on vient chercher // dans un export. coalesceReplay est réutilisé tel quel pour recoller les // milliers de deltas d'un tour en un bloc de texte par bulle. -func (c *Conversation) ExportMarkdown() string { +func (c *Conversation) ExportMarkdown(o exportOpts) string { c.mu.Lock() snapshot := append([]LogEvent(nil), c.Log...) c.mu.Unlock() + if o.Turns > 0 { + snapshot = lastTurnsLog(snapshot, o.Turns) + } var b strings.Builder fmt.Fprintf(&b, "# Conversation AJEAN\n\nExportée le %s par AJEAN %s.\n", time.Now().Format("02/01/2006 à 15:04"), Version) + // Un export tronqué ou allégé doit le DIRE : relu six mois plus tard, un fil + // sans ses raisonnements ne doit pas passer pour le fil complet. + if note := exportNote(o); note != "" { + fmt.Fprintf(&b, "\n%s\n", note) + } // openBubble : une bulle assistant est ouverte (au moins un bout de réponse a // été écrit). Sert à ne poser l'en-tête « AJEAN » qu'une fois par tour, même // quand la réponse est entrecoupée d'appels d'outils. openBubble := false + head := func() { + if !openBubble { + b.WriteString("\n## AJEAN\n") + openBubble = true + } + } for _, ev := range coalesceReplay(snapshot, 0) { switch { case ev["user"] != nil: openBubble = false fmt.Fprintf(&b, "\n---\n\n## Vous\n\n%s\n", mdText(ev["user"])) case ev["reasoning_content"] != nil: - if !openBubble { - b.WriteString("\n## AJEAN\n") - openBubble = true + if !o.Reasoning { + break } + head() fmt.Fprintf(&b, "\n
\nRaisonnement\n\n%s\n\n
\n", mdText(ev["reasoning_content"])) case ev["content"] != nil: - if !openBubble { - b.WriteString("\n## AJEAN\n") - openBubble = true - } + head() fmt.Fprintf(&b, "\n%s\n", mdText(ev["content"])) case ev["tool_used"] != nil: + if !o.Tools { + break + } tu, _ := ev["tool_used"].(map[string]any) if tu == nil { break } - if !openBubble { - b.WriteString("\n## AJEAN\n") - openBubble = true - } + head() label, _ := tu["label"].(string) name, _ := tu["name"].(string) if label == "" { label = name } fmt.Fprintf(&b, "\n> 🔧 **%s** — %s\n", mdInline(name), mdInline(label)) - if res, _ := tu["result"].(string); strings.TrimSpace(res) != "" { + if res, _ := tu["result"].(string); o.Results && strings.TrimSpace(res) != "" { fmt.Fprintf(&b, "\n```\n%s\n```\n", strings.TrimRight(res, "\n")) } case ev["compacted"] != nil: @@ -114,6 +222,26 @@ func (c *Conversation) ExportMarkdown() string { return b.String() } +// exportNote résume en une ligne ce que l'export ne contient PAS. +func exportNote(o exportOpts) string { + var parts []string + if o.Turns > 0 { + parts = append(parts, fmt.Sprintf("%d derniers échanges seulement", o.Turns)) + } + if !o.Reasoning { + parts = append(parts, "raisonnements retirés") + } + if !o.Tools { + parts = append(parts, "appels d'outils retirés") + } else if !o.Results { + parts = append(parts, "sorties d'outils retirées") + } + if len(parts) == 0 { + return "" + } + return "_Export partiel : " + strings.Join(parts, ", ") + "._" +} + // mdText rend une valeur d'événement en texte de bloc Markdown. func mdText(v any) string { s, _ := v.(string) @@ -135,6 +263,16 @@ func exportFilename(ext string) string { return "ajean-conversation-" + time.Now().Format("2006-01-02-1504") + "." + ext } +// exportBody rend la conversation selon les options. Renvoie le corps, son type +// MIME et l'extension de fichier. +func exportBody(o exportOpts) ([]byte, string, string, error) { + if o.Format == "json" { + b, err := conv.ExportJSON(o) + return b, "application/json; charset=utf-8", "json", err + } + return []byte(conv.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil +} + // cmdExport écrit la conversation dans un fichier (ou sur la sortie standard). // // ajean export → ajean-conversation-.md dans le dossier courant @@ -142,19 +280,43 @@ func exportFilename(ext string) string { // ajean export mon-fil.md → nom de fichier imposé (l'extension choisit le format) // ajean export - → sur la sortie standard, pour enchaîner un tube // +// 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 +// // 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). func cmdExport(args []string) error { - format, out := "md", "" - for _, a := range args { + o := defaultExportOpts() + out := "" + for i := 0; i < len(args); i++ { + a := args[i] switch a { case "--json", "-j": - format = "json" + o.Format = "json" case "--md", "--markdown", "-m": - format = "md" + o.Format = "md" + case "--no-reasoning": + o.Reasoning = false + case "--no-tools": + 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") + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n <= 0 { + return fmt.Errorf("--last : nombre d'échanges invalide (%s)", args[i]) + } + o.Turns = n default: if strings.HasPrefix(a, "-") && a != "-" { - return fmt.Errorf("option inconnue : %s (attendu --json ou --md)", a) + return fmt.Errorf("option inconnue : %s (voir « ajean help »)", a) } out = a } @@ -162,21 +324,15 @@ func cmdExport(args []string) error { // Une extension explicite l'emporte sur le drapeau : `ajean export fil.json` // qui produirait du Markdown serait un piège. if strings.HasSuffix(strings.ToLower(out), ".json") { - format = "json" + o.Format = "json" } else if strings.HasSuffix(strings.ToLower(out), ".md") { - format = "md" + o.Format = "md" } LoadConversation() - var body []byte - if format == "json" { - b, err := conv.ExportJSON() - if err != nil { - return err - } - body = b - } else { - body = []byte(conv.ExportMarkdown()) + body, _, ext, err := exportBody(o) + if err != nil { + return err } if out == "-" { @@ -184,7 +340,7 @@ func cmdExport(args []string) error { return err } if out == "" { - out = exportFilename(format) + out = exportFilename(ext) } if err := os.WriteFile(out, body, 0o644); err != nil { return err @@ -196,20 +352,14 @@ func cmdExport(args []string) error { return nil } -// handleChatExport sert la conversation en téléchargement. `format=md` (défaut) -// ou `format=json`. +// 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. func handleChatExport(w http.ResponseWriter, r *http.Request) { - var body []byte - var ctype, ext string - if r.URL.Query().Get("format") == "json" { - b, err := conv.ExportJSON() - if err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - body, ctype, ext = b, "application/json; charset=utf-8", "json" - } else { - body, ctype, ext = []byte(conv.ExportMarkdown()), "text/markdown; charset=utf-8", "md" + o := exportOptsFromQuery(r.URL.Query()) + body, ctype, ext, err := exportBody(o) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return } w.Header().Set("Content-Type", ctype) w.Header().Set("Content-Disposition", `attachment; filename="`+exportFilename(ext)+`"`) diff --git a/internal/ajean/chat_export_test.go b/internal/ajean/chat_export_test.go index 2160f3c..1d0c0ad 100644 --- a/internal/ajean/chat_export_test.go +++ b/internal/ajean/chat_export_test.go @@ -34,7 +34,7 @@ func convDeTest(t *testing.T) { func TestExportMarkdownPorteToutLeFil(t *testing.T) { convDeTest(t) - md := conv.ExportMarkdown() + md := conv.ExportMarkdown(defaultExportOpts()) for _, want := range []string{ "## Vous", "combien font 2+2 ?", "## AJEAN", @@ -54,7 +54,7 @@ func TestExportMarkdownPorteToutLeFil(t *testing.T) { func TestExportJSONRelisible(t *testing.T) { convDeTest(t) - b, err := conv.ExportJSON() + b, err := conv.ExportJSON(defaultExportOpts()) if err != nil { t.Fatal(err) } @@ -96,6 +96,114 @@ func TestHandleChatExportEnPieceJointe(t *testing.T) { } } +// Les options taillent l'export, et un export tronqué le DIT (sans quoi, relu +// plus tard, il passerait pour le fil complet). +func TestExportOptionsTaillentLeMarkdown(t *testing.T) { + convDeTest(t) + o := defaultExportOpts() + o.Reasoning = false + md := conv.ExportMarkdown(o) + if strings.Contains(md, "addition simple") { + t.Error("raisonnement présent malgré --no-reasoning") + } + if !strings.Contains(md, "Export partiel") || !strings.Contains(md, "raisonnements retirés") { + t.Errorf("export allégé non signalé :\n%s", md) + } + if !strings.Contains(md, "echo 4") { + t.Error("les outils ont disparu alors que seule la réflexion était exclue") + } + + o = defaultExportOpts() + o.Results = false + if md := conv.ExportMarkdown(o); strings.Contains(md, "```") { + t.Error("sortie d'outil présente malgré --no-results") + } + + // Couper les outils coupe forcément leurs sorties : la sortie n'a plus de + // bulle où s'accrocher. + o = exportOptsFromQuery(map[string][]string{"tools": {"0"}, "results": {"1"}}) + if o.Results { + t.Error("results resté actif alors que tools est coupé") + } + md = conv.ExportMarkdown(o) + if strings.Contains(md, "echo 4") { + t.Error("outil présent malgré tools=0") + } +} + +// Sans paramètre, l'endpoint reste l'export COMPLET : les options ne doivent pas +// silencieusement appauvrir ceux qui ne les connaissent pas. +func TestExportOptsFromQueryDefautComplet(t *testing.T) { + o := exportOptsFromQuery(map[string][]string{}) + 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 { + t.Fatalf("options mal lues : %+v", o) + } + // Une valeur de tours absurde ne doit pas vider l'export. + if o := exportOptsFromQuery(map[string][]string{"turns": {"-3"}}); o.Turns != 0 { + t.Fatalf("turns=-3 accepté : %d", o.Turns) + } +} + +func TestExportDerniersEchanges(t *testing.T) { + convDeTest(t) + // Un deuxième échange s'ajoute au fil de test. + conv.mu.Lock() + conv.Messages = append(conv.Messages, + Message{Role: "user", Content: "et 3+3 ?"}, Message{Role: "assistant", Content: "6"}) + conv.Log = append(conv.Log, + LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}}, + LogEvent{Seq: 7, TS: 7, Delta: map[string]any{"content": "6"}}) + conv.mu.Unlock() + + o := defaultExportOpts() + o.Turns = 1 + md := conv.ExportMarkdown(o) + if strings.Contains(md, "2+2") { + t.Errorf("le premier échange est encore là avec turns=1 :\n%s", md) + } + if !strings.Contains(md, "3+3") || !strings.Contains(md, "1 derniers échanges") { + t.Errorf("dernier échange absent ou non signalé :\n%s", md) + } + // Demander plus d'échanges qu'il n'en existe rend tout le fil, sans erreur. + o.Turns = 99 + if md := conv.ExportMarkdown(o); !strings.Contains(md, "2+2") { + t.Error("turns supérieur au nombre d'échanges a tronqué le fil") + } + // Même découpe côté JSON. + o = defaultExportOpts() + o.Turns = 1 + b, err := conv.ExportJSON(o) + if err != nil { + t.Fatal(err) + } + var p exportPayload + if err := json.Unmarshal(b, &p); err != nil { + t.Fatal(err) + } + 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 + b, _ = conv.ExportJSON(o) + var sansLog exportPayload + if err := json.Unmarshal(b, &sansLog); err != nil { + t.Fatal(err) + } + if len(sansLog.Log) != 0 { + t.Fatalf("journal présent malgré --messages-only : %d événements", len(sansLog.Log)) + } + if len(sansLog.Messages) == 0 { + t.Fatal("messages perdus avec --messages-only") + } +} + func TestCmdExportEcritLeFichier(t *testing.T) { testHome(t) convDeTest(t) diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go index 57f8b21..c3e4cfe 100644 --- a/internal/ajean/chat_memory.go +++ b/internal/ajean/chat_memory.go @@ -10,7 +10,7 @@ import ( "strings" ) -// La mémoire de ajean = des fichiers Markdown plats sous MEMORY/.md. +// La mémoire de ajean = des fichiers Markdown plats sous memory/.md. // L'IA y range ce qu'elle veut retenir entre les sessions (préférences, // décisions, procédures, infos projet). Quatre outils : mem_search, mem_read, // mem_add, mem_edit. diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go index ae89c85..76a0743 100644 --- a/internal/ajean/chat_tools.go +++ b/internal/ajean/chat_tools.go @@ -54,7 +54,7 @@ func baseSystemPrompt(caps Caps) string { b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n") } if hasMem { - b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under MEMORY/.\n") + b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under memory/.\n") } // Politique d'usage de la mémoire selon le mode. switch caps.Mem { diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index 15de10e..e987bf7 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -56,7 +56,7 @@ func memSearchTool() Tool { Type: "function", Function: ToolFunction{ Name: "mem_search", - Description: "Search your memory (Markdown pages under MEMORY/). Returns a ranked list of {file, title, snippet}. Use it FIRST when the user mentions something you might already know (preferences, ongoing projects, past decisions). Follow up with mem_read on the most relevant page.", + Description: "Search your memory (Markdown pages under memory/). Returns a ranked list of {file, title, snippet}. Use it FIRST when the user mentions something you might already know (preferences, ongoing projects, past decisions). Follow up with mem_read on the most relevant page.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 623dbe4..15fd425 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -144,8 +144,10 @@ Interface (ajean-ui) : Interaction: chat [system-prompt] chat terminal streamé - export [--json] [fichier] exporte la conversation de l'interface web + 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 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 f7a1efc..5fafaf7 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1341,18 +1341,20 @@ button:hover{border-color:var(--dim);color:var(--text)}
-
- - - -
- - + + + +
+ +
+
@@ -1404,6 +1406,51 @@ button:hover{border-color:var(--dim);color:var(--text)} + + @@ -357,6 +359,51 @@ + +