mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-15 11:19:07 +02:00
Fenetre d'export : portee au curseur, format en segments
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.
This commit is contained in:
1 parent
7a27dbf3be
commit
b7042f1127
6 files changed
+322
-28
No files matched your search
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
+126
-14
@@ -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}
|
||||
</style></head><body>
|
||||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||||
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
||||
@@ -1420,8 +1441,13 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<div style="padding:16px 20px;display:flex;flex-direction:column;gap:14px">
|
||||
<div>
|
||||
<div class="subhead" style="margin:0 0 6px">Format</div>
|
||||
<label class="switchrow" style="cursor:pointer"><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()" style="margin-right:8px"> Markdown <span class="muted" style="margin-left:6px">à lire</span></label>
|
||||
<label class="switchrow" style="cursor:pointer"><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()" style="margin-right:8px"> JSON <span class="muted" style="margin-left:6px">à retraiter</span></label>
|
||||
<!-- 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>
|
||||
@@ -1435,13 +1461,19 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
</div>
|
||||
<div>
|
||||
<div class="subhead" style="margin:0 0 6px">Portée</div>
|
||||
<select id="x-turns" class="io-input" onchange="onExportPreview()">
|
||||
<option value="0">toute la conversation</option>
|
||||
<option value="1">dernier échange</option>
|
||||
<option value="3">3 derniers échanges</option>
|
||||
<option value="10">10 derniers échanges</option>
|
||||
<option value="25">25 derniers échanges</option>
|
||||
</select>
|
||||
<!-- 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>
|
||||
@@ -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');
|
||||
|
||||
@@ -373,8 +373,13 @@
|
||||
<div style="padding:16px 20px;display:flex;flex-direction:column;gap:14px">
|
||||
<div>
|
||||
<div class="subhead" style="margin:0 0 6px">Format</div>
|
||||
<label class="switchrow" style="cursor:pointer"><input type="radio" name="x-fmt" value="md" checked onchange="onExportFormat()" style="margin-right:8px"> Markdown <span class="muted" style="margin-left:6px">à lire</span></label>
|
||||
<label class="switchrow" style="cursor:pointer"><input type="radio" name="x-fmt" value="json" onchange="onExportFormat()" style="margin-right:8px"> JSON <span class="muted" style="margin-left:6px">à retraiter</span></label>
|
||||
<!-- 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>
|
||||
@@ -388,13 +393,19 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="subhead" style="margin:0 0 6px">Portée</div>
|
||||
<select id="x-turns" class="io-input" onchange="onExportPreview()">
|
||||
<option value="0">toute la conversation</option>
|
||||
<option value="1">dernier échange</option>
|
||||
<option value="3">3 derniers échanges</option>
|
||||
<option value="10">10 derniers échanges</option>
|
||||
<option value="25">25 derniers échanges</option>
|
||||
</select>
|
||||
<!-- 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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user