mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge pull request #6 from R0m1k3/claude/discussion-file-linking-nzgd0d
Un dossier de fichiers par discussion
This commit is contained in:
21 files changed
+914
-168
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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
@@ -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,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) {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user