diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d846ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaires compilés +/jean +/jean.exe +/dist/ + +# Artefacts de build Go +*.exe +*.test +*.out + +# Config locale / secrets runtime (générés à l'install, jamais versionnés) +config.env +.api_key + +# OS / éditeurs +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/README.md b/README.md index 69bddd9..3906fde 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ Service : status | logs état / logs en direct enable | disable démarrage au boot edit éditer $JEAN_HOME/config.env - set-api-key [clé] protéger l'API (Bearer) ; vide = générer, "" = retirer + set-api-key [clé] protéger llama-server (Bearer) ; vide = générer, "" = retirer + set-web-key [clé] protéger l'API de pilotage 'jean web' ; vide = générer, "" = retirer vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) test vérifie que le modèle répond (health + completion) @@ -158,6 +159,37 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramDa La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans `$JEAN_HOME/.api_key`, séparément de `config.env`. +### API de pilotage à distance + +`jean web` expose une API HTTP pour piloter Jean à distance : status, VRAM, liste/sélection de presets (switch de modèle), démarrage/arrêt/redémarrage du service, chat. Pour l'exposer sur internet en sécurité, protège-la par une clé : + +``` +jean set-web-key # génère une clé aléatoire +jean web 8090 # sert l'API/UI sur :8090 +``` + +Chaque appel `/api/*` doit alors présenter la clé (la page HTML/JS, elle, reste publique car sans secret) : + +``` +Authorization: Bearer +``` + +Endpoints utiles pour un client : + +| Méthode | Endpoint | Rôle | +|---------|----------|------| +| GET | `/api/ping` | vérifie connectivité + validité de la clé (200 / 401) | +| GET | `/api/status` | état du service (active, health, port) | +| GET | `/api/vram` | usage GPU/VRAM | +| GET | `/api/presets` | liste des presets (avec l'actif) | +| POST | `/api/switch` `{"n":}` | switch de modèle/preset | +| POST | `/api/start` · `/api/stop` · `/api/restart` | piloter le service | +| POST | `/api/chat` `{"messages":[…]}` | chat (flux SSE) | + +La clé est stockée dans `$JEAN_HOME/.web_key`, distincte de `.api_key` (pilotage ≠ accès complétions), et relue à chaud à chaque requête. Le pilotage du service est cross-platform (systemd sous Linux, supervision par PID-file sous Windows). + +> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, place Jean derrière un reverse-proxy HTTPS (Caddy, nginx) ou un tunnel (Tailscale, Cloudflare Tunnel). + ### Variables d'environnement | Variable | Signification | Défaut | diff --git a/bench.go b/bench.go index 604fdcf..da004cd 100644 --- a/bench.go +++ b/bench.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "path/filepath" "strconv" "strings" "time" @@ -124,11 +126,86 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) { } elapsed := time.Since(t0).Seconds() t := parsed.Timings - return &benchResult{ + res := &benchResult{ PromptN: t.PromptN, PromptMs: t.PromptMs, PromptPerSecond: t.PromptPerSecond, PredictedN: t.PredictedN, PredictedMs: t.PredictedMs, PredictedPerSec: t.PredictedPerSec, Elapsed: elapsed, - }, nil + } + saveLastBench(res) + saveBenchForActivePreset(res) + return res, nil +} + +// lastBenchPath stores the most recent benchmark result so the web UI can show +// it without re-running. Lives in JEAN_HOME so it survives restarts. +func lastBenchPath() string { return filepath.Join(JeanHome(), ".last_bench.json") } + +// savedBench is a benchResult plus the model it was run against and a timestamp. +type savedBench struct { + Result benchResult `json:"result"` + Model string `json:"model"` + At int64 `json:"at"` +} + +// saveLastBench persists res to JEAN_HOME/.last_bench.json (best-effort). +func saveLastBench(res *benchResult) { + sb := savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} + if b, err := json.Marshal(sb); err == nil { + _ = os.WriteFile(lastBenchPath(), b, 0o644) + } +} + +// loadLastBench reads the persisted benchmark, or nil if none/unreadable. +func loadLastBench() *savedBench { + b, err := os.ReadFile(lastBenchPath()) + if err != nil { + return nil + } + var sb savedBench + if json.Unmarshal(b, &sb) != nil { + return nil + } + return &sb +} + +// benchStorePath holds per-preset benchmark results so the UI can show each +// preset's measured performance. Keyed by preset name. +func benchStorePath() string { return filepath.Join(JeanHome(), ".bench_presets.json") } + +// loadBenchStore returns the per-preset bench map (empty if none/unreadable). +func loadBenchStore() map[string]savedBench { + m := map[string]savedBench{} + b, err := os.ReadFile(benchStorePath()) + if err != nil { + return m + } + _ = json.Unmarshal(b, &m) + return m +} + +// saveBenchForActivePreset records res under the name of the currently active +// preset (the one whose config.env matches the live config). No-op if no preset +// matches — the bench still lives in .last_bench.json via saveLastBench. +func saveBenchForActivePreset(res *benchResult) { + list, err := ListPresets() + if err != nil { + return + } + id := "" + for _, p := range list { + if p.Active { + id = p.ID + break + } + } + if id == "" { + return + } + m := loadBenchStore() + m[id] = savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} + if b, err := json.Marshal(m); err == nil { + _ = os.WriteFile(benchStorePath(), b, 0o644) + } } func cmdBench(args []string) error { diff --git a/install.go b/install.go deleted file mode 100644 index def8844..0000000 --- a/install.go +++ /dev/null @@ -1,196 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "os/user" - "path/filepath" - "strings" -) - -const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart -# Le service systemd lit ce fichier et lance ton binaire llama.cpp. - -# Chemins -BIN="/usr/local/bin/llama-server" -MODEL="/home/USER/models/your-model.gguf" - -# Serveur -PORT="8080" -HOST="0.0.0.0" - -# Inference -CTX="32768" -BATCH="2048" -UBATCH="512" -NGL="999" - -# Args supplémentaires passés à llama-server -EXTRA_ARGS="" -` - -const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by jean). -%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s -` - -const serviceUnitTemplate = `[Unit] -Description=JEAN llama.cpp server -After=network.target - -[Service] -Type=simple -User=%s -WorkingDirectory=%s -ExecStart=%s -Restart=on-failure -RestartSec=3 - -[Install] -WantedBy=multi-user.target -` - -func cmdInstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)") - } - targetUser := os.Getenv("SUDO_USER") - if targetUser == "" { - targetUser = "root" - } - for _, a := range args { - if strings.HasPrefix(a, "--user=") { - targetUser = strings.TrimPrefix(a, "--user=") - } - } - u, err := user.Lookup(targetUser) - if err != nil { - return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) - } - jeanHome := DefaultJeanHome - if v := os.Getenv("JEAN_HOME"); v != "" { - jeanHome = v - } - svc := serviceName() - - fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" JEAN_HOME = %s\n", jeanHome) - fmt.Printf(" service = %s\n", svc) - - // 1. Create directories - for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { - if err := os.MkdirAll(d, 0o755); err != nil { - return err - } - } - - // 2. Drop a config.env if none exists - conf := filepath.Join(jeanHome, "config.env") - if _, err := os.Stat(conf); os.IsNotExist(err) { - body := strings.ReplaceAll(configTemplate, "USER", targetUser) - if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { - return err - } - fmt.Printf(" %s écrit %s\n", green("✓"), conf) - } - - // 3. Symlink current binary to /usr/local/bin/jean - self, err := os.Executable() - if err != nil { - return err - } - target := "/usr/local/bin/jean" - _ = os.Remove(target) - if err := os.Symlink(self, target); err != nil { - // fall back to copy if symlink fails (e.g. cross-fs) - if data, err := os.ReadFile(self); err == nil { - _ = os.WriteFile(target, data, 0o755) - } - } - fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) - - // 4. Drop /etc/default/jean so root invocations resolve JEAN_HOME correctly. - defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) - if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil { - return err - } - fmt.Printf(" %s /etc/default/jean\n", green("✓")) - - // 5. Write the systemd unit (ExecStart = `jean serve`, no start.sh needed) - unit := fmt.Sprintf(serviceUnitTemplate, targetUser, jeanHome, "/usr/local/bin/jean serve") - unitPath := "/etc/systemd/system/" + svc + ".service" - if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { - return err - } - fmt.Printf(" %s %s\n", green("✓"), unitPath) - - // 5. Sudoers drop-in - sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) - sudoersPath := "/etc/sudoers.d/jean-" + svc - if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { - return err - } - fmt.Printf(" %s %s\n", green("✓"), sudoersPath) - - // 6. chown JEAN_HOME contents to target user - chown(jeanHome, u) - - // 7. systemd reload - _ = exec.Command("systemctl", "daemon-reload").Run() - - fmt.Println() - fmt.Printf("%s installation terminée.\n", green("[ok]")) - fmt.Printf("\nProchaines étapes :\n") - fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit")) - fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") - fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" jean start")) - fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web")) - return nil -} - -func cmdUninstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("jean uninstall doit être exécuté en root") - } - svc := serviceName() - keepData := false - for _, a := range args { - if a == "--purge" { - keepData = false - } - if a == "--keep-data" { - keepData = true - } - } - _ = exec.Command("systemctl", "stop", svc).Run() - _ = exec.Command("systemctl", "disable", svc).Run() - for _, p := range []string{ - "/etc/systemd/system/" + svc + ".service", - "/etc/sudoers.d/jean-" + svc, - "/etc/default/jean", - "/usr/local/bin/jean", - } { - if err := os.Remove(p); err == nil { - fmt.Printf(" %s %s\n", green("✓"), p) - } - } - _ = exec.Command("systemctl", "daemon-reload").Run() - if !keepData { - fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)")) - } - fmt.Println(green("[ok]") + " désinstallé") - return nil -} - -// chown recursively changes ownership of path to the given user/group. -func chown(path string, u *user.User) { - var uid, gid int - fmt.Sscanf(u.Uid, "%d", &uid) - fmt.Sscanf(u.Gid, "%d", &gid) - filepath.Walk(path, func(p string, info os.FileInfo, err error) error { - if err == nil { - _ = os.Chown(p, uid, gid) - } - return nil - }) -} diff --git a/install_unix.go b/install_unix.go index 089f491..2e0cd85 100644 --- a/install_unix.go +++ b/install_unix.go @@ -48,6 +48,13 @@ ExecStart=%s Restart=on-failure RestartSec=3 +# Priorité CPU : on remonte le process pour qu'il ne soit pas dépriorisé face +# aux tâches de fond (sampling/orchestration côté CPU pèsent sur le débit même +# en inference GPU). Nice négatif + scheduling normal réactif. +Nice=-10 +CPUSchedulingPolicy=other +CPUAccounting=yes + [Install] WantedBy=multi-user.target ` diff --git a/llm.go b/llm.go index 2f9b193..11cf93f 100644 --- a/llm.go +++ b/llm.go @@ -157,8 +157,17 @@ type streamChunk struct { // runChat drives the full inference loop including tool calling. // On finish_reason="tool_calls" we execute locally, append a "tool" message // and call /v1/chat/completions again — up to 8 iterations as a safety cap. +const thinkClose = "" + func runChat(messages []Message, temperature float64, cb ChatCallback) error { tools := EnabledTools() + // Some backends (vanilla llama.cpp builds) don't populate `reasoning_content` + // in streaming mode: the model's block (opened by the chat template) + // arrives inline in `content`, terminated by a literal . When + // reasoning is enabled we split that out ourselves so the UI's reasoning + // bubble works regardless of backend. The ik_llama.cpp fork already sends + // reasoning_content, in which case we leave content untouched. + reasoningOn := reasoningActive(ReadConfig()["REASONING"]) for iter := 0; iter < 8; iter++ { payload := map[string]any{ "model": "jean", @@ -185,6 +194,10 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { toolCalls := map[int]*ToolCall{} assistantContent := strings.Builder{} finishReason := "" + // Per-completion reasoning-split state (see reasoningOn comment above). + sawReasoningField := false + thinkOpen := reasoningOn + var thinkTail strings.Builder // scanner with a big buffer — some chunks include large arguments JSON sc := bufio.NewScanner(resp.Body) sc.Buffer(make([]byte, 0, 64*1024), 1<<20) @@ -236,6 +249,9 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { continue } if ch.Delta.ReasoningContent != "" { + // Backend already separates reasoning — trust it, disable our split. + sawReasoningField = true + thinkOpen = false if !cb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) { aborted = true break @@ -243,12 +259,50 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { } if ch.Delta.Content != "" { assistantContent.WriteString(ch.Delta.Content) - if !cb(StreamEvent{Content: ch.Delta.Content}) { - aborted = true - break + if !thinkOpen || sawReasoningField { + if !cb(StreamEvent{Content: ch.Delta.Content}) { + aborted = true + break + } + } else { + // Inside the prompt-opened block: stream to the + // reasoning bubble until the closing , then switch + // the remainder to normal content. + thinkTail.WriteString(ch.Delta.Content) + s := thinkTail.String() + if i := strings.Index(s, thinkClose); i >= 0 { + reason := s[:i] + after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n") + thinkOpen = false + thinkTail.Reset() + if reason != "" && !cb(StreamEvent{Reasoning: reason}) { + aborted = true + break + } + if after != "" && !cb(StreamEvent{Content: after}) { + aborted = true + break + } + } else { + // Hold back a tail that could be a partial "". + keep := len(thinkClose) - 1 + if len(s) > keep { + emit := s[:len(s)-keep] + thinkTail.Reset() + thinkTail.WriteString(s[len(s)-keep:]) + if !cb(StreamEvent{Reasoning: emit}) { + aborted = true + break + } + } + } } } } + // Flush any buffered reasoning if the stream ended mid-think. + if !aborted && thinkOpen && thinkTail.Len() > 0 { + cb(StreamEvent{Reasoning: thinkTail.String()}) + } resp.Body.Close() if aborted { return nil diff --git a/main.go b/main.go index 9bb73f7..9eb8c30 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,8 @@ func main() { mustExit(editConfig()) case "set-api-key": mustExit(cmdSetAPIKey(args)) + case "set-web-key": + mustExit(cmdSetWebKey(args)) case "vram": mustExit(showVram()) case "gpu": @@ -73,7 +75,8 @@ Service: status | logs état / logs en direct enable | disable auto-démarrage au boot edit éditer $JEAN_HOME/config.env - set-api-key [clé] protéger l'API (clé Bearer); vide = générer, "" = retirer + set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer + set-web-key [clé] protéger l'API de pilotage 'jean web'; vide = générer, "" = retirer vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) test vérifie que l'IA répond (health + completion) diff --git a/models.go b/models.go new file mode 100644 index 0000000..e1c3e0b --- /dev/null +++ b/models.go @@ -0,0 +1,326 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +// quantSegRe matches a single name segment that looks like a GGUF quantization +// token: Q8_0, Q6_K, Q5_K_M, Q4_K_XL, IQ4_XS, IQ3_XXS, Q4, 4bpw, BF16, F16… +var quantSegRe = regexp.MustCompile(`(?i)^(I?Q\d+(_[A-Za-z0-9]+)*|\d+BPW|BF16|FP16|F16|FP32|F32)$`) + +// quantFromName extracts a quantization tag from a model filename by splitting +// on '-' and '.' and keeping the longest segment that looks like a quant token. +// Returns "" when nothing matches. +func quantFromName(name string) string { + base := name + if dot := strings.LastIndexByte(base, '.'); dot >= 0 && strings.EqualFold(base[dot:], ".gguf") { + base = base[:dot] + } + segs := strings.FieldsFunc(base, func(r rune) bool { return r == '-' || r == '.' }) + best := "" + for _, seg := range segs { + if quantSegRe.MatchString(seg) && len(seg) > len(best) { + best = seg + } + } + return strings.ToUpper(best) +} + +// presetReasoning returns the raw REASONING= value from a preset's config.env +// body, or "" if absent. +func presetReasoning(content string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + i := strings.IndexByte(s, '=') + if i < 0 { + continue + } + if strings.EqualFold(strings.TrimSpace(s[:i]), "REASONING") { + return strings.Trim(strings.TrimSpace(s[i+1:]), `"`) + } + } + return "" +} + +// reasoningActive reports whether a REASONING= value enables reasoning. serve.go +// passes the flag whenever the value is non-empty, but an explicit off/none is +// treated here as disabled so the UI badge isn't misleading. +func reasoningActive(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "", "off", "none", "false", "0", "no", "disable", "disabled": + return false + } + return true +} + +// detectQuant returns the quantization tag for a preset: an explicit QUANT= line +// (manual override, with or without a leading '#') wins; otherwise it is +// auto-detected from the MODEL= filename. Returns "" when unknown. +func detectQuant(content string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#")) + i := strings.IndexByte(s, '=') + if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "QUANT") { + if v := strings.Trim(strings.TrimSpace(s[i+1:]), `"`); v != "" { + return strings.ToUpper(v) + } + } + } + return quantFromName(modelFromPresetContent(content)) +} + +// modelFilePath resolves a model file name to a path inside JEAN_HOME, refusing +// anything that would escape it (path traversal). config.env conventionally +// stores only the basename, so we deliberately strip any directory component. +func modelFilePath(name string) (string, error) { + base := filepath.Base(strings.TrimSpace(name)) + if base == "" || base == "." || base == string(filepath.Separator) { + return "", fmt.Errorf("nom de modèle invalide") + } + if !strings.HasSuffix(strings.ToLower(base), ".gguf") { + return "", fmt.Errorf("seuls les fichiers .gguf peuvent être supprimés") + } + return filepath.Join(JeanHome(), base), nil +} + +// modelFromPresetContent extracts the MODEL= value (basename) from a preset's +// config.env body, or "" if absent. +func modelFromPresetContent(content string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + i := strings.IndexByte(s, '=') + if i < 0 { + continue + } + if strings.TrimSpace(s[:i]) == "MODEL" { + v := strings.Trim(strings.TrimSpace(s[i+1:]), "\"") + return filepath.Base(v) + } + } + return "" +} + +// deleteModelFile removes a .gguf file from JEAN_HOME after validating the name. +func deleteModelFile(name string) error { + p, err := modelFilePath(name) + if err != nil { + return err + } + if err := os.Remove(p); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("modèle introuvable: %s", filepath.Base(p)) + } + return err + } + return nil +} + +// handleModelDelete deletes a single .gguf from JEAN_HOME. +func handleModelDelete(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 := deleteModelFile(req.Name); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// ---- Hugging Face downloads ------------------------------------------------- + +// dlState tracks a single in-flight (or finished) model download. +type dlState struct { + Filename string `json:"filename"` + URL string `json:"url"` + Total int64 `json:"total"` + Done int64 `json:"done"` + Finished bool `json:"finished"` + Err string `json:"error"` + StartedAt int64 `json:"started_at"` +} + +var ( + dlMu sync.Mutex + dlDownloads = map[string]*dlState{} // keyed by filename +) + +// normalizeHFURL turns a Hugging Face "blob" page URL into a direct "resolve" +// download URL, and leaves already-direct URLs untouched. Returns the URL to +// fetch and the target filename. +func normalizeHFURL(raw string) (string, string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("lien vide") + } + u, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("lien invalide: %v", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", "", fmt.Errorf("lien invalide (http/https attendu)") + } + // huggingface.co//blob// → /resolve// + if strings.Contains(u.Host, "huggingface.co") { + u.Path = strings.Replace(u.Path, "/blob/", "/resolve/", 1) + } + name := path.Base(u.Path) + if name == "" || name == "/" || name == "." { + return "", "", fmt.Errorf("impossible de déduire le nom du fichier depuis le lien") + } + if !strings.HasSuffix(strings.ToLower(name), ".gguf") { + return "", "", fmt.Errorf("le lien doit pointer vers un fichier .gguf") + } + return u.String(), name, nil +} + +// handleModelDownload kicks off a background download of a .gguf from a URL +// (typically Hugging Face) into JEAN_HOME. Progress is polled via +// /api/models/download/status. +func handleModelDownload(w http.ResponseWriter, r *http.Request) { + 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": err.Error()}) + return + } + dlURL, name, err := normalizeHFURL(req.URL) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + dest, err := modelFilePath(name) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + + dlMu.Lock() + if st, ok := dlDownloads[name]; ok && !st.Finished { + dlMu.Unlock() + sendJSON(w, 409, map[string]any{"ok": false, "error": "téléchargement déjà en cours pour " + name}) + return + } + if _, err := os.Stat(dest); err == nil { + dlMu.Unlock() + sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name}) + return + } + st := &dlState{Filename: name, URL: dlURL, StartedAt: time.Now().Unix()} + dlDownloads[name] = st + dlMu.Unlock() + + go runDownload(st, dlURL, dest) + sendJSON(w, 200, map[string]any{"ok": true, "filename": name}) +} + +// runDownload streams the URL to a .part file then renames it on success. +func runDownload(st *dlState, dlURL, dest string) { + finish := func(e error) { + dlMu.Lock() + if e != nil { + st.Err = e.Error() + } + st.Finished = true + dlMu.Unlock() + } + + req, err := http.NewRequest("GET", dlURL, nil) + if err != nil { + finish(err) + return + } + // HF gated/private repos may need a token; reuse the same key store if set. + if k := os.Getenv("HF_TOKEN"); k != "" { + req.Header.Set("Authorization", "Bearer "+k) + } + client := &http.Client{Timeout: 0} // large files: no overall timeout + resp, err := client.Do(req) + if err != nil { + finish(err) + return + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + finish(fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)) + return + } + dlMu.Lock() + st.Total = resp.ContentLength + dlMu.Unlock() + + tmp := dest + ".part" + f, err := os.Create(tmp) + if err != nil { + finish(err) + return + } + buf := make([]byte, 1<<20) // 1 MiB + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := f.Write(buf[:n]); werr != nil { + f.Close() + _ = os.Remove(tmp) + finish(werr) + return + } + dlMu.Lock() + st.Done += int64(n) + dlMu.Unlock() + } + if rerr == io.EOF { + break + } + if rerr != nil { + f.Close() + _ = os.Remove(tmp) + finish(rerr) + return + } + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + finish(err) + return + } + if err := os.Rename(tmp, dest); err != nil { + _ = os.Remove(tmp) + finish(err) + return + } + finish(nil) +} + +// handleModelDownloadStatus returns the state of all known downloads this run. +func handleModelDownloadStatus(w http.ResponseWriter, r *http.Request) { + dlMu.Lock() + out := make([]dlState, 0, len(dlDownloads)) + for _, st := range dlDownloads { + out = append(out, *st) + } + dlMu.Unlock() + sendJSON(w, 200, out) +} diff --git a/platform_unix.go b/platform_unix.go index bfa083f..cb77175 100644 --- a/platform_unix.go +++ b/platform_unix.go @@ -6,6 +6,9 @@ import ( "context" "os" "os/exec" + "path/filepath" + "sort" + "strings" "syscall" ) @@ -17,13 +20,41 @@ func defaultJeanHome() string { return "/etc/jean" } func defaultEditor() string { return "nano" } // setLibraryPath ensures llama-server can load shared libs bundled next to the -// binary by prepending dir to LD_LIBRARY_PATH. +// binary by prepending dir to LD_LIBRARY_PATH. It also appends the CUDA runtime +// lib directories: a CUDA-enabled build links against libcudart/libcublas, which +// live under /usr/local/cuda*/lib64 and are often absent from the global ld +// cache — without them llama-server fails to load the GPU backend (or runs +// degraded), costing a large chunk of throughput. func setLibraryPath(dir string) { - ld := dir + parts := []string{dir} + parts = append(parts, cudaLibDirs()...) if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" { - ld = ld + ":" + existing + parts = append(parts, existing) } - _ = os.Setenv("LD_LIBRARY_PATH", ld) + _ = os.Setenv("LD_LIBRARY_PATH", strings.Join(parts, ":")) +} + +// cudaLibDirs returns the CUDA runtime lib directories present on the machine, +// preferring the highest-versioned install. Empty when no CUDA toolkit is found. +func cudaLibDirs() []string { + var dirs []string + seen := map[string]bool{} + add := func(d string) { + if d != "" && !seen[d] && isDir(d) { + seen[d] = true + dirs = append(dirs, d) + } + } + // Default symlink first (usually points at the active toolkit). + add("/usr/local/cuda/lib64") + add("/usr/local/cuda/targets/x86_64-linux/lib") + // Versioned installs, newest last so it takes precedence in PATH order. + versioned, _ := filepath.Glob("/usr/local/cuda-*/lib64") + sort.Strings(versioned) + for i := len(versioned) - 1; i >= 0; i-- { + add(versioned[i]) + } + return dirs } // execServer replaces the current process with llama-server (so systemd diff --git a/presets.go b/presets.go index 1e57588..38eae6a 100644 --- a/presets.go +++ b/presets.go @@ -11,17 +11,48 @@ import ( "sort" "strconv" "strings" - "time" ) +// A preset's IDENTITY is its filename (ID, without the .env suffix), which is +// always unique. Its DISPLAY name lives in an optional `# NAME=` line inside the +// file, so several presets can share the same display name without overwriting +// each other (their filenames differ — see uniquePresetID). type Preset struct { - Name string + ID string // filename without .env — stable, unique identity + Name string // display name (# NAME= line, falls back to ID) Path string Active bool } -// ListPresets returns all configs/*.env files sorted by name, marking the one -// whose contents match the current config.env (by SHA-1). +var nameLineRe = regexp.MustCompile(`(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$`) + +// presetDisplayName extracts the `# NAME=` value from a preset body, falling +// back to `fallback` (the filename id) when absent — keeps old presets working. +func presetDisplayName(content, fallback string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#")) + i := strings.IndexByte(s, '=') + if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "NAME") { + if v := strings.Trim(strings.TrimSpace(s[i+1:]), `"`); v != "" { + return v + } + } + } + return fallback +} + +// withDisplayName ensures the body carries a `# NAME=` line (replacing an +// existing one, or prepended otherwise). +func withDisplayName(content, name string) string { + line := "# NAME=" + name + if nameLineRe.MatchString(content) { + return nameLineRe.ReplaceAllString(content, line) + } + return line + "\n" + content +} + +// ListPresets returns all configs/*.env, marking the one whose contents match +// the current config.env (by SHA-1), sorted by display name. func ListPresets() ([]Preset, error) { dir := presetsDir() _ = os.MkdirAll(dir, 0o755) @@ -45,8 +76,10 @@ func ListPresets() ([]Preset, error) { continue } h := sha1.Sum(b) + id := strings.TrimSuffix(e.Name(), ".env") out = append(out, Preset{ - Name: strings.TrimSuffix(e.Name(), ".env"), + ID: id, + Name: presetDisplayName(string(b), id), Path: p, Active: hex.EncodeToString(h[:]) == cur, }) @@ -55,12 +88,53 @@ func ListPresets() ([]Preset, error) { return out, nil } -var presetNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) +// uniquePresetID derives a unique filename id from a display name, appending +// " (2)", " (3)"… on collision so duplicate names never overwrite. +func uniquePresetID(name string) (string, error) { + base := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(name)) + if base == "" { + base = "preset" + } + cand := base + for n := 2; n < 10000; n++ { + p, err := safePresetPath(cand) + if err != nil { + return "", err + } + if _, err := os.Stat(p); os.IsNotExist(err) { + return cand, nil + } + cand = fmt.Sprintf("%s (%d)", base, n) + } + return "", fmt.Errorf("impossible de générer un nom de fichier unique") +} + +// validPresetName accepts any name (spaces, accents, parentheses…) as long as it +// stays a single safe filename: no path separators, no control chars, and not a +// reserved directory entry. Path containment is double-checked in safePresetPath. +func validPresetName(name string) error { + if name == "" { + return fmt.Errorf("nom vide") + } + if name == "." || name == ".." { + return fmt.Errorf("nom réservé") + } + if strings.ContainsAny(name, `/\`+"\x00") { + return fmt.Errorf(`le nom ne peut pas contenir / ni \`) + } + for _, r := range name { + if r < 0x20 { + return fmt.Errorf("le nom contient un caractère de contrôle invalide") + } + } + return nil +} // safePresetPath validates name and returns its resolved path inside presetsDir. func safePresetPath(name string) (string, error) { - if !presetNameRe.MatchString(name) { - return "", fmt.Errorf("nom invalide (alphanum, ._-)") + name = strings.TrimSpace(name) + if err := validPresetName(name); err != nil { + return "", err } root, err := filepath.Abs(presetsDir()) if err != nil { @@ -84,14 +158,10 @@ func SwitchToPreset(target string) error { if err != nil { return err } - ts := time.Now().Format("20060102-150405") - if cur, err := os.ReadFile(confPath()); err == nil { - _ = os.WriteFile(confPath()+".bak.switch-"+ts, cur, 0o644) - } if err := os.WriteFile(confPath(), src, 0o644); err != nil { return err } - fmt.Printf("%s config.env <- %s (backup .bak.switch-%s)\n", green("[ok]"), filepath.Base(target), ts) + fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target)) fmt.Println(dim("[info] redémarrage du service...")) return serviceAction("restart") } @@ -134,24 +204,39 @@ func cmdSwitch(args []string) error { return SwitchToPreset(list[n-1].Path) } -// SavePreset creates or overwrites a preset, handling rename when old != "". -func SavePreset(name, old, content string) error { - if old != "" && old != name { - if of, err := safePresetPath(old); err == nil { - _ = os.Remove(of) - } +// SavePreset writes a preset. When id == "" it creates a NEW preset under a +// freshly-generated unique filename (so duplicate display names never clash). +// When id != "" it updates that existing preset in place (filename unchanged — +// only the body and its `# NAME=` line change). Returns the resulting id. +func SavePreset(id, name, content string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("nom requis") } - p, err := safePresetPath(name) + content = withDisplayName(content, name) + if id == "" { + newID, err := uniquePresetID(name) + if err != nil { + return "", err + } + p, err := safePresetPath(newID) + if err != nil { + return "", err + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + return newID, os.WriteFile(p, []byte(content), 0o644) + } + p, err := safePresetPath(id) if err != nil { - return err + return "", err } _ = os.MkdirAll(filepath.Dir(p), 0o755) - return os.WriteFile(p, []byte(content), 0o644) + return id, os.WriteFile(p, []byte(content), 0o644) } -// DeletePreset removes a preset; refuses if it is the active config. -func DeletePreset(name string) error { - p, err := safePresetPath(name) +// DeletePreset removes a preset by id; refuses if it is the active config. +func DeletePreset(id string) error { + p, err := safePresetPath(id) if err != nil { return err } @@ -166,9 +251,9 @@ func DeletePreset(name string) error { return os.Remove(p) } -// ReadPreset returns the contents of a preset by name. -func ReadPreset(name string) (string, error) { - p, err := safePresetPath(name) +// ReadPreset returns the contents of a preset by id (filename). +func ReadPreset(id string) (string, error) { + p, err := safePresetPath(id) if err != nil { return "", err } diff --git a/serve.go b/serve.go index f9b708c..43b0237 100644 --- a/serve.go +++ b/serve.go @@ -61,7 +61,11 @@ func cmdServe(args []string) error { llmArgs = append(llmArgs, "-ctv", vtv) } if r := cfg["REASONING"]; r != "" { - llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", "0") + // budget -1 = thinking illimité (défaut). NE PAS forcer 0 : sur llama.cpp + // vanilla, 0 = "immediate end" → coupe le raisonnement net (le fork + // ik_llama.cpp, lui, l'ignore, d'où l'ancien comportement trompeur). + // Configurable via REASONING_BUDGET (ex: 2048 pour plafonner). + llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1")) } // API_KEY protège le serveur quand il est exposé sur internet : llama-server // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis diff --git a/ui/index.html b/ui/index.html index 8165fd9..b1530e6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -6,7 +6,8 @@ :root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#e6edf3;--dim:#7d8590;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--err:#f85149;--mag:#bc8cff} *{box-sizing:border-box} body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);color:var(--text);height:100vh;height:100dvh;display:flex;font-size:14px} -.side{width:280px;border-right:1px solid var(--border);padding:16px;overflow-y:auto;flex-shrink:0} +.side{width:320px;border-right:1px solid var(--border);padding:16px;overflow-y:auto;flex-shrink:0;scrollbar-width:none;-ms-overflow-style:none} +.side::-webkit-scrollbar{display:none} .main{flex:1;display:flex;flex-direction:column;min-width:0} #menubtn{display:none;position:fixed;top:10px;left:10px;z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer} #backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10} @@ -16,12 +17,19 @@ body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background #menubtn,#backdrop.open{display:block} body.drawer-open #menubtn{display:none} .main{width:100%} - #chat{padding:60px 12px 12px} + #chat{padding:60px 8px 12px} .msg{max-width:92%} + #chat .msg.assistant,#chat .msg.reasoning{max-width:100%} #inputbar{padding:8px;gap:4px} #send,#stop{padding:0 12px} h1{margin-left:48px} } +/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ +@media (min-width:721px){ + .side{width:380px} + .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:9px} + .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px} +} h1{margin:0 0 4px;font-size:18px;color:var(--accent)} h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600} details{margin:8px 0;border-top:1px solid var(--border);padding-top:6px} @@ -41,9 +49,19 @@ button{background:var(--panel);color:var(--text);border:1px solid var(--border); button:hover{border-color:var(--accent);color:var(--accent)} button:disabled{opacity:.5;cursor:not-allowed} .row{display:flex;gap:4px;flex-wrap:wrap} -.preset{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border:1px solid var(--border);border-radius:6px;margin:4px 0;cursor:pointer;background:var(--panel)} +.preset{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;margin:4px 0;cursor:pointer;background:var(--panel)} .preset:hover{border-color:var(--accent)} .preset.active{border-color:var(--ok);color:var(--ok)} +.preset>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.preset>button{flex-shrink:0} +.preset-info{flex:1;min-width:0} +.preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word} +.preset-edit{flex-shrink:0;align-self:flex-start;margin:0;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.45} +.preset-edit:hover{opacity:1;color:var(--accent)} +.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap} +.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em;text-transform:uppercase} +.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em} +.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em} .kv{display:flex;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px} .kv span:first-child{color:var(--dim);flex-shrink:0} .kv span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:right;min-width:0} @@ -63,7 +81,11 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg .body li{margin:.15em 0} .msg .body li>p{margin:0} .msg.assistant .body code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.9em} -.msg.assistant .body pre{background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;margin:.5em 0} +.msg.assistant .body pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;margin:.5em 0} +.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:5px;opacity:.5;transition:opacity .15s,color .15s} +.msg.assistant .body pre:hover .copybtn{opacity:1} +.copybtn:hover{border-color:var(--accent);color:var(--accent)} +.copybtn.done{color:var(--ok);border-color:var(--ok);opacity:1} .msg.assistant .body pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4} .msg.assistant .body blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)} .msg.assistant .body a{color:var(--accent);text-decoration:none} @@ -83,6 +105,12 @@ button:disabled{opacity:.5;cursor:not-allowed} #toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:6px;opacity:0;transition:opacity .2s;pointer-events:none} #toast.show{opacity:1} @keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}} +/* iOS Safari auto-zoome au focus sur tout champ < 16px. On force 16px sur les + appareils tactiles uniquement (le desktop garde son rendu, le pinch-zoom + manuel reste possible). */ +@media (hover:none) and (pointer:coarse){ + #input,#sysprompt,#modal input,#modal textarea,#modal select{font-size:16px} +}
@@ -128,10 +156,11 @@ button:disabled{opacity:.5;cursor:not-allowed}
- +
+
Entrée pour envoyer · Maj+Entrée = nouvelle ligne
- +
- +
+ + +
@@ -199,8 +239,22 @@ function toggleSide(){ document.getElementById('side').classList.toggle('open'); function saveSys(){ localStorage.setItem('jean.sys', document.getElementById('sysprompt').value); } document.addEventListener('DOMContentLoaded', ()=>{ document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; }); function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); } -async function jget(u){ const r=await fetch(u); return r.json(); } -async function jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); } +// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*. +// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage. +let TOKEN = localStorage.getItem('jean.key') || ''; +function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; } +async function jfetch(u, opts){ + opts = opts || {}; + opts.headers = authHeaders(opts.headers); + let r = await fetch(u, opts); + if(r.status === 401){ + const k = prompt('Clé de pilotage jean requise :', ''); + if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } + } + return r; +} +async function jget(u){ const r=await jfetch(u); return r.json(); } +async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); } async function loadStatus(){ const s=await jget('/api/status'); const t=s.active?'● active':'○ '+s.state+''; @@ -226,13 +280,53 @@ async function loadCfg(){ async function loadPresets(){ const p=await jget('/api/presets'); const act = p.find(x=>x.active); - document.getElementById('status-preset').innerHTML = act ? ''+act.name+'' : ''; - document.getElementById('presets').innerHTML = p.map((x,i)=> - '
'+ - ''+(x.active?'● ':'')+x.name+''+ - ''+ - '
' - ).join('') || '(aucun)'; + // Build via DOM (not string concat) so preset names can contain anything — + // spaces, accents, quotes, < > & — without breaking markup or handlers. + const sp = document.getElementById('status-preset'); + sp.innerHTML=''; + if(act){ + const tag=document.createElement('span'); + tag.className='tag'; tag.style.borderColor='var(--accent)'; tag.style.color='var(--accent)'; + tag.textContent=act.name; sp.appendChild(tag); + } + const cont=document.getElementById('presets'); + cont.innerHTML=''; + if(!p.length){ cont.innerHTML='(aucun)'; return; } + p.forEach((x,i)=>{ + const row=document.createElement('div'); + row.className='preset'+(x.active?' active':''); + row.onclick=()=>switchTo(i+1, x.name); + const info=document.createElement('div'); info.className='preset-info'; + const nm=document.createElement('div'); nm.className='preset-name'; + nm.textContent=(x.active?'● ':'')+x.name; nm.title=x.name; + // Second row: quant tag + bench perf, so the title row stays full-width. + const meta=document.createElement('div'); meta.className='preset-meta'; + if(x.quant){ + const q=document.createElement('span'); q.className='qtag'; + q.textContent=x.quant; q.title='quantization'; + meta.appendChild(q); + } + if(x.reasoning){ + const rt=document.createElement('span'); rt.className='rtag'; + rt.title='raisonnement actif ('+x.reasoning+')'; + rt.innerHTML=''; + rt.appendChild(document.createTextNode(x.reasoning)); + meta.appendChild(rt); + } + if(x.bench){ + const bt=document.createElement('span'); bt.className='btag'; + bt.title='prefill / decode — dernier bench de ce preset'; + bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'; + meta.appendChild(bt); + } + info.appendChild(nm); + if(meta.children.length) info.appendChild(meta); + const edit=document.createElement('button'); + edit.className='preset-edit'; edit.title='éditer'; edit.textContent='✎'; + edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); }; + row.appendChild(info); row.appendChild(edit); + cont.appendChild(row); + }); } async function loadSkills(){ const s=await jget('/api/skills'); @@ -302,6 +396,7 @@ async function runBenchUI(){ } finally { btn.disabled = false; btn.textContent = '⚡ bench'; rerun.disabled = false; + loadPresets(); } } async function switchTo(n,name){ @@ -310,27 +405,39 @@ async function switchTo(n,name){ const r=await jpost('/api/switch',{n:n}); toast(r.ok?'switched':'erreur'); setTimeout(loadAll,2000); } -let editingOld = '', editingKind = 'preset'; +// editingKey = the identifier of the item being edited: a preset id (filename) +// or a skill name. Empty string = creating a new item. +let editingKey = '', editingKind = 'preset'; const KINDS = { - preset: {label:'Preset', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, - skill: {label:'Skill', getUrl:'/api/skill', saveUrl:'/api/skill/save', delUrl:'/api/skill/delete', reload:()=>loadSkills()}, + // presets are keyed by `id` (filename) so several can share a display name; + // skills keep name-as-identity (param 'name'). + preset: {label:'Preset', param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, + skill: {label:'Skill', param:'name', getUrl:'/api/skill', saveUrl:'/api/skill/save', delUrl:'/api/skill/delete', reload:()=>loadSkills()}, }; -async function openItem(kind, name){ +async function openItem(kind, key){ const K = KINDS[kind]; - const r = await fetch(K.getUrl + '?name=' + encodeURIComponent(name)); + const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); const d = await r.json(); - editingKind = kind; editingOld = name; - document.getElementById('modal-title').textContent = name ? (K.label + ' · ' + name) : ('Nouveau ' + K.label.toLowerCase()); - document.getElementById('m-name').value = name || ''; + editingKind = kind; editingKey = key || ''; + const display = d.name || key || ''; + document.getElementById('modal-title').textContent = key ? (K.label + ' · ' + display) : ('Nouveau ' + K.label.toLowerCase()); + document.getElementById('m-name').value = display; document.getElementById('m-content').value = d.content || ''; - document.getElementById('m-del').style.display = name ? 'inline-block' : 'none'; + document.getElementById('m-del').style.display = key ? 'inline-block' : 'none'; // Model picker is preset-only: it edits the MODEL= line of config.env. const modelRow = document.getElementById('m-model-row'); + const delModelWrap = document.getElementById('m-del-model-wrap'); if(kind === 'preset'){ modelRow.style.display = 'block'; + document.getElementById('m-hf-url').value = ''; + document.getElementById('m-hf-progress').style.display = 'none'; + document.getElementById('m-del-model').checked = false; + document.getElementById('m-quant').value = currentQuantInTextarea(); + delModelWrap.style.display = key ? 'inline-flex' : 'none'; await Promise.all([populateBackendPicker(), populateModelPicker()]); } else { modelRow.style.display = 'none'; + delModelWrap.style.display = 'none'; } document.getElementById('modal').style.display = 'flex'; } @@ -413,7 +520,23 @@ function onPickBackend(){ } toast('BIN='+val); } -const openPreset = (n)=>openItem('preset', n); +// Read/write the QUANT= override line in the preset textarea. +function currentQuantInTextarea(){ + const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=\s*"?([^"\n]*)"?\s*$/mi); + return m ? m[1].trim() : ''; +} +function applyQuant(){ + const val = document.getElementById('m-quant').value.trim(); + const ta = document.getElementById('m-content'); + const re = /^\s*#?\s*QUANT\s*=.*$/mi; + if(val){ + if(re.test(ta.value)) ta.value = ta.value.replace(re, 'QUANT="'+val+'"'); + else ta.value = ta.value.replace(/\s*$/,'') + '\nQUANT="'+val+'"\n'; + } else { + ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n'); + } +} +const openPreset = (id)=>openItem('preset', id); const openSkill = (n)=>openItem('skill', n); function closeModal(){ document.getElementById('modal').style.display = 'none'; } async function saveItem(){ @@ -421,17 +544,68 @@ async function saveItem(){ const name = document.getElementById('m-name').value.trim(); const content = document.getElementById('m-content').value; if(!name){ toast('nom requis'); return; } - const r = await jpost(K.saveUrl, {name, old: editingOld, content}); + // Presets: keyed by id (filename); duplicate display names are allowed. + // Skills: keyed by name, rename via `old`. + const payload = editingKind==='preset' + ? {id: editingKey, name, content} + : {name, old: editingKey, content}; + const r = await jpost(K.saveUrl, payload); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } toast('enregistré'); closeModal(); K.reload(); } async function delItem(){ - if(!editingOld) return; + if(!editingKey) return; const K = KINDS[editingKind]; - if(!confirm('Supprimer le ' + K.label.toLowerCase() + ' "' + editingOld + '" ?')) return; - const r = await jpost(K.delUrl, {name: editingOld}); + const name = document.getElementById('m-name').value.trim() || editingKey; + const delModel = editingKind==='preset' && document.getElementById('m-del-model').checked; + let msg = 'Supprimer le ' + K.label.toLowerCase() + ' "' + name + '" ?'; + if(delModel) msg += '\n\n⚠️ Le fichier .gguf du modèle sera AUSSI supprimé du disque (irréversible).'; + if(!confirm(msg)) return; + const payload = editingKind==='preset' + ? {id: editingKey, deleteModel: delModel} + : {name: editingKey}; + const r = await jpost(K.delUrl, payload); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } - toast('supprimé'); closeModal(); K.reload(); + if(delModel){ + if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError); + else if(r.modelDeleted) toast('preset + modèle supprimés'); + else toast('supprimé (aucun modèle référencé)'); + } else { toast('supprimé'); } + closeModal(); K.reload(); +} +// Download a .gguf from Hugging Face, polling progress until done. +let dlPoll = null; +async function startDownload(){ + const url = document.getElementById('m-hf-url').value.trim(); + if(!url){ toast('colle un lien .gguf'); return; } + const btn = document.getElementById('m-hf-btn'); + const prog = document.getElementById('m-hf-progress'); + btn.disabled = true; + prog.style.display = 'block'; + prog.textContent = 'démarrage…'; + const r = await jpost('/api/models/download', {url}); + if(!r.ok){ prog.innerHTML = 'erreur : '+(r.error||'')+''; btn.disabled=false; return; } + const fname = r.filename; + if(dlPoll) clearInterval(dlPoll); + dlPoll = setInterval(async ()=>{ + const list = await jget('/api/models/download/status'); + const st = (list||[]).find(d=>d.filename===fname); + if(!st){ return; } + if(st.error){ + prog.innerHTML = 'erreur : '+st.error+''; + clearInterval(dlPoll); dlPoll=null; btn.disabled=false; return; + } + if(st.finished){ + prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; + clearInterval(dlPoll); dlPoll=null; btn.disabled=false; + await populateModelPicker(); + document.getElementById('m-model').value = fname; onPickModel(); + return; + } + const pct = st.total>0 ? Math.round(st.done*100/st.total) : 0; + const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : ''; + prog.textContent = '⬇ '+fmtSize(st.done)+tot+' — '+fname; + }, 800); } // Smart autoscroll: follow the bottom while the user hasn't manually scrolled // up. Re-stick when they scroll back near bottom themselves. @@ -470,7 +644,24 @@ function addMsg(role, text){ function setLabel(el, text){ el.querySelector('.label').textContent = text; } function bodyOf(el){ return el.querySelector('.body'); } // Render markdown into a message body in place; safe because md() escapes HTML. -function renderBody(el, text){ bodyOf(el).innerHTML = md(text); scrollMaybe(); } +function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCopyButtons(b); scrollMaybe(); } +// Inject a "copier" button into every
 code block (idempotent).
+function addCopyButtons(root){
+  root.querySelectorAll('pre').forEach(pre=>{
+    if(pre.querySelector('.copybtn')) return;
+    const btn=document.createElement('button');
+    btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
+    btn.onclick=async(e)=>{
+      e.stopPropagation();
+      const code=pre.querySelector('code'), txt=(code||pre).innerText;
+      try{ await navigator.clipboard.writeText(txt); }
+      catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
+      btn.textContent='copié ✓'; btn.classList.add('done');
+      setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
+    };
+    pre.appendChild(btn);
+  });
+}
 function resetChat(){ msgs=[]; document.getElementById('chat').innerHTML=''; toast('chat vidé'); }
 function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
 let abortCtrl=null;
@@ -512,7 +703,7 @@ async function send(){
   try{
     const sys=(document.getElementById('sysprompt').value||'').trim();
     const out = sys ? [{role:'system',content:sys}, ...msgs] : msgs;
-    const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out}),signal:abortCtrl.signal});
+    const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out}),signal:abortCtrl.signal});
     const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
     statsTimer = setInterval(updateStats, 250);
     while(true){
diff --git a/web.go b/web.go
index 9ac48bf..712d86e 100644
--- a/web.go
+++ b/web.go
@@ -29,6 +29,8 @@ func cmdWeb(args []string) error {
 		port = n
 	}
 	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)
 	mux.HandleFunc("/marked.min.js", func(w http.ResponseWriter, r *http.Request) {
 		b, _ := uiFS.ReadFile("ui/marked.min.js")
@@ -36,28 +38,35 @@ func cmdWeb(args []string) error {
 		w.Header().Set("Cache-Control", "public, max-age=86400")
 		w.Write(b)
 	})
-	mux.HandleFunc("/api/status", handleStatus)
-	mux.HandleFunc("/api/vram", handleVram)
-	mux.HandleFunc("/api/config", handleConfigEnv)
-	mux.HandleFunc("/api/models", handleModels)
-	mux.HandleFunc("/api/backends", handleBackends)
-	mux.HandleFunc("/api/presets", handlePresets)
-	mux.HandleFunc("/api/preset", handlePreset)
-	mux.HandleFunc("/api/preset/save", handlePresetSave)
-	mux.HandleFunc("/api/preset/delete", handlePresetDelete)
-	mux.HandleFunc("/api/skills", handleSkills)
-	mux.HandleFunc("/api/skills/toggle", handleSkillsToggle)
-	mux.HandleFunc("/api/skill", handleSkill)
-	mux.HandleFunc("/api/skill/save", handleSkillSave)
-	mux.HandleFunc("/api/skill/delete", handleSkillDelete)
-	mux.HandleFunc("/api/tools", handleTools)
-	mux.HandleFunc("/api/tools/toggle", handleToolsToggle)
-	mux.HandleFunc("/api/switch", handleSwitch)
-	mux.HandleFunc("/api/start", svcHandler("start"))
-	mux.HandleFunc("/api/stop", svcHandler("stop"))
-	mux.HandleFunc("/api/restart", svcHandler("restart"))
-	mux.HandleFunc("/api/bench", handleBench)
-	mux.HandleFunc("/api/chat", handleChat)
+	// api enregistre une route /api/* protégée par la clé de pilotage (webauth.go).
+	api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
+	api("/api/ping", handlePing)
+	api("/api/status", handleStatus)
+	api("/api/vram", handleVram)
+	api("/api/config", handleConfigEnv)
+	api("/api/models", handleModels)
+	api("/api/models/delete", handleModelDelete)
+	api("/api/models/download", handleModelDownload)
+	api("/api/models/download/status", handleModelDownloadStatus)
+	api("/api/backends", handleBackends)
+	api("/api/presets", handlePresets)
+	api("/api/preset", handlePreset)
+	api("/api/preset/save", handlePresetSave)
+	api("/api/preset/delete", handlePresetDelete)
+	api("/api/skills", handleSkills)
+	api("/api/skills/toggle", handleSkillsToggle)
+	api("/api/skill", handleSkill)
+	api("/api/skill/save", handleSkillSave)
+	api("/api/skill/delete", handleSkillDelete)
+	api("/api/tools", handleTools)
+	api("/api/tools/toggle", handleToolsToggle)
+	api("/api/switch", handleSwitch)
+	api("/api/start", svcHandler("start"))
+	api("/api/stop", svcHandler("stop"))
+	api("/api/restart", svcHandler("restart"))
+	api("/api/bench", handleBench)
+	api("/api/bench/last", handleBenchLast)
+	api("/api/chat", handleChat)
 	addr := fmt.Sprintf("0.0.0.0:%d", port)
 
 	ln, err := net.Listen("tcp", addr)
@@ -72,6 +81,12 @@ func cmdWeb(args []string) error {
 		}
 	}
 	fmt.Printf("[jean web] http://%s  (Ctrl-C pour arrêter)\n", addr)
