Merge pull request #6 from R0m1k3/claude/discussion-file-linking-nzgd0d

Un dossier de fichiers par discussion
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-16 17:48:19 +02:00
commit b9d2e88e64
21 files changed
+914 -168

No files matched your search

+18 -12
View File
@@ -22,7 +22,7 @@ seule image.
│ ▲ modèles .gguf │ │ ▲ modèles .gguf │
│ chromium (Playwright) — captures de pages web │ │ chromium (Playwright) — captures de pages web │
│ /data (config, bbolt, presets, mémoire, │ │ /data (config, bbolt, presets, mémoire, │
│ workspace + captures, modèles installés) │ │ workspace par discussion, modèles) │
│ /models (GGUF déposés à la main) │ │ /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/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
| `/data/models/` | modèles téléchargés depuis l'interface | | `/data/models/` | modèles téléchargés depuis l'interface |
| `/data/memory/` | pages de mémoire persistante (`.md`) | | `/data/memory/` | pages de mémoire persistante (`.md`) |
| `/data/workspace/` | dossier de travail de l'agent | | `/data/workspace/` | racine du dossier de travail de l'agent |
| `/data/workspace/captures/<id>/` | captures d'écran, un dossier par discussion | | `/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`) | | `/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`) | | `/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 : Ajoutées par ce fork :
- **Discussions multiples** : historique complet dans la barre latérale, titre - **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. Supprimer une discussion repris du premier message (renommable), suppression. **Chaque discussion a son
efface aussi ses captures d'écran (`workspace/captures/<id>/`), pour que le dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
disque ne se remplisse pas en silence. 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 - **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)). du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot` - **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 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 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. é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 - **Panneau Fichiers** (bouton dossier du pied de carte) : les fichiers de la
écrit dans son dossier de travail, avec navigation dans les sous-dossiers, discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
téléchargement et suppression. Un dossier affiche la taille de **tout** son navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
contenu — c'est ce qu'on libère en le supprimant. Le pied donne l'occupation affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
disque totale de l'agent. Les chemins sont bornés au dossier de travail, liens supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
symboliques résolus des deux côtés : rien du reste du disque n'est atteignable. 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 - **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
dans le fil. dans le fil.
- **Paramètres** : les réglages d'application (identité, apparence, accès - **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. // le bump d'epoch réduit de toute façon au silence.
func (c *Conversation) Reset() { func (c *Conversation) Reset() {
c.Stop() c.Stop()
// Vider la discussion efface aussi ses captures : les messages qui les // Vider la discussion efface aussi ses fichiers (dépôts, captures, ce que
// affichaient disparaissent, les fichiers n'auraient plus aucun lecteur. // l'agent y a écrit) : les messages qui les mentionnaient disparaissent, plus
dropConvCaptures(convEnsureActive()) // rien ne les rattacherait à quoi que ce soit. L'UI le dit avant de demander
// confirmation.
dropConvFiles(convEnsureActive())
c.mu.Lock() c.mu.Lock()
c.Messages = nil c.Messages = nil
c.Log = 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" "time"
) )
// captureDir : sous-dossier du workspace où atterrissent les captures. Elles // captureDir : sous-dossier où atterrissent les captures, À L'INTÉRIEUR du
// sont rangées PAR DISCUSSION (captures/<id>/…) pour que supprimer une // dossier de la discussion (discussions/<id>/captures/…) — comme les dépôts.
// discussion supprime aussi ses images — sinon elles s'accumulaient sur le // Supprimer une discussion emporte donc ses images, qui sinon s'accumulaient
// disque sans qu'aucun écran ne les mentionne plus. // sur le disque sans qu'aucun écran ne les mentionne plus.
const captureDir = "captures" const captureDir = "captures"
// captureDirFor renvoie le dossier de captures d'une discussion (chemin absolu) // 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) { func captureDirFor(convID string) (abs, rel string) {
rel = captureDir + "/" + convID return filepath.Join(convDirFor(convID), captureDir), captureDir
return filepath.Join(agentWorkspace(), captureDir, convID), rel
} }
// dropConvCaptures supprime les captures d'une discussion. Appelé quand on la // Supprimer les captures d'une discussion n'a plus de fonction à soi : elles
// supprime ou qu'on la vide. Best-effort : un échec ne doit rien interrompre. // vivent dans son dossier, et dropConvFiles (chat_convfiles.go) l'emporte en
func dropConvCaptures(convID string) { // entier — dépôts et fichiers écrits par l'agent compris.
if convID == "" {
return
}
abs, _ := captureDirFor(convID)
_ = os.RemoveAll(abs)
}
// screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a // screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a
// son propre délai interne, celui-ci est le garde-fou externe. // 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() { if !visionEnabled() || !engineSeesImages() {
return Message{}, false return Message{}, false
} }
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath)) abs, ok := workspaceFile(relPath)
if !ok {
return Message{}, false
}
mime := imageMime(abs) mime := imageMime(abs)
if mime == "" { if mime == "" {
return Message{}, false return Message{}, false
+8 -3
View File
@@ -11,6 +11,10 @@ package loki
// bkChat/active → identifiant de la discussion ouverte // bkChat/active → identifiant de la discussion ouverte
// bkChat/conv:<id> → l'état complet d'une discussion (JSON de Conversation) // 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 // 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 // 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 // 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) convIndexSave(next)
_ = putBytes(bkChat, convKey(id), nil) _ = putBytes(bkChat, convKey(id), nil)
// Les captures de cette discussion n'ont plus rien qui les référence : les // Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
// garder occuperait le disque pour toujours. // écrit — n'ont plus rien qui les référence : les garder occuperait le disque
dropConvCaptures(id) // pour toujours, et plus aucun écran ne permettrait de les retrouver.
dropConvFiles(id)
if id != getStr(bkChat, ckActive) { if id != getStr(bkChat, ckActive) {
return nil return nil
} }
+7 -6
View File
@@ -149,7 +149,7 @@ func machineSystemPrompt(caps Caps) string {
if u, err := user.Current(); err == nil { if u, err := user.Current(); err == nil {
who = u.Username who = u.Username
} }
cwd := agentWorkspace() cwd := convWorkspace()
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH)) 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) ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel() defer cancel()
cmd := newShellCmd(ctx, command) cmd := newShellCmd(ctx, command)
// Le shell démarre dans le workspace, pas dans le dossier d'où loki a été // Le shell démarre dans le dossier de la discussion ouverte, pas dans le
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau. // 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 // 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 le // 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 // supprime — toutes les commandes suivantes échouaient sur un « chdir : no
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On // 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 // le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
// tout refuser. // tout refuser.
if ws := agentWorkspace(); ws != "" { if ws := convWorkspace(); ws != "" {
if err := os.MkdirAll(ws, 0o755); err == nil { if err := os.MkdirAll(ws, 0o755); err == nil {
cmd.Dir = ws 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 // 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 // restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
// dossier précis, ça doit marcher. // 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" const workspaceEnv = "LOKI_WORKSPACE"
@@ -69,7 +74,8 @@ func workspaceCandidates() []string {
} }
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ; // 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 { func resolveAgentPath(p string) string {
p = strings.TrimSpace(p) p = strings.TrimSpace(p)
if p == "" { if p == "" {
@@ -83,5 +89,5 @@ func resolveAgentPath(p string) string {
if filepath.IsAbs(p) { if filepath.IsAbs(p) {
return 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" "testing"
) )
// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le // Un chemin relatif du modèle doit atterrir dans le dossier de la DISCUSSION
// répertoire courant du processus (Bureau, C:\ProgramData\loki\bin…). // 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) { func TestResolveAgentPathRelative(t *testing.T) {
ws := withWorkspace(t)
got := resolveAgentPath("meteo.json") got := resolveAgentPath("meteo.json")
want := filepath.Join(agentWorkspace(), "meteo.json") want := filepath.Join(ws, "meteo.json")
if got != want { if got != want {
t.Fatalf("resolveAgentPath = %q, attendu %q", 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é. // Un chemin absolu demandé explicitement reste intouché.
@@ -25,9 +30,10 @@ func TestResolveAgentPathAbsolute(t *testing.T) {
} }
func TestResolveAgentPathSubdir(t *testing.T) { func TestResolveAgentPathSubdir(t *testing.T) {
ws := withWorkspace(t)
got := resolveAgentPath("notes/2026/a.txt") got := resolveAgentPath("notes/2026/a.txt")
if !strings.HasPrefix(got, agentWorkspace()) { if !strings.HasPrefix(got, ws) {
t.Fatalf("%q hors du workspace %q", got, agentWorkspace()) t.Fatalf("%q hors du dossier de la discussion %q", got, ws)
} }
if strings.Contains(got, "/") && filepath.Separator != '/' { if strings.Contains(got, "/") && filepath.Separator != '/' {
t.Fatalf("séparateurs non normalisés : %q", got) 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; .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} 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-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. */ /* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
.files-row.busy{opacity:.55;pointer-events:none} .files-row.busy{opacity:.55;pointer-events:none}
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand /* 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 <!-- 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, gabarit que le trombone — ce sont les deux faces du même sujet,
ce qui entre et ce qui sort. --> 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> <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> </button>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)"> <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 id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div> </div>
</div> </div>
<!-- Fichiers : ce que l'agent a écrit dans son dossier de travail. Panneau de <!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
DROITE, symétrique des réglages à gauche, ouvert et fermé par le bouton déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
dossier du pied de carte. Il ne se remplit qu'à l'ouverture (loadFiles) — des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
un ReadDir à chaque chargement de page pour un panneau fermé serait payé carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
par tout le monde et utile à personne. --> chargement de page pour un panneau fermé serait payé par tout le monde et
utile à personne. -->
<div id="files-backdrop" onclick="toggleFiles()"></div> <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"> <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-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button> <button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
</div> </div>
<!-- Fil d'Ariane : le dossier de travail contient des sous-dossiers (captures <!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
par discussion), il faut pouvoir y descendre et en remonter. --> captures), il faut pouvoir y descendre et en remonter. -->
<div id="files-crumb" class="files-crumb"></div> <div id="files-crumb" class="files-crumb"></div>
<div id="files-list" class="files-list"></div> <div id="files-list" class="files-list"></div>
<div id="files-foot" class="files-foot muted"></div> <div id="files-foot" class="files-foot muted"></div>
@@ -4718,9 +4726,18 @@ function addCopyButtons(root){
pre.appendChild(btn); 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. // 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 // 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é // 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. // avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
@@ -4975,8 +4992,9 @@ function handleDelta(d){
return; } return; }
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté // `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 // serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. // a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; } // 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){ if(d.user!==undefined){
newTurn(); newTurn();
let el=PENDING; let el=PENDING;
@@ -5948,12 +5966,18 @@ async function attachPaths(){
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path); return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
} }
renderAttach(); renderAttach();
// ─── Fichiers du dossier de travail ────────────────────────────────────────── // ─── Fichiers de la discussion ───────────────────────────────────────────────
// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici // 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 // 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 // lien dans sa réponse — tout le reste restait invisible, et faire le ménage
// demandait un shell. // 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 // 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 // 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). // 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 // Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
// déjà gérer le blob et les cas particuliers du navigateur. // 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; let FILES_OPEN = false;
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde // 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'); const foot = document.getElementById('files-foot');
if(!list) return; if(!list) return;
let r; 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; } catch(_){ filesMsg('lecture impossible'); return; }
if(!r.ok){ if(!r.ok){
// Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre // 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){ if(!r.entries.length){
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'files-empty muted'; 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); list.appendChild(d);
} }
for(const e of r.entries) list.appendChild(fileRow(e)); 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 || ''; 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){ function filesMsg(txt){
@@ -6026,7 +6078,9 @@ function filesMsg(txt){
list.appendChild(d); 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){ function renderCrumb(r){
const c = document.getElementById('files-crumb'); const c = document.getElementById('files-crumb');
c.textContent = ''; c.textContent = '';
@@ -6039,7 +6093,7 @@ function renderCrumb(r){
c.appendChild(b); c.appendChild(b);
}; };
const parts = r.dir ? r.dir.split('/') : []; const parts = r.dir ? r.dir.split('/') : [];
seg('travail', '', parts.length === 0); seg(FILES_SCOPE === 'legacy' ? 'hors discussion' : 'discussion', '', parts.length === 0);
let acc = ''; let acc = '';
parts.forEach((p, i)=>{ parts.forEach((p, i)=>{
acc = acc ? acc + '/' + p : p; acc = acc ? acc + '/' + p : p;
@@ -6105,12 +6159,21 @@ async function removeFile(e){
: '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.'; : '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.', if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
{title:'Supprimer ?', okText:'Supprimer', danger:true})) return; {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; } if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
toast('supprimé — '+fmtSize(r.freed||0)+' libérés'); toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
loadFiles(); 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, // 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 ? ». // c'est « est-ce que ça vient du tour que je viens de lancer ? ».
document.addEventListener('DOMContentLoaded', initFiles); document.addEventListener('DOMContentLoaded', initFiles);
@@ -6210,7 +6273,9 @@ async function convRename(id, current){
} }
async function convDelete(id, title){ 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; {title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
try{ try{
const r = await jpost('/api/conversations/delete', {id}); 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 <!-- 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, gabarit que le trombone — ce sont les deux faces du même sujet,
ce qui entre et ce qui sort. --> 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> <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> </button>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)"> <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 id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div> </div>
</div> </div>
<!-- Fichiers : ce que l'agent a écrit dans son dossier de travail. Panneau de <!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
DROITE, symétrique des réglages à gauche, ouvert et fermé par le bouton déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
dossier du pied de carte. Il ne se remplit qu'à l'ouverture (loadFiles) — des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
un ReadDir à chaque chargement de page pour un panneau fermé serait payé carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
par tout le monde et utile à personne. --> chargement de page pour un panneau fermé serait payé par tout le monde et
utile à personne. -->
<div id="files-backdrop" onclick="toggleFiles()"></div> <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"> <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-refresh" onclick="loadFiles()" title="rafraîchir">↻</button>
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button> <button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer">×</button>
</div> </div>
<!-- Fil d'Ariane : le dossier de travail contient des sous-dossiers (captures <!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
par discussion), il faut pouvoir y descendre et en remonter. --> captures), il faut pouvoir y descendre et en remonter. -->
<div id="files-crumb" class="files-crumb"></div> <div id="files-crumb" class="files-crumb"></div>
<div id="files-list" class="files-list"></div> <div id="files-list" class="files-list"></div>
<div id="files-foot" class="files-foot muted"></div> <div id="files-foot" class="files-foot muted"></div>
+11 -2
View File
@@ -327,9 +327,18 @@ function addCopyButtons(root){
pre.appendChild(btn); 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. // 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 // 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é // 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. // 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; } return; }
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté // `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 // serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. // a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; } // 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){ if(d.user!==undefined){
newTurn(); newTurn();
let el=PENDING; 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 // 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 // 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 // lien dans sa réponse — tout le reste restait invisible, et faire le ménage
// demandait un shell. // 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 // 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 // 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). // pour les titres de discussions (16-conversations.js).
@@ -11,7 +17,8 @@
// Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait // Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
// déjà gérer le blob et les cas particuliers du navigateur. // 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; let FILES_OPEN = false;
// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde // 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'); const foot = document.getElementById('files-foot');
if(!list) return; if(!list) return;
let r; 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; } catch(_){ filesMsg('lecture impossible'); return; }
if(!r.ok){ if(!r.ok){
// Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre // 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){ if(!r.entries.length){
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'files-empty muted'; 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); list.appendChild(d);
} }
for(const e of r.entries) list.appendChild(fileRow(e)); 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 || ''; 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){ function filesMsg(txt){
@@ -76,7 +110,9 @@ function filesMsg(txt){
list.appendChild(d); 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){ function renderCrumb(r){
const c = document.getElementById('files-crumb'); const c = document.getElementById('files-crumb');
c.textContent = ''; c.textContent = '';
@@ -89,7 +125,7 @@ function renderCrumb(r){
c.appendChild(b); c.appendChild(b);
}; };
const parts = r.dir ? r.dir.split('/') : []; const parts = r.dir ? r.dir.split('/') : [];
seg('travail', '', parts.length === 0); seg(FILES_SCOPE === 'legacy' ? 'hors discussion' : 'discussion', '', parts.length === 0);
let acc = ''; let acc = '';
parts.forEach((p, i)=>{ parts.forEach((p, i)=>{
acc = acc ? acc + '/' + p : p; acc = acc ? acc + '/' + p : p;
@@ -155,12 +191,21 @@ async function removeFile(e){
: '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.'; : '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.', if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
{title:'Supprimer ?', okText:'Supprimer', danger:true})) return; {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; } if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
toast('supprimé — '+fmtSize(r.freed||0)+' libérés'); toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
loadFiles(); 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, // 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 ? ». // c'est « est-ce que ça vient du tour que je viens de lancer ? ».
document.addEventListener('DOMContentLoaded', initFiles); document.addEventListener('DOMContentLoaded', initFiles);
+3 -1
View File
@@ -85,7 +85,9 @@ async function convRename(id, current){
} }
async function convDelete(id, title){ 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; {title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
try{ try{
const r = await jpost('/api/conversations/delete', {id}); 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; .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} 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-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. */ /* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
.files-row.busy{opacity:.55;pointer-events:none} .files-row.busy{opacity:.55;pointer-events:none}
/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand /* 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 // Le téléchargement existait déjà (handleChatFile, web_upload.go). Ce fichier
// ajoute les deux verbes qui manquaient : LISTER et SUPPRIMER. // 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 // 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. // de travail ne peut pas servir d'échappatoire vers le reste du disque.
@@ -29,7 +35,7 @@ import (
// qui aide à décider. // qui aide à décider.
type wsEntry struct { type wsEntry struct {
Name string `json:"name"` 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"` Dir bool `json:"dir"`
Size int64 `json:"size"` Size int64 `json:"size"`
Mod int64 `json:"mod"` // date de modification, epoch en secondes 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). // annoncer une taille approchée (Items le dit).
const wsWalkLimit = 20000 const wsWalkLimit = 20000
// wsResolve traduit un chemin RELATIF venu du client en chemin absolu vérifié. // 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 // sous la racine du panneau. La chaîne vide et "." désignent cette racine — cas
// workspaceRel rejette (il renvoie false sur "."), d'où ce sas. // que relWithin rejette (il renvoie false sur "."), d'où ce sas.
func wsResolve(rel string) (string, bool) { func wsResolve(root, rel string) (string, bool) {
rel = strings.TrimSpace(rel) rel = strings.TrimSpace(rel)
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/") rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
if rel == "" || rel == "." { if rel == "" || rel == "." {
return agentWorkspace(), true return root, true
} }
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel)) abs := filepath.Join(root, filepath.FromSlash(rel))
if _, ok := workspaceRel(abs); !ok { if _, ok := relWithin(root, abs); !ok {
return "", false return "", false
} }
return abs, true return abs, true
@@ -102,17 +108,21 @@ func dirSize(root string) (total int64, count int) {
return total, count return total, count
} }
// handleChatFiles liste un dossier du workspace. // handleChatFiles liste un dossier de la discussion active.
// //
// GET /api/chat/files?dir=<relatif> // GET /api/chat/files?dir=<relatif>[&scope=legacy]
// → {ok, dir, parent, entries:[…], total, count} // → {ok, dir, parent, entries:[…], total, count, scope, legacy}
// //
// `total` et `count` portent sur le dossier de travail ENTIER, pas sur le // `total` et `count` portent sur le dossier ENTIER de la discussion, pas sur le
// dossier affiché : c'est l'occupation disque de l'agent, l'information qu'on // dossier affiché : c'est ce que cette discussion occupe sur le disque, donc ce
// veut avoir sous les yeux en faisant le ménage. // 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) { func handleChatFiles(w http.ResponseWriter, r *http.Request) {
rel := r.URL.Query().Get("dir") root, legacy := filesRoot(r.URL.Query().Get("scope"))
abs, ok := wsResolve(rel) abs, ok := wsResolve(root, r.URL.Query().Get("dir"))
if !ok { if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"}) sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return return
@@ -128,10 +138,15 @@ func handleChatFiles(w http.ResponseWriter, r *http.Request) {
return return
} }
cur, _ := workspaceRel(abs) // "" à la racine cur, _ := relWithin(root, abs) // "" à la racine
entries := []wsEntry{} entries := []wsEntry{}
for _, e := range ents { for _, e := range ents {
name := e.Name() 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() info, err := e.Info()
if err != nil { if err != nil {
continue continue
@@ -164,40 +179,51 @@ func handleChatFiles(w http.ResponseWriter, r *http.Request) {
parent = cur[:i] parent = cur[:i]
} }
} }
total, count := dirSize(agentWorkspace()) total, count := dirSize(root)
scope := ""
if legacy {
scope = "legacy"
}
sendJSON(w, 200, map[string]any{ sendJSON(w, 200, map[string]any{
"ok": true, "dir": cur, "parent": parent, "at_root": cur == "", "ok": true, "dir": cur, "parent": parent, "at_root": cur == "",
"entries": entries, "total": total, "count": count, "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} // → {ok, freed}
// //
// La racine du dossier de travail est refusée : « tout supprimer » n'est pas un // La racine est refusée : « tout supprimer » n'est pas un geste qu'on veut à un
// geste qu'on veut à un clic de distance, et l'agent perdrait aussi le dossier // clic de distance, et l'agent perdrait aussi le dossier dans lequel il écrit.
// 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) { func handleChatFileDelete(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Path string `json:"path"` Path string `json:"path"`
Scope string `json:"scope"`
} }
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
if strings.TrimSpace(req.Path) == "" { if strings.TrimSpace(req.Path) == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"}) sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return return
} }
abs, ok := wsResolve(req.Path) root, legacy := filesRoot(req.Scope)
abs, ok := wsResolve(root, req.Path)
if !ok { if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"}) sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return 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"}) sendJSON(w, 403, map[string]any{"ok": false, "error": "le dossier de travail lui-même ne se supprime pas"})
return 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) st, err := os.Lstat(abs)
if err != nil { if err != nil {
sendJSON(w, 404, map[string]any{"ok": false, "error": "introuvable"}) sendJSON(w, 404, map[string]any{"ok": false, "error": "introuvable"})
+10 -4
View File
@@ -10,11 +10,17 @@ import (
"testing" "testing"
) )
// withWorkspace pointe le dossier de travail sur un dossier temporaire. Le // withWorkspace pointe le dossier de travail sur un dossier temporaire et rend
// chemin est mémoïsé par un sync.Once (chat_workspace.go), donc on force la // le dossier de la discussion ACTIVE — celui où atterrissent les fichiers et
// variable directement — c'est le seul moyen d'isoler ces tests. // 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 { func withWorkspace(t *testing.T) string {
t.Helper() t.Helper()
testHome(t)
dir := t.TempDir() dir := t.TempDir()
// EvalSymlinks : sur macOS /var est un lien vers /private/var, et // EvalSymlinks : sur macOS /var est un lien vers /private/var, et
// workspaceRel compare des chemins résolus. Sans ça tout serait « hors du // workspaceRel compare des chemins résolus. Sans ça tout serait « hors du
@@ -29,7 +35,7 @@ func withWorkspace(t *testing.T) string {
prevPath := agentWorkspace() prevPath := agentWorkspace()
workspacePath = dir workspacePath = dir
t.Cleanup(func() { workspacePath = prevPath }) t.Cleanup(func() { workspacePath = prevPath })
return dir return convWorkspace()
} }
func writeFile(t *testing.T, path, body string) { 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 // Un téléchargement de modèle coupé net (crash, restart du service) laisse un
// .part orphelin non reprenable : on nettoie au démarrage. // .part orphelin non reprenable : on nettoie au démarrage.
cleanStalePartFiles() 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 // 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. // en mémoire, aucun .part ne survit utilement à l'arrêt du process.
cleanStaleUploadParts() cleanStaleUploadParts()
@@ -220,8 +223,8 @@ func newWebMux() *http.ServeMux {
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil 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/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/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/files", handleChatFiles) // liste un dossier de la discussion ouverte (panneau Fichiers)
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier du dossier de travail 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/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/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours 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. // web_upload.go — dépôt de fichiers depuis le chat.
// //
// L'utilisateur glisse un fichier dans le composeur ; il est écrit dans // 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 // uploads/ à l'intérieur du dossier de la DISCUSSION ouverte (chat_convfiles.go),
// chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut avec ses // et son chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut
// outils (read, bash, edit) — on ne tente ni extraction ni interprétation ici. // 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 // 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 // qui traverse le tunnel E2E d'app.ajean.link (relay_e2e.go ne dispatche que des
@@ -43,9 +44,11 @@ const (
downloadChunkMax = 8 << 20 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) { func uploadsDir() (string, error) {
dir := filepath.Join(agentWorkspace(), "uploads") dir := filepath.Join(convWorkspace(), uploadsSub)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err 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 // 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 » // à 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. // à 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) { func workspaceRel(abs string) (string, bool) { return relWithin(agentWorkspace(), abs) }
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
}
// e2eInnerHeader marque une requête dispatchée depuis le proxy chiffré // 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 // (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"}) sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return return
} }
// Un chemin ABSOLU fourni par le client ne doit pas être suivi : on le traite // Un chemin ABSOLU fourni par le client ne doit pas être suivi : workspaceFile
// comme relatif au dossier de travail, et le contrôle ci-dessous tranche. // le traite comme relatif au dossier de la discussion et tranche lui-même.
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel)) abs, ok := workspaceFile(rel)
if _, ok := workspaceRel(abs); !ok { if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"}) sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return return
} }
@@ -356,8 +344,8 @@ func handleChatImage(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"}) sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
return return
} }
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel)) abs, ok := workspaceFile(rel)
if _, ok := workspaceRel(abs); !ok { if !ok {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"}) sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return 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 // 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 // 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. // 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() { func cleanStaleUploadParts() {
dir, err := uploadsDir() dirs := []string{filepath.Join(agentWorkspace(), uploadsSub)} // dépôts d'avant le rangement par discussion
if err != nil { convs := filepath.Join(agentWorkspace(), convFilesRoot)
return 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) for _, dir := range dirs {
if err != nil { entries, err := os.ReadDir(dir)
return if err != nil {
} continue
for _, e := range entries { }
n := e.Name() for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") { n := e.Name()
os.Remove(filepath.Join(dir, n)) 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) { func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
withWorkspace(t)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"name": "../notes.txt", "name": "../notes.txt",
"data": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("bonjour")), "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 à // Le téléchargement est le point sensible : il lit des fichiers du serveur à
// partir d'un chemin fourni par le client. // partir d'un chemin fourni par le client.
func TestChatFileStaysInsideWorkspace(t *testing.T) { func TestChatFileStaysInsideWorkspace(t *testing.T) {
withWorkspace(t)
dir, err := uploadsDir() dir, err := uploadsDir()
if err != nil { if err != nil {
t.Fatal(err) 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 // 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. // passait dans un seul corps JSON, gardé en mémoire des deux côtés.
func TestChatUploadChunked(t *testing.T) { func TestChatUploadChunked(t *testing.T) {
withWorkspace(t)
part1 := strings.Repeat("A", 5000) part1 := strings.Repeat("A", 5000)
part2 := strings.Repeat("B", 3000) part2 := strings.Repeat("B", 3000)
part3 := "fin" 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 // 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. // qu'on est derrière le tunnel, puis savoir servir des tranches base64.
func TestChatFileB64ForE2E(t *testing.T) { func TestChatFileB64ForE2E(t *testing.T) {
withWorkspace(t)
dir, _ := uploadsDir() dir, _ := uploadsDir()
// Des octets NON-UTF8 : c'est exactement ce que le réemballage JSON massacre. // Des octets NON-UTF8 : c'est exactement ce que le réemballage JSON massacre.
raw := make([]byte, 5000) 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 // 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. // protège que la table, l'écriture se fait sous le verrou de la session.
func TestChatUploadConcurrentSessions(t *testing.T) { func TestChatUploadConcurrentSessions(t *testing.T) {
withWorkspace(t)
const files, chunks = 4, 6 const files, chunks = 4, 6
var wg sync.WaitGroup var wg sync.WaitGroup
paths := make([]string, files) paths := make([]string, files)