diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index 6c4ac64..addb86d 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -7210,6 +7210,16 @@ function renderFoot(foot, r){
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';
@@ -7323,6 +7333,51 @@ async function removeFile(e){
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
diff --git a/internal/loki/ui/src/js/15-files.js b/internal/loki/ui/src/js/15-files.js
index a188ea9..87fb15a 100644
--- a/internal/loki/ui/src/js/15-files.js
+++ b/internal/loki/ui/src/js/15-files.js
@@ -86,6 +86,16 @@ function renderFoot(foot, r){
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';
@@ -199,6 +209,51 @@ async function removeFile(e){
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
diff --git a/internal/loki/web_files_zip.go b/internal/loki/web_files_zip.go
new file mode 100644
index 0000000..3398864
--- /dev/null
+++ b/internal/loki/web_files_zip.go
@@ -0,0 +1,240 @@
+package loki
+
+// web_files_zip.go — téléchargement de TOUS les fichiers d'une discussion en
+// une archive .zip (bouton du panneau Fichiers).
+//
+// GET /api/chat/files/zip?meta=1[&scope=legacy] → {ok, name, size, e2e}
+// GET /api/chat/files/zip[&scope=legacy] → le zip, brut
+// GET /api/chat/files/zip?b64=1&offset=&len= → une tranche base64
+//
+// Même danse en trois temps que /api/chat/file (web_upload.go), et pour la
+// même raison : à travers le tunnel chiffré, le binaire ne survit pas — seules
+// les tranches base64 passent. L'archive est construite dans un fichier
+// TEMPORAIRE à l'appel `meta` (le client commence toujours par lui), puis les
+// appels suivants la servent telle quelle : la construire à chaque tranche
+// donnerait des offsets incohérents si un fichier bouge entre deux tranches.
+//
+// Les liens symboliques ne sont PAS suivis (même règle que dirSize) et le
+// parcours s'arrête à wsWalkLimit entrées : le zip d'une discussion est celui
+// de ce que le panneau affiche, pas un aspirateur à disque.
+
+import (
+ "archive/zip"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// zipPathFor : l'archive temporaire d'une racine donnée. Un nom stable par
+// discussion — l'appel meta l'écrase, les tranches la relisent.
+func zipPathFor(scope string) string {
+ id := "legacy"
+ if strings.TrimSpace(scope) != "legacy" {
+ id = safeConvID(convEnsureActive())
+ if id == "" {
+ id = "conv"
+ }
+ }
+ return filepath.Join(os.TempDir(), "loki-files-"+id+".zip")
+}
+
+// zipName : le nom proposé au navigateur.
+func zipName(scope string) string {
+ stamp := time.Now().Format("2006-01-02")
+ if strings.TrimSpace(scope) == "legacy" {
+ return "loki-hors-discussion-" + stamp + ".zip"
+ }
+ // Le titre de la discussion, s'il existe, fait un nom parlant ; l'ID sinon.
+ id := convEnsureActive()
+ title := ""
+ for _, m := range convIndex() {
+ if m.ID == id {
+ title = m.Title
+ break
+ }
+ }
+ slug := slugify(title)
+ if slug == "" {
+ slug = id
+ }
+ return "loki-" + slug + "-" + stamp + ".zip"
+}
+
+// slugify réduit un titre à un nom de fichier sûr : minuscules, ascii, tirets.
+func slugify(s string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(strings.TrimSpace(s)) {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ b.WriteRune(r)
+ case r == ' ' || r == '-' || r == '_' || r == '.':
+ // Pas de tirets en rafale : « Déjà vu » → dj-vu, pas dj---vu.
+ if out := b.String(); out != "" && !strings.HasSuffix(out, "-") {
+ b.WriteByte('-')
+ }
+ }
+ if b.Len() >= 40 {
+ break
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
+// buildZip archive le contenu de root dans dest. Liens symboliques ignorés,
+// parcours borné, chemins en avant-slash relatifs à root. En scope
+// hors-discussion, le dossier des discussions est exclu (il a son propre zip,
+// discussion par discussion).
+func buildZip(root, dest string, excludeConvs bool) (files int, err error) {
+ out, err := os.Create(dest)
+ if err != nil {
+ return 0, err
+ }
+ defer out.Close()
+ zw := zip.NewWriter(out)
+ defer zw.Close()
+
+ var walk func(dir, rel string) error
+ walk = func(dir, rel string) error {
+ ents, rerr := os.ReadDir(dir)
+ if rerr != nil {
+ return nil // dossier illisible : on archive le reste
+ }
+ for _, e := range ents {
+ if files >= wsWalkLimit {
+ return nil
+ }
+ if e.Type()&os.ModeSymlink != 0 {
+ continue
+ }
+ childRel := e.Name()
+ if rel != "" {
+ childRel = rel + "/" + e.Name()
+ }
+ if e.IsDir() {
+ if excludeConvs && rel == "" && e.Name() == convFilesRoot {
+ continue
+ }
+ if werr := walk(filepath.Join(dir, e.Name()), childRel); werr != nil {
+ return werr
+ }
+ continue
+ }
+ info, ierr := e.Info()
+ if ierr != nil {
+ continue
+ }
+ hdr, herr := zip.FileInfoHeader(info)
+ if herr != nil {
+ continue
+ }
+ hdr.Name = childRel
+ hdr.Method = zip.Deflate
+ w, werr := zw.CreateHeader(hdr)
+ if werr != nil {
+ return werr
+ }
+ f, ferr := os.Open(filepath.Join(dir, e.Name()))
+ if ferr != nil {
+ continue
+ }
+ _, cerr := io.Copy(w, f)
+ f.Close()
+ if cerr != nil {
+ return cerr
+ }
+ files++
+ }
+ return nil
+ }
+ if err := walk(root, ""); err != nil {
+ return files, err
+ }
+ return files, nil
+}
+
+// handleChatFilesZip — voir l'en-tête du fichier.
+func handleChatFilesZip(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ scope := q.Get("scope")
+ root, _ := filesRoot(scope)
+ tmp := zipPathFor(scope)
+
+ if q.Get("meta") != "" {
+ files, err := buildZip(root, tmp, scope == "legacy")
+ if err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ if files == 0 {
+ os.Remove(tmp)
+ sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun fichier à archiver"})
+ return
+ }
+ st, err := os.Stat(tmp)
+ if err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{
+ "ok": true, "name": zipName(scope), "size": st.Size(), "files": files,
+ "e2e": r.Header.Get(e2eInnerHeader) != "",
+ })
+ return
+ }
+
+ st, err := os.Stat(tmp)
+ if err != nil {
+ sendJSON(w, 404, map[string]any{"ok": false, "error": "archive absente — redemande la fiche (meta=1) d'abord"})
+ return
+ }
+ if q.Get("b64") != "" {
+ off, _ := strconv.ParseInt(q.Get("offset"), 10, 64)
+ length, _ := strconv.ParseInt(q.Get("len"), 10, 64)
+ if length <= 0 || length > downloadChunkMax {
+ length = downloadChunkMax
+ }
+ if off < 0 || off > st.Size() {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": "position hors du fichier"})
+ return
+ }
+ if off+length > st.Size() {
+ length = st.Size() - off
+ }
+ f, ferr := os.Open(tmp)
+ if ferr != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": ferr.Error()})
+ return
+ }
+ defer f.Close()
+ buf := make([]byte, length)
+ // Même lecture que /api/chat/file : ReadAt remplit tout le tampon, et
+ // io.EOF sur la dernière tranche est normal.
+ n, rerr := f.ReadAt(buf, off)
+ if rerr != nil && rerr != io.EOF {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": rerr.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{
+ "ok": true, "name": zipName(scope), "size": st.Size(), "offset": off,
+ "data": base64.StdEncoding.EncodeToString(buf[:n]),
+ "eof": off+int64(n) >= st.Size(),
+ })
+ return
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipName(scope)))
+ w.Header().Set("Content-Length", strconv.FormatInt(st.Size(), 10))
+ f, err := os.Open(tmp)
+ if err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ defer f.Close()
+ io.Copy(w, f)
+}
diff --git a/internal/loki/web_files_zip_test.go b/internal/loki/web_files_zip_test.go
new file mode 100644
index 0000000..c1fbb36
--- /dev/null
+++ b/internal/loki/web_files_zip_test.go
@@ -0,0 +1,96 @@
+package loki
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// L'archive embarque TOUT l'arbre — fichiers, dossiers, sous-dossiers — avec
+// des chemins relatifs en avant-slash.
+func TestZipToutLArbre(t *testing.T) {
+ ws := withWorkspace(t)
+ writeFile(t, filepath.Join(ws, "racine.txt"), "a")
+ writeFile(t, filepath.Join(ws, "docs", "note.md"), "b")
+ writeFile(t, filepath.Join(ws, "src", "sub", "deep", "main.go"), "c")
+
+ dest := filepath.Join(t.TempDir(), "out.zip")
+ n, err := buildZip(ws, dest, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 3 {
+ t.Fatalf("%d fichiers archivés, attendu 3", n)
+ }
+ zr, err := zip.OpenReader(dest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer zr.Close()
+ got := map[string]bool{}
+ for _, f := range zr.File {
+ got[f.Name] = true
+ }
+ for _, want := range []string{"racine.txt", "docs/note.md", "src/sub/deep/main.go"} {
+ if !got[want] {
+ t.Errorf("%s absent de l'archive (contenu : %v)", want, got)
+ }
+ }
+}
+
+// Le scope hors-discussion n'aspire pas le dossier des discussions.
+func TestZipLegacyExclutDiscussions(t *testing.T) {
+ ws := withWorkspace(t)
+ root := agentWorkspace()
+ writeFile(t, filepath.Join(root, "orphelin.txt"), "x")
+ writeFile(t, filepath.Join(ws, "dans-discussion.txt"), "y") // sous discussions/
+
+ dest := filepath.Join(t.TempDir(), "out.zip")
+ n, err := buildZip(root, dest, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ zr, err := zip.OpenReader(dest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer zr.Close()
+ for _, f := range zr.File {
+ if f.Name == "discussions/"+filepath.Base(ws)+"/dans-discussion.txt" || filepath.Dir(f.Name) != "." && f.Name[:12] == "discussions/" {
+ t.Errorf("le dossier des discussions a fuité dans l'archive : %s", f.Name)
+ }
+ }
+ if n < 1 {
+ t.Fatal("orphelin.txt manquant")
+ }
+}
+
+// Une discussion vide : 404 propre, pas d'archive fantôme.
+func TestZipVide(t *testing.T) {
+ ws := withWorkspace(t)
+ dest := filepath.Join(t.TempDir(), "out.zip")
+ n, err := buildZip(ws, dest, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Fatalf("%d fichiers dans une discussion vide", n)
+ }
+ _ = os.Remove(dest)
+}
+
+// slugify : titres réels → noms de fichiers sûrs.
+func TestSlugify(t *testing.T) {
+ cases := map[string]string{
+ "Corrige le bug d'auth": "corrige-le-bug-dauth",
+ " Déjà vu ! ": "dj-vu",
+ "": "",
+ "UPPER case.and.dots": "upper-case-and-dots",
+ }
+ for in, want := range cases {
+ if got := slugify(in); got != want {
+ t.Errorf("slugify(%q) = %q, attendu %q", in, got, want)
+ }
+ }
+}
diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go
index 534619c..db7cce8 100644
--- a/internal/loki/web_server.go
+++ b/internal/loki/web_server.go
@@ -254,6 +254,7 @@ func newWebMux() *http.ServeMux {
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 de la discussion ouverte (panneau Fichiers)
+ api("/api/chat/files/zip", handleChatFilesZip) // toute la discussion en une archive .zip
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion
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)