mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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
246 lines
8.1 KiB
Go
246 lines
8.1 KiB
Go
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.
|
|
//
|
|
// Le panneau s'ouvre sur le dossier de la DISCUSSION active (chat_convfiles.go)
|
|
// et n'en sort pas : les fichiers appartiennent à la discussion où ils sont nés,
|
|
// changer de discussion change ce qu'on voit. `scope=legacy` ouvre la racine du
|
|
// dossier de travail — les fichiers d'avant ce rangement, que la migration n'a
|
|
// pas su rattacher à une discussion.
|
|
//
|
|
// Tout est borné par relWithin (chat_convfiles.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 à la racine du panneau, 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é,
|
|
// sous la racine du panneau. La chaîne vide et "." désignent cette racine — cas
|
|
// que relWithin rejette (il renvoie false sur "."), d'où ce sas.
|
|
func wsResolve(root, rel string) (string, bool) {
|
|
rel = strings.TrimSpace(rel)
|
|
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
|
|
if rel == "" || rel == "." {
|
|
return root, true
|
|
}
|
|
abs := filepath.Join(root, filepath.FromSlash(rel))
|
|
if _, ok := relWithin(root, 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 de la discussion active.
|
|
//
|
|
// GET /api/chat/files?dir=<relatif>[&scope=legacy]
|
|
// → {ok, dir, parent, entries:[…], total, count, scope, legacy}
|
|
//
|
|
// `total` et `count` portent sur le dossier ENTIER de la discussion, pas sur le
|
|
// dossier affiché : c'est ce que cette discussion occupe sur le disque, donc ce
|
|
// qu'on libère en la supprimant.
|
|
//
|
|
// `legacy` compte ce qui reste à la racine du dossier de travail, hors
|
|
// discussions : l'UI n'offre le détour « hors discussion » que si ce compte est
|
|
// non nul, et le bouton disparaît une fois le ménage fait.
|
|
func handleChatFiles(w http.ResponseWriter, r *http.Request) {
|
|
root, legacy := filesRoot(r.URL.Query().Get("scope"))
|
|
abs, ok := wsResolve(root, r.URL.Query().Get("dir"))
|
|
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, _ := relWithin(root, abs) // "" à la racine
|
|
entries := []wsEntry{}
|
|
for _, e := range ents {
|
|
name := e.Name()
|
|
// Vue « hors discussion » : le dossier des discussions n'y a pas sa place,
|
|
// il est déjà accessible discussion par discussion.
|
|
if legacy && cur == "" && name == convFilesRoot {
|
|
continue
|
|
}
|
|
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(root)
|
|
scope := ""
|
|
if legacy {
|
|
scope = "legacy"
|
|
}
|
|
sendJSON(w, 200, map[string]any{
|
|
"ok": true, "dir": cur, "parent": parent, "at_root": cur == "",
|
|
"entries": entries, "total": total, "count": count,
|
|
"root": root, "scope": scope, "legacy": legacyCount(),
|
|
})
|
|
}
|
|
|
|
// handleChatFileDelete supprime un fichier ou un dossier de la discussion.
|
|
//
|
|
// POST /api/chat/file/delete {"path":"rapport.md"[,"scope":"legacy"]}
|
|
// → {ok, freed}
|
|
//
|
|
// La racine 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.
|
|
// Le dossier des discussions l'est aussi — depuis la vue « hors discussion », il
|
|
// emporterait les fichiers de TOUTES les discussions d'un seul clic.
|
|
func handleChatFileDelete(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if strings.TrimSpace(req.Path) == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
|
|
return
|
|
}
|
|
root, legacy := filesRoot(req.Scope)
|
|
abs, ok := wsResolve(root, req.Path)
|
|
if !ok {
|
|
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
|
|
return
|
|
}
|
|
if abs == root {
|
|
sendJSON(w, 403, map[string]any{"ok": false, "error": "le dossier de travail lui-même ne se supprime pas"})
|
|
return
|
|
}
|
|
if legacy && abs == filepath.Join(agentWorkspace(), convFilesRoot) {
|
|
sendJSON(w, 403, map[string]any{"ok": false, "error": "les dossiers des discussions se suppriment discussion par discussion"})
|
|
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})
|
|
}
|