diff --git a/README.md b/README.md index 615aa84..baabf8c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ seule image. │ ▲ modèles .gguf │ │ chromium (Playwright) — captures de pages web │ │ /data (config, bbolt, presets, mémoire, │ -│ workspace + captures, modèles installés) │ +│ workspace par discussion, modèles) │ │ /models (GGUF déposés à la main) │ └────────────────────────────────────────────────────┘ ``` @@ -122,8 +122,8 @@ et modèles compris. | `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) | | `/data/models/` | modèles téléchargés depuis l'interface | | `/data/memory/` | pages de mémoire persistante (`.md`) | -| `/data/workspace/` | dossier de travail de l'agent | -| `/data/workspace/captures//` | captures d'écran, un dossier par discussion | +| `/data/workspace/` | racine du dossier de travail de l'agent | +| `/data/workspace/discussions//` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit | | `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) | | `/models` | GGUF déposés à la main depuis l'hôte (volume séparé, `LOKI_MODEL_DIRS`) | @@ -161,9 +161,12 @@ Héritées d'AJEAN : Ajoutées par ce fork : - **Discussions multiples** : historique complet dans la barre latérale, titre - repris du premier message (renommable), suppression. Supprimer une discussion - efface aussi ses captures d'écran (`workspace/captures//`), pour que le - disque ne se remplisse pas en silence. + repris du premier message (renommable), suppression. **Chaque discussion a son + dossier de fichiers** (`workspace/discussions//`) : les pièces jointes + déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les + chemins relatifs du modèle y sont résolus. Changer de discussion change donc + les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que + le disque ne se remplisse pas en silence. - **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée du projecteur vision (voir [Installer un modèle](#installer-un-modèle)). - **Captures de pages web** : l'agent dispose de l'outil `web_screenshot` @@ -174,12 +177,15 @@ 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. +- **Panneau Fichiers** (bouton dossier du pied de carte) : les fichiers de la + discussion ouverte — dépôts, captures, ce que l'agent y a écrit — 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, et le pied donne l'occupation disque de la discussion. Les chemins + sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le + reste du disque ni les autres discussions ne sont atteignables. Les fichiers + d'avant ce rangement que la migration n'a pas su rattacher restent joignables + par le bouton **hors discussion**, qui disparaît une fois le ménage fait. - **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 diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index f65e2c3..bc72aec 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -557,9 +557,11 @@ func (c *Conversation) Stop() { // le bump d'epoch réduit de toute façon au silence. func (c *Conversation) Reset() { c.Stop() - // Vider la discussion efface aussi ses captures : les messages qui les - // affichaient disparaissent, les fichiers n'auraient plus aucun lecteur. - dropConvCaptures(convEnsureActive()) + // Vider la discussion efface aussi ses fichiers (dépôts, captures, ce que + // l'agent y a écrit) : les messages qui les mentionnaient disparaissent, plus + // rien ne les rattacherait à quoi que ce soit. L'UI le dit avant de demander + // confirmation. + dropConvFiles(convEnsureActive()) c.mu.Lock() c.Messages = nil c.Log = nil diff --git a/internal/loki/chat_convfiles.go b/internal/loki/chat_convfiles.go new file mode 100644 index 0000000..a060d05 --- /dev/null +++ b/internal/loki/chat_convfiles.go @@ -0,0 +1,304 @@ +package loki + +// chat_convfiles.go — les fichiers rangés PAR DISCUSSION. +// +// Avant, tout ce qui passait par le chat — pièces jointes déposées, rapports +// écrits par l'agent, captures de pages — atterrissait dans UN seul dossier de +// travail partagé par toutes les discussions. Changer de discussion ne changeait +// rien au panneau Fichiers (on revoyait les mêmes), et supprimer une discussion +// laissait ses fichiers derrière elle — seules ses captures, déjà rangées par +// identifiant, partaient avec. +// +// Désormais chaque discussion a son dossier : +// +// /discussions// ← dossier courant de l'agent +// /discussions//uploads/ ← pièces jointes de CETTE discussion +// /discussions//captures/ ← captures de CETTE discussion +// +// Supprimer une discussion (ou la vider) emporte tout le dossier, et le panneau +// Fichiers n'ouvre que celui de la discussion active. +// +// La racine du dossier de travail reste la BORNE de sécurité : les liens écrits +// dans les anciens messages (`uploads/x.pdf`, `captures//y.jpg`) pointent +// encore vers elle et doivent continuer de marcher — c'est le rôle de +// workspaceFile, qui cherche d'abord dans la discussion puis retombe sur les +// emplacements d'avant. + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "unicode" +) + +const ( + // convFilesRoot : le sous-dossier qui contient un dossier par discussion. + // Nommé, plutôt que d'éparpiller les identifiants à la racine, pour que le + // dossier de travail reste lisible quand on l'ouvre depuis l'hôte. + convFilesRoot = "discussions" + + // uploadsSub : les dépôts de l'utilisateur, à l'intérieur de la discussion. + uploadsSub = "uploads" +) + +// safeConvID refuse tout identifiant qui ne soit pas un simple nom : c'est lui +// qui devient un segment de chemin, et il arrive parfois du client (paramètre de +// requête, ancien lien de message). Renvoie "" si l'identifiant est douteux. +func safeConvID(id string) string { + id = strings.TrimSpace(id) + if id == "" || len(id) > 64 { + return "" + } + for _, r := range id { + if r == '-' || r == '_' || unicode.IsDigit(r) || (r < unicode.MaxASCII && unicode.IsLetter(r)) { + continue + } + return "" + } + return id +} + +// convDirFor renvoie le dossier (absolu) d'une discussion, sans le créer. Un +// identifiant vide ou douteux retombe sur la racine du dossier de travail : +// mieux vaut écrire au mauvais endroit que refuser d'écrire. +func convDirFor(convID string) string { + id := safeConvID(convID) + if id == "" { + return agentWorkspace() + } + return filepath.Join(agentWorkspace(), convFilesRoot, id) +} + +// convWorkspace est le dossier de travail COURANT : celui de la discussion +// active, créé au besoin. C'est là que résolvent les chemins relatifs du modèle +// (resolveAgentPath), que démarre son shell, et que se déposent les fichiers. +// +// Si la création échoue (disque plein, droits), on retombe sur la racine du +// dossier de travail : l'agent doit continuer d'écrire quelque part. +func convWorkspace() string { + dir := convDirFor(convEnsureActive()) + if dir == agentWorkspace() { + return dir + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return agentWorkspace() + } + return dir +} + +// dropConvFiles efface TOUT le dossier d'une discussion (dépôts, captures, +// fichiers écrits par l'agent). Appelé quand la discussion est supprimée ou +// vidée : plus un message ne les mentionne, personne ne les retrouverait. +// Best-effort — un échec ne doit rien interrompre. +func dropConvFiles(convID string) { + dir := convDirFor(convID) + if dir == agentWorkspace() { + return // identifiant douteux : surtout pas la racine + } + _ = os.RemoveAll(dir) +} + +// relWithin dit si `abs` se trouve DANS `root` et, si oui, renvoie son chemin +// relatif en séparateurs '/'. +// +// EvalSymlinks des deux côtés : sans ça, un lien posé dans le dossier passerait +// le test de préfixe tout en pointant ailleurs sur le disque. +func relWithin(root, abs string) (string, bool) { + if root == "" { + return "", false + } + if r, err := filepath.EvalSymlinks(root); err == nil { + root = r + } + if a, err := filepath.EvalSymlinks(abs); err == nil { + abs = a + } + rel, err := filepath.Rel(root, abs) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return filepath.ToSlash(rel), true +} + +// workspaceFile résout un chemin RELATIF venu d'un client ou d'un message +// (`uploads/rapport.pdf`, `captures/xxx.jpg`) en fichier du disque. +// +// Ordre de recherche : +// 1. le dossier de la discussion active — le cas normal ; +// 2. les emplacements d'AVANT le rangement par discussion, pour que les liens +// des anciens messages continuent d'ouvrir leur fichier. +// +// ok=false signale une sortie du périmètre (« .. »), pas une absence : un +// fichier introuvable revient avec le chemin attendu, pour que l'appelant +// réponde « introuvable » et non « hors du dossier de travail ». +func workspaceFile(rel string) (string, bool) { + rel = strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"), "/") + if rel == "" { + return "", false + } + base := convWorkspace() + p := filepath.Join(base, filepath.FromSlash(rel)) + if _, ok := relWithin(base, p); !ok { + return "", false + } + if _, err := os.Stat(p); err == nil { + return p, true + } + if legacy, ok := legacyWorkspaceFile(rel); ok { + return legacy, true + } + return p, true +} + +// legacyWorkspaceFile retrouve un fichier désigné par un chemin d'avant le +// rangement par discussion. Deux formes existaient : +// +// captures//x.jpg → discussions//captures/x.jpg (déplacé par la migration) +// uploads/x.pdf → resté à la racine si aucune discussion ne le réclamait +func legacyWorkspaceFile(rel string) (string, bool) { + if parts := strings.SplitN(rel, "/", 3); len(parts) == 3 && parts[0] == captureDir && safeConvID(parts[1]) != "" { + p := filepath.Join(convDirFor(parts[1]), captureDir, filepath.FromSlash(parts[2])) + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, true + } + } + p := filepath.Join(agentWorkspace(), filepath.FromSlash(rel)) + if _, ok := workspaceRel(p); !ok { + return "", false + } + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, true + } + return "", false +} + +// filesRoot donne la racine du panneau Fichiers pour un `scope` de requête : +// la discussion active par défaut, la racine du dossier de travail pour +// `scope=legacy` — celle où dorment les fichiers d'avant le rangement par +// discussion, que la migration n'a pas su rattacher. +func filesRoot(scope string) (root string, legacy bool) { + if strings.TrimSpace(scope) == "legacy" { + return agentWorkspace(), true + } + return convWorkspace(), false +} + +// legacyCount compte ce qui traîne à la racine du dossier de travail, hors +// dossier des discussions. L'UI n'affiche le raccourci « hors discussion » que +// si ce compte est non nul — il tombe à zéro une fois le ménage fait, et le +// bouton disparaît de lui-même. +func legacyCount() int { + ents, err := os.ReadDir(agentWorkspace()) + if err != nil { + return 0 + } + n := 0 + for _, e := range ents { + if e.Name() == convFilesRoot || strings.HasPrefix(e.Name(), ".") { + continue + } + n++ + } + return n +} + +var convFilesMigrateOnce sync.Once + +// migrateConvFiles range les fichiers des versions précédentes dans le dossier +// de leur discussion. Une seule fois par process, au démarrage du serveur web. +func migrateConvFiles() { convFilesMigrateOnce.Do(migrateConvFilesNow) } + +func migrateConvFilesNow() { + root := agentWorkspace() + if root == "" { + return + } + // 1. Les captures étaient DÉJÀ par discussion : captures/ → discussions//captures. + // Un simple renommage, donc instantané quel que soit le nombre d'images. + oldCaps := filepath.Join(root, captureDir) + if ents, err := os.ReadDir(oldCaps); err == nil { + for _, e := range ents { + id := safeConvID(e.Name()) + if !e.IsDir() || id == "" { + continue + } + dst := filepath.Join(convDirFor(id), captureDir) + if _, err := os.Stat(dst); err == nil { + continue // déjà rangé (migration interrompue puis reprise) + } + if os.MkdirAll(filepath.Dir(dst), 0o755) != nil { + continue + } + _ = os.Rename(filepath.Join(oldCaps, e.Name()), dst) + } + _ = os.Remove(oldCaps) // ne part que s'il est vide + } + // 2. Les dépôts, eux, étaient dans un pot commun. On rend chaque fichier à la + // discussion qui le mentionne — l'historique garde le nom des pièces + // jointes de chaque tour. Ce qui n'est réclamé par personne reste à la + // racine : toujours téléchargeable (legacyWorkspaceFile) et visible via + // « hors discussion », plutôt que rattaché au hasard. + migrateLegacyUploads(filepath.Join(root, uploadsSub)) +} + +func migrateLegacyUploads(src string) { + ents, err := os.ReadDir(src) + if err != nil { + return + } + left := map[string]bool{} + for _, e := range ents { + if !e.IsDir() { + left[e.Name()] = true + } + } + for _, m := range convIndex() { + if len(left) == 0 { + break + } + for _, name := range convAttachNames(m.ID) { + if !left[name] { + continue + } + dst := filepath.Join(convDirFor(m.ID), uploadsSub) + if os.MkdirAll(dst, 0o755) != nil { + continue + } + if os.Rename(filepath.Join(src, name), filepath.Join(dst, name)) == nil { + delete(left, name) + } + } + } + _ = os.Remove(src) // ne part que s'il est vide +} + +// convAttachNames relit les pièces jointes annoncées dans le journal d'affichage +// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui +// relie un fichier déposé à sa discussion. +func convAttachNames(id string) []string { + b := getBytes(bkChat, convKey(id)) + if len(b) == 0 { + return nil + } + var st struct { + Log []struct { + Delta struct { + Files []attachInfo `json:"files"` + } `json:"delta"` + } `json:"log"` + } + if json.Unmarshal(b, &st) != nil { + return nil + } + var out []string + for _, ev := range st.Log { + for _, f := range ev.Delta.Files { + if n := safeUploadName(f.Name); n != "" { + out = append(out, n) + } + } + } + return out +} diff --git a/internal/loki/chat_convfiles_test.go b/internal/loki/chat_convfiles_test.go new file mode 100644 index 0000000..27d1730 --- /dev/null +++ b/internal/loki/chat_convfiles_test.go @@ -0,0 +1,243 @@ +package loki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// Le cœur de la demande : deux discussions ne partagent pas leurs fichiers. +// Avant, uploads/ était commun — on retrouvait les mêmes pièces jointes partout. +func TestFichiersIsolesParDiscussion(t *testing.T) { + withWorkspace(t) + + a := convEnsureActive() + dirA, err := uploadsDir() + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(dirA, "note-a.txt"), "aaa") + + b := convNew() // b devient active + if a == b { + t.Fatal("convNew n'a pas créé de nouvelle discussion") + } + dirB, err := uploadsDir() + if err != nil { + t.Fatal(err) + } + if dirA == dirB { + t.Fatalf("les deux discussions déposent au même endroit : %s", dirA) + } + writeFile(t, filepath.Join(dirB, "note-b.txt"), "bbb") + + // La discussion ouverte ne voit QUE ses propres dépôts. + names := listedNames(t, "uploads") + if names["note-a.txt"] || !names["note-b.txt"] { + t.Fatalf("la liste de la discussion b montre %v", names) + } + // Et le message ne peut pas joindre le fichier de l'autre discussion. + if got := attachFiles([]string{"uploads/note-a.txt"}); len(got) != 0 { + t.Fatalf("pièce jointe d'une autre discussion retenue : %+v", got) + } + if got := attachFiles([]string{"uploads/note-b.txt"}); len(got) != 1 { + t.Fatalf("pièce jointe de la discussion courante refusée : %+v", got) + } + + // Retour sur a : ses fichiers sont là, ceux de b ont disparu de la vue. + if err := convSwitch(a); err != nil { + t.Fatal(err) + } + names = listedNames(t, "uploads") + if !names["note-a.txt"] || names["note-b.txt"] { + t.Fatalf("la liste de la discussion a montre %v", names) + } +} + +// Supprimer une discussion emporte TOUS ses fichiers, pas seulement ses +// captures — et surtout pas ceux des autres. +func TestConvDeleteSupprimeLesFichiers(t *testing.T) { + ws := withWorkspace(t) + + a := convEnsureActive() + writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf") + writeFile(t, filepath.Join(ws, "rapport.md"), "rapport") + + b := convNew() + autre := convWorkspace() + writeFile(t, filepath.Join(autre, "garde-moi.txt"), "ok") + + if err := convDelete(a); err != nil { + t.Fatalf("suppression : %v", err) + } + if _, err := os.Stat(ws); !os.IsNotExist(err) { + t.Fatalf("le dossier de la discussion supprimée est toujours là (%v)", err) + } + if _, err := os.Stat(filepath.Join(autre, "garde-moi.txt")); err != nil { + t.Fatalf("les fichiers d'une AUTRE discussion ont été supprimés : %v", err) + } + if convEnsureActive() != b { + t.Fatalf("discussion active = %q, attendu %q", convEnsureActive(), b) + } +} + +// Vider une discussion (« clear chat ») efface aussi ses fichiers : les messages +// qui les mentionnaient partent avec. +func TestResetSupprimeLesFichiersDeLaDiscussion(t *testing.T) { + ws := withWorkspace(t) + writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf") + + conv.Reset() + + if _, err := os.Stat(filepath.Join(ws, "uploads", "joint.pdf")); !os.IsNotExist(err) { + t.Fatalf("le fichier de la discussion vidée a survécu (%v)", err) + } +} + +// Un identifiant de discussion douteux ne doit JAMAIS devenir un segment de +// chemin : dropConvFiles effacerait alors la racine du dossier de travail. +func TestDropConvFilesRefuseUnIdDouteux(t *testing.T) { + ws := withWorkspace(t) + writeFile(t, filepath.Join(ws, "a.txt"), "a") + racine := agentWorkspace() + + for _, bad := range []string{"", ".", "..", "../..", "c1/../..", "/etc", `..\..`} { + dropConvFiles(bad) + if _, err := os.Stat(racine); err != nil { + t.Fatalf("id %q a détruit le dossier de travail : %v", bad, err) + } + } + if _, err := os.Stat(filepath.Join(ws, "a.txt")); err != nil { + t.Fatalf("fichier de la discussion détruit : %v", err) + } +} + +// Les liens des anciens messages doivent continuer d'ouvrir leur fichier : ils +// pointent vers uploads/ et captures// à la RACINE du dossier de travail. +func TestTelechargementDesCheminsDAvant(t *testing.T) { + withWorkspace(t) + racine := agentWorkspace() + id := convEnsureActive() + + writeFile(t, filepath.Join(racine, "uploads", "ancien.txt"), "avant") + // Capture d'avant, déplacée par la migration là où elle est désormais rangée. + writeFile(t, filepath.Join(convDirFor(id), captureDir, "vue.jpg"), "jpeg") + + for path, want := range map[string]string{ + "uploads/ancien.txt": "avant", + captureDir + "/" + id + "/vue.jpg": "jpeg", + captureDir + "/vue.jpg": "jpeg", // le chemin d'aujourd'hui + } { + rec := httptest.NewRecorder() + handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(path), nil)) + if rec.Code != 200 || rec.Body.String() != want { + t.Errorf("path=%q : code %d, corps %q", path, rec.Code, rec.Body.String()) + } + } +} + +// La migration range les fichiers des versions précédentes : les captures par +// leur identifiant, les dépôts par la discussion qui les mentionne. Ce que +// personne ne réclame reste à la racine, atteignable par « hors discussion ». +func TestMigrationRangeLesFichiersDAvant(t *testing.T) { + withWorkspace(t) + racine := agentWorkspace() + + a := convEnsureActive() + // Un tour avec pièce jointe : c'est cette trace, dans le journal de la + // discussion, qui rattache le fichier à elle. + state, _ := json.Marshal(map[string]any{ + "log": []map[string]any{{"seq": 1, "delta": map[string]any{ + "user": "regarde", + "files": []attachInfo{{Name: "joint.pdf", Path: "uploads/joint.pdf", Size: 3}}, + }}}, + }) + if err := putBytes(bkChat, convKey(a), state); err != nil { + t.Fatal(err) + } + + writeFile(t, filepath.Join(racine, "uploads", "joint.pdf"), "pdf") + writeFile(t, filepath.Join(racine, "uploads", "orphelin.bin"), "??") + writeFile(t, filepath.Join(racine, captureDir, a, "vue.jpg"), "jpeg") + + // La migration ne joue qu'une fois par process : on rearme le verrou pour + // pouvoir l'observer ici. + convFilesMigrateOnce = sync.Once{} + migrateConvFiles() + + if _, err := os.Stat(filepath.Join(convDirFor(a), uploadsSub, "joint.pdf")); err != nil { + t.Errorf("le dépôt n'a pas rejoint sa discussion : %v", err) + } + if _, err := os.Stat(filepath.Join(convDirFor(a), captureDir, "vue.jpg")); err != nil { + t.Errorf("la capture n'a pas rejoint sa discussion : %v", err) + } + // Le fichier que personne ne réclame reste lisible plutôt que rattaché au hasard. + if _, err := os.Stat(filepath.Join(racine, uploadsSub, "orphelin.bin")); err != nil { + t.Errorf("le dépôt orphelin a été perdu : %v", err) + } + if legacyCount() == 0 { + t.Error("le reliquat hors discussion n'est pas signalé à l'UI") + } +} + +// La vue « hors discussion » ne doit pas devenir une porte dérobée vers les +// fichiers des autres discussions. +func TestScopeLegacyNeDonnePasLesDiscussions(t *testing.T) { + ws := withWorkspace(t) + racine := agentWorkspace() + writeFile(t, filepath.Join(ws, "secret.txt"), "x") + writeFile(t, filepath.Join(racine, "ancien.txt"), "y") + + out := listScope(t, "", "legacy") + if out["ok"] != true { + t.Fatalf("liste refusée : %v", out) + } + for _, e := range out["entries"].([]any) { + if e.(map[string]any)["name"] == convFilesRoot { + t.Fatalf("le dossier des discussions est listé : %v", out) + } + } + // Et il ne se supprime pas d'un clic : ce serait toutes les discussions. + rec := httptest.NewRecorder() + handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete", + strings.NewReader(`{"path":`+jsonQuote(convFilesRoot)+`,"scope":"legacy"}`))) + if rec.Code == http.StatusOK { + t.Errorf("suppression du dossier des discussions acceptée : %s", rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(ws, "secret.txt")); err != nil { + t.Fatalf("fichier d'une discussion supprimé : %v", err) + } +} + +// listedNames renvoie les noms listés dans un dossier du panneau. +func listedNames(t *testing.T, dir string) map[string]bool { + t.Helper() + out := listFiles(t, dir) + names := map[string]bool{} + if out["ok"] != true { + return names + } + for _, e := range out["entries"].([]any) { + names[e.(map[string]any)["name"].(string)] = true + } + return names +} + +func listScope(t *testing.T, dir, scope string) map[string]any { + t.Helper() + rec := httptest.NewRecorder() + handleChatFiles(rec, httptest.NewRequest("GET", + "/api/chat/files?dir="+url.QueryEscape(dir)+"&scope="+url.QueryEscape(scope), 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 +} diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index e2dc200..5aedc7f 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -30,28 +30,24 @@ import ( "time" ) -// captureDir : sous-dossier du workspace où atterrissent les captures. Elles -// sont rangées PAR DISCUSSION (captures//…) pour que supprimer une -// discussion supprime aussi ses images — sinon elles s'accumulaient sur le -// disque sans qu'aucun écran ne les mentionne plus. +// captureDir : sous-dossier où atterrissent les captures, À L'INTÉRIEUR du +// dossier de la discussion (discussions//captures/…) — comme les dépôts. +// Supprimer une discussion emporte donc ses images, qui sinon s'accumulaient +// sur le disque sans qu'aucun écran ne les mentionne plus. const captureDir = "captures" // captureDirFor renvoie le dossier de captures d'une discussion (chemin absolu) -// et son préfixe relatif, celui qui sert dans les URLs d'affichage. +// et son préfixe relatif, celui qui sert dans les URLs d'affichage. Le relatif +// part du dossier de la DISCUSSION : /api/chat/image résout d'abord là +// (workspaceFile), et les liens des anciens messages — en captures//… — +// continuent d'ouvrir leur image par le chemin de repli. func captureDirFor(convID string) (abs, rel string) { - rel = captureDir + "/" + convID - return filepath.Join(agentWorkspace(), captureDir, convID), rel + return filepath.Join(convDirFor(convID), captureDir), captureDir } -// dropConvCaptures supprime les captures d'une discussion. Appelé quand on la -// supprime ou qu'on la vide. Best-effort : un échec ne doit rien interrompre. -func dropConvCaptures(convID string) { - if convID == "" { - return - } - abs, _ := captureDirFor(convID) - _ = os.RemoveAll(abs) -} +// Supprimer les captures d'une discussion n'a plus de fonction à soi : elles +// vivent dans son dossier, et dropConvFiles (chat_convfiles.go) l'emporte en +// entier — dépôts et fichiers écrits par l'agent compris. // screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a // son propre délai interne, celui-ci est le garde-fou externe. @@ -188,7 +184,10 @@ func screenshotImageMessage(relPath string) (Message, bool) { if !visionEnabled() || !engineSeesImages() { return Message{}, false } - abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath)) + abs, ok := workspaceFile(relPath) + if !ok { + return Message{}, false + } mime := imageMime(abs) if mime == "" { return Message{}, false diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index 1d74754..649f14b 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -11,6 +11,10 @@ package loki // bkChat/active → identifiant de la discussion ouverte // bkChat/conv: → l'état complet d'une discussion (JSON de Conversation) // +// Les FICHIERS suivent le même découpage, sur le disque cette fois : +// /discussions// (chat_convfiles.go). Supprimer une discussion +// supprime donc aussi ses fichiers. +// // Basculer = enregistrer la discussion courante, charger l'autre en mémoire et // incrémenter l'epoch : les abonnés SSE reçoivent alors {reset:true} et // rejouent le nouveau fil depuis zéro. C'est le mécanisme déjà utilisé par @@ -228,9 +232,10 @@ func convDelete(id string) error { } convIndexSave(next) _ = putBytes(bkChat, convKey(id), nil) - // Les captures de cette discussion n'ont plus rien qui les référence : les - // garder occuperait le disque pour toujours. - dropConvCaptures(id) + // Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a + // écrit — n'ont plus rien qui les référence : les garder occuperait le disque + // pour toujours, et plus aucun écran ne permettrait de les retrouver. + dropConvFiles(id) if id != getStr(bkChat, ckActive) { return nil } diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index c300d22..b309016 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -149,7 +149,7 @@ func machineSystemPrompt(caps Caps) string { if u, err := user.Current(); err == nil { who = u.Username } - cwd := agentWorkspace() + cwd := convWorkspace() var b strings.Builder b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH)) @@ -185,16 +185,17 @@ func runShell(parent context.Context, command string, timeoutSec int) string { ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second) defer cancel() cmd := newShellCmd(ctx, command) - // Le shell démarre dans le workspace, pas dans le dossier d'où loki a été - // lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau. + // Le shell démarre dans le dossier de la discussion ouverte, pas dans le + // dossier d'où loki a été lancé : un `> notes.txt` du modèle ne doit pas + // atterrir sur le Bureau, ni dans une AUTRE discussion. // - // Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il - // disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même le + // La racine est résolue UNE fois par process (agentWorkspace), donc si elle + // disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même la // supprime — toutes les commandes suivantes échouaient sur un « chdir : no // such file or directory » incompréhensible, et ce jusqu'au redémarrage. On // le recrée au besoin, et à défaut on démarre là où on peut plutôt que de // tout refuser. - if ws := agentWorkspace(); ws != "" { + if ws := convWorkspace(); ws != "" { if err := os.MkdirAll(ws, 0o755); err == nil { cmd.Dir = ws } diff --git a/internal/loki/chat_workspace.go b/internal/loki/chat_workspace.go index faae313..fbf75c8 100644 --- a/internal/loki/chat_workspace.go +++ b/internal/loki/chat_workspace.go @@ -17,6 +17,11 @@ import ( // chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS // restent honorés tels quels — quand l'utilisateur demande d'écrire dans un // dossier précis, ça doit marcher. +// +// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif +// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour +// qu'un fichier appartienne à la discussion où il est né et disparaisse avec +// elle. La racine reste la borne de sécurité des téléchargements. const workspaceEnv = "LOKI_WORKSPACE" @@ -69,7 +74,8 @@ func workspaceCandidates() []string { } // resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ; -// "~/x" → dans le home de l'utilisateur ; relatif → dans le workspace. +// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la +// discussion ouverte. func resolveAgentPath(p string) string { p = strings.TrimSpace(p) if p == "" { @@ -83,5 +89,5 @@ func resolveAgentPath(p string) string { if filepath.IsAbs(p) { return p } - return filepath.Join(agentWorkspace(), filepath.FromSlash(p)) + return filepath.Join(convWorkspace(), filepath.FromSlash(p)) } diff --git a/internal/loki/chat_workspace_test.go b/internal/loki/chat_workspace_test.go index acb1a52..19df57d 100644 --- a/internal/loki/chat_workspace_test.go +++ b/internal/loki/chat_workspace_test.go @@ -6,14 +6,19 @@ import ( "testing" ) -// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le -// répertoire courant du processus (Bureau, C:\ProgramData\loki\bin…). +// Un chemin relatif du modèle doit atterrir dans le dossier de la DISCUSSION +// ouverte, jamais dans le répertoire courant du processus (Bureau, +// C:\ProgramData\loki\bin…) ni dans un dossier commun à toutes les discussions. func TestResolveAgentPathRelative(t *testing.T) { + ws := withWorkspace(t) got := resolveAgentPath("meteo.json") - want := filepath.Join(agentWorkspace(), "meteo.json") + want := filepath.Join(ws, "meteo.json") if got != want { t.Fatalf("resolveAgentPath = %q, attendu %q", got, want) } + if !strings.Contains(got, convFilesRoot) { + t.Fatalf("le fichier n'est pas rangé par discussion : %q", got) + } } // Un chemin absolu demandé explicitement reste intouché. @@ -25,9 +30,10 @@ func TestResolveAgentPathAbsolute(t *testing.T) { } func TestResolveAgentPathSubdir(t *testing.T) { + ws := withWorkspace(t) got := resolveAgentPath("notes/2026/a.txt") - if !strings.HasPrefix(got, agentWorkspace()) { - t.Fatalf("%q hors du workspace %q", got, agentWorkspace()) + if !strings.HasPrefix(got, ws) { + t.Fatalf("%q hors du dossier de la discussion %q", got, ws) } if strings.Contains(got, "/") && filepath.Separator != '/' { t.Fatalf("séparateurs non normalisés : %q", got) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index e11a22f..b2603a2 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1435,7 +1435,14 @@ html[data-files="1"] #files{display:flex} .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)} +.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border); + display:flex;align-items:center;gap:6px;flex-wrap:wrap} +/* Détour « hors discussion » : discret, en pied de panneau — c'est une sortie de + secours pour les fichiers d'avant le rangement par discussion, pas une vue + qu'on propose au même rang que la discussion ouverte. */ +.files-scope{margin:0;padding:2px 6px;min-height:0;font-family:var(--mono);font-size:10px; + background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--dim)} +.files-scope:hover{color:var(--text);background:var(--row-bg-on)} /* 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 @@ -1799,7 +1806,7 @@ html[data-files="1"] #files-btn{color:var(--accent)} - @@ -1822,20 +1829,21 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Entrée pour envoyer · Maj+Entrée = nouvelle ligne
- +
-