Files
Loki/internal/ajean/chat_export.go
T
nathaninline 6807bad48d v0.8.7 : envoyer et recevoir des fichiers dans le chat
Les fichiers circulent enfin dans les deux sens.

Envoi : trombone dans le composeur, glisser-deposer sur toute la fenetre,
ou Ctrl+V. Le fichier est depose dans uploads/ du dossier de travail de
l'agent et son chemin est annonce au modele, qui en fait ce qu'il veut
avec ses outils. Rien n'est interprete au passage, donc tout type de
fichier passe. Le depot n'a lieu qu'a l'ENVOI du message : retirer un
fichier de la liste ne laisse aucune trace sur le disque. Transport en
base64 dans du JSON et non en multipart, seule forme que le tunnel E2E
dispatche : l'acces distant marche sans code specifique.

Reception : le modele ecrit un lien Markdown ordinaire vers un fichier de
son dossier de travail, et le clic telecharge. Il repondait jusqu'ici par
un chemin serveur, inutilisable depuis un navigateur. Le clic passe par
fetch + blob (la cle de pilotage voyage dans un en-tete qu'une navigation
ne porterait pas, et le chemin de base change derriere le tunnel).
Markdown refusant les espaces non echappes dans une cible de lien, ils
sont encodes avant rendu : sans ca [x](mon rapport.pdf) ne produisait
rien du tout.

Perimetre du telechargement strictement limite au dossier de travail :
'..', chemins absolus et liens symboliques qui en sortent sont refuses,
et le fichier est toujours servi en piece jointe avec nosniff — un .html
produit par le modele ne doit pas s'executer dans l'origine de l'UI, ou
il lirait la cle de pilotage.

Preambule du mode agent : ~2350 -> ~1770 tokens, sans perdre une regle.
Le poids etait dans les schemas d'outils (le double du prompt), pas dans
le prompt. Celui-ci reenumerait des outils que les schemas decrivent
deja, et la regle « write, jamais echo » y figurait trois fois. Trois
tests gardent le budget : ce prompt a deja regrossi ligne par ligne deux
fois.

Aussi : l'export mentionne les pieces jointes (un message sans texte
donnait une section vide), et le decompte de contexte perd le mot
« contexte » au profit du nom du modele.
2026-08-10 17:11:56 +02:00

432 lines
13 KiB
Go

package ajean
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// chat_export.go — sortir la conversation de la base.
//
// Jusqu'à la 0.7, l'historique vivait dans un `conversation.json` qu'on pouvait
// ouvrir, lire et copier pour archive. La 0.8 l'a rangé dans ajean.db (bbolt) :
// un fichier binaire, et qui porte de surcroît un verrou EXCLUSIF tant que le
// service tourne — donc impossible ne serait-ce que de le copier sans arrêter
// AJEAN. Le fil de discussion et les raisonnements sont toujours là, mais plus
// 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 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 // 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
}
func defaultExportOpts() exportOpts {
return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: 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)
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
// raisonnements, les outils et les vitesses).
type exportPayload struct {
Version string `json:"ajean_version"`
ExportedAt string `json:"exported_at"`
CtxUsed int `json:"ctx_used"`
Messages []Message `json:"messages"`
Log []LogEvent `json:"log,omitempty"`
}
// 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: ctxUsed,
Messages: msgs,
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 {
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 <details> (il est souvent plus long que la
// réponse), et les outils appelés en liste.
//
// On repart du JOURNAL D'AFFICHAGE et non de la vue modèle : c'est lui qui porte
// 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(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"]))
// Pièces jointes du tour. Sans elles, un message envoyé SANS texte
// (juste un fichier) donnait une section « Vous » entièrement vide.
if names := exportFileNames(ev["files"]); len(names) > 0 {
fmt.Fprintf(&b, "\nFichiers joints : %s\n", strings.Join(names, ", "))
}
case ev["reasoning_content"] != nil:
if !o.Reasoning {
break
}
head()
fmt.Fprintf(&b, "\n<details>\n<summary>Raisonnement</summary>\n\n%s\n\n</details>\n",
mdText(ev["reasoning_content"]))
case ev["content"] != nil:
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
}
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); o.Results && strings.TrimSpace(res) != "" {
fmt.Fprintf(&b, "\n```\n%s\n```\n", strings.TrimRight(res, "\n"))
}
case ev["compacted"] != nil:
fmt.Fprintf(&b, "\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n")
}
}
if !openBubble && len(snapshot) == 0 {
b.WriteString("\n_Conversation vide._\n")
}
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)
return strings.TrimRight(s, "\n")
}
// mdInline neutralise ce qui casserait une ligne Markdown (un label d'outil peut
// contenir un chemin, des astérisques, un retour à la ligne).
func mdInline(v any) string {
s, _ := v.(string)
s = strings.ReplaceAll(s, "\n", " ")
r := strings.NewReplacer("*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]")
return strings.TrimSpace(r.Replace(s))
}
// exportFilename : nom proposé au téléchargement, horodaté pour que deux exports
// ne s'écrasent pas dans le dossier de téléchargements.
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-<date>.md dans le dossier courant
// ajean export --json → idem en JSON
// 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
//
// 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 {
o := defaultExportOpts()
out := ""
for i := 0; i < len(args); i++ {
a := args[i]
switch a {
case "--json", "-j":
o.Format = "json"
case "--md", "--markdown", "-m":
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 "--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 (voir « ajean help »)", a)
}
out = a
}
}
// 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") {
o.Format = "json"
} else if strings.HasSuffix(strings.ToLower(out), ".md") {
o.Format = "md"
}
LoadConversation()
body, _, ext, err := exportBody(o)
if err != nil {
return err
}
if out == "-" {
_, err := os.Stdout.Write(body)
return err
}
if out == "" {
out = exportFilename(ext)
}
if err := os.WriteFile(out, body, 0o644); err != nil {
return err
}
conv.mu.Lock()
n := len(conv.Messages)
conv.mu.Unlock()
fmt.Printf("%s %s (%d messages, %s)\n", green("[ok]"), out, n, humanBytes(int64(len(body))))
return nil
}
func handleChatExport(w http.ResponseWriter, r *http.Request) {
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)+`"`)
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
_, _ = w.Write(body)
}
// exportFileNames lit les noms des pièces jointes portées par un événement `user`.
//
// Deux formes possibles pour la même donnée : []attachInfo quand l'événement
// vient d'être produit, et []any de maps quand il a été relu depuis le journal
// persisté (JSON). L'export travaille sur les deux, d'où le double cas.
func exportFileNames(v any) []string {
var out []string
switch files := v.(type) {
case []attachInfo:
for _, f := range files {
out = append(out, f.Name)
}
case []any:
for _, it := range files {
if m, ok := it.(map[string]any); ok {
if n, ok := m["name"].(string); ok && n != "" {
out = append(out, n)
}
}
}
}
return out
}