mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Repris d'AJEAN 0.15.7.
HISTORIQUE PAGINÉ
Rouvrir une longue conversation d'agent rejouait TOUT le journal : des Mo
à travers le tunnel, et des milliers de bulles construites avant
d'afficher la moindre chose. Le client annonce désormais `tail` : au
chargement (et à l'ouverture d'une autre discussion), le serveur ne rejoue
que les 20 derniers échanges, précédés de {history_more: N}. Un bouton en
tête du fil redemande le rejeu complet en gardant la position de lecture.
Un client qui n'envoie pas `tail` (app relais…) reçoit tout, comme avant.
Écart avec l'amont : les états que le client garde et que la partie
masquée avait posés — mode Chat/Code, critères du mode Code — sont réémis
à la coupe (historyHead), ainsi que ctx_used à l'ouverture d'une
discussion. Sans ça, une discussion en mode Code rouverte s'affichait en
mode Chat, et la jauge de contexte à zéro.
iOS : LES TAPS PERDUS PENDANT UNE RÉPONSE
iOS annule un appui si la position de défilement change pendant qu'il a
lieu. Le fil se recalant en bas à chaque token, presque chaque tap tombait
dessus — y compris sur stop. Le suivi est suspendu pendant un contact (et
350 ms après), scrollTop n'est plus réécrit quand on est déjà en bas, et
stop agit dès l'appui sur écran tactile (anti-doublon pour le clic qui
suit).
Vérifié dans le navigateur sur 23 échanges : 20 affichés + « afficher les
3 échanges précédents », puis rejeu complet à position de lecture égale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1358 lines
49 KiB
Go
1358 lines
49 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/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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
|
|
// id du preset actif : un autre appareil a pu basculer, l'UI se
|
|
// resynchronise sans reload (voir loadStatus). AJEAN 0.13.6.
|
|
"preset": activePresetID(),
|
|
})
|
|
}
|
|
|
|
// 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}
|
|
content, _ := ReadPreset(p.ID)
|
|
if content != "" {
|
|
// 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 && benchMatchesPreset(sb, parseEnv(content)) {
|
|
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.
|
|
// Réglage du PROJET ACTIF (AJEAN 0.13.12).
|
|
if err := setMemMode(normalizeMemMode(req.Mode)); 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"
|
|
}
|
|
|
|
// --- Chiffrement de la mémoire (mem_crypto/mem_vault/mem_migrate) -----------
|
|
|
|
// handleMemHealth : état du chiffrement (actif ? verrouillé ? tout chiffré ?),
|
|
// nombre de pages et snapshots locaux.
|
|
func handleMemHealth(w http.ResponseWriter, r *http.Request) {
|
|
sendJSON(w, 200, memHealth())
|
|
}
|
|
|
|
// handleMemEncrypt active le chiffrement. Le corps porte le mot de passe
|
|
// mémoire. La réponse contient la CLÉ DE RÉCUPÉRATION, à afficher UNE fois :
|
|
// elle n'est jamais reconsultable ensuite.
|
|
func handleMemEncrypt(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
rec, err := EnableMemEncryption(req.Password)
|
|
if err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "recovery": rec, "health": memHealth()})
|
|
}
|
|
|
|
// handleMemDecrypt remet la mémoire en clair (exige d'être déverrouillé).
|
|
func handleMemDecrypt(w http.ResponseWriter, r *http.Request) {
|
|
if err := DisableMemEncryption(); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
|
}
|
|
|
|
// handleMemUnlock déverrouille la mémoire : accepte le mot de passe OU la clé
|
|
// de récupération (on tente les deux formes). Charge la DEK en RAM.
|
|
func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Secret string `json:"secret"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if strings.TrimSpace(req.Secret) == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "secret vide"})
|
|
return
|
|
}
|
|
v, err := loadVault()
|
|
if err != nil || v == nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun coffre (mémoire non chiffrée ?)"})
|
|
return
|
|
}
|
|
dek, kind, err := v.unlockWith(req.Secret)
|
|
if err != nil {
|
|
// La clé de récupération se saisit avec tirets/espaces : on retente normalisée.
|
|
if d2, k2, e2 := v.unlockWith(normalizeRecovery(req.Secret)); e2 == nil {
|
|
dek, kind, err = d2, k2, e2
|
|
}
|
|
}
|
|
if err != nil {
|
|
sendJSON(w, 401, map[string]any{"ok": false, "error": "secret incorrect"})
|
|
return
|
|
}
|
|
setMemDEK(dek)
|
|
resumeMemMigration() // si une migration attendait le déverrouillage
|
|
reloadEncryptedStores() // recharge la discussion chiffrée en RAM
|
|
// Migration douce : chiffre ce qui serait resté en clair (discussions,
|
|
// blocs archivés, trackers). Idempotent — n'encode que ce qui ne l'est pas.
|
|
if memEncActive() && memUnlocked() {
|
|
_ = reencryptChatStores()
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "kind": kind, "health": memHealth()})
|
|
}
|
|
|
|
// handleMemLock purge la DEK de la RAM (reverrouille sans redémarrer).
|
|
func handleMemLock(w http.ResponseWriter, r *http.Request) {
|
|
clearMemDEK()
|
|
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
|
}
|
|
|
|
// handleMemSnapshots liste les snapshots locaux de la mémoire (GET) ou en
|
|
// restaure un (POST {id}). Un snapshot est pris avant chaque opération qui
|
|
// touche à tout (chiffrement, déchiffrement) : c'est le filet.
|
|
func handleMemSnapshots(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if err := restoreSnapshot(req.ID); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "snapshots": listSnapshots()})
|
|
}
|
|
|
|
// --- Sauvegarde chiffrée, en FICHIER (pas de relais) ------------------------
|
|
//
|
|
// L'amont sauvegarde vers son relais (ajean.link). Ici, le même paquet chiffré
|
|
// se télécharge et se réimporte À LA MAIN : rien ne quitte la machine sans que
|
|
// tu l'aies demandé, et un conteneur se remonte ailleurs avec un seul fichier.
|
|
// Le corps est chiffré avec la DEK de la mémoire ; l'entête porte le coffre, si
|
|
// bien que la restauration n'a besoin que de la clé de pilotage (ou de la clé
|
|
// de récupération) — aucun état de l'ancienne machine.
|
|
|
|
// handleBackupExport (GET) télécharge le paquet chiffré {mémoire, presets,
|
|
// réglages}. Exige une mémoire chiffrée ET déverrouillée : sans DEK, il n'y a
|
|
// pas de quoi sceller le paquet, et l'envoyer en clair serait un piège.
|
|
func handleBackupExport(w http.ResponseWriter, r *http.Request) {
|
|
v, err := loadVault()
|
|
if err != nil || v == nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun coffre : active d'abord le chiffrement de la mémoire"})
|
|
return
|
|
}
|
|
dek, err := currentDEK()
|
|
if err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "mémoire verrouillée"})
|
|
return
|
|
}
|
|
tarData, err := buildBundleTar()
|
|
if err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
blob, err := buildBackupBlob(v, dek, tarData)
|
|
if err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
name := "loki-backup-" + time.Now().Format("20060102-150405") + ".lkbk"
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(blob)))
|
|
_, _ = w.Write(blob)
|
|
}
|
|
|
|
// handleBackupImport (POST {data: base64, secret}) restaure un paquet exporté.
|
|
// Un snapshot de la mémoire est pris AVANT d'écrire quoi que ce soit.
|
|
func handleBackupImport(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Data string `json:"data"`
|
|
Secret string `json:"secret"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
// "data:application/octet-stream;base64,…" : on ne garde que la charge utile.
|
|
data := req.Data
|
|
if i := strings.Index(data, ","); i >= 0 && strings.HasPrefix(data, "data:") {
|
|
data = data[i+1:]
|
|
}
|
|
blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data))
|
|
if err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier illisible (base64 attendu)"})
|
|
return
|
|
}
|
|
tarData, err := openBackupBlob(blob, req.Secret)
|
|
if err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if err := restoreBundleTar(tarData); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
|
}
|
|
|
|
// 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"`
|
|
// Tail (flux d'abonnement) : nombre de derniers ÉCHANGES à rejouer au
|
|
// chargement ; 0 = tout. nil = ancien client (app relais…), qui ne sait pas
|
|
// afficher le bouton « échanges précédents » : il reçoit toujours tout.
|
|
Tail *int `json:"tail"`
|
|
// 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"`
|
|
}
|