mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
L'endpoint compatible OpenAI n'était pas servi par Loki : le panneau annonçait l'adresse de llama-server lui-même, http://<ip>:8080/v1. Dans le déploiement de référence de ce fork, cette adresse ne peut joindre personne — le port 8080 n'est pas publié par le conteneur, l'entrypoint sème HOST=127.0.0.1, et l'IP annoncée est celle du bridge Docker. L'autre voie proposée, « exposer en public (ajean.link) », exigeait un jeton de relais que ce fork ne permet plus d'obtenir : l'interrupteur ne pouvait que renvoyer vers un panneau supprimé. Désormais, Loki sert /v1/* SUR SON PROPRE PORT et relaie vers le moteur. L'API est donc joignable partout où l'interface l'est — IP du réseau local, nom de domaine, reverse proxy — sans publier de second port ni ouvrir le moteur. Serveur - mountOAI (llm_oai.go) monte /v1/ sur le mux, et RIEN d'autre : ni /metrics, ni /props, ni /slots, qui divulgueraient le modèle chargé et l'état des slots. Le filtre interne d'oaiHandler reste en seconde barrière. - requireCompletionKey (web_auth.go) garde cette surface avec la clé des COMPLÉTIONS, pas celle de pilotage : un client OpenAI n'a qu'un en-tête Authorization, et on veut pouvoir lui donner l'accès au modèle sans le droit de redémarrer la machine. Erreurs au format d'OpenAI (body.error.message), que les SDK savent présenter. Le préflight CORS passe sans clé — il n'en porte jamais, et le refuser casserait tout client tiers de navigateur. - effectiveAPIKeyErr (backend_config.go) devient la source unique de la clé exigée : base d'abord, config.env en repli, exactement comme le moteur. Sans ce miroir, un API_KEY résiduel donnait un endpoint « ouvert » côté Loki et un 401 côté moteur, sans rien pour l'expliquer. Lecture ratée = refus, jamais ouverture (même raisonnement que readWebKeyErr). - oaiHandler passe à ReverseProxy.Rewrite : le port du moteur est relu à chaque requête au lieu d'être figé à la construction — il visait l'ancien port dès qu'on changeait PORT, jusqu'au redémarrage de Loki. - withLocalAuth (relay_link.go) n'injecte plus la clé de pilotage sur /v1 : elle aurait été refusée par la garde, et surtout relayée au moteur. Le trafic du tunnel est marqué (en-tête effacé avant d'être posé, sinon un client le forge) et la surface y reste fermée tant que oai_public est faux — la promesse du tunnel est tenue. Adresse affichée - web_public_url.go : normalisation d'une adresse publique saisie à la main (schéma ajouté, /v1 recopié toléré, chemin refusé), origine de la requête via Host + X-Forwarded-Proto, et la règle de priorité entre les deux. - Le calcul quitte le navigateur pour le serveur : c'est la concaténation côté client qui produisait l'adresse fantôme. Interface - Le panneau perd l'interrupteur ajean.link et l'interrupteur d'écoute LAN — ce dernier n'a plus d'objet, et deux interrupteurs pour « rendre l'IA joignable » était la confusion à lever. La route /api/network et `loki network` restent pour qui veut exposer le moteur en direct. - Il gagne un champ « adresse publique » (facultatif, pour le reverse proxy) et un avertissement rouge tant qu'aucune clé n'est définie — l'endpoint est maintenant ouvert PARTOUT où l'interface l'est, ça ne se dit pas à voix basse. Le démarrage de `loki web` le crie aussi. Vérifié bout en bout sur le serveur réel : liste des modèles à travers Loki avec la clé (200), sans la clé (401), et complétion en streaming dont les tokens arrivent espacés de 120 ms — le flux traverse bien le double proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
966 lines
33 KiB
Go
966 lines
33 KiB
Go
// web_api.go — handlers REST /api/* (statut, config, presets, modèles,
|
|
// mémoire, agent, clés, bench…) du serveur web local.
|
|
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
func handlePing(w http.ResponseWriter, r *http.Request) {
|
|
sendJSON(w, 200, map[string]any{"ok": true, "service": "loki", "version": Version})
|
|
}
|
|
|
|
// handleStatus reports service state cross-platform via serviceIsActive
|
|
// (systemd sous Linux, supervision par PID-file sous Windows — voir sys_service_*.go).
|
|
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
active := serviceIsActive()
|
|
state := "inactive"
|
|
if active {
|
|
state = "active"
|
|
}
|
|
health := false
|
|
if active {
|
|
health = healthCheck()
|
|
}
|
|
ctx := 32768
|
|
if v := ReadConfig()["CTX"]; v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
ctx = n
|
|
}
|
|
}
|
|
// Si le modèle n'est pas prêt, on regarde POURQUOI : une tentative de
|
|
// chargement qui a échoué (souvent un modèle incompatible avec le moteur)
|
|
// doit être signalée clairement plutôt que de laisser un « chargement… »
|
|
// perpétuel / un crash-loop muet.
|
|
loadErr := ""
|
|
if !health {
|
|
loadErr = modelLoadError()
|
|
}
|
|
sendJSON(w, 200, map[string]any{
|
|
"state": state,
|
|
"active": active,
|
|
"health": health,
|
|
"port": LLMPort(),
|
|
"ctx": ctx,
|
|
"version": Version,
|
|
"warn": appWarning(), // ex. App Translocation macOS — vide si tout va bien
|
|
"load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon
|
|
})
|
|
}
|
|
|
|
// modelLoadError inspecte la fin du journal du service et renvoie un message
|
|
// clair quand la DERNIÈRE tentative de chargement du modèle s'est soldée par un
|
|
// échec. Le cas le plus courant : un GGUF dont le format de quantification n'est
|
|
// pas reconnu par le moteur (BIN) sélectionné — typiquement un modèle ternaire
|
|
// qui exige un fork de llama.cpp. Renvoie "" quand le dernier chargement a
|
|
// réussi ou qu'aucune tentative n'est visible (évite les faux positifs sur un
|
|
// service simplement arrêté).
|
|
func modelLoadError() string {
|
|
log := serviceLogTail(200)
|
|
if log == "" {
|
|
return ""
|
|
}
|
|
lines := strings.Split(log, "\n")
|
|
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
|
|
start := 0
|
|
for i, l := range lines {
|
|
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") {
|
|
start = i
|
|
}
|
|
}
|
|
loaded, reason := false, ""
|
|
for _, l := range lines[start:] {
|
|
low := strings.ToLower(l)
|
|
switch {
|
|
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"):
|
|
loaded = true
|
|
case strings.Contains(l, "has offset") && strings.Contains(l, "expected"):
|
|
reason = "format de quantification non reconnu par ce moteur"
|
|
case strings.Contains(low, "unknown model architecture"),
|
|
strings.Contains(low, "unknown ftype"),
|
|
strings.Contains(low, "unknown type"),
|
|
strings.Contains(low, "unsupported"):
|
|
if reason == "" {
|
|
reason = "type/architecture de modèle inconnu de ce moteur"
|
|
}
|
|
case strings.Contains(low, "error loading model"),
|
|
strings.Contains(low, "failed to load model"),
|
|
strings.Contains(low, "failed to read tensor data"):
|
|
if reason == "" {
|
|
reason = "échec du chargement du modèle"
|
|
}
|
|
}
|
|
}
|
|
if loaded || reason == "" {
|
|
return ""
|
|
}
|
|
// Message PRÉCIS selon la cause : n'affirmer l'incompatibilité que quand le
|
|
// journal la montre vraiment (quant/architecture). Un échec générique de
|
|
// chargement peut avoir bien d'autres causes (mémoire, fichier, tenseur) — on
|
|
// renvoie alors vers le journal plutôt que d'accuser à tort le moteur.
|
|
switch reason {
|
|
case "format de quantification non reconnu par ce moteur",
|
|
"type/architecture de modèle inconnu de ce moteur":
|
|
return "Modèle incompatible avec le moteur : " + reason +
|
|
". Choisis un autre backend (édite le modèle → Moteur)."
|
|
default:
|
|
return "Le modèle n'a pas pu être chargé (" + reason +
|
|
"). Ouvre le journal du moteur pour le détail."
|
|
}
|
|
}
|
|
|
|
// handleServiceLog (GET /api/service/log) : dernières lignes du journal du
|
|
// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas
|
|
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu
|
|
// d'un « chargement… » perpétuel sans explication.
|
|
func handleServiceLog(w http.ResponseWriter, r *http.Request) {
|
|
n := 80
|
|
if v := r.URL.Query().Get("n"); v != "" {
|
|
if k, err := strconv.Atoi(v); err == nil && k > 0 && k <= 500 {
|
|
n = k
|
|
}
|
|
}
|
|
sendJSON(w, 200, map[string]any{"log": serviceLogTail(n)})
|
|
}
|
|
|
|
func handleVram(w http.ResponseWriter, r *http.Request) {
|
|
out, err := hideCmd(exec.Command("nvidia-smi",
|
|
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
|
|
"--format=csv,noheader,nounits")).Output()
|
|
gpus := []map[string]any{}
|
|
if err == nil {
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
parts := strings.Split(line, ",")
|
|
if len(parts) != 5 {
|
|
continue
|
|
}
|
|
for i := range parts {
|
|
parts[i] = strings.TrimSpace(parts[i])
|
|
}
|
|
used, _ := strconv.Atoi(parts[1])
|
|
total, _ := strconv.Atoi(parts[2])
|
|
util, _ := strconv.Atoi(parts[3])
|
|
temp, _ := strconv.Atoi(parts[4])
|
|
gpus = append(gpus, map[string]any{
|
|
"name": parts[0], "used": used, "total": total, "util": util, "temp": temp,
|
|
})
|
|
}
|
|
}
|
|
sendJSON(w, 200, gpus)
|
|
}
|
|
|
|
// handleRam renvoie la RAM système {used, total} en Mo (mêmes unités que
|
|
// /api/vram → l'UI divise par 1024 pour des Gio). La mesure est spécifique à
|
|
// l'OS (ramUsageMB : /proc/meminfo sous Linux, sysctl+vm_stat sur macOS,
|
|
// GlobalMemoryStatusEx sous Windows). total=0 → l'UI masque le bloc.
|
|
func handleRam(w http.ResponseWriter, r *http.Request) {
|
|
used, total := ramUsageMB()
|
|
sendJSON(w, 200, map[string]any{"used": used, "total": total})
|
|
}
|
|
|
|
func handleConfigEnv(w http.ResponseWriter, r *http.Request) {
|
|
sendJSON(w, 200, ReadConfig())
|
|
}
|
|
|
|
// handleBackends scans LOKI_HOME/backends/<name>/ for a llama-server binary,
|
|
// trying common build subpaths (build/bin, build-sm120/bin, bin, .).
|
|
// Returns [{name, path}].
|
|
func handleBackends(w http.ResponseWriter, r *http.Request) {
|
|
sendJSON(w, 200, listBackendBins())
|
|
}
|
|
|
|
// listBackendBins scanne LOKI_HOME/backends/<name>/ pour un binaire llama-server
|
|
// (essaie les sous-chemins des différents générateurs CMake). Renvoie
|
|
// [{name, path}] — un par dossier de backend contenant un binaire.
|
|
func listBackendBins() []map[string]any {
|
|
root := LokiHome() + "/backends"
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return []map[string]any{}
|
|
}
|
|
subpaths := []string{
|
|
"build/bin/llama-server", "build-sm120/bin/llama-server",
|
|
"build/llama-server", "bin/llama-server", "llama-server",
|
|
// Layout du générateur Visual Studio (multi-config) + suffixe .exe Windows.
|
|
"build/bin/Release/llama-server.exe", "build/bin/llama-server.exe",
|
|
"build/bin/Release/llama-server", "llama-server.exe",
|
|
}
|
|
out := []map[string]any{}
|
|
for _, e := range entries {
|
|
// e can be a directory or a symlink to one; either is fine.
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
for _, sp := range subpaths {
|
|
p := root + "/" + name + "/" + sp
|
|
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
|
out = append(out, map[string]any{"name": name, "path": p})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// handleBackendsCustom liste les seuls backends CUSTOM : les dossiers de
|
|
// backends/ hors moteurs gérés par les cartes ⚡ (prebuilt) et 🔧 (build
|
|
// canonique). Marque celui qui sert de moteur au modèle actif (in_use).
|
|
func handleBackendsCustom(w http.ResponseWriter, r *http.Request) {
|
|
canonical := filepath.Base(defaultRepoDir()) // "llama.cpp"
|
|
prebuilt := filepath.Base(prebuiltDir()) // "llama.cpp-prebuilt"
|
|
cfgBin := ReadConfig()["BIN"]
|
|
out := []map[string]any{}
|
|
for _, b := range listBackendBins() {
|
|
name, _ := b["name"].(string)
|
|
if name == canonical || name == prebuilt {
|
|
continue
|
|
}
|
|
path, _ := b["path"].(string)
|
|
out = append(out, map[string]any{"name": name, "path": path, "in_use": samePath(path, cfgBin)})
|
|
}
|
|
sendJSON(w, 200, out)
|
|
}
|
|
|
|
// handleLlamacppUninstallCustom supprime le dossier d'un backend custom
|
|
// (backends/<name>). Refuse les moteurs gérés (canonique / prebuilt) et un
|
|
// backend actuellement utilisé par le modèle actif (il faut d'abord basculer de
|
|
// moteur pour ce modèle).
|
|
func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
name := sanitizeBackendName(req.Name)
|
|
if name == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom requis"})
|
|
return
|
|
}
|
|
if name == filepath.Base(defaultRepoDir()) || name == filepath.Base(prebuiltDir()) {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "ce moteur se gère depuis les cartes ⚡ / 🔧"})
|
|
return
|
|
}
|
|
dir, err := backendDir(name)
|
|
if err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if !isDir(dir) {
|
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "backend introuvable"})
|
|
return
|
|
}
|
|
cfgBin := ReadConfig()["BIN"]
|
|
for _, b := range listBackendBins() {
|
|
if b["name"] == name {
|
|
if p, _ := b["path"].(string); samePath(p, cfgBin) {
|
|
sendJSON(w, 409, map[string]any{"ok": false, "error": "backend utilisé par le modèle actif — bascule d'abord ce modèle sur un autre moteur"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleModels lists *.gguf files (size in bytes) for the preset editor's model
|
|
// picker, dans LOKI_HOME ET dans les dossiers supplémentaires déclarés (disque
|
|
// externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de
|
|
// fichier pour LOKI_HOME (compatibilité avec l'existant), le chemin complet
|
|
// pour un dossier externe.
|
|
func handleModels(w http.ResponseWriter, r *http.Request) {
|
|
out := []map[string]any{}
|
|
seen := map[string]bool{}
|
|
home := LokiHome()
|
|
for _, dir := range modelDirs() {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
isHome := normDir(dir) == normDir(home)
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") {
|
|
continue
|
|
}
|
|
// Modèle découpé : seule la première tranche est un modèle lançable, les
|
|
// suivantes sont ouvertes par llama-server tout seul. Les lister toutes
|
|
// donnait trois entrées pour un modèle, dont deux qui échouent au démarrage.
|
|
if isFollowerShard(e.Name()) {
|
|
continue
|
|
}
|
|
full := filepath.Join(dir, e.Name())
|
|
if seen[normDir(full)] {
|
|
continue
|
|
}
|
|
seen[normDir(full)] = true
|
|
info, _ := e.Info()
|
|
size := int64(0)
|
|
if info != nil {
|
|
size = info.Size()
|
|
}
|
|
value := full
|
|
if isHome {
|
|
value = e.Name()
|
|
}
|
|
m := map[string]any{
|
|
"name": e.Name(), "size": size, "value": value,
|
|
"path": full, "dir": dir, "home": isHome,
|
|
}
|
|
// Taille annoncée = la famille entière, et on signale les tranches
|
|
// manquantes : un modèle incomplet démarre puis meurt sur un tenseur
|
|
// introuvable, autant le voir avant de le sélectionner.
|
|
if _, total, ok := shardInfo(e.Name()); ok {
|
|
m["shards"] = total
|
|
m["size"] = shardFamilySize(dir, e.Name())
|
|
if missing := shardFamilyMissing(dir, e.Name()); len(missing) > 0 {
|
|
m["missing"] = missing
|
|
}
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
sendJSON(w, 200, out)
|
|
}
|
|
|
|
func handlePresets(w http.ResponseWriter, r *http.Request) {
|
|
list, err := ListPresets()
|
|
if err != nil {
|
|
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
store := loadBenchStore()
|
|
out := []map[string]any{}
|
|
for _, p := range list {
|
|
item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active}
|
|
if content, err := ReadPreset(p.ID); err == nil {
|
|
if q := detectQuant(content); q != "" {
|
|
item["quant"] = q
|
|
}
|
|
if r := presetReasoning(content); reasoningActive(r) {
|
|
item["reasoning"] = strings.ToLower(r)
|
|
}
|
|
}
|
|
if sb, ok := store[p.ID]; ok {
|
|
item["bench"] = map[string]any{
|
|
"prefill": sb.Result.PromptPerSecond,
|
|
"decode": sb.Result.PredictedPerSec,
|
|
"at": sb.At,
|
|
}
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
sendJSON(w, 200, out)
|
|
}
|
|
|
|
func handlePreset(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
if id == "" {
|
|
sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(newPresetSeed())})
|
|
return
|
|
}
|
|
content, err := ReadPreset(id)
|
|
if err != nil {
|
|
sendJSON(w, 404, map[string]any{"error": "not found"})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content})
|
|
}
|
|
|
|
// presetSaveReq is the preset editor payload. `id` identifies an existing
|
|
// preset to update ("" creates a new one); `name` is the display name.
|
|
type presetSaveReq struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
DeleteModel bool `json:"deleteModel"`
|
|
}
|
|
|
|
// saveReq is the skill editor payload (skills keep name-as-identity + rename).
|
|
type saveReq struct {
|
|
Name string `json:"name"`
|
|
Old string `json:"old"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
func handlePresetSave(w http.ResponseWriter, r *http.Request) {
|
|
var req presetSaveReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
newID, err := SavePreset(req.ID, req.Name, req.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})
|
|
}
|
|
|
|
func handlePresetDelete(w http.ResponseWriter, r *http.Request) {
|
|
var req presetSaveReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
// Capture the referenced model before the preset file disappears, so we can
|
|
// optionally delete the .gguf alongside it.
|
|
model := ""
|
|
if req.DeleteModel {
|
|
if content, err := ReadPreset(req.ID); err == nil {
|
|
model = modelFromPresetContent(content)
|
|
}
|
|
}
|
|
if err := DeletePreset(req.ID); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
modelDeleted, modelErr := "", ""
|
|
if req.DeleteModel && model != "" {
|
|
if err := deleteModelFile(model); err != nil {
|
|
modelErr = err.Error()
|
|
} else {
|
|
modelDeleted = model
|
|
}
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr})
|
|
}
|
|
|
|
// handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que
|
|
// l'IA gère via les outils mem_*) — un seul aller-retour pour l'UI. La clé
|
|
// "skills" est conservée en miroir de "pages" pour l'ancien portail ajean.link.
|
|
func handleAgent(w http.ResponseWriter, r *http.Request) {
|
|
pages := MemList()
|
|
out := []map[string]any{}
|
|
for _, p := range pages {
|
|
out = append(out, map[string]any{"name": p.Name, "desc": p.Title})
|
|
}
|
|
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "compact": compactEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out})
|
|
}
|
|
|
|
// handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always).
|
|
//
|
|
// GET → {mode}
|
|
// POST {mode} → persiste MEM_MODE
|
|
func handleMemoryMode(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
var req struct {
|
|
Mode string `json:"mode"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
// On normalise via memMode() en réinjectant la valeur : toute entrée
|
|
// inconnue retombe sur "always", donc on valide en passant par le parseur.
|
|
m := MemAlways
|
|
switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) {
|
|
case MemOff:
|
|
m = MemOff
|
|
case MemOnDemand:
|
|
m = MemOnDemand
|
|
case MemAlways:
|
|
m = MemAlways
|
|
}
|
|
if err := setMemMode(m); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "mode": string(memMode())})
|
|
}
|
|
|
|
// handleCompactToggle active/désactive le compactage automatique du contexte
|
|
// (config.env COMPACT). On=compacte (défaut), off=jamais.
|
|
func handleCompactToggle(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
On bool `json:"on"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
val := ""
|
|
if !req.On {
|
|
val = "off"
|
|
}
|
|
if err := SetConfigKey("COMPACT", val); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "compact": compactEnabled()})
|
|
}
|
|
|
|
func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
On bool `json:"on"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if err := setAgentEnabled(req.On); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "enabled": agentEnabled()})
|
|
}
|
|
|
|
// handleInternet pilote l'accès web de l'IA (serveur Crawl4AI).
|
|
//
|
|
// GET → {enabled, url, reachable}
|
|
// POST {enabled, url, key} → enregistre CRAWL4AI_URL / CRAWL4AI_KEY + le
|
|
// drapeau .internet_enabled. La clé n'est jamais relue par GET (juste un indice).
|
|
//
|
|
// handleAPIKey expose et pilote la clé d'accès à l'endpoint compatible OpenAI
|
|
// (llama-server /v1). GET renvoie l'état ; POST {action:"generate"|"set"|"clear",
|
|
// key?} l'écrit puis redémarre le service (llama-server lit --api-key au lancement).
|
|
func handleAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
var req struct {
|
|
Action string `json:"action"`
|
|
Key string `json:"key"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
var key string
|
|
switch req.Action {
|
|
case "generate":
|
|
key = genAPIKey()
|
|
case "set":
|
|
key = strings.TrimSpace(req.Key)
|
|
case "clear":
|
|
key = ""
|
|
default:
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "action inconnue"})
|
|
return
|
|
}
|
|
if err := writeAPIKey(key); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
// La clé n'est appliquée qu'au (re)démarrage de llama-server.
|
|
if serviceIsActive() {
|
|
_ = serviceAction("restart")
|
|
}
|
|
}
|
|
k, _ := effectiveAPIKeyErr()
|
|
sendJSON(w, 200, map[string]any{
|
|
"ok": true,
|
|
"set": k != "",
|
|
"key": k,
|
|
"masked": maskAPIKey(k),
|
|
// L'adresse à coller dans un client tiers. Calculée côté serveur (voir
|
|
// web_public_url.go) : adresse publique enregistrée, sinon l'origine par
|
|
// laquelle cette requête est arrivée — donc le domaine ou l'IP que
|
|
// l'utilisateur a réellement tapés.
|
|
"url": oaiBaseURL(r) + "/v1",
|
|
"public_url": publicURL(),
|
|
})
|
|
}
|
|
|
|
// handlePublicAddress lit et enregistre l'adresse publique de cette instance —
|
|
// le nom de domaine ou l'IP par lesquels on l'atteint.
|
|
//
|
|
// GET → {ok, url, set}
|
|
// POST {url:"ia.exemple.fr"} → {ok, url} normalisée, ou 400 avec la raison
|
|
//
|
|
// Elle remplace l'ancien interrupteur d'exposition via le relais de l'amont :
|
|
// ce chemin exigeait un jeton de relais que ce fork ne permet plus d'obtenir, et
|
|
// l'interrupteur ne pouvait donc qu'échouer. Aucun redémarrage ici — c'est un
|
|
// libellé d'affichage, pas un réglage du moteur.
|
|
func handlePublicAddress(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
var req struct {
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "requête illisible"})
|
|
return
|
|
}
|
|
clean, err := setPublicURL(req.URL)
|
|
if err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "url": clean, "set": clean != ""})
|
|
return
|
|
}
|
|
u := publicURL()
|
|
sendJSON(w, 200, map[string]any{"ok": true, "url": u, "set": u != ""})
|
|
}
|
|
|
|
// localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du
|
|
// trafic sortant), ou "localhost" à défaut.
|
|
//
|
|
// ⚠️ Elle ne sert plus qu'à décrire l'écoute BRUTE DU MOTEUR dans `loki network`
|
|
// (sys_network.go). L'endpoint annoncé à l'utilisateur, lui, est celui de Loki
|
|
// et se calcule tout autrement (web_public_url.go) : en conteneur, cette IP est
|
|
// celle du bridge Docker, que personne ne peut joindre.
|
|
func localIP() string {
|
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
|
if err != nil {
|
|
return "localhost"
|
|
}
|
|
defer conn.Close()
|
|
if a, ok := conn.LocalAddr().(*net.UDPAddr); ok && a.IP != nil {
|
|
return a.IP.String()
|
|
}
|
|
return "localhost"
|
|
}
|
|
|
|
func handleInternet(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
var req struct {
|
|
Enabled *bool `json:"enabled"`
|
|
URL *string `json:"url"`
|
|
Key *string `json:"key"`
|
|
Engine *string `json:"engine"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
// Moteur web : "go" (intégré, rien à installer) ou "crawl4ai" (serveur
|
|
// externe, rendu JavaScript).
|
|
if req.Engine != nil {
|
|
if err := setWebEngine(strings.ToLower(strings.TrimSpace(*req.Engine))); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
// Clé d'accès au serveur Crawl4AI : chaîne vide = on l'enlève.
|
|
if req.Key != nil {
|
|
if err := writeCrawlKey(strings.TrimSpace(*req.Key)); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
reachMu.Lock()
|
|
reachURL = ""
|
|
reachMu.Unlock()
|
|
}
|
|
if req.URL != nil {
|
|
u := strings.TrimRight(strings.TrimSpace(*req.URL), "/")
|
|
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
reachMu.Lock()
|
|
reachURL = "" // invalide le cache de reachability
|
|
reachMu.Unlock()
|
|
}
|
|
if req.Enabled != nil {
|
|
if err := setInternetEnabled(*req.Enabled); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// La clé n'est JAMAIS renvoyée en clair : l'UI n'a besoin que de savoir
|
|
// qu'elle existe (et de ses 4 derniers caractères pour la reconnaître).
|
|
key, hint := crawl4aiKey(), ""
|
|
if n := len([]rune(key)); n > 4 {
|
|
hint = "…" + string([]rune(key)[n-4:])
|
|
} else if key != "" {
|
|
hint = "…"
|
|
}
|
|
sendJSON(w, 200, map[string]any{
|
|
"ok": true,
|
|
"enabled": internetEnabled(),
|
|
"engine": webEngine(),
|
|
"url": crawl4aiURL(),
|
|
"reachable": crawlReachable(),
|
|
"key_set": key != "",
|
|
"key_hint": hint,
|
|
})
|
|
}
|
|
|
|
// ─── Serveurs MCP ────────────────────────────────────────────────────────────
|
|
//
|
|
// Gestion des serveurs MCP depuis l'UI. Même modèle de protection que le reste
|
|
// de /api/* : la clé web est le seul garde. Un serveur MCP stdio exécute du code
|
|
// arbitraire sur l'hôte, au même titre que l'outil `bash` du mode agent — donc
|
|
// réservé de fait au propriétaire de la machine qui détient la clé.
|
|
|
|
// mcpSaveReq est le payload d'ajout/édition d'un serveur MCP.
|
|
type mcpSaveReq struct {
|
|
Name string `json:"name"`
|
|
Command string `json:"command"`
|
|
Args []string `json:"args"`
|
|
Env map[string]string `json:"env"`
|
|
URL string `json:"url"`
|
|
Headers map[string]string `json:"headers"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
// handleMCP renvoie l'état de tous les serveurs MCP configurés (connexion tentée
|
|
// pour ceux activés → l'UI voit l'état réel + les outils découverts).
|
|
func handleMCP(w http.ResponseWriter, r *http.Request) {
|
|
list, err := MCPStatus()
|
|
if err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
|
}
|
|
|
|
// handleMCPSave ajoute ou remplace un serveur MCP.
|
|
func handleMCPSave(w http.ResponseWriter, r *http.Request) {
|
|
var req mcpSaveReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
cfg := MCPServerConfig{
|
|
Command: strings.TrimSpace(req.Command),
|
|
Args: req.Args,
|
|
Env: req.Env,
|
|
URL: strings.TrimSpace(req.URL),
|
|
Headers: req.Headers,
|
|
Enabled: enabled,
|
|
}
|
|
if err := SetMCPServer(req.Name, cfg); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
// Renvoie l'état à jour (avec tentative de connexion) pour un retour immédiat.
|
|
list, _ := MCPStatus()
|
|
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
|
}
|
|
|
|
// handleMCPDelete retire un serveur MCP.
|
|
func handleMCPDelete(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if err := DeleteMCPServer(req.Name); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleMCPToggle active/désactive un serveur MCP.
|
|
func handleMCPToggle(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
On bool `json:"on"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if err := SetMCPServerEnabled(req.Name, req.On); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
list, _ := MCPStatus()
|
|
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
|
}
|
|
|
|
// handleMCPTool masque/démasque un outil précis d'un serveur.
|
|
func handleMCPTool(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Tool string `json:"tool"`
|
|
On bool `json:"on"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if err := SetMCPToolEnabled(req.Name, req.Tool, req.On); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
list, _ := MCPStatus()
|
|
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
|
}
|
|
|
|
// handleMCPTest force une reconnexion d'un serveur et renvoie son état (bouton
|
|
// « tester/rafraîchir » de l'UI).
|
|
func handleMCPTest(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
mcpInvalidate(req.Name)
|
|
list, err := MCPStatus()
|
|
if err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
|
}
|
|
|
|
// handleMem / handleMemSave / handleMemDelete : éditeur web des pages mémoire
|
|
// (memory/<nom>.md). Payload partagé saveReq (name/old/content) ; "name" = nom
|
|
// de fichier de la page.
|
|
func handleMem(w http.ResponseWriter, r *http.Request) {
|
|
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
|
if name == "" {
|
|
sendJSON(w, 200, map[string]any{"name": "", "content": "# nouvelle page\n\nNote ici ce que loki doit retenir entre les sessions.\n"})
|
|
return
|
|
}
|
|
c := MemContent(name)
|
|
if c == "" {
|
|
sendJSON(w, 404, map[string]any{"error": "not found"})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"name": name, "content": c})
|
|
}
|
|
|
|
func handleMemSave(w http.ResponseWriter, r *http.Request) {
|
|
var req saveReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if err := MemSave(req.Name, req.Old, req.Content); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "name": req.Name})
|
|
}
|
|
|
|
func handleMemDelete(w http.ResponseWriter, r *http.Request) {
|
|
var req saveReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if err := MemDelete(req.Name); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
N int `json:"n"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
list, err := ListPresets()
|
|
if err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if req.N < 1 || req.N > len(list) {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
|
|
return
|
|
}
|
|
target := list[req.N-1]
|
|
// On écrit config.env TOUT DE SUITE (c'est lui qui décide du preset actif),
|
|
// puis on répond — le redémarrage du service part en arrière-plan. Passer par
|
|
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
|
|
// les 2 s de vérification de checkStarted : côté UI, le clic paraissait mou et
|
|
// la sélection ne bougeait qu'au bout de plusieurs secondes, pour rien.
|
|
if err := applyPresetFile(target.Path); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target.Path))
|
|
fmt.Println(dim("[info] redémarrage du service..."))
|
|
go func() {
|
|
if err := serviceAction("restart"); err != nil {
|
|
fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err)
|
|
}
|
|
}()
|
|
sendJSON(w, 200, map[string]any{"ok": true, "preset": target.Name})
|
|
}
|
|
|
|
// svcHandler returns an HTTP handler that triggers a start/stop/restart through
|
|
// the cross-platform serviceAction (systemd sous Linux, supervision PID-file
|
|
// sous Windows — voir sys_service_*.go). C'est ce qui permet à un client distant
|
|
// de relancer Loki.
|
|
func svcHandler(action string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
err := serviceAction(action)
|
|
msg := "ok"
|
|
if err != nil {
|
|
msg = err.Error()
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": err == nil, "out": msg})
|
|
}
|
|
}
|
|
|
|
// handleChat is the SSE proxy with tool-calling. The HTTP handler writes raw
|
|
// data: lines matching what the embedded JS expects (delta.content,
|
|
// delta.reasoning_content, delta.tool_used).
|
|
// handleBench runs `runBench` synchronously. Long enough (~30-60s) that we
|
|
// rely on the client side to show a spinner / disable the button.
|
|
func handleBench(w http.ResponseWriter, r *http.Request) {
|
|
nPrompt, nPredict := 2000, 300
|
|
if v := r.URL.Query().Get("prompt"); v != "" {
|
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
|
nPrompt = parsed
|
|
}
|
|
}
|
|
if v := r.URL.Query().Get("n"); v != "" {
|
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
|
nPredict = parsed
|
|
}
|
|
}
|
|
res, err := runBench(nPrompt, nPredict)
|
|
if err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "result": res})
|
|
}
|
|
|
|
// handleBenchLast returns the most recent persisted benchmark, or {ok:false}
|
|
// when none has been run yet.
|
|
func handleBenchLast(w http.ResponseWriter, r *http.Request) {
|
|
sb := loadLastBench()
|
|
if sb == nil {
|
|
sendJSON(w, 200, map[string]any{"ok": false})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At})
|
|
}
|
|
|
|
// chatReq est le corps d'une requête de chat (commun au chat clair et au chat E2E).
|
|
//
|
|
// `messages` et `ctx_used`, que les clients envoyaient du temps où l'historique
|
|
// vivait dans le navigateur, ont été RETIRÉS : depuis que la conversation est
|
|
// possédée par le serveur (chat_conversation.go), plus personne ne les lisait.
|
|
// Les garder laissait croire qu'un client pouvait encore imposer son historique
|
|
// ou son décompte de contexte. Un client qui les envoie encore n'est pas gêné :
|
|
// le décodeur JSON ignore les champs qu'il ne connaît pas.
|
|
type chatReq struct {
|
|
Temperature float64 `json:"temperature"`
|
|
// Surcharges par requête, portées par les agents ajean.link qui ont leurs
|
|
// propres interrupteurs. nil = on prend la configuration de la machine.
|
|
// ⚠️ Elles ne peuvent que RESTREINDRE, jamais rallumer (voir capsFromBody).
|
|
// Tools/Skills : rétro-compat des anciens clients relais.
|
|
Agent *bool `json:"agent"`
|
|
Tools *bool `json:"tools"`
|
|
Skills *bool `json:"skills"`
|
|
// Surcharge par requête de l'accès internet (outils web).
|
|
Internet *bool `json:"internet"`
|
|
// Message = texte du tour à lancer (/api/chat/send) ; From = dernier Seq déjà
|
|
// vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct).
|
|
Message string `json:"message"`
|
|
From int `json:"from"`
|
|
// Files = chemins relatifs des fichiers déposés juste avant par
|
|
// /api/chat/upload ("uploads/rapport.pdf"). Ils sont annoncés au modèle en
|
|
// tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et
|
|
// n'entre dans le contexte que si le modèle décide de le lire.
|
|
Files []string `json:"files"`
|
|
}
|