mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-15 11:19:07 +02:00
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:
1 parent
b7042f1127
commit
4a66b27227
8 files changed
+284
-371
No files matched your search
@@ -243,7 +243,16 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
func (c *Conversation) state() map[string]any {
|
||||
c.mu.Lock()
|
||||
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).
|
||||
|
||||
@@ -20,24 +20,24 @@ import (
|
||||
// personne ne peut les récupérer. C'est une régression pour qui relisait ses
|
||||
// échanges, et ce fichier la répare.
|
||||
//
|
||||
// Deux formats : le Markdown est fait pour être LU (et se taille avec des
|
||||
// options — un fil relu pour retrouver une réponse n'a que faire des mille
|
||||
// lignes de raisonnement qui l'ont produite), le JSON est fait pour être
|
||||
// RETRAITÉ et reste fidèle par défaut.
|
||||
// Deux formats pour un même contenu : le Markdown est fait pour être LU, le
|
||||
// JSON pour être RETRAITÉ. Les options de contenu (raisonnements, outils,
|
||||
// portée) sont les MÊMES pour les deux — le format ne choisit que le
|
||||
// contenant. Un fil relu pour retrouver une réponse n'a que faire des mille
|
||||
// lignes de raisonnement qui l'ont produite, et ça vaut dans les deux formats.
|
||||
|
||||
// exportOpts décrit ce qu'on veut sortir. Le zéro de la structure n'est PAS un
|
||||
// défaut utilisable (tout serait à false) : passer par defaultExportOpts.
|
||||
type exportOpts struct {
|
||||
Format string // "md" | "json"
|
||||
Reasoning bool // Markdown : inclure les blocs de raisonnement
|
||||
Tools bool // Markdown : inclure les appels d'outils
|
||||
Results bool // Markdown : inclure la sortie des outils (implique Tools)
|
||||
Reasoning bool // inclure les blocs de raisonnement
|
||||
Tools bool // inclure les appels d'outils
|
||||
Results bool // inclure la sortie des outils (exige Tools)
|
||||
Turns int // 0 = tout le fil ; N = les N derniers échanges
|
||||
JSONFull bool // JSON : joindre le journal d'affichage en plus des messages
|
||||
}
|
||||
|
||||
func defaultExportOpts() exportOpts {
|
||||
return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true, JSONFull: true}
|
||||
return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true}
|
||||
}
|
||||
|
||||
// exportOptsFromQuery lit les options d'une requête. Toute clé absente garde sa
|
||||
@@ -64,7 +64,6 @@ func exportOptsFromQuery(q map[string][]string) exportOpts {
|
||||
o.Reasoning = flag("reasoning", o.Reasoning)
|
||||
o.Tools = flag("tools", o.Tools)
|
||||
o.Results = flag("results", o.Results)
|
||||
o.JSONFull = flag("full", o.JSONFull)
|
||||
if v, ok := get("turns"); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
o.Turns = n
|
||||
@@ -105,13 +104,52 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
CtxUsed: ctxUsed,
|
||||
Messages: msgs,
|
||||
}
|
||||
if o.JSONFull {
|
||||
p.Log = log
|
||||
Log: filterLog(log, o),
|
||||
}
|
||||
return json.MarshalIndent(p, "", " ")
|
||||
}
|
||||
|
||||
// filterLog applique au journal les MÊMES cases que le Markdown. Les deux
|
||||
// formats avaient auparavant des options distinctes (le JSON proposait un
|
||||
// obscur « journal d'affichage » à la place des trois autres) : personne ne
|
||||
// pouvait deviner ce que ça changeait, et cocher une case n'avait pas le même
|
||||
// sens selon le format choisi juste au-dessus. Le format ne décide plus que du
|
||||
// contenant ; ce qu'on emporte se règle une fois pour toutes.
|
||||
func filterLog(log []LogEvent, o exportOpts) []LogEvent {
|
||||
if o.Reasoning && o.Tools && o.Results {
|
||||
return log
|
||||
}
|
||||
out := make([]LogEvent, 0, len(log))
|
||||
for _, ev := range log {
|
||||
if _, ok := ev.Delta["reasoning_content"]; ok && !o.Reasoning {
|
||||
continue
|
||||
}
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
if !o.Tools {
|
||||
continue
|
||||
}
|
||||
if !o.Results {
|
||||
// Copie : l'événement appartient à la conversation vivante, le
|
||||
// modifier en place amputerait le fil affiché dans les navigateurs.
|
||||
cp := make(map[string]any, len(tu))
|
||||
for k, v := range tu {
|
||||
if k != "result" {
|
||||
cp[k] = v
|
||||
}
|
||||
}
|
||||
d := make(map[string]any, len(ev.Delta))
|
||||
for k, v := range ev.Delta {
|
||||
d[k] = v
|
||||
}
|
||||
d["tool_used"] = cp
|
||||
ev.Delta = d
|
||||
}
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lastTurnsMessages garde les n derniers échanges de la vue modèle, un échange
|
||||
// commençant à un message `user`.
|
||||
func lastTurnsMessages(msgs []Message, n int) []Message {
|
||||
@@ -282,7 +320,7 @@ func exportBody(o exportOpts) ([]byte, string, string, error) {
|
||||
//
|
||||
// Options de contenu, les mêmes que la fenêtre d'export de l'interface :
|
||||
//
|
||||
// --no-reasoning --no-tools --no-results --last N --messages-only
|
||||
// --no-reasoning --no-tools --no-results --last N
|
||||
//
|
||||
// La conversation est relue depuis la base à chaque appel : la commande marche
|
||||
// pendant que le service tourne (bbolt n'est jamais gardé ouvert, voir store.go).
|
||||
@@ -302,8 +340,6 @@ func cmdExport(args []string) error {
|
||||
o.Tools, o.Results = false, false
|
||||
case "--no-results":
|
||||
o.Results = false
|
||||
case "--messages-only":
|
||||
o.JSONFull = false
|
||||
case "--last":
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("--last attend un nombre d'échanges")
|
||||
@@ -352,40 +388,8 @@ func cmdExport(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// countTurns compte les échanges du journal d'affichage (un échange = un
|
||||
// message utilisateur). C'est la borne du curseur de portée dans l'interface :
|
||||
// proposer « 25 derniers échanges » sur un fil qui en compte trois n'avait
|
||||
// aucun sens, et ne disait pas non plus combien il y en avait.
|
||||
func (c *Conversation) countTurns() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
n := 0
|
||||
for _, ev := range c.Log {
|
||||
if _, ok := ev.Delta["user"]; ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// handleChatExport sert la conversation en téléchargement. Options en paramètres
|
||||
// d'URL (voir exportOptsFromQuery) ; sans aucun paramètre, c'est l'export complet.
|
||||
//
|
||||
// Avec `probe=1`, on ne renvoie PAS le fichier mais sa taille et le nombre
|
||||
// d'échanges disponibles : l'interface annonce ainsi le poids réel avant de
|
||||
// télécharger. Le rendu est fait puis jeté, ce qui coûte quelques millisecondes
|
||||
// et évite d'entretenir un second calcul de taille qui finirait par mentir.
|
||||
func handleChatExport(w http.ResponseWriter, r *http.Request) {
|
||||
o := exportOptsFromQuery(r.URL.Query())
|
||||
if r.URL.Query().Get("probe") == "1" {
|
||||
body, _, _, err := exportBody(o)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "size": len(body), "turns": conv.countTurns()})
|
||||
return
|
||||
}
|
||||
body, ctype, ext, err := exportBody(o)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
|
||||
@@ -138,8 +138,8 @@ func TestExportOptsFromQueryDefautComplet(t *testing.T) {
|
||||
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 {
|
||||
o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}})
|
||||
if o.Format != "json" || o.Turns != 2 {
|
||||
t.Fatalf("options mal lues : %+v", o)
|
||||
}
|
||||
// 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 ?" {
|
||||
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
|
||||
// Les cases de contenu valent AUSSI pour le JSON : le format ne choisit que le
|
||||
// contenant. ⚠️ Structure NEUVE à chaque Unmarshal : les champs absents du JSON
|
||||
// ne sont pas remis à zéro, un `p` réutilisé garderait le journal précédent et
|
||||
// le test passerait pour de mauvaises raisons.
|
||||
o = defaultExportOpts()
|
||||
o.Reasoning = false
|
||||
b, _ = conv.ExportJSON(o)
|
||||
var sansLog exportPayload
|
||||
if err := json.Unmarshal(b, &sansLog); err != nil {
|
||||
var sansRaison exportPayload
|
||||
if err := json.Unmarshal(b, &sansRaison); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sansLog.Log) != 0 {
|
||||
t.Fatalf("journal présent malgré --messages-only : %d événements", len(sansLog.Log))
|
||||
for _, ev := range sansRaison.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 {
|
||||
t.Fatal("messages perdus avec --messages-only")
|
||||
if len(sansRaison.Messages) == 0 || len(sansRaison.Log) == 0 {
|
||||
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
|
||||
// rendre la taille EXACTE du fichier qui serait téléchargé avec les mêmes
|
||||
// options, sinon l'interface annonce un poids qu'elle ne tient pas.
|
||||
func TestHandleChatExportProbe(t *testing.T) {
|
||||
// Le curseur de portée est borné par le nombre d'échanges publié dans l'état de
|
||||
// la conversation : sans lui, l'interface ne saurait pas jusqu'où aller.
|
||||
func TestChatStatePublieLeNombreDEchanges(t *testing.T) {
|
||||
convDeTest(t)
|
||||
probe := func(q string) (size, turns int) {
|
||||
rr := httptest.NewRecorder()
|
||||
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
|
||||
if n, _ := conv.state()["turns"].(int); n != 1 {
|
||||
t.Fatalf("échanges = %v ; le fil de test en a 1", conv.state()["turns"])
|
||||
}
|
||||
download := func(q string) int {
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?"+q, nil))
|
||||
return rr.Body.Len()
|
||||
conv.mu.Lock()
|
||||
conv.Log = append(conv.Log, LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}})
|
||||
conv.mu.Unlock()
|
||||
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"} {
|
||||
if s, _ := probe(q); s != download(q) {
|
||||
t.Errorf("q=%q : sonde %d o, téléchargement %d o", q, s, download(q))
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
// Conversation vide : zéro échange, c'est ce qui déclenche « rien à exporter ».
|
||||
conv.Reset()
|
||||
if n, _ := conv.state()["turns"].(int); n != 0 {
|
||||
t.Fatalf("échanges = %v sur un fil vide", conv.state()["turns"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,8 +146,8 @@ Interaction:
|
||||
chat [system-prompt] chat terminal streamé
|
||||
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
|
||||
--json --last N --no-reasoning
|
||||
--no-tools --no-results
|
||||
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é>]
|
||||
|
||||
+95
-137
@@ -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::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
.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}
|
||||
.xr-legend .xr-size{color:var(--dim);font-size:11px;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
/* Conversation vide : un seul message centré à la place des réglages. */
|
||||
.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>
|
||||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||||
<!-- 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>
|
||||
<!-- 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">
|
||||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||||
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
||||
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
||||
<div class="modal-card" style="max-width:460px">
|
||||
<div class="modal-head">
|
||||
<strong>Exporter la conversation</strong>
|
||||
<button onclick="closeExportModal()" style="margin:0">×</button>
|
||||
<button class="modal-x" onclick="closeExportModal()" title="fermer">×</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>
|
||||
<!-- Contrôle segmenté (.seg), comme le choix de moteur dans l'éditeur de
|
||||
preset : deux cases côte à côte se lisent d'un coup d'œil là où deux
|
||||
boutons radio empilés obligent à lire ligne par ligne. -->
|
||||
<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>
|
||||
<!-- Conversation vide : pas d'options à régler, un seul message. Proposer
|
||||
des réglages pour un fichier qui ne contiendrait rien n'a aucun sens. -->
|
||||
<div id="x-empty" class="xempty" style="display:none">
|
||||
<div class="xempty-i">⤓</div>
|
||||
<div>Rien à exporter</div>
|
||||
<div class="pe-note">La conversation est vide.</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 id="x-body" class="pe-body" style="gap:20px">
|
||||
<div class="pe-group">
|
||||
<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>
|
||||
@@ -2514,33 +2521,33 @@ function renderApiKey(d){
|
||||
}
|
||||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||||
// --- Export de la conversation ---------------------------------------------
|
||||
// Taille d'un export, en français et avec une décimale. fmtSize convient aux
|
||||
// modèles (des Go, où l'arrondi ne se voit pas) mais pas ici : un fil de 2585
|
||||
// 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.
|
||||
// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de
|
||||
// /api/chat/state ; 0 = conversation vide, et la fenêtre n'a alors rien à régler.
|
||||
let xTurnsTotal = 0;
|
||||
function openExportModal(){
|
||||
onExportFormat();
|
||||
async function openExportModal(){
|
||||
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'); }
|
||||
// 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();
|
||||
}
|
||||
// Le format ne change QUE le contenant : les options de contenu sont les mêmes
|
||||
// des deux côtés. Il n'y a donc plus rien à montrer ou cacher ici, seulement le
|
||||
// résumé à rafraîchir.
|
||||
function onExportFormat(){ onExportPreview(); }
|
||||
function exportFormat(){
|
||||
const r = document.querySelector('input[name=x-fmt]:checked');
|
||||
return r ? r.value : 'md';
|
||||
@@ -2550,16 +2557,11 @@ function exportFormat(){
|
||||
// simple /api/chat/export.
|
||||
function exportQuery(){
|
||||
const p = new URLSearchParams();
|
||||
const fmt = exportFormat();
|
||||
if(fmt === 'json') p.set('format','json');
|
||||
if(exportFormat() === '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');
|
||||
}
|
||||
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();
|
||||
if(t > 0) p.set('turns', String(t));
|
||||
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
|
||||
// périmerait à l'échange suivant.
|
||||
function exportTurns(){
|
||||
const el = document.getElementById('x-turns');
|
||||
const v = parseInt(el.value, 10) || 0;
|
||||
const v = parseInt(document.getElementById('x-turns').value, 10) || 0;
|
||||
if(!xTurnsTotal || v >= xTurnsTotal) return 0;
|
||||
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(){
|
||||
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
|
||||
const tools = document.getElementById('x-tools');
|
||||
@@ -2583,18 +2582,13 @@ function onExportPreview(){
|
||||
results.disabled = !tools.checked;
|
||||
if(!tools.checked) results.checked = false;
|
||||
paintExportRange();
|
||||
const parts = [];
|
||||
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(' · ');
|
||||
probeExportSoon();
|
||||
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');
|
||||
document.getElementById('x-note').textContent = off.length
|
||||
? 'Export sans ' + off.join(' ni ') + '.'
|
||||
: '';
|
||||
}
|
||||
// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque
|
||||
// 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)';
|
||||
const scope = document.getElementById('x-scope');
|
||||
if(!scope) return;
|
||||
if(!xTurnsTotal) scope.textContent = 'conversation vide';
|
||||
else if(n >= xTurnsTotal) scope.textContent = 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')';
|
||||
else scope.textContent = 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 = '';
|
||||
}
|
||||
scope.textContent = n >= xTurnsTotal
|
||||
? 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'
|
||||
: n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal;
|
||||
}
|
||||
async function runExport(){
|
||||
const btn = document.getElementById('x-go');
|
||||
|
||||
@@ -361,57 +361,63 @@
|
||||
</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">
|
||||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||||
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
||||
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
||||
<div class="modal-card" style="max-width:460px">
|
||||
<div class="modal-head">
|
||||
<strong>Exporter la conversation</strong>
|
||||
<button onclick="closeExportModal()" style="margin:0">×</button>
|
||||
<button class="modal-x" onclick="closeExportModal()" title="fermer">×</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>
|
||||
<!-- Contrôle segmenté (.seg), comme le choix de moteur dans l'éditeur de
|
||||
preset : deux cases côte à côte se lisent d'un coup d'œil là où deux
|
||||
boutons radio empilés obligent à lire ligne par ligne. -->
|
||||
<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>
|
||||
<!-- Conversation vide : pas d'options à régler, un seul message. Proposer
|
||||
des réglages pour un fichier qui ne contiendrait rien n'a aucun sens. -->
|
||||
<div id="x-empty" class="xempty" style="display:none">
|
||||
<div class="xempty-i">⤓</div>
|
||||
<div>Rien à exporter</div>
|
||||
<div class="pe-note">La conversation est vide.</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 id="x-body" class="pe-body" style="gap:20px">
|
||||
<div class="pe-group">
|
||||
<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>
|
||||
|
||||
@@ -252,33 +252,33 @@ function renderApiKey(d){
|
||||
}
|
||||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||||
// --- Export de la conversation ---------------------------------------------
|
||||
// Taille d'un export, en français et avec une décimale. fmtSize convient aux
|
||||
// modèles (des Go, où l'arrondi ne se voit pas) mais pas ici : un fil de 2585
|
||||
// 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.
|
||||
// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de
|
||||
// /api/chat/state ; 0 = conversation vide, et la fenêtre n'a alors rien à régler.
|
||||
let xTurnsTotal = 0;
|
||||
function openExportModal(){
|
||||
onExportFormat();
|
||||
async function openExportModal(){
|
||||
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'); }
|
||||
// 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();
|
||||
}
|
||||
// Le format ne change QUE le contenant : les options de contenu sont les mêmes
|
||||
// des deux côtés. Il n'y a donc plus rien à montrer ou cacher ici, seulement le
|
||||
// résumé à rafraîchir.
|
||||
function onExportFormat(){ onExportPreview(); }
|
||||
function exportFormat(){
|
||||
const r = document.querySelector('input[name=x-fmt]:checked');
|
||||
return r ? r.value : 'md';
|
||||
@@ -288,16 +288,11 @@ function exportFormat(){
|
||||
// simple /api/chat/export.
|
||||
function exportQuery(){
|
||||
const p = new URLSearchParams();
|
||||
const fmt = exportFormat();
|
||||
if(fmt === 'json') p.set('format','json');
|
||||
if(exportFormat() === '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');
|
||||
}
|
||||
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();
|
||||
if(t > 0) p.set('turns', String(t));
|
||||
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
|
||||
// périmerait à l'échange suivant.
|
||||
function exportTurns(){
|
||||
const el = document.getElementById('x-turns');
|
||||
const v = parseInt(el.value, 10) || 0;
|
||||
const v = parseInt(document.getElementById('x-turns').value, 10) || 0;
|
||||
if(!xTurnsTotal || v >= xTurnsTotal) return 0;
|
||||
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(){
|
||||
// Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit.
|
||||
const tools = document.getElementById('x-tools');
|
||||
@@ -321,18 +313,13 @@ function onExportPreview(){
|
||||
results.disabled = !tools.checked;
|
||||
if(!tools.checked) results.checked = false;
|
||||
paintExportRange();
|
||||
const parts = [];
|
||||
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(' · ');
|
||||
probeExportSoon();
|
||||
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');
|
||||
document.getElementById('x-note').textContent = off.length
|
||||
? 'Export sans ' + off.join(' ni ') + '.'
|
||||
: '';
|
||||
}
|
||||
// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque
|
||||
// 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)';
|
||||
const scope = document.getElementById('x-scope');
|
||||
if(!scope) return;
|
||||
if(!xTurnsTotal) scope.textContent = 'conversation vide';
|
||||
else if(n >= xTurnsTotal) scope.textContent = 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')';
|
||||
else scope.textContent = 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 = '';
|
||||
}
|
||||
scope.textContent = n >= xTurnsTotal
|
||||
? 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'
|
||||
: n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal;
|
||||
}
|
||||
async function runExport(){
|
||||
const btn = document.getElementById('x-go');
|
||||
|
||||
@@ -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::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
.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}
|
||||
.xr-legend .xr-size{color:var(--dim);font-size:11px;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
/* Conversation vide : un seul message centré à la place des réglages. */
|
||||
.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}
|
||||
Reference in new issue
Block a user