+	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("jean set-web-key"))
+	} else {
+		fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]"))
+	}
 	return http.Serve(ln, mux)
 }
 
@@ -167,13 +182,20 @@ func sendJSON(w http.ResponseWriter, code int, v any) {
 	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é).
+func handlePing(w http.ResponseWriter, r *http.Request) {
+	sendJSON(w, 200, map[string]any{"ok": true, "service": "jean", "version": Version})
+}
+
+// handleStatus reports service state cross-platform via serviceIsActive
+// (systemd sous Linux, supervision par PID-file sous Windows — voir service_*.go).
 func handleStatus(w http.ResponseWriter, r *http.Request) {
-	out, _ := exec.Command("systemctl", "is-active", serviceName()).Output()
-	state := strings.TrimSpace(string(out))
-	if state == "" {
-		state = "unknown"
+	active := serviceIsActive()
+	state := "inactive"
+	if active {
+		state = "active"
 	}
-	active := state == "active"
 	health := false
 	if active {
 		health = healthCheck()
@@ -277,29 +299,56 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
 		sendJSON(w, 500, map[string]any{"error": err.Error()})
 		return
 	}
+	store := loadBenchStore()
 	out := []map[string]any{}
 	for _, p := range list {
-		out = append(out, map[string]any{"name": p.Name, "active": p.Active})
+		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) {
-	name := strings.TrimSpace(r.URL.Query().Get("name"))
-	if name == "" {
+	id := strings.TrimSpace(r.URL.Query().Get("id"))
+	if id == "" {
 		// new preset → seed from current config.env so users can tweak rather than start blank
 		b, _ := os.ReadFile(confPath())
-		sendJSON(w, 200, map[string]any{"name": "", "content": string(b)})
+		sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": string(b)})
 		return
 	}
-	content, err := ReadPreset(name)
+	content, err := ReadPreset(id)
 	if err != nil {
 		sendJSON(w, 404, map[string]any{"error": "not found"})
 		return
 	}
-	sendJSON(w, 200, map[string]any{"name": name, "content": content})
+	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"`
@@ -307,29 +356,46 @@ type saveReq struct {
 }
 
 func handlePresetSave(w http.ResponseWriter, r *http.Request) {
-	var req saveReq
+	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
 	}
-	if err := SavePreset(req.Name, req.Old, req.Content); err != nil {
+	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, "name": req.Name})
+	sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name})
 }
 
 func handlePresetDelete(w http.ResponseWriter, r *http.Request) {
-	var req saveReq
+	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
 	}
