mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +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
118 lines
4.9 KiB
Go
118 lines
4.9 KiB
Go
package loki
|
|
|
|
// chat_vision_tool.go — l'outil see_image : le modèle charge lui-même une image
|
|
// du disque dans sa VISION, sans que l'utilisateur ait à la joindre au message.
|
|
//
|
|
// Loki savait déjà voir deux choses : une pièce jointe (web_upload.go) et une
|
|
// capture d'écran qu'il venait de prendre (chat_screenshot.go). Pas un fichier
|
|
// qui dort sur le disque. « Regarde la capture dans ~/photos/bug.png » n'avait
|
|
// donc aucune réponse : `read` rend des octets binaires, et le modèle finissait
|
|
// par décrire ce qu'il croyait deviner du nom de fichier.
|
|
//
|
|
// Mécanique : le résultat de l'outil reste un simple texte (un accusé). L'image,
|
|
// elle, est réinjectée juste après dans un message utilisateur multimodal
|
|
// (image_url) — le SEUL format que llama-server comprenne une fois --mmproj
|
|
// chargé, et exactement le chemin déjà emprunté par les pièces jointes et les
|
|
// captures.
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// maxVisionBytes borne la taille d'une image chargée dans la vision. Au-delà,
|
|
// le base64 gonfle le contexte pour rien : le moteur redimensionne de toute
|
|
// façon avant l'encodeur d'images.
|
|
const maxVisionBytes = 12 << 20 // 12 Mio
|
|
|
|
// toolSeeImage lit un fichier image et renvoie (accusé texte, partie image_url).
|
|
// La partie image vaut nil en cas d'erreur : l'appelant n'injecte alors rien.
|
|
//
|
|
// Chaque refus dit CE QUI manque plutôt que « impossible » : sans projecteur, la
|
|
// réponse n'est pas la même que sur un poste distant ou un fichier trop lourd,
|
|
// et un modèle qui reçoit un motif clair peut corriger son geste lui-même.
|
|
func toolSeeImage(path string) (string, map[string]any) {
|
|
if !visionEnabled() {
|
|
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image", nil
|
|
}
|
|
// Cible = un poste distant : le fichier est LÀ-BAS, pas lisible d'ici.
|
|
if agentTargetSlug() != "" {
|
|
return "[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
|
|
}
|
|
if path == "" {
|
|
return "[erreur] chemin de fichier manquant", nil
|
|
}
|
|
abs := resolveAgentPath(path)
|
|
mime := imageMime(abs)
|
|
if mime == "" {
|
|
return "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : " + path, nil
|
|
}
|
|
st, err := os.Stat(abs)
|
|
if err != nil {
|
|
return "[erreur] fichier introuvable : " + path, nil
|
|
}
|
|
if st.IsDir() {
|
|
return "[erreur] c'est un dossier, pas une image : " + path, nil
|
|
}
|
|
if st.Size() > maxVisionBytes {
|
|
return fmt.Sprintf("[erreur] image trop lourde (%s, max %s) : %s",
|
|
humanBytes(st.Size()), humanBytes(maxVisionBytes), path), nil
|
|
}
|
|
// Sonde du moteur EN DERNIER, juste avant le travail coûteux. Elle fait un
|
|
// appel réseau (/props, mis en cache 10 s) : la poser d'entrée, c'était payer
|
|
// un aller-retour pour répondre « chemin manquant », et surtout masquer les
|
|
// motifs précis ci-dessus derrière un « le moteur ne voit pas » qui n'apprend
|
|
// rien quand le vrai problème est une faute de frappe dans le chemin.
|
|
//
|
|
// Elle reste indispensable : un projecteur déclaré ne garantit pas que le
|
|
// moteur en service sache traiter une image (mauvais couple modèle/mmproj,
|
|
// moteur trop ancien). Autant le dire que d'envoyer 12 Mio de base64 que
|
|
// personne ne regardera.
|
|
if !engineSeesImages() {
|
|
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
|
|
}
|
|
b, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return "[erreur] lecture impossible : " + err.Error(), nil
|
|
}
|
|
if len(b) == 0 {
|
|
return "[erreur] fichier vide : " + path, nil
|
|
}
|
|
return "[ok] image chargée : " + filepath.Base(abs), map[string]any{
|
|
"type": "image_url",
|
|
"image_url": map[string]any{
|
|
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
|
},
|
|
}
|
|
}
|
|
|
|
// seeImageMessage emballe la partie image dans le message utilisateur qui la
|
|
// porte jusqu'au modèle. La légende nomme le fichier : après compaction il ne
|
|
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
|
|
// dirait pas LAQUELLE rouvrir.
|
|
func seeImageMessage(label string, img map[string]any) Message {
|
|
return Message{Role: "user", Content: []map[string]any{
|
|
{"type": "text", "text": "Image demandée (" + label + ") :"},
|
|
img,
|
|
}}
|
|
}
|
|
|
|
// seeImageTool — schéma envoyé au modèle. Description tenue au plus court : les
|
|
// schémas partent dans CHAQUE requête et le préambule a un budget
|
|
// (TestSystemPromptStaysLean).
|
|
func seeImageTool() Tool {
|
|
return Tool{Type: "function", Function: ToolFunction{
|
|
Name: "see_image",
|
|
Description: "Ouvre un fichier image du disque pour le VOIR (png, jpg, gif, webp, bmp).",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"file": map[string]any{"type": "string", "description": "Chemin de l'image (relatif au dossier de travail, ou absolu)"},
|
|
},
|
|
"required": []string{"file"},
|
|
},
|
|
}}
|
|
}
|