Export : memes options quel que soit le format, curseur simple, etat vide

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.
This commit is contained in:
nathaninline committed 2026-08-08 20:56:11 +02:00
1 parent b7042f1127
commit 4a66b27227
8 files changed
+284 -371

No files matched your search

+52 -48
View File
@@ -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()})