mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
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
180 lines
6.0 KiB
Go
180 lines
6.0 KiB
Go
package loki
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// web_external.go — endpoints /api/preset/external/* : lire, enregistrer et
|
|
// tester un preset « externe » (API OpenAI-compatible distante).
|
|
//
|
|
// GET /api/preset/external?id=… champs du preset (jamais la clé en clair)
|
|
// POST /api/preset/external/save enregistre (création si id vide)
|
|
// POST /api/preset/external/test appel de complétion minimal, avant d'enregistrer
|
|
//
|
|
// Le test AVANT enregistrement 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.
|
|
|
|
// handlePresetExternal renvoie les champs d'un preset externe pour préremplir la
|
|
// modale d'édition. id vide = nouveau preset (champs vides).
|
|
func handlePresetExternal(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
if id == "" {
|
|
sendJSON(w, 200, map[string]any{"id": "", "name": "", "url": "", "model": "", "hasKey": false})
|
|
return
|
|
}
|
|
content, err := ReadPreset(id)
|
|
if err != nil {
|
|
sendJSON(w, 404, map[string]any{"error": "not found"})
|
|
return
|
|
}
|
|
cfg := parseEnv(content)
|
|
if !isExternalConfig(cfg) {
|
|
sendJSON(w, 400, map[string]any{"error": "pas un preset externe"})
|
|
return
|
|
}
|
|
// La clé n'est PAS renvoyée en clair : on signale seulement sa présence, pour
|
|
// que la modale montre « clé enregistrée » sans l'exposer. Un ré-enregistrement
|
|
// sans toucher au champ clé la conserve (voir handlePresetExternalSave).
|
|
sendJSON(w, 200, map[string]any{
|
|
"id": id,
|
|
"name": presetDisplayName(content, id),
|
|
"url": strings.TrimSpace(cfg[extKeyURL]),
|
|
"model": strings.TrimSpace(cfg[extKeyModel]),
|
|
"ctx": strings.TrimSpace(cfg["CTX"]),
|
|
"hasKey": strings.TrimSpace(cfg[extKeyToken]) != "",
|
|
})
|
|
}
|
|
|
|
// externalSaveReq : payload de la modale externe. `key` vide sur une ÉDITION
|
|
// conserve la clé existante (voir keyTouched) ; sur une création, absence de clé
|
|
// = pas d'authentification.
|
|
type externalSaveReq struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Model string `json:"model"`
|
|
Key string `json:"key"`
|
|
Ctx string `json:"ctx"` // taille de contexte (clé CTX), optionnelle
|
|
KeyTouched bool `json:"keyTouched"` // l'utilisateur a modifié le champ clé
|
|
}
|
|
|
|
func handlePresetExternalSave(w http.ResponseWriter, r *http.Request) {
|
|
var req externalSaveReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.URL = strings.TrimSpace(req.URL)
|
|
req.Model = strings.TrimSpace(req.Model)
|
|
if req.Name == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom requis"})
|
|
return
|
|
}
|
|
if req.URL == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "URL requise"})
|
|
return
|
|
}
|
|
if req.Model == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle requis"})
|
|
return
|
|
}
|
|
// Clé : sur une édition sans toucher au champ, on relit la clé déjà stockée
|
|
// pour ne pas l'effacer (la modale ne la reçoit jamais en clair).
|
|
key := req.Key
|
|
if req.ID != "" && !req.KeyTouched {
|
|
if content, err := ReadPreset(req.ID); err == nil {
|
|
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
|
|
}
|
|
}
|
|
content := externalPresetContent(req.URL, req.Model, key, req.Ctx)
|
|
newID, err := SavePreset(req.ID, req.Name, content)
|
|
if err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name})
|
|
}
|
|
|
|
// handlePresetExternalTest tente un appel de complétion minimal vers l'endpoint
|
|
// saisi, pour valider URL + modèle + clé avant d'enregistrer. Sur une édition
|
|
// sans clé fournie, on réutilise la clé stockée.
|
|
func handlePresetExternalTest(w http.ResponseWriter, r *http.Request) {
|
|
var req externalSaveReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
url := completionsURL(req.URL)
|
|
model := strings.TrimSpace(req.Model)
|
|
if url == "" || model == "" {
|
|
sendJSON(w, 200, map[string]any{"ok": false, "error": "URL et modèle requis"})
|
|
return
|
|
}
|
|
key := strings.TrimSpace(req.Key)
|
|
if key == "" && req.ID != "" && !req.KeyTouched {
|
|
if content, err := ReadPreset(req.ID); err == nil {
|
|
key = strings.TrimSpace(parseEnv(content)[extKeyToken])
|
|
}
|
|
}
|
|
payload := map[string]any{
|
|
"model": model,
|
|
"messages": []map[string]any{{"role": "user", "content": "ping"}},
|
|
"max_tokens": 1,
|
|
"stream": false,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
hreq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
hreq.Header.Set("Content-Type", "application/json")
|
|
if key != "" {
|
|
hreq.Header.Set("Authorization", "Bearer "+key)
|
|
}
|
|
resp, err := http.DefaultClient.Do(hreq)
|
|
if err != nil {
|
|
sendJSON(w, 200, map[string]any{"ok": false, "error": "injoignable : " + err.Error()})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 2000))
|
|
if resp.StatusCode != http.StatusOK {
|
|
msg := strings.TrimSpace(string(rb))
|
|
if m := extractAPIError(rb); m != "" {
|
|
msg = m
|
|
}
|
|
if msg == "" {
|
|
msg = resp.Status
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": false, "status": resp.StatusCode, "error": msg})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// extractAPIError sort le champ error.message d'une réponse d'erreur au format
|
|
// OpenAI ({"error":{"message":"…"}}), pour un message lisible plutôt que le JSON
|
|
// brut. Renvoie "" si le format ne correspond pas.
|
|
func extractAPIError(b []byte) string {
|
|
var e struct {
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if json.Unmarshal(b, &e) == nil {
|
|
return strings.TrimSpace(e.Error.Message)
|
|
}
|
|
return ""
|
|
}
|