Reprise réseau, presets externes, see_image

Trois apports repérés chez AJEAN (v0.13.8) et OpenFox (2.0.118), portés et
adaptés à Loki.

## Reprise réseau du tour (llm_retry_net.go)

La requête de complétion partait une fois : un Do() qui échoue ou un statut
d'erreur tuait le tour. Les messages d'erreur le disaient eux-mêmes —
« réessaie dans quelques secondes » — autrement dit on demandait à
l'utilisateur de refaire à la main ce que le code pouvait faire seul. Une
tâche planifiée tombée pendant un redémarrage du moteur échouait pour de
bon, sans personne pour recliquer.

Trois reprises consécutives, 0,8 → 1,6 → 3,2 s, plafonnées, interruptibles
par un /stop. La règle de sûreté ne souffre pas d'exception : on ne rejoue
que TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ, sinon la moitié de la réponse
déjà chez l'utilisateur serait dupliquée. Le compteur repart à zéro dès
qu'une réponse arrive.

500 n'est pas un statut de reprise : c'est ce que llama.cpp rend pour un
appel d'outil malformé ou un prompt trop long, deux échecs déterministes
que les filets sémantiques traitent déjà. Restent les codes qui disent
« pas maintenant » : 429, 502, 503, 504.

## Presets externes (backend_external.go, web_external.go)

Un preset avec EXTERNAL=1 route le chat vers une API OpenAI-compatible
distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre machine) au lieu
du llama-server local. C'est un preset COMME UN AUTRE : même liste, même
bascule, même prompt système par preset. La différence ne vit qu'à deux
endroits — l'inférence (resolveChatEndpoint) et la bascule, qui arrête le
moteur local au lieu de le redémarrer.

La clé du serveur local ne part jamais chez un tiers : chaque endpoint
porte la sienne. La clé du preset n'est jamais renvoyée en clair à
l'interface, et un champ vide ne l'efface pas — il faut y avoir touché.
Une fenêtre dédiée plutôt que l'éditeur habituel : un modèle distant n'a
ni quantification, ni couches GPU, ni moteur. Un bouton teste la connexion
avant d'enregistrer, et rend le message de l'API plutôt que le JSON brut.

Le résumé de compactage part au même endroit que le chat : le laisser
taper le moteur local aurait cassé toute compaction sur un preset externe.

## see_image (chat_vision_tool.go)

Loki savait voir une pièce jointe et une capture qu'il venait de prendre,
mais pas un fichier qui dort sur le disque : « regarde ~/photos/bug.png »
n'avait aucune réponse, `read` rendant des octets binaires. L'outil charge
l'image et la réinjecte dans un message utilisateur multimodal — même
chemin que les pièces jointes.