-	if err := DeletePreset(req.Name); err != nil {
+	// 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
 	}
-	sendJSON(w, 200, map[string]any{"ok": true})
+	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})
 }
 
 func handleSkills(w http.ResponseWriter, r *http.Request) {
@@ -434,19 +500,18 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, 200, map[string]any{"ok": true, "preset": target.Name})
 }
 
-// svcHandler returns an HTTP handler that triggers a systemctl action via the
-// passwordless sudo rule installed by `jean install`. Falls back to no-sudo
-// when the web server itself runs as root.
+// 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 service_*.go). C'est ce qui permet à un client distant
+// de relancer Jean.
 func svcHandler(action string) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
-		var cmd *exec.Cmd
-		if os.Geteuid() == 0 {
-			cmd = exec.Command("systemctl", action, serviceName())
-		} else {
-			cmd = exec.Command("sudo", "-n", "systemctl", action, serviceName())
+		err := serviceAction(action)
+		msg := "ok"
+		if err != nil {
+			msg = err.Error()
 		}
-		out, err := cmd.CombinedOutput()
-		sendJSON(w, 200, map[string]any{"ok": err == nil, "out": string(out)})
+		sendJSON(w, 200, map[string]any{"ok": err == nil, "out": msg})
 	}
 }
 
@@ -475,6 +540,17 @@ func handleBench(w http.ResponseWriter, r *http.Request) {
 	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})
