Files
Loki/internal/loki/web_api.go
T
Claude 06f0e74c77 Modèles : les supprimer depuis l'interface, et ne charger que des presets
Un .gguf ne pouvait s'effacer qu'en cochant « supprimer aussi le fichier »
au moment de supprimer son preset. Tout ce qui avait été téléchargé sans
preset — une quantization essayée puis abandonnée, un projecteur du mauvais
dépôt — restait donc sur le disque pour des dizaines de gigaoctets, sans
aucun moyen de partir depuis Loki.

L'éditeur de preset porte désormais une ligne « Modèles installés » (repliée,
sous le sélecteur de fichier) : chaque .gguf des dossiers déclarés avec sa
taille, ses tranches, qui le réclame, et un bouton « supprimer ».

Rien n'est réversible ici, d'où deux garde-fous côté serveur, partagés par
/api/models/delete ET par la case de l'éditeur de preset :

  - le modèle CHARGÉ est intouchable — l'effacer sous le moteur ne rend pas
    l'espace tant qu'il tient le descripteur, et le démarrage suivant meurt
    sur un fichier introuvable ;
  - un modèle réclamé par des presets ne part qu'avec `force`, le refus 409
    nommant les presets pour que la seconde confirmation dise ce qu'on casse.
    Supprimer un preset ne peut donc plus emporter le modèle de son voisin.

La suppression emporte toute la famille de tranches et annonce la place
réellement libérée. /api/models marque au passage le modèle chargé et les
presets qui référencent chaque fichier.

Le sélecteur de l'en-tête ne liste plus que des presets. Il proposait aussi
les .gguf du disque, chargés en n'écrivant que MODEL= : le contexte, le NGL
et l'échantillonnage du modèle PRÉCÉDENT restaient en place, et un 27B chargé
par-dessus les réglages d'un 4B partait sur 100k de contexte pour mourir en
OOM sans que rien ne dise pourquoi. Charger un modèle, c'est appliquer TOUS
ses réglages : ça s'appelle un preset, et c'est le seul chemin. /api/models/use
disparaît avec le groupe qu'il servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
2026-09-09 08:53:36 +00:00

