// 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// 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// 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/). Refuse les moteurs gérés (canonique / prebuilt) et un // backend actuellement utilisé par le modèle actif (il faut d'abord basculer de // moteur pour ce modèle). func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } _ = json.NewDecoder(r.Body).Decode(&req) name := sanitizeBackendName(req.Name) if name == "" { sendJSON(w, 400, map[string]any{"ok": false, "error": "nom requis"}) return } if name == filepath.Base(defaultRepoDir()) || name == filepath.Base(prebuiltDir()) { sendJSON(w, 400, map[string]any{"ok": false, "error": "ce moteur se gère depuis les cartes ⚡ / 🔧"}) return } dir, err := backendDir(name) if err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } if !isDir(dir) { sendJSON(w, 404, map[string]any{"ok": false, "error": "backend introuvable"}) return } cfgBin := ReadConfig()["BIN"] for _, b := range listBackendBins() { if b["name"] == name { if p, _ := b["path"].(string); samePath(p, cfgBin) { sendJSON(w, 409, map[string]any{"ok": false, "error": "backend utilisé par le modèle actif — bascule d'abord ce modèle sur un autre moteur"}) return } } } if err := os.RemoveAll(dir); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true}) } // handleModels lists *.gguf files (size in bytes) for the preset editor's model // picker, dans LOKI_HOME ET dans les dossiers supplémentaires déclarés (disque // externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de // fichier pour LOKI_HOME (compatibilité avec l'existant), le chemin complet // pour un dossier externe. func handleModels(w http.ResponseWriter, r *http.Request) { out := []map[string]any{} seen := map[string]bool{} home := LokiHome() for _, dir := range modelDirs() { entries, err := os.ReadDir(dir) if err != nil { continue } isHome := normDir(dir) == normDir(home) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { continue } // Modèle découpé : seule la première tranche est un modèle lançable, les // suivantes sont ouvertes par llama-server tout seul. Les lister toutes // donnait trois entrées pour un modèle, dont deux qui échouent au démarrage. if isFollowerShard(e.Name()) { continue } full := filepath.Join(dir, e.Name()) if seen[normDir(full)] { continue } seen[normDir(full)] = true info, _ := e.Info() size := int64(0) if info != nil { size = info.Size() } value := full if isHome { value = e.Name() } m := map[string]any{ "name": e.Name(), "size": size, "value": value, "path": full, "dir": dir, "home": isHome, } // Taille annoncée = la famille entière, et on signale les tranches // manquantes : un modèle incomplet démarre puis meurt sur un tenseur // introuvable, autant le voir avant de le sélectionner. if _, total, ok := shardInfo(e.Name()); ok { m["shards"] = total m["size"] = shardFamilySize(dir, e.Name()) if missing := shardFamilyMissing(dir, e.Name()); len(missing) > 0 { m["missing"] = missing } } out = append(out, m) } } sendJSON(w, 200, out) } func handlePresets(w http.ResponseWriter, r *http.Request) { list, err := ListPresets() if err != nil { sendJSON(w, 500, map[string]any{"error": err.Error()}) return } store := loadBenchStore() out := []map[string]any{} for _, p := range list { item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active} if content, err := ReadPreset(p.ID); err == nil { if q := detectQuant(content); q != "" { item["quant"] = q } if r := presetReasoning(content); reasoningActive(r) { item["reasoning"] = strings.ToLower(r) } } if sb, ok := store[p.ID]; ok { item["bench"] = map[string]any{ "prefill": sb.Result.PromptPerSecond, "decode": sb.Result.PredictedPerSec, "at": sb.At, } } out = append(out, item) } sendJSON(w, 200, out) } func handlePreset(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(newPresetSeed())}) return } content, err := ReadPreset(id) if err != nil { sendJSON(w, 404, map[string]any{"error": "not found"}) return } sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content}) } // presetSaveReq is the preset editor payload. `id` identifies an existing // preset to update ("" creates a new one); `name` is the display name. type presetSaveReq struct { ID string `json:"id"` Name string `json:"name"` Content string `json:"content"` DeleteModel bool `json:"deleteModel"` } // saveReq is the skill editor payload (skills keep name-as-identity + rename). type saveReq struct { Name string `json:"name"` Old string `json:"old"` Content string `json:"content"` } func handlePresetSave(w http.ResponseWriter, r *http.Request) { var req presetSaveReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } newID, err := SavePreset(req.ID, req.Name, req.Content) if err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name}) } func handlePresetDelete(w http.ResponseWriter, r *http.Request) { var req presetSaveReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } // Capture the referenced model before the preset file disappears, so we can // optionally delete the .gguf alongside it. model := "" if req.DeleteModel { if content, err := ReadPreset(req.ID); err == nil { model = modelFromPresetContent(content) } } if err := DeletePreset(req.ID); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } modelDeleted, modelErr := "", "" if req.DeleteModel && model != "" { if err := deleteModelFile(model); err != nil { modelErr = err.Error() } else { modelDeleted = model } } sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr}) } // handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que // l'IA gère via les outils mem_*) — un seul aller-retour pour l'UI. La clé // "skills" est conservée en miroir de "pages" pour l'ancien portail ajean.link. func handleAgent(w http.ResponseWriter, r *http.Request) { pages := MemList() out := []map[string]any{} for _, p := range pages { out = append(out, map[string]any{"name": p.Name, "desc": p.Title}) } sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "compact": compactEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out}) } // handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always). // // GET → {mode} // POST {mode} → persiste MEM_MODE func handleMemoryMode(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { var req struct { Mode string `json:"mode"` } _ = json.NewDecoder(r.Body).Decode(&req) // On normalise via memMode() en réinjectant la valeur : toute entrée // inconnue retombe sur "always", donc on valide en passant par le parseur. m := MemAlways switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) { case MemOff: m = MemOff case MemOnDemand: m = MemOnDemand case MemAlways: m = MemAlways } if err := setMemMode(m); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } } sendJSON(w, 200, map[string]any{"ok": true, "mode": string(memMode())}) } // handleCompactToggle active/désactive le compactage automatique du contexte // (config.env COMPACT). On=compacte (défaut), off=jamais. func handleCompactToggle(w http.ResponseWriter, r *http.Request) { var req struct { On bool `json:"on"` } _ = json.NewDecoder(r.Body).Decode(&req) val := "" if !req.On { val = "off" } if err := SetConfigKey("COMPACT", val); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true, "compact": compactEnabled()}) } func handleAgentToggle(w http.ResponseWriter, r *http.Request) { var req struct { On bool `json:"on"` } _ = json.NewDecoder(r.Body).Decode(&req) if err := setAgentEnabled(req.On); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true, "enabled": agentEnabled()}) } // handleInternet pilote l'accès web de l'IA (serveur Crawl4AI). // // GET → {enabled, url, reachable} // POST {enabled, url, key} → enregistre CRAWL4AI_URL / CRAWL4AI_KEY + le // drapeau .internet_enabled. La clé n'est jamais relue par GET (juste un indice). // // handleAPIKey expose et pilote la clé d'accès à l'endpoint compatible OpenAI // (llama-server /v1). GET renvoie l'état ; POST {action:"generate"|"set"|"clear", // key?} l'écrit puis redémarre le service (llama-server lit --api-key au lancement). func handleAPIKey(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { var req struct { Action string `json:"action"` Key string `json:"key"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } var key string switch req.Action { case "generate": key = genAPIKey() case "set": key = strings.TrimSpace(req.Key) case "clear": key = "" default: sendJSON(w, 400, map[string]any{"ok": false, "error": "action inconnue"}) return } if err := writeAPIKey(key); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } // La clé n'est appliquée qu'au (re)démarrage de llama-server. if serviceIsActive() { _ = serviceAction("restart") } } k, _ := effectiveAPIKeyErr() sendJSON(w, 200, map[string]any{ "ok": true, "set": k != "", "key": k, "masked": maskAPIKey(k), // L'adresse à coller dans un client tiers. Calculée côté serveur (voir // web_public_url.go) : adresse publique enregistrée, sinon l'origine par // laquelle cette requête est arrivée — donc le domaine ou l'IP que // l'utilisateur a réellement tapés. "url": oaiBaseURL(r) + "/v1", "public_url": publicURL(), }) } // handlePublicAddress lit et enregistre l'adresse publique de cette instance — // le nom de domaine ou l'IP par lesquels on l'atteint. // // GET → {ok, url, set} // POST {url:"ia.exemple.fr"} → {ok, url} normalisée, ou 400 avec la raison // // Elle remplace l'ancien interrupteur d'exposition via le relais de l'amont : // ce chemin exigeait un jeton de relais que ce fork ne permet plus d'obtenir, et // l'interrupteur ne pouvait donc qu'échouer. Aucun redémarrage ici — c'est un // libellé d'affichage, pas un réglage du moteur. func handlePublicAddress(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { var req struct { URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": "requête illisible"}) return } clean, err := setPublicURL(req.URL) if err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true, "url": clean, "set": clean != ""}) return } u := publicURL() sendJSON(w, 200, map[string]any{"ok": true, "url": u, "set": u != ""}) } // localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du // trafic sortant), ou "localhost" à défaut. // // ⚠️ Elle ne sert plus qu'à décrire l'écoute BRUTE DU MOTEUR dans `loki network` // (sys_network.go). L'endpoint annoncé à l'utilisateur, lui, est celui de Loki // et se calcule tout autrement (web_public_url.go) : en conteneur, cette IP est // celle du bridge Docker, que personne ne peut joindre. func localIP() string { conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { return "localhost" } defer conn.Close() if a, ok := conn.LocalAddr().(*net.UDPAddr); ok && a.IP != nil { return a.IP.String() } return "localhost" } func handleInternet(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { var req struct { Enabled *bool `json:"enabled"` URL *string `json:"url"` Key *string `json:"key"` Engine *string `json:"engine"` } _ = json.NewDecoder(r.Body).Decode(&req) // Moteur web : "go" (intégré, rien à installer) ou "crawl4ai" (serveur // externe, rendu JavaScript). if req.Engine != nil { if err := setWebEngine(strings.ToLower(strings.TrimSpace(*req.Engine))); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } } // Clé d'accès au serveur Crawl4AI : chaîne vide = on l'enlève. if req.Key != nil { if err := writeCrawlKey(strings.TrimSpace(*req.Key)); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } reachMu.Lock() reachURL = "" reachMu.Unlock() } if req.URL != nil { u := strings.TrimRight(strings.TrimSpace(*req.URL), "/") if err := SetConfigKey("CRAWL4AI_URL", u); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } reachMu.Lock() reachURL = "" // invalide le cache de reachability reachMu.Unlock() } if req.Enabled != nil { if err := setInternetEnabled(*req.Enabled); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } } } // La clé n'est JAMAIS renvoyée en clair : l'UI n'a besoin que de savoir // qu'elle existe (et de ses 4 derniers caractères pour la reconnaître). key, hint := crawl4aiKey(), "" if n := len([]rune(key)); n > 4 { hint = "…" + string([]rune(key)[n-4:]) } else if key != "" { hint = "…" } sendJSON(w, 200, map[string]any{ "ok": true, "enabled": internetEnabled(), "engine": webEngine(), "url": crawl4aiURL(), "reachable": crawlReachable(), "key_set": key != "", "key_hint": hint, }) } // ─── Serveurs MCP ──────────────────────────────────────────────────────────── // // Gestion des serveurs MCP depuis l'UI. Même modèle de protection que le reste // de /api/* : la clé web est le seul garde. Un serveur MCP stdio exécute du code // arbitraire sur l'hôte, au même titre que l'outil `bash` du mode agent — donc // réservé de fait au propriétaire de la machine qui détient la clé. // mcpSaveReq est le payload d'ajout/édition d'un serveur MCP. type mcpSaveReq struct { Name string `json:"name"` Command string `json:"command"` Args []string `json:"args"` Env map[string]string `json:"env"` URL string `json:"url"` Headers map[string]string `json:"headers"` Enabled *bool `json:"enabled"` } // handleMCP renvoie l'état de tous les serveurs MCP configurés (connexion tentée // pour ceux activés → l'UI voit l'état réel + les outils découverts). func handleMCP(w http.ResponseWriter, r *http.Request) { list, err := MCPStatus() if err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true, "servers": list}) } // 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/.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"` }