Sync état à jour + fix perf GPU/CPU

- LD_LIBRARY_PATH inclut désormais les dossiers runtime CUDA
  (/usr/local/cuda*/lib64) : corrige le chargement du backend GPU.
- Unit systemd : Nice=-10 + CPUSchedulingPolicy=other (priorité CPU).
- Met à niveau le reste du projet (models, webauth, presets, web UI).
This commit is contained in:
nathaninline committed 2026-06-17 13:21:25 +02:00
1 parent 65b2ada540
commit 6ea2e91810
14 files changed
+1143 -318

No files matched your search

+19
View File
@@ -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/
+33 -1
View File
@@ -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 <clé>
```
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":<index 1-based>}` | 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 |
+79 -2
View File
@@ -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 {
-196
View File
@@ -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
})
}
+7
View File
@@ -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
`
+57 -3
View File
@@ -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 = "</think>"
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 <think> block (opened by the chat template)
// arrives inline in `content`, terminated by a literal </think>. 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 <think> block: stream to the
// reasoning bubble until the closing </think>, 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 "</think>".
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
+4 -1
View File
@@ -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)
+326
View File
@@ -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/<repo>/blob/<rev>/<file> → /resolve/<rev>/<file>
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)
}
+35 -4
View File
@@ -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
+113 -28
View File
@@ -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=<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
}
+5 -1
View File
@@ -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 <clé>". La clé est lue depuis
+224 -33
View File
@@ -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}
}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<div id="backdrop" onclick="toggleSide()"></div>
@@ -128,10 +156,11 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div id="chat"></div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
<div id="inputbar">
<textarea id="input" placeholder="message… (Entrée pour envoyer, Maj+Entrée = nouvelle ligne)" onkeydown="onKey(event)"></textarea>
<textarea id="input" placeholder="message…" onkeydown="onKey(event)"></textarea>
<button id="send" onclick="send()">send</button>
<button id="stop" onclick="stopGen()" style="display:none;background:#3d1f23;border-color:var(--err);color:var(--err)">stop</button>
</div>
<div class="muted" style="padding:2px 12px 6px;font-size:10px;text-align:center;opacity:.7">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div>
<div id="toast"></div>
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
@@ -154,19 +183,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
<button onclick="closeModal()" style="margin:0">×</button>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:10px;overflow:auto;flex:1">
<label class="muted">Nom <span style="opacity:.6">(alphanum, ._-)</span></label>
<label class="muted">Nom <span style="opacity:.6">(espaces et accents OK, sauf / et \)</span></label>
<input id="m-name" style="background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit">
<div id="m-model-row" style="display:none">
<label class="muted">Backend <span style="opacity:.6">(remplace la ligne BIN=)</span></label>
<select id="m-backend" onchange="onPickBackend()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin:6px 0 10px"></select>
<label class="muted">Modèle <span style="opacity:.6">(remplace la ligne MODEL=)</span></label>
<select id="m-model" onchange="onPickModel()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin-top:6px"></select>
<label class="muted" style="margin-top:10px;display:block">Télécharger depuis Hugging Face <span style="opacity:.6">(lien vers un .gguf)</span></label>
<div style="display:flex;gap:6px;margin-top:6px">
<input id="m-hf-url" placeholder="https://huggingface.co/…/resolve/main/model.gguf" style="flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit">
<button id="m-hf-btn" onclick="startDownload()" style="flex-shrink:0">⬇ télécharger</button>
</div>
<div id="m-hf-progress" class="muted" style="display:none;font-size:11px;margin-top:6px"></div>
<label class="muted" style="margin-top:10px;display:block">Quantization (tag) <span style="opacity:.6">(vide = détection auto depuis le nom du modèle)</span></label>
<input id="m-quant" oninput="applyQuant()" placeholder="ex: Q4_K_M, IQ3_XXS, BF16…" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin-top:6px">
</div>
<label class="muted">Contenu</label>
<textarea id="m-content" spellcheck="false" style="background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:280px;resize:vertical"></textarea>
</div>
<div style="padding:14px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:6px;flex-wrap:wrap">
<button id="m-del" onclick="delItem()" style="background:#3d1f23;border-color:var(--err);color:var(--err)">supprimer</button>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<button id="m-del" onclick="delItem()" style="background:#3d1f23;border-color:var(--err);color:var(--err);margin:0">supprimer</button>
<label id="m-del-model-wrap" style="display:none;align-items:center;gap:5px;font-size:11px;color:var(--dim)"><input type="checkbox" id="m-del-model"> + le modèle .gguf</label>
</div>
<div style="display:flex;gap:6px">
<button onclick="closeModal()">annuler</button>
<button onclick="saveItem()" style="border-color:var(--accent);color:var(--accent)">enregistrer</button>
@@ -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?'<span class="tag ok">● active</span>':'<span class="tag err">○ '+s.state+'</span>';
@@ -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 ? '<span class="tag" style="border-color:var(--accent);color:var(--accent)">'+act.name+'</span>' : '';
document.getElementById('presets').innerHTML = p.map((x,i)=>
'<div class="preset '+(x.active?'active':'')+'" onclick="switchTo('+(i+1)+',\''+x.name+'\')">'+
'<span>'+(x.active?'● ':'')+x.name+'</span>'+
'<button onclick="event.stopPropagation();openPreset(\''+x.name+'\')" style="margin:0;padding:2px 8px;font-size:11px">edit</button>'+
'</div>'
).join('') || '<span class="muted">(aucun)</span>';
// 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='<span class="muted">(aucun)</span>'; 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='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
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 = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>'; 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 = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
clearInterval(dlPoll); dlPoll=null; btn.disabled=false; return;
}
if(st.finished){
prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
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 <pre> 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){
+125 -49
View File
@@ -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"`
+116
View File
@@ -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 <clé>" header (cas normal) or as a ?key=<clé>
// 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 <clé> 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
}