mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Le lot 1 savait dire qu'un moteur officiel antérieur à b10864 évince trop tôt les points de reprise des hybrides, mais laissait l'utilisateur chercher seul quelle version prendre. Le panneau Moteur propose maintenant la version recommandée, sur clic seulement, et garde un chemin de retour sans réseau. La mise à jour elle-même n'est pas neutre pour le prompt : depuis b10763, llama-server active preserve_reasoning par défaut, et un gabarit qui retirait la réflexion des tours passés (Qwen3.6, clear_thinking) la rend alors — vide, puisque Loki ne la renvoie pas sans REASONING_ECHO. Avant la bascule, Loki lit le gabarit chargé et le verdict de la sonde ; si le rendu va changer, la confirmation le dit et propose REASONING_PRESERVE=off (case cochée seulement quand c'est établi : sur Qwen3.8, off changerait à son tour le rendu). L'utilisateur choisit. Après la bascule, le rendu de conversations synthétiques est comparé via /apply-template entre l'ancien et le nouveau moteur, et le premier écart est signalé. - encart sans réseau (build de confiance < b10864) : gains cités (points de reprise, MTP rapide b11009, MTP qwen4exp b11331), SPEC reste off - POST /api/engine/plan, sur clic : dernier build publié, vérifié ≥ b10864 et tag figé existant ; sans réseau, une erreur et rien d'installé - POST /api/engine/rollback + fichier engine/previous-bin : la version quittée (gardée par le ménage du lot 1) ; le retour la rend « précédente » à son tour - REASONING_PRESERVE=off posée dans le preset actif, seulement si le nouveau moteur connaît --no-reasoning-preserve et que la clé est vide - comparaison du rendu en tâche de fond, après chargement et changement de build_info (un ancien moteur survivant ne fausse pas le verdict) - aucune clé nouvelle ; rien au démarrage ; aucune mise à jour sans clic Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
26 KiB
Go
518 lines
26 KiB
Go
package loki
|
|
|
|
import (
|
|
"bufio"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:generate go run ../../tools/assemble-ui ui
|
|
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
|
|
var uiFS embed.FS
|
|
|
|
// cmdWeb starts the HTTP server on the given port (default 8090).
|
|
func cmdWeb(args []string) error {
|
|
port := 8090
|
|
if len(args) > 0 && args[0] != "" {
|
|
n, err := strconv.Atoi(args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("port invalide: %s", args[0])
|
|
}
|
|
port = n
|
|
}
|
|
mux := newWebMux()
|
|
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
|
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
// Port occupé : on identifie le process qui le tient et on propose de
|
|
// le terminer pour relancer à sa place.
|
|
if !resolvePortConflict(port) {
|
|
return err
|
|
}
|
|
if ln, err = net.Listen("tcp", addr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("[loki web] http://%s (Ctrl-C pour arrêter)\n", addr)
|
|
// Perte de données imminente ? Le dire AU DÉMARRAGE, en rouge, dans le
|
|
// journal du conteneur — l'endroit qu'on regarde quand ça va mal.
|
|
if dv := dataVolumeWarning(); dv != "" {
|
|
fmt.Println(red(dv))
|
|
}
|
|
// Simple conseil de performance (fuse.shfs d'Unraid) : jaune, pas rouge —
|
|
// rien n'est perdu, seul le décodage d'un gros modèle hors RAM ralentit.
|
|
if sh := storageFuseHint(); sh != "" {
|
|
fmt.Println(yellow(sh))
|
|
}
|
|
if readWebKey() == "" {
|
|
fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]"))
|
|
fmt.Printf(" %s\n", bold("loki set-web-key"))
|
|
} else {
|
|
fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]"))
|
|
}
|
|
// Même avertissement pour la surface des complétions : depuis qu'elle est
|
|
// servie ici, elle est joignable partout où l'interface l'est.
|
|
if k, _ := effectiveAPIKeyErr(); k == "" {
|
|
fmt.Printf("%s endpoint OpenAI /v1 NON protégé (aucune clé) — quiconque joint cette adresse peut utiliser le modèle :\n", yellow("[!]"))
|
|
fmt.Printf(" %s\n", bold("loki set-api-key"))
|
|
} else {
|
|
fmt.Printf("%s endpoint OpenAI : http://%s/v1 (clé requise)\n", green("[oai]"), addr)
|
|
}
|
|
|
|
// Accès distant : le tunnel vers le relais est ouvert ICI, dans le process qui
|
|
// sert déjà l'UI, et avec le MÊME mux. C'est la condition d'une conversation
|
|
// unique — elle vit en mémoire (voir chat_conversation.go), donc deux process
|
|
// qui la servent, c'est deux fils qui divergent et s'écrasent l'un l'autre.
|
|
// Sans jeton enregistré, startAppLink ne fait rien : l'UI locale suffit.
|
|
appOwnsLink = true
|
|
appWebMux = mux
|
|
if readLinkToken() != "" {
|
|
killForeignUIWorker() // rescapé d'une version antérieure ou d'une autre copie
|
|
fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL())
|
|
if fp := e2eFingerprint(); fp != "" {
|
|
fmt.Printf("%s empreinte E2E : %s (code d'appairage : loki link code)\n", green("[e2e]"), bold(fp))
|
|
}
|
|
fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled())
|
|
startAppLink(mux)
|
|
}
|
|
// ReadHeaderTimeout : sans lui, une connexion qui n'envoie jamais sa requête
|
|
// immobilise une goroutine pour toujours — et ce port écoute sur 0.0.0.0.
|
|
// Surtout PAS de WriteTimeout ici : il couperait les flux SSE du chat, qui
|
|
// restent ouverts aussi longtemps que l'utilisateur regarde la page — et
|
|
// depuis mountOAI, il couperait aussi les complétions en streaming des
|
|
// clients tiers, en plein milieu d'une génération.
|
|
// gzipHandler : l'UI (~590 Ko) et les gros JSON partent compressés ; les flux
|
|
// SSE et les binaires passent tels quels (voir web_gzip.go).
|
|
srv := &http.Server{Handler: gzipHandler(mux), ReadHeaderTimeout: 10 * time.Second}
|
|
return srv.Serve(ln)
|
|
}
|
|
|
|
// newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être
|
|
// réutilisé par `loki link`, qui sert ce même mux à travers le tunnel sans
|
|
// repasser par un écouteur TCP local.
|
|
var convLoadOnce sync.Once
|
|
|
|
func newWebMux() *http.ServeMux {
|
|
// Projets : amorce « Générale » et migre l'existant (mémoire plate, discussions
|
|
// et tâches sans projet). AVANT le chargement de la conversation, qui consulte
|
|
// déjà le projet actif. Idempotent : sans effet aux démarrages suivants.
|
|
ensureDefaultProject()
|
|
// Charge l'état de conversation persisté (une fois par process : loki web ET
|
|
// loki link serve appellent newWebMux).
|
|
convLoadOnce.Do(LoadConversation)
|
|
// Pré-chauffe les serveurs MCP en tâche de fond : sinon le handshake (plusieurs
|
|
// secondes pour un serveur lancé via npx) est payé par le premier message.
|
|
MCPPrewarm()
|
|
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
|
|
// .part orphelin non reprenable : on nettoie au démarrage.
|
|
cleanStalePartFiles()
|
|
// Les fichiers des versions précédentes vivaient dans un pot commun : on les
|
|
// range dans le dossier de leur discussion (une fois par process).
|
|
migrateConvFiles()
|
|
// Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent
|
|
// en mémoire, aucun .part ne survit utilement à l'arrêt du process.
|
|
cleanStaleUploadParts()
|
|
// Idem pour une installation de moteur : elle meurt avec le process. On
|
|
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
|
|
// plus rien du tout.
|
|
lcRestoreOnce.Do(lcRestore)
|
|
// Chiffrement de la mémoire : une migration interrompue (coupure de courant
|
|
// en plein chiffrement) reprend ici, si la clé est disponible. Sans clé, elle
|
|
// attend le déverrouillage (voir handleMemUnlock). Idempotent.
|
|
resumeMemMigration()
|
|
// Planificateur des tâches : une goroutine, un tic par minute, dans le process
|
|
// qui détient la conversation et le modèle (idempotent, cf. sync.Once).
|
|
StartTaskScheduler()
|
|
// Jeton de tentative du décodage spéculatif automatique : effacé dès que le
|
|
// moteur répond, sans dépendre d'une page ouverte (voir backend_serve_spec.go).
|
|
startSpecAttemptWatch()
|
|
// Optimisation interrompue (Loki ou « loki tune » tué en plein essai) : essai
|
|
// orphelin arrêté, moteur relancé s'il tournait avant (backend_tune_lock.go)
|
|
// — au démarrage, puis toutes les 30 s (un stat sans verrou).
|
|
go tuneRecoverWatch(time.NewTicker(tuneRecoverEvery).C)
|
|
mux := http.NewServeMux()
|
|
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
|
|
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
|
|
mux.HandleFunc("/", handleIndex)
|
|
// Poste distant : deux routes PUBLIQUES (pas derrière la clé de pilotage).
|
|
// L'enrôlement est authentifié par le code d'appairage à usage unique, et le
|
|
// WebSocket par la clé d'appareil (Bearer) — le poste ne connaît pas la clé
|
|
// de pilotage. Voir node_api.go / node_server.go.
|
|
mux.HandleFunc("/api/node/enroll", handleNodeEnroll)
|
|
mux.HandleFunc("/api/node/ws", handleNodeWS)
|
|
mux.HandleFunc("/marked.min.js", func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := uiFS.ReadFile("ui/marked.min.js")
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Write(b)
|
|
})
|
|
// Service worker + manifeste des notifications Web Push (voir push.go / sw.js).
|
|
// PUBLICS (aucun secret) et servis en clair à la RACINE : un service worker
|
|
// doit venir de l'origine même, et son scope est celui de son URL. no-store
|
|
// sur le worker pour qu'une mise à jour de l'image soit toujours reprise.
|
|
mux.HandleFunc("/sw.js", func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := uiFS.ReadFile("ui/sw.js")
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
|
w.Header().Set("Service-Worker-Allowed", "/")
|
|
w.Write(b)
|
|
})
|
|
mux.HandleFunc("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := uiFS.ReadFile("ui/manifest.webmanifest")
|
|
w.Header().Set("Content-Type", "application/manifest+json")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Write(b)
|
|
})
|
|
// Polices embarquées (Inter + JetBrains Mono, sous-ensemble latin). Servies
|
|
// par Loki et non par Google Fonts : une instance locale ou derrière un
|
|
// réseau fermé doit s'afficher correctement sans appeler un tiers, et sans
|
|
// lui signaler qui consulte l'interface.
|
|
mux.HandleFunc("/fonts/", func(w http.ResponseWriter, r *http.Request) {
|
|
name := path.Base(r.URL.Path)
|
|
if !strings.HasSuffix(name, ".woff2") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
b, err := uiFS.ReadFile("ui/fonts/" + name)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "font/woff2")
|
|
// Immuables : leur nom change si leur contenu change (nouvelle version
|
|
// de l'image), donc un an de cache est sans risque.
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Write(b)
|
|
})
|
|
// Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers
|
|
// llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de
|
|
// domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le
|
|
// moteur sur le réseau. Il n'est PAS derrière requireWebAuth : il a sa propre
|
|
// clé, celle des complétions (web_auth.go).
|
|
mountOAI(mux)
|
|
// api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go).
|
|
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
|
api("/api/ping", handlePing)
|
|
api("/api/status", handleStatus)
|
|
api("/api/transcribe", handleTranscribe) // dictée vocale (Parakeet via sherpa-onnx, local)
|
|
api("/api/dictate/config", handleDictateConfig) // réglages : modèle et réactivité
|
|
api("/api/dictate/models", handleDictateModels) // catalogue + présence sur disque
|
|
api("/api/dictate/models/download", handleDictateDownload) // télécharge un modèle
|
|
api("/api/dictate/state", handleDictateState) // serveur allumé, modèle chargé, dernière erreur
|
|
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
|
|
api("/api/vram", handleVram)
|
|
// Rendre la carte à la machine : le modèle occupe la VRAM tant que le moteur
|
|
// tourne, et une autre application qui en a besoin ne trouve rien.
|
|
api("/api/vram/unload", handleVramUnload) // arrête moteur + dictée, rapporte la VRAM rendue
|
|
api("/api/vram/reload", handleVramReload) // relance le moteur (le modèle se recharge)
|
|
api("/api/ram", handleRam)
|
|
api("/api/config", handleConfigEnv)
|
|
api("/api/paths", handlePaths)
|
|
api("/api/update", handleUpdateCheck)
|
|
api("/api/update/apply", handleUpdateApply)
|
|
api("/api/models", handleModels)
|
|
api("/api/models/delete", handleModelDelete)
|
|
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
|
|
api("/api/models/use", handleModelUse) // charger CE .gguf (sélecteur de l'en-tête)
|
|
api("/api/models/download", handleModelDownload)
|
|
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
|
api("/api/models/download/status", handleModelDownloadStatus)
|
|
api("/api/models/download/cancel", handleModelDownloadCancel)
|
|
api("/api/hf/search", handleHFSearch) // chercher un modèle GGUF sur Hugging Face
|
|
api("/api/hf/files", handleHFFiles) // quants et projecteurs d'un dépôt
|
|
api("/api/hf/token", handleHFToken) // jeton pour les dépôts à accès restreint
|
|
api("/api/backends", handleBackends)
|
|
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
|
|
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)
|
|
api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp
|
|
api("/api/llamacpp/check", handleLlamacppCheck) // git fetch + retard sur origin
|
|
api("/api/llamacpp/install", handleLlamacppInstall) // job : clone + build + BIN
|
|
api("/api/llamacpp/install-custom", handleLlamacppInstallCustom) // job : clone d'un fork depuis une URL Git (par preset, sans BIN global)
|
|
api("/api/llamacpp/uninstall-custom", handleLlamacppUninstallCustom) // supprime un backend custom (backends/<name>)
|
|
api("/api/llamacpp/update", handleLlamacppUpdate) // job : pull + rebuild + restart
|
|
api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job
|
|
api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés
|
|
api("/api/llamacpp/prebuilt/check", handleLlamacppPrebuiltCheck) // dernière release officielle vs installée
|
|
api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées
|
|
// Moteur livré par l'image : mise à jour SANS reconstruire l'image, en
|
|
// extrayant /app de l'image officielle llama.cpp (voir web_engine.go).
|
|
api("/api/engine", handleEngineStatus) // version qui tourne + versions installées
|
|
api("/api/engine/check", handleEngineCheck) // dernier build publié vs courant
|
|
api("/api/engine/update", handleEngineUpdate) // job : téléchargement + essai + bascule
|
|
api("/api/engine/use", handleEngineUse) // bascule vers une version installée (ou "image")
|
|
api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée
|
|
api("/api/engine/plan", handleEnginePlan) // sur clic : version visée + effet sur le rendu du gabarit
|
|
api("/api/engine/rollback", handleEngineRollback) // revient au moteur d'avant la dernière bascule
|
|
api("/api/presets", handlePresets)
|
|
api("/api/preset", handlePreset)
|
|
api("/api/preset/save", handlePresetSave)
|
|
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité
|
|
api("/api/preset/moe", handlePresetMoE) // avis MoE / chargement pour le preset édité
|
|
api("/api/preset/autoplace", handlePresetAutoPlace) // copie du preset en placement auto (--fit)
|
|
api("/api/preset/delete", handlePresetDelete)
|
|
// Presets externes : le chat part vers une API OpenAI-compatible distante au
|
|
// lieu du llama-server local (backend_external.go).
|
|
api("/api/preset/external", handlePresetExternal)
|
|
api("/api/preset/external/save", handlePresetExternalSave)
|
|
api("/api/preset/external/test", handlePresetExternalTest)
|
|
api("/api/agent", handleAgent)
|
|
api("/api/agent/toggle", handleAgentToggle)
|
|
api("/api/agent/compact", handleCompactToggle)
|
|
api("/api/apikey", handleAPIKey)
|
|
api("/api/oai/public", handlePublicAddress) // adresse publique (domaine/IP) de cette instance
|
|
api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link)
|
|
api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → loki link
|
|
api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée
|
|
api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé
|
|
api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion
|
|
// Postes distants : gestion réservée au propriétaire (clé de pilotage).
|
|
api("/api/node", handleNodes) // liste + état de connexion
|
|
api("/api/node/pair", handleNodePair) // génère un code d'appairage
|
|
api("/api/node/caps", handleNodeCaps) // règle capacités + dossier racine
|
|
api("/api/node/target", handleNodeTarget) // choisit la machine cible de l'agent
|
|
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
|
|
api("/api/internet", handleInternet)
|
|
api("/api/computer", handleComputer) // pilotage de navigateur (outils browser_*)
|
|
api("/api/mem/health", handleMemHealth) // état chiffrement/verrou/pages/snapshots
|
|
api("/api/mem/encrypt", handleMemEncrypt) // active le chiffrement (renvoie la clé de récupération)
|
|
api("/api/mem/decrypt", handleMemDecrypt) // remet la mémoire en clair
|
|
api("/api/mem/unlock", handleMemUnlock) // déverrouille (mot de passe ou clé de récupération)
|
|
api("/api/mem/lock", handleMemLock) // reverrouille (purge la DEK de la RAM)
|
|
api("/api/mem/snapshots", handleMemSnapshots) // liste + restauration des snapshots locaux
|
|
api("/api/backup/export", handleBackupExport) // télécharge le paquet chiffré (mémoire+presets+réglages)
|
|
api("/api/backup/import", handleBackupImport) // restaure un paquet exporté
|
|
api("/api/mcp", handleMCP)
|
|
api("/api/mcp/catalog", handleMCPCatalog)
|
|
api("/api/mcp/save", handleMCPSave)
|
|
api("/api/mcp/delete", handleMCPDelete)
|
|
api("/api/mcp/toggle", handleMCPToggle)
|
|
api("/api/mcp/tool", handleMCPTool)
|
|
api("/api/mcp/test", handleMCPTest)
|
|
api("/api/memory", handleMemoryMode)
|
|
// Tâches planifiées : l'IA exécute des consignes toute seule sur une fréquence
|
|
// réglable (tasks.go). Le planificateur tourne dans CE process — celui qui
|
|
// détient la conversation et parle au moteur.
|
|
api("/api/tasks", handleTasks)
|
|
api("/api/tasks/save", handleTaskSave)
|
|
api("/api/tasks/delete", handleTaskDelete)
|
|
api("/api/tasks/toggle", handleTaskToggle)
|
|
api("/api/tasks/pause", handleTasksPause)
|
|
api("/api/tasks/run", handleTaskRun)
|
|
api("/api/tasks/stop", handleTaskStop) // arrête la tâche en cours (script : via son registre)
|
|
api("/api/push/key", handlePushKey) // clé publique VAPID (pour s'abonner)
|
|
api("/api/push/subscribe", handlePushSubscribe) // enregistre un abonnement du navigateur
|
|
api("/api/push/unsubscribe", handlePushUnsubscribe) // retire un abonnement
|
|
api("/api/reasoning-effort", handleReasoningEffort) // intensité réglable depuis la barre de saisie
|
|
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
|
api("/api/prefs", handleWebPrefs)
|
|
api("/api/sysprompt", handleSysPrompt)
|
|
// Alias rétro-compat : l'ancien portail ajean.link (dépôt loki-relay) pilote
|
|
// encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E.
|
|
// On les mappe sur le mode agent unifié le temps que le portail soit mis à jour.
|
|
api("/api/tools", handleAgent)
|
|
api("/api/tools/toggle", handleAgentToggle)
|
|
api("/api/skills", handleAgent)
|
|
api("/api/skills/toggle", handleAgentToggle)
|
|
api("/api/mem", handleMem)
|
|
api("/api/mem/save", handleMemSave)
|
|
api("/api/mem/delete", handleMemDelete)
|
|
api("/api/mem/move", handleMemMove)
|
|
// Projets (mémoire, discussions et tâches cloisonnées) et trackers (3e type de
|
|
// mémoire) : un endpoint verbe-orienté chacun, cf. web_projects.go/web_tracker.go.
|
|
api("/api/projects", handleProjects)
|
|
api("/api/tracker", handleTracker)
|
|
api("/api/switch", handleSwitch)
|
|
api("/api/start", svcHandler("start"))
|
|
api("/api/stop", svcHandler("stop"))
|
|
api("/api/restart", svcHandler("restart"))
|
|
api("/api/bench", handleBench) // POST : lance le benchmark en arrière-plan (llm_bench_job.go)
|
|
api("/api/bench/status", handleBenchStatus) // progression, puis résultat
|
|
api("/api/bench/cancel", handleBenchCancel) // POST : annule
|
|
api("/api/bench/last", handleBenchLast)
|
|
api("/api/tune", handleTune) // POST : optimiseur sans perte, en arrière-plan (backend_tune_job.go)
|
|
api("/api/tune/status", handleTuneStatus) // progression, puis résultat
|
|
api("/api/tune/cancel", handleTuneCancel) // POST : annule
|
|
api("/api/tune/apply", handleTuneApply) // POST : écrit le résultat, sur clic seulement
|
|
api("/api/tune/last", handleTuneLast)
|
|
api("/api/perf/summary", handlePerfSummary)
|
|
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
|
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
|
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
|
api("/api/chat/files", handleChatFiles) // liste un dossier de la discussion ouverte (panneau Fichiers)
|
|
api("/api/chat/files/zip", handleChatFilesZip) // toute la discussion en une archive .zip
|
|
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion
|
|
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
|
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
|
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
|
api("/api/chat/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
|
|
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
|
// Discussions : liste, création, bascule, renommage, suppression.
|
|
api("/api/conversations", handleConvList)
|
|
api("/api/conversations/new", handleConvNew)
|
|
api("/api/conversations/switch", handleConvSwitch)
|
|
api("/api/conversations/rename", handleConvRename)
|
|
api("/api/conversations/delete", handleConvDelete)
|
|
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
|
|
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
|
|
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
|
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
|
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
|
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
|
return mux
|
|
}
|
|
|
|
// resolvePortConflict identifies the process listening on `port`, asks the user
|
|
// whether to terminate it, and (on yes) kills it and waits for the port to free.
|
|
// Returns true if the caller should retry binding.
|
|
func resolvePortConflict(port int) bool {
|
|
pid, name := pidOnPort(port)
|
|
if pid == 0 {
|
|
fmt.Printf("%s port %d déjà utilisé, mais le process n'a pas pu être identifié (essaie en root ?)\n", red("[err]"), port)
|
|
return false
|
|
}
|
|
fmt.Printf("%s le port %d est déjà utilisé par %s (PID %d).\n", yellow("[!]"), port, bold(name), pid)
|
|
fmt.Print(dim(" terminer ce process et relancer ? [Y/n] "))
|
|
sc := bufio.NewScanner(os.Stdin)
|
|
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
|
|
fmt.Println(dim(" annulé."))
|
|
return false
|
|
}
|
|
// Arrêt poli d'abord, puis forcé si le port ne se libère pas.
|
|
killPid(pid, false)
|
|
for i := 0; i < 15; i++ {
|
|
time.Sleep(200 * time.Millisecond)
|
|
if p, _ := pidOnPort(port); p == 0 {
|
|
fmt.Printf("%s process %d terminé, redémarrage…\n", green("[ok]"), pid)
|
|
return true
|
|
}
|
|
}
|
|
killPid(pid, true)
|
|
time.Sleep(500 * time.Millisecond)
|
|
if p, _ := pidOnPort(port); p != 0 {
|
|
fmt.Printf("%s impossible de libérer le port %d (PID %d toujours présent)\n", red("[err]"), port, p)
|
|
return false
|
|
}
|
|
fmt.Printf("%s process %d terminé (forcé), redémarrage…\n", green("[ok]"), pid)
|
|
return true
|
|
}
|
|
|
|
// killPid termine un process : kill TERM/KILL sous Unix, taskkill sous Windows
|
|
// (où il n'existe pas d'arrêt « poli » générique — taskkill sans /F échoue sur
|
|
// les process console, donc le second essai passe en forcé).
|
|
func killPid(pid int, force bool) {
|
|
if runtime.GOOS == "windows" {
|
|
args := []string{"/PID", strconv.Itoa(pid)}
|
|
if force {
|
|
args = append(args, "/F")
|
|
}
|
|
_ = hideCmd(exec.Command("taskkill", args...)).Run()
|
|
return
|
|
}
|
|
sig := "-TERM"
|
|
if force {
|
|
sig = "-KILL"
|
|
}
|
|
_ = exec.Command("kill", sig, strconv.Itoa(pid)).Run()
|
|
}
|
|
|
|
// pidOnPort returns the PID and command name of the process listening on the
|
|
// given TCP port, via `ss` (Linux) with an `lsof` fallback, or `netstat -ano`
|
|
// on Windows. Returns 0 if none is found or if the tools can't see it (e.g.
|
|
// owned by another user).
|
|
func pidOnPort(port int) (int, string) {
|
|
if runtime.GOOS == "windows" {
|
|
// netstat -ano : " TCP 0.0.0.0:8090 0.0.0.0:0 LISTENING 1234"
|
|
out, err := hideCmd(exec.Command("netstat", "-ano", "-p", "tcp")).Output()
|
|
if err != nil {
|
|
return 0, ""
|
|
}
|
|
suffix := ":" + strconv.Itoa(port)
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) >= 5 && f[0] == "TCP" && strings.HasSuffix(f[1], suffix) && f[3] == "LISTENING" {
|
|
if pid, err := strconv.Atoi(f[4]); err == nil && pid > 0 {
|
|
return pid, processName(pid)
|
|
}
|
|
}
|
|
}
|
|
return 0, ""
|
|
}
|
|
redir := regexp.MustCompile(`pid=(\d+)`)
|
|
if out, err := exec.Command("ss", "-ltnHp", fmt.Sprintf("sport = :%d", port)).Output(); err == nil {
|
|
if m := redir.FindStringSubmatch(string(out)); m != nil {
|
|
pid, _ := strconv.Atoi(m[1])
|
|
return pid, processName(pid)
|
|
}
|
|
}
|
|
if out, err := exec.Command("lsof", "-ti", fmt.Sprintf("tcp:%d", port), "-sTCP:LISTEN").Output(); err == nil {
|
|
for _, line := range strings.Fields(string(out)) {
|
|
if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil {
|
|
return pid, processName(pid)
|
|
}
|
|
}
|
|
}
|
|
return 0, ""
|
|
}
|
|
|
|
// processName returns a short command name for a PID, or "?" if unknown.
|
|
func processName(pid int) string {
|
|
if runtime.GOOS == "windows" {
|
|
// tasklist CSV : "loki.exe","1234","Console","1","12 345 K"
|
|
out, err := hideCmd(exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH")).Output()
|
|
if err == nil {
|
|
if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 {
|
|
return strings.TrimPrefix(f[0], "\"")
|
|
}
|
|
}
|
|
return "?"
|
|
}
|
|
if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil {
|
|
if n := strings.TrimSpace(string(b)); n != "" {
|
|
return n
|
|
}
|
|
}
|
|
if out, err := exec.Command("ps", "-o", "comm=", "-p", strconv.Itoa(pid)).Output(); err == nil {
|
|
if n := strings.TrimSpace(string(out)); n != "" {
|
|
return n
|
|
}
|
|
}
|
|
return "?"
|
|
}
|
|
|
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
b, err := uiFS.ReadFile("ui/index.html")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
|
w.Write(b)
|
|
}
|
|
|
|
func sendJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// handlePing is a lightweight authenticated endpoint a client hits to verify
|
|
// connectivity AND that its key is valid (200 = bonne clé, 401 = mauvaise clé).
|