Rebaptise AJEAN en Loki (fork, lignée conservée)

- module github.com/R0m1k3/Loki, cmd/loki, internal/loki (package loki)
- LOKI_HOME, LOKI_MODEL_DIRS, LOKI_SERVICE, LOKI_DL_CONNS ; /etc/loki ;
  units loki-engine / loki-ui ; binaire et aide CLI
- updateRepo pointe sur R0m1k3/Loki (l'auto-update ne tirera plus les
  binaires AJEAN amont)

Conservé à l'identique : le domaine ajean.link (service de tunnel amont),
les littéraux de migration 0.7.x (migrate_07.go), RELEASE_NOTES.md et
LICENSE (historique et licence de l'amont).

go build/vet/test : verts.
This commit is contained in:
Loki committed 2026-08-14 21:33:15 +00:00
1 parent 2164b46319
commit de6551a153
157 files changed
+928 -928

No files matched your search

+957
View File
@@ -0,0 +1,957 @@
// 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 := readAPIKey()
sendJSON(w, 200, map[string]any{
"ok": true,
"set": k != "",
"key": k,
"masked": maskAPIKey(k),
"port": LLMPort(),
"host": localIP(),
// Accès OpenAI PUBLIC via ajean.link (passthrough SNI, VPS aveugle) : si
// activé, l'URL publique est https://<machine>.oai.ajean.link/v1.
"oai_public": oaiPublicEnabled(),
"machine": machineID(),
})
}
// handleOAIPublic pilote le drapeau d'accès OpenAI public (exposition via
// ajean.link). GET renvoie l'état ; POST {enabled} l'active/coupe en direct
// (aucun redémarrage : le démux du tunnel relit le drapeau à chaque connexion).
func handleOAIPublic(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if req.Enabled != nil {
if err := setOAIPublic(*req.Enabled); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
}
}
sendJSON(w, 200, map[string]any{
"ok": true,
"enabled": oaiPublicEnabled(),
"machine": machineID(),
})
}
// localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du
// trafic sortant), ou "localhost" à défaut. Sert à annoncer l'endpoint OpenAI
// avec une adresse correcte sur le réseau local MÊME quand l'UI est atteinte via
// le tunnel ajean.link (où location.hostname serait le domaine du relais, faux).
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"`
}