mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 13:38:11 +02:00
Presets externes : vision, compactage, bench et badge pour une API distante
La reprise faite en parallèle sur l'autre branche (b667fd4, jamais poussée) couvrait des trous de celle-ci. Ses ajouts, posés sur la version en place : - chat_template_kwargs, propre à llama.cpp, ne part plus vers une API distante — ni au chat ni au résumé de compactage. Une API stricte (OpenAI) répond 400 à un argument inconnu : toute compaction échouait. - Vision : case « le modèle accepte les images » (EXTERNAL_VISION) dans la fenêtre API externe. Elle remplace, en externe, MMPROJ et la sonde /props : un modèle distant multimodal ne pouvait jamais recevoir d'image. - Le benchmark refuse de tourner sur un preset externe : healthCheck le dit prêt sans moteur, et la mesure tapait un port arrêté ou un moteur resté en vie, attribuée à tort à ce preset. - Le badge de modèle des réponses porte le nom du modèle distant. - Éditer le preset en service l'applique aussitôt (SavePresetApplying). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48b8193dca
commit
2db0ecd431
12 files changed
+124
-17
No files matched your search
@@ -24,10 +24,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||||
|
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
|
||||||
)
|
)
|
||||||
|
|
||||||
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
|
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
|
||||||
@@ -46,6 +47,14 @@ func isExternalConfig(cfg map[string]string) bool {
|
|||||||
// externalActive : le preset actif est-il un endpoint externe ?
|
// externalActive : le preset actif est-il un endpoint externe ?
|
||||||
func externalActive() bool { return isExternalConfig(ReadConfig()) }
|
func externalActive() bool { return isExternalConfig(ReadConfig()) }
|
||||||
|
|
||||||
|
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
|
||||||
|
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
|
||||||
|
// preset dit si le modèle accepte les images.
|
||||||
|
func externalVisionActive() bool {
|
||||||
|
cfg := ReadConfig()
|
||||||
|
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
// completionsURL normalise l'URL saisie en une URL de complétions complète.
|
// completionsURL normalise l'URL saisie en une URL de complétions complète.
|
||||||
// Accepte les trois formes qu'on colle en pratique :
|
// Accepte les trois formes qu'on colle en pratique :
|
||||||
//
|
//
|
||||||
@@ -102,8 +111,9 @@ func (e chatEndpoint) auth(set func(k, v string)) {
|
|||||||
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
|
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
|
||||||
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
|
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
|
||||||
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
|
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
|
||||||
// compaction, exactement comme pour un preset local.
|
// compaction, exactement comme pour un preset local. vision = le modèle distant
|
||||||
func externalPresetContent(url, model, key, ctx string) string {
|
// accepte les images (EXTERNAL_VISION=1).
|
||||||
|
func externalPresetContent(url, model, key, ctx string, vision bool) string {
|
||||||
m := map[string]string{
|
m := map[string]string{
|
||||||
extKeyFlag: "1",
|
extKeyFlag: "1",
|
||||||
extKeyURL: strings.TrimSpace(url),
|
extKeyURL: strings.TrimSpace(url),
|
||||||
@@ -112,6 +122,9 @@ func externalPresetContent(url, model, key, ctx string) string {
|
|||||||
if k := strings.TrimSpace(key); k != "" {
|
if k := strings.TrimSpace(key); k != "" {
|
||||||
m[extKeyToken] = k
|
m[extKeyToken] = k
|
||||||
}
|
}
|
||||||
|
if vision {
|
||||||
|
m[extKeyVision] = "1"
|
||||||
|
}
|
||||||
if c := strings.TrimSpace(ctx); c != "" {
|
if c := strings.TrimSpace(ctx); c != "" {
|
||||||
m["CTX"] = c
|
m["CTX"] = c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package loki
|
package loki
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -46,7 +47,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
|
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
ep = resolveChatEndpoint()
|
ep = resolveChatEndpoint()
|
||||||
@@ -71,7 +72,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
|
|||||||
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
|
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
|
||||||
testHome(t)
|
testHome(t)
|
||||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||||
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
|
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
|
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
|
||||||
@@ -142,7 +143,7 @@ func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
|
|||||||
// d'effacement.
|
// d'effacement.
|
||||||
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||||
testHome(t)
|
testHome(t)
|
||||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
|
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -179,7 +180,7 @@ func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
|||||||
// Un preset externe demande explicitement d'écraser la clé par une vide.
|
// Un preset externe demande explicitement d'écraser la clé par une vide.
|
||||||
func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||||
testHome(t)
|
testHome(t)
|
||||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
|
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -194,3 +195,51 @@ func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
|||||||
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
|
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
|
||||||
|
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
|
||||||
|
// échouait à chaque fois.
|
||||||
|
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if _, ok := body["chat_template_kwargs"]; ok {
|
||||||
|
w.WriteHeader(400)
|
||||||
|
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
|
||||||
|
t.Fatalf("compactage refusé par l'API distante : %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vision et benchmark d'un preset externe : la case du preset décide, et le
|
||||||
|
// bench (qui mesure le moteur local) refuse de tourner.
|
||||||
|
func TestPresetExterneVisionEtBench(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !visionEnabled() || !engineSeesImages() {
|
||||||
|
t.Error("preset externe déclaré multimodal : la vision devrait être active")
|
||||||
|
}
|
||||||
|
if _, err := runBench(10, 10); err == nil {
|
||||||
|
t.Error("benchmark lancé sur un preset externe")
|
||||||
|
}
|
||||||
|
if got := chatModelName(); got != "gpt-4o" {
|
||||||
|
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
|
||||||
|
}
|
||||||
|
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if visionEnabled() {
|
||||||
|
t.Error("preset externe texte seul : la vision devrait être coupée")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -605,6 +605,11 @@ Write the summary in the SAME language as the conversation.`
|
|||||||
// passe ces kwargs au gabarit Jinja (--jinja).
|
// passe ces kwargs au gabarit Jinja (--jinja).
|
||||||
"chat_template_kwargs": map[string]any{"enable_thinking": false},
|
"chat_template_kwargs": map[string]any{"enable_thinking": false},
|
||||||
}
|
}
|
||||||
|
if ep.External {
|
||||||
|
// Propre à llama.cpp : une API distante stricte (OpenAI) refuse un
|
||||||
|
// argument inconnu par un 400, et la compaction échouait à chaque fois.
|
||||||
|
delete(payload, "chat_template_kwargs")
|
||||||
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -656,7 +656,11 @@ func (c *Conversation) dropQueued(epoch int) {
|
|||||||
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
|
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
|
||||||
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
|
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
|
||||||
func chatModelName() string {
|
func chatModelName() string {
|
||||||
m := strings.TrimSpace(ReadConfig()["MODEL"])
|
cfg := ReadConfig()
|
||||||
|
if isExternalConfig(cfg) {
|
||||||
|
return strings.TrimSpace(cfg[extKeyModel]) // modèle distant, tel quel
|
||||||
|
}
|
||||||
|
m := strings.TrimSpace(cfg["MODEL"])
|
||||||
if m == "" {
|
if m == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func engineSeesImages() bool {
|
func engineSeesImages() bool {
|
||||||
|
// Preset externe : aucun /props à sonder, on s'en tient à la déclaration
|
||||||
|
// du preset (case « le modèle accepte les images »).
|
||||||
|
if externalActive() {
|
||||||
|
return externalVisionActive()
|
||||||
|
}
|
||||||
visionProbeMu.Lock()
|
visionProbeMu.Lock()
|
||||||
defer visionProbeMu.Unlock()
|
defer visionProbeMu.Unlock()
|
||||||
if time.Since(visionProbeAt) < 10*time.Second {
|
if time.Since(visionProbeAt) < 10*time.Second {
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une po
|
|||||||
// inflating decode numbers — what you measure here is close to what you'll
|
// inflating decode numbers — what you measure here is close to what you'll
|
||||||
// see in real chat at the same context length.
|
// see in real chat at the same context length.
|
||||||
func runBench(nPrompt, nPredict int) (*benchResult, error) {
|
func runBench(nPrompt, nPredict int) (*benchResult, error) {
|
||||||
|
// Un bench mesure le moteur LOCAL : sur un preset externe, healthCheck dit
|
||||||
|
// « prêt » sans moteur, et la mesure taperait un port arrêté ou un moteur
|
||||||
|
// resté en vie — attribuée à tort à ce preset.
|
||||||
|
if externalActive() {
|
||||||
|
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
|
||||||
|
}
|
||||||
port := LLMPort()
|
port := LLMPort()
|
||||||
if !healthCheck() {
|
if !healthCheck() {
|
||||||
return nil, fmt.Errorf("serveur injoignable sur :%d", port)
|
return nil, fmt.Errorf("serveur injoignable sur :%d", port)
|
||||||
|
|||||||
@@ -994,7 +994,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
|||||||
if reasoningEffort != "" {
|
if reasoningEffort != "" {
|
||||||
payload["reasoning_effort"] = reasoningEffort
|
payload["reasoning_effort"] = reasoningEffort
|
||||||
}
|
}
|
||||||
if reasoningKwargs != nil {
|
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
|
||||||
|
// distante stricte (OpenAI) refuse un argument inconnu (400).
|
||||||
|
if reasoningKwargs != nil && !ep.External {
|
||||||
payload["chat_template_kwargs"] = reasoningKwargs
|
payload["chat_template_kwargs"] = reasoningKwargs
|
||||||
}
|
}
|
||||||
if len(tools) > 0 && !disableTools {
|
if len(tools) > 0 && !disableTools {
|
||||||
|
|||||||
@@ -2502,6 +2502,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
||||||
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pe-row plain">
|
||||||
|
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
|
||||||
|
</div>
|
||||||
<div class="pe-row stack">
|
<div class="pe-row stack">
|
||||||
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
||||||
<div id="ext-test-out" class="pe-note"></div>
|
<div id="ext-test-out" class="pe-note"></div>
|
||||||
@@ -8106,6 +8109,7 @@ async function openExternal(id){
|
|||||||
document.getElementById('ext-test-out').textContent = '';
|
document.getElementById('ext-test-out').textContent = '';
|
||||||
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
||||||
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
||||||
|
document.getElementById('ext-vision').checked = false;
|
||||||
showModal('ext-modal');
|
showModal('ext-modal');
|
||||||
if(!id) return;
|
if(!id) return;
|
||||||
let d = {};
|
let d = {};
|
||||||
@@ -8113,6 +8117,7 @@ async function openExternal(id){
|
|||||||
if(extEditing !== id) return; // une autre ouverture a pris la main
|
if(extEditing !== id) return; // une autre ouverture a pris la main
|
||||||
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
||||||
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
||||||
|
document.getElementById('ext-vision').checked = !!d.vision;
|
||||||
if(d.hasKey){
|
if(d.hasKey){
|
||||||
document.getElementById('ext-key-sub').textContent =
|
document.getElementById('ext-key-sub').textContent =
|
||||||
'une clé est enregistrée — laisse vide pour la conserver';
|
'une clé est enregistrée — laisse vide pour la conserver';
|
||||||
@@ -8129,6 +8134,7 @@ function extPayload(){
|
|||||||
key: document.getElementById('ext-key').value || '',
|
key: document.getElementById('ext-key').value || '',
|
||||||
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
||||||
keyTouched: extKeyTouched,
|
keyTouched: extKeyTouched,
|
||||||
|
vision: document.getElementById('ext-vision').checked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8158,9 +8164,11 @@ async function saveExternal(){
|
|||||||
if(!p.model){ toast('modèle requis'); return; }
|
if(!p.model){ toast('modèle requis'); return; }
|
||||||
const r = await jpost('/api/preset/external/save', p);
|
const r = await jpost('/api/preset/external/save', p);
|
||||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||||
toast('enregistré');
|
// Preset en service : le serveur l'a appliqué aussitôt (applied).
|
||||||
|
toast(r.applied ? 'enregistré et appliqué' : 'enregistré');
|
||||||
closeExternal();
|
closeExternal();
|
||||||
loadPresets();
|
loadPresets();
|
||||||
|
if(r.applied && typeof loadStatus === 'function') loadStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
||||||
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pe-row plain">
|
||||||
|
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
|
||||||
|
</div>
|
||||||
<div class="pe-row stack">
|
<div class="pe-row stack">
|
||||||
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
||||||
<div id="ext-test-out" class="pe-note"></div>
|
<div id="ext-test-out" class="pe-note"></div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async function openExternal(id){
|
|||||||
document.getElementById('ext-test-out').textContent = '';
|
document.getElementById('ext-test-out').textContent = '';
|
||||||
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
||||||
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
||||||
|
document.getElementById('ext-vision').checked = false;
|
||||||
showModal('ext-modal');
|
showModal('ext-modal');
|
||||||
if(!id) return;
|
if(!id) return;
|
||||||
let d = {};
|
let d = {};
|
||||||
@@ -34,6 +35,7 @@ async function openExternal(id){
|
|||||||
if(extEditing !== id) return; // une autre ouverture a pris la main
|
if(extEditing !== id) return; // une autre ouverture a pris la main
|
||||||
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
||||||
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
||||||
|
document.getElementById('ext-vision').checked = !!d.vision;
|
||||||
if(d.hasKey){
|
if(d.hasKey){
|
||||||
document.getElementById('ext-key-sub').textContent =
|
document.getElementById('ext-key-sub').textContent =
|
||||||
'une clé est enregistrée — laisse vide pour la conserver';
|
'une clé est enregistrée — laisse vide pour la conserver';
|
||||||
@@ -50,6 +52,7 @@ function extPayload(){
|
|||||||
key: document.getElementById('ext-key').value || '',
|
key: document.getElementById('ext-key').value || '',
|
||||||
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
||||||
keyTouched: extKeyTouched,
|
keyTouched: extKeyTouched,
|
||||||
|
vision: document.getElementById('ext-vision').checked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,9 +82,11 @@ async function saveExternal(){
|
|||||||
if(!p.model){ toast('modèle requis'); return; }
|
if(!p.model){ toast('modèle requis'); return; }
|
||||||
const r = await jpost('/api/preset/external/save', p);
|
const r = await jpost('/api/preset/external/save', p);
|
||||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||||
toast('enregistré');
|
// Preset en service : le serveur l'a appliqué aussitôt (applied).
|
||||||
|
toast(r.applied ? 'enregistré et appliqué' : 'enregistré');
|
||||||
closeExternal();
|
closeExternal();
|
||||||
loadPresets();
|
loadPresets();
|
||||||
|
if(r.applied && typeof loadStatus === 'function') loadStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func handlePresetExternal(w http.ResponseWriter, r *http.Request) {
|
|||||||
"model": strings.TrimSpace(cfg[extKeyModel]),
|
"model": strings.TrimSpace(cfg[extKeyModel]),
|
||||||
"ctx": strings.TrimSpace(cfg["CTX"]),
|
"ctx": strings.TrimSpace(cfg["CTX"]),
|
||||||
"hasKey": strings.TrimSpace(cfg[extKeyToken]) != "",
|
"hasKey": strings.TrimSpace(cfg[extKeyToken]) != "",
|
||||||
|
"vision": strings.TrimSpace(cfg[extKeyVision]) == "1",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ type externalSaveReq struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Ctx string `json:"ctx"` // taille de contexte (clé CTX), optionnelle
|
Ctx string `json:"ctx"` // taille de contexte (clé CTX), optionnelle
|
||||||
KeyTouched bool `json:"keyTouched"` // l'utilisateur a modifié le champ clé
|
KeyTouched bool `json:"keyTouched"` // l'utilisateur a modifié le champ clé
|
||||||
|
Vision bool `json:"vision"` // le modèle distant accepte les images
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
|
func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -94,13 +96,15 @@ func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
|
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content := externalPresetContent(req.URL, req.Model, key, req.Ctx)
|
content := externalPresetContent(req.URL, req.Model, key, req.Ctx, req.Vision)
|
||||||
newID, err := SavePreset(req.ID, req.Name, content)
|
// Éditer le preset EN SERVICE l'applique tout de suite : sans ça, une URL ou
|
||||||
|
// une clé corrigée ne servait qu'après une bascule aller-retour.
|
||||||
|
newID, applied, err := SavePresetApplying(req.ID, req.Name, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name})
|
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name, "applied": applied})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePresetExternalTest tente un appel de complétion minimal vers l'endpoint
|
// handlePresetExternalTest tente un appel de complétion minimal vers l'endpoint
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ func imageMime(name string) string {
|
|||||||
// seule où envoyer une image AU MODÈLE a un sens. Sans projecteur, llama-server
|
// 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.
|
// rejetterait un contenu image ; on s'en tient alors au dépôt-fichier.
|
||||||
func visionEnabled() bool {
|
func visionEnabled() bool {
|
||||||
|
if externalActive() {
|
||||||
|
return externalVisionActive() // modèle distant déclaré multimodal
|
||||||
|
}
|
||||||
return strings.TrimSpace(ReadConfig()["MMPROJ"]) != ""
|
return strings.TrimSpace(ReadConfig()["MMPROJ"]) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user