mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
65b2ada540
commit
6ea2e91810
14 files changed
+1143
-318
No files matched your search
+19
@@ -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/
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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){
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user