mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
v0.9.4 : la vision configurable par preset (projecteur mmproj) et les images envoyées au modele
This commit is contained in:
1 parent
d4989f2991
commit
aeca75c547
12 files changed
+254
-31
No files matched your search
+13
-7
@@ -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,13 +3,13 @@
|
|||||||
"FileVersion": {
|
"FileVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 9,
|
"Minor": 9,
|
||||||
"Patch": 3,
|
"Patch": 4,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 9,
|
"Minor": 9,
|
||||||
"Patch": 3,
|
"Patch": 4,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
|
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
|
||||||
"OriginalFilename": "ajean.exe",
|
"OriginalFilename": "ajean.exe",
|
||||||
"ProductName": "AJEAN",
|
"ProductName": "AJEAN",
|
||||||
"ProductVersion": "0.9.3",
|
"ProductVersion": "0.9.4",
|
||||||
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
|
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ func cmdServe(args []string) error {
|
|||||||
if vtv != "" {
|
if vtv != "" {
|
||||||
llmArgs = append(llmArgs, "-ctv", 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 :
|
// Raisonnement. Trois cas, et la nuance compte :
|
||||||
//
|
//
|
||||||
// REASONING=on|auto|deepseek → --reasoning <valeur>
|
// REASONING=on|auto|deepseek → --reasoning <valeur>
|
||||||
|
|||||||
@@ -78,10 +78,34 @@ func ctxWindow() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// msgText extrait le texte d'un message (Content est `any`, en pratique string
|
// 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 {
|
func msgText(m Message) string {
|
||||||
if s, ok := m.Content.(string); ok {
|
switch v := m.Content.(type) {
|
||||||
return s
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,7 +287,9 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
|||||||
if strings.TrimSpace(prompt) == "" {
|
if strings.TrimSpace(prompt) == "" {
|
||||||
prompt = "Prends-en connaissance."
|
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
|
epoch := c.epoch
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"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).
|
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
|
||||||
func Main() {
|
func Main() {
|
||||||
|
|||||||
@@ -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-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>
|
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
|
||||||
</div>
|
</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">
|
<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">
|
<div class="pe-inpbtn">
|
||||||
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
|
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
|
||||||
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
|
<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();
|
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||||
populateSettings();
|
populateSettings();
|
||||||
attachDownload(); // téléchargement encore en cours côté serveur ?
|
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;
|
if(seq !== openSeq) return;
|
||||||
}
|
}
|
||||||
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
|
// 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);
|
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é)
|
// 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
|
// 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.
|
// 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.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%';
|
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||||||
stop();
|
stop();
|
||||||
await Promise.all([populateModelPicker(), populateDlDirs()]);
|
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||||
// Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le
|
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
|
||||||
// chemin complet, pas le simple nom de fichier.
|
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
|
||||||
const sel = document.getElementById('m-model');
|
// a pu atterrir hors du dossier ajean : l'option porte alors le chemin
|
||||||
const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname));
|
// complet, pas le simple nom de fichier.
|
||||||
if(opt){ sel.value = opt.value; onPickModel(); }
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const pct = st.total>0 ? st.done*100/st.total : 0;
|
const pct = st.total>0 ? st.done*100/st.total : 0;
|
||||||
|
|||||||
@@ -624,8 +624,12 @@
|
|||||||
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
|
<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>
|
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
|
||||||
</div>
|
</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">
|
<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">
|
<div class="pe-inpbtn">
|
||||||
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
|
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
|
||||||
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
|
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function openItem(kind, key){
|
|||||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||||
populateSettings();
|
populateSettings();
|
||||||
attachDownload(); // téléchargement encore en cours côté serveur ?
|
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;
|
if(seq !== openSeq) return;
|
||||||
}
|
}
|
||||||
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
|
// 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);
|
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é)
|
// 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
|
// 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.
|
// 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.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%';
|
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||||||
stop();
|
stop();
|
||||||
await Promise.all([populateModelPicker(), populateDlDirs()]);
|
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||||
// Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le
|
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
|
||||||
// chemin complet, pas le simple nom de fichier.
|
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
|
||||||
const sel = document.getElementById('m-model');
|
// a pu atterrir hors du dossier ajean : l'option porte alors le chemin
|
||||||
const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname));
|
// complet, pas le simple nom de fichier.
|
||||||
if(opt){ sel.value = opt.value; onPickModel(); }
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const pct = st.total>0 ? st.done*100/st.total : 0;
|
const pct = st.total>0 ? st.done*100/st.total : 0;
|
||||||
|
|||||||
@@ -155,6 +155,72 @@ func attachNote(files []attachInfo) string {
|
|||||||
return head + "\n" + strings.Join(lines, "\n") + "\n\n"
|
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,
|
// workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et,
|
||||||
// si oui, renvoie son chemin relatif en séparateurs '/'.
|
// si oui, renvoie son chemin relatif en séparateurs '/'.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in new issue
Block a user