1097 lines
39 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"
"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 := ""
loadPct := -1
if !health {
loadErr = modelLoadError()
// Modèle en cours de chargement (service actif, pas d'échec relevé) :
// pourcentage estimé par les octets lus du .gguf (sys_loadpct_linux.go),
// pour que « chargement… » bouge au lieu de laisser croire à un plantage.
if active && loadErr == "" {
loadPct = engineLoadPct()
}
}
// Alerte de persistance : /data non monté en conteneur = données perdues à
// la prochaine recréation. Même bandeau que les autres avertissements.
warn := appWarning()
if dv := dataVolumeWarning(); dv != "" {
if warn != "" {
warn += " — " + dv
} else {
warn = dv
}
}
sendJSON(w, 200, map[string]any{
"state": state,
"active": active,
"health": health,
"port": LLMPort(),
"ctx": ctx,
"version": Version,
"warn": warn, // App Translocation macOS, /data non monté… — vide si tout va bien
"load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon
"load_pct": loadPct, // % estimé du chargement en cours (-1 = non mesurable)
})
}
// 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"
}
case strings.Contains(low, "out of memory"),
strings.Contains(low, "cudamalloc failed"),
strings.Contains(low, "failed to allocate"):
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite"
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
if reason == "" {
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
}
// Backtrace = le moteur a PLANTÉ, pas « il charge encore ». Les motifs
// couvrent le dump de llama.cpp (signal + pile libc) : sans eux, un
// crash-loop s'affichait « chargement… » pour l'éternité. PAS de motif
// « libggml » : les logs de chargement normaux citent les .so
// (« loaded CUDA backend from /app/libggml-cuda.so »).
case strings.Contains(low, "__libc_start_main"),
strings.Contains(l, "SIGSEGV"), strings.Contains(l, "SIGABRT"),
strings.Contains(low, "segmentation fault"),
strings.Contains(low, "aborted (core dumped)"):
if reason == "" {
reason = "le moteur a planté au démarrage (backtrace dans le journal)"
}
}
}
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)})
}
// handleVram expose l'état des cartes à l'interface (jauges du moniteur).
// La lecture nvidia-smi elle-même vit dans web_vram.go, avec le déchargement
// de la VRAM qui s'en sert pour chiffrer la mémoire rendue.
func handleVram(w http.ResponseWriter, r *http.Request) {
gpus := []map[string]any{}
for _, g := range gpuStats() {
gpus = append(gpus, map[string]any{
"name": g.Name, "used": g.Used, "total": g.Total, "util": g.Util, "temp": g.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()
// Ce que le moteur a ouvert, et ce que les presets réclament : l'interface en
// a besoin pour dire lesquels sont supprimables sans rien casser.
refs := modelPresetRefs()
loaded := ""
if cur := strings.TrimSpace(ReadConfig()["MODEL"]); cur != "" {
if q, err := resolveServeModelPath(cur); err == nil {
loaded = normDir(q)
}
}
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,
}
key := normDir(full)
if key == loaded {
m["loaded"] = true
}
if len(refs[key]) > 0 {
m["presets"] = refs[key]
}
// 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
}
// Le preset en service modifié est réappliqué sur-le-champ, moteur relancé
// en arrière-plan comme pour une bascule (handleSwitch) : la réponse ne
// doit pas attendre l'arrêt de llama-server.
newID, applied, err := SavePresetApplying(req.ID, req.Name, req.Content)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if applied {
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
fmt.Println(dim("[info] redémarrage du service..."))
go func() {
if err := serviceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après modification du preset: %v\n", red("[ERREUR]"), err)
}
}()
}
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name, "applied": applied})
}
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 != "" {
modelDeleted, modelErr = deletePresetModel(model)
}
sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr})
}
// deletePresetModel efface le .gguf d'un preset qu'on vient de supprimer, avec
// les MÊMES garde-fous que la suppression directe (/api/models/delete) : la case
// « supprimer aussi le fichier » ne doit pas être un chemin détourné pour
// effacer un modèle qu'on protège ailleurs.
//
// Le modèle CHARGÉ est conservé (le preset supprimé n'était pas forcément celui
// en service : deux presets peuvent désigner le même .gguf), et un modèle encore
// réclamé par un AUTRE preset aussi — sinon supprimer un preset laissait son
// voisin avec un moteur qui meurt au démarrage. Le refus est renvoyé à
// l'interface, qui le dit ; la suppression du preset, elle, a déjà eu lieu.
func deletePresetModel(model string) (deleted, errMsg string) {
p, err := resolveModelPath(model)
if err != nil {
return "", err.Error()
}
if modelIsLoaded(p) {
return "", "modèle chargé par le moteur — fichier conservé"
}
if used := presetsUsingModel(p); len(used) > 0 {
return "", "encore utilisé par : " + strings.Join(used, ", ") + " — fichier conservé"
}
if err := deleteModelFile(p); err != nil {
return "", err.Error()
}
return model, ""
}
// 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})
}
// handleReasoningEffort lit (GET) ou règle (POST) l'intensité du raisonnement.
//
// Même rôle que le sélecteur de l'éditeur de preset, mais applicable depuis la
// barre de saisie sans rouvrir les réglages : la valeur part dans la config
// vivante, elle est relue à chaque requête au moteur (llm_client.go). Aucun
// redémarrage du moteur — `reasoning_effort` voyage dans le corps de la requête.
//
// Attention : appliquer un preset réécrit TOUTE la config, donc la valeur du
// preset reprend la main à ce moment-là. C'est voulu — le réglage reste attaché
// au modèle, la barre de saisie ne fait que le changer à la volée.
func handleReasoningEffort(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Value string `json:"value"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
v := strings.ToLower(strings.TrimSpace(req.Value))
// Liste blanche : cette clé finit dans une requête au moteur, on n'y
// laisse pas passer une chaîne arbitraire venue du navigateur.
ok := v == ""
for _, allowed := range ReasoningEfforts {
if v == allowed {
ok = true
}
}
if !ok {
sendJSON(w, 400, map[string]any{"ok": false, "error": "valeur inconnue : " + v})
return
}
if err := SetConfigKey("REASONING_EFFORT", v); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
}
cfg := ReadConfig()
sendJSON(w, 200, map[string]any{
"ok": true,
"value": reasoningEffortValue(cfg["REASONING_EFFORT"]),
"on": reasoningActive(cfg["REASONING"]),
"choices": ReasoningEfforts,
})
}
// handleMCPCatalog renvoie le catalogue embarqué de serveurs MCP connus.
// Lecture seule : rien n'est installé ici, l'UI ne fait que préremplir la modale
// d'ajout avec la commande de l'entrée choisie.
func handleMCPCatalog(w http.ResponseWriter, r *http.Request) {
entries, err := MCPCatalog()
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "catalog": entries})
}
// 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"`
}