mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +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
@@ -174,6 +174,12 @@ Ajoutées par ce fork :
|
||||
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
|
||||
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
|
||||
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
|
||||
- **Panneau Fichiers** (bouton dossier du pied de carte) : ce que l'agent a
|
||||
écrit dans son dossier de travail, avec navigation dans les sous-dossiers,
|
||||
téléchargement et suppression. Un dossier affiche la taille de **tout** son
|
||||
contenu — c'est ce qu'on libère en le supprimant. Le pied donne l'occupation
|
||||
disque totale de l'agent. Les chemins sont bornés au dossier de travail, liens
|
||||
symboliques résolus des deux côtés : rien du reste du disque n'est atteignable.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
||||
|
||||
@@ -1391,6 +1391,75 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
|
||||
color:var(--dim);cursor:pointer}
|
||||
.hf-mm.muted{cursor:default}
|
||||
|
||||
/* --- Panneau « Fichiers » (droite) ------------------------------------
|
||||
Symétrique des réglages à gauche : une colonne fixe qui pousse la
|
||||
conversation au lieu de la recouvrir. C'est possible ici parce que la largeur
|
||||
de lecture est pilotée par --measure et que #chat se recentre tout seul dans
|
||||
la place qui reste — le fil se resserre, il ne passe pas dessous.
|
||||
Le panneau est retiré du flux quand il est fermé (et non simplement décalé) :
|
||||
sinon il conserverait sa gouttière et le fil resterait décentré. */
|
||||
#files{display:none;width:320px;flex-shrink:0;border-left:1px solid var(--border);
|
||||
background:var(--panel);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));
|
||||
overflow-y:auto;flex-direction:column;gap:10px}
|
||||
html[data-files="1"] #files{display:flex}
|
||||
#files-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:14}
|
||||
.files-head{display:flex;align-items:center;gap:8px}
|
||||
.files-head .stitle{flex:1}
|
||||
#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
|
||||
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:8px}
|
||||
#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
|
||||
/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
|
||||
discussion dans le chemin dépasse largement 320 px. */
|
||||
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px}
|
||||
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:7px;
|
||||
background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
|
||||
.files-crumb-b.on{color:var(--text);cursor:default}
|
||||
.files-crumb-s{color:var(--dim);opacity:.5}
|
||||
.files-list{display:flex;flex-direction:column;flex:1;min-height:0}
|
||||
.files-empty{font-size:12px;line-height:1.5;padding:10px 2px}
|
||||
/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
|
||||
survol : au repos on veut lire des noms de fichiers, pas une grille de
|
||||
boutons. Sur écran tactile (pas de survol) elles restent visibles. */
|
||||
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;font-size:12px}
|
||||
.files-row:hover{background:var(--row-bg-on)}
|
||||
.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
|
||||
.files-row.isdir .files-ic{color:var(--accent)}
|
||||
.files-mid{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
|
||||
.files-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
||||
.files-meta{font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
|
||||
.files-acts{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
||||
.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
|
||||
@media (hover:none){.files-acts{opacity:1}}
|
||||
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
|
||||
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
|
||||
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border)}
|
||||
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
|
||||
.files-row.busy{opacity:.55;pointer-events:none}
|
||||
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand
|
||||
le panneau est ouvert — l'état se lit sans regarder à droite. */
|
||||
#files-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
|
||||
display:flex;align-items:center;justify-content:center;border-radius:999px;
|
||||
background:transparent;border:1px solid transparent;color:var(--dim)}
|
||||
#files-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||||
#files-btn:active{transform:scale(.9)}
|
||||
#files-btn svg{display:block}
|
||||
html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* Petit écran : tiroir par la droite, comme les réglages par la gauche. Pousser
|
||||
une colonne de 320 px sur un téléphone ne laisserait rien à la conversation. */
|
||||
@media (max-width:720px){
|
||||
#files{position:fixed;inset:0 0 0 auto;width:85%;max-width:340px;z-index:15;
|
||||
background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));
|
||||
padding-right:calc(16px + env(safe-area-inset-right))}
|
||||
html[data-files="1"] #files-backdrop{display:block}
|
||||
}
|
||||
@media (min-width:721px){
|
||||
/* Dans la charte « Terre », le panneau est une carte comme les autres. */
|
||||
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
|
||||
box-shadow:var(--shadow);margin:0}
|
||||
}
|
||||
</style></head><body>
|
||||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||||
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
||||
@@ -1727,6 +1796,12 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||
<span id="ctx-text" class="muted">0 / 0</span>
|
||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
|
||||
gabarit que le trombone — ce sont les deux faces du même sujet,
|
||||
ce qui entre et ce qui sort. -->
|
||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers du dossier de travail" aria-label="Fichiers du dossier de travail" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
</button>
|
||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
|
||||
@@ -1747,6 +1822,24 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fichiers : ce que l'agent a écrit dans son dossier de travail. Panneau de
|
||||
DROITE, symétrique des réglages à gauche, ouvert et fermé par le bouton
|
||||
dossier du pied de carte. Il ne se remplit qu'à l'ouverture (loadFiles) —
|
||||
un ReadDir à chaque chargement de page pour un panneau fermé serait payé
|
||||
par tout le monde et utile à personne. -->
|
||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||
<aside id="files" aria-label="Fichiers du dossier de travail">
|
||||
<div class="files-head">
|
||||
<span class="stitle">Fichiers</span>
|
||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
|
||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<!-- Fil d'Ariane : le dossier de travail contient des sous-dossiers (captures
|
||||
par discussion), il faut pouvoir y descendre et en remonter. -->
|
||||
<div id="files-crumb" class="files-crumb"></div>
|
||||
<div id="files-list" class="files-list"></div>
|
||||
<div id="files-foot" class="files-foot muted"></div>
|
||||
</aside>
|
||||
<div id="toast"></div>
|
||||
<div id="bench-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)closeBenchModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||
@@ -5855,6 +5948,181 @@ async function attachPaths(){
|
||||
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
|
||||
}
|
||||
renderAttach();
|
||||
// ─── 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'});
|
||||
}
|
||||
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
|
||||
//
|
||||
// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils :
|
||||
|
||||
@@ -361,6 +361,12 @@
|
||||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||
<span id="ctx-text" class="muted">0 / 0</span>
|
||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||
<!-- Fichiers produits par l'agent : ouvre le panneau de droite. Même
|
||||
gabarit que le trombone — ce sont les deux faces du même sujet,
|
||||
ce qui entre et ce qui sort. -->
|
||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers du dossier de travail" aria-label="Fichiers du dossier de travail" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
</button>
|
||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
|
||||
@@ -381,6 +387,24 @@
|
||||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fichiers : ce que l'agent a écrit dans son dossier de travail. Panneau de
|
||||
DROITE, symétrique des réglages à gauche, ouvert et fermé par le bouton
|
||||
dossier du pied de carte. Il ne se remplit qu'à l'ouverture (loadFiles) —
|
||||
un ReadDir à chaque chargement de page pour un panneau fermé serait payé
|
||||
par tout le monde et utile à personne. -->
|
||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||
<aside id="files" aria-label="Fichiers du dossier de travail">
|
||||
<div class="files-head">
|
||||
<span class="stitle">Fichiers</span>
|
||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
|
||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<!-- Fil d'Ariane : le dossier de travail contient des sous-dossiers (captures
|
||||
par discussion), il faut pouvoir y descendre et en remonter. -->
|
||||
<div id="files-crumb" class="files-crumb"></div>
|
||||
<div id="files-list" class="files-list"></div>
|
||||
<div id="files-foot" class="files-foot muted"></div>
|
||||
</aside>
|
||||
<div id="toast"></div>
|
||||
<div id="bench-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)closeBenchModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
@@ -1365,3 +1365,72 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
|
||||
color:var(--dim);cursor:pointer}
|
||||
.hf-mm.muted{cursor:default}
|
||||
|
||||
/* --- Panneau « Fichiers » (droite) ------------------------------------
|
||||
Symétrique des réglages à gauche : une colonne fixe qui pousse la
|
||||
conversation au lieu de la recouvrir. C'est possible ici parce que la largeur
|
||||
de lecture est pilotée par --measure et que #chat se recentre tout seul dans
|
||||
la place qui reste — le fil se resserre, il ne passe pas dessous.
|
||||
Le panneau est retiré du flux quand il est fermé (et non simplement décalé) :
|
||||
sinon il conserverait sa gouttière et le fil resterait décentré. */
|
||||
#files{display:none;width:320px;flex-shrink:0;border-left:1px solid var(--border);
|
||||
background:var(--panel);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));
|
||||
overflow-y:auto;flex-direction:column;gap:10px}
|
||||
html[data-files="1"] #files{display:flex}
|
||||
#files-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:14}
|
||||
.files-head{display:flex;align-items:center;gap:8px}
|
||||
.files-head .stitle{flex:1}
|
||||
#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
|
||||
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:8px}
|
||||
#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
|
||||
/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
|
||||
discussion dans le chemin dépasse largement 320 px. */
|
||||
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px}
|
||||
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:7px;
|
||||
background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
|
||||
.files-crumb-b.on{color:var(--text);cursor:default}
|
||||
.files-crumb-s{color:var(--dim);opacity:.5}
|
||||
.files-list{display:flex;flex-direction:column;flex:1;min-height:0}
|
||||
.files-empty{font-size:12px;line-height:1.5;padding:10px 2px}
|
||||
/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
|
||||
survol : au repos on veut lire des noms de fichiers, pas une grille de
|
||||
boutons. Sur écran tactile (pas de survol) elles restent visibles. */
|
||||
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;font-size:12px}
|
||||
.files-row:hover{background:var(--row-bg-on)}
|
||||
.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
|
||||
.files-row.isdir .files-ic{color:var(--accent)}
|
||||
.files-mid{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
|
||||
.files-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
||||
.files-meta{font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
|
||||
.files-acts{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
||||
.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
|
||||
@media (hover:none){.files-acts{opacity:1}}
|
||||
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
|
||||
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
|
||||
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border)}
|
||||
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
|
||||
.files-row.busy{opacity:.55;pointer-events:none}
|
||||
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand
|
||||
le panneau est ouvert — l'état se lit sans regarder à droite. */
|
||||
#files-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
|
||||
display:flex;align-items:center;justify-content:center;border-radius:999px;
|
||||
background:transparent;border:1px solid transparent;color:var(--dim)}
|
||||
#files-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
|
||||
#files-btn:active{transform:scale(.9)}
|
||||
#files-btn svg{display:block}
|
||||
html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* Petit écran : tiroir par la droite, comme les réglages par la gauche. Pousser
|
||||
une colonne de 320 px sur un téléphone ne laisserait rien à la conversation. */
|
||||
@media (max-width:720px){
|
||||
#files{position:fixed;inset:0 0 0 auto;width:85%;max-width:340px;z-index:15;
|
||||
background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));
|
||||
padding-right:calc(16px + env(safe-area-inset-right))}
|
||||
html[data-files="1"] #files-backdrop{display:block}
|
||||
}
|
||||
@media (min-width:721px){
|
||||
/* Dans la charte « Terre », le panneau est une carte comme les autres. */
|
||||
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
|
||||
box-shadow:var(--shadow);margin:0}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package loki
|
||||
|
||||
// web_files.go — parcourir et supprimer les fichiers produits par l'agent.
|
||||
//
|
||||
// L'agent écrit dans son dossier de travail (chat_workspace.go) : rapports,
|
||||
// scripts, exports, captures d'écran. Jusqu'ici on ne pouvait récupérer un
|
||||
// fichier que si le modèle en avait mis le lien dans sa réponse — tout ce qu'il
|
||||
// produisait sans le dire restait invisible, et rien ne permettait de faire le
|
||||
// ménage sans passer par un shell.
|
||||
//
|
||||
// Le téléchargement existait déjà (handleChatFile, web_upload.go). Ce fichier
|
||||
// ajoute les deux verbes qui manquaient : LISTER et SUPPRIMER.
|
||||
//
|
||||
// Tout est borné par workspaceRel (web_upload.go), qui résout les liens
|
||||
// symboliques des DEUX côtés avant de comparer : un lien posé dans le dossier
|
||||
// de travail ne peut pas servir d'échappatoire vers le reste du disque.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// wsEntry — une ligne du panneau. Pour un dossier, Size est la somme récursive
|
||||
// de son contenu : c'est ce qu'on libère en le supprimant, donc c'est ce chiffre
|
||||
// qui aide à décider.
|
||||
type wsEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"` // relatif au dossier de travail, séparateurs /
|
||||
Dir bool `json:"dir"`
|
||||
Size int64 `json:"size"`
|
||||
Mod int64 `json:"mod"` // date de modification, epoch en secondes
|
||||
Image bool `json:"image"` // affichable via /api/chat/image
|
||||
Items int `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// wsImageExt : extensions présentées comme images dans le panneau. Simple
|
||||
// indice d'affichage — /api/chat/image renifle le CONTENU avant de servir quoi
|
||||
// que ce soit, donc un .png qui contient du HTML est refusé là-bas, pas ici.
|
||||
var wsImageExt = map[string]bool{
|
||||
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
|
||||
".webp": true, ".bmp": true, ".svg": true, ".avif": true,
|
||||
}
|
||||
|
||||
// wsWalkLimit borne le parcours récursif qui calcule la taille d'un dossier.
|
||||
// Un workspace part de zéro et grossit lentement, mais rien n'empêche l'agent
|
||||
// d'y dézipper 200 000 fichiers : la liste doit rester instantanée, quitte à
|
||||
// annoncer une taille approchée (Items le dit).
|
||||
const wsWalkLimit = 20000
|
||||
|
||||
// wsResolve traduit un chemin RELATIF venu du client en chemin absolu vérifié.
|
||||
// La chaîne vide et "." désignent la racine du dossier de travail — cas que
|
||||
// workspaceRel rejette (il renvoie false sur "."), d'où ce sas.
|
||||
func wsResolve(rel string) (string, bool) {
|
||||
rel = strings.TrimSpace(rel)
|
||||
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
|
||||
if rel == "" || rel == "." {
|
||||
return agentWorkspace(), true
|
||||
}
|
||||
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
|
||||
if _, ok := workspaceRel(abs); !ok {
|
||||
return "", false
|
||||
}
|
||||
return abs, true
|
||||
}
|
||||
|
||||
// dirSize additionne le contenu d'un dossier, en s'arrêtant à wsWalkLimit
|
||||
// entrées. Renvoie aussi le nombre de fichiers rencontrés.
|
||||
func dirSize(root string) (total int64, count int) {
|
||||
var walk func(string)
|
||||
walk = func(dir string) {
|
||||
if count >= wsWalkLimit {
|
||||
return
|
||||
}
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range ents {
|
||||
if count >= wsWalkLimit {
|
||||
return
|
||||
}
|
||||
// Un lien symbolique n'est pas suivi : sa cible ne nous appartient
|
||||
// pas, et une boucle de liens ferait tourner ce parcours sans fin.
|
||||
if e.Type()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
if e.IsDir() {
|
||||
walk(filepath.Join(dir, e.Name()))
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil {
|
||||
total += info.Size()
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return total, count
|
||||
}
|
||||
|
||||
// handleChatFiles liste un dossier du workspace.
|
||||
//
|
||||
// GET /api/chat/files?dir=<relatif>
|
||||
// → {ok, dir, parent, entries:[…], total, count}
|
||||
//
|
||||
// `total` et `count` portent sur le dossier de travail ENTIER, pas sur le
|
||||
// dossier affiché : c'est l'occupation disque de l'agent, l'information qu'on
|
||||
// veut avoir sous les yeux en faisant le ménage.
|
||||
func handleChatFiles(w http.ResponseWriter, r *http.Request) {
|
||||
rel := r.URL.Query().Get("dir")
|
||||
abs, ok := wsResolve(rel)
|
||||
if !ok {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil || !st.IsDir() {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "dossier introuvable"})
|
||||
return
|
||||
}
|
||||
ents, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
cur, _ := workspaceRel(abs) // "" à la racine
|
||||
entries := []wsEntry{}
|
||||
for _, e := range ents {
|
||||
name := e.Name()
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p := name
|
||||
if cur != "" {
|
||||
p = cur + "/" + name
|
||||
}
|
||||
it := wsEntry{Name: name, Path: p, Dir: e.IsDir(), Mod: info.ModTime().Unix()}
|
||||
if e.IsDir() {
|
||||
it.Size, it.Items = dirSize(filepath.Join(abs, name))
|
||||
} else {
|
||||
it.Size = info.Size()
|
||||
it.Image = wsImageExt[strings.ToLower(filepath.Ext(name))]
|
||||
}
|
||||
entries = append(entries, it)
|
||||
}
|
||||
// Dossiers d'abord, puis du plus récent au plus ancien : ce qu'on cherche
|
||||
// après un tour d'agent, c'est ce qu'il vient d'écrire.
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].Dir != entries[j].Dir {
|
||||
return entries[i].Dir
|
||||
}
|
||||
return entries[i].Mod > entries[j].Mod
|
||||
})
|
||||
|
||||
parent := ""
|
||||
if cur != "" {
|
||||
if i := strings.LastIndexByte(cur, '/'); i >= 0 {
|
||||
parent = cur[:i]
|
||||
}
|
||||
}
|
||||
total, count := dirSize(agentWorkspace())
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true, "dir": cur, "parent": parent, "at_root": cur == "",
|
||||
"entries": entries, "total": total, "count": count,
|
||||
"root": agentWorkspace(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleChatFileDelete supprime un fichier ou un dossier du workspace.
|
||||
//
|
||||
// POST /api/chat/file/delete {"path":"rapport.md"}
|
||||
// → {ok, freed}
|
||||
//
|
||||
// La racine du dossier de travail est refusée : « tout supprimer » n'est pas un
|
||||
// geste qu'on veut à un clic de distance, et l'agent perdrait aussi le dossier
|
||||
// dans lequel il écrit.
|
||||
func handleChatFileDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if strings.TrimSpace(req.Path) == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
|
||||
return
|
||||
}
|
||||
abs, ok := wsResolve(req.Path)
|
||||
if !ok {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
|
||||
return
|
||||
}
|
||||
if abs == agentWorkspace() {
|
||||
sendJSON(w, 403, map[string]any{"ok": false, "error": "le dossier de travail lui-même ne se supprime pas"})
|
||||
return
|
||||
}
|
||||
st, err := os.Lstat(abs)
|
||||
if err != nil {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "introuvable"})
|
||||
return
|
||||
}
|
||||
var freed int64
|
||||
if st.IsDir() {
|
||||
freed, _ = dirSize(abs)
|
||||
err = os.RemoveAll(abs)
|
||||
} else {
|
||||
freed = st.Size()
|
||||
err = os.Remove(abs)
|
||||
}
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed})
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withWorkspace pointe le dossier de travail sur un dossier temporaire. Le
|
||||
// chemin est mémoïsé par un sync.Once (chat_workspace.go), donc on force la
|
||||
// variable directement — c'est le seul moyen d'isoler ces tests.
|
||||
func withWorkspace(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
// EvalSymlinks : sur macOS /var est un lien vers /private/var, et
|
||||
// workspaceRel compare des chemins résolus. Sans ça tout serait « hors du
|
||||
// dossier de travail ».
|
||||
if r, err := filepath.EvalSymlinks(dir); err == nil {
|
||||
dir = r
|
||||
}
|
||||
// On laisse d'abord agentWorkspace s'initialiser NORMALEMENT : sinon on
|
||||
// consomme le sync.Once avec une valeur vide, et tout test ultérieur qui
|
||||
// appelle agentWorkspace récupère "" après restauration — une panne à
|
||||
// distance, dans un autre fichier, sans rapport visible avec celui-ci.
|
||||
prevPath := agentWorkspace()
|
||||
workspacePath = dir
|
||||
t.Cleanup(func() { workspacePath = prevPath })
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func listFiles(t *testing.T, dir string) map[string]any {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFiles(rec, httptest.NewRequest("GET", "/api/chat/files?dir="+dir, nil))
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
out["_code"] = rec.Code
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFilesListSortsAndSizes(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "rapport.md"), "bonjour")
|
||||
writeFile(t, filepath.Join(ws, "captures", "conv1", "a.jpg"), "0123456789")
|
||||
writeFile(t, filepath.Join(ws, "captures", "conv1", "b.jpg"), "01234")
|
||||
|
||||
out := listFiles(t, "")
|
||||
if out["ok"] != true {
|
||||
t.Fatalf("liste refusée : %v", out)
|
||||
}
|
||||
entries := out["entries"].([]any)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("attendu 2 entrées à la racine, obtenu %d : %v", len(entries), entries)
|
||||
}
|
||||
// Les dossiers passent devant, quelle que soit leur date.
|
||||
first := entries[0].(map[string]any)
|
||||
if first["name"] != "captures" || first["dir"] != true {
|
||||
t.Errorf("le dossier doit être en tête, obtenu %v", first)
|
||||
}
|
||||
// La taille d'un dossier est celle de TOUT son contenu : c'est ce qu'on
|
||||
// libère en le supprimant, donc c'est le chiffre qui compte.
|
||||
if got := first["size"].(float64); got != 15 {
|
||||
t.Errorf("taille du dossier = %v, attendu 15 (10+5)", got)
|
||||
}
|
||||
if got := first["items"].(float64); got != 2 {
|
||||
t.Errorf("items = %v, attendu 2", got)
|
||||
}
|
||||
// Le total porte sur le dossier de travail entier, pas sur le dossier affiché.
|
||||
if got := out["total"].(float64); got != 22 {
|
||||
t.Errorf("total = %v, attendu 22 (7+10+5)", got)
|
||||
}
|
||||
if out["at_root"] != true || out["parent"] != "" {
|
||||
t.Errorf("racine mal signalée : %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesListSubdirAndImageFlag(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "captures", "conv1", "shot.jpg"), "x")
|
||||
writeFile(t, filepath.Join(ws, "captures", "conv1", "notes.txt"), "y")
|
||||
|
||||
out := listFiles(t, "captures/conv1")
|
||||
if out["ok"] != true {
|
||||
t.Fatalf("liste refusée : %v", out)
|
||||
}
|
||||
if out["dir"] != "captures/conv1" || out["parent"] != "captures" {
|
||||
t.Errorf("fil d'Ariane cassé : dir=%v parent=%v", out["dir"], out["parent"])
|
||||
}
|
||||
img := map[string]bool{}
|
||||
for _, e := range out["entries"].([]any) {
|
||||
m := e.(map[string]any)
|
||||
img[m["name"].(string)] = m["image"] == true
|
||||
// Le chemin renvoyé doit être utilisable tel quel par /api/chat/file.
|
||||
if !strings.HasPrefix(m["path"].(string), "captures/conv1/") {
|
||||
t.Errorf("chemin non préfixé : %v", m["path"])
|
||||
}
|
||||
}
|
||||
if !img["shot.jpg"] {
|
||||
t.Error("shot.jpg devrait être marqué image")
|
||||
}
|
||||
if img["notes.txt"] {
|
||||
t.Error("notes.txt ne devrait pas être marqué image")
|
||||
}
|
||||
}
|
||||
|
||||
// Le bornage est la partie qui compte : ces routes lisent et SUPPRIMENT sur
|
||||
// disque à partir d'un chemin fourni par le navigateur.
|
||||
func TestFilesRejectsEscapes(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "dedans.txt"), "ok")
|
||||
secret := filepath.Join(filepath.Dir(ws), "dehors.txt")
|
||||
writeFile(t, secret, "secret")
|
||||
|
||||
for _, bad := range []string{"..", "../", "../..", "/etc", "captures/../..", `..\..`} {
|
||||
out := listFiles(t, bad)
|
||||
if out["ok"] == true {
|
||||
t.Errorf("dir=%q accepté alors qu'il sort du dossier de travail : %v", bad, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Suppression : même bornage, et le fichier voisin doit survivre.
|
||||
for _, bad := range []string{"../dehors.txt", "/etc/passwd", "..", ""} {
|
||||
rec := httptest.NewRecorder()
|
||||
body := strings.NewReader(`{"path":` + jsonQuote(bad) + `}`)
|
||||
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete", body))
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("suppression de %q acceptée : %s", bad, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(secret); err != nil {
|
||||
t.Fatalf("le fichier hors workspace a été supprimé : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// La racine elle-même ne se supprime pas : l'agent perdrait le dossier dans
|
||||
// lequel il écrit, et « tout effacer » ne doit pas être à un clic de distance.
|
||||
func TestFilesRootNotDeletable(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "a.txt"), "a")
|
||||
for _, p := range []string{".", "./", "/"} {
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
|
||||
strings.NewReader(`{"path":`+jsonQuote(p)+`}`)))
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("path=%q a supprimé la racine", p)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(ws); err != nil {
|
||||
t.Fatalf("dossier de travail détruit : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesDeleteFileAndDir(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "rapport.md"), "bonjour")
|
||||
writeFile(t, filepath.Join(ws, "captures", "conv1", "a.jpg"), "0123456789")
|
||||
|
||||
del := func(p string) map[string]any {
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
|
||||
strings.NewReader(`{"path":`+jsonQuote(p)+`}`)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
out["_code"] = rec.Code
|
||||
return out
|
||||
}
|
||||
|
||||
r := del("rapport.md")
|
||||
if r["ok"] != true || r["freed"].(float64) != 7 {
|
||||
t.Errorf("suppression de fichier : %v", r)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, "rapport.md")); !os.IsNotExist(err) {
|
||||
t.Error("le fichier est toujours là")
|
||||
}
|
||||
|
||||
// Un dossier part avec tout son contenu — c'est ce que l'interface annonce.
|
||||
r = del("captures")
|
||||
if r["ok"] != true || r["freed"].(float64) != 10 {
|
||||
t.Errorf("suppression de dossier : %v", r)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, "captures")); !os.IsNotExist(err) {
|
||||
t.Error("le dossier est toujours là")
|
||||
}
|
||||
// Le dossier de travail, lui, doit avoir survécu.
|
||||
if _, err := os.Stat(ws); err != nil {
|
||||
t.Fatalf("dossier de travail détruit : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Un lien symbolique posé dans le dossier de travail ne doit pas servir de
|
||||
// passage vers le reste du disque — ni en lecture, ni en suppression.
|
||||
func TestFilesSymlinkEscape(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
outside := t.TempDir()
|
||||
writeFile(t, filepath.Join(outside, "cible.txt"), "secret")
|
||||
if err := os.Symlink(outside, filepath.Join(ws, "evasion")); err != nil {
|
||||
t.Skipf("liens symboliques indisponibles : %v", err)
|
||||
}
|
||||
if out := listFiles(t, "evasion"); out["ok"] == true {
|
||||
t.Errorf("le lien symbolique a été suivi : %v", out)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
|
||||
strings.NewReader(`{"path":"evasion/cible.txt"}`)))
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("suppression à travers le lien acceptée : %s", rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "cible.txt")); err != nil {
|
||||
t.Fatalf("fichier hors workspace supprimé : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonQuote(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
@@ -217,13 +217,15 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/restart", svcHandler("restart"))
|
||||
api("/api/bench", handleBench)
|
||||
api("/api/bench/last", handleBenchLast)
|
||||
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
||||
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
||||
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
||||
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
||||
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
||||
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
||||
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
||||
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
||||
api("/api/chat/files", handleChatFiles) // liste un dossier du dossier de travail (panneau Fichiers)
|
||||
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier du dossier de travail
|
||||
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
||||
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
||||
// Discussions : liste, création, bascule, renommage, suppression.
|
||||
api("/api/conversations", handleConvList)
|
||||
api("/api/conversations/new", handleConvNew)
|
||||
|
||||
Reference in new issue
Block a user