// 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// 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 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"` } // handlePresetCacheRAM : aperçu de la taille du cache de prompts en RAM pour le // preset en cours d'édition (champ CACHE_RAM de l'éditeur). func handlePresetCacheRAM(w http.ResponseWriter, r *http.Request) { var req struct { Content string `json:"content"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } mib, why := cacheRAMPreview(req.Content) sendJSON(w, 200, map[string]any{"ok": true, "mib": mib, "default": engineCacheRAMDefault, "why": why}) } 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 // AVANT le rechargement : la discussion active est désignée par une clé // qu'une version précédente chiffrait par erreur (voir plainStoreKey). healPlainStoreKeys() 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/.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) { // id = identité stable du preset (nom de fichier). Le numéro seul est // dangereux : si la liste a changé depuis l'affichage (réordonnée, preset // ajouté ou supprimé depuis un autre appareil), la même position désigne un // AUTRE preset, et on chargeait le mauvais (AJEAN 0.17.4). n reste accepté // pour les anciens clients. var req struct { N int `json:"n"` ID string `json:"id"` } 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 } var target Preset if id := strings.TrimSpace(req.ID); id != "" { found := false for _, p := range list { if p.ID == id { target, found = p, true break } } if !found { sendJSON(w, 404, map[string]any{"ok": false, "error": "preset introuvable (supprimé ou renommé ?)"}) return } } else { 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"` // TZ = fuseau IANA du navigateur (« Europe/Paris »), retenu pour dater ce // que le modèle planifie. Voir rememberUserTZ. TZ string `json:"tz"` // 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"` // ClientID = identifiant unique d'un ENVOI (/api/chat/send), stable d'un // réessai à l'autre : le serveur ne met pas deux fois le même message en // file (voir Conversation.recentCIDs). ClientID string `json:"cid"` // ConvID = id de la discussion AFFICHÉE par le client (reçu au dernier // caught_up/reset du flux). Renvoyé à chaque (re)abonnement pour que le // serveur détecte qu'un AUTRE appareil a changé de discussion entre-temps // et ordonne un reset avant de rejouer — sinon deux fils fusionnaient. ConvID string `json:"conv_id"` // 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"` }