diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c6d7b1d..82dd3ed 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index b6a983d..c4cda35 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index c217d2b..ea205d7 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 8e11ce9..401d93c 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -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": { diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index cac150f..4b745c4 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -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 diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go index 75de2c3..3c24789 100644 --- a/internal/ajean/chat_export.go +++ b/internal/ajean/chat_export.go @@ -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 +} diff --git a/internal/ajean/chat_internet_tools.go b/internal/ajean/chat_internet_tools.go index cccf4cb..438fd45 100644 --- a/internal/ajean/chat_internet_tools.go +++ b/internal/ajean/chat_internet_tools.go @@ -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"}, }, diff --git a/internal/ajean/chat_prompt_size_test.go b/internal/ajean/chat_prompt_size_test.go new file mode 100644 index 0000000..fde8d5c --- /dev/null +++ b/internal/ajean/chat_prompt_size_test.go @@ -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) + } +} diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go index 76a0743..228980c 100644 --- a/internal/ajean/chat_tools.go +++ b/internal/ajean/chat_tools.go @@ -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 != "" { diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index 0c4b198..18f0f2e 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -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"}, diff --git a/internal/ajean/run.go b/internal/ajean/run.go index dcd75c7..ede13eb 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -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() { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 5f6fff8..bc02ee4 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -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)}