mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 21:18:20 +02:00
Bouton « .zip » dans le pied du panneau : la discussion entière — dossiers et sous-dossiers compris — part en une archive, nommée d'après le titre de la discussion. Même danse en trois temps que le téléchargement de fichier (meta → brut, ou tranches base64 derrière le tunnel chiffré), liens symboliques ignorés, parcours borné comme le calcul de taille du panneau. Le scope « hors discussion » a son propre zip, sans le dossier des discussions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
// ─── Fichiers de la discussion ───────────────────────────────────────────────
|
|
// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici
|
|
// on ne pouvait récupérer un fichier que si le modèle avait pensé à en mettre le
|
|
// lien dans sa réponse — tout le reste restait invisible, et faire le ménage
|
|
// demandait un shell.
|
|
//
|
|
// Le panneau montre les fichiers de la discussion OUVERTE, et rien d'autre :
|
|
// chaque discussion a son dossier côté serveur (chat_convfiles.go), changer de
|
|
// discussion change donc la liste, et supprimer une discussion emporte ses
|
|
// fichiers. `FILES_SCOPE = 'legacy'` ouvre le détour « hors discussion » : les
|
|
// fichiers d'avant ce rangement, que la migration n'a pas su rattacher.
|
|
//
|
|
// Le panneau est construit EN DOM, jamais en innerHTML : les noms de fichiers
|
|
// viennent du modèle et du système de fichiers, donc d'ailleurs. Même règle que
|
|
// pour les titres de discussions (16-conversations.js).
|
|
//
|
|
// Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
|
|
// déjà gérer le blob et les cas particuliers du navigateur.
|
|
|
|
let FILES_DIR = ''; // dossier affiché, relatif à la racine du panneau
|
|
let FILES_SCOPE = ''; // '' = discussion ouverte, 'legacy' = fichiers hors discussion
|
|
let FILES_OPEN = false;
|
|
|
|
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde
|
|
// souvent le panneau ouvert, et le rouvrir à chaque rechargement est pénible.
|
|
function toggleFiles(force){
|
|
FILES_OPEN = (force === undefined) ? !FILES_OPEN : !!force;
|
|
document.documentElement.setAttribute('data-files', FILES_OPEN ? '1' : '0');
|
|
const btn = document.getElementById('files-btn');
|
|
if(btn) btn.setAttribute('aria-expanded', FILES_OPEN ? 'true' : 'false');
|
|
try{ localStorage.setItem('loki-files-open', FILES_OPEN ? '1' : '0'); }catch(_){}
|
|
// Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` :
|
|
// la gouttière de barre de défilement mesurée dans --sbw resterait celle de
|
|
// l'ancienne largeur, et la carte de saisie serait décalée du fil. La hauteur,
|
|
// elle, est suivie par un ResizeObserver et n'a besoin de rien.
|
|
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
|
|
if(FILES_OPEN) loadFiles();
|
|
}
|
|
|
|
// Restaure l'état à l'ouverture de la page. Sur petit écran le panneau est un
|
|
// tiroir qui masque toute la conversation : on ne le rouvre jamais tout seul.
|
|
function initFiles(){
|
|
let want = false;
|
|
try{ want = localStorage.getItem('loki-files-open') === '1'; }catch(_){}
|
|
toggleFiles(want && window.innerWidth > 720);
|
|
}
|
|
|
|
async function loadFiles(dir){
|
|
if(dir !== undefined) FILES_DIR = dir;
|
|
const list = document.getElementById('files-list');
|
|
const foot = document.getElementById('files-foot');
|
|
if(!list) return;
|
|
let r;
|
|
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)+'&scope='+encodeURIComponent(FILES_SCOPE)); }
|
|
catch(_){ filesMsg('lecture impossible'); return; }
|
|
if(!r.ok){
|
|
// Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre
|
|
// onglet) ne doit pas laisser le panneau bloqué : on remonte à la racine.
|
|
if(FILES_DIR){ FILES_DIR = ''; return loadFiles(); }
|
|
filesMsg(r.error || 'lecture impossible'); return;
|
|
}
|
|
|
|
renderCrumb(r);
|
|
list.textContent = '';
|
|
if(!r.entries.length){
|
|
const d = document.createElement('div');
|
|
d.className = 'files-empty muted';
|
|
d.textContent = FILES_DIR ? '(dossier vide)'
|
|
: (FILES_SCOPE === 'legacy' ? '(plus rien hors discussion)'
|
|
: "Rien dans cette discussion — les fichiers déposés et ceux que l'agent écrit apparaissent ici.");
|
|
list.appendChild(d);
|
|
}
|
|
for(const e of r.entries) list.appendChild(fileRow(e));
|
|
|
|
renderFoot(foot, r);
|
|
}
|
|
|
|
// Pied : occupation disque de ce qui est affiché — la discussion ouverte, ou le
|
|
// hors-discussion — et le va-et-vient entre les deux. Le bouton n'apparaît que
|
|
// s'il reste vraiment quelque chose hors discussion (compté par le serveur), et
|
|
// disparaît de lui-même une fois le ménage fait.
|
|
function renderFoot(foot, r){
|
|
if(!foot) return;
|
|
foot.textContent = '';
|
|
const n = document.createElement('span');
|
|
n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
|
|
foot.appendChild(n);
|
|
foot.title = r.root || '';
|
|
// Tout emporter d'un coup : la discussion entière (dossiers et sous-dossiers
|
|
// compris) en une archive .zip. Absent quand il n'y a rien à archiver.
|
|
if(r.count > 0){
|
|
const z = document.createElement('button');
|
|
z.className = 'files-scope';
|
|
z.textContent = '.zip';
|
|
z.title = 'tout télécharger en une archive .zip';
|
|
z.onclick = ()=>downloadFilesZip(z);
|
|
foot.appendChild(z);
|
|
}
|
|
if(FILES_SCOPE !== 'legacy' && !r.legacy) return;
|
|
const b = document.createElement('button');
|
|
b.className = 'files-scope';
|
|
if(FILES_SCOPE === 'legacy'){
|
|
b.textContent = '← cette discussion';
|
|
b.title = 'revenir aux fichiers de la discussion ouverte';
|
|
b.onclick = ()=>{ FILES_SCOPE = ''; loadFiles(''); };
|
|
} else {
|
|
b.textContent = 'hors discussion (' + r.legacy + ')';
|
|
b.title = "fichiers d'avant le rangement par discussion";
|
|
b.onclick = ()=>{ FILES_SCOPE = 'legacy'; loadFiles(''); };
|
|
}
|
|
foot.appendChild(b);
|
|
}
|
|
|
|
function filesMsg(txt){
|
|
const list = document.getElementById('files-list');
|
|
if(!list) return;
|
|
list.textContent = '';
|
|
const d = document.createElement('div');
|
|
d.className = 'files-empty'; d.style.color = 'var(--err)'; d.textContent = txt;
|
|
list.appendChild(d);
|
|
}
|
|
|
|
// Fil d'Ariane : « discussion / captures », chaque segment cliquable. La racine
|
|
// est le dossier de la discussion ouverte — on n'en sort pas, sauf par le
|
|
// détour « hors discussion », qui a sa propre racine.
|
|
function renderCrumb(r){
|
|
const c = document.getElementById('files-crumb');
|
|
c.textContent = '';
|
|
const seg = (label, dir, last)=>{
|
|
const b = document.createElement('button');
|
|
b.className = 'files-crumb-b' + (last ? ' on' : '');
|
|
b.textContent = label;
|
|
if(!last) b.onclick = ()=>loadFiles(dir);
|
|
else b.disabled = true;
|
|
c.appendChild(b);
|
|
};
|
|
const parts = r.dir ? r.dir.split('/') : [];
|
|
seg(FILES_SCOPE === 'legacy' ? 'hors discussion' : 'discussion', '', parts.length === 0);
|
|
let acc = '';
|
|
parts.forEach((p, i)=>{
|
|
acc = acc ? acc + '/' + p : p;
|
|
const sep = document.createElement('span');
|
|
sep.className = 'files-crumb-s'; sep.textContent = '/';
|
|
c.appendChild(sep);
|
|
seg(p, acc, i === parts.length - 1);
|
|
});
|
|
}
|
|
|
|
function fileRow(e){
|
|
const row = document.createElement('div');
|
|
row.className = 'files-row' + (e.dir ? ' isdir' : '');
|
|
|
|
const ic = document.createElement('span');
|
|
ic.className = 'files-ic';
|
|
ic.appendChild(icon(e.dir ? 'folder' : (e.image ? 'image' : 'file'), 16));
|
|
row.appendChild(ic);
|
|
|
|
const mid = document.createElement('span');
|
|
mid.className = 'files-mid';
|
|
const nm = document.createElement('span');
|
|
nm.className = 'files-name';
|
|
nm.textContent = e.name;
|
|
nm.title = e.name;
|
|
const meta = document.createElement('span');
|
|
meta.className = 'files-meta';
|
|
meta.textContent = fmtSize(e.size) + (e.dir && e.items ? ' · ' + e.items + ' fich.' : '') + ' · ' + fmtWhen(e.mod);
|
|
mid.append(nm, meta);
|
|
row.appendChild(mid);
|
|
|
|
// Un dossier s'ouvre ; un fichier se télécharge. Le clic sur la ligne fait
|
|
// l'action évidente, les boutons ne servent qu'au reste.
|
|
if(e.dir){
|
|
mid.onclick = ()=>loadFiles(e.path);
|
|
mid.style.cursor = 'pointer';
|
|
} else {
|
|
mid.onclick = ()=>downloadWorkspaceFile(e.path, e.name, row);
|
|
mid.style.cursor = 'pointer';
|
|
}
|
|
|
|
const acts = document.createElement('span');
|
|
acts.className = 'files-acts';
|
|
if(!e.dir){
|
|
const dl = document.createElement('button');
|
|
dl.className = 'iconbtn'; dl.appendChild(icon('download', 16));
|
|
dl.title = 'télécharger'; dl.setAttribute('aria-label', 'télécharger');
|
|
dl.onclick = ev=>{ ev.stopPropagation(); downloadWorkspaceFile(e.path, e.name, row); };
|
|
acts.appendChild(dl);
|
|
}
|
|
const rm = document.createElement('button');
|
|
rm.className = 'iconbtn'; rm.appendChild(icon('trash', 16));
|
|
rm.title = 'supprimer'; rm.setAttribute('aria-label', 'supprimer');
|
|
rm.onclick = ev=>{ ev.stopPropagation(); removeFile(e); };
|
|
acts.appendChild(rm);
|
|
row.appendChild(acts);
|
|
return row;
|
|
}
|
|
|
|
// Suppression. Toujours confirmée, et l'invite dit ce qui part : un dossier
|
|
// emporte tout son contenu, ce qu'un nom seul ne laisse pas deviner.
|
|
async function removeFile(e){
|
|
const quoi = e.dir
|
|
? 'Le dossier « '+e.name+' » et tout son contenu ('+(e.items||0)+' fichiers, '+fmtSize(e.size)+') seront supprimés.'
|
|
: '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
|
|
if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
|
|
{title:'Supprimer ?', okText:'Supprimer', danger:true})) return;
|
|
const r = await jpost('/api/chat/file/delete', {path: e.path, scope: FILES_SCOPE});
|
|
if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
|
|
toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
|
|
loadFiles();
|
|
}
|
|
|
|
// Télécharge TOUT le contenu affiché (discussion ou hors-discussion) en .zip.
|
|
// Même danse que downloadWorkspaceFile (14-attach.js) : la fiche meta d'abord —
|
|
// c'est elle qui CONSTRUIT l'archive côté serveur — puis le brut en direct, ou
|
|
// les tranches base64 derrière le tunnel chiffré.
|
|
async function downloadFilesZip(btn){
|
|
if(btn){ btn.disabled = true; btn.textContent = '…'; }
|
|
try{
|
|
const m = await jfetch('/api/chat/files/zip?meta=1&scope='+encodeURIComponent(FILES_SCOPE));
|
|
const meta = await m.json().catch(()=>({}));
|
|
if(!m.ok || !meta.ok){ toast('archive impossible : '+(meta.error||('HTTP '+m.status))); return; }
|
|
let blob;
|
|
if(meta.e2e){
|
|
blob = await fetchZipB64(meta.size||0);
|
|
} else {
|
|
const r = await jfetch('/api/chat/files/zip?scope='+encodeURIComponent(FILES_SCOPE));
|
|
if(!r.ok){ let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} toast('archive impossible : '+msg); return; }
|
|
blob = await r.blob();
|
|
}
|
|
const u = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = u; a.download = meta.name || 'loki-fichiers.zip';
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
setTimeout(()=>URL.revokeObjectURL(u), 10000);
|
|
toast(meta.files + ' fichier'+(meta.files>1?'s':'')+' archivé'+(meta.files>1?'s':''));
|
|
}catch(e){ toast('archive impossible : '+((e&&e.message)||'erreur')); }
|
|
finally{ if(btn){ btn.disabled = false; btn.textContent = '.zip'; } }
|
|
}
|
|
|
|
// Tranches base64 du zip (tunnel chiffré) — cousin de fetchFileB64.
|
|
async function fetchZipB64(size){
|
|
const parts = [];
|
|
let off = 0;
|
|
do{
|
|
const r = await jfetch('/api/chat/files/zip?b64=1&len='+DL_CHUNK+'&offset='+off+'&scope='+encodeURIComponent(FILES_SCOPE));
|
|
const j = await r.json();
|
|
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
|
|
const bytes = b64ToBytes(j.data||'');
|
|
parts.push(bytes);
|
|
if(!bytes.length && !j.eof) throw new Error('transfert interrompu');
|
|
off = (j.offset||0) + bytes.length;
|
|
if(j.eof) break;
|
|
} while(off < size);
|
|
return new Blob(parts, {type:'application/zip'});
|
|
}
|
|
|
|
// Changement de discussion (bascule, « vider »), signalé par le flux SSE :
|
|
// le panneau montre les fichiers de la discussion OUVERTE, il doit donc être
|
|
// redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui
|
|
// de l'autre discussion, il n'existe pas forcément ici.
|
|
function filesOnConvChange(){
|
|
FILES_DIR = ''; FILES_SCOPE = '';
|
|
if(FILES_OPEN) loadFiles('');
|
|
}
|
|
|
|
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
|
|
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
|
|
document.addEventListener('DOMContentLoaded', initFiles);
|
|
|
|
function fmtWhen(sec){
|
|
if(!sec) return '';
|
|
const d = new Date(sec*1000), now = new Date();
|
|
const memeJour = d.toDateString() === now.toDateString();
|
|
return memeJour
|
|
? d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'})
|
|
: d.toLocaleDateString(undefined, {day:'2-digit', month:'2-digit'});
|
|
}
|