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

+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 '/'.
//