mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +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": {
|
||||
"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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 '/'.
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user