mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Export : une fenetre d'options, et le vidage du chat passe en dernier
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.
This commit is contained in:
1 parent
d881895301
commit
7a27dbf3be
10 files changed
+575
-89
No files matched your search
+202
-52
@@ -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 <details> (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<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
|
||||
}
|
||||
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-<date>.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)+`"`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// La mémoire de ajean = des fichiers Markdown plats sous MEMORY/<nom>.md.
|
||||
// La mémoire de ajean = des fichiers Markdown plats sous memory/<nom>.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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 <go|crawl4ai>|url <url>|key <clé>]
|
||||
|
||||
+128
-15
@@ -1341,18 +1341,20 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button>
|
||||
</div>
|
||||
<div id="upd-msg" class="muted"></div>
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button onclick="loadAll()">↻ refresh</button>
|
||||
<button onclick="resetChat()">⌫ clear chat</button>
|
||||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||||
</div>
|
||||
<!-- Export du fil. Depuis la 0.8 la conversation vit dans ajean.db (bbolt,
|
||||
binaire et verrouillé tant que le service tourne) : sans ce bouton,
|
||||
plus personne ne peut relire ni archiver ses échanges, ce que
|
||||
permettait l'ancien conversation.json. -->
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button onclick="exportChat('md')" title="conversation complète, raisonnements compris">⤓ exporter (.md)</button>
|
||||
<button onclick="exportChat('json')" title="format fidèle : vue modèle + journal d'affichage">.json</button>
|
||||
<button onclick="loadAll()">↻ refresh</button>
|
||||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||||
<button onclick="openExportModal()" title="choisir le format et ce qu'on emporte">⤓ exporter…</button>
|
||||
</div>
|
||||
<!-- « clear chat » est en DERNIER, et seul sur sa ligne : c'est la seule
|
||||
action destructive du panneau, elle ne doit pas se trouver sous le
|
||||
doigt qui visait « refresh ». -->
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button onclick="resetChat()">⌫ clear chat</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
@@ -1404,6 +1406,51 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||||
emporte plutôt que de tout prendre ou rien. Les options du format non
|
||||
retenu sont MASQUÉES, pas grisées : un réglage sans effet visible n'a rien
|
||||
à faire à l'écran. -->
|
||||
<div id="export-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeExportModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:460px;display:flex;flex-direction:column">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>Exporter la conversation</strong>
|
||||
<button onclick="closeExportModal()" style="margin:0">×</button>
|
||||
</div>
|
||||
<div style="padding:16px 20px;display:flex;flex-direction:column;gap:14px">
|
||||
<div>
|
||||
<div class="subhead" style="margin:0 0 6px">Format</div>
|
||||
<label class="switchrow" style="cursor:pointer"><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()" style="margin-right:8px"> Markdown <span class="muted" style="margin-left:6px">à lire</span></label>
|
||||
<label class="switchrow" style="cursor:pointer"><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()" style="margin-right:8px"> JSON <span class="muted" style="margin-left:6px">à retraiter</span></label>
|
||||
</div>
|
||||
<div id="x-md-opts">
|
||||
<div class="subhead" style="margin:0 0 6px">Contenu</div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="x-reasoning" checked onchange="onExportPreview()"><span class="slider"></span></span> raisonnements</label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="x-tools" checked onchange="onExportPreview()"><span class="slider"></span></span> appels d'outils</label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="x-results" checked onchange="onExportPreview()"><span class="slider"></span></span> sorties des outils</label>
|
||||
</div>
|
||||
<div id="x-json-opts" style="display:none">
|
||||
<div class="subhead" style="margin:0 0 6px">Contenu</div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="x-full" checked onchange="onExportPreview()"><span class="slider"></span></span> journal d'affichage<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le fil tel qu'il s'affiche : raisonnements, appels d'outils, vitesses. Décoché, l'export ne garde que les messages envoyés au modèle.</span></span></label>
|
||||
</div>
|
||||
<div>
|
||||
<div class="subhead" style="margin:0 0 6px">Portée</div>
|
||||
<select id="x-turns" class="io-input" onchange="onExportPreview()">
|
||||
<option value="0">toute la conversation</option>
|
||||
<option value="1">dernier échange</option>
|
||||
<option value="3">3 derniers échanges</option>
|
||||
<option value="10">10 derniers échanges</option>
|
||||
<option value="25">25 derniers échanges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="x-note" class="muted" style="font-size:11px"></div>
|
||||
</div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||
<button onclick="closeExportModal()">annuler</button>
|
||||
<button id="x-go" onclick="runExport()" style="border-color:var(--accent);color:var(--accent)">⤓ télécharger</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ask-modal" class="ask-overlay" style="display:none" onclick="if(event.target===this)askResolve(false)">
|
||||
<div class="ask-box">
|
||||
<div id="ask-title" class="ask-title"></div>
|
||||
@@ -2435,27 +2482,93 @@ function renderApiKey(d){
|
||||
}
|
||||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||||
// --- Export de la conversation ---------------------------------------------
|
||||
function openExportModal(){ onExportFormat(); showModal('export-modal'); }
|
||||
function closeExportModal(){ hideModal('export-modal'); }
|
||||
// Le format décide des options AFFICHÉES : les réglages Markdown n'ont aucun
|
||||
// effet sur un JSON, les montrer grisés ne ferait qu'encombrer.
|
||||
function onExportFormat(){
|
||||
const json = exportFormat() === 'json';
|
||||
document.getElementById('x-md-opts').style.display = json ? 'none' : '';
|
||||
document.getElementById('x-json-opts').style.display = json ? '' : 'none';
|
||||
onExportPreview();
|
||||
}
|
||||
function exportFormat(){
|
||||
const r = document.querySelector('input[name=x-fmt]:checked');
|
||||
return r ? r.value : 'md';
|
||||
}
|
||||
// Construit la requête d'export à partir des cases. On n'envoie QUE ce qui
|
||||
// s'écarte du défaut : l'URL reste lisible, et un export complet redevient le
|
||||
// simple /api/chat/export.
|
||||
function exportQuery(){
|
||||
const p = new URLSearchParams();
|
||||
const fmt = exportFormat();
|
||||
if(fmt === 'json') p.set('format','json');
|
||||
const on = id => document.getElementById(id).checked;
|
||||
if(fmt === 'json'){
|
||||
if(!on('x-full')) p.set('full','0');
|
||||
} else {
|
||||
if(!on('x-reasoning')) p.set('reasoning','0');
|
||||
if(!on('x-tools')) p.set('tools','0');
|
||||
if(!on('x-results')) p.set('results','0');
|
||||
}
|
||||
const t = document.getElementById('x-turns').value;
|
||||
if(t !== '0') p.set('turns', t);
|
||||
const q = p.toString();
|
||||
return '/api/chat/export' + (q ? '?'+q : '');
|
||||
}
|
||||
// Dit en une ligne ce que l'export va contenir. Sans ça on découvre en ouvrant
|
||||
// le fichier que le raisonnement qu'on venait chercher n'y est pas.
|
||||
function onExportPreview(){
|
||||
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
|
||||
const tools = document.getElementById('x-tools');
|
||||
const results = document.getElementById('x-results');
|
||||
results.disabled = !tools.checked;
|
||||
if(!tools.checked) results.checked = false;
|
||||
const parts = [];
|
||||
const t = document.getElementById('x-turns');
|
||||
parts.push(t.value === '0' ? 'toute la conversation' : t.options[t.selectedIndex].text);
|
||||
if(exportFormat() === 'json'){
|
||||
parts.push(document.getElementById('x-full').checked ? 'messages + journal complet' : 'messages seuls');
|
||||
} else {
|
||||
const off = [];
|
||||
if(!document.getElementById('x-reasoning').checked) off.push('raisonnements');
|
||||
if(!tools.checked) off.push('outils');
|
||||
else if(!results.checked) off.push('sorties d\'outils');
|
||||
parts.push(off.length ? 'sans ' + off.join(' ni ') : 'tout le contenu');
|
||||
}
|
||||
document.getElementById('x-note').textContent = parts.join(' · ');
|
||||
}
|
||||
async function runExport(){
|
||||
const btn = document.getElementById('x-go');
|
||||
btn.disabled = true;
|
||||
try{ await downloadExport(exportQuery()); closeExportModal(); }
|
||||
finally{ btn.disabled = false; }
|
||||
}
|
||||
// On passe par jfetch (et pas par un simple <a href>) pour deux raisons : la clé
|
||||
// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas,
|
||||
// et le chemin de base change derrière le tunnel (/u/<id>).
|
||||
async function exportChat(format){
|
||||
async function downloadExport(url){
|
||||
toast('préparation de l\'export…');
|
||||
try{
|
||||
const r = await jfetch('/api/chat/export?format=' + encodeURIComponent(format||'md'));
|
||||
const r = await jfetch(url);
|
||||
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
|
||||
const blob = await r.blob();
|
||||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom local.
|
||||
let name = 'ajean-conversation.' + (format==='json' ? 'json' : 'md');
|
||||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom
|
||||
// local déduit du type renvoyé.
|
||||
const cd = r.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
if(m) name = m[1];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const name = m ? m[1]
|
||||
: 'ajean-conversation.' + ((r.headers.get('Content-Type')||'').includes('json') ? 'json' : 'md');
|
||||
// ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le
|
||||
// redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte
|
||||
// du const — l'export échouerait avant même de partir.
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name;
|
||||
a.href = blobURL; a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
|
||||
// dans la foulée du clic.
|
||||
setTimeout(()=>URL.revokeObjectURL(url), 10000);
|
||||
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
|
||||
toast('exporté : ' + name);
|
||||
}catch(e){ toast('erreur : ' + e.message); }
|
||||
}
|
||||
|
||||
@@ -294,18 +294,20 @@
|
||||
<button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button>
|
||||
</div>
|
||||
<div id="upd-msg" class="muted"></div>
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button onclick="loadAll()">↻ refresh</button>
|
||||
<button onclick="resetChat()">⌫ clear chat</button>
|
||||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||||
</div>
|
||||
<!-- Export du fil. Depuis la 0.8 la conversation vit dans ajean.db (bbolt,
|
||||
binaire et verrouillé tant que le service tourne) : sans ce bouton,
|
||||
plus personne ne peut relire ni archiver ses échanges, ce que
|
||||
permettait l'ancien conversation.json. -->
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button onclick="exportChat('md')" title="conversation complète, raisonnements compris">⤓ exporter (.md)</button>
|
||||
<button onclick="exportChat('json')" title="format fidèle : vue modèle + journal d'affichage">.json</button>
|
||||
<button onclick="loadAll()">↻ refresh</button>
|
||||
<button id="btn-bench" onclick="runBenchUI()">bench</button>
|
||||
<button onclick="openExportModal()" title="choisir le format et ce qu'on emporte">⤓ exporter…</button>
|
||||
</div>
|
||||
<!-- « clear chat » est en DERNIER, et seul sur sa ligne : c'est la seule
|
||||
action destructive du panneau, elle ne doit pas se trouver sous le
|
||||
doigt qui visait « refresh ». -->
|
||||
<div class="row" style="margin-bottom:0">
|
||||
<button onclick="resetChat()">⌫ clear chat</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
@@ -357,6 +359,51 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||||
emporte plutôt que de tout prendre ou rien. Les options du format non
|
||||
retenu sont MASQUÉES, pas grisées : un réglage sans effet visible n'a rien
|
||||
à faire à l'écran. -->
|
||||
<div id="export-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeExportModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:460px;display:flex;flex-direction:column">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>Exporter la conversation</strong>
|
||||
<button onclick="closeExportModal()" style="margin:0">×</button>
|
||||
</div>
|
||||
<div style="padding:16px 20px;display:flex;flex-direction:column;gap:14px">
|
||||
<div>
|
||||
<div class="subhead" style="margin:0 0 6px">Format</div>
|
||||
<label class="switchrow" style="cursor:pointer"><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()" style="margin-right:8px"> Markdown <span class="muted" style="margin-left:6px">à lire</span></label>
|
||||
<label class="switchrow" style="cursor:pointer"><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()" style="margin-right:8px"> JSON <span class="muted" style="margin-left:6px">à retraiter</span></label>
|
||||
</div>
|
||||
<div id="x-md-opts">
|
||||
<div class="subhead" style="margin:0 0 6px">Contenu</div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="x-reasoning" checked onchange="onExportPreview()"><span class="slider"></span></span> raisonnements</label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="x-tools" checked onchange="onExportPreview()"><span class="slider"></span></span> appels d'outils</label>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="x-results" checked onchange="onExportPreview()"><span class="slider"></span></span> sorties des outils</label>
|
||||
</div>
|
||||
<div id="x-json-opts" style="display:none">
|
||||
<div class="subhead" style="margin:0 0 6px">Contenu</div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="x-full" checked onchange="onExportPreview()"><span class="slider"></span></span> journal d'affichage<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le fil tel qu'il s'affiche : raisonnements, appels d'outils, vitesses. Décoché, l'export ne garde que les messages envoyés au modèle.</span></span></label>
|
||||
</div>
|
||||
<div>
|
||||
<div class="subhead" style="margin:0 0 6px">Portée</div>
|
||||
<select id="x-turns" class="io-input" onchange="onExportPreview()">
|
||||
<option value="0">toute la conversation</option>
|
||||
<option value="1">dernier échange</option>
|
||||
<option value="3">3 derniers échanges</option>
|
||||
<option value="10">10 derniers échanges</option>
|
||||
<option value="25">25 derniers échanges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="x-note" class="muted" style="font-size:11px"></div>
|
||||
</div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||
<button onclick="closeExportModal()">annuler</button>
|
||||
<button id="x-go" onclick="runExport()" style="border-color:var(--accent);color:var(--accent)">⤓ télécharger</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ask-modal" class="ask-overlay" style="display:none" onclick="if(event.target===this)askResolve(false)">
|
||||
<div class="ask-box">
|
||||
<div id="ask-title" class="ask-title"></div>
|
||||
|
||||
@@ -252,27 +252,93 @@ function renderApiKey(d){
|
||||
}
|
||||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||||
// --- Export de la conversation ---------------------------------------------
|
||||
function openExportModal(){ onExportFormat(); showModal('export-modal'); }
|
||||
function closeExportModal(){ hideModal('export-modal'); }
|
||||
// Le format décide des options AFFICHÉES : les réglages Markdown n'ont aucun
|
||||
// effet sur un JSON, les montrer grisés ne ferait qu'encombrer.
|
||||
function onExportFormat(){
|
||||
const json = exportFormat() === 'json';
|
||||
document.getElementById('x-md-opts').style.display = json ? 'none' : '';
|
||||
document.getElementById('x-json-opts').style.display = json ? '' : 'none';
|
||||
onExportPreview();
|
||||
}
|
||||
function exportFormat(){
|
||||
const r = document.querySelector('input[name=x-fmt]:checked');
|
||||
return r ? r.value : 'md';
|
||||
}
|
||||
// Construit la requête d'export à partir des cases. On n'envoie QUE ce qui
|
||||
// s'écarte du défaut : l'URL reste lisible, et un export complet redevient le
|
||||
// simple /api/chat/export.
|
||||
function exportQuery(){
|
||||
const p = new URLSearchParams();
|
||||
const fmt = exportFormat();
|
||||
if(fmt === 'json') p.set('format','json');
|
||||
const on = id => document.getElementById(id).checked;
|
||||
if(fmt === 'json'){
|
||||
if(!on('x-full')) p.set('full','0');
|
||||
} else {
|
||||
if(!on('x-reasoning')) p.set('reasoning','0');
|
||||
if(!on('x-tools')) p.set('tools','0');
|
||||
if(!on('x-results')) p.set('results','0');
|
||||
}
|
||||
const t = document.getElementById('x-turns').value;
|
||||
if(t !== '0') p.set('turns', t);
|
||||
const q = p.toString();
|
||||
return '/api/chat/export' + (q ? '?'+q : '');
|
||||
}
|
||||
// Dit en une ligne ce que l'export va contenir. Sans ça on découvre en ouvrant
|
||||
// le fichier que le raisonnement qu'on venait chercher n'y est pas.
|
||||
function onExportPreview(){
|
||||
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
|
||||
const tools = document.getElementById('x-tools');
|
||||
const results = document.getElementById('x-results');
|
||||
results.disabled = !tools.checked;
|
||||
if(!tools.checked) results.checked = false;
|
||||
const parts = [];
|
||||
const t = document.getElementById('x-turns');
|
||||
parts.push(t.value === '0' ? 'toute la conversation' : t.options[t.selectedIndex].text);
|
||||
if(exportFormat() === 'json'){
|
||||
parts.push(document.getElementById('x-full').checked ? 'messages + journal complet' : 'messages seuls');
|
||||
} else {
|
||||
const off = [];
|
||||
if(!document.getElementById('x-reasoning').checked) off.push('raisonnements');
|
||||
if(!tools.checked) off.push('outils');
|
||||
else if(!results.checked) off.push('sorties d\'outils');
|
||||
parts.push(off.length ? 'sans ' + off.join(' ni ') : 'tout le contenu');
|
||||
}
|
||||
document.getElementById('x-note').textContent = parts.join(' · ');
|
||||
}
|
||||
async function runExport(){
|
||||
const btn = document.getElementById('x-go');
|
||||
btn.disabled = true;
|
||||
try{ await downloadExport(exportQuery()); closeExportModal(); }
|
||||
finally{ btn.disabled = false; }
|
||||
}
|
||||
// On passe par jfetch (et pas par un simple <a href>) pour deux raisons : la clé
|
||||
// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas,
|
||||
// et le chemin de base change derrière le tunnel (/u/<id>).
|
||||
async function exportChat(format){
|
||||
async function downloadExport(url){
|
||||
toast('préparation de l\'export…');
|
||||
try{
|
||||
const r = await jfetch('/api/chat/export?format=' + encodeURIComponent(format||'md'));
|
||||
const r = await jfetch(url);
|
||||
if(!r.ok){ toast('erreur : HTTP ' + r.status); return; }
|
||||
const blob = await r.blob();
|
||||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom local.
|
||||
let name = 'ajean-conversation.' + (format==='json' ? 'json' : 'md');
|
||||
// Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom
|
||||
// local déduit du type renvoyé.
|
||||
const cd = r.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
if(m) name = m[1];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const name = m ? m[1]
|
||||
: 'ajean-conversation.' + ((r.headers.get('Content-Type')||'').includes('json') ? 'json' : 'md');
|
||||
// ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le
|
||||
// redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte
|
||||
// du const — l'export échouerait avant même de partir.
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name;
|
||||
a.href = blobURL; a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
|
||||
// dans la foulée du clic.
|
||||
setTimeout(()=>URL.revokeObjectURL(url), 10000);
|
||||
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
|
||||
toast('exporté : ' + name);
|
||||
}catch(e){ toast('erreur : ' + e.message); }
|
||||
}
|
||||
|
||||
@@ -785,7 +785,7 @@ func handleMCPTest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handleMem / handleMemSave / handleMemDelete : éditeur web des pages mémoire
|
||||
// (MEMORY/<nom>.md). Payload partagé saveReq (name/old/content) ; "name" = nom
|
||||
// (memory/<nom>.md). Payload partagé saveReq (name/old/content) ; "name" = nom
|
||||
// de fichier de la page.
|
||||
func handleMem(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
|
||||
Reference in new issue
Block a user