From b7042f1127ff5aa53b77712a44615f584428df21 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 20:39:58 +0200 Subject: [PATCH] Fenetre d'export : portee au curseur, format en segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le menu de portee proposait « 25 derniers echanges » sur un fil qui en comptait trois, et ne disait nulle part combien il y en avait. Un curseur le remplace, borne au nombre REEL d'echanges, butee droite = toute la conversation. Sous la piste : le compte (« 3 derniers echanges sur 12 ») et le POIDS du fichier pour les options courantes, sonde cote serveur (probe=1, rendu puis jete, donc jamais un second calcul de taille qui divergerait). Le format passe en controle segmente (.seg), celui du choix de moteur dans l'editeur de preset : deux cases cote a cote au lieu de deux boutons radio empiles. Deux details qui faussaient l'affichage : une sonde encore en vol revenait ecraser le « … » par la taille de l'etat PRECEDENT (le poids retardait d'un cran sur le curseur), et fmtSize arrondissait 2585 octets en « 3 KB » dans le seul endroit qui pretend annoncer la taille reelle. --- internal/ajean/chat_export.go | 30 +++++ internal/ajean/chat_export_test.go | 40 +++++++ internal/ajean/ui/index.html | 140 +++++++++++++++++++++--- internal/ajean/ui/src/index.tmpl.html | 29 +++-- internal/ajean/ui/src/js/06-settings.js | 90 ++++++++++++++- internal/ajean/ui/src/styles.css | 21 ++++ 6 files changed, 322 insertions(+), 28 deletions(-) diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go index 96c33ab..98cdfc3 100644 --- a/internal/ajean/chat_export.go +++ b/internal/ajean/chat_export.go @@ -352,10 +352,40 @@ 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()}) diff --git a/internal/ajean/chat_export_test.go b/internal/ajean/chat_export_test.go index 1d0c0ad..cd56c49 100644 --- a/internal/ajean/chat_export_test.go +++ b/internal/ajean/chat_export_test.go @@ -204,6 +204,46 @@ func TestExportDerniersEchanges(t *testing.T) { } } +// 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) { + 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 + } + download := func(q string) int { + rr := httptest.NewRecorder() + handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?"+q, nil)) + return rr.Body.Len() + } + 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) + } +} + func TestCmdExportEcritLeFichier(t *testing.T) { testHome(t) convDeTest(t) diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 5fafaf7..d11f0e4 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1072,6 +1072,27 @@ button:hover{border-color:var(--dim);color:var(--text)} /* Sélecteurs segmentés : noir plein quand c'est coché, pas de couleur. */ .pe-seg>label:has(input:checked),.seg>label:has(input:checked){background:var(--text);border-color:var(--text);color:var(--bg)} + +/* Curseur de portée de l'export. Même vocabulaire visuel que la répartition GPU + (piste arrondie + pastille cerclée), mais une seule valeur : la position tout + à droite vaut « toute la conversation ». Le libellé sous la piste donne le + compte RÉEL d'échanges et le poids du fichier, la question qu'on se pose + vraiment avant d'exporter. */ +.xr{position:relative;display:flex;align-items:center;height:22px} +.xr-track{width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)} +.xr-fill{height:100%;background:var(--accent);border-radius:999px;transition:width .12s} +.xr-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize} +.xr-range:focus{outline:none} +.xr-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none} +.xr-range::-moz-range-track{height:22px;background:transparent;border:none} +.xr-range::-moz-range-progress{background:transparent} +.xr-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.xr-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.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} +
+ + +
Contenu
@@ -1435,13 +1461,19 @@ button:hover{border-color:var(--dim);color:var(--text)}
Portée
- + +
+
+ +
+
+ toute la conversation + … +
@@ -2482,7 +2514,24 @@ function renderApiKey(d){ } async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } // --- Export de la conversation --------------------------------------------- -function openExportModal(){ onExportFormat(); showModal('export-modal'); } +// 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. +let xTurnsTotal = 0; +function openExportModal(){ + onExportFormat(); + showModal('export-modal'); + probeExport(); // remplit la borne du curseur et la taille réelle +} 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. @@ -2511,11 +2560,20 @@ function exportQuery(){ if(!on('x-tools')) p.set('tools','0'); if(!on('x-results')) p.set('results','0'); } - const t = document.getElementById('x-turns').value; - if(t !== '0') p.set('turns', t); + const t = exportTurns(); + if(t > 0) p.set('turns', String(t)); const q = p.toString(); return '/api/chat/export' + (q ? '?'+q : ''); } +// Valeur de portée à envoyer : 0 = tout le fil. La butée DROITE du curseur vaut +// « 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; + 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(){ @@ -2524,9 +2582,8 @@ function onExportPreview(){ const results = document.getElementById('x-results'); results.disabled = !tools.checked; if(!tools.checked) results.checked = false; + paintExportRange(); const parts = []; - const t = document.getElementById('x-turns'); - parts.push(t.value === '0' ? 'toute la conversation' : t.options[t.selectedIndex].text); if(exportFormat() === 'json'){ parts.push(document.getElementById('x-full').checked ? 'messages + journal complet' : 'messages seuls'); } else { @@ -2537,6 +2594,61 @@ function onExportPreview(){ 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 +// pixel de glissement, il ne doit RIEN demander au serveur. +function paintExportRange(){ + const el = document.getElementById('x-turns'); + const n = parseInt(el.value, 10) || 0; + const max = Math.max(1, xTurnsTotal); + // La pastille ne parcourt pas toute la largeur : elle va de THUMB/2 à + // largeur - THUMB/2. On coupe le remplissage dans SON repère, sinon la piste + // colorée déborde à côté d'elle aux extrémités (même calcul que la barre GPU). + const frac = max > 1 ? (n - 1) / (max - 1) : 1; + const fill = document.getElementById('x-fill'); + 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 = ''; + } } async function runExport(){ const btn = document.getElementById('x-go'); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index bd5e4c9..edf7ef6 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -373,8 +373,13 @@
Format
- - + +
+ + +
Contenu
@@ -388,13 +393,19 @@
Portée
- + +
+
+ +
+
+ toute la conversation + … +
diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index 3eed29f..3dcf710 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -252,7 +252,24 @@ function renderApiKey(d){ } async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } // --- Export de la conversation --------------------------------------------- -function openExportModal(){ onExportFormat(); showModal('export-modal'); } +// 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. +let xTurnsTotal = 0; +function openExportModal(){ + onExportFormat(); + showModal('export-modal'); + probeExport(); // remplit la borne du curseur et la taille réelle +} 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. @@ -281,11 +298,20 @@ function exportQuery(){ if(!on('x-tools')) p.set('tools','0'); if(!on('x-results')) p.set('results','0'); } - const t = document.getElementById('x-turns').value; - if(t !== '0') p.set('turns', t); + const t = exportTurns(); + if(t > 0) p.set('turns', String(t)); const q = p.toString(); return '/api/chat/export' + (q ? '?'+q : ''); } +// Valeur de portée à envoyer : 0 = tout le fil. La butée DROITE du curseur vaut +// « 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; + 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(){ @@ -294,9 +320,8 @@ function onExportPreview(){ const results = document.getElementById('x-results'); results.disabled = !tools.checked; if(!tools.checked) results.checked = false; + paintExportRange(); const parts = []; - const t = document.getElementById('x-turns'); - parts.push(t.value === '0' ? 'toute la conversation' : t.options[t.selectedIndex].text); if(exportFormat() === 'json'){ parts.push(document.getElementById('x-full').checked ? 'messages + journal complet' : 'messages seuls'); } else { @@ -307,6 +332,61 @@ function onExportPreview(){ 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 +// pixel de glissement, il ne doit RIEN demander au serveur. +function paintExportRange(){ + const el = document.getElementById('x-turns'); + const n = parseInt(el.value, 10) || 0; + const max = Math.max(1, xTurnsTotal); + // La pastille ne parcourt pas toute la largeur : elle va de THUMB/2 à + // largeur - THUMB/2. On coupe le remplissage dans SON repère, sinon la piste + // colorée déborde à côté d'elle aux extrémités (même calcul que la barre GPU). + const frac = max > 1 ? (n - 1) / (max - 1) : 1; + const fill = document.getElementById('x-fill'); + 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 = ''; + } } async function runExport(){ const btn = document.getElementById('x-go'); diff --git a/internal/ajean/ui/src/styles.css b/internal/ajean/ui/src/styles.css index 1f8f7a6..7a5d7a0 100644 --- a/internal/ajean/ui/src/styles.css +++ b/internal/ajean/ui/src/styles.css @@ -1046,3 +1046,24 @@ button:hover{border-color:var(--dim);color:var(--text)} /* Sélecteurs segmentés : noir plein quand c'est coché, pas de couleur. */ .pe-seg>label:has(input:checked),.seg>label:has(input:checked){background:var(--text);border-color:var(--text);color:var(--bg)} + +/* Curseur de portée de l'export. Même vocabulaire visuel que la répartition GPU + (piste arrondie + pastille cerclée), mais une seule valeur : la position tout + à droite vaut « toute la conversation ». Le libellé sous la piste donne le + compte RÉEL d'échanges et le poids du fichier, la question qu'on se pose + vraiment avant d'exporter. */ +.xr{position:relative;display:flex;align-items:center;height:22px} +.xr-track{width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)} +.xr-fill{height:100%;background:var(--accent);border-radius:999px;transition:width .12s} +.xr-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize} +.xr-range:focus{outline:none} +.xr-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none} +.xr-range::-moz-range-track{height:22px;background:transparent;border:none} +.xr-range::-moz-range-progress{background:transparent} +.xr-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.xr-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.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}