diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 82dd3ed..377c5ce 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,39 +1,18 @@ -Les fichiers circulent enfin dans les deux sens : vous pouvez en envoyer à l'IA, et récupérer ceux qu'elle produit. +La limite d'envoi de fichiers passe de 24 Mo à 1 Go. -## Envoyer un fichier +## Des fichiers d'un gigaoctet -Un trombone est apparu dans le bas du composeur. Vous pouvez aussi glisser un fichier n'importe où dans la fenêtre, ou le coller avec Ctrl+V — une capture d'écran passe donc directement dans le chat. +La 0.8.7 plafonnait à 24 Mo, et ce n'était pas un caprice : le fichier partait dans un seul message, encodé en base64, gardé entier en mémoire par le navigateur puis par le serveur. Un gigaoctet ainsi transmis, c'est près d'un gigaoctet et demi de mémoire de chaque côté — de quoi faire tomber la machine. -Le fichier est déposé dans le dossier de travail de l'IA, sous `uploads/`, et son chemin est annoncé dans le message. Elle en fait ensuite ce qu'elle veut avec ses outils : le lire, le convertir, le lancer. Rien n'est interprété au passage, donc n'importe quel type de fichier fonctionne — à condition que le mode agent soit actif, sans quoi elle voit le nom mais n'a aucun outil pour l'ouvrir. +Le fichier part maintenant par tranches de 8 Mo. Le navigateur n'en lit qu'une à la fois, le serveur l'écrit sur le disque et l'oublie aussitôt : plus personne ne détient le fichier entier. Mesuré sur un envoi de 120 Mo, la mémoire du serveur n'a pas bougé d'un mégaoctet. -Deux détails qui comptent à l'usage. Le dépôt n'a lieu qu'à **l'envoi** du message : tant que vous n'avez pas cliqué, rien n'atterrit sur le disque, et retirer un fichier de la liste ne laisse aucune trace. Et joindre un fichier ne déplace pas la zone de saisie d'un pixel — les vignettes se posent au-dessus de la carte, dans l'espace qu'elle réservait déjà. +Au passage, la vignette affiche l'avancement en pourcentage : sur un gros fichier, l'anneau qui tournait sans rien dire n'était pas d'un grand secours. -Le transfert passe par le tunnel chiffré comme le reste : l'envoi de fichiers fonctionne aussi depuis l'accès distant, sans rien configurer. +Un envoi interrompu — onglet fermé, réseau coupé, service redémarré — ne laisse rien derrière lui : le fichier partiel est effacé au bout de dix minutes, et au démarrage du service. -Taille maximale : 24 Mo par fichier. +Ce qui n'a pas changé : le dépôt n'a toujours lieu qu'à l'envoi du message, et l'accès distant fonctionne pareil, les tranches passant par le tunnel chiffré comme le reste. -## Recevoir un fichier - -Quand vous demandiez un fichier à l'IA, elle répondait par un chemin sur le serveur — `/etc/ajean/workspace/rapport.pdf` — parfaitement inutilisable depuis un navigateur. - -Elle écrit maintenant un lien Markdown ordinaire, et cliquer dessus télécharge le fichier : - -> C'est prêt : [le rapport](rapport.pdf) - -Aucune syntaxe particulière à connaître de son côté, et les liens web restent des liens web. Seul le dossier de travail est accessible ; un chemin qui en sort est refusé, et les fichiers sont toujours servis en téléchargement, jamais affichés. - -## Un quart de contexte rendu au mode agent - -Le préambule envoyé au modèle à chaque tour est passé d'environ 2350 à 1770 tokens, sans qu'une seule consigne disparaisse. - -L'essentiel du poids n'était pas dans le texte du prompt mais dans les schémas des outils, qui pèsent le double. Le prompt réénumérait des outils que ces schémas décrivent déjà, et la règle « écris les fichiers avec `write`, jamais avec `echo` » y figurait trois fois. Elle ne vit plus que là où elle s'applique. - -C'est autant de fenêtre rendue à la conversation, et un compactage qui arrive plus tard. - -## Aussi - -- L'export d'une conversation mentionne les fichiers joints. Un message envoyé sans texte, juste avec un fichier, y apparaissait comme une section vide. -- Le décompte de contexte, sous la zone de saisie, ne s'écrit plus « contexte 4210 / 32768 » mais « 4210 / 32768 » — la jauge juste en dessous dit déjà de quoi il s'agit, et la place gagnée sert au nom du modèle. +Pensez tout de même à la place disque de la machine qui héberge AJEAN : les fichiers envoyés s'accumulent dans `uploads/`, à l'intérieur du dossier de travail de l'IA. ## Mise à jour diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index c4cda35..fb8dd1a 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index ea205d7..055a8ea 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 401d93c..1c0848f 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 8, - "Patch": 7, + "Patch": 8, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 8, - "Patch": 7, + "Patch": 8, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.8.7", + "ProductVersion": "0.8.8", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/run.go b/internal/ajean/run.go index ede13eb..81ceeb4 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.8.7" +const Version = "0.8.8" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index bc02ee4..4090656 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -5021,11 +5021,17 @@ async function remotePairCode(){ // Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la // seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant // fonctionne sans code particulier. -const ATTACH_MAX = 24*1024*1024; // doit rester aligné sur uploadMaxBytes (web_upload.go) +// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go). +// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent +// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer +// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté. +const ATTACH_MAX = 1024*1024*1024; +const ATTACH_CHUNK = 8*1024*1024; let ATTACH = []; let ATTACH_SEQ = 0; function fmtSize(n){ + if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go'; if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo'; if(n >= 1024) return Math.round(n/1024)+' Ko'; return n+' o'; @@ -5158,7 +5164,11 @@ function renderAttach(){ el.appendChild(fileChip(a.name, a.size, { cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''), title: a.error || a.name, - sizeText: a.state==='err' ? 'échec' : fmtSize(a.size), + // Un gros fichier prend du temps : on montre l'avancement plutôt qu'un + // anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre. + sizeText: a.state==='err' ? 'échec' + : (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %' + : fmtSize(a.size), onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); } })); } @@ -5177,22 +5187,43 @@ function readAsBase64(file){ fr.readAsDataURL(file); }); } +// Envoie UN morceau. Pas de jfetch : son délai de 30 s est calibré pour de +// petits appels et couperait un transfert. +async function sendChunk(payload){ + const r=await fetch(API_BASE+'/api/chat/upload',{ + method:'POST', headers:authHeaders({'Content-Type':'application/json'}), + body:JSON.stringify(payload) + }); + const j=await r.json().catch(()=>({})); + if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status)); + return j; +} // Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a // pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer // un fichier de la liste ne laisse donc aucune trace sur le disque. +// +// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois +// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe +// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on +// en est, ce qu'un envoi monolithique ne permettait pas. async function uploadAttach(a){ - a.state='up'; renderAttach(); + a.state='up'; a.sent=0; renderAttach(); try{ - const data=await readAsBase64(a.file); - // Pas de jfetch ici : son délai de 30 s est calibré pour de petits appels, - // et couperait le transfert d'un fichier de plusieurs Mo. - const r=await fetch(API_BASE+'/api/chat/upload',{ - method:'POST', headers:authHeaders({'Content-Type':'application/json'}), - body:JSON.stringify({name:a.name, data:data}) - }); - const j=await r.json().catch(()=>({})); - if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status)); - a.path=j.path; a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page + let id=''; + // `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand + // même faire un tour de boucle, sinon rien ne part. + let off=0; + do{ + const end=Math.min(off+ATTACH_CHUNK, a.size); + const data=await readAsBase64(a.file.slice(off, end)); + const last=end>=a.size; + const j=await sendChunk({name:a.name, data:data, id:id, more:!last}); + if(j.id) id=j.id; + off=end; + a.sent=off; renderAttach(); + if(last){ a.path=j.path; } + } while(off= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go'; if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo'; if(n >= 1024) return Math.round(n/1024)+' Ko'; return n+' o'; @@ -145,7 +151,11 @@ function renderAttach(){ el.appendChild(fileChip(a.name, a.size, { cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''), title: a.error || a.name, - sizeText: a.state==='err' ? 'échec' : fmtSize(a.size), + // Un gros fichier prend du temps : on montre l'avancement plutôt qu'un + // anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre. + sizeText: a.state==='err' ? 'échec' + : (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %' + : fmtSize(a.size), onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); } })); } @@ -164,22 +174,43 @@ function readAsBase64(file){ fr.readAsDataURL(file); }); } +// Envoie UN morceau. Pas de jfetch : son délai de 30 s est calibré pour de +// petits appels et couperait un transfert. +async function sendChunk(payload){ + const r=await fetch(API_BASE+'/api/chat/upload',{ + method:'POST', headers:authHeaders({'Content-Type':'application/json'}), + body:JSON.stringify(payload) + }); + const j=await r.json().catch(()=>({})); + if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status)); + return j; +} // Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a // pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer // un fichier de la liste ne laisse donc aucune trace sur le disque. +// +// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois +// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe +// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on +// en est, ce qu'un envoi monolithique ne permettait pas. async function uploadAttach(a){ - a.state='up'; renderAttach(); + a.state='up'; a.sent=0; renderAttach(); try{ - const data=await readAsBase64(a.file); - // Pas de jfetch ici : son délai de 30 s est calibré pour de petits appels, - // et couperait le transfert d'un fichier de plusieurs Mo. - const r=await fetch(API_BASE+'/api/chat/upload',{ - method:'POST', headers:authHeaders({'Content-Type':'application/json'}), - body:JSON.stringify({name:a.name, data:data}) - }); - const j=await r.json().catch(()=>({})); - if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status)); - a.path=j.path; a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page + let id=''; + // `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand + // même faire un tour de boucle, sinon rien ne part. + let off=0; + do{ + const end=Math.min(off+ATTACH_CHUNK, a.size); + const data=await readAsBase64(a.file.slice(off, end)); + const last=end>=a.size; + const j=await sendChunk({name:a.name, data:data, id:id, more:!last}); + if(j.id) id=j.id; + off=end; + a.sent=off; renderAttach(); + if(last){ a.path=j.path; } + } while(off upSessionTTL { + name := s.f.Name() + s.f.Close() + os.Remove(name) + delete(upSessions, id) + } + } +} + +// handleChatUpload reçoit un fichier, en un ou plusieurs morceaux, et renvoie +// son chemin relatif — celui que le client joindra au message (/api/chat/send, +// champ files). func handleChatUpload(w http.ResponseWriter, r *http.Request) { var body uploadReq - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*uploadMaxBytes)).Decode(&body); err != nil { - sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier trop gros ou requête invalide"}) + // Le plafond porte sur UN morceau, pas sur le fichier : c'est la seule borne + // qui compte pour la mémoire du process. + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*uploadChunkMax)).Decode(&body); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": "morceau trop gros ou requête invalide"}) return } data := body.Data @@ -218,29 +273,103 @@ func handleChatUpload(w http.ResponseWriter, r *http.Request) { sendJSON(w, 400, map[string]any{"ok": false, "error": "contenu illisible (base64 attendu)"}) return } - if len(raw) == 0 { + + upMu.Lock() + defer upMu.Unlock() + sweepUploadSessions() + + s := upSessions[body.ID] + if s == nil { + if body.ID != "" { + // L'envoi a expiré ou le serveur a redémarré en cours de route : le dire, + // plutôt que de recommencer un fichier à partir de son milieu. + sendJSON(w, 409, map[string]any{"ok": false, "error": "envoi expiré — recommence le fichier"}) + return + } + dir, err := uploadsDir() + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + f, err := os.CreateTemp(dir, ".upload-*.part") + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + s = &upSession{f: f, name: safeUploadName(body.Name)} + body.ID = filepath.Base(f.Name()) + upSessions[body.ID] = s + } + s.last = time.Now() + + abort := func(code int, msg string) { + name := s.f.Name() + s.f.Close() + os.Remove(name) + delete(upSessions, body.ID) + sendJSON(w, code, map[string]any{"ok": false, "error": msg}) + } + if s.written+int64(len(raw)) > uploadMaxBytes { + abort(413, fmt.Sprintf("fichier trop gros (max %s)", humanBytes(uploadMaxBytes))) + return + } + if len(raw) > 0 { + if _, err := s.f.Write(raw); err != nil { + abort(500, err.Error()) + return + } + s.written += int64(len(raw)) + } + if body.More { + // Morceau intermédiaire : on rend l'ID pour la suite et l'avancement, qui + // alimente la barre de progression côté client. + sendJSON(w, 200, map[string]any{"ok": true, "id": body.ID, "received": s.written}) + return + } + + // Dernier morceau : on ferme et on donne au fichier son vrai nom. + delete(upSessions, body.ID) + part := s.f.Name() + if err := s.f.Close(); err != nil { + os.Remove(part) + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + if s.written == 0 { + os.Remove(part) sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier vide"}) return } - if len(raw) > uploadMaxBytes { - sendJSON(w, 413, map[string]any{"ok": false, "error": fmt.Sprintf("fichier trop gros (max %s)", humanBytes(int64(uploadMaxBytes)))}) - return - } - dir, err := uploadsDir() - if err != nil { + dest := uniqueUploadPath(filepath.Dir(part), s.name) + if err := os.Rename(part, dest); err != nil { + os.Remove(part) sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } - dest := uniqueUploadPath(dir, safeUploadName(body.Name)) - if err := os.WriteFile(dest, raw, 0o644); err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - rel := "uploads/" + filepath.Base(dest) sendJSON(w, 200, map[string]any{ "ok": true, - "path": rel, // ce que le modèle verra (résolu par resolveAgentPath) + "path": "uploads/" + filepath.Base(dest), "abs": dest, // affiché à l'utilisateur, jamais renvoyé au serveur - "size": len(raw), + "size": s.written, }) } + +// 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. +func cleanStaleUploadParts() { + dir, err := uploadsDir() + if err != nil { + return + } + 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)) + } + } +} diff --git a/internal/ajean/web_upload_test.go b/internal/ajean/web_upload_test.go index 0b8dffa..a7fffa1 100644 --- a/internal/ajean/web_upload_test.go +++ b/internal/ajean/web_upload_test.go @@ -162,3 +162,72 @@ func TestExportFileNamesBothForms(t *testing.T) { t.Fatalf("sans pièce jointe : %v", got) } } + +// upload envoie un morceau et renvoie la réponse décodée. +func uploadChunk(t *testing.T, body map[string]any) (int, map[string]any) { + t.Helper() + b, _ := json.Marshal(body) + rec := httptest.NewRecorder() + handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(b)))) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out +} + +// Un fichier arrive en plusieurs morceaux et doit être reconstitué à l'octet +// 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) { + part1 := strings.Repeat("A", 5000) + part2 := strings.Repeat("B", 3000) + part3 := "fin" + + code, resp := uploadChunk(t, map[string]any{ + "name": "gros.bin", "data": base64.StdEncoding.EncodeToString([]byte(part1)), "more": true}) + if code != 200 || resp["id"] == nil { + t.Fatalf("1er morceau : code %d, resp %+v", code, resp) + } + id := resp["id"].(string) + if got := resp["received"].(float64); got != 5000 { + t.Fatalf("avancement après 1er morceau = %v", got) + } + if code, resp = uploadChunk(t, map[string]any{ + "id": id, "data": base64.StdEncoding.EncodeToString([]byte(part2)), "more": true}); code != 200 { + t.Fatalf("2e morceau : code %d, resp %+v", code, resp) + } + code, resp = uploadChunk(t, map[string]any{ + "id": id, "data": base64.StdEncoding.EncodeToString([]byte(part3))}) + if code != 200 || resp["ok"] != true { + t.Fatalf("dernier morceau : code %d, resp %+v", code, resp) + } + abs := resp["abs"].(string) + defer os.Remove(abs) + if resp["path"] != "uploads/gros.bin" { + t.Fatalf("chemin = %v", resp["path"]) + } + got, err := os.ReadFile(abs) + if err != nil { + t.Fatal(err) + } + if string(got) != part1+part2+part3 { + t.Fatalf("reconstitution : %d octets, attendu %d", len(got), len(part1+part2+part3)) + } + // Aucun .part ne doit traîner une fois l'envoi terminé. + dir, _ := uploadsDir() + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".part") { + t.Fatalf("fichier temporaire laissé : %s", e.Name()) + } + } +} + +// Un identifiant inconnu (envoi expiré, serveur redémarré) ne doit pas produire +// un fichier tronqué qui repart de son milieu. +func TestChatUploadUnknownSessionRefused(t *testing.T) { + code, resp := uploadChunk(t, map[string]any{ + "id": "inconnu-1234.part", "data": base64.StdEncoding.EncodeToString([]byte("x"))}) + if code != 409 { + t.Fatalf("code %d, attendu 409 — resp %+v", code, resp) + } +}