Files
Loki/internal/loki/web_api.go
T
Claude 00b2350fb9 Navigateur piloté, images redressées : reprises d'AJEAN
L'amont (AJEAN 0.15) donne à l'IA le contrôle d'un navigateur ; Loki
embarquait déjà un Chromium pour `web_screenshot` sans jamais le piloter.

Contrôle du navigateur (computer_use.go, computer_cdp.go, browser_grid.go,
portés d'AJEAN) : l'IA ouvre une page en CDP, en reçoit les éléments
interactifs NUMÉROTÉS et agit par numéro (browser_open/snapshot/find/click/
type/key/scroll). Aucune vision requise — un petit modèle texte s'en sort.
Avec un projecteur chargé s'ajoutent browser_screenshot (image quadrillée)
et browser_click_xy. Interrupteur dédié (Réglages, `loki computer`,
/api/computer), sous le mode agent : cliquer et taper dans une page sont des
actions réelles, même niveau de confiance que bash.

Adaptation au conteneur : chromePath cherche D'ABORD le Chromium de
Playwright (PLAYWRIGHT_BROWSERS_PATH, /opt/pw-browsers), le seul navigateur
de l'image — sinon la fonctionnalité se déclarait absente là où le
navigateur est présent. LOKI_CHROME force un chemin, LOKI_CU_HEADFUL ouvre
une vraie fenêtre.

Préparation des images (web_upload_orient.go, porté d'AJEAN) : l'orientation
EXIF est cuite dans les pixels — le projecteur l'ignore et voyait les photos
de téléphone couchées — et le grand côté ramené sous 1568 px. Appliquée aux
pièces jointes, à see_image et aux captures.

Dédup des appels d'outils : bash, bash_bg, bash_tail, see_image et les
browser_* ne sont plus court-circuités sur un appel identique. Relancer la
même commande après avoir modifié un fichier est légitime, et un outil qui
porte une image dans un message à part renvoyait « [déjà fait] » SANS
l'image — le modèle tournait en boucle.

Mode code : le builder ne publie plus de lui-même (pas de commit/push/reset/
rebase ni de redémarrage de service sans demande explicite), reprise de la
leçon d'AJEAN 0.15.4 ; l'inspection en lecture seule reste encouragée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:19:37 +00:00

1187 lines
42 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()
}
// Preset externe : aucun service llama-server local, mais le chat EST prêt
// (il tape vers l'API distante). Sans ça l'interface afficherait un moteur
// éteint pour toujours et laisserait la saisie bloquée.
external := externalActive()
if external {
active, state, health = true, "active", true
}
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)
"external": external, // preset externe : le chat part vers une API distante
})
}
// 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 le dossier de modèles de Loki 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 le dossier de Loki
// (compatibilité avec l'existant, et preset portable d'une machine à l'autre),
// le chemin complet pour un dossier externe.
//
// ⚠️ Le dossier « maison » est modelsDir() — $LOKI_HOME/models — et non
// LokiHome() : c'est là que les téléchargements atterrissent (downloadDestPath)
// et c'est lui que resolveModelPath essaie en premier pour un nom simple. Le
// comparer à LokiHome() ne pouvait JAMAIS être vrai : chaque modèle sortait en
// chemin absolu, l'étiquette « dossier loki » ne s'affichait nulle part, et un
// preset écrit à la main avec MODEL=modele.gguf ne correspondait à aucune
// option du sélecteur — il s'affichait « introuvable » alors que le fichier
// était bien là.
//
// Chaque entrée porte aussi QUI la référence (« used » : noms de presets,
// « active » : la configuration en service). C'est ce qui permet à l'interface
// de proposer la suppression d'un .gguf sans faire disparaître le modèle sous
// le moteur qui tourne.
func handleModels(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{}
seen := map[string]bool{}
home := modelsDir()
refs := buildModelRefIndex()
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,
}
if users, active := refs.lookup(full); len(users) > 0 || active {
if len(users) > 0 {
m["used"] = users
}
m["active"] = active
}
// 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 {
// Preset externe : ni quant, ni raisonnement local à annoncer — on
// l'étiquette et on donne le nom du modèle distant à afficher.
if cfg := parseEnv(content); isExternalConfig(cfg) {
item["external"] = true
if m := strings.TrimSpace(cfg[extKeyModel]); m != "" {
item["model"] = m
}
out = append(out, item)
continue
}
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 := "", ""
freed := int64(0)
if req.DeleteModel && model != "" {
// Le preset vient de disparaître : les « utilisé par » comptés ici sont
// donc les AUTRES presets, et le refus « en service » vise bien le modèle
// que le moteur a ouvert. On ne bloque pas sur un autre preset — c'est un
// geste explicite, coché dans la confirmation — mais on le DIT.
others, _ := modelUsers(model)
if n, err := deleteModelFile(model); err != nil {
modelErr = err.Error()
} else {
modelDeleted, freed = model, n
if len(others) > 0 {
modelErr = "supprimé, mais il était aussi référencé par : " + strings.Join(others, ", ")
}
}
}
sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted,
"modelError": modelErr, "freed": freed})
}
// 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"
}
// handleComputer pilote le pilotage de navigateur (outils browser_*).
//
// GET → {enabled, browser, browser_ok, vision}
// POST {on} → active/désactive (et ferme le navigateur quand on coupe).
func handleComputer(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
On bool `json:"on"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := setComputerUseEnabled(req.On); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
// Couper l'interrupteur ferme le navigateur : le laisser tourner
// garderait un Chromium (et son profil temporaire) sur les bras alors
// que plus aucun outil ne peut s'en servir.
if !req.On {
cdpShutdown()
}
}
bin := chromePath()
sendJSON(w, 200, map[string]any{
"ok": true,
"enabled": computerUseEnabled(),
"browser": bin,
"browser_ok": bin != "",
"vision": visionEnabled(),
})
}
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})
}
// handleModelUse charge un MODÈLE précis (fichier .gguf) sans passer par un
// preset : seul MODEL change dans la configuration active — contexte, NGL et
// échantillonnage sont conservés — et le service redémarre en arrière-plan,
// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
// utilisable même sans avoir créé de preset.
func handleModelUse(w http.ResponseWriter, r *http.Request) {
var req struct {
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Value) == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle manquant"})
return
}
// Le fichier doit exister AVANT d'écrire la config : une faute de frappe ne
// doit pas laisser un moteur qui boucle sur un modèle introuvable.
if _, err := resolveServeModelPath(req.Value); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := SetConfigKey("MODEL", req.Value); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
fmt.Printf("%s MODEL <- %s\n", green("[ok]"), req.Value)
go func() {
if err := serviceAction("restart"); err != nil {
fmt.Println(red("[err] redémarrage : " + err.Error()))
}
}()
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))
// Preset externe : aucun moteur local à (re)démarrer — il n'a pas de MODEL et
// llama-server partirait en boucle de crash. On ARRÊTE au contraire celui qui
// tourne encore : le chat part désormais vers l'API distante, garder le modèle
// en VRAM ne sert plus à rien.
if isExternalConfig(ReadConfig()) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
go func() {
if serviceIsActive() {
if err := serviceAction("stop"); err != nil {
fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err)
}
}
}()
sendJSON(w, 200, map[string]any{"ok": true, "preset": target.Name})
return
}
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"`
}