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

+10 -1
View File
@@ -243,7 +243,16 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
func (c *Conversation) state() map[string]any { func (c *Conversation) state() map[string]any {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed} // `turns` = nombre d'échanges, borne du curseur de portée de l'export (voir
// countTurns). Compté ici plutôt que par un appel dédié : c'est un balayage
// du journal déjà en main, et l'état est de toute façon relu à l'ouverture.
turns := 0
for _, ev := range c.Log {
if _, ok := ev.Delta["user"]; ok {
turns++
}
}
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
} }
// ErrBusy : une génération est déjà en cours (un seul tour à la fois). // ErrBusy : une génération est déjà en cours (un seul tour à la fois).
+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 // personne ne peut les récupérer. C'est une régression pour qui relisait ses
// échanges, et ce fichier la répare. // échanges, et ce fichier la répare.
// //
// Deux formats : le Markdown est fait pour être LU (et se taille avec des // Deux formats pour un même contenu : le Markdown est fait pour être LU, le
// options — un fil relu pour retrouver une réponse n'a que faire des mille // JSON pour être RETRAITÉ. Les options de contenu (raisonnements, outils,
// lignes de raisonnement qui l'ont produite), le JSON est fait pour être // portée) sont les MÊMES pour les deux — le format ne choisit que le
// RETRAITÉ et reste fidèle par défaut. // 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 // 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. // défaut utilisable (tout serait à false) : passer par defaultExportOpts.
type exportOpts struct { type exportOpts struct {
Format string // "md" | "json" Format string // "md" | "json"
Reasoning bool // Markdown : inclure les blocs de raisonnement Reasoning bool // inclure les blocs de raisonnement
Tools bool // Markdown : inclure les appels d'outils Tools bool // inclure les appels d'outils
Results bool // Markdown : inclure la sortie des outils (implique Tools) Results bool // inclure la sortie des outils (exige Tools)
Turns int // 0 = tout le fil ; N = les N derniers échanges 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 { 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 // 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.Reasoning = flag("reasoning", o.Reasoning)
o.Tools = flag("tools", o.Tools) o.Tools = flag("tools", o.Tools)
o.Results = flag("results", o.Results) o.Results = flag("results", o.Results)
o.JSONFull = flag("full", o.JSONFull)
if v, ok := get("turns"); ok { if v, ok := get("turns"); ok {
if n, err := strconv.Atoi(v); err == nil && n > 0 { if n, err := strconv.Atoi(v); err == nil && n > 0 {
o.Turns = n o.Turns = n
@@ -105,13 +104,52 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
ExportedAt: time.Now().Format(time.RFC3339), ExportedAt: time.Now().Format(time.RFC3339),
CtxUsed: ctxUsed, CtxUsed: ctxUsed,
Messages: msgs, Messages: msgs,
} Log: filterLog(log, o),
if o.JSONFull {
p.Log = log
} }
return json.MarshalIndent(p, "", " ") 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 // lastTurnsMessages garde les n derniers échanges de la vue modèle, un échange
// commençant à un message `user`. // commençant à un message `user`.
func lastTurnsMessages(msgs []Message, n int) []Message { 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 : // 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 // 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). // 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 o.Tools, o.Results = false, false
case "--no-results": case "--no-results":
o.Results = false o.Results = false
case "--messages-only":
o.JSONFull = false
case "--last": case "--last":
if i+1 >= len(args) { if i+1 >= len(args) {
return fmt.Errorf("--last attend un nombre d'échanges") return fmt.Errorf("--last attend un nombre d'échanges")
@@ -352,40 +388,8 @@ func cmdExport(args []string) error {
return nil 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) { func handleChatExport(w http.ResponseWriter, r *http.Request) {
o := exportOptsFromQuery(r.URL.Query()) 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) body, ctype, ext, err := exportBody(o)
if err != nil { if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+30 -46
View File
@@ -138,8 +138,8 @@ func TestExportOptsFromQueryDefautComplet(t *testing.T) {
if o != defaultExportOpts() { if o != defaultExportOpts() {
t.Fatalf("défauts modifiés : %+v", o) t.Fatalf("défauts modifiés : %+v", o)
} }
o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}, "full": {"0"}}) o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}})
if o.Format != "json" || o.Turns != 2 || o.JSONFull { if o.Format != "json" || o.Turns != 2 {
t.Fatalf("options mal lues : %+v", o) t.Fatalf("options mal lues : %+v", o)
} }
// Une valeur de tours absurde ne doit pas vider l'export. // Une valeur de tours absurde ne doit pas vider l'export.
@@ -187,60 +187,44 @@ func TestExportDerniersEchanges(t *testing.T) {
if len(p.Messages) != 2 || p.Messages[0].Content != "et 3+3 ?" { if len(p.Messages) != 2 || p.Messages[0].Content != "et 3+3 ?" {
t.Fatalf("messages mal découpés : %+v", p.Messages) t.Fatalf("messages mal découpés : %+v", p.Messages)
} }
// --messages-only laisse tomber le journal. ⚠️ Structure NEUVE : Unmarshal ne // Les cases de contenu valent AUSSI pour le JSON : le format ne choisit que le
// remet pas à zéro les champs absents du JSON, un `p` réutilisé garderait le // contenant. ⚠️ Structure NEUVE à chaque Unmarshal : les champs absents du JSON
// journal de l'appel précédent et le test passerait pour de mauvaises raisons. // ne sont pas remis à zéro, un `p` réutilisé garderait le journal précédent et
o.JSONFull = false // le test passerait pour de mauvaises raisons.
o = defaultExportOpts()
o.Reasoning = false
b, _ = conv.ExportJSON(o) b, _ = conv.ExportJSON(o)
var sansLog exportPayload var sansRaison exportPayload
if err := json.Unmarshal(b, &sansLog); err != nil { if err := json.Unmarshal(b, &sansRaison); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(sansLog.Log) != 0 { for _, ev := range sansRaison.Log {
t.Fatalf("journal présent malgré --messages-only : %d événements", len(sansLog.Log)) if _, ok := ev.Delta["reasoning_content"]; ok {
t.Fatal("raisonnement présent dans le JSON malgré l'option coupée")
}
} }
if len(sansLog.Messages) == 0 { if len(sansRaison.Messages) == 0 || len(sansRaison.Log) == 0 {
t.Fatal("messages perdus avec --messages-only") t.Fatal("le JSON a été vidé alors que seule la réflexion était exclue")
} }
} }
// La sonde alimente le curseur de portée et la taille annoncée : elle doit // Le curseur de portée est borné par le nombre d'échanges publié dans l'état de
// rendre la taille EXACTE du fichier qui serait téléchargé avec les mêmes // la conversation : sans lui, l'interface ne saurait pas jusqu'où aller.
// options, sinon l'interface annonce un poids qu'elle ne tient pas. func TestChatStatePublieLeNombreDEchanges(t *testing.T) {
func TestHandleChatExportProbe(t *testing.T) {
convDeTest(t) convDeTest(t)
probe := func(q string) (size, turns int) { if n, _ := conv.state()["turns"].(int); n != 1 {
rr := httptest.NewRecorder() t.Fatalf("échanges = %v ; le fil de test en a 1", conv.state()["turns"])
handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?probe=1&"+q, nil))
if rr.Code != 200 {
t.Fatalf("%s : HTTP %d", q, rr.Code)
}
var out struct {
Size int `json:"size"`
Turns int `json:"turns"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out.Size, out.Turns
} }
download := func(q string) int { conv.mu.Lock()
rr := httptest.NewRecorder() conv.Log = append(conv.Log, LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}})
handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?"+q, nil)) conv.mu.Unlock()
return rr.Body.Len() if n, _ := conv.state()["turns"].(int); n != 2 {
t.Fatalf("échanges = %v après un second tour", conv.state()["turns"])
} }
for _, q := range []string{"", "reasoning=0", "format=json", "turns=1"} { // Conversation vide : zéro échange, c'est ce qui déclenche « rien à exporter ».
if s, _ := probe(q); s != download(q) { conv.Reset()
t.Errorf("q=%q : sonde %d o, téléchargement %d o", q, s, download(q)) if n, _ := conv.state()["turns"].(int); n != 0 {
} t.Fatalf("échanges = %v sur un fil vide", conv.state()["turns"])
}
// Le compte d'échanges ne dépend PAS des options : c'est la borne du curseur,
// elle ne doit pas rétrécir quand on réduit la portée.
if _, n := probe("turns=1"); n != 1 {
t.Errorf("échanges comptés = %d ; le fil de test en a 1", n)
}
if got := conv.countTurns(); got != 1 {
t.Errorf("countTurns = %d", got)
} }
} }
+2 -2
View File
@@ -146,8 +146,8 @@ Interaction:
chat [system-prompt] chat terminal streamé chat [system-prompt] chat terminal streamé
export [options] [fichier] exporte la conversation de l'interface web export [options] [fichier] exporte la conversation de l'interface web
(Markdown par défaut, « - » = sortie standard) (Markdown par défaut, « - » = sortie standard)
--json --last N --no-reasoning --no-tools --json --last N --no-reasoning
--no-results --messages-only --no-tools --no-results
agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire) agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire)
memory [off|ondemand|always|status] mode mémoire de l'IA memory [off|ondemand|always|status] mode mémoire de l'IA
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>] internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
+95 -137
View File
@@ -1091,8 +1091,9 @@ button:hover{border-color:var(--dim);color:var(--text)}
.xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)} .xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)} .xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.xr-range:disabled{cursor:default;opacity:.5} .xr-range:disabled{cursor:default;opacity:.5}
.xr-legend{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-top:8px;font-size:12px} /* Conversation vide : un seul message centré à la place des réglages. */
.xr-legend .xr-size{color:var(--dim);font-size:11px;white-space:nowrap;font-variant-numeric:tabular-nums} .xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
</style></head><body> </style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button> <button id="menubtn" onclick="toggleSide()">☰</button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est <!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -1429,57 +1430,63 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div> </div>
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des <!-- 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 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 emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
retenu sont MASQUÉES, pas grisées : un réglage sans effet visible n'a rien mêmes pour les deux formats : le format ne décide que du contenant. -->
à faire à l'écran. --> <div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
<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 class="modal-card" style="max-width:460px">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:460px;display:flex;flex-direction:column"> <div class="modal-head">
<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> <strong>Exporter la conversation</strong>
<button onclick="closeExportModal()" style="margin:0">×</button> <button class="modal-x" onclick="closeExportModal()" title="fermer">×</button>
</div> </div>
<div style="padding:16px 20px;display:flex;flex-direction:column;gap:14px"> <!-- Conversation vide : pas d'options à régler, un seul message. Proposer
<div> des réglages pour un fichier qui ne contiendrait rien n'a aucun sens. -->
<div class="subhead" style="margin:0 0 6px">Format</div> <div id="x-empty" class="xempty" style="display:none">
<!-- Contrôle segmenté (.seg), comme le choix de moteur dans l'éditeur de <div class="xempty-i">⤓</div>
preset : deux cases côte à côte se lisent d'un coup d'œil là où deux <div>Rien à exporter</div>
boutons radio empilés obligent à lire ligne par ligne. --> <div class="pe-note">La conversation est vide.</div>
<div class="seg">
<label><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()">Markdown<span class="be-note">à lire</span></label>
<label><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()">JSON<span class="be-note">à retraiter</span></label>
</div>
</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>
<!-- Curseur borné au nombre RÉEL d'échanges du fil (rempli à l'ouverture
par /api/chat/export?probe=1) : la butée de droite vaut « toute la
conversation ». Un menu à valeurs fixes proposait « 25 derniers
échanges » sur un fil qui en comptait trois, sans jamais dire
combien il y en avait. -->
<div class="xr">
<div class="xr-track"><div class="xr-fill" id="x-fill"></div></div>
<input type="range" class="xr-range" id="x-turns" min="1" max="1" value="1" oninput="onExportPreview()" aria-label="nombre d'échanges à exporter">
</div>
<div class="xr-legend">
<span id="x-scope">toute la conversation</span>
<span id="x-size" class="xr-size">…</span>
</div>
</div>
<div id="x-note" class="muted" style="font-size:11px"></div>
</div> </div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px"> <div id="x-body" class="pe-body" style="gap:20px">
<button onclick="closeExportModal()">annuler</button> <div class="pe-group">
<button id="x-go" onclick="runExport()" style="border-color:var(--accent);color:var(--accent)">⤓ télécharger</button> <div class="pe-gh">Format</div>
<div class="pe-seg">
<label><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()"><span>Markdown</span><span class="be-note">à lire</span></label>
<label><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()"><span>JSON</span><span class="be-note">à retraiter</span></label>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Portée</div>
<div class="pe-list">
<div class="pe-row stack">
<div class="xr">
<div class="xr-track"><div class="xr-fill" id="x-fill"></div></div>
<input type="range" class="xr-range" id="x-turns" min="1" max="1" value="1" oninput="onExportPreview()" aria-label="nombre d'échanges à exporter">
</div>
<div class="pe-note" id="x-scope"></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Contenu</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Raisonnements<span class="pe-sub">la réflexion du modèle, souvent plus longue que la réponse</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-reasoning" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Appels d'outils<span class="pe-sub">commandes, lectures et écritures de fichiers</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-tools" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Sorties des outils<span class="pe-sub">ce que chaque outil a répondu</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-results" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div class="pe-note" id="x-note"></div>
</div>
<div class="modal-foot" style="justify-content:flex-end">
<button class="pe-btn" onclick="closeExportModal()">annuler</button>
<button class="pe-btn primary" id="x-go" onclick="runExport()">⤓ télécharger</button>
</div> </div>
</div> </div>
</div> </div>
@@ -2514,33 +2521,33 @@ function renderApiKey(d){
} }
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
// --- Export de la conversation --------------------------------------------- // --- Export de la conversation ---------------------------------------------
// Taille d'un export, en français et avec une décimale. fmtSize convient aux // xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de
// modèles (des Go, où l'arrondi ne se voit pas) mais pas ici : un fil de 2585 // /api/chat/state ; 0 = conversation vide, et la fenêtre n'a alors rien à régler.
// octets s'y affichait « 3 KB », soit une taille fausse dans le seul endroit de
// l'interface qui prétend annoncer la taille réelle.
function fmtExportSize(b){
b = Number(b) || 0;
if(b < 1024) return b + ' o';
if(b < 1024*1024) return (b/1024).toFixed(1).replace('.', ',') + ' Ko';
return (b/(1024*1024)).toFixed(1).replace('.', ',') + ' Mo';
}
// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Connu
// seulement après la sonde ; d'ici là le curseur est désactivé plutôt que faux.
let xTurnsTotal = 0; let xTurnsTotal = 0;
function openExportModal(){ async function openExportModal(){
onExportFormat();
showModal('export-modal'); showModal('export-modal');
probeExport(); // remplit la borne du curseur et la taille réelle try{
const st = await jget('/api/chat/state');
xTurnsTotal = (st && st.turns) || 0;
}catch(e){ xTurnsTotal = 0; }
// Rien à exporter : on remplace les réglages par un message, plutôt que de
// proposer de tailler un fichier qui serait vide de toute façon.
const vide = xTurnsTotal === 0;
document.getElementById('x-empty').style.display = vide ? '' : 'none';
document.getElementById('x-body').style.display = vide ? 'none' : '';
document.getElementById('x-go').style.display = vide ? 'none' : '';
if(vide) return;
const el = document.getElementById('x-turns');
el.max = String(xTurnsTotal);
el.value = el.max; // par défaut : tout le fil
el.disabled = xTurnsTotal < 2; // un seul échange : rien à trancher
onExportFormat();
} }
function closeExportModal(){ hideModal('export-modal'); } function closeExportModal(){ hideModal('export-modal'); }
// Le format décide des options AFFICHÉES : les réglages Markdown n'ont aucun // Le format ne change QUE le contenant : les options de contenu sont les mêmes
// effet sur un JSON, les montrer grisés ne ferait qu'encombrer. // des deux côtés. Il n'y a donc plus rien à montrer ou cacher ici, seulement le
function onExportFormat(){ // résumé à rafraîchir.
const json = exportFormat() === 'json'; function onExportFormat(){ onExportPreview(); }
document.getElementById('x-md-opts').style.display = json ? 'none' : '';
document.getElementById('x-json-opts').style.display = json ? '' : 'none';
onExportPreview();
}
function exportFormat(){ function exportFormat(){
const r = document.querySelector('input[name=x-fmt]:checked'); const r = document.querySelector('input[name=x-fmt]:checked');
return r ? r.value : 'md'; return r ? r.value : 'md';
@@ -2550,16 +2557,11 @@ function exportFormat(){
// simple /api/chat/export. // simple /api/chat/export.
function exportQuery(){ function exportQuery(){
const p = new URLSearchParams(); const p = new URLSearchParams();
const fmt = exportFormat(); if(exportFormat() === 'json') p.set('format','json');
if(fmt === 'json') p.set('format','json');
const on = id => document.getElementById(id).checked; const on = id => document.getElementById(id).checked;
if(fmt === 'json'){ if(!on('x-reasoning')) p.set('reasoning','0');
if(!on('x-full')) p.set('full','0'); if(!on('x-tools')) p.set('tools','0');
} else { if(!on('x-results')) p.set('results','0');
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 = exportTurns(); const t = exportTurns();
if(t > 0) p.set('turns', String(t)); if(t > 0) p.set('turns', String(t));
const q = p.toString(); const q = p.toString();
@@ -2569,13 +2571,10 @@ function exportQuery(){
// « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se // « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se
// périmerait à l'échange suivant. // périmerait à l'échange suivant.
function exportTurns(){ function exportTurns(){
const el = document.getElementById('x-turns'); const v = parseInt(document.getElementById('x-turns').value, 10) || 0;
const v = parseInt(el.value, 10) || 0;
if(!xTurnsTotal || v >= xTurnsTotal) return 0; if(!xTurnsTotal || v >= xTurnsTotal) return 0;
return v; return v;
} }
// 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(){ function onExportPreview(){
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit. // Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
const tools = document.getElementById('x-tools'); const tools = document.getElementById('x-tools');
@@ -2583,18 +2582,13 @@ function onExportPreview(){
results.disabled = !tools.checked; results.disabled = !tools.checked;
if(!tools.checked) results.checked = false; if(!tools.checked) results.checked = false;
paintExportRange(); paintExportRange();
const parts = []; const off = [];
if(exportFormat() === 'json'){ if(!document.getElementById('x-reasoning').checked) off.push('raisonnements');
parts.push(document.getElementById('x-full').checked ? 'messages + journal complet' : 'messages seuls'); if(!tools.checked) off.push('outils');
} else { else if(!results.checked) off.push('sorties d\'outils');
const off = []; document.getElementById('x-note').textContent = off.length
if(!document.getElementById('x-reasoning').checked) off.push('raisonnements'); ? 'Export sans ' + off.join(' ni ') + '.'
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(' · ');
probeExportSoon();
} }
// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque // Remplit la piste du curseur et son libellé. Purement local : appelé à chaque
// pixel de glissement, il ne doit RIEN demander au serveur. // pixel de glissement, il ne doit RIEN demander au serveur.
@@ -2610,45 +2604,9 @@ function paintExportRange(){
if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)'; if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)';
const scope = document.getElementById('x-scope'); const scope = document.getElementById('x-scope');
if(!scope) return; if(!scope) return;
if(!xTurnsTotal) scope.textContent = 'conversation vide'; scope.textContent = n >= xTurnsTotal
else if(n >= xTurnsTotal) scope.textContent = 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'; ? 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'
else scope.textContent = n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal; : n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal;
}
// Sonde la taille RÉELLE du fichier pour les options courantes. Le serveur rend
// l'export puis le jette : c'est peu coûteux, mais pas au point de le refaire à
// chaque pixel du curseur, d'où le délai. Un `seq` protège du désordre : sur un
// glissement rapide, une réponse en retard ne doit pas écraser la plus récente.
let xProbeTimer = null, xProbeSeq = 0;
function probeExportSoon(){
document.getElementById('x-size').textContent = '…';
// ⚠️ On périme TOUT DE SUITE la sonde en vol, sans attendre que la suivante
// parte. Sans ça, une réponse partie avant le changement d'options revenait
// écraser le « … » par la taille de l'état PRÉCÉDENT : le poids affiché
// retardait d'un cran sur le curseur, ce qui est pire que pas de chiffre.
xProbeSeq++;
clearTimeout(xProbeTimer);
xProbeTimer = setTimeout(probeExport, 250);
}
async function probeExport(){
const seq = ++xProbeSeq;
const sep = exportQuery().includes('?') ? '&' : '?';
try{
const r = await jget(exportQuery() + sep + 'probe=1');
if(seq !== xProbeSeq) return; // une sonde plus récente a pris la main
if(!r || !r.ok) throw new Error('probe');
// La borne du curseur n'est connue qu'ici : on la pose (et on la garde en
// butée droite) avant de repeindre.
const el = document.getElementById('x-turns');
const atMax = parseInt(el.value,10) >= (xTurnsTotal || 1);
xTurnsTotal = r.turns || 0;
el.max = String(Math.max(1, xTurnsTotal));
el.disabled = xTurnsTotal < 2; // 0 ou 1 échange : rien à trancher
if(atMax || parseInt(el.value,10) > xTurnsTotal) el.value = el.max;
document.getElementById('x-size').textContent = fmtExportSize(r.size);
paintExportRange();
}catch(e){
if(seq === xProbeSeq) document.getElementById('x-size').textContent = '';
}
} }
async function runExport(){ async function runExport(){
const btn = document.getElementById('x-go'); const btn = document.getElementById('x-go');
+54 -48
View File
@@ -361,57 +361,63 @@
</div> </div>
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des <!-- 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 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 emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
retenu sont MASQUÉES, pas grisées : un réglage sans effet visible n'a rien mêmes pour les deux formats : le format ne décide que du contenant. -->
à faire à l'écran. --> <div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
<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 class="modal-card" style="max-width:460px">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:460px;display:flex;flex-direction:column"> <div class="modal-head">
<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> <strong>Exporter la conversation</strong>
<button onclick="closeExportModal()" style="margin:0">×</button> <button class="modal-x" onclick="closeExportModal()" title="fermer">×</button>
</div> </div>
<div style="padding:16px 20px;display:flex;flex-direction:column;gap:14px"> <!-- Conversation vide : pas d'options à régler, un seul message. Proposer
<div> des réglages pour un fichier qui ne contiendrait rien n'a aucun sens. -->
<div class="subhead" style="margin:0 0 6px">Format</div> <div id="x-empty" class="xempty" style="display:none">
<!-- Contrôle segmenté (.seg), comme le choix de moteur dans l'éditeur de <div class="xempty-i">⤓</div>
preset : deux cases côte à côte se lisent d'un coup d'œil là où deux <div>Rien à exporter</div>
boutons radio empilés obligent à lire ligne par ligne. --> <div class="pe-note">La conversation est vide.</div>
<div class="seg">
<label><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()">Markdown<span class="be-note">à lire</span></label>
<label><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()">JSON<span class="be-note">à retraiter</span></label>
</div>
</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>
<!-- Curseur borné au nombre RÉEL d'échanges du fil (rempli à l'ouverture
par /api/chat/export?probe=1) : la butée de droite vaut « toute la
conversation ». Un menu à valeurs fixes proposait « 25 derniers
échanges » sur un fil qui en comptait trois, sans jamais dire
combien il y en avait. -->
<div class="xr">
<div class="xr-track"><div class="xr-fill" id="x-fill"></div></div>
<input type="range" class="xr-range" id="x-turns" min="1" max="1" value="1" oninput="onExportPreview()" aria-label="nombre d'échanges à exporter">
</div>
<div class="xr-legend">
<span id="x-scope">toute la conversation</span>
<span id="x-size" class="xr-size">…</span>
</div>
</div>
<div id="x-note" class="muted" style="font-size:11px"></div>
</div> </div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px"> <div id="x-body" class="pe-body" style="gap:20px">
<button onclick="closeExportModal()">annuler</button> <div class="pe-group">
<button id="x-go" onclick="runExport()" style="border-color:var(--accent);color:var(--accent)">⤓ télécharger</button> <div class="pe-gh">Format</div>
<div class="pe-seg">
<label><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()"><span>Markdown</span><span class="be-note">à lire</span></label>
<label><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()"><span>JSON</span><span class="be-note">à retraiter</span></label>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Portée</div>
<div class="pe-list">
<div class="pe-row stack">
<div class="xr">
<div class="xr-track"><div class="xr-fill" id="x-fill"></div></div>
<input type="range" class="xr-range" id="x-turns" min="1" max="1" value="1" oninput="onExportPreview()" aria-label="nombre d'échanges à exporter">
</div>
<div class="pe-note" id="x-scope"></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Contenu</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Raisonnements<span class="pe-sub">la réflexion du modèle, souvent plus longue que la réponse</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-reasoning" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Appels d'outils<span class="pe-sub">commandes, lectures et écritures de fichiers</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-tools" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Sorties des outils<span class="pe-sub">ce que chaque outil a répondu</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="x-results" checked onchange="onExportPreview()"><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div class="pe-note" id="x-note"></div>
</div>
<div class="modal-foot" style="justify-content:flex-end">
<button class="pe-btn" onclick="closeExportModal()">annuler</button>
<button class="pe-btn primary" id="x-go" onclick="runExport()">⤓ télécharger</button>
</div> </div>
</div> </div>
</div> </div>
+38 -87
View File
@@ -252,33 +252,33 @@ function renderApiKey(d){
} }
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
// --- Export de la conversation --------------------------------------------- // --- Export de la conversation ---------------------------------------------
// Taille d'un export, en français et avec une décimale. fmtSize convient aux // xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de
// modèles (des Go, où l'arrondi ne se voit pas) mais pas ici : un fil de 2585 // /api/chat/state ; 0 = conversation vide, et la fenêtre n'a alors rien à régler.
// octets s'y affichait « 3 KB », soit une taille fausse dans le seul endroit de
// l'interface qui prétend annoncer la taille réelle.
function fmtExportSize(b){
b = Number(b) || 0;
if(b < 1024) return b + ' o';
if(b < 1024*1024) return (b/1024).toFixed(1).replace('.', ',') + ' Ko';
return (b/(1024*1024)).toFixed(1).replace('.', ',') + ' Mo';
}
// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Connu
// seulement après la sonde ; d'ici là le curseur est désactivé plutôt que faux.
let xTurnsTotal = 0; let xTurnsTotal = 0;
function openExportModal(){ async function openExportModal(){
onExportFormat();
showModal('export-modal'); showModal('export-modal');
probeExport(); // remplit la borne du curseur et la taille réelle try{
const st = await jget('/api/chat/state');
xTurnsTotal = (st && st.turns) || 0;
}catch(e){ xTurnsTotal = 0; }
// Rien à exporter : on remplace les réglages par un message, plutôt que de
// proposer de tailler un fichier qui serait vide de toute façon.
const vide = xTurnsTotal === 0;
document.getElementById('x-empty').style.display = vide ? '' : 'none';
document.getElementById('x-body').style.display = vide ? 'none' : '';
document.getElementById('x-go').style.display = vide ? 'none' : '';
if(vide) return;
const el = document.getElementById('x-turns');
el.max = String(xTurnsTotal);
el.value = el.max; // par défaut : tout le fil
el.disabled = xTurnsTotal < 2; // un seul échange : rien à trancher
onExportFormat();
} }
function closeExportModal(){ hideModal('export-modal'); } function closeExportModal(){ hideModal('export-modal'); }
// Le format décide des options AFFICHÉES : les réglages Markdown n'ont aucun // Le format ne change QUE le contenant : les options de contenu sont les mêmes
// effet sur un JSON, les montrer grisés ne ferait qu'encombrer. // des deux côtés. Il n'y a donc plus rien à montrer ou cacher ici, seulement le
function onExportFormat(){ // résumé à rafraîchir.
const json = exportFormat() === 'json'; function onExportFormat(){ onExportPreview(); }
document.getElementById('x-md-opts').style.display = json ? 'none' : '';
document.getElementById('x-json-opts').style.display = json ? '' : 'none';
onExportPreview();
}
function exportFormat(){ function exportFormat(){
const r = document.querySelector('input[name=x-fmt]:checked'); const r = document.querySelector('input[name=x-fmt]:checked');
return r ? r.value : 'md'; return r ? r.value : 'md';
@@ -288,16 +288,11 @@ function exportFormat(){
// simple /api/chat/export. // simple /api/chat/export.
function exportQuery(){ function exportQuery(){
const p = new URLSearchParams(); const p = new URLSearchParams();
const fmt = exportFormat(); if(exportFormat() === 'json') p.set('format','json');
if(fmt === 'json') p.set('format','json');
const on = id => document.getElementById(id).checked; const on = id => document.getElementById(id).checked;
if(fmt === 'json'){ if(!on('x-reasoning')) p.set('reasoning','0');
if(!on('x-full')) p.set('full','0'); if(!on('x-tools')) p.set('tools','0');
} else { if(!on('x-results')) p.set('results','0');
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 = exportTurns(); const t = exportTurns();
if(t > 0) p.set('turns', String(t)); if(t > 0) p.set('turns', String(t));
const q = p.toString(); const q = p.toString();
@@ -307,13 +302,10 @@ function exportQuery(){
// « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se // « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se
// périmerait à l'échange suivant. // périmerait à l'échange suivant.
function exportTurns(){ function exportTurns(){
const el = document.getElementById('x-turns'); const v = parseInt(document.getElementById('x-turns').value, 10) || 0;
const v = parseInt(el.value, 10) || 0;
if(!xTurnsTotal || v >= xTurnsTotal) return 0; if(!xTurnsTotal || v >= xTurnsTotal) return 0;
return v; return v;
} }
// 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(){ function onExportPreview(){
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit. // Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
const tools = document.getElementById('x-tools'); const tools = document.getElementById('x-tools');
@@ -321,18 +313,13 @@ function onExportPreview(){
results.disabled = !tools.checked; results.disabled = !tools.checked;
if(!tools.checked) results.checked = false; if(!tools.checked) results.checked = false;
paintExportRange(); paintExportRange();
const parts = []; const off = [];
if(exportFormat() === 'json'){ if(!document.getElementById('x-reasoning').checked) off.push('raisonnements');
parts.push(document.getElementById('x-full').checked ? 'messages + journal complet' : 'messages seuls'); if(!tools.checked) off.push('outils');
} else { else if(!results.checked) off.push('sorties d\'outils');
const off = []; document.getElementById('x-note').textContent = off.length
if(!document.getElementById('x-reasoning').checked) off.push('raisonnements'); ? 'Export sans ' + off.join(' ni ') + '.'
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(' · ');
probeExportSoon();
} }
// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque // Remplit la piste du curseur et son libellé. Purement local : appelé à chaque
// pixel de glissement, il ne doit RIEN demander au serveur. // pixel de glissement, il ne doit RIEN demander au serveur.
@@ -348,45 +335,9 @@ function paintExportRange(){
if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)'; if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)';
const scope = document.getElementById('x-scope'); const scope = document.getElementById('x-scope');
if(!scope) return; if(!scope) return;
if(!xTurnsTotal) scope.textContent = 'conversation vide'; scope.textContent = n >= xTurnsTotal
else if(n >= xTurnsTotal) scope.textContent = 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'; ? 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'
else scope.textContent = n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal; : n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal;
}
// Sonde la taille RÉELLE du fichier pour les options courantes. Le serveur rend
// l'export puis le jette : c'est peu coûteux, mais pas au point de le refaire à
// chaque pixel du curseur, d'où le délai. Un `seq` protège du désordre : sur un
// glissement rapide, une réponse en retard ne doit pas écraser la plus récente.
let xProbeTimer = null, xProbeSeq = 0;
function probeExportSoon(){
document.getElementById('x-size').textContent = '…';
// ⚠️ On périme TOUT DE SUITE la sonde en vol, sans attendre que la suivante
// parte. Sans ça, une réponse partie avant le changement d'options revenait
// écraser le « … » par la taille de l'état PRÉCÉDENT : le poids affiché
// retardait d'un cran sur le curseur, ce qui est pire que pas de chiffre.
xProbeSeq++;
clearTimeout(xProbeTimer);
xProbeTimer = setTimeout(probeExport, 250);
}
async function probeExport(){
const seq = ++xProbeSeq;
const sep = exportQuery().includes('?') ? '&' : '?';
try{
const r = await jget(exportQuery() + sep + 'probe=1');
if(seq !== xProbeSeq) return; // une sonde plus récente a pris la main
if(!r || !r.ok) throw new Error('probe');
// La borne du curseur n'est connue qu'ici : on la pose (et on la garde en
// butée droite) avant de repeindre.
const el = document.getElementById('x-turns');
const atMax = parseInt(el.value,10) >= (xTurnsTotal || 1);
xTurnsTotal = r.turns || 0;
el.max = String(Math.max(1, xTurnsTotal));
el.disabled = xTurnsTotal < 2; // 0 ou 1 échange : rien à trancher
if(atMax || parseInt(el.value,10) > xTurnsTotal) el.value = el.max;
document.getElementById('x-size').textContent = fmtExportSize(r.size);
paintExportRange();
}catch(e){
if(seq === xProbeSeq) document.getElementById('x-size').textContent = '';
}
} }
async function runExport(){ async function runExport(){
const btn = document.getElementById('x-go'); const btn = document.getElementById('x-go');
+3 -2
View File
@@ -1065,5 +1065,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
.xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)} .xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)} .xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.xr-range:disabled{cursor:default;opacity:.5} .xr-range:disabled{cursor:default;opacity:.5}
.xr-legend{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-top:8px;font-size:12px} /* Conversation vide : un seul message centré à la place des réglages. */
.xr-legend .xr-size{color:var(--dim);font-size:11px;white-space:nowrap;font-variant-numeric:tabular-nums} .xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}