Un dossier de fichiers par discussion

Les fichiers du chat vivaient dans un pot commun : on changeait de discussion
et on revoyait les mêmes pièces jointes, et supprimer une discussion laissait
derrière elle tout ce qu'on y avait déposé ou fait écrire à l'agent (seules ses
captures, déjà rangées par identifiant, partaient avec).

Chaque discussion a désormais son dossier, <workspace>/discussions/<id>/ :

- les dépôts (uploads/), les captures (captures/) et ce que l'agent écrit y
  atterrissent ; le shell et les chemins relatifs du modèle y sont résolus ;
- le panneau Fichiers s'ouvre sur ce dossier et n'en sort pas, et se redessine
  quand la discussion change (bascule ou vidage, signalés par le flux SSE) ;
- supprimer une discussion — ou la vider — emporte ses fichiers. Les deux gestes
  le disent maintenant avant de demander confirmation ; « clear chat » en
  demandait aucune.

La racine du dossier de travail reste la borne de sécurité : les liens des
anciens messages (uploads/x.pdf, captures/<id>/y.jpg) continuent d'ouvrir leur
fichier par un chemin de repli. Au démarrage, une migration range les captures
dans le dossier de leur discussion et rend chaque dépôt à la discussion qui le
mentionne dans son journal ; ce que personne ne réclame reste à la racine,
atteignable par le bouton « hors discussion » du panneau, qui disparaît une fois
le ménage fait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
This commit is contained in:
Claude committed 2026-08-16 15:33:55 +00:00
1 parent d42c334b9a
commit 001d633750
21 files changed
+914 -168

No files matched your search

+11 -2
View File
@@ -327,9 +327,18 @@ function addCopyButtons(root){
pre.appendChild(btn);
});
}
// Nouvelle conversation POUR TOUS LES APPAREILS : le serveur vide le fil et
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); toast('nouvelle conversation'); }
//
// Confirmé, car le geste emporte aussi les FICHIERS de la discussion (dépôts,
// captures, ce que l'agent y a écrit) : ils n'appartiennent qu'à elle, et plus
// un message ne les mentionnerait.
async function resetChat(){
if(!await askConfirm('Le fil de cette discussion et ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront effacés.\n\nC\'est définitif.',
{title:'Vider la discussion', okText:'Vider', danger:true})) return;
jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{});
toast('discussion vidée');
}
// Compaction : on demande à l'IA un résumé de la conversation destiné à la
// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
+3 -2
View File
@@ -154,8 +154,9 @@ function handleDelta(d){
return; }
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil.
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; }
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
+53 -8
View File
@@ -1,9 +1,15 @@
// ─── Fichiers du dossier de travail ──────────────────────────────────────────
// ─── 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).
@@ -11,7 +17,8 @@
// 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_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
@@ -44,7 +51,7 @@ async function loadFiles(dir){
const foot = document.getElementById('files-foot');
if(!list) return;
let r;
try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)); }
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
@@ -58,13 +65,40 @@ async function loadFiles(dir){
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.";
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));
foot.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
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 || '';
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){
@@ -76,7 +110,9 @@ function filesMsg(txt){
list.appendChild(d);
}
// Fil d'Ariane : « travail / captures / <id> », chaque segment cliquable.
// 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 = '';
@@ -89,7 +125,7 @@ function renderCrumb(r){
c.appendChild(b);
};
const parts = r.dir ? r.dir.split('/') : [];
seg('travail', '', parts.length === 0);
seg(FILES_SCOPE === 'legacy' ? 'hors discussion' : 'discussion', '', parts.length === 0);
let acc = '';
parts.forEach((p, i)=>{
acc = acc ? acc + '/' + p : p;
@@ -155,12 +191,21 @@ async function removeFile(e){
: '« '+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});
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();
}
// 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);
+3 -1
View File
@@ -85,7 +85,9 @@ async function convRename(id, current){
}
async function convDelete(id, title){
if(!await askConfirm('Supprimer « ' + title + ' » ? Cette discussion et son historique seront perdus.',
// Le geste emporte aussi les fichiers de la discussion : ils lui appartiennent
// (un dossier par discussion côté serveur), autant le dire avant.
if(!await askConfirm('Supprimer « ' + title + ' » ? Son historique ET ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront perdus.',
{title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
try{
const r = await jpost('/api/conversations/delete', {id});