v0.8.7 : envoyer et recevoir des fichiers dans le chat

Les fichiers circulent enfin dans les deux sens.

Envoi : trombone dans le composeur, glisser-deposer sur toute la fenetre,
ou Ctrl+V. Le fichier est depose dans uploads/ du dossier de travail de
l'agent et son chemin est annonce au modele, qui en fait ce qu'il veut
avec ses outils. Rien n'est interprete au passage, donc tout type de
fichier passe. Le depot n'a lieu qu'a l'ENVOI du message : retirer un
fichier de la liste ne laisse aucune trace sur le disque. Transport en
base64 dans du JSON et non en multipart, seule forme que le tunnel E2E
dispatche : l'acces distant marche sans code specifique.

Reception : le modele ecrit un lien Markdown ordinaire vers un fichier de
son dossier de travail, et le clic telecharge. Il repondait jusqu'ici par
un chemin serveur, inutilisable depuis un navigateur. Le clic passe par
fetch + blob (la cle de pilotage voyage dans un en-tete qu'une navigation
ne porterait pas, et le chemin de base change derriere le tunnel).
Markdown refusant les espaces non echappes dans une cible de lien, ils
sont encodes avant rendu : sans ca [x](mon rapport.pdf) ne produisait
rien du tout.

Perimetre du telechargement strictement limite au dossier de travail :
'..', chemins absolus et liens symboliques qui en sortent sont refuses,
et le fichier est toujours servi en piece jointe avec nosniff — un .html
produit par le modele ne doit pas s'executer dans l'origine de l'UI, ou
il lirait la cle de pilotage.

Preambule du mode agent : ~2350 -> ~1770 tokens, sans perdre une regle.
Le poids etait dans les schemas d'outils (le double du prompt), pas dans
le prompt. Celui-ci reenumerait des outils que les schemas decrivent
deja, et la regle « write, jamais echo » y figurait trois fois. Trois
tests gardent le budget : ce prompt a deja regrossi ligne par ligne deux
fois.

Aussi : l'export mentionne les pieces jointes (un message sans texte
donnait une section vide), et le decompte de contexte perd le mot
« contexte » au profit du nom du modele.
This commit is contained in:
nathaninline committed 2026-08-10 17:11:56 +02:00
1 parent 5a4c33661d
commit 6807bad48d
23 files changed
+1363 -104

No files matched your search

