mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
Panneau Fichiers : accéder à ce que l'agent écrit
L'agent produit des rapports, des scripts, des exports et des captures dans son dossier de travail. On ne pouvait en récupérer un que si le modèle avait pensé à en mettre le lien dans sa réponse : tout ce qu'il écrivait sans le dire restait invisible, et faire le ménage demandait un shell. Un panneau de droite, ouvert et fermé par le bouton dossier du pied de carte, liste ce dossier avec navigation dans les sous-dossiers, téléchargement et suppression. Il pousse la conversation au lieu de la recouvrir — la largeur de lecture étant pilotée par --measure, le fil se resserre et se recentre tout seul. Sur téléphone c'est un tiroir, comme les réglages à gauche. Le socle existait : /api/chat/file téléchargeait déjà, workspaceRel bornait déjà les chemins, downloadWorkspaceFile gérait déjà le blob. Manquaient les deux verbes qui comptent, /api/chat/files pour lister et /api/chat/file/delete pour supprimer. Trois choix qui méritent d'être dits : - Un dossier affiche la taille de TOUT son contenu, pas celle de son inode : c'est ce qu'on libère en le supprimant, donc c'est le chiffre qui aide à décider. Le parcours est borné à 20 000 entrées pour que la liste reste instantanée si l'agent y dézippe quelque chose d'énorme. - La racine du dossier de travail est refusée à la suppression : l'agent y perd le dossier dans lequel il écrit, et « tout effacer » ne doit pas être à un clic. La suppression d'un dossier annonce le nombre de fichiers et le poids emportés avant de demander confirmation. - Le panneau ne se remplit qu'à l'ouverture. Un ReadDir récursif à chaque chargement de page pour un panneau fermé serait payé par tout le monde. 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. On resynchronise donc explicitement ; la hauteur, elle, est déjà suivie par un ResizeObserver. Vérifié. Six tests Go sur le bornage, qui est la partie sensible — ces routes suppriment sur disque à partir d'un chemin venu du navigateur : « .. », chemins absolus, racine, et un lien symbolique posé dans le dossier de travail sont tous refusés, et le fichier voisin survit. Puis au navigateur, en clair et en sombre : listing trié dossiers d'abord, taille récursive, descente et fil d'Ariane, téléchargement réel (l'événement download porte bien rapport.md), suppression confirmée puis disparition de la ligne, persistance de l'état ouvert, tiroir mobile avec voile, et la saisie reste alignée au pixel sur le fil (0 px des deux côtés) une fois le panneau ouvert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
This commit is contained in:
8 files changed
+1002
-7
No files matched your search
@@ -0,0 +1,175 @@
|
||||
// ─── Fichiers du dossier de travail ──────────────────────────────────────────
|
||||
// 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 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 au dossier de travail
|
||||
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)); }
|
||||
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)' : "L'agent n'a encore rien écrit ici.";
|
||||
list.appendChild(d);
|
||||
}
|
||||
for(const e of r.entries) list.appendChild(fileRow(e));
|
||||
|
||||
foot.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
|
||||
foot.title = r.root || '';
|
||||
}
|
||||
|
||||
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 : « travail / captures / <id> », chaque segment cliquable.
|
||||
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('travail', '', 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.textContent = e.dir ? '▸' : (e.image ? '▣' : '·');
|
||||
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.textContent = '↓'; dl.title = '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.textContent = '🗑'; rm.title = '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});
|
||||
if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
|
||||
toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
|
||||
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'});
|
||||
}
|
||||
Reference in new issue
Block a user