v0.9.4 : la vision configurable par preset (projecteur mmproj) et les images envoyées au modele

This commit is contained in:
nathaninline committed 2026-08-14 15:11:08 +02:00
1 parent d4989f2991
commit aeca75c547
12 files changed
+254 -31

No files matched your search

+13 -7
View File
@@ -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.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -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": {
+17
View File
@@ -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 <valeur>
+27 -3
View File
@@ -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 ""
}
+3 -1
View File
@@ -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()
+1 -1
View File
@@ -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() {
+62 -8
View File
@@ -1794,8 +1794,12 @@ button:hover{border-color:var(--dim);color:var(--text)}
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">lien direct vers un .gguf</span></span>
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">ou un projecteur mmproj — lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
@@ -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 = '<option value="">— aucune —</option>';
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 += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
// 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 += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
}
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 = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
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;
+5 -1
View File
@@ -624,8 +624,12 @@
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">lien direct vers un .gguf</span></span>
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">ou un projecteur mmproj — lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
+57 -7
View File
@@ -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 = '<option value="">— aucune —</option>';
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 += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
// 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 += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
}
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 = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
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;
+66
View File
@@ -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 '/'.
//