Files
Loki/internal/ajean/chat_export.go
T
nathaninline 52baab5786 v0.8.4 : l'historique s'exporte, le moteur se laisse joindre, les modeles en plusieurs fichiers marchent
Trois retours d'utilisateurs apres la 0.8.3.

Export de la conversation (regression de la 0.8 : conversation.json a
disparu dans ajean.db, verrouille et binaire). Bouton dans l'UI +
commande 'ajean export [--json] [fichier]', endpoint /api/chat/export.

Ecoute reseau du moteur : interrupteur 'joignable depuis le reseau local'
dans le panneau Acces OpenAI (HOST + regle de pare-feu Windows), commande
'ajean network [on|off|status]'. HOST devient un reglage de machine
preserve au changement de preset.

Modeles GGUF decoupes en tranches -00001-of-000NN : une famille = un
modele. Telechargement de toutes les tranches depuis n'importe quel lien,
sonde d'espace disque sur le total, liste et suppression regroupees,
demarrage refuse avec le nom du fichier manquant.
2026-08-08 20:01:54 +02:00

219 lines
7.3 KiB
Go

package ajean
import (
"encoding/json"
"fmt"
"net/http"
"os"
"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, un même contenu : le JSON est fidèle (vue modèle + journal
// d'affichage complet, rejouable), le Markdown est fait pour être lu.
// 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"`
}
// ExportJSON rend la conversation entière en JSON indenté.
func (c *Conversation) ExportJSON() ([]byte, error) {
c.mu.Lock()
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...),
}
c.mu.Unlock()
return json.MarshalIndent(p, "", " ")
}
// 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() string {
c.mu.Lock()
snapshot := append([]LogEvent(nil), c.Log...)
c.mu.Unlock()
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)
// 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
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
}
fmt.Fprintf(&b, "\n<details>\n<summary>Raisonnement</summary>\n\n%s\n\n</details>\n",
mdText(ev["reasoning_content"]))
case ev["content"] != nil:
if !openBubble {
b.WriteString("\n## AJEAN\n")
openBubble = true
}
fmt.Fprintf(&b, "\n%s\n", mdText(ev["content"]))
case ev["tool_used"] != nil:
tu, _ := ev["tool_used"].(map[string]any)
if tu == nil {
break
}
if !openBubble {
b.WriteString("\n## AJEAN\n")
openBubble = true
}
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) != "" {
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()
}
// 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
}
// 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
//
// 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 {
switch a {
case "--json", "-j":
format = "json"
case "--md", "--markdown", "-m":
format = "md"
default:
if strings.HasPrefix(a, "-") && a != "-" {
return fmt.Errorf("option inconnue : %s (attendu --json ou --md)", 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") {
format = "json"
} else if strings.HasSuffix(strings.ToLower(out), ".md") {
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())
}
if out == "-" {
_, err := os.Stdout.Write(body)
return err
}
if out == "" {
out = exportFilename(format)
}
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
}
// handleChatExport sert la conversation en téléchargement. `format=md` (défaut)
// ou `format=json`.
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"
}
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)
}