diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ccad0c7..0faa042 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,13 +1,19 @@ -Quelques réglages d'interface pour une lecture plus claire, et un statut du moteur enfin honnête. +La grande nouveauté de cette version : AJEAN sait enfin voir les images. -## Un statut du moteur plus juste +## La vision, configurable par preset -La pastille d'état affichait « modèle incompatible » dès qu'un chargement échouait — même quand la vraie cause était ailleurs. Désormais elle reste courte et neutre (**prêt**, **chargement…**, **erreur**, **arrêté**), et le message détaillé en dessous n'accuse le moteur d'incompatibilité **que lorsque le journal le montre vraiment** (quantification ou architecture non reconnue). Pour tout autre échec, il renvoie simplement au journal du moteur au lieu de donner une fausse piste. +Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul. -## Panneau « Actions » réorganisé +Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision. -Le bouton « refresh », qui ne servait à rien, est retiré. Les actions sont regroupées deux par deux : **mise à jour** et **exporter** sur une ligne, **bench** et **effacer la conversation** sur l'autre. +## Les images arrivent vraiment au modèle -## Petits nettoyages +Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler. -Le titre de l'éditeur de preset n'affiche plus le mot « Preset » redondant (juste le nom), et le mode mémoire automatique s'appelle simplement « auto ». +## Mise à jour + +``` +ajean update +``` + +Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 732983b..f07048d 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 8cd1a12..c92084b 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 6760f6b..63296b7 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 9, - "Patch": 3, + "Patch": 4, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 9, - "Patch": 3, + "Patch": 4, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.9.3", + "ProductVersion": "0.9.4", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index 5d2e767..ebab289 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -116,6 +116,23 @@ func cmdServe(args []string) error { if vtv != "" { llmArgs = append(llmArgs, "-ctv", vtv) } + // Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle. + // C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout + // comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin + // absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que + // le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier. + // Introuvable = on préfère le dire clairement plutôt que laisser llama-server + // mourir en boucle sur un « failed to load mmproj » cryptique. + if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" { + mmPath, err := resolveServeModelPath(mm) + if err != nil { + return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err) + } + if _, err := os.Stat(mmPath); err != nil { + return fmt.Errorf("projecteur vision introuvable : %s", mmPath) + } + llmArgs = append(llmArgs, "--mmproj", mmPath) + } // Raisonnement. Trois cas, et la nuance compte : // // REASONING=on|auto|deepseek → --reasoning diff --git a/internal/ajean/chat_compact.go b/internal/ajean/chat_compact.go index 21bbf18..f5903a8 100644 --- a/internal/ajean/chat_compact.go +++ b/internal/ajean/chat_compact.go @@ -78,10 +78,34 @@ func ctxWindow() int { } // msgText extrait le texte d'un message (Content est `any`, en pratique string -// ou nil quand l'assistant n'a que des tool_calls). +// ou nil quand l'assistant n'a que des tool_calls). Un message multimodal +// (userMessageContent : parties texte + image quand la vision est active) porte +// un tableau de parties ; on en recolle les segments `text` pour que l'estimation +// de contexte et le transcript de compaction ne partent pas d'un texte vide. func msgText(m Message) string { - if s, ok := m.Content.(string); ok { - return s + switch v := m.Content.(type) { + case string: + return v + case []map[string]any: + var b strings.Builder + for _, part := range v { + if part["type"] == "text" { + if s, ok := part["text"].(string); ok { + b.WriteString(s) + } + } + } + return b.String() + case []any: // même contenu relu depuis le JSON persisté (map générique) + var b strings.Builder + for _, p := range v { + if part, ok := p.(map[string]any); ok && part["type"] == "text" { + if s, ok := part["text"].(string); ok { + b.WriteString(s) + } + } + } + return b.String() } return "" } diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index 4b745c4..e5edd14 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -287,7 +287,9 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem if strings.TrimSpace(prompt) == "" { prompt = "Prends-en connaissance." } - c.Messages = append(c.Messages, Message{Role: "user", Content: attachNote(files) + prompt}) + // Content = simple texte d'ordinaire ; format multimodal (texte + images) quand + // la vision est active et qu'une pièce jointe est une image (userMessageContent). + c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)}) epoch := c.epoch c.mu.Unlock() diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 536669e..95058ff 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.9.3" +const Version = "0.9.4" // 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 d7f6ef6..364c8dc 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1794,8 +1794,12 @@ button:hover{border-color:var(--dim);color:var(--text)} Quantizationauto si vide +
+ Visionprojecteur mmproj + +
- Télécharger un modèle lien direct vers un .gguf + Télécharger un modèle ou un projecteur mmproj — lien direct vers un .gguf
@@ -3096,7 +3100,7 @@ async function openItem(kind, key){ document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); attachDownload(); // téléchargement encore en cours côté serveur ? - await Promise.all([populateBackend(), populateModelPicker(), populateDlDirs()]); + await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]); if(seq !== openSeq) return; } // On lève le voile SANS transition (voir la note dans styles.css), puis on rend @@ -3258,6 +3262,50 @@ function onPickModel(){ toast('MODEL='+val); } +// --- Vision (projecteur multimodal --mmproj) -------------------------------- +// Le projecteur est un .gguf séparé du modèle : il apparaît donc dans la même +// liste que les modèles (/api/models). On le range dans sa PROPRE clé MMPROJ, +// que backend_serve.go traduit en --mmproj — plutôt que dans EXTRA_ARGS, pour +// que le chemin soit résolu comme celui du modèle (nom simple = cherché dans +// les dossiers déclarés). +function currentMmprojInTextarea(){ + // Clé dédiée d'abord ; à défaut, un --mmproj posé à la main dans EXTRA_ARGS + // (anciens presets), pour que le champ montre la vision déjà configurée. + return readEnvKey(document.getElementById('m-content').value, 'MMPROJ') + || eaGetValued('--mmproj'); +} +function onPickMmproj(){ + // On retire un éventuel --mmproj d'EXTRA_ARGS pour ne pas le charger deux fois, + // et on centralise le choix dans la clé MMPROJ. + eaSetValued('--mmproj', ''); + cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value); +} +// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la +// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là — +// mêler les modèles de plusieurs Go n'aiderait pas à choisir des yeux. +function isMmprojName(name){ return /mmproj/i.test(String(name||'')); } +async function populateMmproj(){ + const sel = document.getElementById('m-mmproj'); + if(!sel) return; + const list = await jget('/api/models'); + const cur = currentMmprojInTextarea(); + const items = (list||[]).filter(m => isMmprojName(m.name)); + let html = ''; + let matched = false; + for(const m of items){ + const on = samePath(cur, m.value) || samePath(cur, m.path) || + samePath(baseName(cur), m.name) ? ' selected' : ''; + if(on) matched = true; + html += ''; + } + // MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors + // convention) : on le garde affiché plutôt que de l'effacer silencieusement. + if(cur && !matched){ + html += ''; + } + sel.innerHTML = html; +} + // Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé) // qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel // backend fait tourner ce modèle — la barre latérale ne fait qu'installer. @@ -3834,12 +3882,18 @@ function watchDownload(fname){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); - await Promise.all([populateModelPicker(), populateDlDirs()]); - // Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le - // chemin complet, pas le simple nom de fichier. - const sel = document.getElementById('m-model'); - const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); - if(opt){ sel.value = opt.value; onPickModel(); } + await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]); + // Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans + // le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier + // a pu atterrir hors du dossier ajean : l'option porte alors le chemin + // complet, pas le simple nom de fichier. + const pick = (id, cb)=>{ + const s = document.getElementById(id); + const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname)); + if(o){ s.value = o.value; cb(); } + }; + if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj); + else pick('m-model', onPickModel); return; } const pct = st.total>0 ? st.done*100/st.total : 0; diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 88651c7..57970e4 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -624,8 +624,12 @@ Quantizationauto si vide
+
+ Visionprojecteur mmproj + +
- Télécharger un modèle lien direct vers un .gguf + Télécharger un modèle ou un projecteur mmproj — lien direct vers un .gguf
diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 1f8ab55..da38866 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -153,7 +153,7 @@ async function openItem(kind, key){ document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); attachDownload(); // téléchargement encore en cours côté serveur ? - await Promise.all([populateBackend(), populateModelPicker(), populateDlDirs()]); + await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]); if(seq !== openSeq) return; } // On lève le voile SANS transition (voir la note dans styles.css), puis on rend @@ -315,6 +315,50 @@ function onPickModel(){ toast('MODEL='+val); } +// --- Vision (projecteur multimodal --mmproj) -------------------------------- +// Le projecteur est un .gguf séparé du modèle : il apparaît donc dans la même +// liste que les modèles (/api/models). On le range dans sa PROPRE clé MMPROJ, +// que backend_serve.go traduit en --mmproj — plutôt que dans EXTRA_ARGS, pour +// que le chemin soit résolu comme celui du modèle (nom simple = cherché dans +// les dossiers déclarés). +function currentMmprojInTextarea(){ + // Clé dédiée d'abord ; à défaut, un --mmproj posé à la main dans EXTRA_ARGS + // (anciens presets), pour que le champ montre la vision déjà configurée. + return readEnvKey(document.getElementById('m-content').value, 'MMPROJ') + || eaGetValued('--mmproj'); +} +function onPickMmproj(){ + // On retire un éventuel --mmproj d'EXTRA_ARGS pour ne pas le charger deux fois, + // et on centralise le choix dans la clé MMPROJ. + eaSetValued('--mmproj', ''); + cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value); +} +// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la +// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là — +// mêler les modèles de plusieurs Go n'aiderait pas à choisir des yeux. +function isMmprojName(name){ return /mmproj/i.test(String(name||'')); } +async function populateMmproj(){ + const sel = document.getElementById('m-mmproj'); + if(!sel) return; + const list = await jget('/api/models'); + const cur = currentMmprojInTextarea(); + const items = (list||[]).filter(m => isMmprojName(m.name)); + let html = ''; + let matched = false; + for(const m of items){ + const on = samePath(cur, m.value) || samePath(cur, m.path) || + samePath(baseName(cur), m.name) ? ' selected' : ''; + if(on) matched = true; + html += ''; + } + // MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors + // convention) : on le garde affiché plutôt que de l'effacer silencieusement. + if(cur && !matched){ + html += ''; + } + sel.innerHTML = html; +} + // Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé) // qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel // backend fait tourner ce modèle — la barre latérale ne fait qu'installer. @@ -891,12 +935,18 @@ function watchDownload(fname){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); - await Promise.all([populateModelPicker(), populateDlDirs()]); - // Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le - // chemin complet, pas le simple nom de fichier. - const sel = document.getElementById('m-model'); - const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); - if(opt){ sel.value = opt.value; onPickModel(); } + await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]); + // Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans + // le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier + // a pu atterrir hors du dossier ajean : l'option porte alors le chemin + // complet, pas le simple nom de fichier. + const pick = (id, cb)=>{ + const s = document.getElementById(id); + const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname)); + if(o){ s.value = o.value; cb(); } + }; + if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj); + else pick('m-model', onPickModel); return; } const pct = st.total>0 ? st.done*100/st.total : 0; diff --git a/internal/ajean/web_upload.go b/internal/ajean/web_upload.go index a0e2910..f95141b 100644 --- a/internal/ajean/web_upload.go +++ b/internal/ajean/web_upload.go @@ -155,6 +155,72 @@ func attachNote(files []attachInfo) string { return head + "\n" + strings.Join(lines, "\n") + "\n\n" } +// imageMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme image (contenu +// multimodal), quand la vision est active. Un format hors de cette liste reste +// un simple fichier du workspace, que le modèle ouvre avec ses outils. +var imageMimes = map[string]string{ + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", +} + +// imageMime renvoie le type MIME image d'un nom de fichier, ou "" si ce n'est +// pas une image qu'on sait montrer au modèle. +func imageMime(name string) string { + return imageMimes[strings.ToLower(filepath.Ext(name))] +} + +// visionEnabled dit si un projecteur multimodal est configuré (clé MMPROJ) — +// c'est la condition pour que backend_serve.go passe --mmproj au moteur, donc la +// seule où envoyer une image AU MODÈLE a un sens. Sans projecteur, llama-server +// rejetterait un contenu image ; on s'en tient alors au dépôt-fichier. +func visionEnabled() bool { + return strings.TrimSpace(ReadConfig()["MMPROJ"]) != "" +} + +// userMessageContent construit le champ Content du message utilisateur. Cas +// courant : une simple chaîne (note des fichiers joints + texte). Quand la vision +// est active ET qu'au moins une pièce jointe est une image, on renvoie le format +// multimodal d'OpenAI — une partie `text` suivie d'une partie `image_url` par +// image (data URI base64) — que llama-server comprend une fois --mmproj chargé : +// le modèle VOIT alors l'image au lieu de devoir l'ouvrir comme un fichier binaire. +// Les images ainsi intégrées sortent de la note textuelle (inutile de dire au +// modèle d'aller ouvrir un fichier qu'il a déjà sous les yeux) ; les autres +// fichiers, eux, restent annoncés comme avant. +func userMessageContent(files []attachInfo, prompt string) any { + if !visionEnabled() { + return attachNote(files) + prompt + } + dir, err := uploadsDir() + if err != nil { + return attachNote(files) + prompt + } + var imgParts []map[string]any + var textFiles []attachInfo + for _, f := range files { + mime := imageMime(f.Name) + if mime == "" { + textFiles = append(textFiles, f) + continue + } + b, err := os.ReadFile(filepath.Join(dir, f.Name)) + if err != nil { + textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier + continue + } + imgParts = append(imgParts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), + }, + }) + } + if len(imgParts) == 0 { + return attachNote(files) + prompt + } + parts := []map[string]any{{"type": "text", "text": attachNote(textFiles) + prompt}} + return append(parts, imgParts...) +} + // workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et, // si oui, renvoie son chemin relatif en séparateurs '/'. //