+32 -9
View File
@@ -1,14 +1,39 @@
Une seule correction, mais elle touche l'accès distant.
Les fichiers circulent enfin dans les deux sens : vous pouvez en envoyer à l'IA, et récupérer ceux qu'elle produit.
## Changer la clé de pilotage ne coupe plus ajean.link
## Envoyer un fichier
Si vous changiez la clé de pilotage, depuis l'interface ou avec `ajean set-web-key`, l'accès distant tombait aussitôt et ne revenait qu'au redémarrage du service.
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 cause : le tunnel lisait la clé une seule fois, à son ouverture, puis l'injectait dans chaque requête venue du relais. Après un changement, il continuait donc à présenter l'ancienne, et tout passait en refus d'authentification.
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 symptôme, lui, ne disait rien du problème. Le chat n'affiche pas le code d'erreur d'un flux qui n'arrive jamais : on voyait « chargement de la conversation » tourner à l'infini, sans un mot d'explication, alors que le serveur allait très bien.
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à.
La clé est maintenant relue à chaque requête. Si l'accès distant vous a lâché après un changement de clé, cette version suffit à le rétablir.
Le transfert passe par le tunnel chiffré comme le reste : l'envoi de fichiers fonctionne aussi depuis l'accès distant, sans rien configurer.
Taille maximale : 24 Mo par fichier.
## 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.
## Mise à jour
@@ -16,10 +41,8 @@ La clé est maintenant relue à chaque requête. Si l'accès distant vous a lâc
ajean update
```
Puis redémarrez le service d'interface pour rouvrir le tunnel :
Puis redémarrez l'interface :
```bash
ajean ui restart
```
Si vous utilisez le portail, rechargez la page ensuite.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 8,
"Patch": 6,
"Patch": 7,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 8,
"Patch": 6,
"Patch": 7,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "ajean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.8.6",
"ProductVersion": "0.8.7",
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
},
"VarFileInfo": {
+17 -3
View File
@@ -265,7 +265,10 @@ var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger
// StartTurn ajoute le message utilisateur et lance la génération EN ARRIÈRE-PLAN
// (context.Background, détaché de toute connexion HTTP). Renvoie ErrBusy si un
// tour est déjà en cours, ou une erreur si le modèle n'est pas prêt.
func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) error {
// files = pièces jointes déjà déposées (web_upload.go). Elles sont annoncées au
// MODÈLE en tête du message, mais rendues comme pastilles dans la bulle : le fil
// doit montrer ce que l'utilisateur a écrit, pas la consigne qu'on ajoute pour lui.
func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, temperature float64) error {
if !healthCheck() {
return errModelLoading
}
@@ -277,14 +280,25 @@ func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) er
c.Generating = true
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
c.Messages = append(c.Messages, Message{Role: "user", Content: text})
// Envoi sans un mot, juste un fichier : la bulle reste vide (les pastilles
// disent tout), mais le modèle a besoin d'une demande — sans elle il reçoit
// une liste de fichiers et rien à en faire.
prompt := text
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
c.Messages = append(c.Messages, Message{Role: "user", Content: attachNote(files) + prompt})
epoch := c.epoch
c.mu.Unlock()
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite :
// si le process meurt en pleine génération (crash, restart après MAJ), le
// message de l'utilisateur survit au lieu de disparaître avec le tour.
c.appendDelta(epoch, map[string]any{"user": text})
delta := map[string]any{"user": text}
if len(files) > 0 {
delta["files"] = files
}
c.appendDelta(epoch, delta)
c.persist()
if temperature == 0 {
temperature = 0.7
+29
View File
@@ -222,6 +222,11 @@ func (c *Conversation) ExportMarkdown(o exportOpts) string {
case ev["user"] != nil:
openBubble = false
fmt.Fprintf(&b, "\n---\n\n## Vous\n\n%s\n", mdText(ev["user"]))
// Pièces jointes du tour. Sans elles, un message envoyé SANS texte
// (juste un fichier) donnait une section « Vous » entièrement vide.
if names := exportFileNames(ev["files"]); len(names) > 0 {
fmt.Fprintf(&b, "\nFichiers joints : %s\n", strings.Join(names, ", "))
}
case ev["reasoning_content"] != nil:
if !o.Reasoning {
break
@@ -400,3 +405,27 @@ func handleChatExport(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
_, _ = w.Write(body)
}
// exportFileNames lit les noms des pièces jointes portées par un événement `user`.
//
// Deux formes possibles pour la même donnée : []attachInfo quand l'événement
// vient d'être produit, et []any de maps quand il a été relu depuis le journal
// persisté (JSON). L'export travaille sur les deux, d'où le double cas.
func exportFileNames(v any) []string {
var out []string
switch files := v.(type) {
case []attachInfo:
for _, f := range files {
out = append(out, f.Name)
}
case []any:
for _, it := range files {
if m, ok := it.(map[string]any); ok {
if n, ok := m["name"].(string); ok && n != "" {
out = append(out, n)
}
}
}
}
return out
}
+21 -25
View File
@@ -11,14 +11,13 @@ import (
func webSearchTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_search",
Description: "Recherche sur le web via DuckDuckGo. Renvoie une liste classée de {title, url, snippet}. " +
"À utiliser quand l'utilisateur pose une question sans URL, cherche un outil/une bibliothèque, " +
"ou demande une information récente. À enchaîner avec web_open + web_read sur le meilleur résultat.",
Description: "Recherche web (DuckDuckGo) → liste classée de {title, url, snippet}. " +
"Pour une question sans URL ou une information récente. Enchaîner avec web_open + web_read sur le meilleur résultat.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Requête (langage naturel ou mots-clés)"},
"limit": map[string]any{"type": "integer", "description": "Nb max de résultats (défaut 8, max 20)"},
"query": map[string]any{"type": "string", "description": "Requête"},
"limit": map[string]any{"type": "integer", "description": "Défaut 8, max 20"},
},
"required": []string{"query"},
},
@@ -35,17 +34,15 @@ func webSearchTool() Tool {
// donc que ce que le moteur sait réellement faire, et on annonce la limite du JS
// dans la description pour que le modèle change de source au lieu d'insister.
func webOpenTool() Tool {
desc := "Récupère une URL et renvoie SEULEMENT les métadonnées (taille, nb de lignes, plan des titres). " +
"Ne renvoie PAS le contenu. Toujours appeler ceci d'abord avant de lire. Résultat en cache 10 min " +
"— les web_read / web_grep suivants le réutilisent."
desc := "Récupère une URL et renvoie SEULEMENT ses métadonnées (taille, nb de lignes, plan des titres), " +
"PAS le contenu. Toujours l'appeler avant de lire ; mis en cache 10 min pour les web_read/web_grep suivants."
props := map[string]any{
"url": map[string]any{"type": "string", "description": "URL complète à récupérer"},
"refresh": map[string]any{"type": "boolean", "description": "Ignore le cache et re-fetch. Défaut false."},
"url": map[string]any{"type": "string", "description": "URL complète"},
"refresh": map[string]any{"type": "boolean", "description": "Ignore le cache. Défaut false."},
}
if webEngine() == engineGo {
desc += " ⚠ Ce moteur lit le HTML servi SANS exécuter le JavaScript : une page rendue " +
"entièrement côté client ressortira vide. Dans ce cas, ne réessaie pas la même URL — " +
"cherche une autre source (doc officielle, dépôt, article)."
desc += " ⚠ Ce moteur ne lit que le HTML servi, SANS JavaScript : une page rendue côté client " +
"ressort vide. Dans ce cas ne réessaie pas la même URL, change de source."
} else {
props["actions"] = map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "Snippets JS à exécuter sur la page AVANT extraction (déplier des sections, cliquer 'voir plus', etc.)."}
@@ -65,15 +62,14 @@ func webOpenTool() Tool {
func webReadTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_read",
Description: "Lit une plage de lignes d'une URL déjà ouverte avec web_open. Coût en tokens prévisible. " +
"Lignes 1-indexées, préfixées par leur numéro.",
Name: "web_read",
Description: "Lit une plage de lignes d'une URL déjà ouverte avec web_open. Lignes 1-indexées, préfixées par leur numéro.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "URL précédemment ouverte avec web_open"},
"offset": map[string]any{"type": "integer", "description": "Ligne de départ (1-indexée, défaut 1)"},
"limit": map[string]any{"type": "integer", "description": "Nb de lignes (défaut 80, max 500)"},
"url": map[string]any{"type": "string", "description": "URL ouverte avec web_open"},
"offset": map[string]any{"type": "integer", "description": "Ligne de départ (défaut 1)"},
"limit": map[string]any{"type": "integer", "description": "Défaut 80, max 500"},
},
"required": []string{"url"},
},
@@ -83,15 +79,15 @@ func webReadTool() Tool {
func webGrepTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_grep",
Description: "Recherche regex dans une URL déjà ouverte avec web_open. Renvoie les lignes correspondantes " +
"avec contexte et numéros. Idéal quand la page est longue et qu'on connaît un mot-clé.",
Description: "Recherche regex dans une URL déjà ouverte avec web_open → lignes correspondantes, " +
"avec contexte et numéros. Idéal sur une page longue dont on connaît un mot-clé.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "URL précédemment ouverte avec web_open"},
"pattern": map[string]any{"type": "string", "description": "Motif regex (insensible à la casse)"},
"context": map[string]any{"type": "integer", "description": "Lignes de contexte autour de chaque match. Défaut 2."},
"max_matches": map[string]any{"type": "integer", "description": "Plafond de matches renvoyés. Défaut 30."},
"url": map[string]any{"type": "string", "description": "URL ouverte avec web_open"},
"pattern": map[string]any{"type": "string", "description": "Regex (insensible à la casse)"},
"context": map[string]any{"type": "integer", "description": "Lignes de contexte. Défaut 2."},
"max_matches": map[string]any{"type": "integer", "description": "Plafond. Défaut 30."},
},
"required": []string{"url", "pattern"},
},
+65
View File
@@ -0,0 +1,65 @@
package ajean
import (
"encoding/json"
"strings"
"testing"
)
// Budget du préambule envoyé À CHAQUE TOUR : prompt système + schémas des outils.
// Il est payé sur toute la conversation et sort du contexte utile.
//
// Ce test existe pour empêcher le regonflement. Le prompt a déjà été raccourci
// une fois pour une raison de comportement (un préambule verbeux fait
// sur-raisonner les modèles à reasoning, qui finissent leur tour sans appeler
// d'outil), puis une seconde fois pour le coût. Les deux fois, il avait
// regrossi ligne par ligne, chacune paraissant anodine.
//
// Approximation 1 tok ≈ 4 caractères : suffisante pour une alerte, et sans
// dépendance à un tokenizer.
const promptCharBudget = 7500 // ~1875 tokens, tout allumé
func TestSystemPromptStaysLean(t *testing.T) {
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
sp := baseSystemPrompt(caps)
tb, err := json.Marshal(EnabledTools(caps))
if err != nil {
t.Fatal(err)
}
total := len(sp) + len(tb)
t.Logf("prompt %d car (~%d tok) + outils %d car (~%d tok) = ~%d tok",
len(sp), len(sp)/4, len(tb), len(tb)/4, total/4)
if total > promptCharBudget {
t.Fatalf("préambule à %d car (~%d tok), budget %d car (~%d tok).\n"+
"Avant de relever le budget : les schémas d'outils pèsent le double du prompt,\n"+
"et une consigne écrite dans un schéma n'a pas à être répétée dans le prompt.",
total, total/4, promptCharBudget, promptCharBudget/4)
}
}
// Le prompt ne doit pas redevenir un catalogue d'outils : leurs schémas partent
// dans la MÊME requête et les décrivent déjà un par un.
func TestSystemPromptDoesNotRelistTools(t *testing.T) {
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways})
for _, line := range strings.Split(sp, "\n") {
l := strings.TrimSpace(line)
if !strings.HasPrefix(l, "- ") {
continue
}
// Une puce qui commence par un nom d'outil = un catalogue qui revient.
for _, tool := range []string{"bash ", "write ", "edit ", "mem_", "web_"} {
if strings.HasPrefix(l[2:], tool) {
t.Fatalf("le prompt réénumère un outil : %q", l)
}
}
}
}
// Sans agent ni mémoire, aucun préambule : un modèle de chat simple à qui on
// ordonne d'appeler des outils invente des appels textuels qui fuient dans la
// réponse.
func TestSystemPromptEmptyWithoutTools(t *testing.T) {
if sp := baseSystemPrompt(Caps{Mem: MemOff}); sp != "" {
t.Fatalf("préambule non vide sans outils : %q", sp)
}
}
+23 -16
View File
@@ -47,38 +47,45 @@ func baseSystemPrompt(caps Caps) string {
if caps.Mem == MemAlways {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
}
b.WriteString("\n\nTools:\n")
// PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête,
// les décrivent déjà un par un. Les réénumérer coûtait ~120 tokens à chaque
// tour pour répéter ce que le modèle a juste à côté. Ne restent que les
// consignes que les schémas ne portent pas — le shell utilisé, et les
// politiques d'usage.
if caps.Agent {
b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts). The shell is " + shellName() + ": use its syntax.\n")
b.WriteString("- write — create or rewrite a file with exact content.\n")
b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n")
}
if hasMem {
b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under memory/.\n")
b.WriteString("\n\nThe shell is " + shellName() + ": use its syntax.\n")
} else {
b.WriteString("\n\n")
}
// Politique d'usage de la mémoire selon le mode.
switch caps.Mem {
case MemAlways:
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
b.WriteString("- When the user tells you to remember something, or shares a preference, fact, decision, or how-to worth keeping, save it with mem_add (or mem_edit to update an existing page) — do it on your own, without being asked.\n")
b.WriteString("- Before doing any task or answering, first call mem_search to see whether your memory already holds the answer or how to do it, then mem_read the best page. Do this even when the request has new specifics like a name, a place or a value — your saved method still applies, only the parameter changes.\n")
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes.\n")
case MemOnDemand:
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
}
if caps.Agent {
// Règle courte mais indispensable : sans elle le modèle fabrique ses scripts
// avec echo/python -c, ce que cmd.exe massacre (guillemets imbriqués).
b.WriteString("\nTo create or rewrite a file, always use write (or edit to patch one) — never echo, cat, python -c or Set-Content through the shell; quoting breaks. Write the script with write, then run it with bash.\n")
// La règle « write, jamais echo/cat » est INDISPENSABLE (cmd.exe massacre
// les guillemets imbriqués) mais elle vit maintenant dans les schémas de
// write et bash, là où elle s'applique. Elle était écrite trois fois.
b.WriteString("For anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
// Un lien Markdown ordinaire, comme dans n'importe quel chat : l'UI en fait
// un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
// à connaître pour le modèle — juste [texte](chemin).
b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n")
if caps.Mem == MemAlways {
b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n")
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
}
}
if caps.Internet {
b.WriteString("\nWeb access (Crawl4AI): web_search (DuckDuckGo), web_open (fetch a URL → metadata + outline), web_read (read a line range of an opened URL), web_grep (regex in an opened URL). Workflow: web_open first, then web_read/web_grep.\n")
// Le catalogue des outils web est parti dans leurs schémas ; ne reste ici
// que l'ordre d'appel, que les schémas pris isolément ne disent pas.
year := time.Now().Format("2006")
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, fixtures, 'since when') call web_search BEFORE writing any date or version. Your answer must match the dates/facts you actually read.\n")
b.WriteString("SEARCH QUERY YEAR RULE: today is in " + year + ". If your query includes a year, use ONLY " + year + " — NEVER write a past year like " + prevYear(year) + " that you remember from training; it silently biases results toward stale pages. Default: put no year at all and let the freshest result win. Don't hedge ('probably', 'I think') about a fact a tool can verify — search instead.\n")
b.WriteString("\nWeb: web_open first, then web_read/web_grep on it.\n")
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
}
if caps.Agent {
if line := mcpPromptLine(); line != "" {
+21 -21
View File
@@ -56,12 +56,12 @@ func memSearchTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "mem_search",
Description: "Search your memory (Markdown pages under memory/). Returns a ranked list of {file, title, snippet}. Use it FIRST when the user mentions something you might already know (preferences, ongoing projects, past decisions). Follow up with mem_read on the most relevant page.",
Description: "Search your memory (Markdown pages under memory/) → ranked {file, title, snippet}. Use FIRST when the user mentions something you might already know, then mem_read the best page.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Keywords or a short phrase"},
"limit": map[string]any{"type": "integer", "description": "Max results (default 8, max 30)"},
"query": map[string]any{"type": "string", "description": "Keywords"},
"limit": map[string]any{"type": "integer", "description": "Default 8, max 30"},
},
"required": []string{"query"},
},
@@ -74,13 +74,13 @@ func memReadTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "mem_read",
Description: "Read a memory page (Markdown file). 1-indexed output, lines prefixed with their number. offset/limit for long pages.",
Description: "Read a memory page. Lines prefixed with their 1-indexed number; offset/limit for long pages.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Page name (e.g. docker-notes.md)"},
"offset": map[string]any{"type": "integer", "description": "Start line (1-indexed, default 1)"},
"limit": map[string]any{"type": "integer", "description": "Number of lines (default 500, max 500)"},
"offset": map[string]any{"type": "integer", "description": "Start line (default 1)"},
"limit": map[string]any{"type": "integer", "description": "Lines (max 500)"},
},
"required": []string{"file"},
},
@@ -93,12 +93,12 @@ func memAddTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "mem_add",
Description: "Create a new memory page (Markdown). One topic per page, descriptive kebab-case name. First line = short title (#). Refuses to overwrite an existing page (use mem_edit).",
Description: "Create a memory page. One topic per page, kebab-case name, first line = title (#). Refuses to overwrite an existing page (use mem_edit).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Page name (e.g. docker-notes.md)"},
"content": map[string]any{"type": "string", "description": "Markdown content (first line = title #)"},
"file": map[string]any{"type": "string", "description": "Page name"},
"content": map[string]any{"type": "string", "description": "Markdown, first line = title #"},
},
"required": []string{"file", "content"},
},
@@ -111,13 +111,13 @@ func memEditTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "mem_edit",
Description: "Edit a memory page by exact replacement: old → new. old must appear EXACTLY once in the page (add context to make it unique). To append, put the current end of the page in old and the extended version in new.",
Description: "Patch a memory page: old → new, old unique in the page. To append, put the current end of the page in old and the extended version in new.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Page name"},
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique in the page)"},
"new": map[string]any{"type": "string", "description": "Replacement text"},
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"},
"new": map[string]any{"type": "string", "description": "Replacement"},
},
"required": []string{"file", "old", "new"},
},
@@ -130,13 +130,13 @@ func editTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "edit",
Description: "Modify a file on disk by exact replacement: old → new. old must appear EXACTLY once in the file (add surrounding context to make it unique). Prefer this over rewriting the whole file — it avoids retyping everything.",
Description: "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Path of the file to modify"},
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique in the file)"},
"new": map[string]any{"type": "string", "description": "Replacement text"},
"file": map[string]any{"type": "string", "description": "Path"},
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"},
"new": map[string]any{"type": "string", "description": "Replacement"},
},
"required": []string{"file", "old", "new"},
},
@@ -149,12 +149,12 @@ func writeTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "write",
Description: "Write a file on disk with the exact content given (creates it, or replaces it whole; parent directories are created). ALWAYS use this to create a script or any text file — NEVER build a file through the shell with echo, cat, python -c or Set-Content: the shell mangles quotes and breaks the file. Content is written verbatim, no escaping needed.",
Description: "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Path of the file to write"},
"content": map[string]any{"type": "string", "description": "Full content of the file, verbatim"},
"file": map[string]any{"type": "string", "description": "Path"},
"content": map[string]any{"type": "string", "description": "Full content"},
},
"required": []string{"file", "content"},
},
@@ -167,11 +167,11 @@ func bashTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "bash",
Description: "Execute a shell command on this machine (" + shellName() + ") and return stdout, stderr and the exit code. Use " + shellName() + " syntax, not another shell's. For inspecting the system, running scripts, reading files, reading logs. To CREATE or REWRITE a file, use the write tool instead — never echo/cat/python -c. Avoid destructive commands unless explicitly asked.",
Description: "Run a shell command (" + shellName() + " syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string", "description": "The bash command"},
"command": map[string]any{"type": "string", "description": "The command"},
"timeout": map[string]any{"type": "integer", "description": fmt.Sprintf("Timeout s (default %d, max %d)", toolDefaultTimeout, toolMaxTimeout)},
},
"required": []string{"command"},
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.8.6"
const Version = "0.8.7"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
+365 -12
View File
@@ -599,7 +599,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
#ctxtrack{position:absolute;top:0;left:0;right:0;height:3px;background:var(--panel);overflow:hidden}
#ctx-fill{height:100%;width:0%;background:var(--ok,#3a7);transition:width .3s,background .3s}
#ctxmeta{position:relative;display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:8px 12px 0}
#ctx-text{font-size:10px;letter-spacing:.03em;white-space:nowrap}
#ctx-compact{display:none;margin:0;padding:2px 10px;font-size:10px;background:var(--warn-bg);border-color:var(--warn,#c93)}
#inputbar{padding:8px 12px;display:flex;gap:8px}
/* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir
@@ -644,7 +643,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
#send,#stop{padding:0 14px}
#composer .muted{display:none}
#ctxmeta{padding:3px 10px 0}
#ctx-text{font-size:9px}
h1{font-size:15px}
h2,details>summary{font-size:13px}
#menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))}
@@ -1031,7 +1029,7 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
coque à visualViewport.height, ni décaler le composer de la hauteur du
clavier. Dans les deux cas la carte part en haut de l'écran en PWA iOS.
iOS gère déjà correctement le clavier tout seul — on le laisse faire. */
#ctx-text,#status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em}
#ctx-text,#status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em;white-space:nowrap}
#ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)}
#ctx-compact:hover{border-color:var(--dim);color:var(--text)}
/* Jauge = bord bas de la carte : épaisse, plaquée au ras du bord, rognée par
@@ -1039,6 +1037,80 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
#ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
#ctx-fill{background:var(--text);opacity:.75}
#sendhint{order:4;padding:12px var(--col) 2px}
/* ===== Pièces jointes ==============================================
Un fichier joint n'est jamais lu par la page : il est déposé dans le dossier
de travail de l'IA et seul son nom revient. L'affichage se limite donc à une
pastille — pas d'aperçu, pas de vignette, rien qui promette plus que ça.
Le trombone vit dans le PIED de la carte, à droite avec le décompte de contexte
et « compacter ». Il est à LEUR échelle (une ligne d'outils de 10 px), pas à
celle de « send » : joindre un fichier est un geste occasionnel. */
#attach{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
display:flex;align-items:center;justify-content:center;border-radius:999px;
background:transparent;border:1px solid transparent;color:var(--dim)}
#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#attach:active{transform:scale(.9)}
#attach svg{display:block}
/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte.
Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de
dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant
le rembourrage. La carte ne bouge donc pas — c'est la bande vide qui se
remplit, au lieu du composeur qui grandit vers le haut.
Elles ne vont PAS dans le pied : cette ligne appartient au nom du modèle. */
#attach-list{order:0;display:none;gap:6px;margin:0 var(--col);padding:0 2px 8px;
overflow-x:auto;scrollbar-width:none}
#attach-list::-webkit-scrollbar{display:none}
#attach-list.show{display:flex}
#composer:has(#attach-list.show){padding-top:6px}
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
cadre, sinon le texte de la conversation transparaît derrière. */
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
box-shadow:0 2px 10px rgba(0,0,0,.10)}
/* Pastille de fichier. Le × n'apparaît qu'au survol : au repos la pastille doit
rester lisible d'un coup d'œil (nom + poids), pas encombrée d'un bouton. */
.chip-file{display:flex;align-items:center;gap:8px;max-width:230px;min-width:0;
padding:4px 12px;border-radius:999px;background:var(--row-bg-on);
border:1px solid transparent;font-size:12px;line-height:1.3}
/* Avec un × à droite, la pastille se ferme SUR lui : le rembourrage de droite
revient à l'épaisseur du rond, sinon le bouton flotte loin du bord et la
pastille paraît de travers. */
.chip-file:has(button){padding-right:4px}
.chip-file .cf-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chip-file .cf-size{flex:none;color:var(--dim);font-size:11px}
/* Le × est TOUJOURS visible : le révéler au survol le rendait inatteignable sur
mobile, où il n'y a pas de survol — donc impossible de retirer un fichier. */
.chip-file button{flex:none;margin:0;padding:0;width:20px;height:20px;font-size:14px;
display:flex;align-items:center;justify-content:center;line-height:1;
border-radius:50%;border:none;background:transparent;color:var(--dim)}
.chip-file button:hover{background:var(--bubble);color:var(--text)}
/* Transfert en cours : un liseré qui tourne, plutôt qu'une pastille grisée dont
on ne sait pas si elle travaille ou si elle a échoué. */
.chip-file.up .cf-size::after{content:"";display:inline-block;vertical-align:-2px;
margin-left:7px;width:10px;height:10px;border-radius:50%;
border:1.5px solid var(--border);border-top-color:var(--text);animation:spin .7s linear infinite}
.chip-file.err{background:var(--danger-bg);color:var(--err)}
.chip-file.err .cf-size{color:var(--err)}
@media (prefers-reduced-motion:reduce){.chip-file.up .cf-size::after{animation:none}}
/* Fichiers d'un message envoyé : leur propre rangée AU-DESSUS de la bulle,
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
une seule bulle difforme qui déborde vers le haut. */
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
gap:6px;max-width:75%}
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
#chat .msg .filelink{cursor:pointer}
#chat .msg .filelink::after{content:"↓";margin-left:5px;font-size:.9em;opacity:.7;text-decoration:none;display:inline-block}
#chat .msg .filelink.busy{opacity:.55;pointer-events:none}
/* Voile de dépôt. Il couvre la zone de conversation, curseur inclus : c'est la
réponse à « est-ce que je peux lâcher ici ? », elle doit être sans ambiguïté. */
#dropveil{position:absolute;inset:0;z-index:20;display:none;align-items:center;
justify-content:center;padding:24px;background:rgba(0,0,0,.28);pointer-events:none}
#dropveil.show{display:flex}
#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:20px;
background:var(--panel);color:var(--text);font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.18)}
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
button,.mem-search{border-radius:10px}
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
@@ -1397,17 +1469,31 @@ button:hover{border-color:var(--dim);color:var(--text)}
<div id="ce-title">On commence par quoi ?</div>
</div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
<div id="composer">
<div id="ctxmeta">
<div id="ctxtrack"><div id="ctx-fill"></div></div>
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
<span style="display:flex;align-items:center;gap:10px;margin-left:auto">
<span id="ctx-text" class="muted">contexte 0 / 0</span>
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
<span id="ctx-text" class="muted">0 / 0</span>
<button id="ctx-compact" onclick="compactContext()">compacter</button>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
dont le rendu varie énormément d'un système à l'autre. -->
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.4 11.05 12.25 20.2a5.5 5.5 0 0 1-7.78-7.78l9.19-9.19a3.67 3.67 0 1 1 5.18 5.18l-9.2 9.2a1.83 1.83 0 1 1-2.59-2.6l8.49-8.48"/></svg>
</button>
</span>
</div>
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)"></textarea>
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
<button id="send" onclick="send()">send</button>
<button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
</div>
@@ -2213,7 +2299,12 @@ function updateCtxMeter(){
const fill=document.getElementById('ctx-fill');
fill.style.width=pct+'%';
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
document.getElementById('ctx-text').textContent='contexte '+CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
// Les chiffres SANS le mot « contexte » : la jauge juste en dessous dit déjà de
// quoi on parle, et le pied de carte est étroit. Le libellé complet reste en
// infobulle.
const ct=document.getElementById('ctx-text');
ct.textContent=CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
ct.title='contexte utilisé';
// Bouton de compaction MANUELLE : visible dès la moitié du contexte pour qu'on
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
@@ -3828,7 +3919,7 @@ function setStats(el, text){
}
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); markNotices(b); addCopyButtons(b); scrollMaybe(); }
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); scrollMaybe(); }
// Render a tool call as its own conversation message: the command the model
// wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){
@@ -4059,7 +4150,14 @@ let lastSeq=0, streamAbort=null;
// l'événement `user` revient par le flux — preuve que le serveur l'a bien
// enregistré. En cas d'échec d'envoi, elle est retirée et le texte est rendu.
let PENDING=null;
function clearPending(){ if(PENDING){ PENDING.remove(); PENDING=null; } }
// Retire aussi la rangée de pièces jointes, qui vit JUSTE AVANT la bulle : sans
// ça, un envoi échoué laissait les fichiers seuls dans le fil, sans message.
function clearPending(){
if(!PENDING) return;
const f=PENDING.previousElementSibling;
if(f&&f.classList.contains('msg-files')) f.remove();
PENDING.remove(); PENDING=null;
}
function addPending(text){
clearPending();
PENDING=addMsg('user', text);
@@ -4195,7 +4293,15 @@ function handleDelta(d){
syncChatEmpty();
return; }
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
// bulle neuve — sinon elles apparaîtraient en double.
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
setBusy(true); T.typingEl=addTyping(); return; }
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
@@ -4303,15 +4409,25 @@ async function send(){
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return;
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
if(!text && !ATTACH.length) return;
ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
addPending(text);
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
// on doit pouvoir en enlever une, et un échec doit rester visible.
const files=await attachPaths();
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
// Les pastilles passent dans la bulle en attente : le message porte ses
// fichiers dès l'envoi, sans attendre l'aller-retour.
if(PENDING) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){
try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})});
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
if(r.status===409 || r.ok) clearAttach();
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
if(r.ok) return; // la bulle + les tokens arrivent par le flux
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
@@ -4895,4 +5011,241 @@ async function remotePairCode(){
}
}catch(e){ box.textContent='erreur : '+e; }
}
// ===== Pièces jointes du chat ==============================================
// Un fichier joint n'est PAS lu par le navigateur ni injecté dans le contexte :
// il est déposé tel quel dans le dossier de travail de l'IA (uploads/, côté
// serveur), et seul son chemin est annoncé dans le message. Le modèle l'ouvre
// s'il en a besoin, avec ses outils — ce qui marche pour n'importe quel type de
// fichier sans que l'UI ait à en connaître le format.
//
// 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)
let ATTACH = [];
let ATTACH_SEQ = 0;
function fmtSize(n){
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';
}
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
// objet visuel des deux côtés, seul le contexte (CSS) change.
function fileChip(name, size, opts){
opts=opts||{};
const chip=document.createElement('div');
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
const n=document.createElement('span');
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
const s=document.createElement('span');
s.className='cf-size'; s.textContent=opts.sizeText||fmtSize(size);
chip.appendChild(n); chip.appendChild(s);
if(opts.onRemove){
const x=document.createElement('button');
x.type='button'; x.textContent='×'; x.title='retirer';
x.setAttribute('aria-label','retirer '+name);
x.onclick=opts.onRemove;
chip.appendChild(x);
}
return chip;
}
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
// un bloc bâtard, moitié texte moitié pastilles.
function addMsgFiles(el, files){
if(!el || !files || !files.length) return;
const box=document.createElement('div');
box.className='msg-files';
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
el.parentNode.insertBefore(box, el);
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
markEmptyMsg(el);
return box;
}
// Une bulle sans texte n'a rien à montrer — seules ses pièces jointes parlent.
function markEmptyMsg(el){
const b=el&&el.querySelector('.body');
if(b) el.classList.toggle('empty', b.textContent==='');
}
// La rangée de fichiers précède la bulle : c'est là qu'on vérifie sa présence
// avant d'en ajouter une seconde (replay + bulle en attente).
function hasMsgFiles(el){
const p=el&&el.previousElementSibling;
return !!(p&&p.classList.contains('msg-files'));
}
// ===== Fichiers remis par l'IA ============================================
// Aucun outil, aucune syntaxe maison : l'IA écrit un lien Markdown ordinaire
// vers un fichier de son dossier de travail — [Télécharger](rapport.pdf) — et
// c'est l'UI qui le transforme en téléchargement. Le modèle n'a rien à savoir
// de plus que ce qu'il fait déjà naturellement.
//
// Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête
// Authorization que le navigateur ne mettrait pas sur une navigation, et le
// chemin de base change derrière le tunnel (/u/<id>). D'où le fetch + blob.
async function downloadWorkspaceFile(path, name, a){
if(a) a.classList.add('busy');
try{
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
if(!r.ok){
let m='HTTP '+r.status; try{ m=(await r.json()).error||m; }catch(_){}
toast('téléchargement impossible : '+m); return;
}
const blobURL=URL.createObjectURL(await r.blob());
const link=document.createElement('a');
link.href=blobURL; link.download=name||'fichier';
document.body.appendChild(link); link.click(); link.remove();
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
// dans la foulée du clic.
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
}catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); }
finally{ if(a) a.classList.remove('busy'); }
}
// Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle
// écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un
// lien mort. Or les noms de fichiers à espaces sont la règle, pas l'exception. On
// les encode donc avant le rendu (fileLinkPath les redécode).
//
// Les cibles avec un titre — [x](chemin "Titre") — sont laissées tranquilles :
// leurs espaces sont syntaxiques.
function encodeMdLinkSpaces(text){
return String(text).replace(/\]\(([^)\n]*)\)/g, (whole, target)=>{
if(target.indexOf(' ')<0 || /["']/.test(target)) return whole;
if(/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return whole; // URL : pas notre affaire
return '](' + target.replace(/ /g, '%20') + ')';
});
}
// Chemin visé par un lien de la réponse, ramené au dossier de travail — ou ''
// si le lien pointe ailleurs (page web, ancre, mailto…).
//
// Le modèle écrit indifféremment un chemin relatif, un chemin absolu du serveur,
// ou le préfixe `sandbox:` qu'il a vu ailleurs : les trois doivent marcher, on
// ne va pas lui demander d'être rigoureux sur un détail pareil.
function fileLinkPath(href){
if(!href) return '';
let h=href.trim();
if(/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(h)) return '';
h=h.replace(/^sandbox:/i,'').replace(/^file:\/\//i,'');
try{ h=decodeURI(h); }catch(_){}
h=h.replace(/\\/g,'/');
// Chemin absolu : on ne garde que ce qui suit le dossier de travail. Le serveur
// revérifie de toute façon — c'est lui qui fait autorité sur le périmètre.
const m=h.match(/(?:^|\/)workspace\/(.+)$/);
if(m) h=m[1];
h=h.replace(/^\/+/,'').replace(/^\.\//,'');
if(!h || h.indexOf('..')>=0) return '';
return h;
}
// Transforme en téléchargements les liens d'une réponse qui visent un fichier.
function markFileLinks(root){
for(const a of root.querySelectorAll('a[href]')){
const p=fileLinkPath(a.getAttribute('href'));
if(!p) continue;
a.classList.add('filelink');
a.removeAttribute('target');
a.setAttribute('href','#');
const name=p.split('/').pop();
a.title='télécharger '+name;
a.onclick=(e)=>{ e.preventDefault(); downloadWorkspaceFile(p, name, a); };
}
}
function attachListEl(){ return document.getElementById('attach-list'); }
function renderAttach(){
const el = attachListEl(); if(!el) return;
el.innerHTML='';
el.classList.toggle('show', ATTACH.length>0);
for(const a of ATTACH){
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),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
}));
}
}
function clearAttach(){ ATTACH=[]; renderAttach(); }
// Ce qui part avec le message, pour l'afficher dans sa bulle.
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
// Lecture en base64. readAsDataURL préfixe par "data:<mime>;base64," — le
// serveur sait retirer cet en-tête, on n'a donc rien à découper ici.
function readAsBase64(file){
return new Promise((res,rej)=>{
const fr=new FileReader();
fr.onload=()=>res(String(fr.result||''));
fr.onerror=()=>rej(new Error('lecture impossible'));
fr.readAsDataURL(file);
});
}
// 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.
async function uploadAttach(a){
a.state='up'; 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
}catch(e){
a.state='err'; a.error=(e&&e.message)||'échec';
toast('« '+a.name+' » : '+a.error);
}
renderAttach();
}
function addFiles(files){
for(const f of files||[]){
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
}
renderAttach();
}
function onAttachPick(e){
addFiles(e.target.files);
e.target.value=''; // sinon re-choisir le même fichier ne déclenche pas change
}
// Coller un fichier (capture d'écran, fichier copié depuis l'explorateur). On ne
// touche à rien quand le presse-papier ne contient que du texte : le collage
// normal dans la zone de saisie doit rester intact.
function onPaste(e){
const items=(e.clipboardData&&e.clipboardData.files)||[];
if(!items.length) return;
e.preventDefault();
addFiles(items);
}
// Glisser-déposer sur toute la fenêtre. Le compteur de profondeur est nécessaire :
// dragenter/dragleave se déclenchent aussi au passage d'un enfant à l'autre, et
// sans lui le voile clignote dès que le curseur traverse une bulle du fil.
let DRAG_DEPTH=0;
function dropVeil(on){
const v=document.getElementById('dropveil'); if(v) v.classList.toggle('show', on);
}
function hasFiles(e){
const dt=e.dataTransfer; if(!dt) return false;
if(dt.types) for(const t of dt.types){ if(t==='Files') return true; }
return false;
}
window.addEventListener('dragenter', e=>{ if(!hasFiles(e)) return; e.preventDefault(); DRAG_DEPTH++; dropVeil(true); });
window.addEventListener('dragover', e=>{ if(hasFiles(e)) e.preventDefault(); });
window.addEventListener('dragleave', e=>{ if(!hasFiles(e)) return; DRAG_DEPTH=Math.max(0,DRAG_DEPTH-1); if(!DRAG_DEPTH) dropVeil(false); });
window.addEventListener('drop', e=>{
if(!hasFiles(e)) return;
e.preventDefault(); DRAG_DEPTH=0; dropVeil(false);
addFiles(e.dataTransfer.files);
});
// Appelé par send() : c'est ICI que les fichiers partent réellement vers le
// serveur. Ceux qui échouent sont ignorés — leur pastille reste à l'écran en
// rouge, elle dit déjà ce qui s'est passé.
async function attachPaths(){
await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach));
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
}
renderAttach();
</script></body></html>
+17 -3
View File
@@ -328,17 +328,31 @@
<div id="ce-title">On commence par quoi ?</div>
</div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
<div id="composer">
<div id="ctxmeta">
<div id="ctxtrack"><div id="ctx-fill"></div></div>
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
<span style="display:flex;align-items:center;gap:10px;margin-left:auto">
<span id="ctx-text" class="muted">contexte 0 / 0</span>
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
<span id="ctx-text" class="muted">0 / 0</span>
<button id="ctx-compact" onclick="compactContext()">compacter</button>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
<!-- Trombone dessiné en SVG : pas de dépendance à une police d'emoji,
dont le rendu varie énormément d'un système à l'autre. -->
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.4 11.05 12.25 20.2a5.5 5.5 0 0 1-7.78-7.78l9.19-9.19a3.67 3.67 0 1 1 5.18 5.18l-9.2 9.2a1.83 1.83 0 1 1-2.59-2.6l8.49-8.48"/></svg>
</button>
</span>
</div>
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)"></textarea>
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
<button id="send" onclick="send()">send</button>
<button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
</div>
+6 -1
View File
@@ -111,7 +111,12 @@ function updateCtxMeter(){
const fill=document.getElementById('ctx-fill');
fill.style.width=pct+'%';
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
document.getElementById('ctx-text').textContent='contexte '+CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
// Les chiffres SANS le mot « contexte » : la jauge juste en dessous dit déjà de
// quoi on parle, et le pied de carte est étroit. Le libellé complet reste en
// infobulle.
const ct=document.getElementById('ctx-text');
ct.textContent=CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
ct.title='contexte utilisé';
// Bouton de compaction MANUELLE : visible dès la moitié du contexte pour qu'on
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
+1 -1
View File
@@ -135,7 +135,7 @@ function setStats(el, text){
}
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); markNotices(b); addCopyButtons(b); scrollMaybe(); }
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); scrollMaybe(); }
// Render a tool call as its own conversation message: the command the model
// wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){
+29 -4
View File
@@ -10,7 +10,14 @@ let lastSeq=0, streamAbort=null;
// l'événement `user` revient par le flux — preuve que le serveur l'a bien
// enregistré. En cas d'échec d'envoi, elle est retirée et le texte est rendu.
let PENDING=null;
function clearPending(){ if(PENDING){ PENDING.remove(); PENDING=null; } }
// Retire aussi la rangée de pièces jointes, qui vit JUSTE AVANT la bulle : sans
// ça, un envoi échoué laissait les fichiers seuls dans le fil, sans message.
function clearPending(){
if(!PENDING) return;
const f=PENDING.previousElementSibling;
if(f&&f.classList.contains('msg-files')) f.remove();
PENDING.remove(); PENDING=null;
}
function addPending(text){
clearPending();
PENDING=addMsg('user', text);
@@ -146,7 +153,15 @@ function handleDelta(d){
syncChatEmpty();
return; }
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
// bulle neuve — sinon elles apparaîtraient en double.
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
setBusy(true); T.typingEl=addTyping(); return; }
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
@@ -254,15 +269,25 @@ async function send(){
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return;
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
if(!text && !ATTACH.length) return;
ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
addPending(text);
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
// on doit pouvoir en enlever une, et un échec doit rester visible.
const files=await attachPaths();
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
// Les pastilles passent dans la bulle en attente : le message porte ses
// fichiers dès l'envoi, sans attendre l'aller-retour.
if(PENDING) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){
try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})});
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
if(r.status===409 || r.ok) clearAttach();
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
if(r.ok) return; // la bulle + les tokens arrivent par le flux
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
+237
View File
@@ -0,0 +1,237 @@
// ===== Pièces jointes du chat ==============================================
// Un fichier joint n'est PAS lu par le navigateur ni injecté dans le contexte :
// il est déposé tel quel dans le dossier de travail de l'IA (uploads/, côté
// serveur), et seul son chemin est annoncé dans le message. Le modèle l'ouvre
// s'il en a besoin, avec ses outils — ce qui marche pour n'importe quel type de
// fichier sans que l'UI ait à en connaître le format.
//
// 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)
let ATTACH = [];
let ATTACH_SEQ = 0;
function fmtSize(n){
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';
}
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
// objet visuel des deux côtés, seul le contexte (CSS) change.
function fileChip(name, size, opts){
opts=opts||{};
const chip=document.createElement('div');
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
const n=document.createElement('span');
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
const s=document.createElement('span');
s.className='cf-size'; s.textContent=opts.sizeText||fmtSize(size);
chip.appendChild(n); chip.appendChild(s);
if(opts.onRemove){
const x=document.createElement('button');
x.type='button'; x.textContent='×'; x.title='retirer';
x.setAttribute('aria-label','retirer '+name);
x.onclick=opts.onRemove;
chip.appendChild(x);
}
return chip;
}
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
// un bloc bâtard, moitié texte moitié pastilles.
function addMsgFiles(el, files){
if(!el || !files || !files.length) return;
const box=document.createElement('div');
box.className='msg-files';
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
el.parentNode.insertBefore(box, el);
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
markEmptyMsg(el);
return box;
}
// Une bulle sans texte n'a rien à montrer — seules ses pièces jointes parlent.
function markEmptyMsg(el){
const b=el&&el.querySelector('.body');
if(b) el.classList.toggle('empty', b.textContent==='');
}
// La rangée de fichiers précède la bulle : c'est là qu'on vérifie sa présence
// avant d'en ajouter une seconde (replay + bulle en attente).
function hasMsgFiles(el){
const p=el&&el.previousElementSibling;
return !!(p&&p.classList.contains('msg-files'));
}
// ===== Fichiers remis par l'IA ============================================
// Aucun outil, aucune syntaxe maison : l'IA écrit un lien Markdown ordinaire
// vers un fichier de son dossier de travail — [Télécharger](rapport.pdf) — et
// c'est l'UI qui le transforme en téléchargement. Le modèle n'a rien à savoir
// de plus que ce qu'il fait déjà naturellement.
//
// Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête
// Authorization que le navigateur ne mettrait pas sur une navigation, et le
// chemin de base change derrière le tunnel (/u/<id>). D'où le fetch + blob.
async function downloadWorkspaceFile(path, name, a){
if(a) a.classList.add('busy');
try{
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
if(!r.ok){
let m='HTTP '+r.status; try{ m=(await r.json()).error||m; }catch(_){}
toast('téléchargement impossible : '+m); return;
}
const blobURL=URL.createObjectURL(await r.blob());
const link=document.createElement('a');
link.href=blobURL; link.download=name||'fichier';
document.body.appendChild(link); link.click(); link.remove();
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
// dans la foulée du clic.
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
}catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); }
finally{ if(a) a.classList.remove('busy'); }
}
// Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle
// écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un
// lien mort. Or les noms de fichiers à espaces sont la règle, pas l'exception. On
// les encode donc avant le rendu (fileLinkPath les redécode).
//
// Les cibles avec un titre — [x](chemin "Titre") — sont laissées tranquilles :
// leurs espaces sont syntaxiques.
function encodeMdLinkSpaces(text){
return String(text).replace(/\]\(([^)\n]*)\)/g, (whole, target)=>{
if(target.indexOf(' ')<0 || /["']/.test(target)) return whole;
if(/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return whole; // URL : pas notre affaire
return '](' + target.replace(/ /g, '%20') + ')';
});
}
// Chemin visé par un lien de la réponse, ramené au dossier de travail — ou ''
// si le lien pointe ailleurs (page web, ancre, mailto…).
//
// Le modèle écrit indifféremment un chemin relatif, un chemin absolu du serveur,
// ou le préfixe `sandbox:` qu'il a vu ailleurs : les trois doivent marcher, on
// ne va pas lui demander d'être rigoureux sur un détail pareil.
function fileLinkPath(href){
if(!href) return '';
let h=href.trim();
if(/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(h)) return '';
h=h.replace(/^sandbox:/i,'').replace(/^file:\/\//i,'');
try{ h=decodeURI(h); }catch(_){}
h=h.replace(/\\/g,'/');
// Chemin absolu : on ne garde que ce qui suit le dossier de travail. Le serveur
// revérifie de toute façon — c'est lui qui fait autorité sur le périmètre.
const m=h.match(/(?:^|\/)workspace\/(.+)$/);
if(m) h=m[1];
h=h.replace(/^\/+/,'').replace(/^\.\//,'');
if(!h || h.indexOf('..')>=0) return '';
return h;
}
// Transforme en téléchargements les liens d'une réponse qui visent un fichier.
function markFileLinks(root){
for(const a of root.querySelectorAll('a[href]')){
const p=fileLinkPath(a.getAttribute('href'));
if(!p) continue;
a.classList.add('filelink');
a.removeAttribute('target');
a.setAttribute('href','#');
const name=p.split('/').pop();
a.title='télécharger '+name;
a.onclick=(e)=>{ e.preventDefault(); downloadWorkspaceFile(p, name, a); };
}
}
function attachListEl(){ return document.getElementById('attach-list'); }
function renderAttach(){
const el = attachListEl(); if(!el) return;
el.innerHTML='';
el.classList.toggle('show', ATTACH.length>0);
for(const a of ATTACH){
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),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
}));
}
}
function clearAttach(){ ATTACH=[]; renderAttach(); }
// Ce qui part avec le message, pour l'afficher dans sa bulle.
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
// Lecture en base64. readAsDataURL préfixe par "data:<mime>;base64," — le
// serveur sait retirer cet en-tête, on n'a donc rien à découper ici.
function readAsBase64(file){
return new Promise((res,rej)=>{
const fr=new FileReader();
fr.onload=()=>res(String(fr.result||''));
fr.onerror=()=>rej(new Error('lecture impossible'));
fr.readAsDataURL(file);
});
}
// 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.
async function uploadAttach(a){
a.state='up'; 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
}catch(e){
a.state='err'; a.error=(e&&e.message)||'échec';
toast('« '+a.name+' » : '+a.error);
}
renderAttach();
}
function addFiles(files){
for(const f of files||[]){
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
}
renderAttach();
}
function onAttachPick(e){
addFiles(e.target.files);
e.target.value=''; // sinon re-choisir le même fichier ne déclenche pas change
}
// Coller un fichier (capture d'écran, fichier copié depuis l'explorateur). On ne
// touche à rien quand le presse-papier ne contient que du texte : le collage
// normal dans la zone de saisie doit rester intact.
function onPaste(e){
const items=(e.clipboardData&&e.clipboardData.files)||[];
if(!items.length) return;
e.preventDefault();
addFiles(items);
}
// Glisser-déposer sur toute la fenêtre. Le compteur de profondeur est nécessaire :
// dragenter/dragleave se déclenchent aussi au passage d'un enfant à l'autre, et
// sans lui le voile clignote dès que le curseur traverse une bulle du fil.
let DRAG_DEPTH=0;
function dropVeil(on){
const v=document.getElementById('dropveil'); if(v) v.classList.toggle('show', on);
}
function hasFiles(e){
const dt=e.dataTransfer; if(!dt) return false;
if(dt.types) for(const t of dt.types){ if(t==='Files') return true; }
return false;
}
window.addEventListener('dragenter', e=>{ if(!hasFiles(e)) return; e.preventDefault(); DRAG_DEPTH++; dropVeil(true); });
window.addEventListener('dragover', e=>{ if(hasFiles(e)) e.preventDefault(); });
window.addEventListener('dragleave', e=>{ if(!hasFiles(e)) return; DRAG_DEPTH=Math.max(0,DRAG_DEPTH-1); if(!DRAG_DEPTH) dropVeil(false); });
window.addEventListener('drop', e=>{
if(!hasFiles(e)) return;
e.preventDefault(); DRAG_DEPTH=0; dropVeil(false);
addFiles(e.dataTransfer.files);
});
// Appelé par send() : c'est ICI que les fichiers partent réellement vers le
// serveur. Ceux qui échouent sont ignorés — leur pastille reste à l'écran en
// rouge, elle dit déjà ce qui s'est passé.
async function attachPaths(){
await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach));
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
}
renderAttach();
+75 -3
View File
@@ -573,7 +573,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
#ctxtrack{position:absolute;top:0;left:0;right:0;height:3px;background:var(--panel);overflow:hidden}
#ctx-fill{height:100%;width:0%;background:var(--ok,#3a7);transition:width .3s,background .3s}
#ctxmeta{position:relative;display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:8px 12px 0}
#ctx-text{font-size:10px;letter-spacing:.03em;white-space:nowrap}
#ctx-compact{display:none;margin:0;padding:2px 10px;font-size:10px;background:var(--warn-bg);border-color:var(--warn,#c93)}
#inputbar{padding:8px 12px;display:flex;gap:8px}
/* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir
@@ -618,7 +617,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
#send,#stop{padding:0 14px}
#composer .muted{display:none}
#ctxmeta{padding:3px 10px 0}
#ctx-text{font-size:9px}
h1{font-size:15px}
h2,details>summary{font-size:13px}
#menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))}
@@ -1005,7 +1003,7 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
coque à visualViewport.height, ni décaler le composer de la hauteur du
clavier. Dans les deux cas la carte part en haut de l'écran en PWA iOS.
iOS gère déjà correctement le clavier tout seul — on le laisse faire. */
#ctx-text,#status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em}
#ctx-text,#status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em;white-space:nowrap}
#ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)}
#ctx-compact:hover{border-color:var(--dim);color:var(--text)}
/* Jauge = bord bas de la carte : épaisse, plaquée au ras du bord, rognée par
@@ -1013,6 +1011,80 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
#ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
#ctx-fill{background:var(--text);opacity:.75}
#sendhint{order:4;padding:12px var(--col) 2px}
/* ===== Pièces jointes ==============================================
Un fichier joint n'est jamais lu par la page : il est déposé dans le dossier
de travail de l'IA et seul son nom revient. L'affichage se limite donc à une
pastille — pas d'aperçu, pas de vignette, rien qui promette plus que ça.
Le trombone vit dans le PIED de la carte, à droite avec le décompte de contexte
et « compacter ». Il est à LEUR échelle (une ligne d'outils de 10 px), pas à
celle de « send » : joindre un fichier est un geste occasionnel. */
#attach{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
display:flex;align-items:center;justify-content:center;border-radius:999px;
background:transparent;border:1px solid transparent;color:var(--dim)}
#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
#attach:active{transform:scale(.9)}
#attach svg{display:block}
/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte.
Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de
dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant
le rembourrage. La carte ne bouge donc pas — c'est la bande vide qui se
remplit, au lieu du composeur qui grandit vers le haut.
Elles ne vont PAS dans le pied : cette ligne appartient au nom du modèle. */
#attach-list{order:0;display:none;gap:6px;margin:0 var(--col);padding:0 2px 8px;
overflow-x:auto;scrollbar-width:none}
#attach-list::-webkit-scrollbar{display:none}
#attach-list.show{display:flex}
#composer:has(#attach-list.show){padding-top:6px}
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
cadre, sinon le texte de la conversation transparaît derrière. */
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
box-shadow:0 2px 10px rgba(0,0,0,.10)}
/* Pastille de fichier. Le × n'apparaît qu'au survol : au repos la pastille doit
rester lisible d'un coup d'œil (nom + poids), pas encombrée d'un bouton. */
.chip-file{display:flex;align-items:center;gap:8px;max-width:230px;min-width:0;
padding:4px 12px;border-radius:999px;background:var(--row-bg-on);
border:1px solid transparent;font-size:12px;line-height:1.3}
/* Avec un × à droite, la pastille se ferme SUR lui : le rembourrage de droite
revient à l'épaisseur du rond, sinon le bouton flotte loin du bord et la
pastille paraît de travers. */
.chip-file:has(button){padding-right:4px}
.chip-file .cf-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chip-file .cf-size{flex:none;color:var(--dim);font-size:11px}
/* Le × est TOUJOURS visible : le révéler au survol le rendait inatteignable sur
mobile, où il n'y a pas de survol — donc impossible de retirer un fichier. */
.chip-file button{flex:none;margin:0;padding:0;width:20px;height:20px;font-size:14px;
display:flex;align-items:center;justify-content:center;line-height:1;
border-radius:50%;border:none;background:transparent;color:var(--dim)}
.chip-file button:hover{background:var(--bubble);color:var(--text)}
/* Transfert en cours : un liseré qui tourne, plutôt qu'une pastille grisée dont
on ne sait pas si elle travaille ou si elle a échoué. */
.chip-file.up .cf-size::after{content:"";display:inline-block;vertical-align:-2px;
margin-left:7px;width:10px;height:10px;border-radius:50%;
border:1.5px solid var(--border);border-top-color:var(--text);animation:spin .7s linear infinite}
.chip-file.err{background:var(--danger-bg);color:var(--err)}
.chip-file.err .cf-size{color:var(--err)}
@media (prefers-reduced-motion:reduce){.chip-file.up .cf-size::after{animation:none}}
/* Fichiers d'un message envoyé : leur propre rangée AU-DESSUS de la bulle,
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
une seule bulle difforme qui déborde vers le haut. */
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
gap:6px;max-width:75%}
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
#chat .msg .filelink{cursor:pointer}
#chat .msg .filelink::after{content:"↓";margin-left:5px;font-size:.9em;opacity:.7;text-decoration:none;display:inline-block}
#chat .msg .filelink.busy{opacity:.55;pointer-events:none}
/* Voile de dépôt. Il couvre la zone de conversation, curseur inclus : c'est la
réponse à « est-ce que je peux lâcher ici ? », elle doit être sans ambiguïté. */
#dropveil{position:absolute;inset:0;z-index:20;display:none;align-items:center;
justify-content:center;padding:24px;background:rgba(0,0,0,.28);pointer-events:none}
#dropveil.show{display:flex}
#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:20px;
background:var(--panel);color:var(--text);font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.18)}
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
button,.mem-search{border-radius:10px}
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
+5
View File
@@ -938,4 +938,9 @@ type chatReq struct {
// vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct).
Message string `json:"message"`
From int `json:"from"`
// Files = chemins relatifs des fichiers déposés juste avant par
// /api/chat/upload ("uploads/rapport.pdf"). Ils sont annoncés au modèle en
// tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et
// n'entre dans le contexte que si le modèle décide de le lire.
Files []string `json:"files"`
}
+4 -2
View File
@@ -92,11 +92,13 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if strings.TrimSpace(body.Message) == "" {
// Un envoi SANS texte mais AVEC pièce jointe est légitime (« tiens, regarde »).
files := attachFiles(body.Files)
if strings.TrimSpace(body.Message) == "" && len(files) == 0 {
sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"})
return
}
if err := conv.StartTurn(body.Message, capsFromBody(body), body.Temperature); err != nil {
if err := conv.StartTurn(body.Message, files, capsFromBody(body), body.Temperature); err != nil {
// 409 = occupé (génération en cours) ; 503 = modèle pas prêt.
code := 503
if err == ErrBusy {
+2
View File
@@ -181,6 +181,8 @@ func newWebMux() *http.ServeMux {
api("/api/bench/last", handleBenchLast)
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/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
api("/api/chat/reset", handleChatReset) // nouvelle conversation (pour tous les appareils)
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
+246
View File
@@ -0,0 +1,246 @@
// 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.
//
// 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
// corps JSON), donc l'accès distant marche sans code spécifique.
package ajean
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"unicode"
)
// uploadMaxBytes borne la taille d'UN fichier. Tout le chemin (corps JSON, E2E,
// écriture) passe par la mémoire : au-delà, un envoi maladroit ferait gonfler le
// process de plusieurs centaines de Mo. 24 Mo décodés ≈ 32 Mo de base64.
const uploadMaxBytes = 24 << 20
// uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent.
func uploadsDir() (string, error) {
dir := filepath.Join(agentWorkspace(), "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// safeUploadName réduit un nom fourni par le client à un nom de fichier simple :
// pas de dossier, pas de "..", pas de caractères de contrôle ni de séparateurs.
// C'est la seule barrière entre un client hostile et une écriture arbitraire sur
// le disque — l'API écoute sur 0.0.0.0 et n'a pas forcément de clé.
func safeUploadName(name string) string {
// Coupe tout ce qui ressemble à un chemin, dans les deux conventions : un nom
// Windows arrive tel quel sur un serveur Linux, où filepath.Base ne verrait
// pas les antislashs.
name = strings.ReplaceAll(name, "\\", "/")
name = filepath.Base(filepath.FromSlash(name))
var b strings.Builder
for _, r := range name {
switch {
case unicode.IsControl(r), r == '/', r == '\\', r == ':', r == '*',
r == '?', r == '"', r == '<', r == '>', r == '|':
b.WriteRune('_')
default:
b.WriteRune(r)
}
}
name = strings.TrimSpace(b.String())
name = strings.Trim(name, ".") // ".." et les noms cachés/vides
if name == "" {
name = "fichier"
}
// Un nom démesuré casse l'écriture sur certains systèmes de fichiers ; on
// tronque la BASE en gardant l'extension, qui porte le sens.
if len(name) > 120 {
ext := filepath.Ext(name)
if len(ext) > 16 {
ext = ""
}
name = name[:120-len(ext)] + ext
}
return name
}
// uniqueUploadPath évite d'écraser un dépôt précédent : rapport.pdf,
// rapport-2.pdf, rapport-3.pdf…
func uniqueUploadPath(dir, name string) string {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
return p
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 2; i < 1000; i++ {
p = filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext))
if _, err := os.Stat(p); os.IsNotExist(err) {
return p
}
}
return p
}
// attachInfo décrit une pièce jointe retenue : ce que le modèle lira dans le
// texte, et ce que l'UI affiche dans la bulle.
type attachInfo struct {
Name string `json:"name"` // nom seul, pour l'affichage
Path string `json:"path"` // "uploads/<nom>", ce que le modèle reçoit
Size int64 `json:"size"`
}
// attachFiles valide les chemins envoyés par le CLIENT : on les re-normalise en
// uploads/<nom sûr> et on jette ceux qui ne désignent pas un dépôt existant,
// plutôt que d'annoncer au modèle un fichier qu'il ne trouvera pas — ou de le
// laisser pointer ailleurs sur le disque.
func attachFiles(files []string) []attachInfo {
dir, err := uploadsDir()
if err != nil {
return nil
}
var out []attachInfo
for _, f := range files {
name := safeUploadName(f)
st, err := os.Stat(filepath.Join(dir, name))
if err != nil || st.IsDir() {
continue
}
out = append(out, attachInfo{Name: name, Path: "uploads/" + name, Size: st.Size()})
}
return out
}
// attachNote est la phrase ajoutée en tête du message pour le MODÈLE. Elle ne
// s'affiche pas dans le chat : la bulle porte des pastilles de fichier (delta
// `files`), parce qu'une consigne interne recopiée dans le fil se lit comme un
// message que l'utilisateur n'a pas écrit.
func attachNote(files []attachInfo) string {
if len(files) == 0 {
return ""
}
head := "Fichier joint à ce message, déposé dans ton dossier de travail :"
if len(files) > 1 {
head = "Fichiers joints à ce message, déposés dans ton dossier de travail :"
}
var lines []string
for _, f := range files {
lines = append(lines, fmt.Sprintf("- %s (%s)", f.Path, humanBytes(f.Size)))
}
return head + "\n" + strings.Join(lines, "\n") + "\n\n"
}
// workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et,
// si oui, renvoie son chemin relatif en séparateurs '/'.
//
// C'est le seul périmètre téléchargeable. L'agent peut écrire n'importe où quand
// 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
}
// handleChatFile sert un fichier du dossier de travail, en pièce jointe. Le
// client passe le chemin RELATIF que le modèle a écrit dans sa réponse.
func handleChatFile(w http.ResponseWriter, r *http.Request) {
rel := r.URL.Query().Get("path")
if strings.TrimSpace(rel) == "" {
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 {
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
return
}
st, err := os.Stat(abs)
if err != nil || st.IsDir() {
sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"})
return
}
name := filepath.Base(abs)
// Toujours en TÉLÉCHARGEMENT, jamais rendu : un .html écrit par le modèle ne
// doit pas s'exécuter dans l'origine de l'UI (il y lirait la clé de pilotage).
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(name))
http.ServeFile(w, r, abs)
}
type uploadReq struct {
Name string `json:"name"`
Data string `json:"data"` // base64 (accepte un data: URL complet)
}
// handleChatUpload écrit un fichier dans le workspace 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"})
return
}
data := body.Data
// Les clients qui passent par FileReader.readAsDataURL envoient
// "data:application/pdf;base64,JVBER…" : on ne garde que la charge utile.
if strings.HasPrefix(data, "data:") {
if i := strings.Index(data, ","); i >= 0 {
data = data[i+1:]
}
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data))
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "contenu illisible (base64 attendu)"})
return
}
if len(raw) == 0 {
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 {
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)
"abs": dest, // affiché à l'utilisateur, jamais renvoyé au serveur
"size": len(raw),
})
}
+164
View File
@@ -0,0 +1,164 @@
package ajean
import (
"encoding/base64"
"encoding/json"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// Le nom vient du client : aucun d'eux ne doit permettre d'écrire hors du
// dossier de dépôt.
func TestSafeUploadNameNeverEscapes(t *testing.T) {
for _, in := range []string{
"../../evil.sh", `..\..\evil.sh`, "/etc/passwd", `C:\Windows\system32\x.dll`,
"..", ".", "", " ", "a\x00b", "nul\nname",
} {
got := safeUploadName(in)
if got == "" || got == "." || got == ".." {
t.Fatalf("%q → %q : nom inutilisable", in, got)
}
if strings.ContainsAny(got, `/\`) {
t.Fatalf("%q → %q : contient un séparateur de chemin", in, got)
}
if filepath.Base(got) != got {
t.Fatalf("%q → %q : n'est pas un nom simple", in, got)
}
}
}
func TestSafeUploadNameKeepsOrdinaryNames(t *testing.T) {
for in, want := range map[string]string{
"rapport.pdf": "rapport.pdf",
"Compte rendu (final).odt": "Compte rendu (final).odt",
"données-été.csv": "données-été.csv",
`C:\Users\x\notes.md`: "notes.md",
} {
if got := safeUploadName(in); got != want {
t.Fatalf("safeUploadName(%q) = %q, attendu %q", in, got, want)
}
}
}
func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"name": "../notes.txt",
"data": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("bonjour")),
})
rec := httptest.NewRecorder()
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(body))))
if rec.Code != 200 {
t.Fatalf("code %d : %s", rec.Code, rec.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Path string `json:"path"`
Abs string `json:"abs"`
Size int `json:"size"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
defer os.Remove(resp.Abs)
if !resp.OK || resp.Size != 7 {
t.Fatalf("réponse inattendue: %+v", resp)
}
// Le fichier atterrit DANS uploads/, malgré le "../" du nom.
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
if filepath.Dir(resp.Abs) != dir {
t.Fatalf("écrit hors du dossier de dépôt : %s", resp.Abs)
}
if b, err := os.ReadFile(resp.Abs); err != nil || string(b) != "bonjour" {
t.Fatalf("contenu = %q, err=%v", b, err)
}
// Le chemin renvoyé est celui que le client rejoue dans /api/chat/send.
got := attachFiles([]string{resp.Path})
if len(got) != 1 || got[0].Path != resp.Path || got[0].Size != 7 {
t.Fatalf("attachFiles = %+v", got)
}
if note := attachNote(got); !strings.Contains(note, resp.Path) {
t.Fatalf("note sans le fichier : %q", note)
}
// Un chemin inventé par le client n'atteint jamais le modèle.
if n := attachFiles([]string{"uploads/jamais-deposé.bin", "../../etc/passwd"}); len(n) != 0 {
t.Fatalf("fichiers absents retenus : %+v", n)
}
if attachNote(nil) != "" {
t.Fatal("note non vide sans pièce jointe")
}
}
// 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) {
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "produit.txt")
if err := os.WriteFile(p, []byte("resultat"), 0o644); err != nil {
t.Fatal(err)
}
defer os.Remove(p)
rec := httptest.NewRecorder()
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path=uploads/produit.txt", nil))
if rec.Code != 200 || rec.Body.String() != "resultat" {
t.Fatalf("code %d, corps %q", rec.Code, rec.Body.String())
}
if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment;") {
t.Fatalf("pas en pièce jointe : %q", cd)
}
// Un .html produit par le modèle ne doit jamais s'exécuter dans l'origine de
// l'UI : il y lirait la clé de pilotage en localStorage.
if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
t.Fatalf("Content-Type = %q", ct)
}
// Le chemin vient du texte du modèle : il peut pointer n'importe où.
for _, bad := range []string{
"../ajean.db", "../../../etc/passwd", `..\..\secret`,
"/etc/passwd", "C:/Windows/win.ini", "", "uploads/absent.txt",
} {
rec := httptest.NewRecorder()
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(bad), nil))
if rec.Code == 200 {
t.Fatalf("chemin %q servi (code 200)", bad)
}
}
}
func TestChatUploadRejectsEmptyAndBadBase64(t *testing.T) {
for _, data := range []string{"", "pas du base64 !!"} {
body, _ := json.Marshal(map[string]any{"name": "x.bin", "data": data})
rec := httptest.NewRecorder()
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(body))))
if rec.Code != 400 {
t.Fatalf("data=%q : code %d, attendu 400", data, rec.Code)
}
}
}
// L'export doit garder la trace des pièces jointes : un tour envoyé SANS texte
// donnait sinon une section « Vous » vide, sans dire qu'un fichier était passé.
func TestExportFileNamesBothForms(t *testing.T) {
live := []attachInfo{{Name: "photo.jpg", Path: "uploads/photo.jpg", Size: 10}}
if got := exportFileNames(live); len(got) != 1 || got[0] != "photo.jpg" {
t.Fatalf("forme vivante : %v", got)
}
// Forme relue du journal persisté (JSON → []any de maps).
replayed := []any{map[string]any{"name": "photo.jpg", "path": "uploads/photo.jpg"}}
if got := exportFileNames(replayed); len(got) != 1 || got[0] != "photo.jpg" {
t.Fatalf("forme rejouée : %v", got)
}
if got := exportFileNames(nil); len(got) != 0 {
t.Fatalf("sans pièce jointe : %v", got)
}
}