Même règle ÉPHÉMÈRE que les captures (et non celle de l'amont, qui persiste
l'image) : l'image va dans le tour en cours, pas dans l'historique. Un
base64 persisté repartirait à chaque tour et finirait par dépasser la
fenêtre pour de bon.

Le marqueur de perte à la compaction existait déjà mais n'offrait qu'un
recours, « reprends la capture » — ce qui enverrait photographier une page
web alors que l'image perdue est un PNG du disque. Formulation généralisée.

## Au passage

toolCallLabel est extrait de runChat. Cette table nom d'outil → argument a
une double fonction — libellé affiché ET argument principal — donc un outil
absent s'exécute sur une chaîne vide : see_image répondait « chemin de
fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne bronche.
Une table pareille se teste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
This commit is contained in:
Claude committed 2026-09-12 21:05:19 +00:00
1 parent d1626781f9
commit b5904907e8
18 files changed
+1677 -64

No files matched your search

+14 -1
View File
@@ -69,6 +69,16 @@ async function loadPresets(){
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
// Second row: quant tag + bench perf, so the title row stays full-width.
const meta=document.createElement('div'); meta.className='preset-meta';
// Preset externe : ni quant ni bench à montrer — le modèle tourne ailleurs.
// On affiche à la place d'où vient la réponse, sans quoi rien à l'écran ne
// distinguerait un preset local d'un appel qui part sur internet.
if(x.external){
const et=document.createElement('span'); et.className='xtag';
et.title='API externe — le chat part vers un serveur distant';
et.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/></svg>';
et.appendChild(document.createTextNode(x.model || 'externe'));
meta.appendChild(et);
}
if(x.quant){
const q=document.createElement('span'); q.className='qtag';
q.textContent=x.quant; q.title='quantization';
@@ -94,7 +104,10 @@ async function loadPresets(){
// lit comme centrée dans son coin, là où le crayon penché tirait de travers.
edit.className='preset-edit'; edit.title='réglages du preset';
edit.innerHTML='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); };
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
row.appendChild(info); row.appendChild(edit);
cont.appendChild(row);
});
+100
View File
@@ -0,0 +1,100 @@
// 11-external.js — presets « externes » : le chat part vers une API
// OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre
// machine…) au lieu du llama-server local.
//
// Un preset externe vit dans la même liste que les autres et se bascule pareil.
// Mais ses réglages n'ont rien de commun avec ceux d'un modèle local — ni
// quantification, ni couches GPU, ni moteur, ni projecteur vision : la machine
// distante décide de tout ça. D'où cette fenêtre à part, plutôt qu'un éditeur de
// preset aux trois quarts grisé.
// La clé n'est JAMAIS renvoyée en clair par le serveur : le champ s'affiche
// vide même quand une clé est enregistrée. Un champ vide ne vaut donc pas
// « efface la clé » — il faut y avoir touché. Sans ce drapeau, rouvrir la
// fenêtre pour corriger une faute de frappe dans l'URL effaçait la clé au
// passage, et le preset tombait en 401 au message suivant.
let extKeyTouched = false;
let extEditing = '';
function closeExternal(){ hideModal('ext-modal'); }
async function openExternal(id){
extEditing = id || '';
extKeyTouched = false;
const set = (el, v) => { const e = document.getElementById(el); if(e) e.value = v; };
document.getElementById('ext-title').textContent = id ? 'API externe' : 'Nouvelle API externe';
document.getElementById('ext-del').style.display = id ? '' : 'none';
document.getElementById('ext-test-out').textContent = '';
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
showModal('ext-modal');
if(!id) return;
let d = {};
try{ d = await jget('/api/preset/external?id=' + encodeURIComponent(id)); }catch(_){ return; }
if(extEditing !== id) return; // une autre ouverture a pris la main
set('ext-name', d.name || ''); set('ext-url', d.url || '');
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
if(d.hasKey){
document.getElementById('ext-key-sub').textContent =
'une clé est enregistrée — laisse vide pour la conserver';
}
}
// Le corps commun aux deux routes (enregistrement et test).
function extPayload(){
return {
id: extEditing,
name: (document.getElementById('ext-name').value || '').trim(),
url: (document.getElementById('ext-url').value || '').trim(),
model: (document.getElementById('ext-model').value || '').trim(),
key: document.getElementById('ext-key').value || '',
ctx: (document.getElementById('ext-ctx').value || '').trim(),
keyTouched: extKeyTouched,
};
}
// Tester AVANT d'enregistrer n'est pas un confort : une URL mal collée ou une
// clé expirée ne se voit sinon qu'au premier message, sous la forme d'un code
// HTTP nu au milieu d'une conversation.
async function testExternal(){
const out = document.getElementById('ext-test-out');
const btn = document.getElementById('ext-test');
const p = extPayload();
if(!p.url || !p.model){ out.innerHTML = '<span style="color:var(--err)">URL et modèle requis</span>'; return; }
btn.disabled = true;
out.textContent = 'appel de test en cours…';
let r = {};
try{ r = await jpost('/api/preset/external/test', p); }
catch(_){ r = {ok:false, error:'réseau'}; }
btn.disabled = false;
out.innerHTML = r.ok
? '<span style="color:var(--ok)">✓ l’API répond</span>'
: '<span style="color:var(--err)">' + escHtml((r.status ? r.status + ' — ' : '') + (r.error || 'échec')) + '</span>';
}
async function saveExternal(){
const p = extPayload();
if(!p.name){ toast('nom requis'); return; }
if(!p.url){ toast('URL requise'); return; }
if(!p.model){ toast('modèle requis'); return; }
const r = await jpost('/api/preset/external/save', p);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('enregistré');
closeExternal();
loadPresets();
}
// Même route de suppression que n'importe quel preset — et même refus si c'est
// celui en service. Pas de case « supprimer aussi le .gguf » : il n'y a pas de
// fichier, le modèle est chez quelqu'un d'autre.
async function deleteExternal(){
if(!extEditing) return;
const name = (document.getElementById('ext-name').value || '').trim() || extEditing;
if(!await askConfirm('Supprimer le preset « ' + name + ' » ?',
{title:'Suppression', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/preset/delete', {id: extEditing});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('supprimé');
closeExternal();
loadPresets();
}