+}
+
 func handleChat(w http.ResponseWriter, r *http.Request) {
 	var body struct {
 		Messages    []Message `json:"messages"`
diff --git a/webauth.go b/webauth.go
new file mode 100644
index 0000000..1aa5d18
--- /dev/null
+++ b/webauth.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+	"crypto/rand"
+	"crypto/subtle"
+	"encoding/hex"
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// webauth.go protège l'API de pilotage (jean web) quand elle est exposée sur
+// internet — c.-à-d. l'API que tout client (navigateur, app mobile, script…)
+// utilise pour switcher de preset, redémarrer le service, lire le status, etc.
+//
+// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
+// protège llama-server / les complétions). On veut pouvoir donner à un client
+// un accès aux complétions sans lui donner le droit de redémarrer la machine,
+// et inversement. Elle est stockée dans $JEAN_HOME/.web_key et lue à chaque
+// requête (pas de cache) pour qu'un changement de clé prenne effet sans
+// redémarrer le serveur web.
+
+func webKeyPath() string { return filepath.Join(JeanHome(), ".web_key") }
+
+// readWebKey returns the trimmed contents of $JEAN_HOME/.web_key, or "" if the
+// file is absent/empty.
+func readWebKey() string {
+	b, err := os.ReadFile(webKeyPath())
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(string(b))
+}
+
+// requireWebAuth wraps an HTTP handler, rejecting requests that don't present
+// the configured Bearer token. When no key is configured the handler is left
+// open (pratique en local) — cmdWeb avertit alors bruyamment au démarrage.
+func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		key := readWebKey()
+		if key == "" {
+			next(w, r)
+			return
+		}
+		if !checkBearer(r, key) {
+			w.Header().Set("WWW-Authenticate", `Bearer realm="jean"`)
+			sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"})
+			return
+		}
+		next(w, r)
+	}
+}
+
+// checkBearer reports whether the request carries the expected key, either as an
+// "Authorization: Bearer " header (cas normal) or as a ?key=
+// query param (repli pour les clients qui ne peuvent pas poser d'en-tête, p.ex.
+// une URL ouverte directement). La comparaison est à temps constant.
+func checkBearer(r *http.Request, key string) bool {
+	want := []byte(key)
+	if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
+		got := []byte(strings.TrimSpace(h[len("Bearer "):]))
+		if subtle.ConstantTimeCompare(got, want) == 1 {
+			return true
+		}
+	}
+	if q := r.URL.Query().Get("key"); q != "" {
+		if subtle.ConstantTimeCompare([]byte(q), want) == 1 {
+			return true
+		}
+	}
+	return false
+}
+
+// cmdSetWebKey sets (or clears) the control-API key in $JEAN_HOME/.web_key.
+//
+//	jean set-web-key      définit la clé
+//	jean set-web-key           génère une clé aléatoire
+//	jean set-web-key ""        supprime la protection (API ouverte)
+//
+// Contrairement à set-api-key, aucun redémarrage n'est nécessaire : le serveur
+// web relit la clé à chaque requête.
+func cmdSetWebKey(args []string) error {
+	var key string
+	switch {
+	case len(args) == 0:
+		buf := make([]byte, 24)
+		if _, err := rand.Read(buf); err != nil {
+			return err
+		}
+		key = "jean-web-" + hex.EncodeToString(buf)
+		fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key))
+	case args[0] == "" || args[0] == "off" || args[0] == "none":
+		key = ""
+	default:
+		key = strings.TrimSpace(args[0])
+	}
+	if key == "" {
+		if err := os.Remove(webKeyPath()); err != nil && !os.IsNotExist(err) {
+			return err
+		}
+		fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]"))
+		return nil
+	}
+	if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
+		return err
+	}
+	if err := os.WriteFile(webKeyPath(), []byte(key+"\n"), 0o600); err != nil {
+		return err
+	}
+	fmt.Printf("%s clé de pilotage enregistrée dans %s\n", green("[ok]"), webKeyPath())
+	fmt.Printf("       les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
+	fmt.Printf("       (relance 'jean web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
+	return nil
+}