Export : memes options quel que soit le format, curseur simple, etat vide

Trois choses ne tenaient pas debout dans la fenetre.

Les options CHANGEAIENT selon le format : le JSON proposait un obscur
« journal d'affichage » a la place des trois cases du Markdown.
Personne ne pouvait deviner ce que ca recouvrait, et cocher une case
n'avait pas le meme sens selon le format choisi juste au-dessus. Les
trois memes options valent desormais pour les deux ; le format ne
decide que du contenant. Cote JSON, le journal est filtre par les
memes criteres.

La taille du fichier annoncee sous le curseur ne servait a rien et
justifiait a elle seule une sonde cote serveur. Supprimee, sonde
comprise : le nombre d'echanges vient maintenant de /api/chat/state,
qui est deja l'instantane leger de la conversation.

La fenetre reprend le vocabulaire visuel de l'editeur de preset
(modal-card, pe-group, pe-row, interrupteurs pe-switch) au lieu de son
balisage bricole, et chaque option porte son sous-titre. Conversation
vide : un simple « Rien a exporter » remplace les reglages, plutot que
d'offrir de tailler un fichier qui serait vide de toute facon.
This commit is contained in:
nathaninline committed 2026-08-08 20:56:11 +02:00
1 parent b7042f1127
commit 4a66b27227
8 files changed
+284 -371

No files matched your search

+54 -48
View File
@@ -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>
+38 -87
View File
@@ -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');
+3 -2
View File
@@ -1065,5 +1065,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
.xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)}
.xr-range:focus-visible::-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}