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

+18 -12
View File
@@ -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/<id>/` | captures d'écran, un dossier par discussion |
| `/data/workspace/` | racine du dossier de travail de l'agent |
| `/data/workspace/discussions/<id>/` | 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/<id>/`), 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/<id>/`) : 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
+5 -3
View File
@@ -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
+304
View File
@@ -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 :
//
// <workspace>/discussions/<id>/ ← dossier courant de l'agent
// <workspace>/discussions/<id>/uploads/ ← pièces jointes de CETTE discussion
// <workspace>/discussions/<id>/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/<id>/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/<id>/x.jpg → discussions/<id>/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/<id> → discussions/<id>/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
}
+243
View File
@@ -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/<id>/ à 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
}
+16 -17
View File
@@ -30,28 +30,24 @@ import (
"time"
)
// captureDir : sous-dossier du workspace où atterrissent les captures. Elles
// sont rangées PAR DISCUSSION (captures/<id>/…) 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/<id>/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/<id>/… —
// 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
+8 -3
View File
@@ -11,6 +11,10 @@ package loki
// bkChat/active → identifiant de la discussion ouverte
// bkChat/conv:<id> → l'état complet d'une discussion (JSON de Conversation)
//
// Les FICHIERS suivent le même découpage, sur le disque cette fois :
// <workspace>/discussions/<id>/ (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
}
+7 -6
View File
@@ -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
}
+8 -2
View File
@@ -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))
}
+11 -5
View File
@@ -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)
+89 -24
View File
@@ -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)}
<!-- 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">
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" 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)">
@@ -1822,20 +1829,21 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<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. -->
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
déposées, captures, ce que l'agent y a écrit. 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">
<aside id="files" aria-label="Fichiers de cette discussion">
<div class="files-head">
<span class="stitle">Fichiers</span>
<span class="stitle">Fichiers de la discussion</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. -->
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
captures), 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>
@@ -4718,9 +4726,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.
@@ -4975,8 +4992,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;
@@ -5948,12 +5966,18 @@ async function attachPaths(){
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
}
renderAttach();
// ─── 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).
@@ -5961,7 +5985,8 @@ renderAttach();
// 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
@@ -5994,7 +6019,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
@@ -6008,13 +6033,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){
@@ -6026,7 +6078,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 = '';
@@ -6039,7 +6093,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;
@@ -6105,12 +6159,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);
@@ -6210,7 +6273,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});
+11 -10
View File
@@ -364,7 +364,7 @@
<!-- 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">
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" 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)">
@@ -387,20 +387,21 @@
<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. -->
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
déposées, captures, ce que l'agent y a écrit. 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">
<aside id="files" aria-label="Fichiers de cette discussion">
<div class="files-head">
<span class="stitle">Fichiers</span>
<span class="stitle">Fichiers de la discussion</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. -->
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
captures), 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>
+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});
+8 -1
View File
@@ -1409,7 +1409,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
+54 -28
View File
@@ -11,7 +11,13 @@ package loki
// 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
// 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.
@@ -29,7 +35,7 @@ import (
// qui aide à décider.
type wsEntry struct {
Name string `json:"name"`
Path string `json:"path"` // relatif au dossier de travail, séparateurs /
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
@@ -51,17 +57,17 @@ var wsImageExt = map[string]bool{
// 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) {
// 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 agentWorkspace(), true
return root, true
}
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(abs); !ok {
abs := filepath.Join(root, filepath.FromSlash(rel))
if _, ok := relWithin(root, abs); !ok {
return "", false
}
return abs, true
@@ -102,17 +108,21 @@ func dirSize(root string) (total int64, count int) {
return total, count
}
// handleChatFiles liste un dossier du workspace.
// handleChatFiles liste un dossier de la discussion active.
//
// GET /api/chat/files?dir=<relatif>
// → {ok, dir, parent, entries:[…], total, count}
// GET /api/chat/files?dir=<relatif>[&scope=legacy]
// → {ok, dir, parent, entries:[…], total, count, scope, legacy}
//
// `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.
// `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) {
rel := r.URL.Query().Get("dir")
abs, ok := wsResolve(rel)
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
@@ -128,10 +138,15 @@ func handleChatFiles(w http.ResponseWriter, r *http.Request) {
return
}
cur, _ := workspaceRel(abs) // "" à la racine
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
@@ -164,40 +179,51 @@ func handleChatFiles(w http.ResponseWriter, r *http.Request) {
parent = cur[:i]
}
}
total, count := dirSize(agentWorkspace())
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": agentWorkspace(),
"root": root, "scope": scope, "legacy": legacyCount(),
})
}
// handleChatFileDelete supprime un fichier ou un dossier du workspace.
// handleChatFileDelete supprime un fichier ou un dossier de la discussion.
//
// POST /api/chat/file/delete {"path":"rapport.md"}
// POST /api/chat/file/delete {"path":"rapport.md"[,"scope":"legacy"]}
// → {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.
// 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"`
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
}
abs, ok := wsResolve(req.Path)
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 == agentWorkspace() {
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"})
+10 -4
View File
@@ -10,11 +10,17 @@ import (
"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.
// withWorkspace pointe le dossier de travail sur un dossier temporaire et rend
// le dossier de la discussion ACTIVE — celui où atterrissent les fichiers et
// que le panneau ouvre. Le chemin de la racine 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.
//
// testHome donne au passage une base à part : la discussion active s'y lit, et
// aucun test ne doit toucher celle de la machine qui les fait tourner.
func withWorkspace(t *testing.T) string {
t.Helper()
testHome(t)
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
@@ -29,7 +35,7 @@ func withWorkspace(t *testing.T) string {
prevPath := agentWorkspace()
workspacePath = dir
t.Cleanup(func() { workspacePath = prevPath })
return dir
return convWorkspace()
}
func writeFile(t *testing.T, path, body string) {
+5 -2
View File
@@ -93,6 +93,9 @@ func newWebMux() *http.ServeMux {
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
// .part orphelin non reprenable : on nettoie au démarrage.
cleanStalePartFiles()
// Les fichiers des versions précédentes vivaient dans un pot commun : on les
// range dans le dossier de leur discussion (une fois par process).
migrateConvFiles()
// Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent
// en mémoire, aucun .part ne survit utilement à l'arrêt du process.
cleanStaleUploadParts()
@@ -220,8 +223,8 @@ func newWebMux() *http.ServeMux {
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/files", handleChatFiles) // liste un dossier de la discussion ouverte (panneau Fichiers)
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)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
+37 -38
View File
@@ -1,9 +1,10 @@
// web_upload.go — dépôt de fichiers depuis le chat.
//
// L'utilisateur glisse un fichier dans le composeur ; il est écrit dans
// uploads/ à l'intérieur du workspace de l'agent (chat_workspace.go), et son
// chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut avec ses
// outils (read, bash, edit) — on ne tente ni extraction ni interprétation ici.
// uploads/ à l'intérieur du dossier de la DISCUSSION ouverte (chat_convfiles.go),
// et son chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut
// avec ses outils (read, bash, edit) — on ne tente ni extraction ni
// interprétation ici.
//
// Le transport est du JSON base64, et non du multipart : c'est la seule forme
// qui traverse le tunnel E2E d'app.ajean.link (relay_e2e.go ne dispatche que des
@@ -43,9 +44,11 @@ const (
downloadChunkMax = 8 << 20
)
// uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent.
// uploadsDir renvoie (en le créant) le dossier de dépôt de la discussion
// ouverte. Un fichier joint appartient à la discussion où il a été déposé : il
// n'apparaît pas dans les autres et part avec elle.
func uploadsDir() (string, error) {
dir := filepath.Join(agentWorkspace(), "uploads")
dir := filepath.Join(convWorkspace(), uploadsSub)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
@@ -233,22 +236,7 @@ func userMessageContent(files []attachInfo, prompt string) any {
// on lui donne un chemin absolu (voir resolveAgentPath) ; ouvrir le téléchargement
// à ces fichiers-là ferait de /api/chat/file un « lis-moi ce fichier du serveur »
// à usage général — l'API n'a pas forcément de clé et écoute sur 0.0.0.0.
func workspaceRel(abs string) (string, bool) {
root := agentWorkspace()
// EvalSymlinks des deux côtés : sans ça, un lien qui sort du dossier passerait
// le test de préfixe.
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
}
func workspaceRel(abs string) (string, bool) { return relWithin(agentWorkspace(), abs) }
// e2eInnerHeader marque une requête dispatchée depuis le proxy chiffré
// (relay_e2e.go). Cf. handleChatFile : c'est le seul moyen pour un handler de
@@ -275,10 +263,10 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return
}
// Un chemin ABSOLU fourni par le client ne doit pas être suivi : on le traite
// comme relatif au dossier de travail, et le contrôle ci-dessous tranche.
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(abs); !ok {
// Un chemin ABSOLU fourni par le client ne doit pas être suivi : workspaceFile
// le traite comme relatif au dossier de la discussion et tranche lui-même.
abs, ok := workspaceFile(rel)
if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
}
@@ -356,8 +344,8 @@ func handleChatImage(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return
}
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(abs); !ok {
abs, ok := workspaceFile(rel)
if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
}
@@ -595,19 +583,30 @@ func handleChatUpload(w http.ResponseWriter, r *http.Request) {
// cleanStaleUploadParts efface les .part laissés par un envoi que le process n'a
// pas pu terminer (arrêt du service, crash). Appelé au démarrage : les sessions
// vivent en mémoire, aucun de ces fichiers n'est reprenable.
//
// Le balayage couvre TOUTES les discussions, pas seulement l'active : un envoi
// coupé dans une discussion qu'on ne rouvrira jamais laisserait sinon son .part
// occuper le disque pour toujours.
func cleanStaleUploadParts() {
dir, err := uploadsDir()
if err != nil {
return
dirs := []string{filepath.Join(agentWorkspace(), uploadsSub)} // dépôts d'avant le rangement par discussion
convs := filepath.Join(agentWorkspace(), convFilesRoot)
if ents, err := os.ReadDir(convs); err == nil {
for _, e := range ents {
if e.IsDir() {
dirs = append(dirs, filepath.Join(convs, e.Name(), uploadsSub))
}
}
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
n := e.Name()
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") {
os.Remove(filepath.Join(dir, n))
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
n := e.Name()
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") {
os.Remove(filepath.Join(dir, n))
}
}
}
}
+10
View File
@@ -48,6 +48,8 @@ func TestSafeUploadNameKeepsOrdinaryNames(t *testing.T) {
}
func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
withWorkspace(t)
body, _ := json.Marshal(map[string]any{
"name": "../notes.txt",
"data": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("bonjour")),
@@ -101,6 +103,8 @@ func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
// Le téléchargement est le point sensible : il lit des fichiers du serveur à
// partir d'un chemin fourni par le client.
func TestChatFileStaysInsideWorkspace(t *testing.T) {
withWorkspace(t)
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
@@ -181,6 +185,8 @@ func uploadChunk(t *testing.T, body map[string]any) (int, map[string]any) {
// près. C'est ce découpage qui permet le gigaoctet : sans lui, tout le fichier
// passait dans un seul corps JSON, gardé en mémoire des deux côtés.
func TestChatUploadChunked(t *testing.T) {
withWorkspace(t)
part1 := strings.Repeat("A", 5000)
part2 := strings.Repeat("B", 3000)
part3 := "fin"
@@ -239,6 +245,8 @@ func TestChatUploadUnknownSessionRefused(t *testing.T) {
// réponse en JSON, où du binaire ne survit pas. Le handler doit donc annoncer
// qu'on est derrière le tunnel, puis savoir servir des tranches base64.
func TestChatFileB64ForE2E(t *testing.T) {
withWorkspace(t)
dir, _ := uploadsDir()
// Des octets NON-UTF8 : c'est exactement ce que le réemballage JSON massacre.
raw := make([]byte, 5000)
@@ -308,6 +316,8 @@ func TestChatFileB64ForE2E(t *testing.T) {
// jointe). Les sessions doivent donc être indépendantes : le verrou global ne
// protège que la table, l'écriture se fait sous le verrou de la session.
func TestChatUploadConcurrentSessions(t *testing.T) {
withWorkspace(t)
const files, chunks = 4, 6
var wg sync.WaitGroup
paths := make([]string, files)