From 5d35ff04848d349c7b6d1ba2bb49f98376c32856 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 12:54:03 +0200 Subject: [PATCH 001/279] Initial commit --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..2739492 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# jean \ No newline at end of file From 6802962120a97a7fc7731f27d13b5baf66dc666f Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 12:54:49 +0200 Subject: [PATCH 002/279] Add files via upload --- .gitattributes | 7 + .gitignore | 34 +++ LICENSE | 21 ++ README.md | 169 +++++++++++++- bench.go | 161 +++++++++++++ chat.go | 115 ++++++++++ config.go | 150 ++++++++++++ go.mod | 3 + install.go | 196 ++++++++++++++++ llamacpp.go | 607 +++++++++++++++++++++++++++++++++++++++++++++++++ llm.go | 332 +++++++++++++++++++++++++++ main.go | 195 ++++++++++++++++ presets.go | 180 +++++++++++++++ serve.go | 85 +++++++ service.go | 115 ++++++++++ skills.go | 186 +++++++++++++++ test_cmd.go | 65 ++++++ tools.go | 114 ++++++++++ tty.go | 12 + web.go | 440 +++++++++++++++++++++++++++++++++++ 20 files changed, 3186 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 bench.go create mode 100644 chat.go create mode 100644 config.go create mode 100644 go.mod create mode 100644 install.go create mode 100644 llamacpp.go create mode 100644 llm.go create mode 100644 main.go create mode 100644 presets.go create mode 100644 serve.go create mode 100644 service.go create mode 100644 skills.go create mode 100644 test_cmd.go create mode 100644 tools.go create mode 100644 tty.go create mode 100644 web.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6bb6f89 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Normalize line endings to LF for all text files. +* text=auto eol=lf +*.go text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.html text eol=lf +*.js text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d096b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Compiled binaries +/jean +/jean.exe +/jean-linux* +/jean-darwin* +/jean-windows* +jean-*-amd64 +jean-*-arm64 +*.exe +*.out + +# Build artifacts +/dist/ +/build/ + +# Local secrets / runtime data — never commit +.env +*.api_key +.api_key +config.env + +# Scratch / logs +*.log +out.txt +out*.txt +err.txt +err*.txt + +# OS / editor +.DS_Store +Thumbs.db +*.swp +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bea6060 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jean contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2739492..5c010e4 100644 --- a/README.md +++ b/README.md @@ -1 +1,168 @@ -# jean \ No newline at end of file +# Jean + +**A single-binary manager for self-hosted [llama.cpp](https://github.com/ggml-org/llama.cpp) servers — plus a built-in web UI, terminal chat, and an auto-detecting backend builder.** + +Drop one binary on a machine, run `jean llamacpp install`, and Jean clones, configures, and compiles llama.cpp for *that* machine's hardware (CUDA / ROCm / Metal / Vulkan / CPU) — no flags to remember. Then `jean start` and you have an OpenAI-compatible endpoint with a web chat on top. + +``` +download binary → jean llamacpp install → jean edit → jean start → done +``` + +--- + +## Why + +Running llama.cpp as a real service usually means: figure out the right CMake flags for your GPU, write a systemd unit, manage an API key, swap models, keep the build up to date… Jean turns all of that into a handful of subcommands behind a single static binary with **no runtime dependencies** (other than llama.cpp itself, which Jean can build for you). + +## Features + +- **`jean llamacpp install` / `update`** — clones and compiles llama.cpp with the right flags **auto-detected** for the host: + - **CUDA** when an NVIDIA GPU + `nvcc` are present (compute capability detected per-GPU via `nvidia-smi`, so multi-GPU machines build for all cards) + - **ROCm/HIP** (AMD), **Metal** (macOS / Apple Silicon), **Vulkan**, or **CPU** fallback + - `update` pulls the latest commit, stops the service while it rebuilds, then restarts it +- **systemd integration** — `jean install` writes the unit, a passwordless `systemctl` sudoers rule, and the data dirs +- **Web UI** (`jean web`) — chat, model/preset switching, skills & tools toggles +- **Terminal chat** (`jean chat`) — streamed responses +- **Presets** (`jean switch`) — keep multiple `config.env` profiles and swap between them +- **API key protection** (`jean set-api-key`) — Bearer auth for exposing the server publicly; the key is stored separately so it survives preset switches +- **Benchmark** (`jean bench`) — honest prefill/decode tok/s using a varied corpus +- **Single static binary** — built with `CGO_ENABLED=0`, cross-compiles trivially + +## Quick start + +### 1. Get the binary + +Grab a prebuilt binary from the [Releases](../../releases) page, or [build from source](#building-from-source): + +```bash +# example: Linux x86_64 +curl -L -o jean https://github.com/jean-llm/jean/releases/latest/download/jean-linux-amd64 +chmod +x jean +sudo mv jean /usr/local/bin/jean +``` + +### 2. Install (systemd unit, dirs, sudoers) + +```bash +sudo jean install +``` + +### 3. Build a llama.cpp backend for this machine + +```bash +jean llamacpp install +``` + +Jean auto-detects your accelerator, compiles `llama-server`, and points the config at the new binary. Requires `git` and `cmake` (plus the matching toolkit, e.g. CUDA, if you want GPU acceleration). + +### 4. Point it at a model and start + +```bash +jean edit # set MODEL=/path/to/your-model.gguf +jean start +jean test # verify the model answers +``` + +### 5. (optional) Web UI + +```bash +jean web # http://:8090 +``` + +## Commands + +``` +Service: + start | stop | restart manage the systemd service + status | logs status / live logs + enable | disable start on boot + edit edit $JEAN_HOME/config.env + set-api-key [key] protect the API (Bearer); empty = generate, "" = remove + vram GPU/VRAM usage (nvidia-smi) + test check the model answers (health + completion) + bench [N] measure prefill + decode tok/s + +Presets: + switch [N] pick a preset from configs/ (interactive or by number) + +Interaction: + chat [system-prompt] streamed terminal chat + web [PORT] web UI (default :8090) + +LLM-side tooling: + skills [on|off|list] let the model read SKILLS//SKILL.md + tools [on|off|status] enable run_shell (model executes shell commands) + +Backend (llama.cpp): + llamacpp install clone + build llama.cpp (auto-detect CUDA/ROCm/Metal/CPU), set BIN + llamacpp update git pull + rebuild the existing backend (stops/restarts the service) + llamacpp status current commit, detected backend, commits behind origin + +Install: + install install (systemd unit, sudoers, dirs) + uninstall uninstall +``` + +### `jean llamacpp` flags + +``` +install [--dir=PATH] [--ref=GIT_REF] [--force] [--no-switch] +update [--ref=GIT_REF] [--clean] [--no-restart] [--force] +``` + +- `--dir=` — where to clone (default `$JEAN_HOME/backends/llama.cpp`) +- `--ref=` — build a specific branch/tag/commit +- `--clean` — wipe `build/` and recompile from scratch +- `--no-switch` — don't touch `config.env` (install only) +- `--no-restart` — leave the service stopped after updating + +## Configuration + +Everything lives under **`$JEAN_HOME`** (default `/etc/jean`). The service reads `config.env`: + +| Key | Meaning | Default | +|-----|---------|---------| +| `BIN` | path to `llama-server` (set by `llamacpp install`) | — | +| `MODEL` | path to the `.gguf` model | — | +| `HOST` / `PORT` | bind address / port | `0.0.0.0` / `8080` | +| `CTX` | context size | `32768` | +| `NGL` | GPU layers to offload | `999` | +| `BATCH` / `UBATCH` | batch / micro-batch | `2048` / `512` | +| `THREADS` / `THREADS_BATCH` | CPU threads | `0` (auto) | +| `KV_TYPE` (`_K`/`_V`) | KV cache quantization | — | +| `REASONING` | reasoning mode passthrough | — | +| `EXTRA_ARGS` | appended verbatim to `llama-server` | — | + +The API key (when set with `jean set-api-key`) is stored in `$JEAN_HOME/.api_key`, separate from `config.env`. + +### Environment + +| Var | Meaning | Default | +|-----|---------|---------| +| `JEAN_HOME` | data root | `/etc/jean` (or `$HOME/JEAN`) | +| `JEAN_SERVICE` | systemd unit name | `jean` | +| `EDITOR` | editor for `jean edit` | `nano` | + +## Building from source + +Requires Go 1.22+. Jean is a pure-Go binary (the web UI is embedded via `go:embed`): + +```bash +git clone https://github.com/jean-llm/jean.git +cd jean +CGO_ENABLED=0 go build -o jean . + +# cross-compile, e.g. Linux from any host: +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jean-linux-amd64 . +``` + +> Building **Jean** needs only Go. Building the **llama.cpp backend** (`jean llamacpp install`) needs `git`, `cmake`, and your accelerator's toolkit (CUDA, ROCm, etc.). + +## How it works + +- `jean serve` is the systemd `ExecStart`: it reads `config.env`, builds the `llama-server` argument list, and `exec`s into it so systemd supervises llama.cpp directly. +- `jean llamacpp` manages the llama.cpp checkout next to wherever `BIN` points, handling the common "relocated build dir" CMake-cache pitfall and stopping the service during a rebuild to avoid *Text file busy*. + +## License + +[MIT](LICENSE). The bundled `ui/marked.min.js` is [Marked](https://github.com/markedjs/marked), also MIT. diff --git a/bench.go b/bench.go new file mode 100644 index 0000000..604fdcf --- /dev/null +++ b/bench.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// benchResult captures the timings llama.cpp returns from /completion. +type benchResult struct { + PromptN int `json:"prompt_n"` + PromptMs float64 `json:"prompt_ms"` + PromptPerSecond float64 `json:"prompt_per_second"` + PredictedN int `json:"predicted_n"` + PredictedMs float64 `json:"predicted_ms"` + PredictedPerSec float64 `json:"predicted_per_second"` + Elapsed float64 `json:"elapsed_sec"` +} + +// benchCorpus is a varied passage used to defeat speculative decoding +// (MTP / n-gram draft) — repetitive text inflates decode tok/s because every +// drafted token gets accepted, which is unlike real chat. We pull a chunk of +// natural-looking content and tile it to reach the target prompt size. +const benchCorpus = `In the early hours of an October morning, Camille walked along the canal, watching the cargo barges slip past the iron bridge that spanned the water. She thought about the meeting she had skipped, the unanswered messages on her phone, the way the city always seemed to forget her name after summer ended. Three streets away, a pâtisserie opened its shutters and the smell of warm butter mixed with diesel exhaust from the waiting bus. +Pendant ce temps, à Marseille, un chercheur en biologie marine prépare son matériel pour une plongée. Il étudie les herbiers de posidonie, ces prairies sous-marines vieilles de plusieurs milliers d'années qui stockent autant de carbone qu'une forêt amazonienne. Le bateau quitte le port à six heures vingt-trois. +Quantum computers, properly engineered, can solve certain classes of problems exponentially faster than classical machines. The catch is that decoherence ruins everything. Engineers use dilution refrigerators to drop superconducting qubits to fifteen millikelvin, colder than deep space. The wires connecting the chip to room-temperature electronics must dissipate almost no heat, or the qubit state collapses before any useful computation finishes. +Le boulanger lève la pâte à quatre heures. Il regarde la balance numérique en plissant les yeux : six cent vingt-trois grammes, presque le compte. Son chien dort sur le tapis de farine près du four. Dehors, deux chats se disputent un poisson abandonné par le pêcheur de nuit. +Consider a recursive descent parser written in Go. The lexer emits tokens; the parser consumes them and produces an abstract syntax tree. Error recovery is hard: after a syntax error, the parser must resynchronize at a known boundary—a semicolon, a closing brace—without losing track of subsequent diagnostics. Tree-sitter solves this with incremental parsing and a glr-like algorithm. +Le philosophe stoïcien disait : "Ce qui nous trouble, ce n'est pas ce qui nous arrive, mais l'opinion que nous nous en faisons." Vingt siècles plus tard, la phrase apparaît dans un livre de poche au rayon développement personnel d'une librairie d'aéroport, à côté d'un roman policier suédois. +Mitochondria descended from ancient bacteria engulfed by archaeal cells roughly two billion years ago. They still keep their own ring of DNA, separate from the nuclear genome. Mutations in mitochondrial DNA accumulate with age and have been implicated in everything from Parkinson's disease to ordinary muscle fatigue. Yet they remain stubbornly difficult to repair therapeutically because each cell contains hundreds. +La marée descend lentement, exposant des rochers couverts d'huîtres et d'algues vertes. Un héron immobile surveille les flaques laissées par l'eau. Plus loin, deux enfants courent avec un cerf-volant rouge qui refuse de monter à cause de l'humidité dans la voile. +Compilers translate high-level languages into machine code through several intermediate representations. LLVM IR sits in the middle: typed, mostly static-single-assignment, suitable for both aggressive optimization and direct lowering to x86 or ARM. The optimizer runs dozens of passes—dead code elimination, loop-invariant code motion, induction variable simplification—each touching the IR in carefully ordered ways. +Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une pomme de terre crue coupée en quartiers, sachant qu'elle absorbera l'excès en mijotant vingt minutes. Sa grand-mère lui a appris ce geste un dimanche de novembre il y a très longtemps. +` + +// runBench fires a prompt of roughly `nPrompt` tokens at /completion with +// cache_prompt:false (so prefill is actually measured, not cached). The prompt +// is a varied corpus to keep speculative decoding (MTP / n-gram draft) from +// inflating decode numbers — what you measure here is close to what you'll +// see in real chat at the same context length. +func runBench(nPrompt, nPredict int) (*benchResult, error) { + port := LLMPort() + if !healthCheck() { + return nil, fmt.Errorf("serveur injoignable sur :%d", port) + } + if nPrompt <= 0 { + nPrompt = 2000 + } + // 1 word ≈ 1.3 tokens roughly. Tile the varied corpus until we exceed + // nPrompt, then truncate to characters so the server tokenises a passage + // close to the requested size. + corpusWords := strings.Fields(benchCorpus) + target := nPrompt * 5 // ~5 chars/token gives a generous over-estimate + var b strings.Builder + for b.Len() < target { + for _, w := range corpusWords { + b.WriteString(w) + b.WriteByte(' ') + if b.Len() >= target { + break + } + } + } + prompt := strings.TrimSpace(b.String()) + // Use the same endpoint your real chat hits, so the comparison is honest + // (chat template, reasoning, OpenAI-compat layer all included). + payload := map[string]any{ + "model": "jean", + "messages": []Message{{Role: "user", Content: prompt + "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics."}}, + "max_tokens": nPredict, + "stream": false, + "temperature": 0.7, + "cache_prompt": false, + } + body, _ := json.Marshal(payload) + url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", port) + t0 := time.Now() + req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + authHeader(req) + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var parsed struct { + Timings struct { + PromptN int `json:"prompt_n"` + PromptMs float64 `json:"prompt_ms"` + PromptPerSecond float64 `json:"prompt_per_second"` + PredictedN int `json:"predicted_n"` + PredictedMs float64 `json:"predicted_ms"` + PredictedPerSec float64 `json:"predicted_per_second"` + } `json:"timings"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + } `json:"usage"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + // /v1/chat/completions may report timings at top level or omit them; if + // missing, fall back to wall-clock derived from `usage` so we always show + // numbers comparable to what chat displays in its label. + if parsed.Timings.PredictedN == 0 && parsed.Usage.CompletionTokens > 0 { + elapsed := time.Since(t0).Seconds() + parsed.Timings.PromptN = parsed.Usage.PromptTokens + parsed.Timings.PredictedN = parsed.Usage.CompletionTokens + // Distribute elapsed time using a rough split (prefill is usually <20% at this size). + parsed.Timings.PromptMs = elapsed * 1000 * 0.15 + parsed.Timings.PredictedMs = elapsed * 1000 * 0.85 + if parsed.Timings.PromptMs > 0 { + parsed.Timings.PromptPerSecond = float64(parsed.Timings.PromptN) / (parsed.Timings.PromptMs / 1000) + } + if parsed.Timings.PredictedMs > 0 { + parsed.Timings.PredictedPerSec = float64(parsed.Timings.PredictedN) / (parsed.Timings.PredictedMs / 1000) + } + } + elapsed := time.Since(t0).Seconds() + t := parsed.Timings + return &benchResult{ + PromptN: t.PromptN, PromptMs: t.PromptMs, PromptPerSecond: t.PromptPerSecond, + PredictedN: t.PredictedN, PredictedMs: t.PredictedMs, PredictedPerSec: t.PredictedPerSec, + Elapsed: elapsed, + }, nil +} + +func cmdBench(args []string) error { + nPredict, nPrompt := 300, 2000 + if len(args) >= 1 && args[0] != "" { + if n, err := strconv.Atoi(args[0]); err == nil { + nPredict = n + } else { + return fmt.Errorf("argument invalide: %s", args[0]) + } + } + if len(args) >= 2 && args[1] != "" { + if n, err := strconv.Atoi(args[1]); err == nil { + nPrompt = n + } else { + return fmt.Errorf("argument invalide: %s", args[1]) + } + } + fmt.Printf("[bench] prompt ~%d tokens, n_predict=%d…\n", nPrompt, nPredict) + r, err := runBench(nPrompt, nPredict) + if err != nil { + return err + } + fmt.Println() + fmt.Printf(" %s %7.1f tok/s (%d tokens en %.2fs)\n", cyan("Prefill"), r.PromptPerSecond, r.PromptN, r.PromptMs/1000) + fmt.Printf(" %s %7.1f tok/s (%d tokens en %.2fs)\n", cyan("Decode "), r.PredictedPerSec, r.PredictedN, r.PredictedMs/1000) + fmt.Printf(" Total %.2fs\n", r.Elapsed) + fmt.Println() + return nil +} diff --git a/chat.go b/chat.go new file mode 100644 index 0000000..a3070eb --- /dev/null +++ b/chat.go @@ -0,0 +1,115 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// cmdChat is the interactive terminal chat loop. +// First positional arg (if any) becomes the system prompt. +func cmdChat(args []string) error { + if !healthCheck() { + return fmt.Errorf("serveur injoignable sur :%d — jean start d'abord", LLMPort()) + } + sysPrompt := strings.Join(args, " ") + msgs := []Message{} + if sysPrompt != "" { + msgs = append(msgs, Message{Role: "system", Content: sysPrompt}) + } + fmt.Printf("\n%s — /reset pour vider, /sys pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("jean chat")) + if sysPrompt != "" { + fmt.Println(dim("system: " + sysPrompt)) + } + fmt.Println() + sc := bufio.NewScanner(os.Stdin) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + for { + fmt.Print(bold("you") + " > ") + if !sc.Scan() { + fmt.Println() + return nil + } + user := sc.Text() + u := strings.TrimSpace(user) + if u == "" { + continue + } + if u == "/quit" || u == "/exit" { + return nil + } + if u == "/reset" { + kept := []Message{} + for _, m := range msgs { + if m.Role == "system" { + kept = append(kept, m) + } + } + msgs = kept + fmt.Println(dim("[contexte vidé]")) + continue + } + if strings.HasPrefix(u, "/sys ") { + newSys := strings.TrimSpace(u[5:]) + msgs = msgs[:0] + if newSys != "" { + msgs = append(msgs, Message{Role: "system", Content: newSys}) + } + fmt.Println(dim("[system mis à jour]")) + continue + } + msgs = append(msgs, Message{Role: "user", Content: user}) + full := strings.Builder{} + inReason := false + var stats *StatsEvent + // Print the assistant prefix once; reasoning is shown inline with a tag. + fmt.Print(cyan("jean") + " > ") + err := runChat(InjectSkills(msgs), 0.7, func(ev StreamEvent) bool { + switch { + case ev.Err != nil: + fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error())) + case ev.Stats != nil: + stats = ev.Stats + case ev.ToolUsed != nil: + icon := "📖" + verb := "lecture du skill" + if ev.ToolUsed.Name == "run_shell" { + icon = "⚙️" + verb = "exécution" + } + if inReason { + fmt.Print("\n") + inReason = false + } + fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("jean")+" >") + case ev.Reasoning != "": + if !inReason { + fmt.Print(magenta("[reasoning] ") + dim("")) + inReason = true + } + fmt.Print(dim(ev.Reasoning)) + case ev.Content != "": + if inReason { + fmt.Print("\n" + cyan("jean") + " > ") + inReason = false + } + full.WriteString(ev.Content) + fmt.Print(ev.Content) + } + return true + }) + if inReason { + fmt.Println() + } + fmt.Println() + if stats != nil { + fmt.Printf("%s prefill %d tok · %.0f tok/s decode %d tok · %.1f tok/s\n", + dim("→"), stats.PromptTokens, stats.PromptPerSecond, stats.GenTokens, stats.GenPerSecond) + } + fmt.Println() + if err == nil { + msgs = append(msgs, Message{Role: "assistant", Content: full.String()}) + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..bb614aa --- /dev/null +++ b/config.go @@ -0,0 +1,150 @@ +package main + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "os" + "strconv" + "strings" +) + +// readAPIKey returns the trimmed contents of $JEAN_HOME/.api_key, or "" if the +// file is absent/empty. This store is independent of config.env so the key +// survives preset switches (which rewrite config.env wholesale). +func readAPIKey() string { + b, err := os.ReadFile(apiKeyPath()) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// authHeader sets the Authorization: Bearer header on req when an API key is +// configured, so jean's own internal calls (chat/web/bench/test) authenticate +// against a protected llama-server. No-op when no key is set. +func authHeader(req *http.Request) { + if k := readAPIKey(); k != "" { + req.Header.Set("Authorization", "Bearer "+k) + } +} + +// cmdSetAPIKey sets (or clears) the API key stored in $JEAN_HOME/.api_key. When +// exposed on the internet, llama-server requires "Authorization: Bearer " +// for every call. +// +// jean set-api-key définit la clé +// jean set-api-key génère une clé aléatoire +// jean set-api-key "" supprime la protection +func cmdSetAPIKey(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 = "sk-jean-" + 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]) + } + // Stocke dans le fichier dédié (survit aux switches de preset). On nettoie + // aussi un éventuel API_KEY résiduel dans config.env pour éviter la confusion. + _ = SetConfigKey("API_KEY", "") + if key == "" { + if err := os.Remove(apiKeyPath()); err != nil && !os.IsNotExist(err) { + return err + } + } else if err := os.WriteFile(apiKeyPath(), []byte(key+"\n"), 0o600); err != nil { + return err + } + if key == "" { + fmt.Printf("%s API_KEY supprimée — serveur ouvert (pas d'authentification)\n", yellow("[info]")) + } else { + fmt.Printf("%s API_KEY enregistrée dans %s\n", green("[ok]"), apiKeyPath()) + fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key)) + } + fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] ")) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") { + fmt.Println(dim("[info] pense à lancer 'jean restart'")) + return nil + } + return serviceAction("restart") +} + +// ReadConfig parses config.env into a key/value map. +// Lines starting with '#' and blanks are ignored. Values may be quoted with ". +func ReadConfig() map[string]string { + m := map[string]string{} + b, err := os.ReadFile(confPath()) + if err != nil { + return m + } + for _, line := range strings.Split(string(b), "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + i := strings.IndexByte(s, '=') + if i < 0 { + continue + } + k := strings.TrimSpace(s[:i]) + v := strings.TrimSpace(s[i+1:]) + v = strings.Trim(v, "\"") + m[k] = v + } + return m +} + +// SetConfigKey sets key=value in config.env, updating the line in place if the +// key already exists (preserving comments/order) or appending it otherwise. +// An empty value removes the key. The file is created if missing. +func SetConfigKey(key, value string) error { + b, _ := os.ReadFile(confPath()) + lines := []string{} + if len(b) > 0 { + lines = strings.Split(strings.TrimRight(string(b), "\n"), "\n") + } + newLine := key + "=" + value + found := false + out := []string{} + for _, line := range lines { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + out = append(out, line) + continue + } + i := strings.IndexByte(s, '=') + if i >= 0 && strings.TrimSpace(s[:i]) == key { + found = true + if value != "" { + out = append(out, newLine) + } + // empty value => drop the line + continue + } + out = append(out, line) + } + if !found && value != "" { + out = append(out, newLine) + } + content := strings.Join(out, "\n") + "\n" + return os.WriteFile(confPath(), []byte(content), 0o644) +} + +// LLMPort returns the configured server port (config.env PORT), default 8080. +func LLMPort() int { + if p, ok := ReadConfig()["PORT"]; ok { + if n, err := strconv.Atoi(p); err == nil && n > 0 { + return n + } + } + return 8080 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..af69418 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/jean-llm/jean + +go 1.22 diff --git a/install.go b/install.go new file mode 100644 index 0000000..def8844 --- /dev/null +++ b/install.go @@ -0,0 +1,196 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" +) + +const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart +# Le service systemd lit ce fichier et lance ton binaire llama.cpp. + +# Chemins +BIN="/usr/local/bin/llama-server" +MODEL="/home/USER/models/your-model.gguf" + +# Serveur +PORT="8080" +HOST="0.0.0.0" + +# Inference +CTX="32768" +BATCH="2048" +UBATCH="512" +NGL="999" + +# Args supplémentaires passés à llama-server +EXTRA_ARGS="" +` + +const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by jean). +%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s +` + +const serviceUnitTemplate = `[Unit] +Description=JEAN llama.cpp server +After=network.target + +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=%s +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +` + +func cmdInstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)") + } + targetUser := os.Getenv("SUDO_USER") + if targetUser == "" { + targetUser = "root" + } + for _, a := range args { + if strings.HasPrefix(a, "--user=") { + targetUser = strings.TrimPrefix(a, "--user=") + } + } + u, err := user.Lookup(targetUser) + if err != nil { + return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) + } + jeanHome := DefaultJeanHome + if v := os.Getenv("JEAN_HOME"); v != "" { + jeanHome = v + } + svc := serviceName() + + fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) + fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" service = %s\n", svc) + + // 1. Create directories + for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + + // 2. Drop a config.env if none exists + conf := filepath.Join(jeanHome, "config.env") + if _, err := os.Stat(conf); os.IsNotExist(err) { + body := strings.ReplaceAll(configTemplate, "USER", targetUser) + if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { + return err + } + fmt.Printf(" %s écrit %s\n", green("✓"), conf) + } + + // 3. Symlink current binary to /usr/local/bin/jean + self, err := os.Executable() + if err != nil { + return err + } + target := "/usr/local/bin/jean" + _ = os.Remove(target) + if err := os.Symlink(self, target); err != nil { + // fall back to copy if symlink fails (e.g. cross-fs) + if data, err := os.ReadFile(self); err == nil { + _ = os.WriteFile(target, data, 0o755) + } + } + fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) + + // 4. Drop /etc/default/jean so root invocations resolve JEAN_HOME correctly. + defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) + if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil { + return err + } + fmt.Printf(" %s /etc/default/jean\n", green("✓")) + + // 5. Write the systemd unit (ExecStart = `jean serve`, no start.sh needed) + unit := fmt.Sprintf(serviceUnitTemplate, targetUser, jeanHome, "/usr/local/bin/jean serve") + unitPath := "/etc/systemd/system/" + svc + ".service" + if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), unitPath) + + // 5. Sudoers drop-in + sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) + sudoersPath := "/etc/sudoers.d/jean-" + svc + if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), sudoersPath) + + // 6. chown JEAN_HOME contents to target user + chown(jeanHome, u) + + // 7. systemd reload + _ = exec.Command("systemctl", "daemon-reload").Run() + + fmt.Println() + fmt.Printf("%s installation terminée.\n", green("[ok]")) + fmt.Printf("\nProchaines étapes :\n") + fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit")) + fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") + fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" jean start")) + fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web")) + return nil +} + +func cmdUninstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("jean uninstall doit être exécuté en root") + } + svc := serviceName() + keepData := false + for _, a := range args { + if a == "--purge" { + keepData = false + } + if a == "--keep-data" { + keepData = true + } + } + _ = exec.Command("systemctl", "stop", svc).Run() + _ = exec.Command("systemctl", "disable", svc).Run() + for _, p := range []string{ + "/etc/systemd/system/" + svc + ".service", + "/etc/sudoers.d/jean-" + svc, + "/etc/default/jean", + "/usr/local/bin/jean", + } { + if err := os.Remove(p); err == nil { + fmt.Printf(" %s %s\n", green("✓"), p) + } + } + _ = exec.Command("systemctl", "daemon-reload").Run() + if !keepData { + fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)")) + } + fmt.Println(green("[ok]") + " désinstallé") + return nil +} + +// chown recursively changes ownership of path to the given user/group. +func chown(path string, u *user.User) { + var uid, gid int + fmt.Sscanf(u.Uid, "%d", &uid) + fmt.Sscanf(u.Gid, "%d", &gid) + filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err == nil { + _ = os.Chown(p, uid, gid) + } + return nil + }) +} diff --git a/llamacpp.go b/llamacpp.go new file mode 100644 index 0000000..367d846 --- /dev/null +++ b/llamacpp.go @@ -0,0 +1,607 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +// llamacpp.go — gestion du backend llama.cpp (clone, build, mise à jour). +// +// `jean llamacpp install` installe un build neuf, détecte automatiquement +// l'accélérateur (CUDA / ROCm / Metal / CPU) et la +// compute capability du GPU, puis pointe BIN dessus. +// `jean llamacpp update` met à jour le dépôt existant (git pull) et recompile +// avec la bonne config, sans intervention. +// `jean llamacpp status` montre le commit courant, le backend détecté et le +// retard éventuel sur origin. + +const llamacppRepoURL = "https://github.com/ggml-org/llama.cpp.git" + +// buildPlan capture les flags CMake adaptés à la machine courante. +type buildPlan struct { + backend string // "cuda" | "hip" | "metal" | "vulkan" | "cpu" + cudaArch string // ex. "120" ou "86;89" (vide => détection native par CMake) + cudaCXX string // chemin de nvcc quand backend == cuda + flags []string // flags -D… passés à `cmake -B build` + jobs int // parallélisme du build +} + +func cmdLlamacpp(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + args = args[1:] + } + switch sub { + case "install": + return llamacppInstall(args) + case "update", "upgrade": + return llamacppUpdate(args) + case "status", "info", "": + return llamacppStatus(args) + default: + return fmt.Errorf("sous-commande inconnue: %s (install | update | status)", sub) + } +} + +// --------------------------------------------------------------------------- +// Localisation du dépôt +// --------------------------------------------------------------------------- + +// llamacppRepoDir resolves the llama.cpp checkout: derived from config BIN when +// possible (so `update` targets whatever build the service actually runs), +// otherwise the default under $JEAN_HOME/backends/llama.cpp. +func llamacppRepoDir() string { + if bin := ReadConfig()["BIN"]; bin != "" { + if real, err := filepath.EvalSymlinks(bin); err == nil { + bin = real + } + if root := findRepoRoot(bin); root != "" { + return root + } + } + return defaultRepoDir() +} + +func defaultRepoDir() string { + return filepath.Join(JeanHome(), "backends", "llama.cpp") +} + +// findRepoRoot walks up from a binary path (…/build/bin/llama-server) looking +// for the llama.cpp source root (a dir holding .git or CMakeLists.txt). +func findRepoRoot(binPath string) string { + d := filepath.Dir(binPath) + for i := 0; i < 6; i++ { + if isDir(filepath.Join(d, ".git")) || isFile(filepath.Join(d, "CMakeLists.txt")) { + return d + } + parent := filepath.Dir(d) + if parent == d { + break + } + d = parent + } + return "" +} + +// --------------------------------------------------------------------------- +// install +// --------------------------------------------------------------------------- + +func llamacppInstall(args []string) error { + repo := defaultRepoDir() + ref := "" + force := false + noSwitch := false + for _, a := range args { + switch { + case strings.HasPrefix(a, "--dir="): + repo = strings.TrimPrefix(a, "--dir=") + case strings.HasPrefix(a, "--ref="): + ref = strings.TrimPrefix(a, "--ref=") + case a == "--force": + force = true + case a == "--no-switch": + noSwitch = true + default: + return fmt.Errorf("option inconnue: %s", a) + } + } + + if err := requireTools("git", "cmake"); err != nil { + return err + } + + // Dépôt déjà présent ? On bascule sur update plutôt que de re-cloner. + if isDir(filepath.Join(repo, ".git")) { + if !force { + fmt.Printf("%s dépôt déjà présent dans %s\n", yellow("[info]"), repo) + fmt.Printf(" → %s pour le mettre à jour, ou --force pour repartir de zéro\n", bold("jean llamacpp update")) + return nil + } + fmt.Printf("%s --force : suppression de %s\n", yellow("[info]"), repo) + if err := os.RemoveAll(repo); err != nil { + return err + } + } + + if err := os.MkdirAll(filepath.Dir(repo), 0o755); err != nil { + return err + } + + fmt.Printf("%s clone de llama.cpp dans %s\n", cyan("▶"), repo) + if err := runStep("git clone", "", "git", "clone", "--depth=1", llamacppRepoURL, repo); err != nil { + return err + } + if ref != "" { + // --depth=1 ne récupère que HEAD ; on approfondit pour atteindre le ref. + _ = runStep("git fetch", repo, "git", "fetch", "--unshallow", "origin") + if err := runStep("git checkout", repo, "git", "checkout", ref); err != nil { + return err + } + } + + plan := detectBuildPlan() + printPlan(plan, repo) + + if err := buildLlamacpp(repo, plan, true); err != nil { + return err + } + + bin := filepath.Join(repo, "build", "bin", "llama-server") + if !isFile(bin) { + return fmt.Errorf("build terminé mais binaire introuvable: %s", bin) + } + fmt.Printf("\n%s binaire compilé : %s\n", green("✓"), bin) + + if noSwitch { + fmt.Printf("%s --no-switch : config.env inchangée (BIN à régler manuellement)\n", dim("[info]")) + return nil + } + if err := SetConfigKey("BIN", bin); err != nil { + return fmt.Errorf("build ok mais échec écriture BIN dans config.env: %w", err) + } + fmt.Printf("%s BIN mis à jour dans %s\n", green("✓"), confPath()) + fmt.Printf("\nProchaines étapes :\n 1. renseigne MODEL : %s\n 2. démarre : %s\n", + bold("jean edit"), bold("jean restart")) + return nil +} + +// --------------------------------------------------------------------------- +// update +// --------------------------------------------------------------------------- + +func llamacppUpdate(args []string) error { + ref := "" + clean := false + noRestart := false + force := false + for _, a := range args { + switch { + case strings.HasPrefix(a, "--ref="): + ref = strings.TrimPrefix(a, "--ref=") + case a == "--clean": + clean = true + case a == "--no-restart": + noRestart = true + case a == "--force": + force = true + default: + return fmt.Errorf("option inconnue: %s", a) + } + } + + if err := requireTools("git", "cmake"); err != nil { + return err + } + + repo := llamacppRepoDir() + if !isDir(filepath.Join(repo, ".git")) { + return fmt.Errorf("aucun dépôt llama.cpp trouvé (%s).\n → lance d'abord %s", repo, bold("jean llamacpp install")) + } + fmt.Printf("%s dépôt : %s\n", cyan("▶"), repo) + + oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") + + // Détermine la branche à suivre (master par défaut si HEAD détaché). + branch := ref + if branch == "" { + branch = gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + if branch == "" || branch == "HEAD" { + branch = "master" + } + } + + if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { + return err + } + + // Déjà à jour ? On s'arrête (sauf --clean / --force qui forcent un rebuild). + localRev := gitOutput(repo, "rev-parse", "HEAD") + remoteRev := gitOutput(repo, "rev-parse", "origin/"+branch) + bin := filepath.Join(repo, "build", "bin", "llama-server") + if localRev != "" && localRev == remoteRev && !clean && !force && isFile(bin) { + fmt.Printf("%s déjà à jour (%s) — rien à faire\n", green("[ok]"), oldCommit) + fmt.Printf(" (utilise %s pour forcer une recompilation)\n", dim("--force")) + return nil + } + + // Met à jour la source. + if ref != "" { + if err := runStep("git checkout", repo, "git", "checkout", ref); err != nil { + return err + } + } else { + if err := runStep("git pull --ff-only", repo, "git", "pull", "--ff-only", "origin", branch); err != nil { + return fmt.Errorf("git pull a échoué (modifs locales ? essaie de résoudre à la main): %w", err) + } + } + newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") + + // On stoppe le service : le binaire en cours d'exécution ne peut pas être + // réécrit par l'étape de link (« Text file busy »). + svcWasUp := serviceIsActive() + if svcWasUp { + fmt.Printf("%s arrêt du service %s le temps du build…\n", yellow("[info]"), serviceName()) + if err := serviceAction("stop"); err != nil { + fmt.Printf("%s impossible d'arrêter le service (%v) — le build peut échouer si le binaire est verrouillé\n", yellow("[warn]"), err) + } + } + + plan := detectBuildPlan() + printPlan(plan, repo) + + if err := buildLlamacpp(repo, plan, clean); err != nil { + // On tente de remettre le service debout même en cas d'échec. + if svcWasUp && !noRestart { + _ = serviceAction("start") + } + return err + } + if !isFile(bin) { + return fmt.Errorf("build terminé mais binaire introuvable: %s", bin) + } + + fmt.Printf("\n%s mis à jour : %s → %s\n", green("✓"), oldCommit, newCommit) + + if noRestart { + fmt.Printf("%s --no-restart : pense à lancer %s\n", dim("[info]"), bold("jean restart")) + return nil + } + if svcWasUp { + fmt.Printf("%s redémarrage du service…\n", cyan("▶")) + return serviceAction("start") + } + fmt.Printf("%s service non démarré auparavant — lance %s quand tu veux\n", dim("[info]"), bold("jean start")) + return nil +} + +// --------------------------------------------------------------------------- +// status +// --------------------------------------------------------------------------- + +func llamacppStatus(args []string) error { + repo := llamacppRepoDir() + fmt.Printf("%s\n", bold("llama.cpp")) + fmt.Printf(" dépôt : %s\n", repo) + if !isDir(filepath.Join(repo, ".git")) { + fmt.Printf(" %s pas encore installé — %s\n", yellow("état"), bold("jean llamacpp install")) + return nil + } + commit := gitOutput(repo, "log", "-1", "--format=%h %ci %s") + branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + fmt.Printf(" branche : %s\n", branch) + fmt.Printf(" commit : %s\n", commit) + + bin := filepath.Join(repo, "build", "bin", "llama-server") + if isFile(bin) { + fmt.Printf(" binaire : %s\n", green(bin)) + } else { + fmt.Printf(" binaire : %s (pas encore compilé)\n", yellow("absent")) + } + + // Retard sur origin (best-effort, sans fetch réseau). + if branch != "" && branch != "HEAD" { + if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); behind != "" && behind != "0" { + fmt.Printf(" maj : %s commit(s) de retard sur origin/%s — %s\n", yellow(behind), branch, bold("jean llamacpp update")) + } + } + + plan := detectBuildPlan() + fmt.Printf(" backend : %s\n", planLabel(plan)) + return nil +} + +// --------------------------------------------------------------------------- +// Détection matérielle & build +// --------------------------------------------------------------------------- + +// detectBuildPlan probes the machine and returns the CMake flags for the best +// available accelerator. Order of preference: CUDA → ROCm/HIP → Metal (macOS) +// → Vulkan → CPU. +func detectBuildPlan() buildPlan { + p := buildPlan{backend: "cpu", jobs: numJobs()} + // Flags communs : Release + tuning natif pour la machine de build. + // (libcurl est activé d'office par llama.cpp ; LLAMA_CURL est déprécié.) + p.flags = []string{ + "-DCMAKE_BUILD_TYPE=Release", + "-DGGML_NATIVE=ON", + } + + if runtime.GOOS == "darwin" { + // Metal est activé par défaut sur Apple Silicon ; on l'explicite. + p.backend = "metal" + p.flags = append(p.flags, "-DGGML_METAL=ON") + return p + } + + // CUDA : nvcc présent ET un GPU NVIDIA visible. + if nvcc := findNvcc(); nvcc != "" && hasNvidiaGPU() { + p.backend = "cuda" + p.cudaCXX = nvcc + p.flags = append(p.flags, "-DGGML_CUDA=ON", "-DGGML_CUDA_F16=ON", "-DGGML_CUDA_FA_ALL_QUANTS=ON") + if arch := detectCudaArch(); arch != "" { + p.cudaArch = arch + p.flags = append(p.flags, "-DCMAKE_CUDA_ARCHITECTURES="+arch) + } + return p + } + + // AMD ROCm / HIP. + if hasTool("hipcc") || isDir("/opt/rocm") { + p.backend = "hip" + p.flags = append(p.flags, "-DGGML_HIP=ON") + return p + } + + // Vulkan (GPU générique) — utile sur Intel/AMD sans ROCm. + if hasTool("glslc") && (isFile("/usr/lib/x86_64-linux-gnu/libvulkan.so.1") || hasTool("vulkaninfo")) { + p.backend = "vulkan" + p.flags = append(p.flags, "-DGGML_VULKAN=ON") + return p + } + + return p // CPU +} + +// buildLlamacpp configures and builds the llama-server target. It handles the +// "relocated checkout" gotcha: a build/ whose CMake cache was generated under a +// different source path can't reconfigure in place, so we wipe it. `clean` +// forces a from-scratch build regardless. +func buildLlamacpp(repo string, p buildPlan, clean bool) error { + build := filepath.Join(repo, "build") + + if clean || cacheStale(build, repo) { + if isDir(build) { + fmt.Printf("%s reconfiguration propre (suppression de build/)\n", dim("[info]")) + old := build + ".old" + _ = os.RemoveAll(old) + if err := os.Rename(build, old); err != nil { + _ = os.RemoveAll(build) // dernier recours + } + } + } + + // nvcc doit être dans le PATH et exposé via CUDACXX pour la config CMake. + env := "" + if p.backend == "cuda" && p.cudaCXX != "" { + cudaBin := filepath.Dir(p.cudaCXX) + env = "CUDACXX=" + p.cudaCXX + "\x00PATH=" + cudaBin + string(os.PathListSeparator) + os.Getenv("PATH") + } + + cfgArgs := append([]string{"-B", "build", "-S", "."}, p.flags...) + if err := runStepEnv("cmake configure", repo, env, "cmake", cfgArgs...); err != nil { + return fmt.Errorf("configuration CMake échouée: %w", err) + } + + buildArgs := []string{"--build", "build", "--config", "Release", + "-j", fmt.Sprintf("%d", p.jobs), "--target", "llama-server"} + if err := runStepEnv("cmake build", repo, env, "cmake", buildArgs...); err != nil { + return fmt.Errorf("compilation échouée: %w", err) + } + return nil +} + +// cacheStale reports whether build/CMakeCache.txt was generated for a different +// source directory than `repo` (the relocated-checkout case). +func cacheStale(build, repo string) bool { + cache := filepath.Join(build, "CMakeCache.txt") + b, err := os.ReadFile(cache) + if err != nil { + return false // pas de cache => configure neuf, rien à nettoyer + } + absRepo, _ := filepath.Abs(repo) + for _, line := range strings.Split(string(b), "\n") { + // CMAKE_HOME_DIRECTORY pointe vers le source dir d'origine. + if strings.HasPrefix(line, "CMAKE_HOME_DIRECTORY:") { + if i := strings.IndexByte(line, '='); i >= 0 { + home := strings.TrimSpace(line[i+1:]) + return home != "" && home != absRepo + } + } + } + return false +} + +// --------------------------------------------------------------------------- +// Sondes matérielles +// --------------------------------------------------------------------------- + +// findNvcc returns the path to nvcc from PATH or a /usr/local/cuda* install, +// preferring the highest version. +func findNvcc() string { + if p, err := exec.LookPath("nvcc"); err == nil { + return p + } + if p := "/usr/local/cuda/bin/nvcc"; isFile(p) { + return p + } + matches, _ := filepath.Glob("/usr/local/cuda-*/bin/nvcc") + if len(matches) > 0 { + sort.Strings(matches) // cuda-12.2 < cuda-12.8 lexicographiquement → on prend le dernier + return matches[len(matches)-1] + } + return "" +} + +func hasNvidiaGPU() bool { + if !hasTool("nvidia-smi") { + return false + } + out, err := exec.Command("nvidia-smi", "-L").Output() + return err == nil && strings.Contains(string(out), "GPU") +} + +// detectCudaArch queries every GPU's compute capability via nvidia-smi and +// returns them as CMake-style arch codes (e.g. "8.6" → "86"), deduped and +// joined with ';'. Empty when the driver is too old to report it (CMake then +// falls back to native detection). +func detectCudaArch() string { + out, err := exec.Command("nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader").Output() + if err != nil { + return "" + } + seen := map[string]bool{} + var archs []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + cap := strings.TrimSpace(line) + if cap == "" || strings.Contains(strings.ToLower(cap), "not supported") { + continue + } + code := strings.ReplaceAll(cap, ".", "") // "12.0" → "120" + if code != "" && !seen[code] { + seen[code] = true + archs = append(archs, code) + } + } + return strings.Join(archs, ";") +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func numJobs() int { + n := runtime.NumCPU() + if n < 1 { + return 1 + } + return n +} + +func isFile(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() +} + +func isDir(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + +func hasTool(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func requireTools(tools ...string) error { + var missing []string + for _, t := range tools { + if !hasTool(t) { + missing = append(missing, t) + } + } + if len(missing) > 0 { + return fmt.Errorf("outils manquants: %s — installe-les puis réessaie", strings.Join(missing, ", ")) + } + return nil +} + +func serviceIsActive() bool { + out, _ := exec.Command("systemctl", "is-active", serviceName()).Output() + return strings.TrimSpace(string(out)) == "active" +} + +// gitOutput runs a git command in `dir` and returns trimmed stdout (or ""). +func gitOutput(dir string, args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// runStep runs a command in `dir` streaming output live to the terminal. +func runStep(name, dir, bin string, args ...string) error { + return runStepEnv(name, dir, "", bin, args...) +} + +// runStepEnv is runStep with optional extra env vars (NUL-separated KEY=VAL +// pairs in `extraEnv`, which override existing ones). +func runStepEnv(name, dir, extraEnv, bin string, args ...string) error { + fmt.Printf("\n%s %s %s\n", cyan("▶"), name, dim(strings.Join(args, " "))) + cmd := exec.Command(bin, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if extraEnv != "" { + env := os.Environ() + for _, kv := range strings.Split(extraEnv, "\x00") { + if kv == "" { + continue + } + env = upsertEnv(env, kv) + } + cmd.Env = env + } + return cmd.Run() +} + +// upsertEnv replaces KEY=… in env if present, else appends kv (kv is "KEY=VAL"). +func upsertEnv(env []string, kv string) []string { + key := kv + if i := strings.IndexByte(kv, '='); i >= 0 { + key = kv[:i] + } + for i, e := range env { + if strings.HasPrefix(e, key+"=") { + env[i] = kv + return env + } + } + return append(env, kv) +} + +func planLabel(p buildPlan) string { + switch p.backend { + case "cuda": + arch := p.cudaArch + if arch == "" { + arch = "native" + } + return green("CUDA") + dim(" (arch="+arch+", nvcc="+p.cudaCXX+")") + case "hip": + return green("ROCm/HIP") + case "metal": + return green("Metal") + case "vulkan": + return green("Vulkan") + default: + return yellow("CPU") + dim(" (aucun accélérateur détecté)") + } +} + +func printPlan(p buildPlan, repo string) { + fmt.Printf("\n%s configuration du build\n", bold("•")) + fmt.Printf(" backend : %s\n", planLabel(p)) + fmt.Printf(" jobs : %d\n", p.jobs) + fmt.Printf(" flags : %s\n", dim(strings.Join(p.flags, " "))) +} diff --git a/llm.go b/llm.go new file mode 100644 index 0000000..2f9b193 --- /dev/null +++ b/llm.go @@ -0,0 +1,332 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" +) + +// Message is one entry in the chat history sent to llama.cpp. +// `Content` may be nil when an assistant message only contains tool_calls. +type Message struct { + Role string `json:"role"` + Content any `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function ToolCallFunc `json:"function"` +} +type ToolCallFunc struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type Tool struct { + Type string `json:"type"` + Function ToolFunction `json:"function"` +} +type ToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` +} + +// readSkillTool / runShellTool: OpenAI-shaped function definitions advertised +// to the model when the corresponding feature flag is on. +func readSkillTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "read_skill", + Description: "Lit le contenu détaillé d'un skill listé dans le system prompt.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string", "description": "Nom exact du skill"}, + }, + "required": []string{"name"}, + }, + }, + } +} + +func runShellTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "run_shell", + Description: "Exécute une commande shell sur le serveur (bash). Retourne stdout, stderr et le code de sortie. Utilise-le pour inspecter le système, lancer des scripts, consulter des logs. Évite les commandes destructrices sauf si l'utilisateur le demande explicitement.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{"type": "string", "description": "La commande bash à exécuter"}, + "timeout": map[string]any{"type": "integer", "description": fmt.Sprintf("Timeout en secondes (défaut %d, max %d)", toolDefaultTimeout, toolMaxTimeout)}, + }, + "required": []string{"command"}, + }, + }, + } +} + +// InjectSkills prepends the lightweight skills directory message to msgs when +// skills are enabled. Merges with an existing system message if present. +func InjectSkills(msgs []Message) []Message { + sp := skillsSystemPrompt() + if sp == "" { + return msgs + } + if len(msgs) > 0 && msgs[0].Role == "system" { + existing, _ := msgs[0].Content.(string) + merged := append([]Message{{Role: "system", Content: sp + "\n\n" + existing}}, msgs[1:]...) + return merged + } + return append([]Message{{Role: "system", Content: sp}}, msgs...) +} + +// EnabledTools returns the tools to advertise on the next inference call. +func EnabledTools() []Tool { + tools := []Tool{} + if skillsEnabled() && len(ListSkills()) > 0 { + tools = append(tools, readSkillTool()) + } + if toolsEnabled() { + tools = append(tools, runShellTool()) + } + return tools +} + +// StreamEvent is what a ChatCallback receives for each piece of streamed output. +// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err} is set per call. +type StreamEvent struct { + Content string + Reasoning string + ToolUsed *ToolUsedEvent + Stats *StatsEvent + Err error +} +type ToolUsedEvent struct { + Name string + Label string // user-visible summary (skill name or first ~80 chars of command) +} + +// StatsEvent carries llama.cpp's per-completion timing (final chunk). +type StatsEvent struct { + PromptTokens int `json:"prompt_tokens"` + PromptPerSecond float64 `json:"prompt_per_second"` + PromptMs float64 `json:"prompt_ms"` + GenTokens int `json:"gen_tokens"` + GenPerSecond float64 `json:"gen_per_second"` + GenMs float64 `json:"gen_ms"` +} + +// ChatCallback receives stream events. Return false to abort the stream. +type ChatCallback func(StreamEvent) bool + +// completionResp / streamChunk model the subset of llama.cpp's +// OpenAI-compatible /v1/chat/completions response that we care about. +type streamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + ToolCalls []ToolCall `json:"tool_calls"` + } `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + // llama.cpp's "timings" appears on the final chunk and on intermediate + // /completion endpoint responses. Snake-case mapping per llama.cpp source. + Timings *struct { + PromptN int `json:"prompt_n"` + PromptMs float64 `json:"prompt_ms"` + PromptPerSecond float64 `json:"prompt_per_second"` + PredictedN int `json:"predicted_n"` + PredictedMs float64 `json:"predicted_ms"` + PredictedPerSec float64 `json:"predicted_per_second"` + } `json:"timings"` +} + +// 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. +func runChat(messages []Message, temperature float64, cb ChatCallback) error { + tools := EnabledTools() + for iter := 0; iter < 8; iter++ { + payload := map[string]any{ + "model": "jean", + "messages": messages, + "stream": true, + "temperature": temperature, + } + if len(tools) > 0 { + payload["tools"] = tools + } + body, _ := json.Marshal(payload) + url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + authHeader(req) + resp, err := http.DefaultClient.Do(req) + if err != nil { + cb(StreamEvent{Err: err}) + return err + } + toolCalls := map[int]*ToolCall{} + assistantContent := strings.Builder{} + finishReason := "" + // 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) + aborted := false + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(line[5:]) + if data == "" || data == "[DONE]" { + continue + } + var chunk streamChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil || len(chunk.Choices) == 0 { + continue + } + ch := chunk.Choices[0] + if ch.FinishReason != "" { + finishReason = ch.FinishReason + } + if chunk.Timings != nil { + cb(StreamEvent{Stats: &StatsEvent{ + PromptTokens: chunk.Timings.PromptN, + PromptPerSecond: chunk.Timings.PromptPerSecond, + PromptMs: chunk.Timings.PromptMs, + GenTokens: chunk.Timings.PredictedN, + GenPerSecond: chunk.Timings.PredictedPerSec, + GenMs: chunk.Timings.PredictedMs, + }}) + } + if len(ch.Delta.ToolCalls) > 0 { + for i, tc := range ch.Delta.ToolCalls { + // llama.cpp's stream may omit index; fall back to slot i. + idx := i + cur, ok := toolCalls[idx] + if !ok { + cur = &ToolCall{Type: "function"} + toolCalls[idx] = cur + } + if tc.ID != "" { + cur.ID = tc.ID + } + if tc.Function.Name != "" { + cur.Function.Name = tc.Function.Name + } + cur.Function.Arguments += tc.Function.Arguments + } + continue + } + if ch.Delta.ReasoningContent != "" { + if !cb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) { + aborted = true + break + } + } + if ch.Delta.Content != "" { + assistantContent.WriteString(ch.Delta.Content) + if !cb(StreamEvent{Content: ch.Delta.Content}) { + aborted = true + break + } + } + } + resp.Body.Close() + if aborted { + return nil + } + + if finishReason == "tool_calls" && len(toolCalls) > 0 { + // 1. Append assistant message with tool_calls so the model sees its own decision next turn. + idxs := make([]int, 0, len(toolCalls)) + for k := range toolCalls { + idxs = append(idxs, k) + } + sort.Ints(idxs) + tcs := make([]ToolCall, 0, len(idxs)) + for i, k := range idxs { + tc := *toolCalls[k] + if tc.ID == "" { + tc.ID = fmt.Sprintf("call_%d_%d", iter, i) + } + if tc.Function.Arguments == "" { + tc.Function.Arguments = "{}" + } + tcs = append(tcs, tc) + } + assistant := Message{Role: "assistant", ToolCalls: tcs} + if s := assistantContent.String(); s != "" { + assistant.Content = s + } + messages = append(messages, assistant) + // 2. Execute each tool locally and append a "tool" reply. + for _, tc := range tcs { + var args map[string]any + _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) + result := "" + label := "" + switch tc.Function.Name { + case "read_skill": + name, _ := args["name"].(string) + label = name + if c := SkillContent(name); c != "" { + result = c + } else { + result = fmt.Sprintf("[erreur] skill '%s' introuvable", name) + } + case "run_shell": + cmd, _ := args["command"].(string) + to := 0 + switch v := args["timeout"].(type) { + case float64: + to = int(v) + case int: + to = v + } + label = cmd + if len(label) > 80 { + label = label[:80] + "…" + } + result = runShell(cmd, to) + default: + result = "[erreur] outil inconnu: " + tc.Function.Name + } + cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}}) + messages = append(messages, Message{Role: "tool", ToolCallID: tc.ID, Content: result}) + } + continue + } + return nil + } + cb(StreamEvent{Content: "\n\n[stop: trop d'appels d'outils]"}) + return nil +} + +// healthCheck pings llama.cpp's /health endpoint. +func healthCheck() bool { + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort())) + if err != nil { + return false + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + return resp.StatusCode == 200 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1a66a42 --- /dev/null +++ b/main.go @@ -0,0 +1,195 @@ +// jean — single-binary LLM server manager + web UI for llama.cpp deployments. +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const Version = "0.1.0" + +func main() { + args := os.Args[1:] + cmd := "help" + if len(args) > 0 { + cmd = args[0] + args = args[1:] + } + switch cmd { + case "start", "stop", "restart", "status", "enable", "disable": + mustExit(serviceAction(cmd)) + case "logs": + mustExit(serviceLogs()) + case "edit": + mustExit(editConfig()) + case "set-api-key": + mustExit(cmdSetAPIKey(args)) + case "vram": + mustExit(showVram()) + case "switch": + mustExit(cmdSwitch(args)) + case "chat": + mustExit(cmdChat(args)) + case "web": + mustExit(cmdWeb(args)) + case "skills": + mustExit(cmdSkills(args)) + case "tools": + mustExit(cmdTools(args)) + case "serve": + mustExit(cmdServe(args)) + case "test": + mustExit(cmdTest(args)) + case "bench": + mustExit(cmdBench(args)) + case "llamacpp", "llama": + mustExit(cmdLlamacpp(args)) + case "install": + mustExit(cmdInstall(args)) + case "uninstall": + mustExit(cmdUninstall(args)) + case "version", "-v", "--version": + fmt.Println("jean", Version) + case "help", "-h", "--help", "": + printHelp() + default: + fmt.Fprintf(os.Stderr, "commande inconnue: %s\n\n", cmd) + printHelp() + os.Exit(2) + } +} + +func printHelp() { + fmt.Printf(`jean %s — manager llama.cpp + UI web (single binary) + +Usage: jean [args] + +Service: + start | stop | restart gérer le service systemd + 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 + vram utilisation GPU/VRAM (nvidia-smi) + test vérifie que l'IA répond (health + completion) + bench [N] mesure prefill + decode tok/s (prompt 2000 tok, N=200 par défaut) + +Presets: + switch [N] choisir un preset dans configs/ (interactif ou par numéro) + +Interaction: + chat [system-prompt] chat terminal streamé + web [PORT] UI web (défaut :8090) — chat + presets + skills + tools + +LLM-side outils: + skills [on|off|list] active la lecture de SKILLS//SKILL.md par l'IA + tools [on|off|status] active run_shell (l'IA exécute des commandes bash) + +Backend llama.cpp : + llamacpp install clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), pointe BIN dessus + llamacpp update git pull + recompile le backend existant (arrête/redémarre le service) + llamacpp status commit courant, backend détecté, retard sur origin + +Entrypoint (utilisé par jean.service) : + serve lit config.env et exec le binaire llama-server + +Installation: + install installer (systemd unit, sudoers, dossiers) + uninstall désinstaller + +Env: + JEAN_HOME racine (défaut: $HOME/JEAN, ou /home//JEAN si root) + EDITOR éditeur pour 'jean edit' (défaut: nano) + +Config: $JEAN_HOME/config.env +`, Version) +} + +func mustExit(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "[err]", err) + os.Exit(1) + } +} + +// DefaultJeanHome is where everything lives unless overridden. +const DefaultJeanHome = "/etc/jean" + +// JeanHome resolves the JEAN data directory. +// Precedence: $JEAN_HOME → /etc/default/jean → DefaultJeanHome. +func JeanHome() string { + if h := os.Getenv("JEAN_HOME"); h != "" { + return h + } + if h := readEtcDefault(); h != "" { + return h + } + return DefaultJeanHome +} + +// readEtcDefault parses /etc/default/jean for JEAN_HOME=. Quiet on errors. +func readEtcDefault() string { + b, err := os.ReadFile("/etc/default/jean") + if err != nil { + return "" + } + for _, line := range strings.Split(string(b), "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + s = strings.TrimPrefix(s, "export ") + if eq := strings.IndexByte(s, '='); eq > 0 { + k := strings.TrimSpace(s[:eq]) + v := strings.Trim(strings.TrimSpace(s[eq+1:]), "\"'") + if k == "JEAN_HOME" { + return v + } + } + } + return "" +} + +func confPath() string { return filepath.Join(JeanHome(), "config.env") } +func presetsDir() string { return filepath.Join(JeanHome(), "configs") } +func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") } +func skillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } +func toolsFlag() string { return filepath.Join(JeanHome(), ".tools_enabled") } +func apiKeyPath() string { return filepath.Join(JeanHome(), ".api_key") } +func serviceName() string { + if n := os.Getenv("JEAN_SERVICE"); n != "" { + return n + } + return "jean" +} + +// Color helpers (ANSI). Disabled when stdout is not a TTY. +var colorOn = isTerminal() + +func col(code, s string) string { + if !colorOn { + return s + } + return "\033[" + code + "m" + s + "\033[0m" +} +func bold(s string) string { return col("1", s) } +func cyan(s string) string { return col("1;36", s) } +func green(s string) string { return col("32", s) } +func red(s string) string { return col("31", s) } +func dim(s string) string { return col("2", s) } +func yellow(s string) string { return col("33", s) } +func magenta(s string) string { return col("35", s) } + +// trimSplit splits and drops empty tokens. +func trimSplit(s, sep string) []string { + out := []string{} + for _, p := range strings.Split(s, sep) { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/presets.go b/presets.go new file mode 100644 index 0000000..1e57588 --- /dev/null +++ b/presets.go @@ -0,0 +1,180 @@ +package main + +import ( + "bufio" + "crypto/sha1" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +type Preset struct { + Name string + 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). +func ListPresets() ([]Preset, error) { + dir := presetsDir() + _ = os.MkdirAll(dir, 0o755) + cur := "" + if b, err := os.ReadFile(confPath()); err == nil { + h := sha1.Sum(b) + cur = hex.EncodeToString(h[:]) + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + out := []Preset{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") { + continue + } + p := filepath.Join(dir, e.Name()) + b, err := os.ReadFile(p) + if err != nil { + continue + } + h := sha1.Sum(b) + out = append(out, Preset{ + Name: strings.TrimSuffix(e.Name(), ".env"), + Path: p, + Active: hex.EncodeToString(h[:]) == cur, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +var presetNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// 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, ._-)") + } + root, err := filepath.Abs(presetsDir()) + if err != nil { + return "", err + } + p := filepath.Join(root, name+".env") + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + if !strings.HasPrefix(abs, root+string(filepath.Separator)) { + return "", fmt.Errorf("path invalide") + } + return abs, nil +} + +// SwitchToPreset backs up the current config and copies the target into place, +// then restarts the service. +func SwitchToPreset(target string) error { + src, err := os.ReadFile(target) + 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.Println(dim("[info] redémarrage du service...")) + return serviceAction("restart") +} + +func cmdSwitch(args []string) error { + list, err := ListPresets() + if err != nil { + return err + } + if len(list) == 0 { + return fmt.Errorf("aucun preset dans %s", presetsDir()) + } + fmt.Printf("\n %s (%s)\n\n", cyan("Presets disponibles"), presetsDir()) + for i, p := range list { + mark := " " + if p.Active { + mark = green("●") + " actif" + } + fmt.Printf(" %2d) %-30s %s\n", i+1, p.Name, mark) + } + fmt.Println() + choice := "" + if len(args) > 0 { + choice = args[0] + } else { + fmt.Print("Numéro à activer (vide = annuler) : ") + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() { + choice = strings.TrimSpace(sc.Text()) + } + } + if choice == "" { + fmt.Println(dim("[info] annulé")) + return nil + } + n, err := strconv.Atoi(choice) + if err != nil || n < 1 || n > len(list) { + return fmt.Errorf("choix invalide") + } + 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) + } + } + p, err := safePresetPath(name) + if err != nil { + return err + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + return 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) + if err != nil { + return err + } + cur, _ := os.ReadFile(confPath()) + target, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("introuvable") + } + if sha1.Sum(cur) == sha1.Sum(target) { + return fmt.Errorf("preset actif, switche d'abord") + } + return os.Remove(p) +} + +// ReadPreset returns the contents of a preset by name. +func ReadPreset(name string) (string, error) { + p, err := safePresetPath(name) + if err != nil { + return "", err + } + b, err := os.ReadFile(p) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/serve.go b/serve.go new file mode 100644 index 0000000..ed6e9b9 --- /dev/null +++ b/serve.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +// cmdServe replaces the historic start.sh: read config.env, build the +// llama-server invocation, and exec it (replacing this process so systemd +// supervises llama-server directly). +func cmdServe(args []string) error { + cfg := ReadConfig() + bin := cfg["BIN"] + if bin == "" { + return fmt.Errorf("BIN non défini dans %s", confPath()) + } + model := cfg["MODEL"] + if model == "" { + return fmt.Errorf("MODEL non défini dans %s", confPath()) + } + + // Resolve LD_LIBRARY_PATH the same way start.sh did so that custom llama.cpp + // builds with bundled shared libs continue to load their .so neighbours. + ld := filepath.Dir(bin) + if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" { + ld = ld + ":" + existing + } + _ = os.Setenv("LD_LIBRARY_PATH", ld) + + get := func(key, fallback string) string { + if v, ok := cfg[key]; ok && v != "" { + return v + } + return fallback + } + kv := get("KV_TYPE", "") + ktv := get("KV_TYPE_K", kv) + vtv := get("KV_TYPE_V", kv) + + llmArgs := []string{bin, + "-m", model, + "-ngl", get("NGL", "999"), + "-c", get("CTX", "32768"), + "-t", get("THREADS", "0"), + "-tb", get("THREADS_BATCH", "0"), + "-b", get("BATCH", "2048"), + "-ub", get("UBATCH", "512"), + "--host", get("HOST", "0.0.0.0"), + "--port", get("PORT", "8080"), + } + if ktv != "" { + llmArgs = append(llmArgs, "-ctk", ktv) + } + if vtv != "" { + llmArgs = append(llmArgs, "-ctv", vtv) + } + if r := cfg["REASONING"]; r != "" { + llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", "0") + } + // API_KEY protège le serveur quand il est exposé sur internet : llama-server + // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis + // $JEAN_HOME/.api_key en priorité (elle survit ainsi aux changements de preset + // qui réécrivent config.env), avec config.env comme repli rétro-compatible. + if k := readAPIKey(); k != "" { + llmArgs = append(llmArgs, "--api-key", k) + } else if k := cfg["API_KEY"]; k != "" { + llmArgs = append(llmArgs, "--api-key", k) + } + // EXTRA_ARGS is appended verbatim, split on whitespace like the shell would. + for _, a := range trimSplit(cfg["EXTRA_ARGS"], " ") { + llmArgs = append(llmArgs, a) + } + + // Working dir = JEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj + // mmproj-F16.gguf) still resolve. + _ = os.Chdir(JeanHome()) + + fmt.Fprintf(os.Stderr, "[jean serve] %s model=%s port=%s\n", + bin, filepath.Base(model), get("PORT", "8080")) + + // exec replaces this process — same as start.sh's `exec`. + return syscall.Exec(bin, llmArgs, os.Environ()) +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..1916aa9 --- /dev/null +++ b/service.go @@ -0,0 +1,115 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// serviceAction wraps `systemctl ` with passwordless sudo where +// it makes sense, and prints a follow-up status check after start/restart. +func serviceAction(action string) error { + svc := serviceName() + needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable" + args := []string{} + bin := "systemctl" + if needsRoot && os.Geteuid() != 0 { + bin = "sudo" + args = append(args, "-n", "systemctl") + } + args = append(args, action, svc) + cmd := exec.Command(bin, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + switch action { + case "start", "restart": + return checkStarted(svc) + case "stop": + fmt.Println(green("[ok]") + " arrêté") + case "enable": + fmt.Println(green("[ok]") + " démarrage auto activé") + case "disable": + fmt.Println(green("[ok]") + " démarrage auto désactivé") + } + return nil +} + +func checkStarted(svc string) error { + time.Sleep(2 * time.Second) + out, _ := exec.Command("systemctl", "is-active", svc).Output() + state := strings.TrimSpace(string(out)) + if state == "active" || state == "activating" { + fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state) + return nil + } + fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state) + fmt.Println("------------------------------------------------") + logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output() + fmt.Print(string(logs)) + fmt.Println("------------------------------------------------") + fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + return fmt.Errorf("service %s non démarré", svc) +} + +func serviceLogs() error { + cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func editConfig() error { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "nano" + } + cmd := exec.Command(editor, confPath()) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + fmt.Println(dim("[info] jean restart pour appliquer")) + return nil +} + +// showVram parses `nvidia-smi --query-gpu=...` and renders a colored bar. +func showVram() error { + out, err := exec.Command("nvidia-smi", + "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", + "--format=csv,noheader,nounits").Output() + if err != nil { + return fmt.Errorf("nvidia-smi indisponible: %w", err) + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.Split(line, ",") + if len(parts) != 5 { + continue + } + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + name := parts[0] + used, _ := strconv.Atoi(parts[1]) + total, _ := strconv.Atoi(parts[2]) + util, _ := strconv.Atoi(parts[3]) + temp, _ := strconv.Atoi(parts[4]) + pct := 0 + if total > 0 { + pct = used * 100 / total + } + full := pct / 5 + bar := strings.Repeat("█", full) + strings.Repeat("░", 20-full) + fmt.Printf("\n %s\n", cyan(name)) + fmt.Printf(" VRAM %s %3d%% %.1f / %.1f GiB\n", green(bar), pct, float64(used)/1024, float64(total)/1024) + fmt.Printf(" GPU %3d%% Temp %d°C\n\n", util, temp) + } + return nil +} diff --git a/skills.go b/skills.go new file mode 100644 index 0000000..be4fd11 --- /dev/null +++ b/skills.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +type Skill struct { + Name string + Desc string // first non-empty line, with leading #'s stripped + Content string +} + +func skillsEnabled() bool { + _, err := os.Stat(skillsFlag()) + return err == nil +} + +func setSkillsEnabled(on bool) error { + _ = os.MkdirAll(skillsDir(), 0o755) + if on { + f, err := os.Create(skillsFlag()) + if err != nil { + return err + } + return f.Close() + } + if err := os.Remove(skillsFlag()); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// ListSkills walks SKILLS//SKILL.md, returning name+desc+full content. +func ListSkills() []Skill { + entries, err := os.ReadDir(skillsDir()) + if err != nil { + return nil + } + out := []Skill{} + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + f := filepath.Join(skillsDir(), e.Name(), "SKILL.md") + b, err := os.ReadFile(f) + if err != nil { + continue + } + content := string(b) + desc := "" + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#")) + if s != "" { + desc = s + break + } + } + out = append(out, Skill{Name: e.Name(), Desc: desc, Content: content}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +var skillNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// safeSkillDir validates and returns the on-disk path for a skill directory. +func safeSkillDir(name string) (string, error) { + if !skillNameRe.MatchString(name) { + return "", fmt.Errorf("nom invalide (alphanum, ._-)") + } + root, err := filepath.Abs(skillsDir()) + if err != nil { + return "", err + } + p := filepath.Join(root, name) + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + if !strings.HasPrefix(abs, root+string(filepath.Separator)) { + return "", fmt.Errorf("path invalide") + } + return abs, nil +} + +// SkillContent loads SKILL.md for the given name (used by the read_skill tool). +func SkillContent(name string) string { + d, err := safeSkillDir(name) + if err != nil { + return "" + } + b, err := os.ReadFile(filepath.Join(d, "SKILL.md")) + if err != nil { + return "" + } + return string(b) +} + +func SaveSkill(name, old, content string) error { + if old != "" && old != name { + if od, err := safeSkillDir(old); err == nil { + _ = os.RemoveAll(od) + } + } + d, err := safeSkillDir(name) + if err != nil { + return err + } + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(d, "SKILL.md"), []byte(content), 0o644) +} + +func DeleteSkill(name string) error { + d, err := safeSkillDir(name) + if err != nil { + return err + } + if _, err := os.Stat(d); err != nil { + return fmt.Errorf("introuvable") + } + return os.RemoveAll(d) +} + +// skillsSystemPrompt returns the lightweight skills directory message to +// prepend to the conversation when skills are enabled. +func skillsSystemPrompt() string { + if !skillsEnabled() { + return "" + } + list := ListSkills() + if len(list) == 0 { + return "" + } + var b strings.Builder + b.WriteString(`Tu as accès à des skills (guides spécialisés). Pour lire le contenu détaillé d'un skill quand c'est pertinent, appelle la fonction read_skill(name=""). N'appelle pas read_skill si la question est sans rapport avec un skill listé. + +Skills disponibles : +`) + for _, s := range list { + fmt.Fprintf(&b, "- %s: %s\n", s.Name, s.Desc) + } + return strings.TrimRight(b.String(), "\n") +} + +func cmdSkills(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "on": + if err := setSkillsEnabled(true); err != nil { + return err + } + fmt.Println(green("[ok]") + " skills activés") + case "off": + if err := setSkillsEnabled(false); err != nil { + return err + } + fmt.Println(green("[ok]") + " skills désactivés") + case "", "list": + state := dim("off") + if skillsEnabled() { + state = green("on") + } + fmt.Printf("%s (%s) état: %s\n", cyan("Skills"), skillsDir(), state) + sk := ListSkills() + if len(sk) == 0 { + fmt.Printf(" (aucun — crée %s//SKILL.md)\n", skillsDir()) + return nil + } + for _, s := range sk { + fmt.Printf(" %s %s\n", bold(s.Name), s.Desc) + } + default: + return fmt.Errorf("usage: jean skills [on|off|list]") + } + return nil +} diff --git a/test_cmd.go b/test_cmd.go new file mode 100644 index 0000000..1097b8e --- /dev/null +++ b/test_cmd.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "strings" + "time" +) + +// cmdTest sanity-checks the LLM end-to-end: HTTP /health, then a minimal chat +// completion to confirm the model actually generates tokens. +func cmdTest(args []string) error { + port := LLMPort() + fmt.Printf("→ GET http://localhost:%d/health … ", port) + if !healthCheck() { + fmt.Println(red("ko")) + return fmt.Errorf("/health ne répond pas — jean start d'abord") + } + fmt.Println(green("ok")) + + fmt.Printf("→ chat completion (prompt « ping ») … ") + msgs := []Message{{Role: "user", Content: "Réponds juste « pong ». Rien d'autre."}} + var reply strings.Builder + t0 := time.Now() + var firstTok time.Time + tokens := 0 + err := runChat(msgs, 0, func(ev StreamEvent) bool { + if ev.Content != "" { + if firstTok.IsZero() { + firstTok = time.Now() + } + tokens++ + reply.WriteString(ev.Content) + } + return true + }) + if err != nil { + fmt.Println(red("ko")) + return err + } + elapsed := time.Since(t0) + ttft := time.Duration(0) + if !firstTok.IsZero() { + ttft = firstTok.Sub(t0) + } + fmt.Println(green("ok")) + + out := strings.TrimSpace(reply.String()) + if len(out) > 120 { + out = out[:120] + "…" + } + fmt.Printf("\n %s %s\n", cyan("réponse :"), out) + fmt.Printf(" %s %s\n", cyan("ttft :"), ttft.Round(time.Millisecond)) + fmt.Printf(" %s %s (%d tokens)\n", cyan("total :"), elapsed.Round(time.Millisecond), tokens) + if elapsed > 0 { + tps := float64(tokens) / (elapsed - ttft).Seconds() + if tokens > 0 && elapsed > ttft { + fmt.Printf(" %s %.1f tok/s\n", cyan("decode :"), tps) + } + } + if tokens == 0 { + return fmt.Errorf("aucun token généré") + } + fmt.Println("\n" + green("[ok]") + " l'IA répond") + return nil +} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..967dc1e --- /dev/null +++ b/tools.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const ( + toolDefaultTimeout = 30 + toolMaxTimeout = 300 + toolMaxOutput = 8000 // characters of stdout/stderr returned to the model +) + +func toolsEnabled() bool { + _, err := os.Stat(toolsFlag()) + return err == nil +} + +func setToolsEnabled(on bool) error { + _ = os.MkdirAll(JeanHome(), 0o755) + if on { + f, err := os.Create(toolsFlag()) + if err != nil { + return err + } + return f.Close() + } + if err := os.Remove(toolsFlag()); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// runShell executes a command via `bash -c` with a clamped timeout, returning +// a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..." truncated +// to keep tool output bounded. +func runShell(command string, timeoutSec int) string { + if timeoutSec <= 0 { + timeoutSec = toolDefaultTimeout + } + if timeoutSec > toolMaxTimeout { + timeoutSec = toolMaxTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "/bin/bash", "-c", command) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if ctx.Err() == context.DeadlineExceeded { + return fmt.Sprintf("[timeout après %ds]", timeoutSec) + } + exit := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + exit = ee.ExitCode() + } else { + return fmt.Sprintf("[erreur: %v]", err) + } + } + out := tailRunes(stdout.String(), toolMaxOutput) + errOut := tailRunes(stderr.String(), toolMaxOutput) + parts := []string{fmt.Sprintf("exit: %d", exit)} + if out != "" { + parts = append(parts, "stdout:\n"+out) + } + if errOut != "" { + parts = append(parts, "stderr:\n"+errOut) + } + return strings.Join(parts, "\n\n") +} + +// tailRunes returns the last n runes of s (used to cap tool output). +func tailRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[len(r)-n:]) +} + +func cmdTools(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "on": + if err := setToolsEnabled(true); err != nil { + return err + } + fmt.Println(green("[ok]") + " tool run_shell activé — exécution bash possible") + case "off": + if err := setToolsEnabled(false); err != nil { + return err + } + fmt.Println(green("[ok]") + " tools désactivés") + case "", "status": + state := dim("off") + if toolsEnabled() { + state = green("on") + } + fmt.Printf("%s run_shell état: %s\n", cyan("Tools"), state) + fmt.Printf(" timeout défaut: %ds, max: %ds\n", toolDefaultTimeout, toolMaxTimeout) + default: + return fmt.Errorf("usage: jean tools [on|off|status]") + } + return nil +} diff --git a/tty.go b/tty.go new file mode 100644 index 0000000..c837617 --- /dev/null +++ b/tty.go @@ -0,0 +1,12 @@ +package main + +import "os" + +// isTerminal reports whether stdout is a TTY. Stdlib-only check via FileMode. +func isTerminal() bool { + fi, err := os.Stdout.Stat() + if err != nil || fi == nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/web.go b/web.go new file mode 100644 index 0000000..06ddf95 --- /dev/null +++ b/web.go @@ -0,0 +1,440 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "strconv" + "strings" +) + +//go:embed ui/index.html ui/marked.min.js +var uiFS embed.FS + +// cmdWeb starts the HTTP server on the given port (default 8090). +func cmdWeb(args []string) error { + port := 8090 + if len(args) > 0 && args[0] != "" { + n, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("port invalide: %s", args[0]) + } + port = n + } + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/marked.min.js", func(w http.ResponseWriter, r *http.Request) { + b, _ := uiFS.ReadFile("ui/marked.min.js") + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(b) + }) + 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) + addr := fmt.Sprintf("0.0.0.0:%d", port) + fmt.Printf("[jean web] http://%s (Ctrl-C pour arrêter)\n", addr) + return http.ListenAndServe(addr, mux) +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/index.html" { + http.NotFound(w, r) + return + } + b, err := uiFS.ReadFile("ui/index.html") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Write(b) +} + +func sendJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +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 := state == "active" + health := false + if active { + health = healthCheck() + } + sendJSON(w, 200, map[string]any{ + "state": state, + "active": active, + "health": health, + "port": LLMPort(), + }) +} + +func handleVram(w http.ResponseWriter, r *http.Request) { + out, err := exec.Command("nvidia-smi", + "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", + "--format=csv,noheader,nounits").Output() + gpus := []map[string]any{} + if err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.Split(line, ",") + if len(parts) != 5 { + continue + } + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + used, _ := strconv.Atoi(parts[1]) + total, _ := strconv.Atoi(parts[2]) + util, _ := strconv.Atoi(parts[3]) + temp, _ := strconv.Atoi(parts[4]) + gpus = append(gpus, map[string]any{ + "name": parts[0], "used": used, "total": total, "util": util, "temp": temp, + }) + } + } + sendJSON(w, 200, gpus) +} + +func handleConfigEnv(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, ReadConfig()) +} + +// handleBackends scans JEAN_HOME/backends// for a llama-server binary, +// trying common build subpaths (build/bin, build-sm120/bin, bin, .). +// Returns [{name, path}]. +func handleBackends(w http.ResponseWriter, r *http.Request) { + root := JeanHome() + "/backends" + entries, err := os.ReadDir(root) + if err != nil { + sendJSON(w, 200, []map[string]any{}) + return + } + subpaths := []string{ + "build/bin/llama-server", "build-sm120/bin/llama-server", + "build/llama-server", "bin/llama-server", "llama-server", + } + out := []map[string]any{} + for _, e := range entries { + // e can be a directory or a symlink to one; either is fine. + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + for _, sp := range subpaths { + p := root + "/" + name + "/" + sp + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + out = append(out, map[string]any{"name": name, "path": p}) + break + } + } + } + sendJSON(w, 200, out) +} + +// handleModels lists *.gguf files in JEAN_HOME (size in bytes) for the preset +// editor's model picker. +func handleModels(w http.ResponseWriter, r *http.Request) { + entries, err := os.ReadDir(JeanHome()) + if err != nil { + sendJSON(w, 200, []map[string]any{}) + return + } + out := []map[string]any{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { + continue + } + info, _ := e.Info() + size := int64(0) + if info != nil { + size = info.Size() + } + out = append(out, map[string]any{"name": e.Name(), "size": size}) + } + sendJSON(w, 200, out) +} + +func handlePresets(w http.ResponseWriter, r *http.Request) { + list, err := ListPresets() + if err != nil { + sendJSON(w, 500, map[string]any{"error": err.Error()}) + return + } + out := []map[string]any{} + for _, p := range list { + out = append(out, map[string]any{"name": p.Name, "active": p.Active}) + } + sendJSON(w, 200, out) +} + +func handlePreset(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + // 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)}) + return + } + content, err := ReadPreset(name) + if err != nil { + sendJSON(w, 404, map[string]any{"error": "not found"}) + return + } + sendJSON(w, 200, map[string]any{"name": name, "content": content}) +} + +type saveReq struct { + Name string `json:"name"` + Old string `json:"old"` + Content string `json:"content"` +} + +func handlePresetSave(w http.ResponseWriter, r *http.Request) { + var req saveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := SavePreset(req.Name, req.Old, req.Content); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "name": req.Name}) +} + +func handlePresetDelete(w http.ResponseWriter, r *http.Request) { + var req saveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := DeletePreset(req.Name); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +func handleSkills(w http.ResponseWriter, r *http.Request) { + sk := ListSkills() + out := []map[string]any{} + for _, s := range sk { + out = append(out, map[string]any{"name": s.Name, "desc": s.Desc}) + } + sendJSON(w, 200, map[string]any{"enabled": skillsEnabled(), "skills": out}) +} + +func handleSkillsToggle(w http.ResponseWriter, r *http.Request) { + var req struct { + On bool `json:"on"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := setSkillsEnabled(req.On); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "enabled": skillsEnabled()}) +} + +func handleSkill(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + sendJSON(w, 200, map[string]any{"name": "", "content": "# nouveau skill\n\nDécris ici quand et comment l'IA doit utiliser ce skill.\n"}) + return + } + c := SkillContent(name) + if c == "" { + sendJSON(w, 404, map[string]any{"error": "not found"}) + return + } + sendJSON(w, 200, map[string]any{"name": name, "content": c}) +} + +func handleSkillSave(w http.ResponseWriter, r *http.Request) { + var req saveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := SaveSkill(req.Name, req.Old, req.Content); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "name": req.Name}) +} + +func handleSkillDelete(w http.ResponseWriter, r *http.Request) { + var req saveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := DeleteSkill(req.Name); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +func handleTools(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, map[string]any{"enabled": toolsEnabled()}) +} + +func handleToolsToggle(w http.ResponseWriter, r *http.Request) { + var req struct { + On bool `json:"on"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := setToolsEnabled(req.On); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "enabled": toolsEnabled()}) +} + +func handleSwitch(w http.ResponseWriter, r *http.Request) { + var req struct { + N int `json:"n"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + list, err := ListPresets() + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + if req.N < 1 || req.N > len(list) { + sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"}) + return + } + target := list[req.N-1] + if err := SwitchToPreset(target.Path); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + 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. +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()) + } + out, err := cmd.CombinedOutput() + sendJSON(w, 200, map[string]any{"ok": err == nil, "out": string(out)}) + } +} + +// handleChat is the SSE proxy with tool-calling. The HTTP handler writes raw +// data: lines matching what the embedded JS expects (delta.content, +// delta.reasoning_content, delta.tool_used). +// handleBench runs `runBench` synchronously. Long enough (~30-60s) that we +// rely on the client side to show a spinner / disable the button. +func handleBench(w http.ResponseWriter, r *http.Request) { + nPrompt, nPredict := 2000, 300 + if v := r.URL.Query().Get("prompt"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + nPrompt = parsed + } + } + if v := r.URL.Query().Get("n"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + nPredict = parsed + } + } + res, err := runBench(nPrompt, nPredict) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "result": res}) +} + +func handleChat(w http.ResponseWriter, r *http.Request) { + var body struct { + Messages []Message `json:"messages"` + Temperature float64 `json:"temperature"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), 400) + return + } + if body.Temperature == 0 { + body.Temperature = 0.7 + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + flusher, _ := w.(http.Flusher) + emit := func(obj map[string]any) bool { + b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}}) + if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil { + return false + } + if flusher != nil { + flusher.Flush() + } + return true + } + msgs := InjectSkills(body.Messages) + _ = runChat(msgs, body.Temperature, func(ev StreamEvent) bool { + if ev.Err != nil { + emit(map[string]any{"error": ev.Err.Error()}) + return true + } + if ev.ToolUsed != nil { + emit(map[string]any{"tool_used": map[string]any{"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label}}) + return true + } + if ev.Stats != nil { + emit(map[string]any{"stats": ev.Stats}) + return true + } + if ev.Reasoning != "" { + return emit(map[string]any{"reasoning_content": ev.Reasoning}) + } + if ev.Content != "" { + return emit(map[string]any{"content": ev.Content}) + } + return true + }) +} + From 28afbb7223001fcedeb1f3b889c5ead18ed46b39 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 12:57:24 +0200 Subject: [PATCH 003/279] Create index.html --- ui/index.html | 574 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 ui/index.html diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..8165fd9 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,574 @@ + + + +jean + + +
+
+

jean

+
…
+
GPU +
…
+
+
Config active +
…
+
+
Presets +
+
+
Tools + +
⚠️ permet à l'IA d'exécuter des commandes bash sur le serveur (timeout 30s, max 300s)
+
+
Skills + +
+
+
System prompt + +
+
Service +
+ + + +
+
+
Actions +
+ + + +
+
+
+
+
+ +
+ + + +
+
+
+ + + + From 506934124e02c74298d1c65e45039dc934f1765a Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 12:57:42 +0200 Subject: [PATCH 004/279] Add files via upload --- ui/marked.min.js | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 ui/marked.min.js diff --git a/ui/marked.min.js b/ui/marked.min.js new file mode 100644 index 0000000..a91afe7 --- /dev/null +++ b/ui/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v12.0.2 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1");e=x(e.replace(/^ *>[ \t]?/gm,""),"\n");const n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),q={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},Z=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...q,table:Z,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Z).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...q,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",C=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),M=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),O=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:M,emStrongRDelimAst:O,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:C,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); From baeaeda20e970aaec6f33d408d4d8c25a44c222f Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 12:58:55 +0200 Subject: [PATCH 005/279] Delete .gitignore --- .gitignore | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 6d096b7..0000000 --- a/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# Compiled binaries -/jean -/jean.exe -/jean-linux* -/jean-darwin* -/jean-windows* -jean-*-amd64 -jean-*-arm64 -*.exe -*.out - -# Build artifacts -/dist/ -/build/ - -# Local secrets / runtime data — never commit -.env -*.api_key -.api_key -config.env - -# Scratch / logs -*.log -out.txt -out*.txt -err.txt -err*.txt - -# OS / editor -.DS_Store -Thumbs.db -*.swp -.idea/ -.vscode/ From 46fab2edf45ead98ad92a5fe1a44995a34fb6c8d Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 12:59:06 +0200 Subject: [PATCH 006/279] Delete .gitattributes --- .gitattributes | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6bb6f89..0000000 --- a/.gitattributes +++ /dev/null @@ -1,7 +0,0 @@ -# Normalize line endings to LF for all text files. -* text=auto eol=lf -*.go text eol=lf -*.md text eol=lf -*.yml text eol=lf -*.html text eol=lf -*.js text eol=lf From 5841e0f24c82366f3130b16d6411abfb51dd3e46 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 12:59:29 +0200 Subject: [PATCH 007/279] Add files via upload --- README.md | 180 +++++++++++++++++++++++++++--------------------------- 1 file changed, 90 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 5c010e4..029201a 100644 --- a/README.md +++ b/README.md @@ -1,168 +1,168 @@ # Jean -**A single-binary manager for self-hosted [llama.cpp](https://github.com/ggml-org/llama.cpp) servers — plus a built-in web UI, terminal chat, and an auto-detecting backend builder.** +**Un gestionnaire mono-binaire pour serveurs [llama.cpp](https://github.com/ggml-org/llama.cpp) auto-hébergés — avec une interface web intégrée, un chat terminal, et un compilateur de backend qui détecte automatiquement le matériel.** -Drop one binary on a machine, run `jean llamacpp install`, and Jean clones, configures, and compiles llama.cpp for *that* machine's hardware (CUDA / ROCm / Metal / Vulkan / CPU) — no flags to remember. Then `jean start` and you have an OpenAI-compatible endpoint with a web chat on top. +Dépose un seul binaire sur une machine, lance `jean llamacpp install`, et Jean clone, configure et compile llama.cpp pour le matériel de *cette* machine (CUDA / ROCm / Metal / Vulkan / CPU) — aucun flag à retenir. Ensuite `jean start` et tu as un endpoint compatible OpenAI avec un chat web par-dessus. ``` -download binary → jean llamacpp install → jean edit → jean start → done +télécharge le binaire → jean llamacpp install → jean edit → jean start → c'est parti ``` --- -## Why +## Pourquoi -Running llama.cpp as a real service usually means: figure out the right CMake flags for your GPU, write a systemd unit, manage an API key, swap models, keep the build up to date… Jean turns all of that into a handful of subcommands behind a single static binary with **no runtime dependencies** (other than llama.cpp itself, which Jean can build for you). +Faire tourner llama.cpp comme un vrai service, ça veut dire d'habitude : trouver les bons flags CMake pour ton GPU, écrire une unité systemd, gérer une clé API, changer de modèle, garder le build à jour… Jean transforme tout ça en quelques sous-commandes derrière un binaire statique unique, **sans dépendance à l'exécution** (à part llama.cpp lui-même, que Jean peut compiler pour toi). -## Features +## Fonctionnalités -- **`jean llamacpp install` / `update`** — clones and compiles llama.cpp with the right flags **auto-detected** for the host: - - **CUDA** when an NVIDIA GPU + `nvcc` are present (compute capability detected per-GPU via `nvidia-smi`, so multi-GPU machines build for all cards) - - **ROCm/HIP** (AMD), **Metal** (macOS / Apple Silicon), **Vulkan**, or **CPU** fallback - - `update` pulls the latest commit, stops the service while it rebuilds, then restarts it -- **systemd integration** — `jean install` writes the unit, a passwordless `systemctl` sudoers rule, and the data dirs -- **Web UI** (`jean web`) — chat, model/preset switching, skills & tools toggles -- **Terminal chat** (`jean chat`) — streamed responses -- **Presets** (`jean switch`) — keep multiple `config.env` profiles and swap between them -- **API key protection** (`jean set-api-key`) — Bearer auth for exposing the server publicly; the key is stored separately so it survives preset switches -- **Benchmark** (`jean bench`) — honest prefill/decode tok/s using a varied corpus -- **Single static binary** — built with `CGO_ENABLED=0`, cross-compiles trivially +- **`jean llamacpp install` / `update`** — clone et compile llama.cpp avec les bons flags **détectés automatiquement** pour l'hôte : + - **CUDA** quand un GPU NVIDIA + `nvcc` sont présents (compute capability détectée par GPU via `nvidia-smi`, donc les machines multi-GPU compilent pour toutes les cartes) + - **ROCm/HIP** (AMD), **Metal** (macOS / Apple Silicon), **Vulkan**, ou repli **CPU** + - `update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre +- **Intégration systemd** — `jean install` écrit l'unité, une règle sudoers `systemctl` sans mot de passe, et les dossiers de données +- **Interface web** (`jean web`) — chat, changement de modèle/preset, activation des skills & tools +- **Chat terminal** (`jean chat`) — réponses en streaming +- **Presets** (`jean switch`) — garde plusieurs profils `config.env` et bascule entre eux +- **Protection par clé API** (`jean set-api-key`) — auth Bearer pour exposer le serveur publiquement ; la clé est stockée à part pour survivre aux changements de preset +- **Benchmark** (`jean bench`) — tok/s prefill/decode honnêtes avec un corpus varié +- **Binaire statique unique** — compilé avec `CGO_ENABLED=0`, se cross-compile trivialement -## Quick start +## Démarrage rapide -### 1. Get the binary +### 1. Récupère le binaire -Grab a prebuilt binary from the [Releases](../../releases) page, or [build from source](#building-from-source): +Prends un binaire pré-compilé depuis la page [Releases](../../releases), ou [compile depuis les sources](#compiler-depuis-les-sources) : ```bash -# example: Linux x86_64 +# exemple : Linux x86_64 curl -L -o jean https://github.com/jean-llm/jean/releases/latest/download/jean-linux-amd64 chmod +x jean sudo mv jean /usr/local/bin/jean ``` -### 2. Install (systemd unit, dirs, sudoers) +### 2. Installe (unité systemd, dossiers, sudoers) ```bash sudo jean install ``` -### 3. Build a llama.cpp backend for this machine +### 3. Compile un backend llama.cpp pour cette machine ```bash jean llamacpp install ``` -Jean auto-detects your accelerator, compiles `llama-server`, and points the config at the new binary. Requires `git` and `cmake` (plus the matching toolkit, e.g. CUDA, if you want GPU acceleration). +Jean détecte ton accélérateur, compile `llama-server`, et pointe la config sur le nouveau binaire. Nécessite `git` et `cmake` (plus le toolkit correspondant, ex. CUDA, si tu veux l'accélération GPU). -### 4. Point it at a model and start +### 4. Pointe-le sur un modèle et démarre ```bash -jean edit # set MODEL=/path/to/your-model.gguf +jean edit # règle MODEL=/chemin/vers/ton-modele.gguf jean start -jean test # verify the model answers +jean test # vérifie que le modèle répond ``` -### 5. (optional) Web UI +### 5. (optionnel) Interface web ```bash -jean web # http://:8090 +jean web # http://:8090 ``` -## Commands +## Commandes ``` -Service: - start | stop | restart manage the systemd service - status | logs status / live logs - enable | disable start on boot - edit edit $JEAN_HOME/config.env - set-api-key [key] protect the API (Bearer); empty = generate, "" = remove - vram GPU/VRAM usage (nvidia-smi) - test check the model answers (health + completion) - bench [N] measure prefill + decode tok/s +Service : + start | stop | restart gérer le service systemd + 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 + vram utilisation GPU/VRAM (nvidia-smi) + test vérifie que le modèle répond (health + completion) + bench [N] mesure prefill + decode tok/s -Presets: - switch [N] pick a preset from configs/ (interactive or by number) +Presets : + switch [N] choisir un preset dans configs/ (interactif ou par numéro) -Interaction: - chat [system-prompt] streamed terminal chat - web [PORT] web UI (default :8090) +Interaction : + chat [system-prompt] chat terminal streamé + web [PORT] interface web (défaut :8090) -LLM-side tooling: - skills [on|off|list] let the model read SKILLS//SKILL.md - tools [on|off|status] enable run_shell (model executes shell commands) +Outils côté LLM : + skills [on|off|list] laisse le modèle lire SKILLS//SKILL.md + tools [on|off|status] active run_shell (le modèle exécute des commandes shell) -Backend (llama.cpp): - llamacpp install clone + build llama.cpp (auto-detect CUDA/ROCm/Metal/CPU), set BIN - llamacpp update git pull + rebuild the existing backend (stops/restarts the service) - llamacpp status current commit, detected backend, commits behind origin +Backend (llama.cpp) : + llamacpp install clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), règle BIN + llamacpp update git pull + recompile le backend existant (arrête/redémarre le service) + llamacpp status commit courant, backend détecté, commits de retard sur origin -Install: - install install (systemd unit, sudoers, dirs) - uninstall uninstall +Installation : + install installer (unité systemd, sudoers, dossiers) + uninstall désinstaller ``` -### `jean llamacpp` flags +### Options de `jean llamacpp` ``` -install [--dir=PATH] [--ref=GIT_REF] [--force] [--no-switch] -update [--ref=GIT_REF] [--clean] [--no-restart] [--force] +install [--dir=CHEMIN] [--ref=REF_GIT] [--force] [--no-switch] +update [--ref=REF_GIT] [--clean] [--no-restart] [--force] ``` -- `--dir=` — where to clone (default `$JEAN_HOME/backends/llama.cpp`) -- `--ref=` — build a specific branch/tag/commit -- `--clean` — wipe `build/` and recompile from scratch -- `--no-switch` — don't touch `config.env` (install only) -- `--no-restart` — leave the service stopped after updating +- `--dir=` — où cloner (défaut `$JEAN_HOME/backends/llama.cpp`) +- `--ref=` — compiler une branche/tag/commit précis +- `--clean` — vide `build/` et recompile de zéro +- `--no-switch` — ne touche pas à `config.env` (install seulement) +- `--no-restart` — laisse le service arrêté après la mise à jour ## Configuration -Everything lives under **`$JEAN_HOME`** (default `/etc/jean`). The service reads `config.env`: +Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean`). Le service lit `config.env` : -| Key | Meaning | Default | -|-----|---------|---------| -| `BIN` | path to `llama-server` (set by `llamacpp install`) | — | -| `MODEL` | path to the `.gguf` model | — | -| `HOST` / `PORT` | bind address / port | `0.0.0.0` / `8080` | -| `CTX` | context size | `32768` | -| `NGL` | GPU layers to offload | `999` | +| Clé | Signification | Défaut | +|-----|---------------|--------| +| `BIN` | chemin vers `llama-server` (réglé par `llamacpp install`) | — | +| `MODEL` | chemin vers le modèle `.gguf` | — | +| `HOST` / `PORT` | adresse / port d'écoute | `0.0.0.0` / `8080` | +| `CTX` | taille du contexte | `32768` | +| `NGL` | couches à déporter sur le GPU | `999` | | `BATCH` / `UBATCH` | batch / micro-batch | `2048` / `512` | -| `THREADS` / `THREADS_BATCH` | CPU threads | `0` (auto) | -| `KV_TYPE` (`_K`/`_V`) | KV cache quantization | — | -| `REASONING` | reasoning mode passthrough | — | -| `EXTRA_ARGS` | appended verbatim to `llama-server` | — | +| `THREADS` / `THREADS_BATCH` | threads CPU | `0` (auto) | +| `KV_TYPE` (`_K`/`_V`) | quantization du cache KV | — | +| `REASONING` | passthrough du mode raisonnement | — | +| `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — | -The API key (when set with `jean set-api-key`) is stored in `$JEAN_HOME/.api_key`, separate from `config.env`. +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`. -### Environment +### Variables d'environnement -| Var | Meaning | Default | -|-----|---------|---------| -| `JEAN_HOME` | data root | `/etc/jean` (or `$HOME/JEAN`) | -| `JEAN_SERVICE` | systemd unit name | `jean` | -| `EDITOR` | editor for `jean edit` | `nano` | +| Variable | Signification | Défaut | +|----------|---------------|--------| +| `JEAN_HOME` | racine des données | `/etc/jean` (ou `$HOME/JEAN`) | +| `JEAN_SERVICE` | nom de l'unité systemd | `jean` | +| `EDITOR` | éditeur pour `jean edit` | `nano` | -## Building from source +## Compiler depuis les sources -Requires Go 1.22+. Jean is a pure-Go binary (the web UI is embedded via `go:embed`): +Nécessite Go 1.22+. Jean est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) : ```bash git clone https://github.com/jean-llm/jean.git cd jean CGO_ENABLED=0 go build -o jean . -# cross-compile, e.g. Linux from any host: +# cross-compilation, ex. Linux depuis n'importe quel hôte : GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jean-linux-amd64 . ``` -> Building **Jean** needs only Go. Building the **llama.cpp backend** (`jean llamacpp install`) needs `git`, `cmake`, and your accelerator's toolkit (CUDA, ROCm, etc.). +> Compiler **Jean** ne nécessite que Go. Compiler le **backend llama.cpp** (`jean llamacpp install`) nécessite `git`, `cmake`, et le toolkit de ton accélérateur (CUDA, ROCm, etc.). -## How it works +## Comment ça marche -- `jean serve` is the systemd `ExecStart`: it reads `config.env`, builds the `llama-server` argument list, and `exec`s into it so systemd supervises llama.cpp directly. -- `jean llamacpp` manages the llama.cpp checkout next to wherever `BIN` points, handling the common "relocated build dir" CMake-cache pitfall and stopping the service during a rebuild to avoid *Text file busy*. +- `jean serve` est l'`ExecStart` de systemd : il lit `config.env`, construit la liste d'arguments de `llama-server`, et fait un `exec` dessus pour que systemd supervise directement llama.cpp. +- `jean llamacpp` gère le checkout llama.cpp à côté de l'endroit où pointe `BIN`, en gérant le piège classique du « dossier de build relocalisé » (cache CMake) et en arrêtant le service pendant la recompilation pour éviter le *Text file busy*. -## License +## Licence -[MIT](LICENSE). The bundled `ui/marked.min.js` is [Marked](https://github.com/markedjs/marked), also MIT. +[MIT](LICENSE). Le fichier `ui/marked.min.js` embarqué est [Marked](https://github.com/markedjs/marked), également MIT. From 63cd01a27e595ac14dec1e13165a55c068dbf12a Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 15:53:51 +0200 Subject: [PATCH 008/279] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 029201a..384eae8 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Prends un binaire pré-compilé depuis la page [Releases](../../releases), ou [c ```bash # exemple : Linux x86_64 -curl -L -o jean https://github.com/jean-llm/jean/releases/latest/download/jean-linux-amd64 +curl -L -o jean https://github.com/nathaninline/jean/releases/latest/download/jean-linux-amd64 chmod +x jean sudo mv jean /usr/local/bin/jean ``` From a40c50c4004e3ef8f436cfbee687e689ec53250b Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 8 Jun 2026 15:54:12 +0200 Subject: [PATCH 009/279] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 384eae8..d5c430b 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans Nécessite Go 1.22+. Jean est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) : ```bash -git clone https://github.com/jean-llm/jean.git +git clone https://github.com/nathaninline/jean.git cd jean CGO_ENABLED=0 go build -o jean . From ca0a315260f0a49e59e89bb35fb4fbc5ec380588 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 9 Jun 2026 09:35:07 +0200 Subject: [PATCH 010/279] Correction Windows --- README.md | 29 ++++++- gpu.go | 158 ++++++++++++++++++++++++++++++++++ install_unix.go | 198 ++++++++++++++++++++++++++++++++++++++++++ install_windows.go | 96 +++++++++++++++++++++ llamacpp.go | 5 -- main.go | 19 +++-- platform_unix.go | 39 +++++++++ platform_windows.go | 55 ++++++++++++ serve.go | 23 +++-- service.go | 65 ++------------ service_unix.go | 72 ++++++++++++++++ service_windows.go | 203 ++++++++++++++++++++++++++++++++++++++++++++ tools.go | 9 +- web.go | 89 ++++++++++++++++++- 14 files changed, 973 insertions(+), 87 deletions(-) create mode 100644 gpu.go create mode 100644 install_unix.go create mode 100644 install_windows.go create mode 100644 platform_unix.go create mode 100644 platform_windows.go create mode 100644 service_unix.go create mode 100644 service_windows.go diff --git a/README.md b/README.md index d5c430b..f857cfe 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Prends un binaire pré-compilé depuis la page [Releases](../../releases), ou [c ```bash # exemple : Linux x86_64 -curl -L -o jean https://github.com/nathaninline/jean/releases/latest/download/jean-linux-amd64 +curl -L -o jean https://github.com/jean-llm/jean/releases/latest/download/jean-linux-amd64 chmod +x jean sudo mv jean /usr/local/bin/jean ``` @@ -69,6 +69,27 @@ jean test # vérifie que le modèle répond jean web # http://:8090 ``` +## Windows + +Jean fonctionne aussi sur Windows. Les différences avec Linux : + +- **Pas de systemd.** `jean start` lance `jean serve` en arrière-plan (processus détaché), suivi via un fichier PID. `stop` / `restart` / `status` / `logs` agissent dessus — aucun droit administrateur requis. `enable` / `disable` (démarrage au boot) ne sont pas gérés : utilise une tâche planifiée ou `sc.exe` si tu en as besoin. +- **`JEAN_HOME`** vaut par défaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). Surcharge avec la variable d'environnement `JEAN_HOME`. +- **`jean install`** crée seulement le dossier de données et un `config.env` de départ (pas de sudoers ni de symlink). Ajoute toi-même le dossier de `jean.exe` au `PATH` pour l'appeler de partout. +- **`jean edit`** ouvre `notepad` par défaut (surchargeable via `%EDITOR%`). +- **`jean tools`** exécute les commandes via `cmd /C` (au lieu de `bash`). + +```powershell +# depuis PowerShell +jean install # crée %ProgramData%\jean\config.env +jean edit # règle BIN=...\llama-server.exe et MODEL=...\modele.gguf +jean start +jean status +jean logs # suit %ProgramData%\jean\jean.log +``` + +`jean llamacpp install` peut compiler llama.cpp si `git` et `cmake` sont présents ; sinon récupère un binaire `llama-server.exe` pré-compilé et pointe `BIN` dessus avec `jean edit`. + ## Commandes ``` @@ -79,6 +100,7 @@ Service : edit éditer $JEAN_HOME/config.env set-api-key [clé] protéger l'API (Bearer) ; 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) bench [N] mesure prefill + decode tok/s @@ -118,7 +140,7 @@ update [--ref=REF_GIT] [--clean] [--no-restart] [--force] ## Configuration -Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean`). Le service lit `config.env` : +Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramData%\jean` sur Windows). Le service lit `config.env` : | Clé | Signification | Défaut | |-----|---------------|--------| @@ -129,6 +151,7 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean`). Le service lit `config.env | `NGL` | couches à déporter sur le GPU | `999` | | `BATCH` / `UBATCH` | batch / micro-batch | `2048` / `512` | | `THREADS` / `THREADS_BATCH` | threads CPU | `0` (auto) | +| `CUDA_VISIBLE_DEVICES` | GPU à utiliser (réglé par `jean gpu`) | tous | | `KV_TYPE` (`_K`/`_V`) | quantization du cache KV | — | | `REASONING` | passthrough du mode raisonnement | — | | `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — | @@ -148,7 +171,7 @@ La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans Nécessite Go 1.22+. Jean est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) : ```bash -git clone https://github.com/nathaninline/jean.git +git clone https://github.com/jean-llm/jean.git cd jean CGO_ENABLED=0 go build -o jean . diff --git a/gpu.go b/gpu.go new file mode 100644 index 0000000..ccf0586 --- /dev/null +++ b/gpu.go @@ -0,0 +1,158 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// gpu.go — sélection du/des GPU utilisés par llama-server. +// +// jean gpu liste les GPU et montre la sélection courante +// jean gpu 1 n'utilise que le GPU d'index 1 +// jean gpu 0 1 utilise les GPU 0 et 1 +// jean gpu all réinitialise (tous les GPU visibles) +// +// La sélection est stockée dans config.env sous CUDA_VISIBLE_DEVICES ; serve.go +// l'exporte (avec CUDA_DEVICE_ORDER=PCI_BUS_ID pour que les index correspondent +// à ceux affichés par nvidia-smi). + +type gpuInfo struct { + Index int + Name string + MemTotal string // en MiB + MemUsed string + Cap string // compute capability +} + +func cmdGPU(args []string) error { + if len(args) == 0 || args[0] == "list" || args[0] == "ls" { + return gpuList() + } + switch args[0] { + case "all", "reset", "none", "auto": + return gpuSet("") + } + // Sinon : une liste d'index (« 1 », « 0 1 », « 0,1 »). + gpus, err := detectGPUs() + if err != nil { + return err + } + raw := strings.Join(args, ",") + var idx []string + for _, tok := range strings.Split(raw, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + n, err := strconv.Atoi(tok) + if err != nil { + return fmt.Errorf("index GPU invalide: %q (attendu un nombre)", tok) + } + if n < 0 || n >= len(gpus) { + return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « jean gpu »", n, len(gpus)-1) + } + idx = append(idx, strconv.Itoa(n)) + } + if len(idx) == 0 { + return fmt.Errorf("aucun index fourni") + } + return gpuSet(strings.Join(idx, ",")) +} + +// gpuList affiche les GPU détectés et marque ceux actuellement sélectionnés. +func gpuList() error { + gpus, err := detectGPUs() + if err != nil { + return err + } + sel := ReadConfig()["CUDA_VISIBLE_DEVICES"] + selected := map[int]bool{} + if sel != "" { + for _, t := range strings.Split(sel, ",") { + if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { + selected[n] = true + } + } + } + + fmt.Println() + for _, g := range gpus { + mark := " " + line := fmt.Sprintf("[%d] %s — %s/%s MiB (cc %s)", g.Index, g.Name, g.MemUsed, g.MemTotal, g.Cap) + active := sel == "" || selected[g.Index] + if sel != "" && selected[g.Index] { + mark = green("● ") + line = green(line) + } else if sel == "" { + mark = dim("○ ") + } else { + mark = dim("○ ") + line = dim(line) + } + _ = active + fmt.Printf(" %s%s\n", mark, line) + } + fmt.Println() + if sel == "" { + fmt.Printf(" Sélection : %s (tous les GPU)\n", bold("auto")) + } else { + fmt.Printf(" Sélection : %s (CUDA_VISIBLE_DEVICES=%s)\n", bold(sel), sel) + } + fmt.Printf(" %s jean gpu pour choisir, jean gpu all pour réinitialiser\n", dim("→")) + return nil +} + +// gpuSet writes (or clears) CUDA_VISIBLE_DEVICES in config.env then offers a +// restart so the change takes effect. +func gpuSet(value string) error { + if err := SetConfigKey("CUDA_VISIBLE_DEVICES", value); err != nil { + return err + } + if value == "" { + fmt.Printf("%s sélection GPU réinitialisée — tous les GPU seront visibles\n", green("[ok]")) + } else { + fmt.Printf("%s GPU sélectionné(s) : %s\n", green("[ok]"), bold(value)) + } + fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] ")) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") { + fmt.Println(dim("[info] pense à lancer 'jean restart'")) + return nil + } + return serviceAction("restart") +} + +// detectGPUs queries nvidia-smi for the list of NVIDIA GPUs. +func detectGPUs() ([]gpuInfo, error) { + if !hasTool("nvidia-smi") { + return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA") + } + out, err := exec.Command("nvidia-smi", + "--query-gpu=index,name,memory.total,memory.used,compute_cap", + "--format=csv,noheader,nounits").Output() + if err != nil { + return nil, fmt.Errorf("nvidia-smi a échoué: %w", err) + } + var gpus []gpuInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.Split(line, ",") + if len(parts) < 5 { + continue + } + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + idx, _ := strconv.Atoi(parts[0]) + gpus = append(gpus, gpuInfo{ + Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4], + }) + } + if len(gpus) == 0 { + return nil, fmt.Errorf("aucun GPU NVIDIA détecté") + } + return gpus, nil +} diff --git a/install_unix.go b/install_unix.go new file mode 100644 index 0000000..089f491 --- /dev/null +++ b/install_unix.go @@ -0,0 +1,198 @@ +//go:build unix + +package main + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" +) + +const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart +# Le service systemd lit ce fichier et lance ton binaire llama.cpp. + +# Chemins +BIN="/usr/local/bin/llama-server" +MODEL="/home/USER/models/your-model.gguf" + +# Serveur +PORT="8080" +HOST="0.0.0.0" + +# Inference +CTX="32768" +BATCH="2048" +UBATCH="512" +NGL="999" + +# Args supplémentaires passés à llama-server +EXTRA_ARGS="" +` + +const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by jean). +%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s +` + +const serviceUnitTemplate = `[Unit] +Description=JEAN llama.cpp server +After=network.target + +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=%s +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +` + +func cmdInstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)") + } + targetUser := os.Getenv("SUDO_USER") + if targetUser == "" { + targetUser = "root" + } + for _, a := range args { + if strings.HasPrefix(a, "--user=") { + targetUser = strings.TrimPrefix(a, "--user=") + } + } + u, err := user.Lookup(targetUser) + if err != nil { + return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) + } + jeanHome := defaultJeanHome() + if v := os.Getenv("JEAN_HOME"); v != "" { + jeanHome = v + } + svc := serviceName() + + fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) + fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" service = %s\n", svc) + + // 1. Create directories + for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + + // 2. Drop a config.env if none exists + conf := filepath.Join(jeanHome, "config.env") + if _, err := os.Stat(conf); os.IsNotExist(err) { + body := strings.ReplaceAll(configTemplate, "USER", targetUser) + if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { + return err + } + fmt.Printf(" %s écrit %s\n", green("✓"), conf) + } + + // 3. Symlink current binary to /usr/local/bin/jean + self, err := os.Executable() + if err != nil { + return err + } + target := "/usr/local/bin/jean" + _ = os.Remove(target) + if err := os.Symlink(self, target); err != nil { + // fall back to copy if symlink fails (e.g. cross-fs) + if data, err := os.ReadFile(self); err == nil { + _ = os.WriteFile(target, data, 0o755) + } + } + fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) + + // 4. Drop /etc/default/jean so root invocations resolve JEAN_HOME correctly. + defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) + if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil { + return err + } + fmt.Printf(" %s /etc/default/jean\n", green("✓")) + + // 5. Write the systemd unit (ExecStart = `jean serve`, no start.sh needed) + unit := fmt.Sprintf(serviceUnitTemplate, targetUser, jeanHome, "/usr/local/bin/jean serve") + unitPath := "/etc/systemd/system/" + svc + ".service" + if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), unitPath) + + // 5. Sudoers drop-in + sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) + sudoersPath := "/etc/sudoers.d/jean-" + svc + if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), sudoersPath) + + // 6. chown JEAN_HOME contents to target user + chown(jeanHome, u) + + // 7. systemd reload + _ = exec.Command("systemctl", "daemon-reload").Run() + + fmt.Println() + fmt.Printf("%s installation terminée.\n", green("[ok]")) + fmt.Printf("\nProchaines étapes :\n") + fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit")) + fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") + fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" jean start")) + fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web")) + return nil +} + +func cmdUninstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("jean uninstall doit être exécuté en root") + } + svc := serviceName() + keepData := false + for _, a := range args { + if a == "--purge" { + keepData = false + } + if a == "--keep-data" { + keepData = true + } + } + _ = exec.Command("systemctl", "stop", svc).Run() + _ = exec.Command("systemctl", "disable", svc).Run() + for _, p := range []string{ + "/etc/systemd/system/" + svc + ".service", + "/etc/sudoers.d/jean-" + svc, + "/etc/default/jean", + "/usr/local/bin/jean", + } { + if err := os.Remove(p); err == nil { + fmt.Printf(" %s %s\n", green("✓"), p) + } + } + _ = exec.Command("systemctl", "daemon-reload").Run() + if !keepData { + fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)")) + } + fmt.Println(green("[ok]") + " désinstallé") + return nil +} + +// chown recursively changes ownership of path to the given user/group. +func chown(path string, u *user.User) { + var uid, gid int + fmt.Sscanf(u.Uid, "%d", &uid) + fmt.Sscanf(u.Gid, "%d", &gid) + filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err == nil { + _ = os.Chown(p, uid, gid) + } + return nil + }) +} diff --git a/install_windows.go b/install_windows.go new file mode 100644 index 0000000..d2db2c9 --- /dev/null +++ b/install_windows.go @@ -0,0 +1,96 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +// On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate. +// `jean install` simply provisions the data directory and a starter config; the +// service itself is managed by the PID-file supervisor in service_windows.go +// (jean start / stop / status), which needs no admin rights. + +const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart +# jean serve lit ce fichier et lance ton binaire llama.cpp. + +# Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent) +BIN="C:/llama.cpp/build/bin/Release/llama-server.exe" +MODEL="C:/models/your-model.gguf" + +# Serveur +PORT="8080" +HOST="127.0.0.1" + +# Inference +CTX="32768" +BATCH="2048" +UBATCH="512" +NGL="999" + +# Args supplémentaires passés à llama-server +EXTRA_ARGS="" +` + +func cmdInstall(args []string) error { + jeanHome := JeanHome() + + fmt.Printf("Installation (Windows)\n") + fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" service = %s\n", serviceName()) + + // 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) { + if err := os.WriteFile(conf, []byte(configTemplate), 0o644); err != nil { + return err + } + fmt.Printf(" %s écrit %s\n", green("✓"), conf) + } else { + fmt.Printf(" %s config.env déjà présent — inchangé\n", dim("•")) + } + + fmt.Println() + fmt.Printf("%s installation terminée.\n", green("[ok]")) + fmt.Printf("\nProchaines étapes :\n") + fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("jean edit")) + fmt.Printf(" 2. démarre le service: %s\n", bold("jean start")) + fmt.Printf(" 3. UI web : %s\n", bold("jean web")) + fmt.Printf("\n%s pour exécuter 'jean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]")) + return nil +} + +func cmdUninstall(args []string) error { + keepData := true + for _, a := range args { + if a == "--purge" { + keepData = false + } + if a == "--keep-data" { + keepData = true + } + } + // Stop the background server if it's running. + _ = svcStop(false) + + if !keepData { + jeanHome := JeanHome() + if err := os.RemoveAll(jeanHome); err != nil { + return fmt.Errorf("suppression de %s: %w", jeanHome, err) + } + fmt.Printf(" %s %s supprimé\n", green("✓"), jeanHome) + } else { + fmt.Println(dim("(données utilisateur conservées — relance avec --purge pour tout supprimer)")) + } + fmt.Println(green("[ok]") + " désinstallé") + return nil +} diff --git a/llamacpp.go b/llamacpp.go index 367d846..0241279 100644 --- a/llamacpp.go +++ b/llamacpp.go @@ -522,11 +522,6 @@ func requireTools(tools ...string) error { return nil } -func serviceIsActive() bool { - out, _ := exec.Command("systemctl", "is-active", serviceName()).Output() - return strings.TrimSpace(string(out)) == "active" -} - // gitOutput runs a git command in `dir` and returns trimmed stdout (or ""). func gitOutput(dir string, args ...string) string { cmd := exec.Command("git", args...) diff --git a/main.go b/main.go index 1a66a42..477fdf5 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,8 @@ func main() { mustExit(cmdSetAPIKey(args)) case "vram": mustExit(showVram()) + case "gpu": + mustExit(cmdGPU(args)) case "switch": mustExit(cmdSwitch(args)) case "chat": @@ -67,12 +69,13 @@ func printHelp() { Usage: jean [args] Service: - start | stop | restart gérer le service systemd + start | stop | restart gérer le service (systemd sous Linux, processus en arrière-plan sous Windows) 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 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) bench [N] mesure prefill + decode tok/s (prompt 2000 tok, N=200 par défaut) @@ -96,12 +99,12 @@ Entrypoint (utilisé par jean.service) : serve lit config.env et exec le binaire llama-server Installation: - install installer (systemd unit, sudoers, dossiers) + install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config) uninstall désinstaller Env: - JEAN_HOME racine (défaut: $HOME/JEAN, ou /home//JEAN si root) - EDITOR éditeur pour 'jean edit' (défaut: nano) + JEAN_HOME racine (défaut: /etc/jean sur Linux/macOS, %%ProgramData%%\jean sur Windows) + EDITOR éditeur pour 'jean edit' (défaut: nano sur Unix, notepad sur Windows) Config: $JEAN_HOME/config.env `, Version) @@ -114,11 +117,9 @@ func mustExit(err error) { } } -// DefaultJeanHome is where everything lives unless overridden. -const DefaultJeanHome = "/etc/jean" - // JeanHome resolves the JEAN data directory. -// Precedence: $JEAN_HOME → /etc/default/jean → DefaultJeanHome. +// Precedence: $JEAN_HOME → /etc/default/jean (unix only) → defaultJeanHome(). +// defaultJeanHome() is platform-specific (see platform_unix.go / platform_windows.go). func JeanHome() string { if h := os.Getenv("JEAN_HOME"); h != "" { return h @@ -126,7 +127,7 @@ func JeanHome() string { if h := readEtcDefault(); h != "" { return h } - return DefaultJeanHome + return defaultJeanHome() } // readEtcDefault parses /etc/default/jean for JEAN_HOME=. Quiet on errors. diff --git a/platform_unix.go b/platform_unix.go new file mode 100644 index 0000000..bfa083f --- /dev/null +++ b/platform_unix.go @@ -0,0 +1,39 @@ +//go:build unix + +package main + +import ( + "context" + "os" + "os/exec" + "syscall" +) + +// defaultJeanHome is the data root when neither $JEAN_HOME nor /etc/default/jean +// override it. +func defaultJeanHome() string { return "/etc/jean" } + +// defaultEditor is used by `jean edit` when $EDITOR is unset. +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. +func setLibraryPath(dir string) { + ld := dir + if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" { + ld = ld + ":" + existing + } + _ = os.Setenv("LD_LIBRARY_PATH", ld) +} + +// execServer replaces the current process with llama-server (so systemd +// supervises llama-server directly, as the old start.sh did with `exec`). +// args[0] must be the binary path. +func execServer(bin string, args []string) error { + return syscall.Exec(bin, args, os.Environ()) +} + +// newShellCmd builds the command used by the run_shell tool. +func newShellCmd(ctx context.Context, command string) *exec.Cmd { + return exec.CommandContext(ctx, "/bin/bash", "-c", command) +} diff --git a/platform_windows.go b/platform_windows.go new file mode 100644 index 0000000..536b3aa --- /dev/null +++ b/platform_windows.go @@ -0,0 +1,55 @@ +//go:build windows + +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" +) + +// defaultJeanHome is the data root when $JEAN_HOME is unset. We use +// %ProgramData%\jean (machine-wide, the closest analogue to /etc/jean), falling +// back to %LOCALAPPDATA%\jean for unprivileged setups. +func defaultJeanHome() string { + if pd := os.Getenv("ProgramData"); pd != "" { + return filepath.Join(pd, "jean") + } + if la := os.Getenv("LOCALAPPDATA"); la != "" { + return filepath.Join(la, "jean") + } + return filepath.Join(os.TempDir(), "jean") +} + +// defaultEditor is used by `jean edit` when $EDITOR is unset. +func defaultEditor() string { return "notepad" } + +// setLibraryPath ensures llama-server can load DLLs bundled next to the binary. +// Windows resolves dependent DLLs via PATH (and the binary's own directory), so +// we prepend dir to PATH. +func setLibraryPath(dir string) { + p := dir + if existing := os.Getenv("PATH"); existing != "" { + p = dir + string(os.PathListSeparator) + existing + } + _ = os.Setenv("PATH", p) +} + +// execServer runs llama-server as a child process and waits for it. Windows has +// no exec() that replaces the current image, so `jean serve` stays alive as the +// parent (this is the detached process the service supervisor tracks). +// args[0] is the binary path; the rest are its arguments. +func execServer(bin string, args []string) error { + cmd := exec.Command(bin, args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + return cmd.Run() +} + +// newShellCmd builds the command used by the run_shell tool. +func newShellCmd(ctx context.Context, command string) *exec.Cmd { + return exec.CommandContext(ctx, "cmd", "/C", command) +} diff --git a/serve.go b/serve.go index ed6e9b9..f9b708c 100644 --- a/serve.go +++ b/serve.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "syscall" ) // cmdServe replaces the historic start.sh: read config.env, build the @@ -21,13 +20,18 @@ func cmdServe(args []string) error { return fmt.Errorf("MODEL non défini dans %s", confPath()) } - // Resolve LD_LIBRARY_PATH the same way start.sh did so that custom llama.cpp - // builds with bundled shared libs continue to load their .so neighbours. - ld := filepath.Dir(bin) - if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" { - ld = ld + ":" + existing + // Make sure llama-server can find its bundled shared libraries (the .so/.dll + // neighbours of the binary). This is platform-specific: LD_LIBRARY_PATH on + // Linux, PATH on Windows — handled inside execServer. + setLibraryPath(filepath.Dir(bin)) + + // Sélection GPU (jean gpu) : on filtre les devices visibles par llama-server. + // CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux + // affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide"). + if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" { + _ = os.Setenv("CUDA_VISIBLE_DEVICES", v) + _ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID") } - _ = os.Setenv("LD_LIBRARY_PATH", ld) get := func(key, fallback string) string { if v, ok := cfg[key]; ok && v != "" { @@ -80,6 +84,7 @@ func cmdServe(args []string) error { fmt.Fprintf(os.Stderr, "[jean serve] %s model=%s port=%s\n", bin, filepath.Base(model), get("PORT", "8080")) - // exec replaces this process — same as start.sh's `exec`. - return syscall.Exec(bin, llmArgs, os.Environ()) + // Hand off to the llama-server process. On Unix this replaces the current + // process (exec); on Windows it runs as a child and waits. See platform_*.go. + return execServer(bin, llmArgs) } diff --git a/service.go b/service.go index 1916aa9..33ca02a 100644 --- a/service.go +++ b/service.go @@ -6,68 +6,19 @@ import ( "os/exec" "strconv" "strings" - "time" ) -// serviceAction wraps `systemctl ` with passwordless sudo where -// it makes sense, and prints a follow-up status check after start/restart. -func serviceAction(action string) error { - svc := serviceName() - needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable" - args := []string{} - bin := "systemctl" - if needsRoot && os.Geteuid() != 0 { - bin = "sudo" - args = append(args, "-n", "systemctl") - } - args = append(args, action, svc) - cmd := exec.Command(bin, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return err - } - switch action { - case "start", "restart": - return checkStarted(svc) - case "stop": - fmt.Println(green("[ok]") + " arrêté") - case "enable": - fmt.Println(green("[ok]") + " démarrage auto activé") - case "disable": - fmt.Println(green("[ok]") + " démarrage auto désactivé") - } - return nil -} - -func checkStarted(svc string) error { - time.Sleep(2 * time.Second) - out, _ := exec.Command("systemctl", "is-active", svc).Output() - state := strings.TrimSpace(string(out)) - if state == "active" || state == "activating" { - fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state) - return nil - } - fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state) - fmt.Println("------------------------------------------------") - logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output() - fmt.Print(string(logs)) - fmt.Println("------------------------------------------------") - fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") - return fmt.Errorf("service %s non démarré", svc) -} - -func serviceLogs() error { - cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} +// service.go holds the platform-neutral pieces of service management. The +// actual start/stop/restart/status/logs implementation is platform-specific: +// - service_unix.go → systemd (systemctl/journalctl) +// - service_windows.go → PID-file background process supervisor +// +// editConfig and showVram live here because they work the same everywhere. func editConfig() error { editor := os.Getenv("EDITOR") if editor == "" { - editor = "nano" + editor = defaultEditor() } cmd := exec.Command(editor, confPath()) cmd.Stdin = os.Stdin @@ -81,6 +32,8 @@ func editConfig() error { } // showVram parses `nvidia-smi --query-gpu=...` and renders a colored bar. +// nvidia-smi is available on both Linux and Windows when an NVIDIA driver is +// installed, so this is platform-neutral. func showVram() error { out, err := exec.Command("nvidia-smi", "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", diff --git a/service_unix.go b/service_unix.go new file mode 100644 index 0000000..2ce6cb5 --- /dev/null +++ b/service_unix.go @@ -0,0 +1,72 @@ +//go:build unix + +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// serviceAction wraps `systemctl ` with passwordless sudo where +// it makes sense, and prints a follow-up status check after start/restart. +func serviceAction(action string) error { + svc := serviceName() + needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable" + args := []string{} + bin := "systemctl" + if needsRoot && os.Geteuid() != 0 { + bin = "sudo" + args = append(args, "-n", "systemctl") + } + args = append(args, action, svc) + cmd := exec.Command(bin, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + switch action { + case "start", "restart": + return checkStarted(svc) + case "stop": + fmt.Println(green("[ok]") + " arrêté") + case "enable": + fmt.Println(green("[ok]") + " démarrage auto activé") + case "disable": + fmt.Println(green("[ok]") + " démarrage auto désactivé") + } + return nil +} + +func checkStarted(svc string) error { + time.Sleep(2 * time.Second) + out, _ := exec.Command("systemctl", "is-active", svc).Output() + state := strings.TrimSpace(string(out)) + if state == "active" || state == "activating" { + fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state) + return nil + } + fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state) + fmt.Println("------------------------------------------------") + logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output() + fmt.Print(string(logs)) + fmt.Println("------------------------------------------------") + fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + return fmt.Errorf("service %s non démarré", svc) +} + +func serviceLogs() error { + cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// serviceIsActive reports whether the systemd unit is currently running. +func serviceIsActive() bool { + out, _ := exec.Command("systemctl", "is-active", serviceName()).Output() + return strings.TrimSpace(string(out)) == "active" +} diff --git a/service_windows.go b/service_windows.go new file mode 100644 index 0000000..0d2b81b --- /dev/null +++ b/service_windows.go @@ -0,0 +1,203 @@ +//go:build windows + +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// On Windows there is no systemd, so "the service" is a background copy of +// `jean serve` that this process launches detached. We track it with a PID file +// and stream its output to a log file under JEAN_HOME. This needs no admin +// rights and no external tools beyond the always-present tasklist/taskkill. + +const ( + createNewProcessGroup = 0x00000200 // CREATE_NEW_PROCESS_GROUP + detachedProcess = 0x00000008 // DETACHED_PROCESS +) + +func pidFilePath() string { return filepath.Join(JeanHome(), serviceName()+".pid") } +func logFilePath() string { return filepath.Join(JeanHome(), serviceName()+".log") } + +func serviceAction(action string) error { + switch action { + case "start": + return svcStart() + case "stop": + return svcStop(true) + case "restart": + _ = svcStop(false) + time.Sleep(500 * time.Millisecond) + return svcStart() + case "status": + return svcStatus() + case "enable", "disable": + fmt.Printf("%s '%s' n'est pas géré sur Windows (pas de service système).\n", yellow("[info]"), action) + fmt.Printf(" Pour un démarrage au boot, crée une tâche planifiée ou un service via %s.\n", bold("sc.exe")) + return nil + default: + return fmt.Errorf("action inconnue: %s", action) + } +} + +func svcStart() error { + if pid := readServicePID(); pid > 0 && processAlive(pid) { + fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid) + return nil + } + self, err := os.Executable() + if err != nil { + return err + } + if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + return err + } + logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("ouverture du log %s: %w", logFilePath(), err) + } + defer logf.Close() + + cmd := exec.Command(self, "serve") + cmd.Stdout = logf + cmd.Stderr = logf + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup | detachedProcess} + if err := cmd.Start(); err != nil { + return fmt.Errorf("démarrage de 'jean serve': %w", err) + } + pid := cmd.Process.Pid + if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil { + return fmt.Errorf("écriture du PID: %w", err) + } + // Don't wait — let it run detached. + _ = cmd.Process.Release() + return checkStarted(pid) +} + +func checkStarted(pid int) error { + time.Sleep(2 * time.Second) + if processAlive(pid) { + fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid) + fmt.Printf(" logs: %s (jean logs pour suivre)\n", dim(logFilePath())) + return nil + } + fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName()) + fmt.Println("------------------------------------------------") + fmt.Print(tailFile(logFilePath(), 20)) + fmt.Println("------------------------------------------------") + fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + _ = os.Remove(pidFilePath()) + return fmt.Errorf("service %s non démarré", serviceName()) +} + +func svcStop(verbose bool) error { + pid := readServicePID() + if pid <= 0 || !processAlive(pid) { + _ = os.Remove(pidFilePath()) + if verbose { + fmt.Println(yellow("[info]") + " aucun service en cours d'exécution") + } + return nil + } + // taskkill /T tue aussi le processus enfant llama-server. + cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("arrêt du PID %d: %w\n%s", pid, err, string(out)) + } + _ = os.Remove(pidFilePath()) + if verbose { + fmt.Println(green("[ok]") + " arrêté") + } + return nil +} + +func svcStatus() error { + pid := readServicePID() + if pid > 0 && processAlive(pid) { + fmt.Printf("%s %s: actif (PID %d)\n", green("[ok]"), serviceName(), pid) + } else { + fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName()) + } + fmt.Printf(" logs : %s\n", logFilePath()) + fmt.Printf(" config : %s\n", confPath()) + return nil +} + +func serviceLogs() error { + path := logFilePath() + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("aucun log à %s (le service a-t-il déjà démarré ?): %w", path, err) + } + defer f.Close() + // Print the tail, then follow appended bytes (poor man's tail -f). + fmt.Print(tailFile(path, 80)) + if _, err := f.Seek(0, io.SeekEnd); err != nil { + return err + } + buf := make([]byte, 4096) + for { + n, err := f.Read(buf) + if n > 0 { + os.Stdout.Write(buf[:n]) + } + if err == io.EOF { + time.Sleep(500 * time.Millisecond) + continue + } + if err != nil { + return err + } + } +} + +// serviceIsActive reports whether the background server is running. +func serviceIsActive() bool { + pid := readServicePID() + return pid > 0 && processAlive(pid) +} + +func readServicePID() int { + b, err := os.ReadFile(pidFilePath()) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) + return pid +} + +// processAlive reports whether a PID is currently running, via tasklist (always +// present on Windows, needs no special privileges). +func processAlive(pid int) bool { + out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH").Output() + if err != nil { + return false + } + s := string(out) + if strings.Contains(s, "No tasks") { + return false + } + return strings.Contains(s, strconv.Itoa(pid)) +} + +// tailFile returns the last n lines of the file at path (best-effort). +func tailFile(path string, n int) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") + "\n" +} diff --git a/tools.go b/tools.go index 967dc1e..9143501 100644 --- a/tools.go +++ b/tools.go @@ -35,9 +35,10 @@ func setToolsEnabled(on bool) error { return nil } -// runShell executes a command via `bash -c` with a clamped timeout, returning -// a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..." truncated -// to keep tool output bounded. +// runShell executes a command via the platform shell (bash -c on Unix, cmd /C +// on Windows — see newShellCmd in platform_*.go) with a clamped timeout, +// returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..." +// truncated to keep tool output bounded. func runShell(command string, timeoutSec int) string { if timeoutSec <= 0 { timeoutSec = toolDefaultTimeout @@ -47,7 +48,7 @@ func runShell(command string, timeoutSec int) string { } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "/bin/bash", "-c", command) + cmd := newShellCmd(ctx, command) var stdout, stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/web.go b/web.go index 06ddf95..9ac48bf 100644 --- a/web.go +++ b/web.go @@ -1,14 +1,18 @@ package main import ( + "bufio" "embed" "encoding/json" "fmt" + "net" "net/http" "os" "os/exec" + "regexp" "strconv" "strings" + "time" ) //go:embed ui/index.html ui/marked.min.js @@ -55,8 +59,91 @@ func cmdWeb(args []string) error { mux.HandleFunc("/api/bench", handleBench) mux.HandleFunc("/api/chat", handleChat) addr := fmt.Sprintf("0.0.0.0:%d", port) + + ln, err := net.Listen("tcp", addr) + if err != nil { + // Port occupé : on identifie le process qui le tient et on propose de + // le terminer pour relancer à sa place. + if !resolvePortConflict(port) { + return err + } + if ln, err = net.Listen("tcp", addr); err != nil { + return err + } + } fmt.Printf("[jean web] http://%s (Ctrl-C pour arrêter)\n", addr) - return http.ListenAndServe(addr, mux) + return http.Serve(ln, mux) +} + +// resolvePortConflict identifies the process listening on `port`, asks the user +// whether to terminate it, and (on yes) kills it and waits for the port to free. +// Returns true if the caller should retry binding. +func resolvePortConflict(port int) bool { + pid, name := pidOnPort(port) + if pid == 0 { + fmt.Printf("%s port %d déjà utilisé, mais le process n'a pas pu être identifié (essaie en root ?)\n", red("[err]"), port) + return false + } + fmt.Printf("%s le port %d est déjà utilisé par %s (PID %d).\n", yellow("[!]"), port, bold(name), pid) + fmt.Print(dim(" terminer ce process et relancer ? [Y/n] ")) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") { + fmt.Println(dim(" annulé.")) + return false + } + // SIGTERM d'abord, puis SIGKILL si le port ne se libère pas. + _ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run() + for i := 0; i < 15; i++ { + time.Sleep(200 * time.Millisecond) + if p, _ := pidOnPort(port); p == 0 { + fmt.Printf("%s process %d terminé, redémarrage…\n", green("[ok]"), pid) + return true + } + } + _ = exec.Command("kill", "-KILL", strconv.Itoa(pid)).Run() + time.Sleep(500 * time.Millisecond) + if p, _ := pidOnPort(port); p != 0 { + fmt.Printf("%s impossible de libérer le port %d (PID %d toujours présent)\n", red("[err]"), port, p) + return false + } + fmt.Printf("%s process %d terminé (forcé), redémarrage…\n", green("[ok]"), pid) + return true +} + +// pidOnPort returns the PID and command name of the process listening on the +// given TCP port, via `ss` (Linux) with an `lsof` fallback. Returns 0 if none +// is found or if the tools can't see it (e.g. owned by another user). +func pidOnPort(port int) (int, string) { + redir := regexp.MustCompile(`pid=(\d+)`) + if out, err := exec.Command("ss", "-ltnHp", fmt.Sprintf("sport = :%d", port)).Output(); err == nil { + if m := redir.FindStringSubmatch(string(out)); m != nil { + pid, _ := strconv.Atoi(m[1]) + return pid, processName(pid) + } + } + if out, err := exec.Command("lsof", "-ti", fmt.Sprintf("tcp:%d", port), "-sTCP:LISTEN").Output(); err == nil { + for _, line := range strings.Fields(string(out)) { + if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil { + return pid, processName(pid) + } + } + } + return 0, "" +} + +// processName returns a short command name for a PID, or "?" if unknown. +func processName(pid int) string { + if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil { + if n := strings.TrimSpace(string(b)); n != "" { + return n + } + } + if out, err := exec.Command("ps", "-o", "comm=", "-p", strconv.Itoa(pid)).Output(); err == nil { + if n := strings.TrimSpace(string(out)); n != "" { + return n + } + } + return "?" } func handleIndex(w http.ResponseWriter, r *http.Request) { From 44637ac47f73ca0616fc514f31abf72109ffb822 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 9 Jun 2026 09:36:10 +0200 Subject: [PATCH 011/279] Add files via upload From 65b2ada54074186dc38f07d8ebfc81c7d77efecd Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 9 Jun 2026 10:06:56 +0200 Subject: [PATCH 012/279] Add files via upload --- README.md | 4 ++-- go.mod | 2 +- main.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f857cfe..69bddd9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Prends un binaire pré-compilé depuis la page [Releases](../../releases), ou [c ```bash # exemple : Linux x86_64 -curl -L -o jean https://github.com/jean-llm/jean/releases/latest/download/jean-linux-amd64 +curl -L -o jean https://github.com/nathaninline/jean/releases/latest/download/jean-linux-amd64 chmod +x jean sudo mv jean /usr/local/bin/jean ``` @@ -171,7 +171,7 @@ La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans Nécessite Go 1.22+. Jean est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) : ```bash -git clone https://github.com/jean-llm/jean.git +git clone https://github.com/nathaninline/jean.git cd jean CGO_ENABLED=0 go build -o jean . diff --git a/go.mod b/go.mod index af69418..2482621 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/jean-llm/jean +module github.com/nathaninline/jean go 1.22 diff --git a/main.go b/main.go index 477fdf5..9bb73f7 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "0.1.0" +const Version = "0.2.0" func main() { args := os.Args[1:] From 6ea2e91810ecc6126e2fc854ed24a019f27d2baf Mon Sep 17 00:00:00 2001 From: nathaninline <291755048+nathaninline@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:21:25 +0200 Subject: [PATCH 013/279] =?UTF-8?q?Sync=20=C3=A9tat=20=C3=A0=20jour=20+=20?= =?UTF-8?q?fix=20perf=20GPU/CPU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .gitignore | 19 +++ README.md | 34 ++++- bench.go | 81 +++++++++++- install.go | 196 ---------------------------- install_unix.go | 7 + llm.go | 60 ++++++++- main.go | 5 +- models.go | 326 +++++++++++++++++++++++++++++++++++++++++++++++ platform_unix.go | 39 +++++- presets.go | 141 ++++++++++++++++---- serve.go | 6 +- ui/index.html | 257 ++++++++++++++++++++++++++++++++----- web.go | 174 ++++++++++++++++++------- webauth.go | 116 +++++++++++++++++ 14 files changed, 1143 insertions(+), 318 deletions(-) create mode 100644 .gitignore delete mode 100644 install.go create mode 100644 models.go create mode 100644 webauth.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d846ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaires compilés +/jean +/jean.exe +/dist/ + +# Artefacts de build Go +*.exe +*.test +*.out + +# Config locale / secrets runtime (générés à l'install, jamais versionnés) +config.env +.api_key + +# OS / éditeurs +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/README.md b/README.md index 69bddd9..3906fde 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ Service : status | logs état / logs en direct enable | disable démarrage au boot edit éditer $JEAN_HOME/config.env - set-api-key [clé] protéger l'API (Bearer) ; vide = générer, "" = retirer + set-api-key [clé] protéger llama-server (Bearer) ; vide = générer, "" = retirer + set-web-key [clé] protéger l'API de pilotage 'jean web' ; vide = générer, "" = retirer vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) test vérifie que le modèle répond (health + completion) @@ -158,6 +159,37 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramDa La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans `$JEAN_HOME/.api_key`, séparément de `config.env`. +### API de pilotage à distance + +`jean web` expose une API HTTP pour piloter Jean à distance : status, VRAM, liste/sélection de presets (switch de modèle), démarrage/arrêt/redémarrage du service, chat. Pour l'exposer sur internet en sécurité, protège-la par une clé : + +``` +jean set-web-key # génère une clé aléatoire +jean web 8090 # sert l'API/UI sur :8090 +``` + +Chaque appel `/api/*` doit alors présenter la clé (la page HTML/JS, elle, reste publique car sans secret) : + +``` +Authorization: Bearer +``` + +Endpoints utiles pour un client : + +| Méthode | Endpoint | Rôle | +|---------|----------|------| +| GET | `/api/ping` | vérifie connectivité + validité de la clé (200 / 401) | +| GET | `/api/status` | état du service (active, health, port) | +| GET | `/api/vram` | usage GPU/VRAM | +| GET | `/api/presets` | liste des presets (avec l'actif) | +| POST | `/api/switch` `{"n":}` | switch de modèle/preset | +| POST | `/api/start` · `/api/stop` · `/api/restart` | piloter le service | +| POST | `/api/chat` `{"messages":[…]}` | chat (flux SSE) | + +La clé est stockée dans `$JEAN_HOME/.web_key`, distincte de `.api_key` (pilotage ≠ accès complétions), et relue à chaud à chaque requête. Le pilotage du service est cross-platform (systemd sous Linux, supervision par PID-file sous Windows). + +> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, place Jean derrière un reverse-proxy HTTPS (Caddy, nginx) ou un tunnel (Tailscale, Cloudflare Tunnel). + ### Variables d'environnement | Variable | Signification | Défaut | diff --git a/bench.go b/bench.go index 604fdcf..da004cd 100644 --- a/bench.go +++ b/bench.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "path/filepath" "strconv" "strings" "time" @@ -124,11 +126,86 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) { } elapsed := time.Since(t0).Seconds() t := parsed.Timings - return &benchResult{ + res := &benchResult{ PromptN: t.PromptN, PromptMs: t.PromptMs, PromptPerSecond: t.PromptPerSecond, PredictedN: t.PredictedN, PredictedMs: t.PredictedMs, PredictedPerSec: t.PredictedPerSec, Elapsed: elapsed, - }, nil + } + saveLastBench(res) + saveBenchForActivePreset(res) + return res, nil +} + +// lastBenchPath stores the most recent benchmark result so the web UI can show +// it without re-running. Lives in JEAN_HOME so it survives restarts. +func lastBenchPath() string { return filepath.Join(JeanHome(), ".last_bench.json") } + +// savedBench is a benchResult plus the model it was run against and a timestamp. +type savedBench struct { + Result benchResult `json:"result"` + Model string `json:"model"` + At int64 `json:"at"` +} + +// saveLastBench persists res to JEAN_HOME/.last_bench.json (best-effort). +func saveLastBench(res *benchResult) { + sb := savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} + if b, err := json.Marshal(sb); err == nil { + _ = os.WriteFile(lastBenchPath(), b, 0o644) + } +} + +// loadLastBench reads the persisted benchmark, or nil if none/unreadable. +func loadLastBench() *savedBench { + b, err := os.ReadFile(lastBenchPath()) + if err != nil { + return nil + } + var sb savedBench + if json.Unmarshal(b, &sb) != nil { + return nil + } + return &sb +} + +// benchStorePath holds per-preset benchmark results so the UI can show each +// preset's measured performance. Keyed by preset name. +func benchStorePath() string { return filepath.Join(JeanHome(), ".bench_presets.json") } + +// loadBenchStore returns the per-preset bench map (empty if none/unreadable). +func loadBenchStore() map[string]savedBench { + m := map[string]savedBench{} + b, err := os.ReadFile(benchStorePath()) + if err != nil { + return m + } + _ = json.Unmarshal(b, &m) + return m +} + +// saveBenchForActivePreset records res under the name of the currently active +// preset (the one whose config.env matches the live config). No-op if no preset +// matches — the bench still lives in .last_bench.json via saveLastBench. +func saveBenchForActivePreset(res *benchResult) { + list, err := ListPresets() + if err != nil { + return + } + id := "" + for _, p := range list { + if p.Active { + id = p.ID + break + } + } + if id == "" { + return + } + m := loadBenchStore() + m[id] = savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} + if b, err := json.Marshal(m); err == nil { + _ = os.WriteFile(benchStorePath(), b, 0o644) + } } func cmdBench(args []string) error { diff --git a/install.go b/install.go deleted file mode 100644 index def8844..0000000 --- a/install.go +++ /dev/null @@ -1,196 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "os/user" - "path/filepath" - "strings" -) - -const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart -# Le service systemd lit ce fichier et lance ton binaire llama.cpp. - -# Chemins -BIN="/usr/local/bin/llama-server" -MODEL="/home/USER/models/your-model.gguf" - -# Serveur -PORT="8080" -HOST="0.0.0.0" - -# Inference -CTX="32768" -BATCH="2048" -UBATCH="512" -NGL="999" - -# Args supplémentaires passés à llama-server -EXTRA_ARGS="" -` - -const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by jean). -%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s -` - -const serviceUnitTemplate = `[Unit] -Description=JEAN llama.cpp server -After=network.target - -[Service] -Type=simple -User=%s -WorkingDirectory=%s -ExecStart=%s -Restart=on-failure -RestartSec=3 - -[Install] -WantedBy=multi-user.target -` - -func cmdInstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)") - } - targetUser := os.Getenv("SUDO_USER") - if targetUser == "" { - targetUser = "root" - } - for _, a := range args { - if strings.HasPrefix(a, "--user=") { - targetUser = strings.TrimPrefix(a, "--user=") - } - } - u, err := user.Lookup(targetUser) - if err != nil { - return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) - } - jeanHome := DefaultJeanHome - if v := os.Getenv("JEAN_HOME"); v != "" { - jeanHome = v - } - svc := serviceName() - - fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" JEAN_HOME = %s\n", jeanHome) - fmt.Printf(" service = %s\n", svc) - - // 1. Create directories - for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { - if err := os.MkdirAll(d, 0o755); err != nil { - return err - } - } - - // 2. Drop a config.env if none exists - conf := filepath.Join(jeanHome, "config.env") - if _, err := os.Stat(conf); os.IsNotExist(err) { - body := strings.ReplaceAll(configTemplate, "USER", targetUser) - if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { - return err - } - fmt.Printf(" %s écrit %s\n", green("✓"), conf) - } - - // 3. Symlink current binary to /usr/local/bin/jean - self, err := os.Executable() - if err != nil { - return err - } - target := "/usr/local/bin/jean" - _ = os.Remove(target) - if err := os.Symlink(self, target); err != nil { - // fall back to copy if symlink fails (e.g. cross-fs) - if data, err := os.ReadFile(self); err == nil { - _ = os.WriteFile(target, data, 0o755) - } - } - fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) - - // 4. Drop /etc/default/jean so root invocations resolve JEAN_HOME correctly. - defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) - if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil { - return err - } - fmt.Printf(" %s /etc/default/jean\n", green("✓")) - - // 5. Write the systemd unit (ExecStart = `jean serve`, no start.sh needed) - unit := fmt.Sprintf(serviceUnitTemplate, targetUser, jeanHome, "/usr/local/bin/jean serve") - unitPath := "/etc/systemd/system/" + svc + ".service" - if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { - return err - } - fmt.Printf(" %s %s\n", green("✓"), unitPath) - - // 5. Sudoers drop-in - sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) - sudoersPath := "/etc/sudoers.d/jean-" + svc - if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { - return err - } - fmt.Printf(" %s %s\n", green("✓"), sudoersPath) - - // 6. chown JEAN_HOME contents to target user - chown(jeanHome, u) - - // 7. systemd reload - _ = exec.Command("systemctl", "daemon-reload").Run() - - fmt.Println() - fmt.Printf("%s installation terminée.\n", green("[ok]")) - fmt.Printf("\nProchaines étapes :\n") - fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit")) - fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") - fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" jean start")) - fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web")) - return nil -} - -func cmdUninstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("jean uninstall doit être exécuté en root") - } - svc := serviceName() - keepData := false - for _, a := range args { - if a == "--purge" { - keepData = false - } - if a == "--keep-data" { - keepData = true - } - } - _ = exec.Command("systemctl", "stop", svc).Run() - _ = exec.Command("systemctl", "disable", svc).Run() - for _, p := range []string{ - "/etc/systemd/system/" + svc + ".service", - "/etc/sudoers.d/jean-" + svc, - "/etc/default/jean", - "/usr/local/bin/jean", - } { - if err := os.Remove(p); err == nil { - fmt.Printf(" %s %s\n", green("✓"), p) - } - } - _ = exec.Command("systemctl", "daemon-reload").Run() - if !keepData { - fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)")) - } - fmt.Println(green("[ok]") + " désinstallé") - return nil -} - -// chown recursively changes ownership of path to the given user/group. -func chown(path string, u *user.User) { - var uid, gid int - fmt.Sscanf(u.Uid, "%d", &uid) - fmt.Sscanf(u.Gid, "%d", &gid) - filepath.Walk(path, func(p string, info os.FileInfo, err error) error { - if err == nil { - _ = os.Chown(p, uid, gid) - } - return nil - }) -} diff --git a/install_unix.go b/install_unix.go index 089f491..2e0cd85 100644 --- a/install_unix.go +++ b/install_unix.go @@ -48,6 +48,13 @@ ExecStart=%s Restart=on-failure RestartSec=3 +# Priorité CPU : on remonte le process pour qu'il ne soit pas dépriorisé face +# aux tâches de fond (sampling/orchestration côté CPU pèsent sur le débit même +# en inference GPU). Nice négatif + scheduling normal réactif. +Nice=-10 +CPUSchedulingPolicy=other +CPUAccounting=yes + [Install] WantedBy=multi-user.target ` diff --git a/llm.go b/llm.go index 2f9b193..11cf93f 100644 --- a/llm.go +++ b/llm.go @@ -157,8 +157,17 @@ type streamChunk struct { // runChat drives the full inference loop including tool calling. // On finish_reason="tool_calls" we execute locally, append a "tool" message // and call /v1/chat/completions again — up to 8 iterations as a safety cap. +const thinkClose = "" + func runChat(messages []Message, temperature float64, cb ChatCallback) error { tools := EnabledTools() + // Some backends (vanilla llama.cpp builds) don't populate `reasoning_content` + // in streaming mode: the model's block (opened by the chat template) + // arrives inline in `content`, terminated by a literal . When + // reasoning is enabled we split that out ourselves so the UI's reasoning + // bubble works regardless of backend. The ik_llama.cpp fork already sends + // reasoning_content, in which case we leave content untouched. + reasoningOn := reasoningActive(ReadConfig()["REASONING"]) for iter := 0; iter < 8; iter++ { payload := map[string]any{ "model": "jean", @@ -185,6 +194,10 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { toolCalls := map[int]*ToolCall{} assistantContent := strings.Builder{} finishReason := "" + // Per-completion reasoning-split state (see reasoningOn comment above). + sawReasoningField := false + thinkOpen := reasoningOn + var thinkTail strings.Builder // scanner with a big buffer — some chunks include large arguments JSON sc := bufio.NewScanner(resp.Body) sc.Buffer(make([]byte, 0, 64*1024), 1<<20) @@ -236,6 +249,9 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { continue } if ch.Delta.ReasoningContent != "" { + // Backend already separates reasoning — trust it, disable our split. + sawReasoningField = true + thinkOpen = false if !cb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) { aborted = true break @@ -243,12 +259,50 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { } if ch.Delta.Content != "" { assistantContent.WriteString(ch.Delta.Content) - if !cb(StreamEvent{Content: ch.Delta.Content}) { - aborted = true - break + if !thinkOpen || sawReasoningField { + if !cb(StreamEvent{Content: ch.Delta.Content}) { + aborted = true + break + } + } else { + // Inside the prompt-opened block: stream to the + // reasoning bubble until the closing , then switch + // the remainder to normal content. + thinkTail.WriteString(ch.Delta.Content) + s := thinkTail.String() + if i := strings.Index(s, thinkClose); i >= 0 { + reason := s[:i] + after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n") + thinkOpen = false + thinkTail.Reset() + if reason != "" && !cb(StreamEvent{Reasoning: reason}) { + aborted = true + break + } + if after != "" && !cb(StreamEvent{Content: after}) { + aborted = true + break + } + } else { + // Hold back a tail that could be a partial "". + keep := len(thinkClose) - 1 + if len(s) > keep { + emit := s[:len(s)-keep] + thinkTail.Reset() + thinkTail.WriteString(s[len(s)-keep:]) + if !cb(StreamEvent{Reasoning: emit}) { + aborted = true + break + } + } + } } } } + // Flush any buffered reasoning if the stream ended mid-think. + if !aborted && thinkOpen && thinkTail.Len() > 0 { + cb(StreamEvent{Reasoning: thinkTail.String()}) + } resp.Body.Close() if aborted { return nil diff --git a/main.go b/main.go index 9bb73f7..9eb8c30 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,8 @@ func main() { mustExit(editConfig()) case "set-api-key": mustExit(cmdSetAPIKey(args)) + case "set-web-key": + mustExit(cmdSetWebKey(args)) case "vram": mustExit(showVram()) case "gpu": @@ -73,7 +75,8 @@ Service: status | logs état / logs en direct enable | disable auto-démarrage au boot edit éditer $JEAN_HOME/config.env - set-api-key [clé] protéger l'API (clé Bearer); vide = générer, "" = retirer + set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer + set-web-key [clé] protéger l'API de pilotage 'jean web'; vide = générer, "" = retirer vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) test vérifie que l'IA répond (health + completion) diff --git a/models.go b/models.go new file mode 100644 index 0000000..e1c3e0b --- /dev/null +++ b/models.go @@ -0,0 +1,326 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +// quantSegRe matches a single name segment that looks like a GGUF quantization +// token: Q8_0, Q6_K, Q5_K_M, Q4_K_XL, IQ4_XS, IQ3_XXS, Q4, 4bpw, BF16, F16… +var quantSegRe = regexp.MustCompile(`(?i)^(I?Q\d+(_[A-Za-z0-9]+)*|\d+BPW|BF16|FP16|F16|FP32|F32)$`) + +// quantFromName extracts a quantization tag from a model filename by splitting +// on '-' and '.' and keeping the longest segment that looks like a quant token. +// Returns "" when nothing matches. +func quantFromName(name string) string { + base := name + if dot := strings.LastIndexByte(base, '.'); dot >= 0 && strings.EqualFold(base[dot:], ".gguf") { + base = base[:dot] + } + segs := strings.FieldsFunc(base, func(r rune) bool { return r == '-' || r == '.' }) + best := "" + for _, seg := range segs { + if quantSegRe.MatchString(seg) && len(seg) > len(best) { + best = seg + } + } + return strings.ToUpper(best) +} + +// presetReasoning returns the raw REASONING= value from a preset's config.env +// body, or "" if absent. +func presetReasoning(content string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + i := strings.IndexByte(s, '=') + if i < 0 { + continue + } + if strings.EqualFold(strings.TrimSpace(s[:i]), "REASONING") { + return strings.Trim(strings.TrimSpace(s[i+1:]), `"`) + } + } + return "" +} + +// reasoningActive reports whether a REASONING= value enables reasoning. serve.go +// passes the flag whenever the value is non-empty, but an explicit off/none is +// treated here as disabled so the UI badge isn't misleading. +func reasoningActive(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "", "off", "none", "false", "0", "no", "disable", "disabled": + return false + } + return true +} + +// detectQuant returns the quantization tag for a preset: an explicit QUANT= line +// (manual override, with or without a leading '#') wins; otherwise it is +// auto-detected from the MODEL= filename. Returns "" when unknown. +func detectQuant(content string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#")) + i := strings.IndexByte(s, '=') + if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "QUANT") { + if v := strings.Trim(strings.TrimSpace(s[i+1:]), `"`); v != "" { + return strings.ToUpper(v) + } + } + } + return quantFromName(modelFromPresetContent(content)) +} + +// modelFilePath resolves a model file name to a path inside JEAN_HOME, refusing +// anything that would escape it (path traversal). config.env conventionally +// stores only the basename, so we deliberately strip any directory component. +func modelFilePath(name string) (string, error) { + base := filepath.Base(strings.TrimSpace(name)) + if base == "" || base == "." || base == string(filepath.Separator) { + return "", fmt.Errorf("nom de modèle invalide") + } + if !strings.HasSuffix(strings.ToLower(base), ".gguf") { + return "", fmt.Errorf("seuls les fichiers .gguf peuvent être supprimés") + } + return filepath.Join(JeanHome(), base), nil +} + +// modelFromPresetContent extracts the MODEL= value (basename) from a preset's +// config.env body, or "" if absent. +func modelFromPresetContent(content string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + i := strings.IndexByte(s, '=') + if i < 0 { + continue + } + if strings.TrimSpace(s[:i]) == "MODEL" { + v := strings.Trim(strings.TrimSpace(s[i+1:]), "\"") + return filepath.Base(v) + } + } + return "" +} + +// deleteModelFile removes a .gguf file from JEAN_HOME after validating the name. +func deleteModelFile(name string) error { + p, err := modelFilePath(name) + if err != nil { + return err + } + if err := os.Remove(p); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("modèle introuvable: %s", filepath.Base(p)) + } + return err + } + return nil +} + +// handleModelDelete deletes a single .gguf from JEAN_HOME. +func handleModelDelete(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := deleteModelFile(req.Name); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// ---- Hugging Face downloads ------------------------------------------------- + +// dlState tracks a single in-flight (or finished) model download. +type dlState struct { + Filename string `json:"filename"` + URL string `json:"url"` + Total int64 `json:"total"` + Done int64 `json:"done"` + Finished bool `json:"finished"` + Err string `json:"error"` + StartedAt int64 `json:"started_at"` +} + +var ( + dlMu sync.Mutex + dlDownloads = map[string]*dlState{} // keyed by filename +) + +// normalizeHFURL turns a Hugging Face "blob" page URL into a direct "resolve" +// download URL, and leaves already-direct URLs untouched. Returns the URL to +// fetch and the target filename. +func normalizeHFURL(raw string) (string, string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("lien vide") + } + u, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("lien invalide: %v", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", "", fmt.Errorf("lien invalide (http/https attendu)") + } + // huggingface.co//blob// → /resolve// + if strings.Contains(u.Host, "huggingface.co") { + u.Path = strings.Replace(u.Path, "/blob/", "/resolve/", 1) + } + name := path.Base(u.Path) + if name == "" || name == "/" || name == "." { + return "", "", fmt.Errorf("impossible de déduire le nom du fichier depuis le lien") + } + if !strings.HasSuffix(strings.ToLower(name), ".gguf") { + return "", "", fmt.Errorf("le lien doit pointer vers un fichier .gguf") + } + return u.String(), name, nil +} + +// handleModelDownload kicks off a background download of a .gguf from a URL +// (typically Hugging Face) into JEAN_HOME. Progress is polled via +// /api/models/download/status. +func handleModelDownload(w http.ResponseWriter, r *http.Request) { + var req struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + dlURL, name, err := normalizeHFURL(req.URL) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + dest, err := modelFilePath(name) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + + dlMu.Lock() + if st, ok := dlDownloads[name]; ok && !st.Finished { + dlMu.Unlock() + sendJSON(w, 409, map[string]any{"ok": false, "error": "téléchargement déjà en cours pour " + name}) + return + } + if _, err := os.Stat(dest); err == nil { + dlMu.Unlock() + sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name}) + return + } + st := &dlState{Filename: name, URL: dlURL, StartedAt: time.Now().Unix()} + dlDownloads[name] = st + dlMu.Unlock() + + go runDownload(st, dlURL, dest) + sendJSON(w, 200, map[string]any{"ok": true, "filename": name}) +} + +// runDownload streams the URL to a .part file then renames it on success. +func runDownload(st *dlState, dlURL, dest string) { + finish := func(e error) { + dlMu.Lock() + if e != nil { + st.Err = e.Error() + } + st.Finished = true + dlMu.Unlock() + } + + req, err := http.NewRequest("GET", dlURL, nil) + if err != nil { + finish(err) + return + } + // HF gated/private repos may need a token; reuse the same key store if set. + if k := os.Getenv("HF_TOKEN"); k != "" { + req.Header.Set("Authorization", "Bearer "+k) + } + client := &http.Client{Timeout: 0} // large files: no overall timeout + resp, err := client.Do(req) + if err != nil { + finish(err) + return + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + finish(fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)) + return + } + dlMu.Lock() + st.Total = resp.ContentLength + dlMu.Unlock() + + tmp := dest + ".part" + f, err := os.Create(tmp) + if err != nil { + finish(err) + return + } + buf := make([]byte, 1<<20) // 1 MiB + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := f.Write(buf[:n]); werr != nil { + f.Close() + _ = os.Remove(tmp) + finish(werr) + return + } + dlMu.Lock() + st.Done += int64(n) + dlMu.Unlock() + } + if rerr == io.EOF { + break + } + if rerr != nil { + f.Close() + _ = os.Remove(tmp) + finish(rerr) + return + } + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + finish(err) + return + } + if err := os.Rename(tmp, dest); err != nil { + _ = os.Remove(tmp) + finish(err) + return + } + finish(nil) +} + +// handleModelDownloadStatus returns the state of all known downloads this run. +func handleModelDownloadStatus(w http.ResponseWriter, r *http.Request) { + dlMu.Lock() + out := make([]dlState, 0, len(dlDownloads)) + for _, st := range dlDownloads { + out = append(out, *st) + } + dlMu.Unlock() + sendJSON(w, 200, out) +} diff --git a/platform_unix.go b/platform_unix.go index bfa083f..cb77175 100644 --- a/platform_unix.go +++ b/platform_unix.go @@ -6,6 +6,9 @@ import ( "context" "os" "os/exec" + "path/filepath" + "sort" + "strings" "syscall" ) @@ -17,13 +20,41 @@ func defaultJeanHome() string { return "/etc/jean" } func defaultEditor() string { return "nano" } // setLibraryPath ensures llama-server can load shared libs bundled next to the -// binary by prepending dir to LD_LIBRARY_PATH. +// binary by prepending dir to LD_LIBRARY_PATH. It also appends the CUDA runtime +// lib directories: a CUDA-enabled build links against libcudart/libcublas, which +// live under /usr/local/cuda*/lib64 and are often absent from the global ld +// cache — without them llama-server fails to load the GPU backend (or runs +// degraded), costing a large chunk of throughput. func setLibraryPath(dir string) { - ld := dir + parts := []string{dir} + parts = append(parts, cudaLibDirs()...) if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" { - ld = ld + ":" + existing + parts = append(parts, existing) } - _ = os.Setenv("LD_LIBRARY_PATH", ld) + _ = os.Setenv("LD_LIBRARY_PATH", strings.Join(parts, ":")) +} + +// cudaLibDirs returns the CUDA runtime lib directories present on the machine, +// preferring the highest-versioned install. Empty when no CUDA toolkit is found. +func cudaLibDirs() []string { + var dirs []string + seen := map[string]bool{} + add := func(d string) { + if d != "" && !seen[d] && isDir(d) { + seen[d] = true + dirs = append(dirs, d) + } + } + // Default symlink first (usually points at the active toolkit). + add("/usr/local/cuda/lib64") + add("/usr/local/cuda/targets/x86_64-linux/lib") + // Versioned installs, newest last so it takes precedence in PATH order. + versioned, _ := filepath.Glob("/usr/local/cuda-*/lib64") + sort.Strings(versioned) + for i := len(versioned) - 1; i >= 0; i-- { + add(versioned[i]) + } + return dirs } // execServer replaces the current process with llama-server (so systemd diff --git a/presets.go b/presets.go index 1e57588..38eae6a 100644 --- a/presets.go +++ b/presets.go @@ -11,17 +11,48 @@ import ( "sort" "strconv" "strings" - "time" ) +// A preset's IDENTITY is its filename (ID, without the .env suffix), which is +// always unique. Its DISPLAY name lives in an optional `# NAME=` line inside the +// file, so several presets can share the same display name without overwriting +// each other (their filenames differ — see uniquePresetID). type Preset struct { - Name string + ID string // filename without .env — stable, unique identity + Name string // display name (# NAME= line, falls back to ID) Path string Active bool } -// ListPresets returns all configs/*.env files sorted by name, marking the one -// whose contents match the current config.env (by SHA-1). +var nameLineRe = regexp.MustCompile(`(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$`) + +// presetDisplayName extracts the `# NAME=` value from a preset body, falling +// back to `fallback` (the filename id) when absent — keeps old presets working. +func presetDisplayName(content, fallback string) string { + for _, line := range strings.Split(content, "\n") { + s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#")) + i := strings.IndexByte(s, '=') + if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "NAME") { + if v := strings.Trim(strings.TrimSpace(s[i+1:]), `"`); v != "" { + return v + } + } + } + return fallback +} + +// withDisplayName ensures the body carries a `# NAME=` line (replacing an +// existing one, or prepended otherwise). +func withDisplayName(content, name string) string { + line := "# NAME=" + name + if nameLineRe.MatchString(content) { + return nameLineRe.ReplaceAllString(content, line) + } + return line + "\n" + content +} + +// ListPresets returns all configs/*.env, marking the one whose contents match +// the current config.env (by SHA-1), sorted by display name. func ListPresets() ([]Preset, error) { dir := presetsDir() _ = os.MkdirAll(dir, 0o755) @@ -45,8 +76,10 @@ func ListPresets() ([]Preset, error) { continue } h := sha1.Sum(b) + id := strings.TrimSuffix(e.Name(), ".env") out = append(out, Preset{ - Name: strings.TrimSuffix(e.Name(), ".env"), + ID: id, + Name: presetDisplayName(string(b), id), Path: p, Active: hex.EncodeToString(h[:]) == cur, }) @@ -55,12 +88,53 @@ func ListPresets() ([]Preset, error) { return out, nil } -var presetNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) +// uniquePresetID derives a unique filename id from a display name, appending +// " (2)", " (3)"… on collision so duplicate names never overwrite. +func uniquePresetID(name string) (string, error) { + base := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(name)) + if base == "" { + base = "preset" + } + cand := base + for n := 2; n < 10000; n++ { + p, err := safePresetPath(cand) + if err != nil { + return "", err + } + if _, err := os.Stat(p); os.IsNotExist(err) { + return cand, nil + } + cand = fmt.Sprintf("%s (%d)", base, n) + } + return "", fmt.Errorf("impossible de générer un nom de fichier unique") +} + +// validPresetName accepts any name (spaces, accents, parentheses…) as long as it +// stays a single safe filename: no path separators, no control chars, and not a +// reserved directory entry. Path containment is double-checked in safePresetPath. +func validPresetName(name string) error { + if name == "" { + return fmt.Errorf("nom vide") + } + if name == "." || name == ".." { + return fmt.Errorf("nom réservé") + } + if strings.ContainsAny(name, `/\`+"\x00") { + return fmt.Errorf(`le nom ne peut pas contenir / ni \`) + } + for _, r := range name { + if r < 0x20 { + return fmt.Errorf("le nom contient un caractère de contrôle invalide") + } + } + return nil +} // safePresetPath validates name and returns its resolved path inside presetsDir. func safePresetPath(name string) (string, error) { - if !presetNameRe.MatchString(name) { - return "", fmt.Errorf("nom invalide (alphanum, ._-)") + name = strings.TrimSpace(name) + if err := validPresetName(name); err != nil { + return "", err } root, err := filepath.Abs(presetsDir()) if err != nil { @@ -84,14 +158,10 @@ func SwitchToPreset(target string) error { if err != nil { return err } - ts := time.Now().Format("20060102-150405") - if cur, err := os.ReadFile(confPath()); err == nil { - _ = os.WriteFile(confPath()+".bak.switch-"+ts, cur, 0o644) - } if err := os.WriteFile(confPath(), src, 0o644); err != nil { return err } - fmt.Printf("%s config.env <- %s (backup .bak.switch-%s)\n", green("[ok]"), filepath.Base(target), ts) + fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target)) fmt.Println(dim("[info] redémarrage du service...")) return serviceAction("restart") } @@ -134,24 +204,39 @@ func cmdSwitch(args []string) error { return SwitchToPreset(list[n-1].Path) } -// SavePreset creates or overwrites a preset, handling rename when old != "". -func SavePreset(name, old, content string) error { - if old != "" && old != name { - if of, err := safePresetPath(old); err == nil { - _ = os.Remove(of) - } +// SavePreset writes a preset. When id == "" it creates a NEW preset under a +// freshly-generated unique filename (so duplicate display names never clash). +// When id != "" it updates that existing preset in place (filename unchanged — +// only the body and its `# NAME=` line change). Returns the resulting id. +func SavePreset(id, name, content string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("nom requis") } - p, err := safePresetPath(name) + content = withDisplayName(content, name) + if id == "" { + newID, err := uniquePresetID(name) + if err != nil { + return "", err + } + p, err := safePresetPath(newID) + if err != nil { + return "", err + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + return newID, os.WriteFile(p, []byte(content), 0o644) + } + p, err := safePresetPath(id) if err != nil { - return err + return "", err } _ = os.MkdirAll(filepath.Dir(p), 0o755) - return os.WriteFile(p, []byte(content), 0o644) + return id, os.WriteFile(p, []byte(content), 0o644) } -// DeletePreset removes a preset; refuses if it is the active config. -func DeletePreset(name string) error { - p, err := safePresetPath(name) +// DeletePreset removes a preset by id; refuses if it is the active config. +func DeletePreset(id string) error { + p, err := safePresetPath(id) if err != nil { return err } @@ -166,9 +251,9 @@ func DeletePreset(name string) error { return os.Remove(p) } -// ReadPreset returns the contents of a preset by name. -func ReadPreset(name string) (string, error) { - p, err := safePresetPath(name) +// ReadPreset returns the contents of a preset by id (filename). +func ReadPreset(id string) (string, error) { + p, err := safePresetPath(id) if err != nil { return "", err } diff --git a/serve.go b/serve.go index f9b708c..43b0237 100644 --- a/serve.go +++ b/serve.go @@ -61,7 +61,11 @@ func cmdServe(args []string) error { llmArgs = append(llmArgs, "-ctv", vtv) } if r := cfg["REASONING"]; r != "" { - llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", "0") + // budget -1 = thinking illimité (défaut). NE PAS forcer 0 : sur llama.cpp + // vanilla, 0 = "immediate end" → coupe le raisonnement net (le fork + // ik_llama.cpp, lui, l'ignore, d'où l'ancien comportement trompeur). + // Configurable via REASONING_BUDGET (ex: 2048 pour plafonner). + llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1")) } // API_KEY protège le serveur quand il est exposé sur internet : llama-server // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis diff --git a/ui/index.html b/ui/index.html index 8165fd9..b1530e6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -6,7 +6,8 @@ :root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#e6edf3;--dim:#7d8590;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--err:#f85149;--mag:#bc8cff} *{box-sizing:border-box} body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);color:var(--text);height:100vh;height:100dvh;display:flex;font-size:14px} -.side{width:280px;border-right:1px solid var(--border);padding:16px;overflow-y:auto;flex-shrink:0} +.side{width:320px;border-right:1px solid var(--border);padding:16px;overflow-y:auto;flex-shrink:0;scrollbar-width:none;-ms-overflow-style:none} +.side::-webkit-scrollbar{display:none} .main{flex:1;display:flex;flex-direction:column;min-width:0} #menubtn{display:none;position:fixed;top:10px;left:10px;z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer} #backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10} @@ -16,12 +17,19 @@ body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background #menubtn,#backdrop.open{display:block} body.drawer-open #menubtn{display:none} .main{width:100%} - #chat{padding:60px 12px 12px} + #chat{padding:60px 8px 12px} .msg{max-width:92%} + #chat .msg.assistant,#chat .msg.reasoning{max-width:100%} #inputbar{padding:8px;gap:4px} #send,#stop{padding:0 12px} h1{margin-left:48px} } +/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ +@media (min-width:721px){ + .side{width:380px} + .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:9px} + .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px} +} h1{margin:0 0 4px;font-size:18px;color:var(--accent)} h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600} details{margin:8px 0;border-top:1px solid var(--border);padding-top:6px} @@ -41,9 +49,19 @@ button{background:var(--panel);color:var(--text);border:1px solid var(--border); button:hover{border-color:var(--accent);color:var(--accent)} button:disabled{opacity:.5;cursor:not-allowed} .row{display:flex;gap:4px;flex-wrap:wrap} -.preset{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border:1px solid var(--border);border-radius:6px;margin:4px 0;cursor:pointer;background:var(--panel)} +.preset{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;margin:4px 0;cursor:pointer;background:var(--panel)} .preset:hover{border-color:var(--accent)} .preset.active{border-color:var(--ok);color:var(--ok)} +.preset>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.preset>button{flex-shrink:0} +.preset-info{flex:1;min-width:0} +.preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word} +.preset-edit{flex-shrink:0;align-self:flex-start;margin:0;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.45} +.preset-edit:hover{opacity:1;color:var(--accent)} +.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap} +.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em;text-transform:uppercase} +.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em} +.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em} .kv{display:flex;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px} .kv span:first-child{color:var(--dim);flex-shrink:0} .kv span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:right;min-width:0} @@ -63,7 +81,11 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg .body li{margin:.15em 0} .msg .body li>p{margin:0} .msg.assistant .body code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.9em} -.msg.assistant .body pre{background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;margin:.5em 0} +.msg.assistant .body pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;margin:.5em 0} +.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:5px;opacity:.5;transition:opacity .15s,color .15s} +.msg.assistant .body pre:hover .copybtn{opacity:1} +.copybtn:hover{border-color:var(--accent);color:var(--accent)} +.copybtn.done{color:var(--ok);border-color:var(--ok);opacity:1} .msg.assistant .body pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4} .msg.assistant .body blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)} .msg.assistant .body a{color:var(--accent);text-decoration:none} @@ -83,6 +105,12 @@ button:disabled{opacity:.5;cursor:not-allowed} #toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:6px;opacity:0;transition:opacity .2s;pointer-events:none} #toast.show{opacity:1} @keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}} +/* iOS Safari auto-zoome au focus sur tout champ < 16px. On force 16px sur les + appareils tactiles uniquement (le desktop garde son rendu, le pinch-zoom + manuel reste possible). */ +@media (hover:none) and (pointer:coarse){ + #input,#sysprompt,#modal input,#modal textarea,#modal select{font-size:16px} +}
    @@ -128,10 +156,11 @@ button:disabled{opacity:.5;cursor:not-allowed}
    - +
    +
    Entrée pour envoyer · Maj+Entrée = nouvelle ligne
    - +
    - +
    + + +
    @@ -199,8 +239,22 @@ function toggleSide(){ document.getElementById('side').classList.toggle('open'); function saveSys(){ localStorage.setItem('jean.sys', document.getElementById('sysprompt').value); } document.addEventListener('DOMContentLoaded', ()=>{ document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; }); function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); } -async function jget(u){ const r=await fetch(u); return r.json(); } -async function jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); } +// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*. +// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage. +let TOKEN = localStorage.getItem('jean.key') || ''; +function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; } +async function jfetch(u, opts){ + opts = opts || {}; + opts.headers = authHeaders(opts.headers); + let r = await fetch(u, opts); + if(r.status === 401){ + const k = prompt('Clé de pilotage jean requise :', ''); + if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } + } + return r; +} +async function jget(u){ const r=await jfetch(u); return r.json(); } +async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); } async function loadStatus(){ const s=await jget('/api/status'); const t=s.active?'● active':'○ '+s.state+''; @@ -226,13 +280,53 @@ async function loadCfg(){ async function loadPresets(){ const p=await jget('/api/presets'); const act = p.find(x=>x.active); - document.getElementById('status-preset').innerHTML = act ? ''+act.name+'' : ''; - document.getElementById('presets').innerHTML = p.map((x,i)=> - '
    '+ - ''+(x.active?'● ':'')+x.name+''+ - ''+ - '
    ' - ).join('') || '(aucun)'; + // Build via DOM (not string concat) so preset names can contain anything — + // spaces, accents, quotes, < > & — without breaking markup or handlers. + const sp = document.getElementById('status-preset'); + sp.innerHTML=''; + if(act){ + const tag=document.createElement('span'); + tag.className='tag'; tag.style.borderColor='var(--accent)'; tag.style.color='var(--accent)'; + tag.textContent=act.name; sp.appendChild(tag); + } + const cont=document.getElementById('presets'); + cont.innerHTML=''; + if(!p.length){ cont.innerHTML='(aucun)'; return; } + p.forEach((x,i)=>{ + const row=document.createElement('div'); + row.className='preset'+(x.active?' active':''); + row.onclick=()=>switchTo(i+1, x.name); + const info=document.createElement('div'); info.className='preset-info'; + const nm=document.createElement('div'); nm.className='preset-name'; + nm.textContent=(x.active?'● ':'')+x.name; nm.title=x.name; + // Second row: quant tag + bench perf, so the title row stays full-width. + const meta=document.createElement('div'); meta.className='preset-meta'; + if(x.quant){ + const q=document.createElement('span'); q.className='qtag'; + q.textContent=x.quant; q.title='quantization'; + meta.appendChild(q); + } + if(x.reasoning){ + const rt=document.createElement('span'); rt.className='rtag'; + rt.title='raisonnement actif ('+x.reasoning+')'; + rt.innerHTML=''; + rt.appendChild(document.createTextNode(x.reasoning)); + meta.appendChild(rt); + } + if(x.bench){ + const bt=document.createElement('span'); bt.className='btag'; + bt.title='prefill / decode — dernier bench de ce preset'; + bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'; + meta.appendChild(bt); + } + info.appendChild(nm); + if(meta.children.length) info.appendChild(meta); + const edit=document.createElement('button'); + edit.className='preset-edit'; edit.title='éditer'; edit.textContent='✎'; + edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); }; + row.appendChild(info); row.appendChild(edit); + cont.appendChild(row); + }); } async function loadSkills(){ const s=await jget('/api/skills'); @@ -302,6 +396,7 @@ async function runBenchUI(){ } finally { btn.disabled = false; btn.textContent = '⚡ bench'; rerun.disabled = false; + loadPresets(); } } async function switchTo(n,name){ @@ -310,27 +405,39 @@ async function switchTo(n,name){ const r=await jpost('/api/switch',{n:n}); toast(r.ok?'switched':'erreur'); setTimeout(loadAll,2000); } -let editingOld = '', editingKind = 'preset'; +// editingKey = the identifier of the item being edited: a preset id (filename) +// or a skill name. Empty string = creating a new item. +let editingKey = '', editingKind = 'preset'; const KINDS = { - preset: {label:'Preset', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, - skill: {label:'Skill', getUrl:'/api/skill', saveUrl:'/api/skill/save', delUrl:'/api/skill/delete', reload:()=>loadSkills()}, + // presets are keyed by `id` (filename) so several can share a display name; + // skills keep name-as-identity (param 'name'). + preset: {label:'Preset', param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, + skill: {label:'Skill', param:'name', getUrl:'/api/skill', saveUrl:'/api/skill/save', delUrl:'/api/skill/delete', reload:()=>loadSkills()}, }; -async function openItem(kind, name){ +async function openItem(kind, key){ const K = KINDS[kind]; - const r = await fetch(K.getUrl + '?name=' + encodeURIComponent(name)); + const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); const d = await r.json(); - editingKind = kind; editingOld = name; - document.getElementById('modal-title').textContent = name ? (K.label + ' · ' + name) : ('Nouveau ' + K.label.toLowerCase()); - document.getElementById('m-name').value = name || ''; + editingKind = kind; editingKey = key || ''; + const display = d.name || key || ''; + document.getElementById('modal-title').textContent = key ? (K.label + ' · ' + display) : ('Nouveau ' + K.label.toLowerCase()); + document.getElementById('m-name').value = display; document.getElementById('m-content').value = d.content || ''; - document.getElementById('m-del').style.display = name ? 'inline-block' : 'none'; + document.getElementById('m-del').style.display = key ? 'inline-block' : 'none'; // Model picker is preset-only: it edits the MODEL= line of config.env. const modelRow = document.getElementById('m-model-row'); + const delModelWrap = document.getElementById('m-del-model-wrap'); if(kind === 'preset'){ modelRow.style.display = 'block'; + document.getElementById('m-hf-url').value = ''; + document.getElementById('m-hf-progress').style.display = 'none'; + document.getElementById('m-del-model').checked = false; + document.getElementById('m-quant').value = currentQuantInTextarea(); + delModelWrap.style.display = key ? 'inline-flex' : 'none'; await Promise.all([populateBackendPicker(), populateModelPicker()]); } else { modelRow.style.display = 'none'; + delModelWrap.style.display = 'none'; } document.getElementById('modal').style.display = 'flex'; } @@ -413,7 +520,23 @@ function onPickBackend(){ } toast('BIN='+val); } -const openPreset = (n)=>openItem('preset', n); +// Read/write the QUANT= override line in the preset textarea. +function currentQuantInTextarea(){ + const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=\s*"?([^"\n]*)"?\s*$/mi); + return m ? m[1].trim() : ''; +} +function applyQuant(){ + const val = document.getElementById('m-quant').value.trim(); + const ta = document.getElementById('m-content'); + const re = /^\s*#?\s*QUANT\s*=.*$/mi; + if(val){ + if(re.test(ta.value)) ta.value = ta.value.replace(re, 'QUANT="'+val+'"'); + else ta.value = ta.value.replace(/\s*$/,'') + '\nQUANT="'+val+'"\n'; + } else { + ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n'); + } +} +const openPreset = (id)=>openItem('preset', id); const openSkill = (n)=>openItem('skill', n); function closeModal(){ document.getElementById('modal').style.display = 'none'; } async function saveItem(){ @@ -421,17 +544,68 @@ async function saveItem(){ const name = document.getElementById('m-name').value.trim(); const content = document.getElementById('m-content').value; if(!name){ toast('nom requis'); return; } - const r = await jpost(K.saveUrl, {name, old: editingOld, content}); + // Presets: keyed by id (filename); duplicate display names are allowed. + // Skills: keyed by name, rename via `old`. + const payload = editingKind==='preset' + ? {id: editingKey, name, content} + : {name, old: editingKey, content}; + const r = await jpost(K.saveUrl, payload); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } toast('enregistré'); closeModal(); K.reload(); } async function delItem(){ - if(!editingOld) return; + if(!editingKey) return; const K = KINDS[editingKind]; - if(!confirm('Supprimer le ' + K.label.toLowerCase() + ' "' + editingOld + '" ?')) return; - const r = await jpost(K.delUrl, {name: editingOld}); + const name = document.getElementById('m-name').value.trim() || editingKey; + const delModel = editingKind==='preset' && document.getElementById('m-del-model').checked; + let msg = 'Supprimer le ' + K.label.toLowerCase() + ' "' + name + '" ?'; + if(delModel) msg += '\n\n⚠️ Le fichier .gguf du modèle sera AUSSI supprimé du disque (irréversible).'; + if(!confirm(msg)) return; + const payload = editingKind==='preset' + ? {id: editingKey, deleteModel: delModel} + : {name: editingKey}; + const r = await jpost(K.delUrl, payload); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } - toast('supprimé'); closeModal(); K.reload(); + if(delModel){ + if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError); + else if(r.modelDeleted) toast('preset + modèle supprimés'); + else toast('supprimé (aucun modèle référencé)'); + } else { toast('supprimé'); } + closeModal(); K.reload(); +} +// Download a .gguf from Hugging Face, polling progress until done. +let dlPoll = null; +async function startDownload(){ + const url = document.getElementById('m-hf-url').value.trim(); + if(!url){ toast('colle un lien .gguf'); return; } + const btn = document.getElementById('m-hf-btn'); + const prog = document.getElementById('m-hf-progress'); + btn.disabled = true; + prog.style.display = 'block'; + prog.textContent = 'démarrage…'; + const r = await jpost('/api/models/download', {url}); + if(!r.ok){ prog.innerHTML = 'erreur : '+(r.error||'')+''; btn.disabled=false; return; } + const fname = r.filename; + if(dlPoll) clearInterval(dlPoll); + dlPoll = setInterval(async ()=>{ + const list = await jget('/api/models/download/status'); + const st = (list||[]).find(d=>d.filename===fname); + if(!st){ return; } + if(st.error){ + prog.innerHTML = 'erreur : '+st.error+''; + clearInterval(dlPoll); dlPoll=null; btn.disabled=false; return; + } + if(st.finished){ + prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; + clearInterval(dlPoll); dlPoll=null; btn.disabled=false; + await populateModelPicker(); + document.getElementById('m-model').value = fname; onPickModel(); + return; + } + const pct = st.total>0 ? Math.round(st.done*100/st.total) : 0; + const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : ''; + prog.textContent = '⬇ '+fmtSize(st.done)+tot+' — '+fname; + }, 800); } // Smart autoscroll: follow the bottom while the user hasn't manually scrolled // up. Re-stick when they scroll back near bottom themselves. @@ -470,7 +644,24 @@ function addMsg(role, text){ function setLabel(el, text){ el.querySelector('.label').textContent = text; } function bodyOf(el){ return el.querySelector('.body'); } // Render markdown into a message body in place; safe because md() escapes HTML. -function renderBody(el, text){ bodyOf(el).innerHTML = md(text); scrollMaybe(); } +function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCopyButtons(b); scrollMaybe(); } +// Inject a "copier" button into every
     code block (idempotent).
    +function addCopyButtons(root){
    +  root.querySelectorAll('pre').forEach(pre=>{
    +    if(pre.querySelector('.copybtn')) return;
    +    const btn=document.createElement('button');
    +    btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
    +    btn.onclick=async(e)=>{
    +      e.stopPropagation();
    +      const code=pre.querySelector('code'), txt=(code||pre).innerText;
    +      try{ await navigator.clipboard.writeText(txt); }
    +      catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
    +      btn.textContent='copié ✓'; btn.classList.add('done');
    +      setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
    +    };
    +    pre.appendChild(btn);
    +  });
    +}
     function resetChat(){ msgs=[]; document.getElementById('chat').innerHTML=''; toast('chat vidé'); }
     function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
     let abortCtrl=null;
    @@ -512,7 +703,7 @@ async function send(){
       try{
         const sys=(document.getElementById('sysprompt').value||'').trim();
         const out = sys ? [{role:'system',content:sys}, ...msgs] : msgs;
    -    const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out}),signal:abortCtrl.signal});
    +    const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out}),signal:abortCtrl.signal});
         const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
         statsTimer = setInterval(updateStats, 250);
         while(true){
    diff --git a/web.go b/web.go
    index 9ac48bf..712d86e 100644
    --- a/web.go
    +++ b/web.go
    @@ -29,6 +29,8 @@ func cmdWeb(args []string) error {
     		port = n
     	}
     	mux := http.NewServeMux()
    +	// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
    +	// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
     	mux.HandleFunc("/", handleIndex)
     	mux.HandleFunc("/marked.min.js", func(w http.ResponseWriter, r *http.Request) {
     		b, _ := uiFS.ReadFile("ui/marked.min.js")
    @@ -36,28 +38,35 @@ func cmdWeb(args []string) error {
     		w.Header().Set("Cache-Control", "public, max-age=86400")
     		w.Write(b)
     	})
    -	mux.HandleFunc("/api/status", handleStatus)
    -	mux.HandleFunc("/api/vram", handleVram)
    -	mux.HandleFunc("/api/config", handleConfigEnv)
    -	mux.HandleFunc("/api/models", handleModels)
    -	mux.HandleFunc("/api/backends", handleBackends)
    -	mux.HandleFunc("/api/presets", handlePresets)
    -	mux.HandleFunc("/api/preset", handlePreset)
    -	mux.HandleFunc("/api/preset/save", handlePresetSave)
    -	mux.HandleFunc("/api/preset/delete", handlePresetDelete)
    -	mux.HandleFunc("/api/skills", handleSkills)
    -	mux.HandleFunc("/api/skills/toggle", handleSkillsToggle)
    -	mux.HandleFunc("/api/skill", handleSkill)
    -	mux.HandleFunc("/api/skill/save", handleSkillSave)
    -	mux.HandleFunc("/api/skill/delete", handleSkillDelete)
    -	mux.HandleFunc("/api/tools", handleTools)
    -	mux.HandleFunc("/api/tools/toggle", handleToolsToggle)
    -	mux.HandleFunc("/api/switch", handleSwitch)
    -	mux.HandleFunc("/api/start", svcHandler("start"))
    -	mux.HandleFunc("/api/stop", svcHandler("stop"))
    -	mux.HandleFunc("/api/restart", svcHandler("restart"))
    -	mux.HandleFunc("/api/bench", handleBench)
    -	mux.HandleFunc("/api/chat", handleChat)
    +	// api enregistre une route /api/* protégée par la clé de pilotage (webauth.go).
    +	api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
    +	api("/api/ping", handlePing)
    +	api("/api/status", handleStatus)
    +	api("/api/vram", handleVram)
    +	api("/api/config", handleConfigEnv)
    +	api("/api/models", handleModels)
    +	api("/api/models/delete", handleModelDelete)
    +	api("/api/models/download", handleModelDownload)
    +	api("/api/models/download/status", handleModelDownloadStatus)
    +	api("/api/backends", handleBackends)
    +	api("/api/presets", handlePresets)
    +	api("/api/preset", handlePreset)
    +	api("/api/preset/save", handlePresetSave)
    +	api("/api/preset/delete", handlePresetDelete)
    +	api("/api/skills", handleSkills)
    +	api("/api/skills/toggle", handleSkillsToggle)
    +	api("/api/skill", handleSkill)
    +	api("/api/skill/save", handleSkillSave)
    +	api("/api/skill/delete", handleSkillDelete)
    +	api("/api/tools", handleTools)
    +	api("/api/tools/toggle", handleToolsToggle)
    +	api("/api/switch", handleSwitch)
    +	api("/api/start", svcHandler("start"))
    +	api("/api/stop", svcHandler("stop"))
    +	api("/api/restart", svcHandler("restart"))
    +	api("/api/bench", handleBench)
    +	api("/api/bench/last", handleBenchLast)
    +	api("/api/chat", handleChat)
     	addr := fmt.Sprintf("0.0.0.0:%d", port)
     
     	ln, err := net.Listen("tcp", addr)
    @@ -72,6 +81,12 @@ func cmdWeb(args []string) error {
     		}
     	}
     	fmt.Printf("[jean web] http://%s  (Ctrl-C pour arrêter)\n", addr)
    +	if readWebKey() == "" {
    +		fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]"))
    +		fmt.Printf("       %s\n", bold("jean set-web-key"))
    +	} else {
    +		fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]"))
    +	}
     	return http.Serve(ln, mux)
     }
     
    @@ -167,13 +182,20 @@ func sendJSON(w http.ResponseWriter, code int, v any) {
     	json.NewEncoder(w).Encode(v)
     }
     
    +// handlePing is a lightweight authenticated endpoint a client hits to verify
    +// connectivity AND that its key is valid (200 = bonne clé, 401 = mauvaise clé).
    +func handlePing(w http.ResponseWriter, r *http.Request) {
    +	sendJSON(w, 200, map[string]any{"ok": true, "service": "jean", "version": Version})
    +}
    +
    +// handleStatus reports service state cross-platform via serviceIsActive
    +// (systemd sous Linux, supervision par PID-file sous Windows — voir service_*.go).
     func handleStatus(w http.ResponseWriter, r *http.Request) {
    -	out, _ := exec.Command("systemctl", "is-active", serviceName()).Output()
    -	state := strings.TrimSpace(string(out))
    -	if state == "" {
    -		state = "unknown"
    +	active := serviceIsActive()
    +	state := "inactive"
    +	if active {
    +		state = "active"
     	}
    -	active := state == "active"
     	health := false
     	if active {
     		health = healthCheck()
    @@ -277,29 +299,56 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
     		sendJSON(w, 500, map[string]any{"error": err.Error()})
     		return
     	}
    +	store := loadBenchStore()
     	out := []map[string]any{}
     	for _, p := range list {
    -		out = append(out, map[string]any{"name": p.Name, "active": p.Active})
    +		item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active}
    +		if content, err := ReadPreset(p.ID); err == nil {
    +			if q := detectQuant(content); q != "" {
    +				item["quant"] = q
    +			}
    +			if r := presetReasoning(content); reasoningActive(r) {
    +				item["reasoning"] = strings.ToLower(r)
    +			}
    +		}
    +		if sb, ok := store[p.ID]; ok {
    +			item["bench"] = map[string]any{
    +				"prefill": sb.Result.PromptPerSecond,
    +				"decode":  sb.Result.PredictedPerSec,
    +				"at":      sb.At,
    +			}
    +		}
    +		out = append(out, item)
     	}
     	sendJSON(w, 200, out)
     }
     
     func handlePreset(w http.ResponseWriter, r *http.Request) {
    -	name := strings.TrimSpace(r.URL.Query().Get("name"))
    -	if name == "" {
    +	id := strings.TrimSpace(r.URL.Query().Get("id"))
    +	if id == "" {
     		// new preset → seed from current config.env so users can tweak rather than start blank
     		b, _ := os.ReadFile(confPath())
    -		sendJSON(w, 200, map[string]any{"name": "", "content": string(b)})
    +		sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": string(b)})
     		return
     	}
    -	content, err := ReadPreset(name)
    +	content, err := ReadPreset(id)
     	if err != nil {
     		sendJSON(w, 404, map[string]any{"error": "not found"})
     		return
     	}
    -	sendJSON(w, 200, map[string]any{"name": name, "content": content})
    +	sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content})
     }
     
    +// presetSaveReq is the preset editor payload. `id` identifies an existing
    +// preset to update ("" creates a new one); `name` is the display name.
    +type presetSaveReq struct {
    +	ID          string `json:"id"`
    +	Name        string `json:"name"`
    +	Content     string `json:"content"`
    +	DeleteModel bool   `json:"deleteModel"`
    +}
    +
    +// saveReq is the skill editor payload (skills keep name-as-identity + rename).
     type saveReq struct {
     	Name    string `json:"name"`
     	Old     string `json:"old"`
    @@ -307,29 +356,46 @@ type saveReq struct {
     }
     
     func handlePresetSave(w http.ResponseWriter, r *http.Request) {
    -	var req saveReq
    +	var req presetSaveReq
     	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
     		sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
     		return
     	}
    -	if err := SavePreset(req.Name, req.Old, req.Content); err != nil {
    +	newID, err := SavePreset(req.ID, req.Name, req.Content)
    +	if err != nil {
     		sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
     		return
     	}
    -	sendJSON(w, 200, map[string]any{"ok": true, "name": req.Name})
    +	sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name})
     }
     
     func handlePresetDelete(w http.ResponseWriter, r *http.Request) {
    -	var req saveReq
    +	var req presetSaveReq
     	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
     		sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
     		return
     	}
    -	if err := DeletePreset(req.Name); err != nil {
    +	// Capture the referenced model before the preset file disappears, so we can
    +	// optionally delete the .gguf alongside it.
    +	model := ""
    +	if req.DeleteModel {
    +		if content, err := ReadPreset(req.ID); err == nil {
    +			model = modelFromPresetContent(content)
    +		}
    +	}
    +	if err := DeletePreset(req.ID); err != nil {
     		sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
     		return
     	}
    -	sendJSON(w, 200, map[string]any{"ok": true})
    +	modelDeleted, modelErr := "", ""
    +	if req.DeleteModel && model != "" {
    +		if err := deleteModelFile(model); err != nil {
    +			modelErr = err.Error()
    +		} else {
    +			modelDeleted = model
    +		}
    +	}
    +	sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr})
     }
     
     func handleSkills(w http.ResponseWriter, r *http.Request) {
    @@ -434,19 +500,18 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
     	sendJSON(w, 200, map[string]any{"ok": true, "preset": target.Name})
     }
     
    -// svcHandler returns an HTTP handler that triggers a systemctl action via the
    -// passwordless sudo rule installed by `jean install`. Falls back to no-sudo
    -// when the web server itself runs as root.
    +// svcHandler returns an HTTP handler that triggers a start/stop/restart through
    +// the cross-platform serviceAction (systemd sous Linux, supervision PID-file
    +// sous Windows — voir service_*.go). C'est ce qui permet à un client distant
    +// de relancer Jean.
     func svcHandler(action string) http.HandlerFunc {
     	return func(w http.ResponseWriter, r *http.Request) {
    -		var cmd *exec.Cmd
    -		if os.Geteuid() == 0 {
    -			cmd = exec.Command("systemctl", action, serviceName())
    -		} else {
    -			cmd = exec.Command("sudo", "-n", "systemctl", action, serviceName())
    +		err := serviceAction(action)
    +		msg := "ok"
    +		if err != nil {
    +			msg = err.Error()
     		}
    -		out, err := cmd.CombinedOutput()
    -		sendJSON(w, 200, map[string]any{"ok": err == nil, "out": string(out)})
    +		sendJSON(w, 200, map[string]any{"ok": err == nil, "out": msg})
     	}
     }
     
    @@ -475,6 +540,17 @@ func handleBench(w http.ResponseWriter, r *http.Request) {
     	sendJSON(w, 200, map[string]any{"ok": true, "result": res})
     }
     
    +// handleBenchLast returns the most recent persisted benchmark, or {ok:false}
    +// when none has been run yet.
    +func handleBenchLast(w http.ResponseWriter, r *http.Request) {
    +	sb := loadLastBench()
    +	if sb == nil {
    +		sendJSON(w, 200, map[string]any{"ok": false})
    +		return
    +	}
    +	sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At})
    +}
    +
     func handleChat(w http.ResponseWriter, r *http.Request) {
     	var body struct {
     		Messages    []Message `json:"messages"`
    diff --git a/webauth.go b/webauth.go
    new file mode 100644
    index 0000000..1aa5d18
    --- /dev/null
    +++ b/webauth.go
    @@ -0,0 +1,116 @@
    +package main
    +
    +import (
    +	"crypto/rand"
    +	"crypto/subtle"
    +	"encoding/hex"
    +	"fmt"
    +	"net/http"
    +	"os"
    +	"path/filepath"
    +	"strings"
    +)
    +
    +// webauth.go protège l'API de pilotage (jean web) quand elle est exposée sur
    +// internet — c.-à-d. l'API que tout client (navigateur, app mobile, script…)
    +// utilise pour switcher de preset, redémarrer le service, lire le status, etc.
    +//
    +// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
    +// protège llama-server / les complétions). On veut pouvoir donner à un client
    +// un accès aux complétions sans lui donner le droit de redémarrer la machine,
    +// et inversement. Elle est stockée dans $JEAN_HOME/.web_key et lue à chaque
    +// requête (pas de cache) pour qu'un changement de clé prenne effet sans
    +// redémarrer le serveur web.
    +
    +func webKeyPath() string { return filepath.Join(JeanHome(), ".web_key") }
    +
    +// readWebKey returns the trimmed contents of $JEAN_HOME/.web_key, or "" if the
    +// file is absent/empty.
    +func readWebKey() string {
    +	b, err := os.ReadFile(webKeyPath())
    +	if err != nil {
    +		return ""
    +	}
    +	return strings.TrimSpace(string(b))
    +}
    +
    +// requireWebAuth wraps an HTTP handler, rejecting requests that don't present
    +// the configured Bearer token. When no key is configured the handler is left
    +// open (pratique en local) — cmdWeb avertit alors bruyamment au démarrage.
    +func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
    +	return func(w http.ResponseWriter, r *http.Request) {
    +		key := readWebKey()
    +		if key == "" {
    +			next(w, r)
    +			return
    +		}
    +		if !checkBearer(r, key) {
    +			w.Header().Set("WWW-Authenticate", `Bearer realm="jean"`)
    +			sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"})
    +			return
    +		}
    +		next(w, r)
    +	}
    +}
    +
    +// checkBearer reports whether the request carries the expected key, either as an
    +// "Authorization: Bearer " header (cas normal) or as a ?key=
    +// query param (repli pour les clients qui ne peuvent pas poser d'en-tête, p.ex.
    +// une URL ouverte directement). La comparaison est à temps constant.
    +func checkBearer(r *http.Request, key string) bool {
    +	want := []byte(key)
    +	if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
    +		got := []byte(strings.TrimSpace(h[len("Bearer "):]))
    +		if subtle.ConstantTimeCompare(got, want) == 1 {
    +			return true
    +		}
    +	}
    +	if q := r.URL.Query().Get("key"); q != "" {
    +		if subtle.ConstantTimeCompare([]byte(q), want) == 1 {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
    +// cmdSetWebKey sets (or clears) the control-API key in $JEAN_HOME/.web_key.
    +//
    +//	jean set-web-key      définit la clé
    +//	jean set-web-key           génère une clé aléatoire
    +//	jean set-web-key ""        supprime la protection (API ouverte)
    +//
    +// Contrairement à set-api-key, aucun redémarrage n'est nécessaire : le serveur
    +// web relit la clé à chaque requête.
    +func cmdSetWebKey(args []string) error {
    +	var key string
    +	switch {
    +	case len(args) == 0:
    +		buf := make([]byte, 24)
    +		if _, err := rand.Read(buf); err != nil {
    +			return err
    +		}
    +		key = "jean-web-" + hex.EncodeToString(buf)
    +		fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key))
    +	case args[0] == "" || args[0] == "off" || args[0] == "none":
    +		key = ""
    +	default:
    +		key = strings.TrimSpace(args[0])
    +	}
    +	if key == "" {
    +		if err := os.Remove(webKeyPath()); err != nil && !os.IsNotExist(err) {
    +			return err
    +		}
    +		fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]"))
    +		return nil
    +	}
    +	if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
    +		return err
    +	}
    +	if err := os.WriteFile(webKeyPath(), []byte(key+"\n"), 0o600); err != nil {
    +		return err
    +	}
    +	fmt.Printf("%s clé de pilotage enregistrée dans %s\n", green("[ok]"), webKeyPath())
    +	fmt.Printf("       les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
    +	fmt.Printf("       (relance 'jean web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
    +	return nil
    +}
    
    From e606206d90179c5c878589c31f34c227be09d0cc Mon Sep 17 00:00:00 2001
    From: nathaninline 
    Date: Sat, 20 Jun 2026 20:04:09 +0200
    Subject: [PATCH 014/279] Ajoute `jean link` : acces distant via le relais
     ajean.link
    
    - nouvelle commande `jean link ` (link.go) : connexion sortante
      WebSocket + yamux vers le relais, sert l'UI web Jean ET l'endpoint
      compatible OpenAI (/v1) a travers le tunnel, avec injection des cles
      locales, keepalive et reconnexion automatique.
    - web.go : extraction de newWebMux() pour reutiliser le routeur HTTP
      cote `jean link` sans repasser par un ecouteur TCP local.
    - ui/index.html : base d'URL relative (API_BASE) + script marked relatif.
    - main.go : cablage de la commande `link` + aide.
    - README : section acces distant (ajean.link) + commande link.
    - .gitignore : secrets runtime (.web_key/.link_token/.link_machine).
    
    Inclut aussi les evolutions en cours du coeur de jean (llm.go, skills.go,
    tools.go, chat.go).
    ---
     .gitignore    |   3 +
     README.md     |  23 ++++-
     chat.go       |   7 +-
     go.mod        |   7 +-
     go.sum        |   4 +
     link.go       | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++
     llm.go        | 225 +++++++++++++++++++++++++++++++++++++++++--------
     main.go       |  10 ++-
     skills.go     |  39 +++++++--
     tools.go      |  41 ++++++++-
     ui/index.html | 169 +++++++++++++++++++++++++++++++++----
     web.go        |  59 ++++++++-----
     12 files changed, 724 insertions(+), 91 deletions(-)
     create mode 100644 go.sum
     create mode 100644 link.go
    
    diff --git a/.gitignore b/.gitignore
    index 7d846ad..aa7cc33 100644
    --- a/.gitignore
    +++ b/.gitignore
    @@ -11,6 +11,9 @@
     # Config locale / secrets runtime (générés à l'install, jamais versionnés)
     config.env
     .api_key
    +.web_key
    +.link_token
    +.link_machine
     
     # OS / éditeurs
     .DS_Store
    diff --git a/README.md b/README.md
    index 3906fde..c585cda 100644
    --- a/README.md
    +++ b/README.md
    @@ -23,6 +23,7 @@ Faire tourner llama.cpp comme un vrai service, ça veut dire d'habitude : trouve
     - **Intégration systemd** — `jean install` écrit l'unité, une règle sudoers `systemctl` sans mot de passe, et les dossiers de données
     - **Interface web** (`jean web`) — chat, changement de modèle/preset, activation des skills & tools
     - **Chat terminal** (`jean chat`) — réponses en streaming
    +- **Accès distant** (`jean link`) — connecte ce serveur à [ajean.link](https://ajean.link) par une connexion **sortante** (aucun port à ouvrir, marche même en CGNAT) : retrouve l'UI web et un endpoint **compatible OpenAI** depuis n'importe où
     - **Presets** (`jean switch`) — garde plusieurs profils `config.env` et bascule entre eux
     - **Protection par clé API** (`jean set-api-key`) — auth Bearer pour exposer le serveur publiquement ; la clé est stockée à part pour survivre aux changements de preset
     - **Benchmark** (`jean bench`) — tok/s prefill/decode honnêtes avec un corpus varié
    @@ -112,9 +113,13 @@ Interaction :
       chat [system-prompt]          chat terminal streamé
       web [PORT]                    interface web (défaut :8090)
     
    +Accès distant (ajean.link) :
    +  link                   connecte ce Jean au relais (accès web + API OpenAI depuis partout)
    +  link status | logout          état du lien / oublier le token
    +
     Outils côté LLM :
       skills [on|off|list]          laisse le modèle lire SKILLS//SKILL.md
    -  tools  [on|off|status]        active run_shell (le modèle exécute des commandes shell)
    +  machine [on|off|status]       active l'accès machine (le modèle dispose d'un shell complet sur le serveur)
     
     Backend (llama.cpp) :
       llamacpp install              clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), règle BIN
    @@ -190,6 +195,22 @@ La clé est stockée dans `$JEAN_HOME/.web_key`, distincte de `.api_key` (pilota
     
     > ⚠️ 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).
     
    +### Accès distant via ajean.link
    +
    +Plutôt que d'exposer un port, `jean link` ouvre une connexion **sortante** vers le relais [ajean.link](https://ajean.link) : ton serveur reste injoignable depuis l'extérieur, mais tu y accèdes quand même depuis n'importe où — idéal derrière une box ou en CGNAT.
    +
    +```bash
    +jean web                 # l'UI doit tourner localement
    +jean link         # token fourni sur ajean.link
    +```
    +
    +Une fois lié, tu retrouves depuis le portail :
    +- l'**interface web** de ton serveur, à distance ;
    +- un **endpoint compatible OpenAI** (`https://ajean.link/oai//v1`, clé = ton token) pour brancher n'importe quel outil (OpenCode, etc.) ;
    +- la gestion de **plusieurs serveurs** et d'**agents** depuis un tableau de bord.
    +
    +C'est un service optionnel et payant ; tout le reste de Jean est et restera open source et gratuit.
    +
     ### Variables d'environnement
     
     | Variable | Signification | Défaut |
    diff --git a/chat.go b/chat.go
    index a3070eb..35075d8 100644
    --- a/chat.go
    +++ b/chat.go
    @@ -72,8 +72,11 @@ func cmdChat(args []string) error {
     			case ev.Stats != nil:
     				stats = ev.Stats
     			case ev.ToolUsed != nil:
    -				icon := "📖"
    -				verb := "lecture du skill"
    +				if ev.ToolUsed.Done || ev.ToolUsed.Typing {
    +					break // résultat / frappe live affichés côté web ; en terminal on garde l'annonce seule
    +				}
    +				icon := "📚"
    +				verb := "skill"
     				if ev.ToolUsed.Name == "run_shell" {
     					icon = "⚙️"
     					verb = "exécution"
    diff --git a/go.mod b/go.mod
    index 2482621..06fc5ea 100644
    --- a/go.mod
    +++ b/go.mod
    @@ -1,3 +1,8 @@
     module github.com/nathaninline/jean
     
    -go 1.22
    +go 1.23
    +
    +require (
    +	github.com/coder/websocket v1.8.15
    +	github.com/hashicorp/yamux v0.1.2
    +)
    diff --git a/go.sum b/go.sum
    new file mode 100644
    index 0000000..c5d316b
    --- /dev/null
    +++ b/go.sum
    @@ -0,0 +1,4 @@
    +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
    +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
    +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
    +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
    diff --git a/link.go b/link.go
    new file mode 100644
    index 0000000..7daaa26
    --- /dev/null
    +++ b/link.go
    @@ -0,0 +1,228 @@
    +package main
    +
    +// link.go — `jean link ` : connecte ce serveur Jean au relais public
    +// (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur
    +// y accède depuis n'importe où sans ouvrir de port (CGNAT, box, etc.).
    +//
    +// Principe : l'agent ouvre un WebSocket vers le relais, l'authentifie avec le
    +// token d'abonnement, puis multiplexe (yamux) ce lien unique en un stream par
    +// requête navigateur. Chaque stream est reverse-proxyfié vers le `jean web`
    +// local. Keepalive + reconnexion automatique avec backoff.
    +//
    +// Le token est fourni par la boutique à l'achat. Il est mémorisé dans
    +// $JEAN_HOME/.link_token pour que `jean link` (sans argument) reprenne la
    +// connexion.
    +
    +import (
    +	"context"
    +	"crypto/rand"
    +	"encoding/hex"
    +	"fmt"
    +	"io"
    +	"net/http"
    +	"net/http/httputil"
    +	"net/url"
    +	"os"
    +	"path/filepath"
    +	"strings"
    +	"time"
    +
    +	"github.com/coder/websocket"
    +	"github.com/hashicorp/yamux"
    +)
    +
    +// defaultRelayURL est l'endpoint WebSocket du relais. Surchageable via
    +// $JEAN_LINK_URL (utile pour tester contre un relais local).
    +const defaultRelayURL = "wss://ajean.link/agent"
    +
    +func linkTokenPath() string   { return filepath.Join(JeanHome(), ".link_token") }
    +func linkMachinePath() string { return filepath.Join(JeanHome(), ".link_machine") }
    +
    +// machineID returns a stable per-machine identifier, creating one on first use.
    +// Permet au relais de regrouper les connexions d'une même machine sous le compte.
    +func machineID() string {
    +	if b, err := os.ReadFile(linkMachinePath()); err == nil {
    +		if id := strings.TrimSpace(string(b)); id != "" {
    +			return id
    +		}
    +	}
    +	buf := make([]byte, 8)
    +	_, _ = rand.Read(buf)
    +	id := hex.EncodeToString(buf)
    +	_ = os.MkdirAll(JeanHome(), 0o755)
    +	_ = os.WriteFile(linkMachinePath(), []byte(id+"\n"), 0o600)
    +	return id
    +}
    +
    +// readLinkToken returns the saved subscription token, or "".
    +func readLinkToken() string {
    +	b, err := os.ReadFile(linkTokenPath())
    +	if err != nil {
    +		return ""
    +	}
    +	return strings.TrimSpace(string(b))
    +}
    +
    +func saveLinkToken(tok string) error {
    +	if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
    +		return err
    +	}
    +	return os.WriteFile(linkTokenPath(), []byte(tok+"\n"), 0o600)
    +}
    +
    +// relayURL resolves the relay WebSocket endpoint (env override → default).
    +func relayURL() string {
    +	if u := strings.TrimSpace(os.Getenv("JEAN_LINK_URL")); u != "" {
    +		return u
    +	}
    +	return defaultRelayURL
    +}
    +
    +func cmdLink(args []string) error {
    +	// Sous-commandes utilitaires.
    +	if len(args) > 0 {
    +		switch args[0] {
    +		case "status":
    +			tok := readLinkToken()
    +			if tok == "" {
    +				fmt.Println(yellow("[info]") + " aucun token enregistré — lance: jean link ")
    +				return nil
    +			}
    +			fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL())
    +			return nil
    +		case "logout":
    +			_ = os.Remove(linkTokenPath())
    +			fmt.Println(green("[ok]") + " token supprimé")
    +			return nil
    +		}
    +	}
    +
    +	token := readLinkToken()
    +	if len(args) > 0 && args[0] != "" {
    +		token = strings.TrimSpace(args[0])
    +		if err := saveLinkToken(token); err != nil {
    +			return err
    +		}
    +	}
    +	if token == "" {
    +		return fmt.Errorf("aucun token. Usage: jean link   (token fourni à l'achat sur la boutique)")
    +	}
    +
    +	fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL())
    +	fmt.Printf("       (UI Jean + endpoint OpenAI servis dans le tunnel — pas besoin de 'jean web')\n")
    +
    +	// On construit le handler une seule fois ; il est servi à travers chaque tunnel.
    +	handler := newLinkHandler()
    +
    +	backoff := time.Second
    +	for {
    +		err := runLinkSession(token, handler)
    +		if err != nil {
    +			fmt.Printf("%s lien perdu: %v — reconnexion dans %s\n", yellow("[link]"), err, backoff)
    +		}
    +		time.Sleep(backoff)
    +		if backoff < 30*time.Second {
    +			backoff *= 2
    +			if backoff > 30*time.Second {
    +				backoff = 30 * time.Second
    +			}
    +		}
    +	}
    +}
    +
    +// newLinkHandler construit le handler servi à travers le tunnel :
    +//   - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint
    +//     compatible OpenAI, avec injection de la clé API locale) → permet de
    +//     brancher OpenCode, Hermes, etc. sur ajean.link/oai//v1
    +//   - tout le reste → l'UI web de Jean (avec injection de la clé de pilotage)
    +func newLinkHandler() http.Handler {
    +	web := withLocalAuth(newWebMux())
    +	llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
    +	lp := httputil.NewSingleHostReverseProxy(llama)
    +	lp.FlushInterval = -1 // streaming SSE des complétions
    +	apiKey := readAPIKey()
    +	base := lp.Director
    +	lp.Director = func(req *http.Request) {
    +		base(req)
    +		// Le client distant n'a pas la clé API de llama-server ; on l'injecte ici
    +		// (l'auth réelle est faite par le relais via la clé de liaison du compte).
    +		if apiKey != "" {
    +			req.Header.Set("Authorization", "Bearer "+apiKey)
    +		}
    +	}
    +	lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
    +		http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway)
    +	}
    +	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +		p := r.URL.Path
    +		if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {
    +			lp.ServeHTTP(w, r)
    +			return
    +		}
    +		web.ServeHTTP(w, r)
    +	})
    +}
    +
    +// withLocalAuth injecte la clé de pilotage locale dans chaque requête arrivant
    +// par le tunnel. Le navigateur distant ne connaît que le token (vérifié par le
    +// relais) ; c'est ici, en local, qu'on satisfait l'auth de l'API web sans
    +// exposer la clé au client.
    +func withLocalAuth(next http.Handler) http.Handler {
    +	webKey := readWebKey()
    +	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +		if webKey != "" && r.Header.Get("Authorization") == "" {
    +			r.Header.Set("Authorization", "Bearer "+webKey)
    +		}
    +		next.ServeHTTP(w, r)
    +	})
    +}
    +
    +// runLinkSession opens one WebSocket→yamux session and serves it until it dies.
    +// It blocks for the lifetime of the connection and returns the error that ended
    +// it (so the caller can reconnect).
    +func runLinkSession(token string, handler http.Handler) error {
    +	ctx := context.Background()
    +	dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
    +	defer cancel()
    +
    +	// Identifie la machine auprès du relais (id stable + hostname).
    +	host, _ := os.Hostname()
    +	dialURL := relayURL()
    +	q := url.Values{"m": {machineID()}, "h": {host}}
    +	if strings.Contains(dialURL, "?") {
    +		dialURL += "&" + q.Encode()
    +	} else {
    +		dialURL += "?" + q.Encode()
    +	}
    +
    +	c, _, err := websocket.Dial(dialCtx, dialURL, &websocket.DialOptions{
    +		HTTPHeader: http.Header{"Authorization": {"Bearer " + token}},
    +	})
    +	if err != nil {
    +		return fmt.Errorf("dial: %w", err)
    +	}
    +	// Pas de limite de taille : on fait passer des streams arbitraires (SSE).
    +	c.SetReadLimit(-1)
    +	conn := websocket.NetConn(ctx, c, websocket.MessageBinary)
    +
    +	// L'agent est le côté "serveur" yamux : c'est le relais qui ouvre un stream
    +	// par requête navigateur, et nous on les accepte.
    +	ycfg := yamux.DefaultConfig()
    +	ycfg.EnableKeepAlive = true
    +	ycfg.KeepAliveInterval = 25 * time.Second
    +	ycfg.ConnectionWriteTimeout = 30 * time.Second
    +	ycfg.LogOutput = io.Discard
    +	sess, err := yamux.Server(conn, ycfg)
    +	if err != nil {
    +		return fmt.Errorf("yamux: %w", err)
    +	}
    +	defer sess.Close()
    +
    +	fmt.Printf("%s lien établi ✓\n", green("[link]"))
    +
    +	// sess implémente net.Listener : chaque Accept() = un stream = une requête.
    +	// On sert directement l'UI Jean (le handler gère lui-même le flush SSE).
    +	srv := &http.Server{Handler: handler}
    +	return srv.Serve(sess)
    +}
    +
    diff --git a/llm.go b/llm.go
    index 11cf93f..dd4a290 100644
    --- a/llm.go
    +++ b/llm.go
    @@ -42,18 +42,22 @@ type ToolFunction struct {
     
     // readSkillTool / runShellTool: OpenAI-shaped function definitions advertised
     // to the model when the corresponding feature flag is on.
    -func readSkillTool() Tool {
    +// skillTool : un seul outil pour toute la gestion des skills (lecture +
    +// auto-gestion création/maj/suppression), ce qui évite d'envoyer trois schémas.
    +func skillTool() Tool {
     	return Tool{
     		Type: "function",
     		Function: ToolFunction{
    -			Name:        "read_skill",
    -			Description: "Lit le contenu détaillé d'un skill listé dans le system prompt.",
    +			Name:        "skill",
    +			Description: "Gère tes skills (guides Markdown réutilisables). action: read=lire, write=créer/remplacer, append=ajouter à la fin sans réécrire, delete=supprimer. content requis pour write/append (1re ligne = titre court #).",
     			Parameters: map[string]any{
     				"type": "object",
     				"properties": map[string]any{
    -					"name": map[string]any{"type": "string", "description": "Nom exact du skill"},
    +					"action":  map[string]any{"type": "string", "enum": []string{"read", "write", "append", "delete"}},
    +					"name":    map[string]any{"type": "string", "description": "Nom du skill (alphanum, ._-)"},
    +					"content": map[string]any{"type": "string", "description": "Contenu Markdown (write/append)"},
     				},
    -				"required": []string{"name"},
    +				"required": []string{"action", "name"},
     			},
     		},
     	}
    @@ -64,12 +68,12 @@ func runShellTool() Tool {
     		Type: "function",
     		Function: ToolFunction{
     			Name:        "run_shell",
    -			Description: "Exécute une commande shell sur le serveur (bash). Retourne stdout, stderr et le code de sortie. Utilise-le pour inspecter le système, lancer des scripts, consulter des logs. Évite les commandes destructrices sauf si l'utilisateur le demande explicitement.",
    +			Description: "Exécute une commande shell (bash) sur cette machine et retourne stdout, stderr et le code de sortie. Pour inspecter le système, lancer des scripts, lire des logs. Évite les commandes destructrices sauf demande explicite.",
     			Parameters: map[string]any{
     				"type": "object",
     				"properties": map[string]any{
    -					"command": map[string]any{"type": "string", "description": "La commande bash à exécuter"},
    -					"timeout": map[string]any{"type": "integer", "description": fmt.Sprintf("Timeout en secondes (défaut %d, max %d)", toolDefaultTimeout, toolMaxTimeout)},
    +					"command": map[string]any{"type": "string", "description": "La commande bash"},
    +					"timeout": map[string]any{"type": "integer", "description": fmt.Sprintf("Timeout s (défaut %d, max %d)", toolDefaultTimeout, toolMaxTimeout)},
     				},
     				"required": []string{"command"},
     			},
    @@ -77,26 +81,34 @@ func runShellTool() Tool {
     	}
     }
     
    -// InjectSkills prepends the lightweight skills directory message to msgs when
    -// skills are enabled. Merges with an existing system message if present.
    +// InjectSkills prepends context system messages to msgs: the machine briefing
    +// (when machine access is enabled) and the lightweight skills directory (when
    +// skills are enabled). Merges with an existing system message if present.
     func InjectSkills(msgs []Message) []Message {
    -	sp := skillsSystemPrompt()
    -	if sp == "" {
    +	var parts []string
    +	if mp := machineSystemPrompt(); mp != "" {
    +		parts = append(parts, mp)
    +	}
    +	if sp := skillsSystemPrompt(); sp != "" {
    +		parts = append(parts, sp)
    +	}
    +	if len(parts) == 0 {
     		return msgs
     	}
    +	prefix := strings.Join(parts, "\n\n")
     	if len(msgs) > 0 && msgs[0].Role == "system" {
     		existing, _ := msgs[0].Content.(string)
    -		merged := append([]Message{{Role: "system", Content: sp + "\n\n" + existing}}, msgs[1:]...)
    +		merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
     		return merged
     	}
    -	return append([]Message{{Role: "system", Content: sp}}, msgs...)
    +	return append([]Message{{Role: "system", Content: prefix}}, msgs...)
     }
     
     // EnabledTools returns the tools to advertise on the next inference call.
     func EnabledTools() []Tool {
     	tools := []Tool{}
    -	if skillsEnabled() && len(ListSkills()) > 0 {
    -		tools = append(tools, readSkillTool())
    +	if skillsEnabled() {
    +		tools = append(tools, skillTool())
     	}
     	if toolsEnabled() {
     		tools = append(tools, runShellTool())
    @@ -114,8 +126,58 @@ type StreamEvent struct {
     	Err       error
     }
     type ToolUsedEvent struct {
    -	Name  string
    -	Label string // user-visible summary (skill name or first ~80 chars of command)
    +	Name   string
    +	Label  string // user-visible summary (skill name or the command)
    +	Result string // tool output (stdout/stderr/exit for run_shell, skill body for read_skill)
    +	Done   bool   // false = call announced (command only); true = result is ready
    +	Typing bool   // true = command still being written (partial), no spinner yet
    +}
    +
    +// previewArg pulls the (possibly incomplete) string value of key out of a
    +// streaming tool-call arguments JSON, so the UI can show the command being
    +// typed live. Best-effort: it tolerates a truncated tail and basic escapes.
    +func previewArg(args, key string) string {
    +	i := strings.Index(args, "\""+key+"\"")
    +	if i < 0 {
    +		return ""
    +	}
    +	rest := args[i+len(key)+2:]
    +	if j := strings.Index(rest, ":"); j >= 0 {
    +		rest = rest[j+1:]
    +	} else {
    +		return ""
    +	}
    +	q := strings.Index(rest, "\"")
    +	if q < 0 {
    +		return ""
    +	}
    +	rest = rest[q+1:]
    +	var b strings.Builder
    +	for x := 0; x < len(rest); x++ {
    +		c := rest[x]
    +		if c == '\\' && x+1 < len(rest) {
    +			switch rest[x+1] {
    +			case 'n':
    +				b.WriteByte('\n')
    +			case 't':
    +				b.WriteByte('\t')
    +			case 'r':
    +			case '"':
    +				b.WriteByte('"')
    +			case '\\':
    +				b.WriteByte('\\')
    +			default:
    +				b.WriteByte(rest[x+1])
    +			}
    +			x++
    +			continue
    +		}
    +		if c == '"' {
    +			break
    +		}
    +		b.WriteByte(c)
    +	}
    +	return b.String()
     }
     
     // StatsEvent carries llama.cpp's per-completion timing (final chunk).
    @@ -168,6 +230,10 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error {
     	// 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"])
    +	// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
    +	// retry the same turn once with tools removed so the model answers in plain
    +	// text from the tool results already gathered, instead of dying mid-chat.
    +	disableTools := false
     	for iter := 0; iter < 8; iter++ {
     		payload := map[string]any{
     			"model":       "jean",
    @@ -175,8 +241,13 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error {
     			"stream":      true,
     			"temperature": temperature,
     		}
    -		if len(tools) > 0 {
    +		if len(tools) > 0 && !disableTools {
     			payload["tools"] = tools
    +			// The model sometimes emits parallel tool calls, which this llama.cpp
    +			// build serialises as two concatenated JSON objects in one arguments
    +			// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
    +			// single tool call per turn avoids that.
    +			payload["parallel_tool_calls"] = false
     		}
     		body, _ := json.Marshal(payload)
     		url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
    @@ -191,9 +262,36 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error {
     			cb(StreamEvent{Err: err})
     			return err
     		}
    +		// A non-200 here (e.g. context window exceeded after several large tool
    +		// outputs) is NOT valid SSE: without this check we'd scan an empty/HTML
    +		// body, find no data lines, and return silently — the chat just stops
    +		// with no answer. Surface the body so the cause is visible instead.
    +		if resp.StatusCode != http.StatusOK {
    +			b, _ := io.ReadAll(io.LimitReader(resp.Body, 2000))
    +			resp.Body.Close()
    +			msg := strings.TrimSpace(string(b))
    +			if msg == "" {
    +				msg = resp.Status
    +			}
    +			// Most common 500 here: llama.cpp couldn't parse a malformed tool call
    +			// the model emitted. Retry the turn once without tools so it answers
    +			// in plain text rather than leaving the chat dead.
    +			if !disableTools && len(tools) > 0 {
    +				disableTools = true
    +				// Nudge the model to answer in plain text from what it already
    +				// gathered, so it doesn't immediately re-emit a tool call that
    +				// llama.cpp would again fail to parse.
    +				messages = append(messages, Message{Role: "system", Content: "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues."})
    +				continue
    +			}
    +			err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg)
    +			cb(StreamEvent{Err: err})
    +			return err
    +		}
     		toolCalls := map[int]*ToolCall{}
     		assistantContent := strings.Builder{}
     		finishReason := ""
    +		lastPreview := "" // last command preview emitted (to stream the typing)
     		// Per-completion reasoning-split state (see reasoningOn comment above).
     		sawReasoningField := false
     		thinkOpen := reasoningOn
    @@ -246,6 +344,21 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error {
     					}
     					cur.Function.Arguments += tc.Function.Arguments
     				}
    +				// Stream the command being typed: extract the partial value and
    +				// emit it whenever it grows, so the UI shows it appear live.
    +				if cur := toolCalls[0]; cur != nil {
    +					key := "command"
    +					if cur.Function.Name == "skill" {
    +						key = "name"
    +					}
    +					if p := previewArg(cur.Function.Arguments, key); p != "" && p != lastPreview {
    +						lastPreview = p
    +						if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: p, Typing: true}}) {
    +							aborted = true
    +							break
    +						}
    +					}
    +				}
     				continue
     			}
     			if ch.Delta.ReasoningContent != "" {
    @@ -308,7 +421,10 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error {
     			return nil
     		}
     
    -		if finishReason == "tool_calls" && len(toolCalls) > 0 {
    +		// Treat any accumulated tool calls as a tool turn even if the backend set
    +		// finish_reason to "stop" instead of "tool_calls" (some llama.cpp builds
    +		// do this) — otherwise we'd skip execution AND skip answering.
    +		if len(toolCalls) > 0 {
     			// 1. Append assistant message with tool_calls so the model sees its own decision next turn.
     			idxs := make([]int, 0, len(toolCalls))
     			for k := range toolCalls {
    @@ -335,19 +451,52 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error {
     			for _, tc := range tcs {
     				var args map[string]any
     				_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
    -				result := ""
    +				// Derive the human label (command / skill name) up front so we can
    +				// announce the call BEFORE running it — otherwise the UI shows
    +				// nothing while a slow shell command runs and looks frozen.
     				label := ""
     				switch tc.Function.Name {
    -				case "read_skill":
    -					name, _ := args["name"].(string)
    -					label = name
    -					if c := SkillContent(name); c != "" {
    -						result = c
    -					} else {
    -						result = fmt.Sprintf("[erreur] skill '%s' introuvable", name)
    +				case "skill":
    +					label, _ = args["name"].(string)
    +				case "run_shell":
    +					label, _ = args["command"].(string)
    +				}
    +				cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
    +
    +				result := ""
    +				switch tc.Function.Name {
    +				case "skill":
    +					action, _ := args["action"].(string)
    +					content, _ := args["content"].(string)
    +					switch action {
    +					case "read":
    +						if c := SkillContent(label); c != "" {
    +							result = c
    +						} else {
    +							result = fmt.Sprintf("[erreur] skill '%s' introuvable", label)
    +						}
    +					case "write":
    +						if werr := SaveSkill(label, "", content); werr != nil {
    +							result = "[erreur] " + werr.Error()
    +						} else {
    +							result = fmt.Sprintf("[ok] skill '%s' enregistré", label)
    +						}
    +					case "append":
    +						if werr := AppendSkill(label, content); werr != nil {
    +							result = "[erreur] " + werr.Error()
    +						} else {
    +							result = fmt.Sprintf("[ok] skill '%s' enrichi (append)", label)
    +						}
    +					case "delete":
    +						if derr := DeleteSkill(label); derr != nil {
    +							result = "[erreur] " + derr.Error()
    +						} else {
    +							result = fmt.Sprintf("[ok] skill '%s' supprimé", label)
    +						}
    +					default:
    +						result = "[erreur] action skill inconnue: " + action
     					}
     				case "run_shell":
    -					cmd, _ := args["command"].(string)
     					to := 0
     					switch v := args["timeout"].(type) {
     					case float64:
    @@ -355,19 +504,25 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error {
     					case int:
     						to = v
     					}
    -					label = cmd
    -					if len(label) > 80 {
    -						label = label[:80] + "…"
    -					}
    -					result = runShell(cmd, to)
    +					result = runShell(label, to)
     				default:
     					result = "[erreur] outil inconnu: " + tc.Function.Name
     				}
    -				cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
    +				shown := result
    +				if r := []rune(shown); len(r) > 4000 {
    +					shown = string(r[:4000]) + "\n…[tronqué]"
    +				}
    +				cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shown, Done: true}})
     				messages = append(messages, Message{Role: "tool", ToolCallID: tc.ID, Content: result})
     			}
     			continue
     		}
    +		// Normal end of turn. If the model produced no visible answer at all
    +		// (empty content, e.g. it stopped right after a tool result), say so
    +		// instead of leaving the user staring at a silent, finished chat.
    +		if strings.TrimSpace(assistantContent.String()) == "" {
    +			cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})
    +		}
     		return nil
     	}
     	cb(StreamEvent{Content: "\n\n[stop: trop d'appels d'outils]"})
    diff --git a/main.go b/main.go
    index 9eb8c30..4cd4669 100644
    --- a/main.go
    +++ b/main.go
    @@ -38,9 +38,11 @@ func main() {
     		mustExit(cmdChat(args))
     	case "web":
     		mustExit(cmdWeb(args))
    +	case "link":
    +		mustExit(cmdLink(args))
     	case "skills":
     		mustExit(cmdSkills(args))
    -	case "tools":
    +	case "machine", "tools":
     		mustExit(cmdTools(args))
     	case "serve":
     		mustExit(cmdServe(args))
    @@ -89,9 +91,13 @@ Interaction:
       chat [system-prompt]          chat terminal streamé
       web [PORT]                    UI web (défaut :8090) — chat + presets + skills + tools
     
    +Accès distant (ajean.link) :
    +  link                   connecte ce Jean au relais public (accès web depuis partout, sans ouvrir de port)
    +  link status | logout          état du lien / oublier le token
    +
     LLM-side outils:
       skills [on|off|list]          active la lecture de SKILLS//SKILL.md par l'IA
    -  tools  [on|off|status]        active run_shell (l'IA exécute des commandes bash)
    +  machine [on|off|status]       active l'accès machine (l'IA dispose d'un shell complet sur le serveur)
     
     Backend llama.cpp :
       llamacpp install              clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), pointe BIN dessus
    diff --git a/skills.go b/skills.go
    index be4fd11..6279ffa 100644
    --- a/skills.go
    +++ b/skills.go
    @@ -117,6 +117,32 @@ func SaveSkill(name, old, content string) error {
     	return os.WriteFile(filepath.Join(d, "SKILL.md"), []byte(content), 0o644)
     }
     
    +// AppendSkill ajoute du contenu à la fin de SKILL.md (le crée si absent).
    +// C'est le mode "offset" : l'IA peut consigner une solution trouvée sans
    +// réécrire tout le skill.
    +func AppendSkill(name, content string) error {
    +	d, err := safeSkillDir(name)
    +	if err != nil {
    +		return err
    +	}
    +	if err := os.MkdirAll(d, 0o755); err != nil {
    +		return err
    +	}
    +	p := filepath.Join(d, "SKILL.md")
    +	existing, _ := os.ReadFile(p)
    +	var b strings.Builder
    +	if len(existing) > 0 {
    +		b.Write(existing)
    +		if !strings.HasSuffix(string(existing), "\n") {
    +			b.WriteString("\n")
    +		}
    +		b.WriteString("\n")
    +	}
    +	b.WriteString(strings.TrimRight(content, "\n"))
    +	b.WriteString("\n")
    +	return os.WriteFile(p, []byte(b.String()), 0o644)
    +}
    +
     func DeleteSkill(name string) error {
     	d, err := safeSkillDir(name)
     	if err != nil {
    @@ -135,14 +161,13 @@ func skillsSystemPrompt() string {
     		return ""
     	}
     	list := ListSkills()
    -	if len(list) == 0 {
    -		return ""
    -	}
     	var b strings.Builder
    -	b.WriteString(`Tu as accès à des skills (guides spécialisés). Pour lire le contenu détaillé d'un skill quand c'est pertinent, appelle la fonction read_skill(name=""). N'appelle pas read_skill si la question est sans rapport avec un skill listé.
    -
    -Skills disponibles :
    -`)
    +	b.WriteString(`Skills = guides Markdown que tu gères toi-même via le tool skill(action, name, content) : read=lire, write=créer/remplacer, append=ajouter à la fin sans réécrire, delete=supprimer. De ta propre initiative, crée ou enrichis un skill quand tu découvres une solution réutilisable (problème enfin résolu, procédure, astuce) ; sa 1re ligne est un titre court (#) servant de description. N'utilise read que si la question concerne un skill listé.`)
    +	if len(list) == 0 {
    +		b.WriteString("\nAucun skill pour l'instant.")
    +		return b.String()
    +	}
    +	b.WriteString("\nSkills :\n")
     	for _, s := range list {
     		fmt.Fprintf(&b, "- %s: %s\n", s.Name, s.Desc)
     	}
    diff --git a/tools.go b/tools.go
    index 9143501..4d47062 100644
    --- a/tools.go
    +++ b/tools.go
    @@ -5,6 +5,8 @@ import (
     	"fmt"
     	"os"
     	"os/exec"
    +	"os/user"
    +	"runtime"
     	"strings"
     	"time"
     )
    @@ -35,6 +37,37 @@ func setToolsEnabled(on bool) error {
     	return nil
     }
     
    +// machineSystemPrompt returns a short briefing about the host the model is
    +// running on, so that when machine access is enabled it knows *which* machine
    +// run_shell acts upon (and doesn't claim it has no access to "your PC").
    +// Returns "" when machine access is off.
    +func machineSystemPrompt() string {
    +	if !toolsEnabled() {
    +		return ""
    +	}
    +	host, _ := os.Hostname()
    +	if host == "" {
    +		host = "inconnu"
    +	}
    +	who := ""
    +	if u, err := user.Current(); err == nil {
    +		who = u.Username
    +	}
    +	cwd, _ := os.Getwd()
    +
    +	var b strings.Builder
    +	b.WriteString("Accès machine actif : le tool run_shell exécute des commandes sur cette machine — c'est ton accès réel, ne dis jamais que tu n'y as pas accès. Pour toute question système (OS, disque, process, fichiers, réseau), utilise-le au lieu de supposer.\n")
    +	b.WriteString(fmt.Sprintf("Machine : hôte=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
    +	if who != "" {
    +		b.WriteString(", user=" + who)
    +	}
    +	if cwd != "" {
    +		b.WriteString(", cwd=" + cwd)
    +	}
    +	b.WriteString(".")
    +	return b.String()
    +}
    +
     // runShell executes a command via the platform shell (bash -c on Unix, cmd /C
     // on Windows — see newShellCmd in platform_*.go) with a clamped timeout,
     // returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..."
    @@ -95,21 +128,21 @@ func cmdTools(args []string) error {
     		if err := setToolsEnabled(true); err != nil {
     			return err
     		}
    -		fmt.Println(green("[ok]") + " tool run_shell activé — exécution bash possible")
    +		fmt.Println(green("[ok]") + " accès machine activé — l'IA dispose d'un shell complet sur le serveur")
     	case "off":
     		if err := setToolsEnabled(false); err != nil {
     			return err
     		}
    -		fmt.Println(green("[ok]") + " tools désactivés")
    +		fmt.Println(green("[ok]") + " accès machine désactivé")
     	case "", "status":
     		state := dim("off")
     		if toolsEnabled() {
     			state = green("on")
     		}
    -		fmt.Printf("%s  run_shell  état: %s\n", cyan("Tools"), state)
    +		fmt.Printf("%s  état: %s\n", cyan("Accès machine"), state)
     		fmt.Printf("  timeout défaut: %ds, max: %ds\n", toolDefaultTimeout, toolMaxTimeout)
     	default:
    -		return fmt.Errorf("usage: jean tools [on|off|status]")
    +		return fmt.Errorf("usage: jean machine [on|off|status]")
     	}
     	return nil
     }
    diff --git a/ui/index.html b/ui/index.html
    index b1530e6..e034aab 100644
    --- a/ui/index.html
    +++ b/ui/index.html
    @@ -96,6 +96,14 @@ button:disabled{opacity:.5;cursor:not-allowed}
     #scrollbtn{position:absolute;bottom:80px;right:20px;background:var(--panel);border:1px solid var(--border);color:var(--accent);width:36px;height:36px;border-radius:50%;cursor:pointer;display:none;font-size:16px;z-index:5}
     #scrollbtn.show{display:block}
     .msg.reasoning{align-self:flex-start;background:transparent;border:1px dashed var(--border);color:var(--mag);font-size:12px;max-width:90%}
    +.msg.tool{align-self:flex-start;background:var(--panel);border:1px solid var(--border);max-width:100%;white-space:normal;padding:8px 12px}
    +.msg.tool .tool-head{font-size:12px;color:var(--accent);font-weight:600;margin-bottom:6px}
    +.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
    +.msg.tool pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
    +.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
    +.msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
    +.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
    +@keyframes toolblink{0%,100%{opacity:.45}50%{opacity:1}}
     .msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block}
     #inputbar{border-top:1px solid var(--border);padding:12px;display:flex;gap:8px}
     #input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:44px;max-height:200px}
    @@ -126,9 +134,9 @@ button:disabled{opacity:.5;cursor:not-allowed}
       
    Presets
    -
    Tools - -
    ⚠️ permet à l'IA d'exécuter des commandes bash sur le serveur (timeout 30s, max 300s)
    +
    Accès machine + +
    ⚠️ donne à l'IA un accès shell complet au serveur — elle peut inspecter, lancer des scripts et tout faire via le terminal (timeout 30s, max 300s)
    Skills @@ -155,6 +163,13 @@ button:disabled{opacity:.5;cursor:not-allowed}
    +
    +
    +
    +
    + contexte 0 / 0 + +
    @@ -214,7 +229,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
    - + + +
    +
    +
    +

    ajean

    + … +
    +
    GPU +
    …
    +
    +
    Config active +
    …
    +
    +
    Presets +
    +
    +
    Mode agent + + +
    +
    Mémoire?
    + +
    + ▸ + Pages + +
    + +
    +
    +
    Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI. Actifs seulement si le serveur répond.
    + +
    + + +
    +
    +
    +
    +
    Paramètres
    + + +
    +
    +
    Apparence + + +
    +
    System prompt + +
    +
    Accès OpenAI +
    Endpoint compatible OpenAI?Branche n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur cette URL. Modèle : jean. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
    + + +
    +
    🏠 local (réseau)
    +
    + + +
    +
    +
    modèle : jean
    +
    Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
    +
    + + + +
    +
    + + + +
    +
    +
    Service +
    + + + +
    +
    +
    Actions +
    +
    +
    + + + +
    +
    +
    +
    +
    + +
    +
    +
    + + + contexte 0 / 0 + + +
    +
    + + + +
    +
    Entrée pour envoyer · Maj+Entrée = nouvelle ligne
    +
    +
    +
    + + + + + diff --git a/ui/src/js/00-md.js b/ui/src/js/00-md.js new file mode 100644 index 0000000..a9db1da --- /dev/null +++ b/ui/src/js/00-md.js @@ -0,0 +1,21 @@ +// marked: GFM on, no auto-breaks (single newlines stay inline). +marked.setOptions({ gfm: true, breaks: false }); +// Pre-process to fix common LLM markdown mistakes before handing to marked: +// - code fences glued to preceding text on the same line ("foo ```ruby ...") +// - fences without a closing newline +// - 3+ consecutive blank lines collapsed to one (LLMs love padding) +function fixMd(s){ + if(!s) return ''; + // Force a newline before a fence that's been glued to preceding text + // (common LLM mistake: "...crée le fichier et```ruby"). We do NOT split + // *after* the fence — the part after the fence is the language identifier. + s = s.replace(/([^\n])(\s*)(```+|~~~+)(?=\w*\s*\n)/g, '$1\n$3'); + // Collapse runs of blank lines that LLMs love to emit. + s = s.replace(/\n{3,}/g, '\n\n'); + return s; +} +function md(src){ return src ? marked.parse(fixMd(src)) : ''; } +let msgs = []; +let busy = false; +function toggleSide(){ document.getElementById('side').classList.toggle('open'); document.getElementById('backdrop').classList.toggle('open'); document.body.classList.toggle('drawer-open'); } +function saveSys(){ localStorage.setItem('jean.sys', document.getElementById('sysprompt').value); } diff --git a/ui/src/js/01-theme.js b/ui/src/js/01-theme.js new file mode 100644 index 0000000..883476d --- /dev/null +++ b/ui/src/js/01-theme.js @@ -0,0 +1,43 @@ +// ===== Thèmes ================================================================ +// Registre des thèmes disponibles. Pour en AJOUTER un : ajouter un bloc +// [data-theme="…"] dans le
    @@ -388,6 +394,21 @@ button:disabled{opacity:.5;cursor:not-allowed}
    +
    Backend llama.cpp +
    …
    +
    + + + + +
    +
    + +
    Service
    @@ -944,7 +965,7 @@ async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); } -async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs()]); } +async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs(),loadLlamacpp()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; } function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; } @@ -1570,4 +1591,144 @@ async function send(){ loadAll(); setInterval(loadStatus, 5000); setInterval(loadVram, 3000); +// Backend llama.cpp : statut, vérification des maj, install / update / rebuild +// en job asynchrone côté serveur (logs + progression pollés sur /api/llamacpp/job). +let lcState = null, lcPoll = null, lcLogNext = 0; + +function lcBadge(html){ document.getElementById('lc-badge').innerHTML = html; } + +async function loadLlamacpp(){ + let s; + try{ s = await jget('/api/llamacpp'); }catch(_){ return; } + lcState = s; + const st = document.getElementById('lc-status'); + const parts = []; + if(!s.installed){ + parts.push('llama.cpp n\'est pas encore installé sur cette machine.'); + parts.push('L\'installation clone le dépôt, détecte le GPU et compile llama-server automatiquement.'); + lcBadge('absent'); + } else { + const esc = t=>String(t||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c])); + parts.push('commit '+esc(s.commit)+''+(s.branch?' ('+esc(s.branch)+')':'')); + if(s.commit_date) parts.push(''+esc(String(s.commit_date).slice(0,16))+' — '+esc(s.commit_msg)); + if(s.bin){ + parts.push('binaire : ✓ compilé'+(s.in_use?' · utilisé par la config':' · non utilisé par la config (BIN pointe ailleurs)')); + } else { + parts.push('binaire : absent (pas encore compilé)'); + } + if(s.behind > 0) lcBadge('maj dispo'); + else if(s.bin) lcBadge('ok'); + else lcBadge('à compiler'); + } + if(s.plan){ + const p = s.plan; + parts.push('accélérateur détecté : '+p.backend.toUpperCase()+''+(p.arch?' (arch '+p.arch+')':'')+' · '+p.jobs+' jobs'); + } + st.innerHTML = parts.join('
    '); + document.getElementById('lc-install').style.display = s.installed ? 'none' : ''; + document.getElementById('lc-check').style.display = s.installed ? '' : 'none'; + document.getElementById('lc-update').style.display = (s.installed && (s.behind>0 || !s.bin)) ? '' : 'none'; + document.getElementById('lc-rebuild').style.display = s.installed ? '' : 'none'; + // Job en cours (page rechargée pendant un build) → on raccroche le polling. + if(s.job && s.job.exists && s.job.running && !lcPoll){ + document.getElementById('lc-details').open = true; + lcStartPolling(); + } +} + +async function lcCheck(){ + const btn = document.getElementById('lc-check'); + const msg = document.getElementById('lc-check-msg'); + btn.disabled = true; btn.textContent = '⏳ vérification…'; + try{ + const r = await jpost('/api/llamacpp/check'); + if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } + if(r.behind > 0){ + msg.innerHTML = ''+r.behind+' commit(s) de retard sur origin/'+r.branch+'
    '+ + 'dernier distant : '+r.remote+' — '+String(r.remote_msg||'').replace(/[<>&]/g,''); + document.getElementById('lc-update').style.display = ''; + lcBadge('maj dispo'); + } else if(!r.has_binary){ + msg.innerHTML = 'sources à jour mais binaire absent — lance une recompilation.'; + } else { + msg.innerHTML = '✓ à jour ('+r.local+')'; + } + } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } +} + +async function lcInstall(){ + if(!await askConfirm('Cloner llama.cpp puis compiler llama-server pour cette machine (détection GPU automatique). La compilation peut prendre de longues minutes (surtout en CUDA).', {title:'Installer llama.cpp ?', okText:'Installer'})) return; + const r = await jpost('/api/llamacpp/install', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + +async function lcUpdate(clean){ + const msg = clean + ? 'Recompiler llama.cpp from scratch (build/ supprimé). Le service sera arrêté pendant le build puis redémarré.' + : 'Mettre à jour llama.cpp (git pull) et recompiler. Le service sera arrêté pendant le build puis redémarré.'; + if(!await askConfirm(msg, {title: clean ? 'Recompiler llama.cpp ?' : 'Mettre à jour llama.cpp ?', okText: clean ? 'Recompiler' : 'Mettre à jour'})) return; + const r = await jpost('/api/llamacpp/update', {clean: !!clean}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + +function lcSetButtons(disabled){ + ['lc-install','lc-check','lc-update','lc-rebuild'].forEach(id=>{ + const b=document.getElementById(id); if(b) b.disabled = disabled; + }); +} + +function lcStartPolling(){ + const jobBox = document.getElementById('lc-job'); + jobBox.style.display = ''; + document.getElementById('lc-log').textContent = ''; + lcLogNext = 0; + lcSetButtons(true); + lcBadge('⏳ build'); + if(lcPoll) clearInterval(lcPoll); + lcPoll = setInterval(lcPollJob, 1000); + lcPollJob(); +} + +async function lcPollJob(){ + let j; + try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } + if(!j.exists) return; + const phaseEl = document.getElementById('lc-job-phase'); + // Logs incrémentaux (offset absolu — le serveur tronque le début si besoin). + if(j.lines && j.lines.length){ + const pre = document.getElementById('lc-log'); + const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; + pre.textContent += j.lines.join('\n') + '\n'; + if(stick) pre.scrollTop = pre.scrollHeight; + } + if(typeof j.next === 'number') lcLogNext = j.next; + if(j.running){ + phaseEl.innerHTML = '⏳ '+String(j.phase||'…').replace(/[<>&]/g,'')+''; + return; + } + // Terminé. + clearInterval(lcPoll); lcPoll = null; + lcSetButtons(false); + if(j.error){ + phaseEl.innerHTML = '✗ '+String(j.error).replace(/[<>&]/g,'')+''; + // Ouvre les logs pour que l'erreur réelle soit visible tout de suite. + const pre = document.getElementById('lc-log'); + if(pre.hasAttribute('hidden')) lcToggleLog(); + pre.scrollTop = pre.scrollHeight; + toast('build llama.cpp en échec'); + } else { + phaseEl.innerHTML = '✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+''; + toast('llama.cpp : '+(j.phase||'terminé')); + } + loadAll(); // rafraîchit tout (config, statut service… et ce panneau) +} + +function lcToggleLog(){ + const pre = document.getElementById('lc-log'); + const bar = document.querySelector('.lc-logbar'); + if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; } + else { pre.setAttribute('hidden',''); bar.classList.remove('open'); } +} diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 9d50b61..f00ed94 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -116,6 +116,21 @@
    +
    Backend llama.cpp +
    …
    +
    + + + + +
    +
    + +
    Service
    diff --git a/internal/jean/ui/src/js/06-settings.js b/internal/jean/ui/src/js/06-settings.js index f1e6b23..4fd803e 100644 --- a/internal/jean/ui/src/js/06-settings.js +++ b/internal/jean/ui/src/js/06-settings.js @@ -216,5 +216,5 @@ async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); } -async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs()]); } +async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs(),loadLlamacpp()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/jean/ui/src/js/10-llamacpp.js b/internal/jean/ui/src/js/10-llamacpp.js new file mode 100644 index 0000000..54520c8 --- /dev/null +++ b/internal/jean/ui/src/js/10-llamacpp.js @@ -0,0 +1,140 @@ +// Backend llama.cpp : statut, vérification des maj, install / update / rebuild +// en job asynchrone côté serveur (logs + progression pollés sur /api/llamacpp/job). +let lcState = null, lcPoll = null, lcLogNext = 0; + +function lcBadge(html){ document.getElementById('lc-badge').innerHTML = html; } + +async function loadLlamacpp(){ + let s; + try{ s = await jget('/api/llamacpp'); }catch(_){ return; } + lcState = s; + const st = document.getElementById('lc-status'); + const parts = []; + if(!s.installed){ + parts.push('llama.cpp n\'est pas encore installé sur cette machine.'); + parts.push('L\'installation clone le dépôt, détecte le GPU et compile llama-server automatiquement.'); + lcBadge('absent'); + } else { + const esc = t=>String(t||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c])); + parts.push('commit '+esc(s.commit)+''+(s.branch?' ('+esc(s.branch)+')':'')); + if(s.commit_date) parts.push(''+esc(String(s.commit_date).slice(0,16))+' — '+esc(s.commit_msg)); + if(s.bin){ + parts.push('binaire : ✓ compilé'+(s.in_use?' · utilisé par la config':' · non utilisé par la config (BIN pointe ailleurs)')); + } else { + parts.push('binaire : absent (pas encore compilé)'); + } + if(s.behind > 0) lcBadge('maj dispo'); + else if(s.bin) lcBadge('ok'); + else lcBadge('à compiler'); + } + if(s.plan){ + const p = s.plan; + parts.push('accélérateur détecté : '+p.backend.toUpperCase()+''+(p.arch?' (arch '+p.arch+')':'')+' · '+p.jobs+' jobs'); + } + st.innerHTML = parts.join('
    '); + document.getElementById('lc-install').style.display = s.installed ? 'none' : ''; + document.getElementById('lc-check').style.display = s.installed ? '' : 'none'; + document.getElementById('lc-update').style.display = (s.installed && (s.behind>0 || !s.bin)) ? '' : 'none'; + document.getElementById('lc-rebuild').style.display = s.installed ? '' : 'none'; + // Job en cours (page rechargée pendant un build) → on raccroche le polling. + if(s.job && s.job.exists && s.job.running && !lcPoll){ + document.getElementById('lc-details').open = true; + lcStartPolling(); + } +} + +async function lcCheck(){ + const btn = document.getElementById('lc-check'); + const msg = document.getElementById('lc-check-msg'); + btn.disabled = true; btn.textContent = '⏳ vérification…'; + try{ + const r = await jpost('/api/llamacpp/check'); + if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } + if(r.behind > 0){ + msg.innerHTML = ''+r.behind+' commit(s) de retard sur origin/'+r.branch+'
    '+ + 'dernier distant : '+r.remote+' — '+String(r.remote_msg||'').replace(/[<>&]/g,''); + document.getElementById('lc-update').style.display = ''; + lcBadge('maj dispo'); + } else if(!r.has_binary){ + msg.innerHTML = 'sources à jour mais binaire absent — lance une recompilation.'; + } else { + msg.innerHTML = '✓ à jour ('+r.local+')'; + } + } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } +} + +async function lcInstall(){ + if(!await askConfirm('Cloner llama.cpp puis compiler llama-server pour cette machine (détection GPU automatique). La compilation peut prendre de longues minutes (surtout en CUDA).', {title:'Installer llama.cpp ?', okText:'Installer'})) return; + const r = await jpost('/api/llamacpp/install', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + +async function lcUpdate(clean){ + const msg = clean + ? 'Recompiler llama.cpp from scratch (build/ supprimé). Le service sera arrêté pendant le build puis redémarré.' + : 'Mettre à jour llama.cpp (git pull) et recompiler. Le service sera arrêté pendant le build puis redémarré.'; + if(!await askConfirm(msg, {title: clean ? 'Recompiler llama.cpp ?' : 'Mettre à jour llama.cpp ?', okText: clean ? 'Recompiler' : 'Mettre à jour'})) return; + const r = await jpost('/api/llamacpp/update', {clean: !!clean}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + +function lcSetButtons(disabled){ + ['lc-install','lc-check','lc-update','lc-rebuild'].forEach(id=>{ + const b=document.getElementById(id); if(b) b.disabled = disabled; + }); +} + +function lcStartPolling(){ + const jobBox = document.getElementById('lc-job'); + jobBox.style.display = ''; + document.getElementById('lc-log').textContent = ''; + lcLogNext = 0; + lcSetButtons(true); + lcBadge('⏳ build'); + if(lcPoll) clearInterval(lcPoll); + lcPoll = setInterval(lcPollJob, 1000); + lcPollJob(); +} + +async function lcPollJob(){ + let j; + try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } + if(!j.exists) return; + const phaseEl = document.getElementById('lc-job-phase'); + // Logs incrémentaux (offset absolu — le serveur tronque le début si besoin). + if(j.lines && j.lines.length){ + const pre = document.getElementById('lc-log'); + const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; + pre.textContent += j.lines.join('\n') + '\n'; + if(stick) pre.scrollTop = pre.scrollHeight; + } + if(typeof j.next === 'number') lcLogNext = j.next; + if(j.running){ + phaseEl.innerHTML = '⏳ '+String(j.phase||'…').replace(/[<>&]/g,'')+''; + return; + } + // Terminé. + clearInterval(lcPoll); lcPoll = null; + lcSetButtons(false); + if(j.error){ + phaseEl.innerHTML = '✗ '+String(j.error).replace(/[<>&]/g,'')+''; + // Ouvre les logs pour que l'erreur réelle soit visible tout de suite. + const pre = document.getElementById('lc-log'); + if(pre.hasAttribute('hidden')) lcToggleLog(); + pre.scrollTop = pre.scrollHeight; + toast('build llama.cpp en échec'); + } else { + phaseEl.innerHTML = '✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+''; + toast('llama.cpp : '+(j.phase||'terminé')); + } + loadAll(); // rafraîchit tout (config, statut service… et ce panneau) +} + +function lcToggleLog(){ + const pre = document.getElementById('lc-log'); + const bar = document.querySelector('.lc-logbar'); + if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; } + else { pre.setAttribute('hidden',''); bar.classList.remove('open'); } +} diff --git a/internal/jean/ui/src/styles.css b/internal/jean/ui/src/styles.css index fb40d2f..8fb570d 100644 --- a/internal/jean/ui/src/styles.css +++ b/internal/jean/ui/src/styles.css @@ -271,3 +271,9 @@ button:disabled{opacity:.5;cursor:not-allowed} h2,details>summary{font-size:10px} #menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))} } +/* --- Backend llama.cpp (gestion install/update depuis l'UI) --------------- */ +.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:6px 0;margin-top:4px} +.lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} +.lc-logbar.open #lc-log-caret{transform:rotate(90deg)} +#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0} +.lc-spin{display:inline-block;animation:spin 1s linear infinite} diff --git a/internal/jean/web_llamacpp.go b/internal/jean/web_llamacpp.go new file mode 100644 index 0000000..0224fa5 --- /dev/null +++ b/internal/jean/web_llamacpp.go @@ -0,0 +1,452 @@ +// web_llamacpp.go — pilotage du backend llama.cpp depuis l'UI web : +// statut (commit, retard, binaire, plan de build), vérification des mises à +// jour (git fetch), et jobs asynchrones install / update / rebuild dont la +// progression et les logs sont pollés par le client. +// +// GET /api/llamacpp → statut complet (dépôt, commit, binaire, plan) +// POST /api/llamacpp/check → git fetch + retard sur origin (réseau) +// POST /api/llamacpp/install → job d'installation {force?} +// POST /api/llamacpp/update → job de mise à jour {clean?} +// GET /api/llamacpp/job?from= → progression du job (phase, lignes, erreur) +package jean + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// lcJob est LE job llama.cpp en cours (un seul à la fois). Les lignes de log +// sont indexées de façon absolue (base = index de Lines[0]) pour que le client +// puisse poller « à partir de N » même quand on tronque le début. +type lcJob struct { + Action string // "install" | "update" + Phase string + Compiled int // fichiers compilés (progression du build) + Running bool + Err string + StartedAt int64 + EndedAt int64 + OldCommit string + NewCommit string + lines []string + base int +} + +var ( + lcMu sync.Mutex + lcCur *lcJob +) + +const lcMaxLines = 4000 + +// lcAppend ajoute une ligne au log du job courant et met à jour la phase / le +// compteur de fichiers compilés à partir du contenu (mêmes heuristiques que le +// spinner du CLI). Appelée par le sink de build (emitBuildLine). +func lcAppend(line string) { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur == nil { + return + } + lcCur.lines = append(lcCur.lines, line) + if len(lcCur.lines) > lcMaxLines { + drop := len(lcCur.lines) - lcMaxLines + lcCur.lines = append([]string(nil), lcCur.lines[drop:]...) + lcCur.base += drop + } + if f := compiledFile(line); f != "" { + lcCur.Compiled++ + lcCur.Phase = fmt.Sprintf("compilation… %d fichiers", lcCur.Compiled) + } else if p := phaseLabel(line); p != "" { + lcCur.Phase = p + } +} + +// lcPhase pose une phase explicite (étapes hors build : clone, fetch, service…) +// et la trace aussi dans le log. +func lcPhase(phase string) { + lcMu.Lock() + if lcCur != nil { + lcCur.Phase = phase + lcCur.lines = append(lcCur.lines, "▶ "+phase) + } + lcMu.Unlock() +} + +// startLcJob démarre un job (install ou update) si aucun n'est en cours. +func startLcJob(action string, run func()) error { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur != nil && lcCur.Running { + return fmt.Errorf("un job %s est déjà en cours", lcCur.Action) + } + lcCur = &lcJob{Action: action, Running: true, Phase: "démarrage…", StartedAt: time.Now().Unix()} + setBuildSink(lcAppend) + go func() { + defer func() { + setBuildSink(nil) + lcMu.Lock() + lcCur.Running = false + lcCur.EndedAt = time.Now().Unix() + lcMu.Unlock() + }() + run() + }() + return nil +} + +func lcFail(err error) { + lcMu.Lock() + if lcCur != nil { + lcCur.Err = err.Error() + lcCur.Phase = "échec" + lcCur.lines = append(lcCur.lines, "✗ "+err.Error()) + } + lcMu.Unlock() +} + +func lcDone(msg string) { + lcMu.Lock() + if lcCur != nil { + lcCur.Phase = msg + lcCur.lines = append(lcCur.lines, "✓ "+msg) + } + lcMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// handleLlamacpp renvoie le statut complet du backend llama.cpp : dépôt, +// commit courant, retard connu (sans fetch réseau), binaire compilé et son +// usage dans config.env, plan de build détecté, et le job éventuel. +func handleLlamacpp(w http.ResponseWriter, r *http.Request) { + repo := llamacppRepoDir() + out := map[string]any{ + "repo": repo, + "installed": isDir(filepath.Join(repo, ".git")), + } + if out["installed"].(bool) { + out["branch"] = gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + out["commit"] = gitOutput(repo, "rev-parse", "--short", "HEAD") + out["commit_date"] = gitOutput(repo, "log", "-1", "--format=%ci") + out["commit_msg"] = gitOutput(repo, "log", "-1", "--format=%s") + if br, _ := out["branch"].(string); br != "" && br != "HEAD" { + if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+br); behind != "" { + n, _ := strconv.Atoi(behind) + out["behind"] = n + } + } + } + bin := llamaServerBin(repo) + out["bin"] = bin + cfgBin := ReadConfig()["BIN"] + out["config_bin"] = cfgBin + out["in_use"] = bin != "" && samePath(bin, cfgBin) + + plan := detectBuildPlan() + out["plan"] = map[string]any{ + "backend": plan.backend, + "arch": plan.cudaArch, + "jobs": plan.jobs, + } + out["job"] = lcJobSnapshot(0, false) + sendJSON(w, 200, out) +} + +// samePath compare deux chemins en neutralisant séparateurs, symlinks et casse +// (Windows). +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + norm := func(p string) string { + if real, err := filepath.EvalSymlinks(p); err == nil { + p = real + } + p = filepath.Clean(p) + return strings.ToLower(filepath.ToSlash(p)) + } + return norm(a) == norm(b) +} + +// handleLlamacppCheck fait un vrai git fetch puis renvoie le retard sur origin +// et le dernier commit distant. Synchrone (quelques secondes réseau). +func handleLlamacppCheck(w http.ResponseWriter, r *http.Request) { + repo := llamacppRepoDir() + if !isDir(filepath.Join(repo, ".git")) { + sendJSON(w, 200, map[string]any{"ok": false, "error": "llama.cpp n'est pas installé"}) + return + } + branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + if branch == "" || branch == "HEAD" { + branch = "master" + } + if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": "git fetch a échoué : " + err.Error()}) + return + } + behind := 0 + if b := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); b != "" { + behind, _ = strconv.Atoi(b) + } + sendJSON(w, 200, map[string]any{ + "ok": true, + "behind": behind, + "local": gitOutput(repo, "rev-parse", "--short", "HEAD"), + "remote": gitOutput(repo, "rev-parse", "--short", "origin/"+branch), + "remote_date": gitOutput(repo, "log", "-1", "--format=%ci", "origin/"+branch), + "remote_msg": gitOutput(repo, "log", "-1", "--format=%s", "origin/"+branch), + "branch": branch, + "has_binary": llamaServerBin(repo) != "", + "needs_rebuild": behind > 0 || llamaServerBin(repo) == "", + }) +} + +// handleLlamacppInstall lance le job d'installation (clone + build + BIN). +func handleLlamacppInstall(w http.ResponseWriter, r *http.Request) { + var req struct { + Force bool `json:"force"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := startLcJob("install", func() { lcRunInstall(req.Force) }); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleLlamacppUpdate lance le job de mise à jour (pull + rebuild + restart). +// {clean:true} force une recompilation from scratch même sans nouveau commit. +func handleLlamacppUpdate(w http.ResponseWriter, r *http.Request) { + var req struct { + Clean bool `json:"clean"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := startLcJob("update", func() { lcRunUpdate(req.Clean) }); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleLlamacppJob renvoie l'état du job courant + les lignes de log depuis +// l'offset absolu ?from=N (le client mémorise `next` et enchaîne). +func handleLlamacppJob(w http.ResponseWriter, r *http.Request) { + from, _ := strconv.Atoi(r.URL.Query().Get("from")) + sendJSON(w, 200, lcJobSnapshot(from, true)) +} + +// lcJobSnapshot construit la vue JSON du job courant. withLines=false renvoie +// juste l'entête (imbriquée dans /api/llamacpp). +func lcJobSnapshot(from int, withLines bool) map[string]any { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur == nil { + return map[string]any{"exists": false} + } + j := lcCur + out := map[string]any{ + "exists": true, + "action": j.Action, + "running": j.Running, + "phase": j.Phase, + "compiled": j.Compiled, + "error": j.Err, + "started_at": j.StartedAt, + "ended_at": j.EndedAt, + "old": j.OldCommit, + "new": j.NewCommit, + } + if withLines { + start := from - j.base + if start < 0 { + start = 0 + } + if start > len(j.lines) { + start = len(j.lines) + } + out["lines"] = append([]string(nil), j.lines[start:]...) + out["next"] = j.base + len(j.lines) + } + return out +} + +// --------------------------------------------------------------------------- +// Corps des jobs (miroir web de llamacppInstall / llamacppUpdate, sans stdout +// interactif : tout passe par lcPhase / le sink de build) +// --------------------------------------------------------------------------- + +func lcRunInstall(force bool) { + repo := defaultRepoDir() + + lcPhase("vérification des outils (git, cmake, compilateur)…") + if err := requireTools("git", "cmake"); err != nil { + lcFail(err) + return + } + if err := ensureCompiler(); err != nil { + lcFail(err) + return + } + ensureAccelerator() + + if isDir(filepath.Join(repo, ".git")) { + if !force { + // Dépôt déjà là : on bascule sur une mise à jour (même intention). + lcPhase("dépôt déjà présent — bascule en mise à jour") + lcRunUpdate(false) + return + } + lcPhase("suppression du dépôt existant (--force)…") + if err := os.RemoveAll(repo); err != nil { + lcFail(err) + return + } + } + if err := os.MkdirAll(filepath.Dir(repo), 0o755); err != nil { + lcFail(err) + return + } + + lcPhase("clone de llama.cpp…") + if err := runStep("git clone", "", "git", "clone", "--depth=1", llamacppRepoURL, repo); err != nil { + lcFail(fmt.Errorf("git clone a échoué : %w", err)) + return + } + + if !lcBuildAndSwitch(repo, true) { + return + } + lcDone("installation terminée") +} + +func lcRunUpdate(clean bool) { + lcPhase("vérification des outils (git, cmake, compilateur)…") + if err := requireTools("git", "cmake"); err != nil { + lcFail(err) + return + } + if err := ensureCompiler(); err != nil { + lcFail(err) + return + } + ensureAccelerator() + + repo := llamacppRepoDir() + if !isDir(filepath.Join(repo, ".git")) { + lcFail(fmt.Errorf("aucun dépôt llama.cpp (%s) — lance d'abord l'installation", repo)) + return + } + + oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") + lcMu.Lock() + lcCur.OldCommit = oldCommit + lcMu.Unlock() + + branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + if branch == "" || branch == "HEAD" { + branch = "master" + } + + lcPhase("git fetch origin…") + if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { + lcFail(fmt.Errorf("git fetch a échoué : %w", err)) + return + } + localRev := gitOutput(repo, "rev-parse", "HEAD") + remoteRev := gitOutput(repo, "rev-parse", "origin/"+branch) + if localRev != "" && localRev == remoteRev && !clean && llamaServerBin(repo) != "" { + lcDone("déjà à jour (" + oldCommit + ") — rien à faire") + return + } + if localRev != remoteRev { + lcPhase("git pull origin/" + branch + "…") + if err := runStep("git pull --ff-only", repo, "git", "pull", "--ff-only", "origin", branch); err != nil { + lcFail(fmt.Errorf("git pull a échoué (modifs locales ?) : %w", err)) + return + } + } + newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") + lcMu.Lock() + lcCur.NewCommit = newCommit + lcMu.Unlock() + + // Le binaire en cours d'exécution ne peut pas être réécrit → stop du service + // pendant le build, redémarrage après (même en échec). + svcWasUp := serviceIsActive() + if svcWasUp { + lcPhase("arrêt du service le temps du build…") + if err := serviceAction("stop"); err != nil { + lcAppend("[warn] impossible d'arrêter le service : " + err.Error()) + } + } + + ok := lcBuildAndSwitch(repo, clean) + if svcWasUp { + lcPhase("redémarrage du service…") + if err := serviceAction("start"); err != nil { + lcAppend("[warn] redémarrage du service échoué : " + err.Error()) + } + } + if !ok { + return + } + if oldCommit == newCommit { + lcDone("recompilé (" + newCommit + ")") + } else { + lcDone("mis à jour : " + oldCommit + " → " + newCommit) + } +} + +// lcBuildAndSwitch détecte le plan, compile llama-server et pointe BIN dessus. +// Renvoie false (job en échec) si une étape casse. +func lcBuildAndSwitch(repo string, clean bool) bool { + plan := detectBuildPlan() + lcAppend(fmt.Sprintf("plan de build : backend=%s arch=%s jobs=%d", plan.backend, plan.cudaArch, plan.jobs)) + lcPhase("configuration CMake…") + if err := buildLlamacpp(repo, plan, clean); err != nil { + lcAppendLogTail(filepath.Join(repo, "configure.log"), filepath.Join(repo, "build.log")) + lcFail(err) + return false + } + bin := llamaServerBin(repo) + if bin == "" { + lcFail(fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build"))) + return false + } + lcAppend("binaire compilé : " + bin) + if err := SetConfigKey("BIN", bin); err != nil { + lcFail(fmt.Errorf("build ok mais échec écriture BIN dans config.env : %w", err)) + return false + } + lcAppend("BIN mis à jour dans " + confPath()) + return true +} + +// lcAppendLogTail remonte la fin des logs de build dans le job pour que +// l'erreur réelle soit visible dans l'UI sans aller chercher les fichiers. +func lcAppendLogTail(paths ...string) { + for _, p := range paths { + b, err := os.ReadFile(p) + if err != nil { + continue + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + if len(lines) > 30 { + lines = lines[len(lines)-30:] + } + lcAppend("--- fin de " + filepath.Base(p) + " ---") + for _, l := range lines { + lcAppend(l) + } + } +} diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index 121e059..8209c35 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -88,6 +88,11 @@ func newWebMux() *http.ServeMux { api("/api/models/download", handleModelDownload) api("/api/models/download/status", handleModelDownloadStatus) api("/api/backends", handleBackends) + api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp + api("/api/llamacpp/check", handleLlamacppCheck) // git fetch + retard sur origin + api("/api/llamacpp/install", handleLlamacppInstall) // job : clone + build + BIN + api("/api/llamacpp/update", handleLlamacppUpdate) // job : pull + rebuild + restart + api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job api("/api/presets", handlePresets) api("/api/preset", handlePreset) api("/api/preset/save", handlePresetSave) From 58b59da823e0c7dae575f1b2fb6fe45c9c856aed Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 22 Jul 2026 17:59:12 +0200 Subject: [PATCH 118/279] v0.4.8 : gestion du backend llama.cpp depuis l'interface web (statut, verification des maj, install / update / recompilation avec logs en direct) --- internal/jean/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/jean/run.go b/internal/jean/run.go index 7e6c7ba..670e8c3 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.4.7" +const Version = "0.4.8" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { From b1890ed6f7e1245f58350f16bc19e4c31cf07ce3 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 22 Jul 2026 19:22:08 +0200 Subject: [PATCH 119/279] backend llama.cpp : mode binaires precompiles officiels (aucune compilation) Alternative rapide a la compilation locale : telechargement des binaires officiels de ggml-org/llama.cpp publies a chaque release (~2 min au lieu de ~40 de build CUDA). Le variant est choisi selon la machine (CUDA Windows avec selection de la version selon le pilote + cudart, Vulkan, Metal, ROCm, CPU), extrait dans backends/llama.cpp-prebuilt et BIN repointe dessus. - backend_prebuilt.go : detection release/variant, telechargement avec progression, extraction zip/tar.gz anti zip-slip, marqueur VERSION - UI : bouton 'binaires precompiles' + 'compiler (sources)', le statut distingue le mode actif (precompile vs build local), 'verifier les maj' compare a la derniere release officielle en mode precompile - CLI : 'jean llamacpp prebuilt' - le build local reste sur le disque : bascule possible via l'editeur de preset Limites : builds generiques (pas de tuning natif) ; pas de build CUDA officiel Linux (variant Vulkan) ; la compilation locale reste requise pour les forks. --- internal/jean/backend_llamacpp.go | 16 +- internal/jean/backend_prebuilt.go | 451 +++++++++++++++++++++++++ internal/jean/ui/index.html | 45 ++- internal/jean/ui/src/index.tmpl.html | 3 +- internal/jean/ui/src/js/10-llamacpp.js | 42 ++- internal/jean/web_llamacpp.go | 75 ++++ internal/jean/web_server.go | 12 +- 7 files changed, 628 insertions(+), 16 deletions(-) create mode 100644 internal/jean/backend_prebuilt.go diff --git a/internal/jean/backend_llamacpp.go b/internal/jean/backend_llamacpp.go index 43e7828..3576774 100644 --- a/internal/jean/backend_llamacpp.go +++ b/internal/jean/backend_llamacpp.go @@ -43,8 +43,22 @@ func cmdLlamacpp(args []string) error { return llamacppUpdate(args) case "status", "info", "": return llamacppStatus(args) + case "prebuilt": + // Binaires officiels précompilés (aucune compilation) — voir backend_prebuilt.go. + bin, err := prebuiltInstall( + func(s string) { fmt.Println(" " + s) }, + func(s string) { fmt.Printf("%s %s\n", cyan("▶"), s) }, + ) + if err != nil { + return err + } + if err := SetConfigKey("BIN", bin); err != nil { + return fmt.Errorf("binaires installés mais échec écriture BIN dans config.env: %w", err) + } + fmt.Printf("%s BIN mis à jour dans %s — %s pour appliquer\n", green("✓"), confPath(), bold("jean restart")) + return nil default: - return fmt.Errorf("sous-commande inconnue: %s (install | update | status)", sub) + return fmt.Errorf("sous-commande inconnue: %s (install | update | prebuilt | status)", sub) } } diff --git a/internal/jean/backend_prebuilt.go b/internal/jean/backend_prebuilt.go new file mode 100644 index 0000000..ef877ea --- /dev/null +++ b/internal/jean/backend_prebuilt.go @@ -0,0 +1,451 @@ +// backend_prebuilt.go — backend llama.cpp SANS compilation : télécharge les +// binaires officiels précompilés publiés à chaque release de ggml-org/llama.cpp +// (zip Windows, tar.gz macOS/Linux), choisit l'asset adapté à la machine +// (CUDA / ROCm / Vulkan / CPU), l'extrait dans backends/llama.cpp-prebuilt et +// pointe BIN dessus. ~2 minutes au lieu d'une compilation complète. +// +// Limites assumées : builds génériques (pas de tuning natif), et pas de build +// CUDA officiel pour Linux (on retombe sur Vulkan — la compilation locale +// reste la voie CUDA sous Linux). La compilation locale reste indispensable +// pour les forks (ex. PrismML). +package jean + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" +) + +const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest" + +type ghAsset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +func prebuiltDir() string { + return filepath.Join(JeanHome(), "backends", "llama.cpp-prebuilt") +} + +// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer" +// (cudaVer vide hors CUDA Windows). +func prebuiltVersion() (tag, cudaVer string) { + b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION")) + if err != nil { + return "", "" + } + f := strings.Fields(strings.TrimSpace(string(b))) + if len(f) > 0 { + tag = f[0] + } + if len(f) > 1 { + cudaVer = f[1] + } + return +} + +// prebuiltServerBin localise llama-server(.exe) sous le dossier prebuilt +// (l'arborescence interne des archives officielles varie : racine, build/bin…). +func prebuiltServerBin() string { + want := "llama-server" + if runtime.GOOS == "windows" { + want += ".exe" + } + var found string + _ = filepath.WalkDir(prebuiltDir(), func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if d.Name() == want { + found = p + return filepath.SkipAll + } + return nil + }) + return found +} + +// fetchLlamaLatest interroge l'API GitHub pour la dernière release officielle. +func fetchLlamaLatest() (string, []ghAsset, error) { + req, err := http.NewRequest("GET", llamaReleasesAPI, nil) + if err != nil { + return "", nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", nil, fmt.Errorf("GitHub API : HTTP %d", resp.StatusCode) + } + var rel struct { + TagName string `json:"tag_name"` + Assets []ghAsset `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return "", nil, err + } + if rel.TagName == "" { + return "", nil, fmt.Errorf("release invalide (tag vide)") + } + return rel.TagName, rel.Assets, nil +} + +// driverCudaVersion renvoie la version CUDA max supportée par le pilote NVIDIA +// (bandeau de nvidia-smi : « CUDA Version: 12.8 »), ou 0 si inconnue. +func driverCudaVersion() float64 { + out, err := hideCmd(exec.Command("nvidia-smi")).Output() + if err != nil { + return 0 + } + m := regexp.MustCompile(`CUDA Version:\s*([0-9]+\.[0-9]+)`).FindSubmatch(out) + if m == nil { + return 0 + } + v, _ := strconv.ParseFloat(string(m[1]), 64) + return v +} + +// assetMatch garde les assets dont le nom contient TOUS les fragments. +func assetMatch(assets []ghAsset, frags ...string) []ghAsset { + var out []ghAsset + for _, a := range assets { + ok := true + for _, f := range frags { + if !strings.Contains(a.Name, f) { + ok = false + break + } + } + if ok { + out = append(out, a) + } + } + return out +} + +var reCudaAssetVer = regexp.MustCompile(`cuda-([0-9]+\.[0-9]+)`) + +// pickPrebuilt choisit l'asset principal (+ cudart pour CUDA Windows) adapté à +// la machine, et renvoie un label lisible du variant retenu. +func pickPrebuilt(assets []ghAsset) (main *ghAsset, cudart *ghAsset, label, cudaVer string, err error) { + pickOne := func(list []ghAsset) *ghAsset { + if len(list) == 0 { + return nil + } + return &list[0] + } + switch runtime.GOOS { + case "windows": + if runtime.GOARCH == "arm64" { + main = pickOne(assetMatch(assets, "llama-", "bin-win-cpu-arm64")) + label = "CPU (Windows arm64)" + break + } + if hasNvidiaGPU() { + // Plusieurs versions CUDA publiées (ex. 12.4 et 13.3) : on prend la plus + // haute supportée par le pilote (sinon la plus basse, la plus compatible). + // NB : les archives cudart-llama-bin-win-cuda-… contiennent aussi ces + // fragments — on les écarte explicitement du choix du binaire principal. + var cand []ghAsset + for _, a := range assetMatch(assets, "llama-", "bin-win-cuda-", "-x64.zip") { + if !strings.HasPrefix(a.Name, "cudart") { + cand = append(cand, a) + } + } + maxV := driverCudaVersion() + var best *ghAsset + bestV := 0.0 + for i := range cand { + m := reCudaAssetVer.FindStringSubmatch(cand[i].Name) + if m == nil { + continue + } + v, _ := strconv.ParseFloat(m[1], 64) + ok := maxV == 0 && (best == nil || v < bestV) || // pilote inconnu → la plus basse + maxV > 0 && v <= maxV && v > bestV // sinon la plus haute compatible + if ok { + best = &cand[i] + bestV = v + cudaVer = m[1] + } + } + if best != nil { + main = best + cudart = pickOne(assetMatch(assets, "cudart-", "win-cuda-"+cudaVer)) + label = "CUDA " + cudaVer + " (Windows x64)" + break + } + } + if a := pickOne(assetMatch(assets, "llama-", "bin-win-vulkan-x64")); a != nil { + main, label = a, "Vulkan (Windows x64)" + break + } + main = pickOne(assetMatch(assets, "llama-", "bin-win-cpu-x64")) + label = "CPU (Windows x64)" + case "darwin": + arch := "x64" + if runtime.GOARCH == "arm64" { + arch = "arm64" + } + main = pickOne(assetMatch(assets, "llama-", "bin-macos-"+arch)) + label = "Metal (macOS " + arch + ")" + default: // linux + arch := "x64" + if runtime.GOARCH == "arm64" { + arch = "arm64" + } + if hasTool("hipcc") || isDir("/opt/rocm") { + if a := pickOne(assetMatch(assets, "llama-", "bin-ubuntu-rocm-", arch)); a != nil { + main, label = a, "ROCm (Linux "+arch+")" + break + } + } + // Pas de build CUDA officiel pour Linux : sur GPU NVIDIA le variant Vulkan + // fonctionne via le pilote (moins optimal que le build CUDA local). + if hasNvidiaGPU() || hasTool("vulkaninfo") { + if a := pickOne(assetMatch(assets, "llama-", "bin-ubuntu-vulkan-"+arch)); a != nil { + main, label = a, "Vulkan (Linux "+arch+")" + if hasNvidiaGPU() { + label += " — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif" + } + break + } + } + main = pickOne(assetMatch(assets, "llama-", "bin-ubuntu-"+arch+".tar.gz")) + label = "CPU (Linux " + arch + ")" + } + if main == nil { + return nil, nil, "", "", fmt.Errorf("aucun binaire précompilé adapté à cette machine dans la release officielle") + } + return main, cudart, label, cudaVer, nil +} + +// prebuiltInstall télécharge et installe (ou met à jour) les binaires +// précompilés. logf reçoit chaque ligne de log ; phasef la phase courante. +// Renvoie le chemin du binaire installé. +func prebuiltInstall(logf, phasef func(string)) (string, error) { + phasef("récupération de la dernière release llama.cpp…") + tag, assets, err := fetchLlamaLatest() + if err != nil { + return "", fmt.Errorf("impossible d'interroger les releases llama.cpp : %w", err) + } + curTag, curCuda := prebuiltVersion() + main, cudart, label, cudaVer, err := pickPrebuilt(assets) + if err != nil { + return "", err + } + logf(fmt.Sprintf("release %s — variant retenu : %s", tag, label)) + + if curTag == tag && prebuiltServerBin() != "" { + logf("déjà à jour (" + tag + ")") + return prebuiltServerBin(), nil + } + + dir := prebuiltDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + + // cudart (DLLs runtime CUDA, ~400 Mo) : seulement si absent ou si la version + // CUDA du variant a changé depuis la dernière installation. + if cudart != nil { + haveDLL, _ := filepath.Glob(filepath.Join(dir, "**", "cudart64*.dll")) + if len(haveDLL) == 0 { + haveDLL, _ = filepath.Glob(filepath.Join(dir, "cudart64*.dll")) + } + if len(haveDLL) > 0 && curCuda == cudaVer { + logf("cudart " + cudaVer + " déjà présent — téléchargement évité") + cudart = nil + } + } + + for _, a := range []*ghAsset{main, cudart} { + if a == nil { + continue + } + phasef(fmt.Sprintf("téléchargement de %s (%d Mo)…", a.Name, a.Size/1_000_000)) + tmp := filepath.Join(dir, a.Name+".part") + if err := downloadWithProgress(a.URL, tmp, a.Size, logf); err != nil { + _ = os.Remove(tmp) + return "", fmt.Errorf("téléchargement de %s : %w", a.Name, err) + } + phasef("extraction de " + a.Name + "…") + if err := extractArchive(tmp, dir); err != nil { + _ = os.Remove(tmp) + return "", fmt.Errorf("extraction de %s : %w", a.Name, err) + } + _ = os.Remove(tmp) + } + + bin := prebuiltServerBin() + if bin == "" { + return "", fmt.Errorf("archives extraites mais llama-server introuvable sous %s", dir) + } + if runtime.GOOS != "windows" { + _ = os.Chmod(bin, 0o755) + } + if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte(tag+" "+cudaVer+"\n"), 0o644); err != nil { + return "", err + } + logf("binaire installé : " + bin + " (release " + tag + ")") + return bin, nil +} + +// downloadWithProgress télécharge url vers dest en journalisant la progression +// par tranches de ~25 Mo. +func downloadWithProgress(url, dest string, total int64, logf func(string)) error { + client := &http.Client{Timeout: 0} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + if total <= 0 { + total = resp.ContentLength + } + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + buf := make([]byte, 1<<20) + var done, lastLog int64 + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := f.Write(buf[:n]); werr != nil { + return werr + } + done += int64(n) + if done-lastLog >= 25<<20 { + lastLog = done + if total > 0 { + logf(fmt.Sprintf("⬇ %d / %d Mo (%d%%)", done/1_000_000, total/1_000_000, done*100/total)) + } else { + logf(fmt.Sprintf("⬇ %d Mo", done/1_000_000)) + } + } + } + if rerr == io.EOF { + return nil + } + if rerr != nil { + return rerr + } + } +} + +// extractArchive extrait un .zip ou un .tar.gz dans dir, en refusant toute +// entrée qui s'échapperait du dossier (zip-slip). +func extractArchive(path, dir string) error { + safe := func(name string) (string, error) { + p := filepath.Join(dir, filepath.FromSlash(name)) + if rel, err := filepath.Rel(dir, p); err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("entrée d'archive suspecte : %s", name) + } + return p, nil + } + if strings.HasSuffix(path, ".zip") || strings.HasSuffix(path, ".zip.part") { + zr, err := zip.OpenReader(path) + if err != nil { + return err + } + defer zr.Close() + for _, f := range zr.File { + p, err := safe(f.Name) + if err != nil { + return err + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(p, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + w, err := os.Create(p) + if err != nil { + rc.Close() + return err + } + _, err = io.Copy(w, rc) + rc.Close() + w.Close() + if err != nil { + return err + } + } + return nil + } + // tar.gz + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + p, err := safe(h.Name) + if err != nil { + return err + } + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(p, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + w, err := os.Create(p) + if err != nil { + return err + } + if _, err := io.Copy(w, tr); err != nil { + w.Close() + return err + } + w.Close() + _ = os.Chmod(p, os.FileMode(h.Mode)&0o777) + } + } +} diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 5874b93..efbf4c4 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -397,7 +397,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
    Backend llama.cpp
    …
    - + + @@ -1624,11 +1625,20 @@ async function loadLlamacpp(){ const p = s.plan; parts.push('accélérateur détecté : '+p.backend.toUpperCase()+''+(p.arch?' (arch '+p.arch+')':'')+' · '+p.jobs+' jobs'); } + // Mode actif : binaires officiels précompilés vs build local. + const pb = s.prebuilt || {}; + if(pb.in_use){ + parts.push('mode : ⚡ binaires précompilés officiels'+(pb.tag?' ('+pb.tag+')':'')); + lcBadge('précompilé'); + } else if(s.installed && s.in_use){ + parts.push('mode : 🔧 compilé depuis les sources'+(pb.tag?' (précompilé '+pb.tag+' aussi installé)':'')); + } st.innerHTML = parts.join('
    '); document.getElementById('lc-install').style.display = s.installed ? 'none' : ''; - document.getElementById('lc-check').style.display = s.installed ? '' : 'none'; - document.getElementById('lc-update').style.display = (s.installed && (s.behind>0 || !s.bin)) ? '' : 'none'; - document.getElementById('lc-rebuild').style.display = s.installed ? '' : 'none'; + document.getElementById('lc-check').style.display = (s.installed || pb.in_use) ? '' : 'none'; + document.getElementById('lc-update').style.display = (s.installed && !pb.in_use && (s.behind>0 || !s.bin)) ? '' : 'none'; + document.getElementById('lc-rebuild').style.display = (s.installed && !pb.in_use) ? '' : 'none'; + document.getElementById('lc-prebuilt').textContent = pb.in_use ? '⚡ maj binaires précompilés' : '⚡ binaires précompilés'; // Job en cours (page rechargée pendant un build) → on raccroche le polling. if(s.job && s.job.exists && s.job.running && !lcPoll){ document.getElementById('lc-details').open = true; @@ -1640,6 +1650,20 @@ async function lcCheck(){ const btn = document.getElementById('lc-check'); const msg = document.getElementById('lc-check-msg'); btn.disabled = true; btn.textContent = '⏳ vérification…'; + // Mode précompilé : on compare à la dernière release officielle, pas au git. + if(lcState && lcState.prebuilt && lcState.prebuilt.in_use){ + try{ + const r = await jpost('/api/llamacpp/prebuilt/check'); + if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } + if(r.update){ + msg.innerHTML = 'nouvelle release officielle : '+r.latest+' (installée : '+(r.current||'aucune')+')
    '+String(r.variant||'').replace(/[<>&]/g,'')+' · ~'+r.size_mb+' Mo'; + lcBadge('maj dispo'); + } else { + msg.innerHTML = '✓ à jour ('+r.current+')'; + } + } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } + return; + } try{ const r = await jpost('/api/llamacpp/check'); if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } @@ -1663,6 +1687,17 @@ async function lcInstall(){ lcStartPolling(); } +async function lcPrebuilt(){ + const inUse = lcState && lcState.prebuilt && lcState.prebuilt.in_use; + const msg = inUse + ? 'Télécharger la dernière release officielle précompilée de llama.cpp (~2 min, aucune compilation). Le service sera arrêté pendant le remplacement puis redémarré.' + : 'Télécharger les binaires OFFICIELS précompilés de llama.cpp au lieu de compiler (~2 min). Le variant est choisi automatiquement (CUDA / Vulkan / Metal / CPU) et BIN pointera dessus. Le build local, s\'il existe, reste sur le disque — tu peux y revenir via l\'éditeur de preset. Note : builds génériques (pas de tuning natif) ; sous Linux il n\'existe pas de build CUDA officiel (variant Vulkan).'; + if(!await askConfirm(msg, {title: inUse ? 'Mettre à jour les binaires précompilés ?' : 'Passer aux binaires précompilés ?', okText: inUse ? 'Mettre à jour' : 'Télécharger'})) return; + const r = await jpost('/api/llamacpp/prebuilt', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + async function lcUpdate(clean){ const msg = clean ? 'Recompiler llama.cpp from scratch (build/ supprimé). Le service sera arrêté pendant le build puis redémarré.' @@ -1674,7 +1709,7 @@ async function lcUpdate(clean){ } function lcSetButtons(disabled){ - ['lc-install','lc-check','lc-update','lc-rebuild'].forEach(id=>{ + ['lc-install','lc-check','lc-update','lc-rebuild','lc-prebuilt'].forEach(id=>{ const b=document.getElementById(id); if(b) b.disabled = disabled; }); } diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index f00ed94..0b12bd2 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -119,7 +119,8 @@
    Backend llama.cpp
    …
    - + + diff --git a/internal/jean/ui/src/js/10-llamacpp.js b/internal/jean/ui/src/js/10-llamacpp.js index 54520c8..02f30e0 100644 --- a/internal/jean/ui/src/js/10-llamacpp.js +++ b/internal/jean/ui/src/js/10-llamacpp.js @@ -31,11 +31,20 @@ async function loadLlamacpp(){ const p = s.plan; parts.push('accélérateur détecté : '+p.backend.toUpperCase()+''+(p.arch?' (arch '+p.arch+')':'')+' · '+p.jobs+' jobs'); } + // Mode actif : binaires officiels précompilés vs build local. + const pb = s.prebuilt || {}; + if(pb.in_use){ + parts.push('mode : ⚡ binaires précompilés officiels'+(pb.tag?' ('+pb.tag+')':'')); + lcBadge('précompilé'); + } else if(s.installed && s.in_use){ + parts.push('mode : 🔧 compilé depuis les sources'+(pb.tag?' (précompilé '+pb.tag+' aussi installé)':'')); + } st.innerHTML = parts.join('
    '); document.getElementById('lc-install').style.display = s.installed ? 'none' : ''; - document.getElementById('lc-check').style.display = s.installed ? '' : 'none'; - document.getElementById('lc-update').style.display = (s.installed && (s.behind>0 || !s.bin)) ? '' : 'none'; - document.getElementById('lc-rebuild').style.display = s.installed ? '' : 'none'; + document.getElementById('lc-check').style.display = (s.installed || pb.in_use) ? '' : 'none'; + document.getElementById('lc-update').style.display = (s.installed && !pb.in_use && (s.behind>0 || !s.bin)) ? '' : 'none'; + document.getElementById('lc-rebuild').style.display = (s.installed && !pb.in_use) ? '' : 'none'; + document.getElementById('lc-prebuilt').textContent = pb.in_use ? '⚡ maj binaires précompilés' : '⚡ binaires précompilés'; // Job en cours (page rechargée pendant un build) → on raccroche le polling. if(s.job && s.job.exists && s.job.running && !lcPoll){ document.getElementById('lc-details').open = true; @@ -47,6 +56,20 @@ async function lcCheck(){ const btn = document.getElementById('lc-check'); const msg = document.getElementById('lc-check-msg'); btn.disabled = true; btn.textContent = '⏳ vérification…'; + // Mode précompilé : on compare à la dernière release officielle, pas au git. + if(lcState && lcState.prebuilt && lcState.prebuilt.in_use){ + try{ + const r = await jpost('/api/llamacpp/prebuilt/check'); + if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } + if(r.update){ + msg.innerHTML = 'nouvelle release officielle : '+r.latest+' (installée : '+(r.current||'aucune')+')
    '+String(r.variant||'').replace(/[<>&]/g,'')+' · ~'+r.size_mb+' Mo'; + lcBadge('maj dispo'); + } else { + msg.innerHTML = '✓ à jour ('+r.current+')'; + } + } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } + return; + } try{ const r = await jpost('/api/llamacpp/check'); if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } @@ -70,6 +93,17 @@ async function lcInstall(){ lcStartPolling(); } +async function lcPrebuilt(){ + const inUse = lcState && lcState.prebuilt && lcState.prebuilt.in_use; + const msg = inUse + ? 'Télécharger la dernière release officielle précompilée de llama.cpp (~2 min, aucune compilation). Le service sera arrêté pendant le remplacement puis redémarré.' + : 'Télécharger les binaires OFFICIELS précompilés de llama.cpp au lieu de compiler (~2 min). Le variant est choisi automatiquement (CUDA / Vulkan / Metal / CPU) et BIN pointera dessus. Le build local, s\'il existe, reste sur le disque — tu peux y revenir via l\'éditeur de preset. Note : builds génériques (pas de tuning natif) ; sous Linux il n\'existe pas de build CUDA officiel (variant Vulkan).'; + if(!await askConfirm(msg, {title: inUse ? 'Mettre à jour les binaires précompilés ?' : 'Passer aux binaires précompilés ?', okText: inUse ? 'Mettre à jour' : 'Télécharger'})) return; + const r = await jpost('/api/llamacpp/prebuilt', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + async function lcUpdate(clean){ const msg = clean ? 'Recompiler llama.cpp from scratch (build/ supprimé). Le service sera arrêté pendant le build puis redémarré.' @@ -81,7 +115,7 @@ async function lcUpdate(clean){ } function lcSetButtons(disabled){ - ['lc-install','lc-check','lc-update','lc-rebuild'].forEach(id=>{ + ['lc-install','lc-check','lc-update','lc-rebuild','lc-prebuilt'].forEach(id=>{ const b=document.getElementById(id); if(b) b.disabled = disabled; }); } diff --git a/internal/jean/web_llamacpp.go b/internal/jean/web_llamacpp.go index 0224fa5..270d8f0 100644 --- a/internal/jean/web_llamacpp.go +++ b/internal/jean/web_llamacpp.go @@ -158,6 +158,14 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) { "arch": plan.cudaArch, "jobs": plan.jobs, } + // Mode « binaires précompilés » : installé ? utilisé par la config ? + pbTag, _ := prebuiltVersion() + pbBin := prebuiltServerBin() + out["prebuilt"] = map[string]any{ + "tag": pbTag, + "bin": pbBin, + "in_use": pbBin != "" && samePath(pbBin, cfgBin), + } out["job"] = lcJobSnapshot(0, false) sendJSON(w, 200, out) } @@ -238,6 +246,73 @@ func handleLlamacppUpdate(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true}) } +// handleLlamacppPrebuiltCheck interroge la dernière release officielle de +// llama.cpp et la compare à la version précompilée installée. Synchrone. +func handleLlamacppPrebuiltCheck(w http.ResponseWriter, r *http.Request) { + tag, assets, err := fetchLlamaLatest() + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + main, _, label, _, err := pickPrebuilt(assets) + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + cur, _ := prebuiltVersion() + sendJSON(w, 200, map[string]any{ + "ok": true, + "latest": tag, + "current": cur, + "variant": label, + "size_mb": main.Size / 1_000_000, + "update": cur != tag || prebuiltServerBin() == "", + }) +} + +// handleLlamacppPrebuilt lance le job de téléchargement / mise à jour des +// binaires précompilés officiels (pas de compilation). +func handleLlamacppPrebuilt(w http.ResponseWriter, r *http.Request) { + if err := startLcJob("prebuilt", lcRunPrebuilt); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// lcRunPrebuilt : télécharge les binaires officiels, pointe BIN dessus, en +// stoppant le service pendant le remplacement (binaire verrouillé en cours +// d'exécution) puis en le relançant. +func lcRunPrebuilt() { + svcWasUp := serviceIsActive() + if svcWasUp { + lcPhase("arrêt du service le temps de l'installation…") + if err := serviceAction("stop"); err != nil { + lcAppend("[warn] impossible d'arrêter le service : " + err.Error()) + } + } + bin, err := prebuiltInstall(lcAppend, lcPhase) + if err == nil { + if serr := SetConfigKey("BIN", bin); serr != nil { + err = fmt.Errorf("binaires installés mais échec écriture BIN dans config.env : %w", serr) + } else { + lcAppend("BIN mis à jour dans " + confPath()) + } + } + if svcWasUp { + lcPhase("redémarrage du service…") + if serr := serviceAction("start"); serr != nil { + lcAppend("[warn] redémarrage du service échoué : " + serr.Error()) + } + } + if err != nil { + lcFail(err) + return + } + tag, _ := prebuiltVersion() + lcDone("binaires précompilés installés (" + tag + ")") +} + // handleLlamacppJob renvoie l'état du job courant + les lignes de log depuis // l'offset absolu ?from=N (le client mémorise `next` et enchaîne). func handleLlamacppJob(w http.ResponseWriter, r *http.Request) { diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index 8209c35..be5125d 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -88,11 +88,13 @@ func newWebMux() *http.ServeMux { api("/api/models/download", handleModelDownload) api("/api/models/download/status", handleModelDownloadStatus) api("/api/backends", handleBackends) - api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp - api("/api/llamacpp/check", handleLlamacppCheck) // git fetch + retard sur origin - api("/api/llamacpp/install", handleLlamacppInstall) // job : clone + build + BIN - api("/api/llamacpp/update", handleLlamacppUpdate) // job : pull + rebuild + restart - api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job + api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp + api("/api/llamacpp/check", handleLlamacppCheck) // git fetch + retard sur origin + api("/api/llamacpp/install", handleLlamacppInstall) // job : clone + build + BIN + api("/api/llamacpp/update", handleLlamacppUpdate) // job : pull + rebuild + restart + api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job + api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés + api("/api/llamacpp/prebuilt/check", handleLlamacppPrebuiltCheck) // dernière release officielle vs installée api("/api/presets", handlePresets) api("/api/preset", handlePreset) api("/api/preset/save", handlePresetSave) From b0c1503ed8ff4075cc812ee8fc18ac7b6ec5b770 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 22 Jul 2026 19:22:34 +0200 Subject: [PATCH 120/279] v0.4.9 : backend llama.cpp precompile (binaires officiels, plus besoin de recompiler) --- internal/jean/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/jean/run.go b/internal/jean/run.go index 670e8c3..b1b73c0 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.4.8" +const Version = "0.4.9" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { From ff045ba21e730a6df3da6f37dbd80889bb961981 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 22 Jul 2026 19:54:48 +0200 Subject: [PATCH 121/279] UI backend llama.cpp : vue simplifiee (2 modes rapide/optimisee, plus de badge, sans jargon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte de la section pour la rendre utilisable sans connaissances techniques : - suppression du badge a cote du titre et de tous les details (commit, retard git, arch CUDA, nombre de jobs, chemins) ; une seule ligne 'Moteur actif : …' - deux cartes claires : Version rapide (precompile, conseillee) / Version optimisee (compilee) ; un clic installe, active ou bascule - bascule instantanee entre deux versions deja installees (nouvel endpoint /api/llamacpp/use : repointe BIN + redemarre, sans recompiler) - carte active : '✓ Actif' + 'mettre a jour' ; progression et logs replies sous 'details' --- internal/jean/ui/index.html | 235 ++++++++++++------------- internal/jean/ui/src/index.tmpl.html | 26 +-- internal/jean/ui/src/js/10-llamacpp.js | 193 +++++++++----------- internal/jean/ui/src/styles.css | 16 +- internal/jean/web_llamacpp.go | 33 ++++ internal/jean/web_server.go | 1 + 6 files changed, 260 insertions(+), 244 deletions(-) diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index efbf4c4..a79283a 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -290,7 +290,21 @@ button:disabled{opacity:.5;cursor:not-allowed} h2,details>summary{font-size:10px} #menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))} } -/* --- Backend llama.cpp (gestion install/update depuis l'UI) --------------- */ +/* --- Backend llama.cpp (2 modes : rapide / optimisée) --------------------- */ +.lc-head{font-size:12px;line-height:1.5;margin-bottom:10px} +.lc-modes{display:flex;flex-direction:column;gap:8px} +.lc-modes.busy{opacity:.5;pointer-events:none} +.lc-mode{border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .12s,background .12s} +.lc-mode:hover{border-color:var(--accent)} +.lc-mode.active{border-color:var(--accent);background:var(--panel);cursor:default} +.lc-mode-t{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px} +.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--accent);border:1px solid var(--accent);border-radius:8px;padding:1px 6px} +.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px} +.lc-mode-state{font-size:11px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.lc-mode-go{color:var(--accent)} +.lc-mode-active-tag{color:var(--accent);font-weight:600} +.lc-update-link{color:var(--dim);text-decoration:underline;cursor:pointer} +.lc-update-link:hover{color:var(--accent)} .lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:6px 0;margin-top:4px} .lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} .lc-logbar.open #lc-log-caret{transform:rotate(90deg)} @@ -394,19 +408,23 @@ button:disabled{opacity:.5;cursor:not-allowed}
    -
    Backend llama.cpp -
    …
    -
    - - - - - +
    Backend llama.cpp +
    …
    +
    +
    +
    ⚡ Version rapide conseillé
    +
    prête à l'emploi, installée en ~2 min
    +
    +
    +
    +
    🔧 Version optimisée
    +
    compilée pour ta machine, plus longue
    +
    +
    -
    -
    @@ -1592,135 +1610,111 @@ async function send(){ loadAll(); setInterval(loadStatus, 5000); setInterval(loadVram, 3000); -// Backend llama.cpp : statut, vérification des maj, install / update / rebuild -// en job asynchrone côté serveur (logs + progression pollés sur /api/llamacpp/job). +// Backend llama.cpp — vue simple : deux modes au choix. +// ⚡ Version rapide = binaires officiels précompilés (aucune compilation) +// 🔧 Version optimisée = compilée pour la machine +// Un clic sur une carte l'installe (ou l'active si déjà installée). La carte +// active propose « mettre à jour ». Aucun détail technique affiché. let lcState = null, lcPoll = null, lcLogNext = 0; -function lcBadge(html){ document.getElementById('lc-badge').innerHTML = html; } - async function loadLlamacpp(){ let s; try{ s = await jget('/api/llamacpp'); }catch(_){ return; } lcState = s; - const st = document.getElementById('lc-status'); - const parts = []; - if(!s.installed){ - parts.push('llama.cpp n\'est pas encore installé sur cette machine.'); - parts.push('L\'installation clone le dépôt, détecte le GPU et compile llama-server automatiquement.'); - lcBadge('absent'); - } else { - const esc = t=>String(t||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c])); - parts.push('commit '+esc(s.commit)+''+(s.branch?' ('+esc(s.branch)+')':'')); - if(s.commit_date) parts.push(''+esc(String(s.commit_date).slice(0,16))+' — '+esc(s.commit_msg)); - if(s.bin){ - parts.push('binaire : ✓ compilé'+(s.in_use?' · utilisé par la config':' · non utilisé par la config (BIN pointe ailleurs)')); - } else { - parts.push('binaire : absent (pas encore compilé)'); - } - if(s.behind > 0) lcBadge('maj dispo'); - else if(s.bin) lcBadge('ok'); - else lcBadge('à compiler'); - } - if(s.plan){ - const p = s.plan; - parts.push('accélérateur détecté : '+p.backend.toUpperCase()+''+(p.arch?' (arch '+p.arch+')':'')+' · '+p.jobs+' jobs'); - } - // Mode actif : binaires officiels précompilés vs build local. const pb = s.prebuilt || {}; - if(pb.in_use){ - parts.push('mode : ⚡ binaires précompilés officiels'+(pb.tag?' ('+pb.tag+')':'')); - lcBadge('précompilé'); - } else if(s.installed && s.in_use){ - parts.push('mode : 🔧 compilé depuis les sources'+(pb.tag?' (précompilé '+pb.tag+' aussi installé)':'')); + const fastActive = !!pb.in_use; + const optActive = s.installed && s.in_use && !pb.in_use; + + const head = document.getElementById('lc-status'); + if(!fastActive && !optActive){ + head.innerHTML = 'Le moteur d\'IA n\'est pas encore installé. Choisis une version ci-dessous — la rapide convient à presque tout le monde.'; + } else if(fastActive){ + head.innerHTML = 'Moteur actif : version rapide.'; + } else { + head.innerHTML = 'Moteur actif : version optimisée.'; } - st.innerHTML = parts.join('
    '); - document.getElementById('lc-install').style.display = s.installed ? 'none' : ''; - document.getElementById('lc-check').style.display = (s.installed || pb.in_use) ? '' : 'none'; - document.getElementById('lc-update').style.display = (s.installed && !pb.in_use && (s.behind>0 || !s.bin)) ? '' : 'none'; - document.getElementById('lc-rebuild').style.display = (s.installed && !pb.in_use) ? '' : 'none'; - document.getElementById('lc-prebuilt').textContent = pb.in_use ? '⚡ maj binaires précompilés' : '⚡ binaires précompilés'; - // Job en cours (page rechargée pendant un build) → on raccroche le polling. + lcRenderMode('fast', fastActive, !!pb.bin); + lcRenderMode('opt', optActive, !!s.bin); + + // Job en cours (page rechargée pendant une install) → on raccroche l'affichage. if(s.job && s.job.exists && s.job.running && !lcPoll){ document.getElementById('lc-details').open = true; lcStartPolling(); } } -async function lcCheck(){ - const btn = document.getElementById('lc-check'); - const msg = document.getElementById('lc-check-msg'); - btn.disabled = true; btn.textContent = '⏳ vérification…'; - // Mode précompilé : on compare à la dernière release officielle, pas au git. - if(lcState && lcState.prebuilt && lcState.prebuilt.in_use){ - try{ - const r = await jpost('/api/llamacpp/prebuilt/check'); - if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } - if(r.update){ - msg.innerHTML = 'nouvelle release officielle : '+r.latest+' (installée : '+(r.current||'aucune')+')
    '+String(r.variant||'').replace(/[<>&]/g,'')+' · ~'+r.size_mb+' Mo'; - lcBadge('maj dispo'); - } else { - msg.innerHTML = '✓ à jour ('+r.current+')'; - } - } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } - return; +function lcRenderMode(mode, active, installed){ + const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); + const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); + card.classList.toggle('active', active); + if(active){ + state.innerHTML = '✓ Actif' + + '↻ mettre à jour'; + } else if(installed){ + state.innerHTML = '→ cliquer pour l\'activer'; + } else { + state.innerHTML = '→ cliquer pour installer'; } - try{ - const r = await jpost('/api/llamacpp/check'); - if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } - if(r.behind > 0){ - msg.innerHTML = ''+r.behind+' commit(s) de retard sur origin/'+r.branch+'
    '+ - 'dernier distant : '+r.remote+' — '+String(r.remote_msg||'').replace(/[<>&]/g,''); - document.getElementById('lc-update').style.display = ''; - lcBadge('maj dispo'); - } else if(!r.has_binary){ - msg.innerHTML = 'sources à jour mais binaire absent — lance une recompilation.'; - } else { - msg.innerHTML = '✓ à jour ('+r.local+')'; +} + +// Clic sur une carte : activer / installer le mode correspondant. +async function lcPick(mode){ + const s = lcState || {}, pb = s.prebuilt || {}; + if(mode === 'fast'){ + if(pb.in_use) return; // déjà actif + if(pb.bin){ // déjà téléchargée → simple bascule + if(!await askConfirm('Basculer sur la version rapide (déjà installée) ? Le moteur va redémarrer.', {title:'Version rapide', okText:'Basculer'})) return; + return lcUse('fast'); } - } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } + if(!await askConfirm('Installer la version rapide de llama.cpp : téléchargement prêt à l\'emploi (~2 min, aucune compilation). Le moteur va redémarrer à la fin.', {title:'Version rapide', okText:'Installer'})) return; + const r = await jpost('/api/llamacpp/prebuilt', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); + } else { + if(s.installed && s.in_use && !pb.in_use) return; // déjà actif + if(s.bin){ // déjà compilée → simple bascule + if(!await askConfirm('Basculer sur la version optimisée (déjà compilée) ? Le moteur va redémarrer.', {title:'Version optimisée', okText:'Basculer'})) return; + return lcUse('opt'); + } + if(!await askConfirm('Compiler la version optimisée pour ta machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA). Le moteur va redémarrer à la fin.', {title:'Version optimisée', okText:'Compiler'})) return; + const r = await jpost('/api/llamacpp/install', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); + } } -async function lcInstall(){ - if(!await askConfirm('Cloner llama.cpp puis compiler llama-server pour cette machine (détection GPU automatique). La compilation peut prendre de longues minutes (surtout en CUDA).', {title:'Installer llama.cpp ?', okText:'Installer'})) return; - const r = await jpost('/api/llamacpp/install', {}); +// Bascule instantanée entre deux versions déjà installées (pas de recompilation). +async function lcUse(mode){ + toast('bascule…'); + const r = await jpost('/api/llamacpp/use', {mode}); if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + toast(mode==='fast' ? 'version rapide activée' : 'version optimisée activée'); + setTimeout(loadAll, 1500); +} + +// « Mettre à jour » sur la carte active. +async function lcUpdateActive(){ + const s = lcState || {}, pb = s.prebuilt || {}; + if(pb.in_use){ + if(!await askConfirm('Vérifier et installer la dernière version rapide. Le moteur va redémarrer.', {title:'Mettre à jour', okText:'Mettre à jour'})) return; + const r = await jpost('/api/llamacpp/prebuilt', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + } else { + if(!await askConfirm('Vérifier et installer la dernière version optimisée (recompilation si besoin). Le moteur va redémarrer.', {title:'Mettre à jour', okText:'Mettre à jour'})) return; + const r = await jpost('/api/llamacpp/update', {clean:false}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + } lcStartPolling(); } -async function lcPrebuilt(){ - const inUse = lcState && lcState.prebuilt && lcState.prebuilt.in_use; - const msg = inUse - ? 'Télécharger la dernière release officielle précompilée de llama.cpp (~2 min, aucune compilation). Le service sera arrêté pendant le remplacement puis redémarré.' - : 'Télécharger les binaires OFFICIELS précompilés de llama.cpp au lieu de compiler (~2 min). Le variant est choisi automatiquement (CUDA / Vulkan / Metal / CPU) et BIN pointera dessus. Le build local, s\'il existe, reste sur le disque — tu peux y revenir via l\'éditeur de preset. Note : builds génériques (pas de tuning natif) ; sous Linux il n\'existe pas de build CUDA officiel (variant Vulkan).'; - if(!await askConfirm(msg, {title: inUse ? 'Mettre à jour les binaires précompilés ?' : 'Passer aux binaires précompilés ?', okText: inUse ? 'Mettre à jour' : 'Télécharger'})) return; - const r = await jpost('/api/llamacpp/prebuilt', {}); - if(!r.ok){ toast('erreur : '+(r.error||'')); return; } - lcStartPolling(); -} - -async function lcUpdate(clean){ - const msg = clean - ? 'Recompiler llama.cpp from scratch (build/ supprimé). Le service sera arrêté pendant le build puis redémarré.' - : 'Mettre à jour llama.cpp (git pull) et recompiler. Le service sera arrêté pendant le build puis redémarré.'; - if(!await askConfirm(msg, {title: clean ? 'Recompiler llama.cpp ?' : 'Mettre à jour llama.cpp ?', okText: clean ? 'Recompiler' : 'Mettre à jour'})) return; - const r = await jpost('/api/llamacpp/update', {clean: !!clean}); - if(!r.ok){ toast('erreur : '+(r.error||'')); return; } - lcStartPolling(); -} - -function lcSetButtons(disabled){ - ['lc-install','lc-check','lc-update','lc-rebuild','lc-prebuilt'].forEach(id=>{ - const b=document.getElementById(id); if(b) b.disabled = disabled; - }); -} +// --- Progression de l'installation (téléchargement / compilation) ---------- +function lcBusy(on){ document.querySelector('.lc-modes').classList.toggle('busy', on); } function lcStartPolling(){ - const jobBox = document.getElementById('lc-job'); - jobBox.style.display = ''; + document.getElementById('lc-job').style.display = ''; document.getElementById('lc-log').textContent = ''; lcLogNext = 0; - lcSetButtons(true); - lcBadge('⏳ build'); + lcBusy(true); if(lcPoll) clearInterval(lcPoll); lcPoll = setInterval(lcPollJob, 1000); lcPollJob(); @@ -1731,7 +1725,6 @@ async function lcPollJob(){ try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } if(!j.exists) return; const phaseEl = document.getElementById('lc-job-phase'); - // Logs incrémentaux (offset absolu — le serveur tronque le début si besoin). if(j.lines && j.lines.length){ const pre = document.getElementById('lc-log'); const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; @@ -1743,21 +1736,19 @@ async function lcPollJob(){ phaseEl.innerHTML = '⏳ '+String(j.phase||'…').replace(/[<>&]/g,'')+''; return; } - // Terminé. clearInterval(lcPoll); lcPoll = null; - lcSetButtons(false); + lcBusy(false); if(j.error){ phaseEl.innerHTML = '✗ '+String(j.error).replace(/[<>&]/g,'')+''; - // Ouvre les logs pour que l'erreur réelle soit visible tout de suite. const pre = document.getElementById('lc-log'); if(pre.hasAttribute('hidden')) lcToggleLog(); pre.scrollTop = pre.scrollHeight; - toast('build llama.cpp en échec'); + toast('échec — voir les détails'); } else { phaseEl.innerHTML = '✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+''; - toast('llama.cpp : '+(j.phase||'terminé')); + toast('c\'est prêt ✓'); } - loadAll(); // rafraîchit tout (config, statut service… et ce panneau) + loadAll(); } function lcToggleLog(){ diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 0b12bd2..2e1c628 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -116,19 +116,23 @@
    -
    Backend llama.cpp -
    …
    -
    - - - - - +
    Backend llama.cpp +
    …
    +
    +
    +
    ⚡ Version rapide conseillé
    +
    prête à l'emploi, installée en ~2 min
    +
    +
    +
    +
    🔧 Version optimisée
    +
    compilée pour ta machine, plus longue
    +
    +
    -
    -
    diff --git a/internal/jean/ui/src/js/10-llamacpp.js b/internal/jean/ui/src/js/10-llamacpp.js index 02f30e0..af18b5f 100644 --- a/internal/jean/ui/src/js/10-llamacpp.js +++ b/internal/jean/ui/src/js/10-llamacpp.js @@ -1,132 +1,108 @@ -// Backend llama.cpp : statut, vérification des maj, install / update / rebuild -// en job asynchrone côté serveur (logs + progression pollés sur /api/llamacpp/job). +// Backend llama.cpp — vue simple : deux modes au choix. +// ⚡ Version rapide = binaires officiels précompilés (aucune compilation) +// 🔧 Version optimisée = compilée pour la machine +// Un clic sur une carte l'installe (ou l'active si déjà installée). La carte +// active propose « mettre à jour ». Aucun détail technique affiché. let lcState = null, lcPoll = null, lcLogNext = 0; -function lcBadge(html){ document.getElementById('lc-badge').innerHTML = html; } - async function loadLlamacpp(){ let s; try{ s = await jget('/api/llamacpp'); }catch(_){ return; } lcState = s; - const st = document.getElementById('lc-status'); - const parts = []; - if(!s.installed){ - parts.push('llama.cpp n\'est pas encore installé sur cette machine.'); - parts.push('L\'installation clone le dépôt, détecte le GPU et compile llama-server automatiquement.'); - lcBadge('absent'); - } else { - const esc = t=>String(t||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c])); - parts.push('commit '+esc(s.commit)+''+(s.branch?' ('+esc(s.branch)+')':'')); - if(s.commit_date) parts.push(''+esc(String(s.commit_date).slice(0,16))+' — '+esc(s.commit_msg)); - if(s.bin){ - parts.push('binaire : ✓ compilé'+(s.in_use?' · utilisé par la config':' · non utilisé par la config (BIN pointe ailleurs)')); - } else { - parts.push('binaire : absent (pas encore compilé)'); - } - if(s.behind > 0) lcBadge('maj dispo'); - else if(s.bin) lcBadge('ok'); - else lcBadge('à compiler'); - } - if(s.plan){ - const p = s.plan; - parts.push('accélérateur détecté : '+p.backend.toUpperCase()+''+(p.arch?' (arch '+p.arch+')':'')+' · '+p.jobs+' jobs'); - } - // Mode actif : binaires officiels précompilés vs build local. const pb = s.prebuilt || {}; - if(pb.in_use){ - parts.push('mode : ⚡ binaires précompilés officiels'+(pb.tag?' ('+pb.tag+')':'')); - lcBadge('précompilé'); - } else if(s.installed && s.in_use){ - parts.push('mode : 🔧 compilé depuis les sources'+(pb.tag?' (précompilé '+pb.tag+' aussi installé)':'')); + const fastActive = !!pb.in_use; + const optActive = s.installed && s.in_use && !pb.in_use; + + const head = document.getElementById('lc-status'); + if(!fastActive && !optActive){ + head.innerHTML = 'Le moteur d\'IA n\'est pas encore installé. Choisis une version ci-dessous — la rapide convient à presque tout le monde.'; + } else if(fastActive){ + head.innerHTML = 'Moteur actif : version rapide.'; + } else { + head.innerHTML = 'Moteur actif : version optimisée.'; } - st.innerHTML = parts.join('
    '); - document.getElementById('lc-install').style.display = s.installed ? 'none' : ''; - document.getElementById('lc-check').style.display = (s.installed || pb.in_use) ? '' : 'none'; - document.getElementById('lc-update').style.display = (s.installed && !pb.in_use && (s.behind>0 || !s.bin)) ? '' : 'none'; - document.getElementById('lc-rebuild').style.display = (s.installed && !pb.in_use) ? '' : 'none'; - document.getElementById('lc-prebuilt').textContent = pb.in_use ? '⚡ maj binaires précompilés' : '⚡ binaires précompilés'; - // Job en cours (page rechargée pendant un build) → on raccroche le polling. + lcRenderMode('fast', fastActive, !!pb.bin); + lcRenderMode('opt', optActive, !!s.bin); + + // Job en cours (page rechargée pendant une install) → on raccroche l'affichage. if(s.job && s.job.exists && s.job.running && !lcPoll){ document.getElementById('lc-details').open = true; lcStartPolling(); } } -async function lcCheck(){ - const btn = document.getElementById('lc-check'); - const msg = document.getElementById('lc-check-msg'); - btn.disabled = true; btn.textContent = '⏳ vérification…'; - // Mode précompilé : on compare à la dernière release officielle, pas au git. - if(lcState && lcState.prebuilt && lcState.prebuilt.in_use){ - try{ - const r = await jpost('/api/llamacpp/prebuilt/check'); - if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } - if(r.update){ - msg.innerHTML = 'nouvelle release officielle : '+r.latest+' (installée : '+(r.current||'aucune')+')
    '+String(r.variant||'').replace(/[<>&]/g,'')+' · ~'+r.size_mb+' Mo'; - lcBadge('maj dispo'); - } else { - msg.innerHTML = '✓ à jour ('+r.current+')'; - } - } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } - return; +function lcRenderMode(mode, active, installed){ + const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); + const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); + card.classList.toggle('active', active); + if(active){ + state.innerHTML = '✓ Actif' + + '↻ mettre à jour'; + } else if(installed){ + state.innerHTML = '→ cliquer pour l\'activer'; + } else { + state.innerHTML = '→ cliquer pour installer'; } - try{ - const r = await jpost('/api/llamacpp/check'); - if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } - if(r.behind > 0){ - msg.innerHTML = ''+r.behind+' commit(s) de retard sur origin/'+r.branch+'
    '+ - 'dernier distant : '+r.remote+' — '+String(r.remote_msg||'').replace(/[<>&]/g,''); - document.getElementById('lc-update').style.display = ''; - lcBadge('maj dispo'); - } else if(!r.has_binary){ - msg.innerHTML = 'sources à jour mais binaire absent — lance une recompilation.'; - } else { - msg.innerHTML = '✓ à jour ('+r.local+')'; +} + +// Clic sur une carte : activer / installer le mode correspondant. +async function lcPick(mode){ + const s = lcState || {}, pb = s.prebuilt || {}; + if(mode === 'fast'){ + if(pb.in_use) return; // déjà actif + if(pb.bin){ // déjà téléchargée → simple bascule + if(!await askConfirm('Basculer sur la version rapide (déjà installée) ? Le moteur va redémarrer.', {title:'Version rapide', okText:'Basculer'})) return; + return lcUse('fast'); } - } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } + if(!await askConfirm('Installer la version rapide de llama.cpp : téléchargement prêt à l\'emploi (~2 min, aucune compilation). Le moteur va redémarrer à la fin.', {title:'Version rapide', okText:'Installer'})) return; + const r = await jpost('/api/llamacpp/prebuilt', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); + } else { + if(s.installed && s.in_use && !pb.in_use) return; // déjà actif + if(s.bin){ // déjà compilée → simple bascule + if(!await askConfirm('Basculer sur la version optimisée (déjà compilée) ? Le moteur va redémarrer.', {title:'Version optimisée', okText:'Basculer'})) return; + return lcUse('opt'); + } + if(!await askConfirm('Compiler la version optimisée pour ta machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA). Le moteur va redémarrer à la fin.', {title:'Version optimisée', okText:'Compiler'})) return; + const r = await jpost('/api/llamacpp/install', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); + } } -async function lcInstall(){ - if(!await askConfirm('Cloner llama.cpp puis compiler llama-server pour cette machine (détection GPU automatique). La compilation peut prendre de longues minutes (surtout en CUDA).', {title:'Installer llama.cpp ?', okText:'Installer'})) return; - const r = await jpost('/api/llamacpp/install', {}); +// Bascule instantanée entre deux versions déjà installées (pas de recompilation). +async function lcUse(mode){ + toast('bascule…'); + const r = await jpost('/api/llamacpp/use', {mode}); if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + toast(mode==='fast' ? 'version rapide activée' : 'version optimisée activée'); + setTimeout(loadAll, 1500); +} + +// « Mettre à jour » sur la carte active. +async function lcUpdateActive(){ + const s = lcState || {}, pb = s.prebuilt || {}; + if(pb.in_use){ + if(!await askConfirm('Vérifier et installer la dernière version rapide. Le moteur va redémarrer.', {title:'Mettre à jour', okText:'Mettre à jour'})) return; + const r = await jpost('/api/llamacpp/prebuilt', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + } else { + if(!await askConfirm('Vérifier et installer la dernière version optimisée (recompilation si besoin). Le moteur va redémarrer.', {title:'Mettre à jour', okText:'Mettre à jour'})) return; + const r = await jpost('/api/llamacpp/update', {clean:false}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + } lcStartPolling(); } -async function lcPrebuilt(){ - const inUse = lcState && lcState.prebuilt && lcState.prebuilt.in_use; - const msg = inUse - ? 'Télécharger la dernière release officielle précompilée de llama.cpp (~2 min, aucune compilation). Le service sera arrêté pendant le remplacement puis redémarré.' - : 'Télécharger les binaires OFFICIELS précompilés de llama.cpp au lieu de compiler (~2 min). Le variant est choisi automatiquement (CUDA / Vulkan / Metal / CPU) et BIN pointera dessus. Le build local, s\'il existe, reste sur le disque — tu peux y revenir via l\'éditeur de preset. Note : builds génériques (pas de tuning natif) ; sous Linux il n\'existe pas de build CUDA officiel (variant Vulkan).'; - if(!await askConfirm(msg, {title: inUse ? 'Mettre à jour les binaires précompilés ?' : 'Passer aux binaires précompilés ?', okText: inUse ? 'Mettre à jour' : 'Télécharger'})) return; - const r = await jpost('/api/llamacpp/prebuilt', {}); - if(!r.ok){ toast('erreur : '+(r.error||'')); return; } - lcStartPolling(); -} - -async function lcUpdate(clean){ - const msg = clean - ? 'Recompiler llama.cpp from scratch (build/ supprimé). Le service sera arrêté pendant le build puis redémarré.' - : 'Mettre à jour llama.cpp (git pull) et recompiler. Le service sera arrêté pendant le build puis redémarré.'; - if(!await askConfirm(msg, {title: clean ? 'Recompiler llama.cpp ?' : 'Mettre à jour llama.cpp ?', okText: clean ? 'Recompiler' : 'Mettre à jour'})) return; - const r = await jpost('/api/llamacpp/update', {clean: !!clean}); - if(!r.ok){ toast('erreur : '+(r.error||'')); return; } - lcStartPolling(); -} - -function lcSetButtons(disabled){ - ['lc-install','lc-check','lc-update','lc-rebuild','lc-prebuilt'].forEach(id=>{ - const b=document.getElementById(id); if(b) b.disabled = disabled; - }); -} +// --- Progression de l'installation (téléchargement / compilation) ---------- +function lcBusy(on){ document.querySelector('.lc-modes').classList.toggle('busy', on); } function lcStartPolling(){ - const jobBox = document.getElementById('lc-job'); - jobBox.style.display = ''; + document.getElementById('lc-job').style.display = ''; document.getElementById('lc-log').textContent = ''; lcLogNext = 0; - lcSetButtons(true); - lcBadge('⏳ build'); + lcBusy(true); if(lcPoll) clearInterval(lcPoll); lcPoll = setInterval(lcPollJob, 1000); lcPollJob(); @@ -137,7 +113,6 @@ async function lcPollJob(){ try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } if(!j.exists) return; const phaseEl = document.getElementById('lc-job-phase'); - // Logs incrémentaux (offset absolu — le serveur tronque le début si besoin). if(j.lines && j.lines.length){ const pre = document.getElementById('lc-log'); const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; @@ -149,21 +124,19 @@ async function lcPollJob(){ phaseEl.innerHTML = '⏳ '+String(j.phase||'…').replace(/[<>&]/g,'')+''; return; } - // Terminé. clearInterval(lcPoll); lcPoll = null; - lcSetButtons(false); + lcBusy(false); if(j.error){ phaseEl.innerHTML = '✗ '+String(j.error).replace(/[<>&]/g,'')+''; - // Ouvre les logs pour que l'erreur réelle soit visible tout de suite. const pre = document.getElementById('lc-log'); if(pre.hasAttribute('hidden')) lcToggleLog(); pre.scrollTop = pre.scrollHeight; - toast('build llama.cpp en échec'); + toast('échec — voir les détails'); } else { phaseEl.innerHTML = '✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+''; - toast('llama.cpp : '+(j.phase||'terminé')); + toast('c\'est prêt ✓'); } - loadAll(); // rafraîchit tout (config, statut service… et ce panneau) + loadAll(); } function lcToggleLog(){ diff --git a/internal/jean/ui/src/styles.css b/internal/jean/ui/src/styles.css index 8fb570d..793da28 100644 --- a/internal/jean/ui/src/styles.css +++ b/internal/jean/ui/src/styles.css @@ -271,7 +271,21 @@ button:disabled{opacity:.5;cursor:not-allowed} h2,details>summary{font-size:10px} #menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))} } -/* --- Backend llama.cpp (gestion install/update depuis l'UI) --------------- */ +/* --- Backend llama.cpp (2 modes : rapide / optimisée) --------------------- */ +.lc-head{font-size:12px;line-height:1.5;margin-bottom:10px} +.lc-modes{display:flex;flex-direction:column;gap:8px} +.lc-modes.busy{opacity:.5;pointer-events:none} +.lc-mode{border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .12s,background .12s} +.lc-mode:hover{border-color:var(--accent)} +.lc-mode.active{border-color:var(--accent);background:var(--panel);cursor:default} +.lc-mode-t{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px} +.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--accent);border:1px solid var(--accent);border-radius:8px;padding:1px 6px} +.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px} +.lc-mode-state{font-size:11px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.lc-mode-go{color:var(--accent)} +.lc-mode-active-tag{color:var(--accent);font-weight:600} +.lc-update-link{color:var(--dim);text-decoration:underline;cursor:pointer} +.lc-update-link:hover{color:var(--accent)} .lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:6px 0;margin-top:4px} .lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} .lc-logbar.open #lc-log-caret{transform:rotate(90deg)} diff --git a/internal/jean/web_llamacpp.go b/internal/jean/web_llamacpp.go index 270d8f0..34661b3 100644 --- a/internal/jean/web_llamacpp.go +++ b/internal/jean/web_llamacpp.go @@ -313,6 +313,39 @@ func lcRunPrebuilt() { lcDone("binaires précompilés installés (" + tag + ")") } +// handleLlamacppUse bascule BIN entre deux versions DÉJÀ installées, sans +// rien recompiler : "fast" = binaires précompilés, "opt" = build local. Le +// service redémarre pour prendre le nouveau binaire. L'UI n'appelle ceci que +// quand la version cible existe déjà (sinon elle lance un job d'installation). +func handleLlamacppUse(w http.ResponseWriter, r *http.Request) { + var req struct { + Mode string `json:"mode"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + var bin string + switch req.Mode { + case "fast": + bin = prebuiltServerBin() + case "opt": + bin = llamaServerBin(llamacppRepoDir()) + default: + sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu"}) + return + } + if bin == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "cette version n'est pas installée"}) + return + } + if err := SetConfigKey("BIN", bin); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + if serviceIsActive() { + _ = serviceAction("restart") + } + sendJSON(w, 200, map[string]any{"ok": true, "bin": bin}) +} + // handleLlamacppJob renvoie l'état du job courant + les lignes de log depuis // l'offset absolu ?from=N (le client mémorise `next` et enchaîne). func handleLlamacppJob(w http.ResponseWriter, r *http.Request) { diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index be5125d..2094c9b 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -95,6 +95,7 @@ func newWebMux() *http.ServeMux { api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés api("/api/llamacpp/prebuilt/check", handleLlamacppPrebuiltCheck) // dernière release officielle vs installée + api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées api("/api/presets", handlePresets) api("/api/preset", handlePreset) api("/api/preset/save", handlePresetSave) From c5522a51a57084fbd49877775403b594dd663405 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 22 Jul 2026 19:55:04 +0200 Subject: [PATCH 122/279] v0.4.10 : backend llama.cpp, interface simplifiee (2 modes rapide / optimisee) --- internal/jean/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/jean/run.go b/internal/jean/run.go index b1b73c0..a7969b9 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.4.9" +const Version = "0.4.10" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { From ef5b036d992fbc358450313545ae1d63a840a6c3 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 22 Jul 2026 20:17:23 +0200 Subject: [PATCH 123/279] backend llama.cpp : choix du moteur PAR MODELE (3 options), la barre laterale n'installe plus que Corrige la confusion : deux endroits ecrivaient la ligne BIN et se battaient (la barre laterale changeait le BIN global + redemarrait, puis selectionner un preset remettait le BIN du preset). Desormais : - la section laterale 'Backend llama.cpp' sert UNIQUEMENT a installer / mettre a jour les moteurs (rapide / optimisee), elle ne change plus le BIN actif - le choix du moteur se fait PAR MODELE, dans l'editeur de preset, avec 3 options claires : Rapide (precompile) / Optimise (compile) / Personnalise (chemin libre ou backend depose dans le dossier backends de jean, detecte) - detection auto de l'option a l'ouverture selon le BIN du preset ; l'astuce indique le dossier ou deposer un binaire custom - /api/llamacpp expose backends_dir ; l'endpoint /use n'est plus utilise par l'UI v0.4.11 --- internal/jean/run.go | 2 +- internal/jean/ui/index.html | 186 +++++++++++++++---------- internal/jean/ui/src/index.tmpl.html | 13 +- internal/jean/ui/src/js/07-models.js | 87 ++++++++---- internal/jean/ui/src/js/10-llamacpp.js | 79 ++++------- internal/jean/ui/src/styles.css | 7 +- internal/jean/web_llamacpp.go | 1 + 7 files changed, 220 insertions(+), 155 deletions(-) diff --git a/internal/jean/run.go b/internal/jean/run.go index a7969b9..1038a6d 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.4.10" +const Version = "0.4.11" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index a79283a..e0ac5e5 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -296,7 +296,7 @@ button:disabled{opacity:.5;cursor:not-allowed} .lc-modes.busy{opacity:.5;pointer-events:none} .lc-mode{border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .12s,background .12s} .lc-mode:hover{border-color:var(--accent)} -.lc-mode.active{border-color:var(--accent);background:var(--panel);cursor:default} +.lc-mode.installed{border-color:var(--accent)} .lc-mode-t{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px} .lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--accent);border:1px solid var(--accent);border-radius:8px;padding:1px 6px} .lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px} @@ -305,6 +305,11 @@ button:disabled{opacity:.5;cursor:not-allowed} .lc-mode-active-tag{color:var(--accent);font-weight:600} .lc-update-link{color:var(--dim);text-decoration:underline;cursor:pointer} .lc-update-link:hover{color:var(--accent)} +/* Choix du moteur dans l'éditeur de modèle (3 options : rapide/optimisé/perso) */ +.be-opts{display:flex;flex-direction:column;gap:7px;margin:6px 0 8px} +.be-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer} +.be-opt input{margin:0;flex-shrink:0} +.be-note{font-size:11px;color:var(--dim);margin-left:auto} .lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:6px 0;margin-top:4px} .lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} .lc-logbar.open #lc-log-caret{transform:rotate(90deg)} @@ -500,8 +505,17 @@ button:disabled{opacity:.5;cursor:not-allowed} '; }).join('') || '(pas de GPU)'; } +async function loadRam(){ + const m=await jget('/api/ram'); + const box=document.getElementById('ram-details'); + if(!m || !m.total){ if(box) box.style.display='none'; return; } + if(box) box.style.display=''; + const pct=Math.round(m.used*100/m.total); + document.getElementById('ram').innerHTML = + '
    '+ + '
    '+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB · '+pct+'%
    '; +} async function loadCfg(){ const c=await jget('/api/config'); const keys=['BIN','MODEL','CTX','BATCH','UBATCH','NGL','PORT']; diff --git a/internal/jean/ui/src/js/06-settings.js b/internal/jean/ui/src/js/06-settings.js index 4fd803e..5bd1dcf 100644 --- a/internal/jean/ui/src/js/06-settings.js +++ b/internal/jean/ui/src/js/06-settings.js @@ -216,5 +216,5 @@ async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); } -async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs(),loadLlamacpp()]); } +async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs(),loadLlamacpp()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/jean/ui/src/js/09-stream.js b/internal/jean/ui/src/js/09-stream.js index 33be052..787df04 100644 --- a/internal/jean/ui/src/js/09-stream.js +++ b/internal/jean/ui/src/js/09-stream.js @@ -144,3 +144,4 @@ async function send(){ loadAll(); setInterval(loadStatus, 5000); setInterval(loadVram, 3000); +setInterval(loadRam, 3000); diff --git a/internal/jean/web_api.go b/internal/jean/web_api.go index 6eb3b25..c2d494e 100644 --- a/internal/jean/web_api.go +++ b/internal/jean/web_api.go @@ -70,6 +70,35 @@ func handleVram(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, gpus) } +// handleRam renvoie la RAM système {used, total} en Mo (mêmes unités que +// /api/vram → l'UI divise par 1024 pour des Gio). Lit /proc/meminfo (Linux, la +// box de prod). Sur un OS sans /proc, renvoie total=0 → l'UI masque le bloc. +func handleRam(w http.ResponseWriter, r *http.Request) { + out := map[string]any{"used": 0, "total": 0} + b, err := os.ReadFile("/proc/meminfo") + if err == nil { + var totalKB, availKB int + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) < 2 { + continue + } + v, _ := strconv.Atoi(f[1]) // valeur en kB + switch f[0] { + case "MemTotal:": + totalKB = v + case "MemAvailable:": + availKB = v + } + } + if totalKB > 0 { + out["total"] = totalKB / 1024 + out["used"] = (totalKB - availKB) / 1024 + } + } + sendJSON(w, 200, out) +} + func handleConfigEnv(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, ReadConfig()) } diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index 2094c9b..badeb4f 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -79,6 +79,7 @@ func newWebMux() *http.ServeMux { api("/api/ping", handlePing) api("/api/status", handleStatus) api("/api/vram", handleVram) + api("/api/ram", handleRam) api("/api/config", handleConfigEnv) api("/api/catalog", handleCatalog) api("/api/update", handleUpdateCheck) From 6bdf9e8ae471be975139294e8c825cdf02caef55 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 13:45:20 +0200 Subject: [PATCH 126/279] UI config active : BIN affiche le moteur (rapide/optimise/custom+chemin) + n-cpu-moe - BIN devient un libelle lisible : "rapide" (moteur precompile), "optimise" (compile), ou "custom : " sinon (via in_use / prebuilt.in_use de /api/llamacpp). Le chemin complet reste en tooltip. - Ajout d'une ligne "n-cpu-moe" quand --n-cpu-moe est present dans EXTRA_ARGS. --- internal/jean/ui/index.html | 26 +++++++++++++++++++++----- internal/jean/ui/src/js/05-status.js | 26 +++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 822f22e..43a484b 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -786,12 +786,28 @@ async function loadRam(){ '
    '+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB · '+pct+'%
    '; } async function loadCfg(){ - const c=await jget('/api/config'); - const keys=['BIN','MODEL','CTX','BATCH','UBATCH','NGL','PORT']; - document.getElementById('cfg').innerHTML = keys.filter(k=>c[k]).map(k=>{ + // /api/llamacpp en parallèle : il indique si le BIN de la config correspond au + // moteur rapide (prebuilt.in_use) ou optimisé (in_use) — sinon c'est un custom. + const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]); + const row=(k,v,title)=>'
    '+k+''+String(v)+'
    '; + const rows=[]; + if(c.BIN){ + // Moteur : rapide / optimisé / custom (avec le chemin). Le title garde + // toujours le chemin complet, quel que soit le libellé. + let v; + if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide'; + else if(lc && lc.in_use) v='🔧 optimisé'; + else v='custom : '+c.BIN; + rows.push(row('BIN', v, c.BIN)); + } + ['MODEL','CTX','BATCH','UBATCH','NGL','PORT'].filter(k=>c[k]).forEach(k=>{ let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); - return '
    '+k+''+v+'
    '; - }).join(''); + rows.push(row(k, v)); + }); + // n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS. + const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/); + if(m) rows.push(row('n-cpu-moe', m[1])); + document.getElementById('cfg').innerHTML = rows.join(''); } async function loadPresets(){ const p=await jget('/api/presets'); diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js index 8dff798..055b873 100644 --- a/internal/jean/ui/src/js/05-status.js +++ b/internal/jean/ui/src/js/05-status.js @@ -75,10 +75,26 @@ async function loadRam(){ '
    '+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB · '+pct+'%
    '; } async function loadCfg(){ - const c=await jget('/api/config'); - const keys=['BIN','MODEL','CTX','BATCH','UBATCH','NGL','PORT']; - document.getElementById('cfg').innerHTML = keys.filter(k=>c[k]).map(k=>{ + // /api/llamacpp en parallèle : il indique si le BIN de la config correspond au + // moteur rapide (prebuilt.in_use) ou optimisé (in_use) — sinon c'est un custom. + const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]); + const row=(k,v,title)=>'
    '+k+''+String(v)+'
    '; + const rows=[]; + if(c.BIN){ + // Moteur : rapide / optimisé / custom (avec le chemin). Le title garde + // toujours le chemin complet, quel que soit le libellé. + let v; + if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide'; + else if(lc && lc.in_use) v='🔧 optimisé'; + else v='custom : '+c.BIN; + rows.push(row('BIN', v, c.BIN)); + } + ['MODEL','CTX','BATCH','UBATCH','NGL','PORT'].filter(k=>c[k]).forEach(k=>{ let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); - return '
    '+k+''+v+'
    '; - }).join(''); + rows.push(row(k, v)); + }); + // n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS. + const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/); + if(m) rows.push(row('n-cpu-moe', m[1])); + document.getElementById('cfg').innerHTML = rows.join(''); } From a04613a87d97163d2a97ac1d3de6eb36b51a4f43 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 13:47:39 +0200 Subject: [PATCH 127/279] UI: 'BIN' renomme 'moteur' dans la config active --- internal/jean/ui/index.html | 2 +- internal/jean/ui/src/js/05-status.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 43a484b..484ba38 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -798,7 +798,7 @@ async function loadCfg(){ if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide'; else if(lc && lc.in_use) v='🔧 optimisé'; else v='custom : '+c.BIN; - rows.push(row('BIN', v, c.BIN)); + rows.push(row('moteur', v, c.BIN)); } ['MODEL','CTX','BATCH','UBATCH','NGL','PORT'].filter(k=>c[k]).forEach(k=>{ let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js index 055b873..e787523 100644 --- a/internal/jean/ui/src/js/05-status.js +++ b/internal/jean/ui/src/js/05-status.js @@ -87,7 +87,7 @@ async function loadCfg(){ if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide'; else if(lc && lc.in_use) v='🔧 optimisé'; else v='custom : '+c.BIN; - rows.push(row('BIN', v, c.BIN)); + rows.push(row('moteur', v, c.BIN)); } ['MODEL','CTX','BATCH','UBATCH','NGL','PORT'].filter(k=>c[k]).forEach(k=>{ let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); From 996f918e725300d0baad9728963cf813f9ca3601 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 13:49:53 +0200 Subject: [PATCH 128/279] UI: MOTEUR en majuscule dans la config active --- internal/jean/ui/index.html | 2 +- internal/jean/ui/src/js/05-status.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 484ba38..c1f863e 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -798,7 +798,7 @@ async function loadCfg(){ if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide'; else if(lc && lc.in_use) v='🔧 optimisé'; else v='custom : '+c.BIN; - rows.push(row('moteur', v, c.BIN)); + rows.push(row('MOTEUR', v, c.BIN)); } ['MODEL','CTX','BATCH','UBATCH','NGL','PORT'].filter(k=>c[k]).forEach(k=>{ let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js index e787523..83d9452 100644 --- a/internal/jean/ui/src/js/05-status.js +++ b/internal/jean/ui/src/js/05-status.js @@ -87,7 +87,7 @@ async function loadCfg(){ if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide'; else if(lc && lc.in_use) v='🔧 optimisé'; else v='custom : '+c.BIN; - rows.push(row('moteur', v, c.BIN)); + rows.push(row('MOTEUR', v, c.BIN)); } ['MODEL','CTX','BATCH','UBATCH','NGL','PORT'].filter(k=>c[k]).forEach(k=>{ let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); From 6d54c45afa43bb341545728080dec9396f9ff027 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 13:56:09 +0200 Subject: [PATCH 129/279] UI moteur : section renommee MOTEUR, vouvoiement, verification de version - Titre "Backend llama.cpp" -> "MOTEUR". - Passage au vouvoiement dans tous les textes de la section. - Nouveau lien "verifier la version" sur chaque moteur (rapide/optimise) : interroge la derniere version dispo (endpoints check existants) SANS installer, et affiche le resultat en toast avant tout lancement d'install. --- internal/jean/ui/index.html | 39 ++++++++++++++++++++------ internal/jean/ui/src/index.tmpl.html | 4 +-- internal/jean/ui/src/js/10-llamacpp.js | 35 +++++++++++++++++++---- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index c1f863e..30b26d2 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -416,7 +416,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
    -
    Backend llama.cpp +
    MOTEUR
    …
    @@ -426,7 +426,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
    🔧 Version optimisée
    -
    compilée pour ta machine, plus longue
    +
    compilée pour votre machine, plus longue
    @@ -1709,9 +1709,9 @@ async function loadLlamacpp(){ const head = document.getElementById('lc-status'); if(!fastInstalled && !optInstalled){ - head.innerHTML = 'Installe le moteur d\'IA. La version rapide convient à presque tout le monde.'; + head.innerHTML = 'Installez le moteur d\'IA. La version rapide convient à presque tout le monde.'; } else { - head.innerHTML = 'Installe les moteurs ici. Tu choisis lequel utiliser quand tu édites un modèle (bouton ✎).'; + head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ✎).'; } lcRenderMode('fast', fastInstalled); lcRenderMode('opt', optInstalled); @@ -1727,20 +1727,43 @@ function lcRenderMode(mode, installed){ const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); card.classList.toggle('installed', installed); + // Lien de vérification : interroge la dernière version SANS rien installer. + // event.stopPropagation empêche le clic de la carte (qui lance l'install). + const check = '🔍 vérifier la version'; if(installed){ state.innerHTML = '✓ installée' - + '↻ mettre à jour'; + + '↻ mettre à jour' + + check; } else { - state.innerHTML = '→ cliquer pour installer'; + state.innerHTML = '→ cliquer pour installer' + check; } } +// lcCheck vérifie s'il existe une version plus récente AVANT toute installation +// (endpoints de check dédiés, sans effet de bord). Résultat affiché en toast. +async function lcCheck(mode){ + toast('vérification…'); + try{ + if(mode === 'fast'){ + const r = await jpost('/api/llamacpp/prebuilt/check', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + if(r.update) toast('nouvelle version disponible : '+r.latest+(r.current ? ' (installée : '+r.current+')' : '')); + else toast('moteur rapide à jour ✓'+(r.latest ? ' ('+r.latest+')' : '')); + } else { + const r = await jpost('/api/llamacpp/check', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + if(r.behind > 0) toast(r.behind+' nouveau(x) commit(s) disponible(s) — utilisez « mettre à jour »'); + else toast('moteur optimisé à jour ✓'); + } + }catch(_){ toast('erreur réseau'); } +} + // Clic sur une carte : installer le moteur (s'il ne l'est pas déjà). async function lcPick(mode){ const s = lcState || {}, pb = s.prebuilt || {}; const installed = mode==='fast' ? !!pb.bin : !!s.bin; if(installed){ - toast('déjà installée — choisis-la dans l\'édition d\'un modèle (✎)'); + toast('déjà installée — choisissez-la dans l\'édition d\'un modèle (✎)'); return; } if(mode === 'fast'){ @@ -1748,7 +1771,7 @@ async function lcPick(mode){ const r = await jpost('/api/llamacpp/prebuilt', {}); if(!r.ok){ toast('erreur : '+(r.error||'')); return; } } else { - if(!await askConfirm('Compiler la version optimisée pour ta machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA).', {title:'Version optimisée', okText:'Compiler'})) return; + if(!await askConfirm('Compiler la version optimisée pour votre machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA).', {title:'Version optimisée', okText:'Compiler'})) return; const r = await jpost('/api/llamacpp/install', {}); if(!r.ok){ toast('erreur : '+(r.error||'')); return; } } diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 34c15a9..87b863b 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -119,7 +119,7 @@
    -
    Backend llama.cpp +
    MOTEUR
    …
    @@ -129,7 +129,7 @@
    🔧 Version optimisée
    -
    compilée pour ta machine, plus longue
    +
    compilée pour votre machine, plus longue
    diff --git a/internal/jean/ui/src/js/10-llamacpp.js b/internal/jean/ui/src/js/10-llamacpp.js index a3642db..e7c1fab 100644 --- a/internal/jean/ui/src/js/10-llamacpp.js +++ b/internal/jean/ui/src/js/10-llamacpp.js @@ -16,9 +16,9 @@ async function loadLlamacpp(){ const head = document.getElementById('lc-status'); if(!fastInstalled && !optInstalled){ - head.innerHTML = 'Installe le moteur d\'IA. La version rapide convient à presque tout le monde.'; + head.innerHTML = 'Installez le moteur d\'IA. La version rapide convient à presque tout le monde.'; } else { - head.innerHTML = 'Installe les moteurs ici. Tu choisis lequel utiliser quand tu édites un modèle (bouton ✎).'; + head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ✎).'; } lcRenderMode('fast', fastInstalled); lcRenderMode('opt', optInstalled); @@ -34,20 +34,43 @@ function lcRenderMode(mode, installed){ const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); card.classList.toggle('installed', installed); + // Lien de vérification : interroge la dernière version SANS rien installer. + // event.stopPropagation empêche le clic de la carte (qui lance l'install). + const check = '🔍 vérifier la version'; if(installed){ state.innerHTML = '✓ installée' - + '↻ mettre à jour'; + + '↻ mettre à jour' + + check; } else { - state.innerHTML = '→ cliquer pour installer'; + state.innerHTML = '→ cliquer pour installer' + check; } } +// lcCheck vérifie s'il existe une version plus récente AVANT toute installation +// (endpoints de check dédiés, sans effet de bord). Résultat affiché en toast. +async function lcCheck(mode){ + toast('vérification…'); + try{ + if(mode === 'fast'){ + const r = await jpost('/api/llamacpp/prebuilt/check', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + if(r.update) toast('nouvelle version disponible : '+r.latest+(r.current ? ' (installée : '+r.current+')' : '')); + else toast('moteur rapide à jour ✓'+(r.latest ? ' ('+r.latest+')' : '')); + } else { + const r = await jpost('/api/llamacpp/check', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + if(r.behind > 0) toast(r.behind+' nouveau(x) commit(s) disponible(s) — utilisez « mettre à jour »'); + else toast('moteur optimisé à jour ✓'); + } + }catch(_){ toast('erreur réseau'); } +} + // Clic sur une carte : installer le moteur (s'il ne l'est pas déjà). async function lcPick(mode){ const s = lcState || {}, pb = s.prebuilt || {}; const installed = mode==='fast' ? !!pb.bin : !!s.bin; if(installed){ - toast('déjà installée — choisis-la dans l\'édition d\'un modèle (✎)'); + toast('déjà installée — choisissez-la dans l\'édition d\'un modèle (✎)'); return; } if(mode === 'fast'){ @@ -55,7 +78,7 @@ async function lcPick(mode){ const r = await jpost('/api/llamacpp/prebuilt', {}); if(!r.ok){ toast('erreur : '+(r.error||'')); return; } } else { - if(!await askConfirm('Compiler la version optimisée pour ta machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA).', {title:'Version optimisée', okText:'Compiler'})) return; + if(!await askConfirm('Compiler la version optimisée pour votre machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA).', {title:'Version optimisée', okText:'Compiler'})) return; const r = await jpost('/api/llamacpp/install', {}); if(!r.ok){ toast('erreur : '+(r.error||'')); return; } } From 3574039d56028154d237248a39841fdf72c896ce Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 13:57:35 +0200 Subject: [PATCH 130/279] UI config active : retire PORT (deja affiche en haut) --- internal/jean/ui/index.html | 2 +- internal/jean/ui/src/js/05-status.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 30b26d2..e20e1aa 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -800,7 +800,7 @@ async function loadCfg(){ else v='custom : '+c.BIN; rows.push(row('MOTEUR', v, c.BIN)); } - ['MODEL','CTX','BATCH','UBATCH','NGL','PORT'].filter(k=>c[k]).forEach(k=>{ + ['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{ let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); rows.push(row(k, v)); }); diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js index 83d9452..5203d48 100644 --- a/internal/jean/ui/src/js/05-status.js +++ b/internal/jean/ui/src/js/05-status.js @@ -89,7 +89,7 @@ async function loadCfg(){ else v='custom : '+c.BIN; rows.push(row('MOTEUR', v, c.BIN)); } - ['MODEL','CTX','BATCH','UBATCH','NGL','PORT'].filter(k=>c[k]).forEach(k=>{ + ['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{ let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); rows.push(row(k, v)); }); From 2b5356acd86cebd78781d33594012cbcbb49ab6e Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 13:59:26 +0200 Subject: [PATCH 131/279] UI config active : N-CPU-MOE en majuscule --- internal/jean/ui/index.html | 2 +- internal/jean/ui/src/js/05-status.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index e20e1aa..0560592 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -806,7 +806,7 @@ async function loadCfg(){ }); // n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS. const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/); - if(m) rows.push(row('n-cpu-moe', m[1])); + if(m) rows.push(row('N-CPU-MOE', m[1])); document.getElementById('cfg').innerHTML = rows.join(''); } async function loadPresets(){ diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js index 5203d48..8faa92f 100644 --- a/internal/jean/ui/src/js/05-status.js +++ b/internal/jean/ui/src/js/05-status.js @@ -95,6 +95,6 @@ async function loadCfg(){ }); // n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS. const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/); - if(m) rows.push(row('n-cpu-moe', m[1])); + if(m) rows.push(row('N-CPU-MOE', m[1])); document.getElementById('cfg').innerHTML = rows.join(''); } From 3f03808e2ca2a937218e651b793c80cf0cd684a8 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 15:08:59 +0200 Subject: [PATCH 132/279] v0.4.13 : RAM dans la sidebar, config active clarifiee, verification de moteur - Sidebar : nouvel affichage de la RAM systeme (used/total, /proc/meminfo). - Config active : BIN devient MOTEUR (rapide/optimise/custom+chemin), ajout de N-CPU-MOE si present dans EXTRA_ARGS, retrait de PORT (deja affiche en haut). - Section moteurs : renommee MOTEUR, passage au vouvoiement, et lien "verifier la version" (interroge la derniere version sans installer). --- cmd/jean/resource_windows_amd64.syso | Bin 8348 -> 8348 bytes cmd/jean/resource_windows_arm64.syso | Bin 8348 -> 8348 bytes cmd/jean/versioninfo.json | 6 +++--- internal/jean/run.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 091e13dc8d4e5e89e464ca9ed5ffd29c9330a788..1ba2434939a292e35e6a575f74abdb84833a93e5 100644 GIT binary patch delta 36 ncmbQ^ILC2=lnf^?0}BHP@NQO?5oTgE-fS)x&IqLX<#`zah*<{) delta 36 ncmbQ^ILC2=lnf^i0}BHP@N8C=5oTgE+H5Wt&IqLX<#`zah!zI| diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 136c8d5fb14f013dfe292a0b4d7782d3a04e10d4..857498ff27a588f6da37b5a7d3caae9ab4dc15c7 100644 GIT binary patch delta 36 ncmbQ^ILC2=lnf^?0}BHP@NQO?5oTgE-fS)x&IqLX<#`zah*<{) delta 36 ncmbQ^ILC2=lnf^i0}BHP@N8C=5oTgE+H5Wt&IqLX<#`zah!zI| diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 72ddce7..be80b2e 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 4, - "Patch": 12, + "Patch": 13, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 4, - "Patch": 12, + "Patch": 13, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "Jean", - "ProductVersion": "0.4.12", + "ProductVersion": "0.4.13", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/run.go b/internal/jean/run.go index 33a5d52..af9332c 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.4.12" +const Version = "0.4.13" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { From 37c4b4ca52d5960d711d53605f1dd394c6737284 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 23 Jul 2026 15:35:14 +0200 Subject: [PATCH 133/279] UI: refonte propre du modale editeur de preset Styles inline -> classes CSS reutilisables (.modal-*, .fld, .ctl, .card, .seg). Bloc modele/moteur regroupe dans une carte "Modele & moteur", selecteur de moteur en 3 segments (rapide/optimise/perso), hierarchie et espacements clairs. Verifie desktop + mobile. IDs et handlers inchanges. --- internal/jean/ui/index.html | 122 +++++++++++++++++++-------- internal/jean/ui/src/index.tmpl.html | 87 +++++++++++-------- internal/jean/ui/src/js/07-models.js | 2 +- internal/jean/ui/src/styles.css | 33 ++++++++ 4 files changed, 174 insertions(+), 70 deletions(-) diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 0560592..8b018e3 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -310,6 +310,39 @@ button:disabled{opacity:.5;cursor:not-allowed} .be-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer} .be-opt input{margin:0;flex-shrink:0} .be-note{font-size:11px;color:var(--dim);margin-left:auto} +/* Éditeur de preset/mémoire — mise en forme propre du modale */ +.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:30;align-items:center;justify-content:center;padding:20px} +.modal-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.5)} +.modal-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center} +.modal-head strong{font-size:14px} +.modal-x{margin:0;padding:3px 10px;font-size:16px;line-height:1;color:var(--dim)} +.modal-body{padding:16px;overflow:auto;flex:1} +.modal-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap} +.foot-l{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.foot-r{display:flex;gap:8px} +.mform{display:flex;flex-direction:column;gap:14px} +.fld{display:flex;flex-direction:column;gap:5px} +.fld-l{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)} +.fld-l .opt{text-transform:none;letter-spacing:0;font-weight:400;opacity:.65} +.fld-hint{font-size:11px;color:var(--dim);margin-top:2px} +.ctl{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:9px 11px;font:inherit} +.ctl:focus{outline:none;border-color:var(--accent)} +.ta{min-height:220px;resize:vertical;line-height:1.5} +.card{border:1px solid var(--border);border-radius:10px;padding:13px;display:flex;flex-direction:column;gap:13px} +.card-h{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)} +.row{display:flex;gap:6px} +.row .ctl{flex:1;min-width:0} +.btn-dl{flex-shrink:0;margin:0} +.seg{display:flex;gap:6px} +.seg>label{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;min-height:46px;padding:6px 4px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:12px;background:var(--panel);transition:border-color .12s,background .12s,color .12s} +.seg>label:hover{border-color:var(--accent)} +.seg>label:has(input:checked){border-color:var(--accent);background:var(--accent-bg);color:var(--accent)} +.seg input{display:none} +.seg .be-note{font-size:10px;color:var(--dim);margin:0} +.btn-danger{background:var(--danger-bg);border-color:var(--err);color:var(--err);margin:0} +.btn-save{border-color:var(--accent);color:var(--accent);margin:0} +.del-model{align-items:center;gap:5px;font-size:11px;color:var(--dim)} +.del-model input{margin:0} .lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:6px 0;margin-top:4px} .lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} .lc-logbar.open #lc-log-caret{transform:rotate(90deg)} @@ -498,49 +531,68 @@ button:disabled{opacity:.5;cursor:not-allowed} -
    +
    Accès distant +
    ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
    + +
    +

    Ouvrez l'accès distant à ce serveur en quelques clics — compte, abonnement et connexion, sans terminal.

    + +
    + + +
    MOTEUR
    …
    @@ -1155,7 +1179,19 @@ async function apiKeyAction(action){ renderApiKey(await jpost('/api/apikey', {action})); } async function toggleOAIPublic(){ - const on = document.getElementById('oai-public-toggle').checked; + const cb = document.getElementById('oai-public-toggle'); + const on = cb.checked; + // L'accès OpenAI public passe par ajean.link (.oai.ajean.link) : il exige + // que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique. + if(on){ + let linked = false; + try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){} + if(!linked){ + cb.checked = false; + await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'}); + return; + } + } await jpost('/api/oai/public', {enabled:on}); toast(on ? 'accès public activé' : 'accès public coupé'); loadApiKey(); @@ -1176,7 +1212,7 @@ async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); } -async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]); } +async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; } function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; } @@ -2167,4 +2203,81 @@ async function deleteMcp(){ loadMCP(); toast('serveur supprimé'); } +// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal. +// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte + +// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à +// l'agent local (/api/link/connect) qui fait le `jean link` (token + service). + +const AJEAN_APP_ORIGIN = 'https://app.ajean.link'; + +function renderRemote(d){ + const off = document.getElementById('remote-off'), on = document.getElementById('remote-on'); + const badge = document.getElementById('remote-badge'); + if(!d || !d.linked){ + off.style.display=''; on.style.display='none'; + if(badge){ badge.textContent=''; } + return; + } + off.style.display='none'; on.style.display=''; + document.getElementById('remote-url').value = d.machineURL || ''; + const st = document.getElementById('remote-status'); + if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; } + else { st.textContent='○ service arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; } + if(badge){ badge.textContent = d.active ? '● connecté' : '○ arrêté'; badge.style.color = d.active?'var(--accent)':'var(--warn)'; } +} + +async function loadRemote(){ + // Le panneau ne vaut qu'en LOCAL : il pilote /api/link/* que le tunnel bloque + // (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache. + const det = document.getElementById('remote-details'); + if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; } + try{ renderRemote(await jget('/api/link/status')); }catch(e){} +} + +// Ouvre la popup de connexion et attend la clé renvoyée par postMessage. +function remoteConnect(){ + const params = new URLSearchParams({ origin: window.location.origin, host: window.location.hostname || 'ce serveur' }); + const url = AJEAN_APP_ORIGIN + '/connect.html?' + params.toString(); + const pop = window.open(url, 'ajean-connect', 'width=440,height=640'); + if(!pop){ toast('autorisez les popups pour connecter ajean.link'); return; } + + async function onMsg(ev){ + // Anti-usurpation : n'accepte QUE des messages du portail ajean.link. + if(ev.origin !== AJEAN_APP_ORIGIN) return; + const d = ev.data || {}; + if(d.type !== 'ajean-link' || !d.token) return; + window.removeEventListener('message', onMsg); + try{ + const r = await jpost('/api/link/connect', { token: d.token }); + if(r && r.linked){ + toast('✓ accès distant connecté'); + if(r.serviceErr){ toast('token enregistré (service : '+r.serviceErr+')'); } + loadRemote(); + } else { + toast((r && r.error) || 'échec de la connexion'); + } + }catch(e){ toast('erreur : '+e); } + } + window.addEventListener('message', onMsg); +} + +async function remoteDisconnect(){ + const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'}); + if(!ok) return; + try{ await jpost('/api/link/disconnect', {}); toast('accès distant coupé'); loadRemote(); } + catch(e){ toast('erreur : '+e); } +} + +async function remotePairCode(){ + const box = document.getElementById('remote-pair'); + box.style.display=''; box.textContent='génération du code…'; + try{ + const r = await jpost('/api/link/paircode', {}); + if(r && r.code){ + box.innerHTML = 'Empreinte : '+(r.fingerprint||'—')+'
    Code d\'appairage (valable 10 min, usage unique) : '+r.code+''; + } else { + box.textContent = (r && r.error) || 'code indisponible'; + } + }catch(e){ box.textContent='erreur : '+e; } +} diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index da6ed74..df23aa6 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -124,6 +124,30 @@
    +
    Accès distant +
    ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
    + +
    +

    Ouvrez l'accès distant à ce serveur en quelques clics — compte, abonnement et connexion, sans terminal.

    + +
    + + +
    MOTEUR
    …
    diff --git a/internal/jean/ui/src/js/06-settings.js b/internal/jean/ui/src/js/06-settings.js index 72f7cdf..9b031ff 100644 --- a/internal/jean/ui/src/js/06-settings.js +++ b/internal/jean/ui/src/js/06-settings.js @@ -195,7 +195,19 @@ async function apiKeyAction(action){ renderApiKey(await jpost('/api/apikey', {action})); } async function toggleOAIPublic(){ - const on = document.getElementById('oai-public-toggle').checked; + const cb = document.getElementById('oai-public-toggle'); + const on = cb.checked; + // L'accès OpenAI public passe par ajean.link (.oai.ajean.link) : il exige + // que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique. + if(on){ + let linked = false; + try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){} + if(!linked){ + cb.checked = false; + await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'}); + return; + } + } await jpost('/api/oai/public', {enabled:on}); toast(on ? 'accès public activé' : 'accès public coupé'); loadApiKey(); @@ -216,5 +228,5 @@ async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); } -async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]); } +async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/jean/ui/src/js/13-remote.js b/internal/jean/ui/src/js/13-remote.js new file mode 100644 index 0000000..57e8910 --- /dev/null +++ b/internal/jean/ui/src/js/13-remote.js @@ -0,0 +1,77 @@ +// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal. +// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte + +// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à +// l'agent local (/api/link/connect) qui fait le `jean link` (token + service). + +const AJEAN_APP_ORIGIN = 'https://app.ajean.link'; + +function renderRemote(d){ + const off = document.getElementById('remote-off'), on = document.getElementById('remote-on'); + const badge = document.getElementById('remote-badge'); + if(!d || !d.linked){ + off.style.display=''; on.style.display='none'; + if(badge){ badge.textContent=''; } + return; + } + off.style.display='none'; on.style.display=''; + document.getElementById('remote-url').value = d.machineURL || ''; + const st = document.getElementById('remote-status'); + if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; } + else { st.textContent='○ service arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; } + if(badge){ badge.textContent = d.active ? '● connecté' : '○ arrêté'; badge.style.color = d.active?'var(--accent)':'var(--warn)'; } +} + +async function loadRemote(){ + // Le panneau ne vaut qu'en LOCAL : il pilote /api/link/* que le tunnel bloque + // (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache. + const det = document.getElementById('remote-details'); + if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; } + try{ renderRemote(await jget('/api/link/status')); }catch(e){} +} + +// Ouvre la popup de connexion et attend la clé renvoyée par postMessage. +function remoteConnect(){ + const params = new URLSearchParams({ origin: window.location.origin, host: window.location.hostname || 'ce serveur' }); + const url = AJEAN_APP_ORIGIN + '/connect.html?' + params.toString(); + const pop = window.open(url, 'ajean-connect', 'width=440,height=640'); + if(!pop){ toast('autorisez les popups pour connecter ajean.link'); return; } + + async function onMsg(ev){ + // Anti-usurpation : n'accepte QUE des messages du portail ajean.link. + if(ev.origin !== AJEAN_APP_ORIGIN) return; + const d = ev.data || {}; + if(d.type !== 'ajean-link' || !d.token) return; + window.removeEventListener('message', onMsg); + try{ + const r = await jpost('/api/link/connect', { token: d.token }); + if(r && r.linked){ + toast('✓ accès distant connecté'); + if(r.serviceErr){ toast('token enregistré (service : '+r.serviceErr+')'); } + loadRemote(); + } else { + toast((r && r.error) || 'échec de la connexion'); + } + }catch(e){ toast('erreur : '+e); } + } + window.addEventListener('message', onMsg); +} + +async function remoteDisconnect(){ + const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'}); + if(!ok) return; + try{ await jpost('/api/link/disconnect', {}); toast('accès distant coupé'); loadRemote(); } + catch(e){ toast('erreur : '+e); } +} + +async function remotePairCode(){ + const box = document.getElementById('remote-pair'); + box.style.display=''; box.textContent='génération du code…'; + try{ + const r = await jpost('/api/link/paircode', {}); + if(r && r.code){ + box.innerHTML = 'Empreinte : '+(r.fingerprint||'—')+'
    Code d\'appairage (valable 10 min, usage unique) : '+r.code+''; + } else { + box.textContent = (r && r.error) || 'code indisponible'; + } + }catch(e){ box.textContent='erreur : '+e; } +} diff --git a/internal/jean/web_link_api.go b/internal/jean/web_link_api.go new file mode 100644 index 0000000..4086940 --- /dev/null +++ b/internal/jean/web_link_api.go @@ -0,0 +1,85 @@ +package jean + +// web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI +// web de Jean, pour éviter le terminal. Le panneau « Accès distant » ouvre une +// popup app.ajean.link/connect.html qui gère compte + paiement puis renvoie une +// clé de liaison (jl_…) ; l'UI la POSTe ici, et Jean fait le `jean link` tout +// seul (écrit le token + (re)démarre le service). +// +// Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais, +// newLinkHandler refuse tout /api/* non chiffré (boîte noire). La configuration +// de l'accès distant se fait donc depuis la machine elle-même, ce qui est correct. + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// remoteServerURL est l'URL du portail distant pointant droit sur cette machine. +func remoteServerURL() string { + return "https://app.ajean.link/server.html?m=" + machineID() +} + +// handleLinkStatus (GET /api/link/status) : état de l'accès distant pour l'UI. +func handleLinkStatus(w http.ResponseWriter, r *http.Request) { + tok := readLinkToken() + sendJSON(w, http.StatusOK, map[string]any{ + "linked": tok != "", + "active": linkServiceActive(), + "machineURL": remoteServerURL(), + "fingerprint": e2eFingerprint(), + }) +} + +// handleLinkConnect (POST /api/link/connect {token}) : enregistre la clé de +// liaison remise par la popup connect.html et (re)démarre le service de lien. +func handleLinkConnect(w http.ResponseWriter, r *http.Request) { + var req struct{ Token string } + _ = json.NewDecoder(r.Body).Decode(&req) + tok := strings.TrimSpace(req.Token) + if !strings.HasPrefix(tok, "jl_") { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "clé de liaison invalide"}) + return + } + if err := saveLinkToken(tok); err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) + return + } + // (re)démarre le worker pour qu'il prenne la nouvelle clé. Sur une machine sans + // systemd (dev/Windows), le token est quand même enregistré : on renvoie l'info + // mais on signale que le service n'a pas pu démarrer ici. + svcErr := "" + if err := startLink(true); err != nil { + svcErr = err.Error() + } + sendJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "linked": true, + "active": linkServiceActive(), + "machineURL": remoteServerURL(), + "serviceErr": svcErr, + }) +} + +// handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie +// la clé (équivalent `jean link stop` + `jean link logout`). +func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) { + _ = linkServiceCtl("stop") + _ = removeLinkToken() + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "linked": false, "active": false}) +} + +// handleLinkPairCode (POST /api/link/paircode) : génère un code d'appairage frais +// (usage unique, TTL 10 min) + l'empreinte E2E, à saisir UNE fois dans le portail +// distant lors de la première connexion (confirmation de la boîte noire). Évite le +// détour par `jean link code` en terminal. +func handleLinkPairCode(w http.ResponseWriter, r *http.Request) { + code, err := newPairCode() + if err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": fmt.Sprintf("génération du code (droits ?): %v", err)}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"code": code, "fingerprint": e2eFingerprint()}) +} diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index e1a479e..a11abf2 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -107,6 +107,10 @@ func newWebMux() *http.ServeMux { api("/api/agent/compact", handleCompactToggle) api("/api/apikey", handleAPIKey) api("/api/oai/public", handleOAIPublic) + api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link) + api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → jean link + api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé + api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion api("/api/internet", handleInternet) api("/api/mcp", handleMCP) api("/api/mcp/save", handleMCPSave) @@ -187,7 +191,7 @@ func killPid(pid int, force bool) { if force { args = append(args, "/F") } - _ = exec.Command("taskkill", args...).Run() + _ = hideCmd(exec.Command("taskkill", args...)).Run() return } sig := "-TERM" @@ -204,7 +208,7 @@ func killPid(pid int, force bool) { func pidOnPort(port int) (int, string) { if runtime.GOOS == "windows" { // netstat -ano : " TCP 0.0.0.0:8090 0.0.0.0:0 LISTENING 1234" - out, err := exec.Command("netstat", "-ano", "-p", "tcp").Output() + out, err := hideCmd(exec.Command("netstat", "-ano", "-p", "tcp")).Output() if err != nil { return 0, "" } @@ -240,7 +244,7 @@ func pidOnPort(port int) (int, string) { func processName(pid int) string { if runtime.GOOS == "windows" { // tasklist CSV : "jean.exe","1234","Console","1","12 345 K" - out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output() + out, err := hideCmd(exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH")).Output() if err == nil { if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 { return strings.TrimPrefix(f[0], "\"") From 142dd872995fa84be55db8ba5880d6f49671d756 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 24 Jul 2026 17:06:13 +0200 Subject: [PATCH 146/279] v0.5.2 : robustesse appels d'outils + nettoyage UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chat : valide le JSON des arguments d'outil avant de les stocker dans l'historique. Un modèle très quantifié pouvait émettre des arguments tronqués/non-JSON (non vides) qui, persistés, faisaient échouer le template de chat de llama.cpp à chaque requête → 500 en boucle jusqu'au reset. json.Valid remplace le simple test != "" (llm_client.go). - UI : section « Accès OpenAI » remise au propre (adresses locale/publique groupées, clé API), sans émojis ; « Accès distant » sans émojis. Classes .io-input/.io-cap partagées. --- cmd/jean/resource_windows_amd64.syso | Bin 8340 -> 8340 bytes cmd/jean/resource_windows_arm64.syso | Bin 8340 -> 8340 bytes cmd/jean/versioninfo.json | 6 +-- internal/jean/llm_client.go | 10 ++++- internal/jean/run.go | 2 +- internal/jean/ui/index.html | 53 ++++++++++++++----------- internal/jean/ui/src/index.tmpl.html | 45 +++++++++++---------- internal/jean/ui/src/js/06-settings.js | 2 +- internal/jean/ui/src/styles.css | 6 +++ 9 files changed, 73 insertions(+), 51 deletions(-) diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index bc0a39b3b7f6d675d05456065a7c8154fcd1d8a8..49a3bc40cf70da22a7588075ba0cceb7f5f677d6 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^m11kdvFl|;lnf^$11kdvFm6_s5oBUC+-xcr%m}19<+&LFgJ%ZI diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 2552e14c5e54ba0b33d0fecdd12533bacd0c9f0b..45c3f44cc6da12271636c25eed6c4277c4020e1c 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^m11kdvFl|;lnf^$11kdvFm6_s5oBUC+-xcr%m}19<+&LFgJ%ZI diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 02208c7..64ff05d 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 5, - "Patch": 1, + "Patch": 2, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 5, - "Patch": 1, + "Patch": 2, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "Jean", - "ProductVersion": "0.5.1", + "ProductVersion": "0.5.2", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/llm_client.go b/internal/jean/llm_client.go index 88bd1aa..9ed4837 100644 --- a/internal/jean/llm_client.go +++ b/internal/jean/llm_client.go @@ -679,7 +679,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if tc.ID == "" { tc.ID = fmt.Sprintf("call_%d_%d", iter, i) } - if tc.Function.Arguments == "" { + // Les arguments DOIVENT être du JSON valide : ils sont rangés dans + // l'historique vu par le modèle, et le template de chat de llama.cpp les + // re-parse à CHAQUE requête suivante. Un modèle très quantifié peut sortir + // des arguments vides OU tronqués/non-JSON (ex: `{"command":"python3 …`) ; + // stockés tels quels, ils font échouer le parsing du template → 500 en + // boucle jusqu'au reset. On neutralise tout ce qui n'est pas du JSON valide + // en objet vide (l'appel a de toute façon déjà été exécuté). json.Valid("") + // étant faux, ça couvre aussi le cas vide d'origine. + if !json.Valid([]byte(tc.Function.Arguments)) { tc.Function.Arguments = "{}" } tcs = append(tcs, tc) diff --git a/internal/jean/run.go b/internal/jean/run.go index 8fc0d61..c8712a8 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.5.1" +const Version = "0.5.2" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index d4f3233..45c91c6 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -378,6 +378,12 @@ button:disabled{opacity:.5;cursor:not-allowed} .mcp-tool-row:hover{background:var(--panel)} .mcp-tool-switch{flex:none;transform:scale(.85)} .mcp-tool-name{font-size:12px;color:var(--text);word-break:break-all} + +/* Accès OpenAI / Accès distant : champs lecture seule (URL, clé) + libellés + compacts, pour une présentation homogène et sans surcharge visuelle. */ +.io-cap{color:var(--dim);font-size:11px;margin:0 0 3px} +.io-input{flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit;font-size:12px} +.io-group{margin-top:12px}
    @@ -455,27 +461,28 @@ button:disabled{opacity:.5;cursor:not-allowed}
    Accès OpenAI -
    Endpoint compatible OpenAI?Branche n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur cette URL. Modèle : jean. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
    - -
    Accès OpenAI -
    Endpoint compatible OpenAI?Branche n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur cette URL. Modèle : jean. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
    - - +
    Mode agent - +
    Mémoire?
    @@ -686,7 +781,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
    -
    Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠️ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
    +
    Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
    @@ -770,17 +865,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
    …
    -
    ⚡ Version rapide conseillé
    +
    Version rapide conseillé
    prête à l'emploi, installée en ~2 min
    -
    🔧 Version optimisée
    +
    Version optimisée
    compilée pour votre machine, plus longue
    -
    🧩 Backend personnalisé
    +
    Backend personnalisé
    forks llama.cpp pour modèles spéciaux (ternaire…)
    @@ -804,7 +899,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
    - +
    @@ -822,18 +917,18 @@ button:disabled{opacity:.5;cursor:not-allowed}
    - +
    -
    Entrée pour envoyer · Maj+Entrée = nouvelle ligne
    +
    Entrée pour envoyer · Maj+Entrée = nouvelle ligne
    Apparence -
    System prompt diff --git a/internal/jean/ui/src/js/01-theme.js b/internal/jean/ui/src/js/01-theme.js index ff4e616..1be7036 100644 --- a/internal/jean/ui/src/js/01-theme.js +++ b/internal/jean/ui/src/js/01-theme.js @@ -2,9 +2,28 @@ // Registre des thèmes disponibles. Pour en AJOUTER un : ajouter un bloc // [data-theme="…"] dans le
    @@ -763,25 +702,27 @@ button:disabled{opacity:.5;cursor:not-allowed}
    
       
    -  
    GPU -
    …
    + +
    Machine +
    +
    …
    +
    +
    -
    RAM -
    …
    +
    Config active +
    …
    -
    Config active -
    …
    -
    -
    Presets +
    Presets
    -
    Mode agent - +
    Mode agent +
    Mémoire?
    -
    -
    Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI. Actifs seulement si le serveur répond.
    +
    Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI. Actifs seulement si le serveur répond.
    - +
    - +
    @@ -816,7 +757,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
    -
    Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
    +
    Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
    @@ -826,37 +767,38 @@ button:disabled{opacity:.5;cursor:not-allowed}
    -
    Apparence - - - -
    Affichage
    +
    Apparence +
    + +
    +
    Affichage
    +
    +
    -
    System prompt - +
    System prompt +
    -
    Accès OpenAI +
    Accès OpenAI
    Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
    Modèle : jean
    +
    Adresse locale (réseau)
    - - +
    +
    + -
    Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
    -
    +
    Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
    +
    +
    @@ -875,33 +818,36 @@ button:disabled{opacity:.5;cursor:not-allowed}
    +
    -
    Accès distant +
    Accès distant
    ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
    -
    +

    Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

    -
    MOTEUR +
    Moteur
    …
    @@ -926,20 +872,24 @@ button:disabled{opacity:.5;cursor:not-allowed}
    -
    Service +
    Service +
    +
    -
    Actions -
    -
    -
    - - - +
    Actions +
    +
    +
    +
    + + + +
    @@ -965,7 +915,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
    -
    -
    Apparence - - - -
    Affichage
    +
    Apparence +
    + +
    +
    Affichage
    +
    +
    -
    System prompt - +
    System prompt +
    -
    Accès OpenAI +
    Accès OpenAI
    Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
    Modèle : jean
    +
    Adresse locale (réseau)
    - - +
    +
    + -
    Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
    -
    +
    Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
    +
    +
    @@ -150,33 +154,36 @@
    +
    -
    Accès distant +
    Accès distant
    ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
    -
    +

    Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

    -
    MOTEUR +
    Moteur
    …
    @@ -201,20 +208,24 @@
    -
    Service +
    Service +
    +
    -
    Actions -
    -
    -
    - - - +
    Actions +
    +
    +
    +
    + + + +
    @@ -240,7 +251,7 @@
    -
    System prompt @@ -193,18 +195,18 @@
    …
    -
    Version rapide conseillé
    -
    prête à l'emploi, installée en ~2 min
    +
    llama.cpp précompilé conseillé
    +
    binaire officiel téléchargé tel quel, prêt en ~2 min
    -
    Version optimisée
    -
    compilée pour votre machine, plus longue
    +
    llama.cpp compilé
    +
    compilé ici pour votre machine — plus rapide, plus long à installer
    -
    Backend personnalisé
    -
    forks llama.cpp pour modèles spéciaux (ternaire…)
    +
    llama.cpp personnalisé
    +
    un fork de llama.cpp pour modèles spéciaux (quant ternaire…)
    @@ -213,14 +215,16 @@
    ▸ détails
    -
    -
    Service + +
    Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
    -
    - - - -
    +
    + + + +
    Actions @@ -413,8 +417,8 @@
    Moteur
    - - + +
    diff --git a/internal/jean/ui/src/js/07-models.js b/internal/jean/ui/src/js/07-models.js index 42e2cf3..2a504ff 100644 --- a/internal/jean/ui/src/js/07-models.js +++ b/internal/jean/ui/src/js/07-models.js @@ -89,6 +89,9 @@ async function openItem(kind, key){ rawToggle.style.display = ''; rawBody.style.display = 'none'; rawCaret.classList.remove('open'); + // Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture. + document.getElementById('m-dir-path').value = ''; + setModelDirsOpen(false); await Promise.all([populateBackend(), populateModelPicker()]); } else { modelRow.style.display = 'none'; @@ -115,24 +118,109 @@ function currentModelInTextarea(){ const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m); return m ? m[1].trim() : ''; } +// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers : +// on ne contrôle pas ce que l'utilisateur y met). +function escHtml(s){ + return String(s==null?'':s).replace(/[&<>"']/g, c => + ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +// Compare deux chemins/noms de modèle en neutralisant séparateurs et casse. +function samePath(a, b){ + const n = x => String(x||'').trim().replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase(); + return !!a && !!b && n(a) === n(b); +} +function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); } async function populateModelPicker(){ const sel = document.getElementById('m-model'); const list = await jget('/api/models'); const cur = currentModelInTextarea(); - // Strip directory; config.env conventionally only stores the file basename. - const curBase = (cur.split('/').pop() || '').trim(); + // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les + // regroupe par dossier, JEAN_HOME d'abord (l'API les renvoie dans cet ordre). + const groups = []; + for(const m of (list||[])){ + let g = groups.find(x => x.dir === m.dir); + if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); } + g.items.push(m); + } let html = ''; let matched = false; - for(const m of list){ - const sel = (m.name === curBase) ? ' selected' : ''; - if(sel) matched = true; - html += ''; + for(const g of groups){ + let opts = ''; + for(const m of g.items){ + // Le preset peut référencer le chemin complet OU le simple nom de fichier. + const on = samePath(cur, m.value) || samePath(cur, m.path) || + (g.home && samePath(baseName(cur), m.name)) ? ' selected' : ''; + if(on) matched = true; + opts += ''; + } + html += groups.length > 1 + ? ''+opts+'' + : opts; } - // If MODEL points to something not in JEAN_HOME, show it as a disabled hint. + // MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré : + // on déplie alors la section des dossiers, c'est là que ça se répare. if(cur && !matched){ - html += ''; + html += ''; } sel.innerHTML = html; + if(cur && !matched) setModelDirsOpen(true); +} +// ---- Dossiers de modèles : JEAN_HOME + dossiers ajoutés (disque externe…) --- +// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. +function setModelDirsOpen(open){ + const b = document.getElementById('m-dirs-body'); + const c = document.getElementById('m-dirs-caret'); + if(!b) return; + b.style.display = open ? '' : 'none'; + if(c) c.classList.toggle('open', open); + if(open) populateModelDirs(); +} +function toggleModelDirs(){ + setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none'); +} +async function populateModelDirs(){ + const box = document.getElementById('m-dirs-list'); + if(!box) return; + let d = {}; + try{ d = await jget('/api/models/dirs'); }catch(_){ return; } + // Construit en DOM (pas en HTML concaténé) : les chemins viennent de + // l'utilisateur et se retrouveraient sinon dans un attribut onclick. + box.innerHTML = ''; + for(const x of (d.dirs||[])){ + const row = document.createElement('div'); + const p = document.createElement('span'); + p.textContent = x.path + ' '; + const info = document.createElement('span'); + info.className = 'muted'; + const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; + info.textContent = '(' + n + (x.home ? ', dossier jean' : '') + ')'; + row.append(p, info); + if(!x.home){ + const del = document.createElement('button'); + del.className = 'pe-link'; + del.textContent = 'retirer'; + del.style.marginLeft = '8px'; + del.onclick = () => removeModelDir(x.path); + row.append(del); + } + box.append(row); + } +} +async function addModelDir(){ + const inp = document.getElementById('m-dir-path'); + const p = inp.value.trim(); + if(!p){ toast('indique un chemin de dossier'); return; } + const r = await jpost('/api/models/dirs', {path: p, action: 'add'}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + inp.value = ''; + toast('dossier ajouté'); + await Promise.all([populateModelDirs(), populateModelPicker()]); +} +async function removeModelDir(p){ + const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('dossier retiré'); + await Promise.all([populateModelDirs(), populateModelPicker()]); } function onPickModel(){ const val = document.getElementById('m-model').value; diff --git a/internal/jean/ui/src/js/13-remote.js b/internal/jean/ui/src/js/13-remote.js index 040c64f..cc158fa 100644 --- a/internal/jean/ui/src/js/13-remote.js +++ b/internal/jean/ui/src/js/13-remote.js @@ -9,8 +9,10 @@ function renderRemote(d){ const off = document.getElementById('remote-off'), on = document.getElementById('remote-on'); const badge = document.getElementById('remote-badge'); if(!d || !d.linked){ + // Serveur jamais lié : on l'affiche explicitement plutôt que de laisser la + // pastille vide, qui se lisait comme « je ne sais pas ». off.style.display=''; on.style.display='none'; - setBadge(badge, null); + setBadge(badge, false, 'non connecté'); return; } off.style.display='none'; on.style.display=''; @@ -28,7 +30,9 @@ async function loadRemote(){ // (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache. const det = document.getElementById('remote-details'); if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; } - try{ renderRemote(await jget('/api/link/status')); }catch(e){} + // Statut injoignable : on retombe sur « non connecté » plutôt que de garder + // l'affichage précédent, qui pourrait annoncer « connecté » à tort. + try{ renderRemote(await jget('/api/link/status')); }catch(e){ renderRemote(null); } } // Ouvre la popup de connexion et attend la clé renvoyée par postMessage. diff --git a/internal/jean/web_api.go b/internal/jean/web_api.go index 43bf035..70d35f8 100644 --- a/internal/jean/web_api.go +++ b/internal/jean/web_api.go @@ -261,25 +261,44 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true}) } -// handleModels lists *.gguf files in JEAN_HOME (size in bytes) for the preset -// editor's model picker. +// handleModels lists *.gguf files (size in bytes) for the preset editor's model +// picker, dans JEAN_HOME ET dans les dossiers supplémentaires déclarés (disque +// externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de +// fichier pour JEAN_HOME (compatibilité avec l'existant), le chemin complet +// pour un dossier externe. func handleModels(w http.ResponseWriter, r *http.Request) { - entries, err := os.ReadDir(JeanHome()) - if err != nil { - sendJSON(w, 200, []map[string]any{}) - return - } out := []map[string]any{} - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { + seen := map[string]bool{} + home := JeanHome() + for _, dir := range modelDirs() { + entries, err := os.ReadDir(dir) + if err != nil { continue } - info, _ := e.Info() - size := int64(0) - if info != nil { - size = info.Size() + isHome := normDir(dir) == normDir(home) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { + continue + } + full := filepath.Join(dir, e.Name()) + if seen[normDir(full)] { + continue + } + seen[normDir(full)] = true + info, _ := e.Info() + size := int64(0) + if info != nil { + size = info.Size() + } + value := full + if isHome { + value = e.Name() + } + out = append(out, map[string]any{ + "name": e.Name(), "size": size, "value": value, + "path": full, "dir": dir, "home": isHome, + }) } - out = append(out, map[string]any{"name": e.Name(), "size": size}) } sendJSON(w, 200, out) } diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index 211b8ca..e51fdd8 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -93,6 +93,7 @@ func newWebMux() *http.ServeMux { api("/api/update/apply", handleUpdateApply) api("/api/models", handleModels) api("/api/models/delete", handleModelDelete) + api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…) api("/api/models/download", handleModelDownload) api("/api/models/download/status", handleModelDownloadStatus) api("/api/models/download/cancel", handleModelDownloadCancel) From 7b0a5dbf75d59ff203f89242ace36599ad561be4 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 3 Aug 2026 15:12:01 +0200 Subject: [PATCH 182/279] =?UTF-8?q?v0.6.9=20:=20l'IA=20=C3=A9crit=20enfin?= =?UTF-8?q?=20les=20fichiers=20directement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvel outil write : créer ou réécrire un fichier passe par le disque, plus par le shell. Sous Windows le shell est cmd.exe et son quoting cassait tout script contenant des guillemets imbriqués (echo, python -c, base64) ; l'IA bouclait sans jamais y arriver. Signalé par Arnaud Hocquelet. L'outil bash annonce désormais le vrai shell de la plateforme (cmd.exe sous Windows) au lieu de dire bash partout, pour que le modèle arrête de générer la syntaxe d'un autre shell. Interface : le contenu d'une écriture se remplit ligne à ligne en direct dans la bulle (diffusé à la ligne et pas au token, pour ne pas saturer le rendu mobile), et les compteurs +N -N passent sur l'étiquette pour rester visibles une fois la bulle repliée. --- cmd/jean/resource_windows_amd64.syso | Bin 8340 -> 8340 bytes cmd/jean/resource_windows_arm64.syso | Bin 8340 -> 8340 bytes cmd/jean/versioninfo.json | 6 +- internal/jean/chat_agent.go | 4 +- internal/jean/chat_cmd.go | 3 + internal/jean/chat_conversation.go | 5 ++ internal/jean/chat_tools.go | 51 ++++++++++++++- internal/jean/chat_tools_write_test.go | 50 +++++++++++++++ internal/jean/llm_client.go | 75 +++++++++++++++++++--- internal/jean/run.go | 2 +- internal/jean/ui/index.html | 60 +++++++++++++++-- internal/jean/ui/src/index.tmpl.html | 2 +- internal/jean/ui/src/js/08-chat-render.js | 51 +++++++++++++-- internal/jean/ui/src/styles.css | 7 ++ 14 files changed, 286 insertions(+), 30 deletions(-) create mode 100644 internal/jean/chat_tools_write_test.go diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index daa703f81df55dcfdbc004d232d97aa0ce4c209d..38393b05f33e15c494ee5cb0ff49e1f2fd6cccac 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^)0~-SfaBfzX5oKbu+-xZq%m}1<<=Ghlh7AVv delta 36 ncmbQ@IK^>;lnf^a0~-SfaBNnV5oKbu*lZ~m%m}1<<=Ghlg~|r- diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 2da7299b3ffaf4d2dca5ae79dc517bd59b82617f..067b298e8331c84a2416b91ca847711686cc5eb1 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^)0~-SfaBfzX5oKbu+-xZq%m}1<<=Ghlh7AVv delta 36 ncmbQ@IK^>;lnf^a0~-SfaBNnV5oKbu*lZ~m%m}1<<=Ghlg~|r- diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 3834654..dc6b939 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 8, + "Patch": 9, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 8, + "Patch": 9, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.8", + "ProductVersion": "0.6.9", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/chat_agent.go b/internal/jean/chat_agent.go index a8adcad..fdbaccd 100644 --- a/internal/jean/chat_agent.go +++ b/internal/jean/chat_agent.go @@ -55,7 +55,7 @@ func cmdAgent(args []string) error { if err := setAgentEnabled(true); err != nil { return err } - fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet (bash) et de sa mémoire") + fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet (" + shellName() + "), de l'écriture de fichiers et de sa mémoire") case "off": if err := setAgentEnabled(false); err != nil { return err @@ -67,7 +67,7 @@ func cmdAgent(args []string) error { state = green("on") } fmt.Printf("%s état: %s\n", cyan("Mode agent"), state) - fmt.Printf(" outils : bash (timeout %ds, max %ds) + mémoire (mem_search/mem_read/mem_add/mem_edit)\n", toolDefaultTimeout, toolMaxTimeout) + fmt.Printf(" outils : %s (timeout %ds, max %ds) + write/edit + mémoire (mem_search/mem_read/mem_add/mem_edit)\n", shellName(), toolDefaultTimeout, toolMaxTimeout) mem := MemList() if len(mem) == 0 { fmt.Printf(" mémoire : aucune page — crée %s/.md\n", memoryDir()) diff --git a/internal/jean/chat_cmd.go b/internal/jean/chat_cmd.go index 2dfc54f..a6cb6dd 100644 --- a/internal/jean/chat_cmd.go +++ b/internal/jean/chat_cmd.go @@ -106,6 +106,9 @@ func cmdChat(args []string) error { case "bash": icon = "⚙️" verb = "exécution" + case "write": + icon = "📄" + verb = "écriture" case "edit": icon = "✏️" verb = "édition" diff --git a/internal/jean/chat_conversation.go b/internal/jean/chat_conversation.go index 6c5a383..9a2420b 100644 --- a/internal/jean/chat_conversation.go +++ b/internal/jean/chat_conversation.go @@ -339,6 +339,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa "name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label, "result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing, } + // Corps en cours de frappe : transitoire (l'état final est le diff), on + // ne l'ajoute que quand il est là pour ne pas gonfler chaque événement. + if ev.ToolUsed.Body != "" { + tu["body"] = ev.ToolUsed.Body + } // Lignes +/- d'une écriture (edit / mémoire) : persistées avec le reste // pour que le diff soit encore là après un rafraîchissement. if len(ev.ToolUsed.Diff) > 0 { diff --git a/internal/jean/chat_tools.go b/internal/jean/chat_tools.go index dbd72b9..56d52e1 100644 --- a/internal/jean/chat_tools.go +++ b/internal/jean/chat_tools.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "os/user" + "path/filepath" "runtime" "strconv" "strings" @@ -47,7 +48,8 @@ func baseSystemPrompt(caps Caps) string { } b.WriteString("\n\nTools:\n") if caps.Agent { - b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts).\n") + b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts). The shell is " + shellName() + ": use its syntax.\n") + b.WriteString("- write — create or rewrite a file with exact content.\n") b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n") } if hasMem { @@ -63,7 +65,10 @@ func baseSystemPrompt(caps Caps) string { b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n") } if caps.Agent { - b.WriteString("\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n") + // Règle courte mais indispensable : sans elle le modèle fabrique ses scripts + // avec echo/python -c, ce que cmd.exe massacre (guillemets imbriqués). + b.WriteString("\nTo create or rewrite a file, always use write (or edit to patch one) — never echo, cat, python -c or Set-Content through the shell; quoting breaks. Write the script with write, then run it with bash.\n") + b.WriteString("For anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n") if caps.Mem == MemAlways { b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n") } @@ -164,6 +169,48 @@ func runShell(command string, timeoutSec int) string { return strings.Join(parts, "\n\n") } +// shellName is the shell runShell actually spawns on this platform. The model is +// told this explicitly: advertising the tool as "bash" on Windows made it emit +// bash quoting into cmd.exe, which mangles it (unterminated string literals, and +// stray "Commande ECHO activée." landing inside generated files). +func shellName() string { + if runtime.GOOS == "windows" { + return "cmd.exe" + } + return "bash" +} + +// fileWrite writes content to path verbatim, creating parent directories and +// replacing any existing file. This is the escape hatch from shell quoting: a +// model with only a shell has to build files with echo/python -c, which is +// unreliable everywhere and outright broken on cmd.exe. +func fileWrite(path, content string) string { + if strings.TrimSpace(path) == "" { + return "[erreur] chemin vide" + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "[erreur] " + err.Error() + } + } + // Préserve les permissions d'origine quand le fichier existe déjà (un script + // 0755 réécrit doit rester exécutable). + mode := os.FileMode(0o644) + existed := false + if fi, err := os.Stat(path); err == nil { + mode = fi.Mode() + existed = true + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + return "[erreur] " + err.Error() + } + verb := "créé" + if existed { + verb = "réécrit" + } + return fmt.Sprintf("[ok] %s %s (%d octets)", path, verb, len(content)) +} + // fileEdit applies a single exact-text replacement to a file on disk: oldText // must appear EXACTLY once (otherwise it errors), so the model can patch a file // without rewriting it whole. Returns a short status string for the tool result. diff --git a/internal/jean/chat_tools_write_test.go b/internal/jean/chat_tools_write_test.go new file mode 100644 index 0000000..cab2195 --- /dev/null +++ b/internal/jean/chat_tools_write_test.go @@ -0,0 +1,50 @@ +package jean + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Le contenu doit arriver sur le disque à l'octet près : c'est tout l'intérêt de +// l'outil write (les guillemets imbriqués d'un script Python ne survivaient pas +// au passage par le shell). +func TestFileWriteVerbatim(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "kpi.py") + content := "import sqlite3\nq = \"SELECT * FROM t WHERE n = 'a'\"\nprint(q)\n" + + res := fileWrite(path, content) + if !strings.HasPrefix(res, "[ok]") { + t.Fatalf("write a échoué: %s", res) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("relecture: %v", err) + } + if string(got) != content { + t.Fatalf("contenu altéré:\n%q\nattendu:\n%q", got, content) + } +} + +func TestFileWriteOverwrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "a.txt") + if res := fileWrite(path, "ancien"); !strings.HasPrefix(res, "[ok]") { + t.Fatalf("premier write: %s", res) + } + res := fileWrite(path, "nouveau") + if !strings.Contains(res, "réécrit") { + t.Fatalf("attendu « réécrit », obtenu: %s", res) + } + got, _ := os.ReadFile(path) + if string(got) != "nouveau" { + t.Fatalf("contenu = %q", got) + } +} + +func TestFileWriteRejectsEmptyPath(t *testing.T) { + if res := fileWrite(" ", "x"); !strings.HasPrefix(res, "[erreur]") { + t.Fatalf("attendu une erreur, obtenu: %s", res) + } +} diff --git a/internal/jean/llm_client.go b/internal/jean/llm_client.go index b1734a7..c4880cb 100644 --- a/internal/jean/llm_client.go +++ b/internal/jean/llm_client.go @@ -140,12 +140,30 @@ func editTool() Tool { } } +func writeTool() Tool { + return Tool{ + Type: "function", + Function: ToolFunction{ + Name: "write", + Description: "Write a file on disk with the exact content given (creates it, or replaces it whole; parent directories are created). ALWAYS use this to create a script or any text file — NEVER build a file through the shell with echo, cat, python -c or Set-Content: the shell mangles quotes and breaks the file. Content is written verbatim, no escaping needed.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]any{"type": "string", "description": "Path of the file to write"}, + "content": map[string]any{"type": "string", "description": "Full content of the file, verbatim"}, + }, + "required": []string{"file", "content"}, + }, + }, + } +} + func bashTool() Tool { return Tool{ Type: "function", Function: ToolFunction{ Name: "bash", - Description: "Execute a shell command (bash) on this machine and return stdout, stderr and the exit code. For inspecting the system, running scripts, reading/editing files, reading logs. Avoid destructive commands unless explicitly asked.", + Description: "Execute a shell command on this machine (" + shellName() + ") and return stdout, stderr and the exit code. Use " + shellName() + " syntax, not another shell's. For inspecting the system, running scripts, reading files, reading logs. To CREATE or REWRITE a file, use the write tool instead — never echo/cat/python -c. Avoid destructive commands unless explicitly asked.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -223,7 +241,7 @@ func InjectSkills(msgs []Message, caps Caps) []Message { func EnabledTools(caps Caps) []Tool { tools := []Tool{} if caps.Agent { - tools = append(tools, bashTool(), editTool()) + tools = append(tools, bashTool(), writeTool(), editTool()) } // Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès // que le mode mémoire n'est pas « off » (que l'agent soit actif ou non). @@ -278,11 +296,29 @@ type ToolUsedEvent struct { Result string // tool output (stdout/stderr/exit for run_shell, skill body for read_skill) Done bool // false = call announced (command only); true = result is ready Typing bool // true = command still being written (partial), no spinner yet + // Body : contenu en cours d'écriture par un outil d'écriture (write/edit, + // mem_add/mem_edit), diffusé ligne à ligne pendant que le modèle le tape, + // pour que la bulle se remplisse en direct au lieu de rester figée puis de + // s'ouvrir d'un coup. Transitoire : seul Diff (état final) est rejoué. + Body string // Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose // (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-). Diff []DiffLine } +// writeBodyKey returns the argument holding the text an écriture tool is about +// to commit — the part worth showing live in the bubble — or "" for tools that +// have no such body (bash, lectures, recherches). +func writeBodyKey(tool string) string { + switch tool { + case "write", "mem_add": + return "content" + case "edit", "mem_edit": + return "new" + } + return "" +} + // previewArg pulls the (possibly incomplete) string value of key out of a // streaming tool-call arguments JSON, so the UI can show the command being // typed live. Best-effort: it tolerates a truncated tail and basic escapes. @@ -522,7 +558,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ // pour que les consommateurs (terminal, web) aient toujours tout. var stats StatsEvent - lastPreview := "" // last command preview emitted (to stream the typing) + lastPreview := "" // last command preview emitted (to stream the typing) + lastBodyLines := -1 // lignes déjà diffusées du corps en cours d'écriture // Per-completion reasoning-split state (see reasoningOn comment above). sawReasoningField := false thinkOpen := reasoningOn @@ -608,14 +645,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps switch cur.Function.Name { case "mem_search", "web_search": key = "query" - case "mem_read", "mem_add", "mem_edit", "edit": + case "mem_read", "mem_add", "mem_edit", "edit", "write": key = "file" case "web_open", "web_read", "web_grep": key = "url" } - if p := previewArg(cur.Function.Arguments, key); p != "" && p != lastPreview { - lastPreview = p - if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: p, Typing: true}}) { + p := previewArg(cur.Function.Arguments, key) + // Corps en cours de frappe pour les outils d'écriture : on le diffuse + // à la LIGNE, pas au token. Un événement par token republierait tout le + // contenu à chaque fois (coût quadratique, et c'est ce flot qui saturait + // le rendu mobile) ; à la ligne, le nombre d'événements est celui du + // fichier et l'animation reste fluide. + body := "" + if bk := writeBodyKey(cur.Function.Name); bk != "" { + body = previewArg(cur.Function.Arguments, bk) + } + grew := body != "" && strings.Count(body, "\n") > lastBodyLines + if (p != "" && p != lastPreview) || grew { + if p != "" { + lastPreview = p + } + if grew { + lastBodyLines = strings.Count(body, "\n") + } + if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) { aborted = true break } @@ -744,7 +797,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps switch tc.Function.Name { case "mem_search", "web_search": label, _ = args["query"].(string) - case "mem_read", "mem_add", "mem_edit", "edit": + case "mem_read", "mem_add", "mem_edit", "edit", "write": label, _ = args["file"].(string) case "bash": label, _ = args["command"].(string) @@ -827,6 +880,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps result = fmt.Sprintf("[ok] page '%s' modifiée", label) diff = lineDiff(oldText, newText) } + case "write": + content, _ := args["content"].(string) + result = fileWrite(label, content) + if !strings.HasPrefix(result, "[erreur]") { + diff = addedDiff(content) + } case "edit": oldText, _ := args["old"].(string) newText, _ := args["new"].(string) diff --git a/internal/jean/run.go b/internal/jean/run.go index 9441879..11b8d20 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.8" +const Version = "0.6.9" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index a6539bc..59ae378 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -273,6 +273,13 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)} .msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)} .msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)} +/* Écriture en direct : le bloc se remplit ligne à ligne pendant que le modèle + tape. Seule la dernière ligne est en fondu (.fresh) — animer tout le bloc à + chaque nouvelle ligne le ferait clignoter. Hauteur bornée + scroll auto en + bas (voir 08-chat-render.js) pour qu'un long script ne pousse pas le fil. */ +.msg.tool pre.diff.live .dl.fresh{animation:difffresh .18s ease-out} +@keyframes difffresh{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}} +@media (prefers-reduced-motion:reduce){.msg.tool pre.diff.live .dl.fresh{animation:none}} .diff-count{margin-left:8px;letter-spacing:0;text-transform:none} .diff-count .a{color:var(--add)} .diff-count .d{color:var(--del)} @@ -881,7 +888,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
    Mode agent -
    +
    Mémoire?
    @@ -2621,6 +2628,17 @@ function collapseInstant(el){ requestAnimationFrame(()=>{ bw.style.transition=''; }); } function setLabel(el, text){ el.querySelector('.label').textContent = text; } +// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible +// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture +// survit au repli, donc on le met là plutôt que dans le corps seul. +function setLabelCounts(el, add, del){ + const lab=el.querySelector('.label'); + const cnt=document.createElement('span'); cnt.className='diff-count'; + if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add})); + if(add && del) cnt.appendChild(document.createTextNode(' ')); + if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); + lab.appendChild(cnt); +} // Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN // sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait // avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée @@ -2649,6 +2667,7 @@ function renderToolMsg(el, tu){ // (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire. const META = { bash: {lbl:'terminal', head:'commande'}, + write: {lbl:'fichier', head:'écriture'}, edit: {lbl:'édition', head:'édition'}, web_search: {lbl:'recherche', head:'recherche web'}, web_open: {lbl:'page web', head:'ouverture'}, @@ -2669,6 +2688,12 @@ function renderToolMsg(el, tu){ // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; } setLabel(el, lbl); + // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe) + // reporté sur l'étiquette, pour rester lisible bulle repliée. + let add=0, del=0; + if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } + else if(tu.body){ add=tu.body.split('\n').length; } + if(add||del) setLabelCounts(el, add, del); const body=bodyOf(el); body.innerHTML=''; const head=document.createElement('div'); head.className='tool-head'; head.textContent = meta.head; @@ -2679,16 +2704,37 @@ function renderToolMsg(el, tu){ if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); } pre.appendChild(code); body.appendChild(pre); } + // Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à + // ligne, dans la même forme que le diff final, pour que la bulle se remplisse + // sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la + // dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement + // ferait clignoter le bloc entier. + if(tu.body && !(tu.diff && tu.diff.length)){ + const lines=tu.body.split('\n'); + const sub=document.createElement('div'); sub.className='tool-sub'; + sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée) + body.appendChild(sub); + const pre=document.createElement('pre'); pre.className='diff live'; + lines.forEach((t,i)=>{ + const ln=document.createElement('span'); + ln.className='dl add'+(i===lines.length-1?' fresh':''); + ln.textContent='+ '+t; + if(i===lines.length-1 && tu.typing){ + const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; + ln.appendChild(car); + } + pre.appendChild(ln); + }); + body.appendChild(pre); + // Le bloc est re-créé à chaque événement : on le recale en bas pour suivre + // la ligne en cours (max-height côté CSS l'empêche de pousser le fil). + pre.scrollTop = pre.scrollHeight; + } // Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert, // retirées en rouge, contexte en gris — comme un diff de terminal. if(tu.diff && tu.diff.length){ const sub=document.createElement('div'); sub.className='tool-sub'; - let add=0, del=0; - tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); - sub.textContent='modifications'; - const cnt=document.createElement('span'); cnt.className='diff-count'; - cnt.innerHTML='+'+add+' -'+del+''; - sub.appendChild(cnt); + sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée) body.appendChild(sub); const pre=document.createElement('pre'); pre.className='diff'; tu.diff.forEach(l=>{ diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 239d7d5..9911af4 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -60,7 +60,7 @@
    Mode agent -
    +
    Mémoire?
    diff --git a/internal/jean/ui/src/js/08-chat-render.js b/internal/jean/ui/src/js/08-chat-render.js index 497da1a..0aacd60 100644 --- a/internal/jean/ui/src/js/08-chat-render.js +++ b/internal/jean/ui/src/js/08-chat-render.js @@ -104,6 +104,17 @@ function collapseInstant(el){ requestAnimationFrame(()=>{ bw.style.transition=''; }); } function setLabel(el, text){ el.querySelector('.label').textContent = text; } +// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible +// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture +// survit au repli, donc on le met là plutôt que dans le corps seul. +function setLabelCounts(el, add, del){ + const lab=el.querySelector('.label'); + const cnt=document.createElement('span'); cnt.className='diff-count'; + if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add})); + if(add && del) cnt.appendChild(document.createTextNode(' ')); + if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); + lab.appendChild(cnt); +} // Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN // sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait // avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée @@ -132,6 +143,7 @@ function renderToolMsg(el, tu){ // (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire. const META = { bash: {lbl:'terminal', head:'commande'}, + write: {lbl:'fichier', head:'écriture'}, edit: {lbl:'édition', head:'édition'}, web_search: {lbl:'recherche', head:'recherche web'}, web_open: {lbl:'page web', head:'ouverture'}, @@ -152,6 +164,12 @@ function renderToolMsg(el, tu){ // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; } setLabel(el, lbl); + // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe) + // reporté sur l'étiquette, pour rester lisible bulle repliée. + let add=0, del=0; + if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } + else if(tu.body){ add=tu.body.split('\n').length; } + if(add||del) setLabelCounts(el, add, del); const body=bodyOf(el); body.innerHTML=''; const head=document.createElement('div'); head.className='tool-head'; head.textContent = meta.head; @@ -162,16 +180,37 @@ function renderToolMsg(el, tu){ if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); } pre.appendChild(code); body.appendChild(pre); } + // Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à + // ligne, dans la même forme que le diff final, pour que la bulle se remplisse + // sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la + // dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement + // ferait clignoter le bloc entier. + if(tu.body && !(tu.diff && tu.diff.length)){ + const lines=tu.body.split('\n'); + const sub=document.createElement('div'); sub.className='tool-sub'; + sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée) + body.appendChild(sub); + const pre=document.createElement('pre'); pre.className='diff live'; + lines.forEach((t,i)=>{ + const ln=document.createElement('span'); + ln.className='dl add'+(i===lines.length-1?' fresh':''); + ln.textContent='+ '+t; + if(i===lines.length-1 && tu.typing){ + const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; + ln.appendChild(car); + } + pre.appendChild(ln); + }); + body.appendChild(pre); + // Le bloc est re-créé à chaque événement : on le recale en bas pour suivre + // la ligne en cours (max-height côté CSS l'empêche de pousser le fil). + pre.scrollTop = pre.scrollHeight; + } // Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert, // retirées en rouge, contexte en gris — comme un diff de terminal. if(tu.diff && tu.diff.length){ const sub=document.createElement('div'); sub.className='tool-sub'; - let add=0, del=0; - tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); - sub.textContent='modifications'; - const cnt=document.createElement('span'); cnt.className='diff-count'; - cnt.innerHTML='+'+add+' -'+del+''; - sub.appendChild(cnt); + sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée) body.appendChild(sub); const pre=document.createElement('pre'); pre.className='diff'; tu.diff.forEach(l=>{ diff --git a/internal/jean/ui/src/styles.css b/internal/jean/ui/src/styles.css index 380fbff..b8f5f25 100644 --- a/internal/jean/ui/src/styles.css +++ b/internal/jean/ui/src/styles.css @@ -247,6 +247,13 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)} .msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)} .msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)} +/* Écriture en direct : le bloc se remplit ligne à ligne pendant que le modèle + tape. Seule la dernière ligne est en fondu (.fresh) — animer tout le bloc à + chaque nouvelle ligne le ferait clignoter. Hauteur bornée + scroll auto en + bas (voir 08-chat-render.js) pour qu'un long script ne pousse pas le fil. */ +.msg.tool pre.diff.live .dl.fresh{animation:difffresh .18s ease-out} +@keyframes difffresh{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}} +@media (prefers-reduced-motion:reduce){.msg.tool pre.diff.live .dl.fresh{animation:none}} .diff-count{margin-left:8px;letter-spacing:0;text-transform:none} .diff-count .a{color:var(--add)} .diff-count .d{color:var(--del)} From 0e6763920b2436b6d478da1784ec2a07a5171c8f Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 3 Aug 2026 17:26:49 +0200 Subject: [PATCH 183/279] =?UTF-8?q?README=20:=20remise=20=C3=A0=20jour=20e?= =?UTF-8?q?t=20simplification,=20nouvelle=20capture=20d'=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le README avait pris du retard sur les versions 0.5 et 0.6. Ajouts : serveurs MCP (format mcp.json compatible Claude Desktop, transports stdio et HTTP), compactage automatique du contexte, commandes app et oai, téléchargement de modèles depuis l'UI. Corrections : le mode agent donne désormais terminal, écriture/édition de fichiers, mémoire, web et MCP — pas seulement « shell + mémoire ». La section Windows ne parle plus de « jean tools ». Tableaux de config et de variables d'environnement complétés (COMPACT, CRAWL4AI_KEY, JEAN_MODEL_DIRS, HF_TOKEN, JEAN_DL_CONNS). Structure resserrée : commandes regroupées par usage plutôt que par ordre historique, sections longues raccourcies sans perte d'information. --- README.md | 386 +++++++++++++++++++++++----------------------------- docs/ui.png | Bin 121436 -> 121015 bytes 2 files changed, 169 insertions(+), 217 deletions(-) diff --git a/README.md b/README.md index 3750f06..03b4124 100644 --- a/README.md +++ b/README.md @@ -2,325 +2,277 @@ ![Interface web d'AJEAN](docs/ui.png) -**Un gestionnaire mono-binaire pour serveurs [llama.cpp](https://github.com/ggml-org/llama.cpp) auto-hébergés — avec une interface web intégrée, un chat terminal, et un compilateur de backend qui détecte automatiquement le matériel.** - -Dépose un seul binaire sur une machine, lance `jean llamacpp install`, et AJEAN clone, configure et compile llama.cpp pour le matériel de *cette* machine (CUDA / ROCm / Metal / Vulkan / CPU) — aucun flag à retenir. Ensuite `jean start` et tu as un endpoint compatible OpenAI avec un chat web par-dessus. +**Un seul binaire pour faire tourner [llama.cpp](https://github.com/ggml-org/llama.cpp) chez toi : il compile le backend pour ton matériel, tient le service, et te donne un chat web complet par-dessus.** ``` télécharge le binaire → jean llamacpp install → jean edit → jean start → c'est parti ``` +Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur. Tu obtiens un endpoint compatible OpenAI et une interface web avec chat, mémoire, accès internet et outils. + --- -## Pourquoi +## Ce que ça fait -Faire tourner llama.cpp comme un vrai service, ça veut dire d'habitude : trouver les bons flags CMake pour ton GPU, écrire une unité systemd, gérer une clé API, changer de modèle, garder le build à jour… AJEAN transforme tout ça en quelques sous-commandes derrière un binaire statique unique, **sans dépendance à l'exécution** (à part llama.cpp lui-même, que AJEAN peut compiler pour toi). +**Le backend, tout seul.** `jean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU marche), ROCm, Metal, Vulkan, ou repli CPU. `jean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, et le redémarre. -## Fonctionnalités +**Le service.** `jean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Sous Windows et macOS, voir plus bas. -- **`jean llamacpp install` / `update`** — clone et compile llama.cpp avec les bons flags **détectés automatiquement** pour l'hôte : - - **CUDA** quand un GPU NVIDIA + `nvcc` sont présents (compute capability détectée par GPU via `nvidia-smi`, donc les machines multi-GPU compilent pour toutes les cartes) - - **ROCm/HIP** (AMD), **Metal** (macOS / Apple Silicon), **Vulkan**, ou repli **CPU** - - `update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre -- **Intégration systemd** — `jean install` écrit l'unité, une règle sudoers `systemctl` sans mot de passe, et les dossiers de données -- **Interface web** (`jean web`) — chat, changement de modèle/preset, mode agent, accès internet/mémoire, panneau « Accès OpenAI », et réglages d'apparence (thème sombre/clair/doux, mode complet/simple) synchronisés entre appareils -- **Chat terminal** (`jean chat`) — réponses en streaming -- **Accès internet** (`jean internet`) — branche un serveur [Crawl4AI](https://github.com/unclecode/crawl4ai) et l'IA gagne 4 outils web (`web_search` DuckDuckGo, `web_open`, `web_read`, `web_grep`), actifs seulement si le mode agent est actif et le serveur joignable -- **Mémoire persistante à 3 modes** (`jean memory`) — l'IA garde des notes Markdown entre les sessions ; réglable en `off` / `ondemand` (utilisée seulement quand tu le demandes) / `always` (proactive) -- **Accès distant chiffré** (`jean link`) — connecte ce serveur à [ajean.link](https://ajean.link) par une connexion **sortante** (aucun port à ouvrir, marche même en CGNAT). Le chat est **chiffré de bout en bout** : le relais ne voit jamais tes conversations (voir [Sécurité — boîte noire](#sécurité--boîte-noire)) -- **Presets** (`jean switch`) — garde plusieurs profils `config.env` et bascule entre eux -- **Protection par clé API** (`jean set-api-key`) — auth Bearer pour exposer le serveur publiquement ; la clé est stockée à part pour survivre aux changements de preset -- **Benchmark** (`jean bench`) — tok/s prefill/decode honnêtes avec un corpus varié -- **Auto-mise à jour** (`jean update`) — récupère la dernière release GitHub et remplace le binaire en place (`--check` pour seulement vérifier) -- **Binaire unique** — statique (`CGO_ENABLED=0`) sur Linux et Windows, se cross-compile trivialement ; la build macOS ajoute juste Cocoa pour l’icône de barre de menus +**Le chat web** (`jean web`, ou `jean app` pour ouvrir le navigateur d'un coup). Changement de modèle et de preset, réglages d'apparence synchronisés entre appareils, prompt système éditable, téléchargement de modèles depuis un lien `.gguf`, et un compactage automatique du contexte quand la conversation devient longue. -## Démarrage rapide +**Le mode agent** (`jean agent on`) — un seul interrupteur qui donne à l'IA ses vrais outils : -### 1. Récupère le binaire +| Outil | Rôle | +|---|---| +| terminal | exécute une commande (bash sous Unix, `cmd.exe` sous Windows) | +| write / edit | écrit un fichier, ou le modifie par remplacement exact | +| mem_* | mémoire Markdown persistante entre les sessions | +| web_* | recherche et lecture de pages, via Crawl4AI | +| mcp__* | les outils de tes serveurs MCP | -Prends un binaire pré-compilé depuis la page [Releases](../../releases), ou [compile depuis les sources](#compiler-depuis-les-sources) : +**Accès distant chiffré** (`jean link`) — une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et ça marche en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais tes conversations. + +## Démarrage + +### 1. Le binaire ```bash -# exemple : Linux x86_64 curl -L -o jean https://github.com/nathaninline/jean/releases/latest/download/jean-linux-amd64 chmod +x jean sudo mv jean /usr/local/bin/jean ``` -### 2. Installe (unité systemd, dossiers, sudoers) +(Autres cibles sur la page [Releases](../../releases) : Linux/macOS/Windows, amd64 et arm64.) + +### 2. Installe et compile ```bash -sudo jean install +sudo jean install # unité systemd, sudoers, dossiers +jean llamacpp install # compile llama.cpp pour ton GPU ``` -### 3. Compile un backend llama.cpp pour cette machine +Nécessite `git` et `cmake`, plus le toolkit de ton accélérateur (CUDA, ROCm…) pour le GPU. -```bash -jean llamacpp install -``` - -AJEAN détecte ton accélérateur, compile `llama-server`, et pointe la config sur le nouveau binaire. Nécessite `git` et `cmake` (plus le toolkit correspondant, ex. CUDA, si tu veux l'accélération GPU). - -### 4. Pointe-le sur un modèle et démarre +### 3. Démarre ```bash jean edit # règle MODEL=/chemin/vers/ton-modele.gguf jean start jean test # vérifie que le modèle répond +jean web # http://:8090 ``` -### 5. (optionnel) Interface web - -```bash -jean web # http://:8090 -``` - -## Windows - -AJEAN fonctionne aussi sur Windows. Les différences avec Linux : - -- **Pas de systemd.** `jean start` lance `jean serve` en arrière-plan (processus détaché), suivi via un fichier PID. `stop` / `restart` / `status` / `logs` agissent dessus — aucun droit administrateur requis. `enable` / `disable` (démarrage au boot) ne sont pas gérés : utilise une tâche planifiée ou `sc.exe` si tu en as besoin. -- **`JEAN_HOME`** vaut par défaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). Surcharge avec la variable d'environnement `JEAN_HOME`. -- **`jean install`** crée seulement le dossier de données et un `config.env` de départ (pas de sudoers ni de symlink). Ajoute toi-même le dossier de `jean.exe` au `PATH` pour l'appeler de partout. -- **`jean edit`** ouvre `notepad` par défaut (surchargeable via `%EDITOR%`). -- **`jean tools`** exécute les commandes via `cmd /C` (au lieu de `bash`). - -```powershell -# depuis PowerShell -jean install # crée %ProgramData%\jean\config.env -jean edit # règle BIN=...\llama-server.exe et MODEL=...\modele.gguf -jean start -jean status -jean logs # suit %ProgramData%\jean\jean.log -``` - -`jean llamacpp install` peut compiler llama.cpp si `git` et `cmake` sont présents ; sinon récupère un binaire `llama-server.exe` pré-compilé et pointe `BIN` dessus avec `jean edit`. - -## macOS - -Comme sous Windows, tu peux lancer AJEAN **d'un simple clic** : la page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézippe, glisse-le dans *Applications*, ouvre-le : AJEAN démarre son UI web sur `http://localhost:8090`, l'ouvre dans le navigateur et pose son **icône dans la barre de menus** (en haut à droite) — pendant exact du system tray Windows, avec « Ouvrir AJEAN » / « Quitter ». Pas de fenêtre de Terminal, pas d'icône dans le Dock. - -L'app n'est **pas signée** par un certificat Apple (seulement ad-hoc) : au premier lancement, fais **clic droit → Ouvrir** (ou *Réglages Système → Confidentialité et sécurité → Ouvrir quand même*). - -Pour l'usage CLI, prends le binaire nu `jean-darwin-arm64` (ou `-amd64`) et mets-le dans `/usr/local/bin/jean` : hors bundle, il garde son comportement en ligne de commande. Les services utilisent **launchd** (pas systemd). - ## Commandes ``` +Application : + app lance l'UI web + ouvre le navigateur (auto au double-clic) + web [PORT] UI web seule (défaut :8090) + chat [prompt-système] chat dans le terminal + Service : - start | stop | restart gérer le service systemd + start | stop | restart gérer le 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 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) - bench [N] mesure prefill + decode tok/s + test | bench [N] vérifier que ça répond / mesurer les tok/s + vram | gpu [index…] VRAM / choix des GPU (gpu all = tous) + set-api-key [clé] protéger llama-server (Bearer) + set-web-key [clé] protéger l'API de pilotage -Presets : - switch [N] choisir un preset dans configs/ (interactif ou par numéro) +Capacités de l'IA : + agent [on|off|status] active TOUS les outils (terminal, fichiers, mémoire) + memory [off|ondemand|always] mode mémoire + internet [on|off|url |key ] accès web via Crawl4AI -Interaction : - chat [system-prompt] chat terminal streamé - web [PORT] interface web (défaut :8090) - internet [on|off|status|url ] accès web de l'IA via un serveur Crawl4AI - memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto) +Presets et backend : + switch [N] changer de preset (configs/) + llamacpp install|update|status Accès distant (ajean.link) : - link [token] démarre le lien au relais en arrière-plan (token = 1re fois / pour le changer) - link start | restart | stop démarre / redémarre / arrête le service de lien - link code génère un code d'appairage (valable 10 min, usage unique) pour le portail - link status | logout état du lien / oublier le token - -Mode agent : - agent [on|off|status] active TOUS les outils du modèle (shell complet + mémoire) — un seul interrupteur - -Backend (llama.cpp) : - llamacpp install clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), règle BIN - llamacpp update git pull + recompile le backend existant (arrête/redémarre le service) - llamacpp status commit courant, backend détecté, commits de retard sur origin + link enregistre le token et démarre le lien + link start|restart|stop gérer le service de lien + link code code d'appairage (10 min, usage unique) + link status|logout Installation : - install installer (unité systemd, sudoers, dossiers) - uninstall désinstaller - update [--check] mettre à jour jean depuis les releases GitHub (--check = signale sans installer) - version affiche la version installée + install | uninstall + update [--check] se mettre à jour depuis les releases GitHub + version ``` -### Options de `jean llamacpp` - -``` -install [--dir=CHEMIN] [--ref=REF_GIT] [--force] [--no-switch] -update [--ref=REF_GIT] [--clean] [--no-restart] [--force] -``` - -- `--dir=` — où cloner (défaut `$JEAN_HOME/backends/llama.cpp`) -- `--ref=` — compiler une branche/tag/commit précis -- `--clean` — vide `build/` et recompile de zéro -- `--no-switch` — ne touche pas à `config.env` (install seulement) -- `--no-restart` — laisse le service arrêté après la mise à jour - ## Configuration -Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramData%\jean` sur Windows). Le service lit `config.env` : +Tout vit sous **`$JEAN_HOME`** (`/etc/jean` sous Linux/macOS, `%ProgramData%\jean` sous Windows). Le service lit `config.env` : | Clé | Signification | Défaut | |-----|---------------|--------| | `BIN` | chemin vers `llama-server` (réglé par `llamacpp install`) | — | -| `MODEL` | nom de fichier (cherché dans les dossiers de modèles) ou chemin complet vers le `.gguf` | — | +| `MODEL` | nom de fichier ou chemin complet du `.gguf` | — | | `HOST` / `PORT` | adresse / port d'écoute | `0.0.0.0` / `8080` | | `CTX` | taille du contexte | `32768` | -| `NGL` | couches à déporter sur le GPU | `999` | +| `NGL` | couches déportées sur le GPU | `999` | | `BATCH` / `UBATCH` | batch / micro-batch | `2048` / `512` | | `THREADS` / `THREADS_BATCH` | threads CPU | `0` (auto) | -| `CUDA_VISIBLE_DEVICES` | GPU à utiliser (réglé par `jean gpu`) | tous | -| `KV_TYPE` (`_K`/`_V`) | quantization du cache KV | — | +| `KV_TYPE` (`_K` / `_V`) | quantization du cache KV | — | +| `CUDA_VISIBLE_DEVICES` | GPU utilisés (réglé par `jean gpu`) | tous | | `REASONING` | passthrough du mode raisonnement | — | -| `REASONING_BUDGET` | plafond de tokens de réflexion passé à llama-server ; `-1` = illimité | `-1` | -| `CRAWL4AI_URL` | URL du serveur Crawl4AI pour l'accès internet (réglé par `jean internet url`) | — | -| `MEM_MODE` | mode mémoire de l'IA : `off` / `ondemand` / `always` (réglé par `jean memory`) | `always` | +| `REASONING_BUDGET` | plafond de tokens de réflexion ; `-1` = illimité | `-1` | +| `COMPACT` | compactage automatique du contexte (`off` pour couper) | activé | +| `MEM_MODE` | mémoire : `off` / `ondemand` / `always` | `always` | +| `CRAWL4AI_URL` / `CRAWL4AI_KEY` | serveur d'accès internet | — | | `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — | -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`. +La clé API (`jean set-api-key`) vit dans `$JEAN_HOME/.api_key`, à part, pour survivre aux changements de preset. -**Modèles sur un autre disque.** Les `.gguf` n'ont pas à vivre dans `$JEAN_HOME` : dans l'éditeur de preset de l'UI web, section *Modèle → Dossiers de modèles*, ajoutez le dossier de votre disque externe. Ses modèles apparaissent alors dans la liste déroulante, groupés par dossier. La liste est enregistrée dans `$JEAN_HOME/model_dirs.json` (elle survit donc aux changements de preset) et peut aussi être fournie par `JEAN_MODEL_DIRS` (dossiers séparés par `:`, `;` sous Windows). Les téléchargements, eux, atterrissent toujours dans `$JEAN_HOME`. +**Modèles sur un autre disque.** Les `.gguf` n'ont pas à vivre dans `$JEAN_HOME` : dans l'éditeur de preset de l'UI, section *Modèle → Dossiers de modèles*, ajoute le dossier de ton disque externe. Ses modèles apparaissent dans la liste, groupés par dossier. Enregistré dans `model_dirs.json`, donc conservé d'un preset à l'autre. -### API de pilotage à distance +### Variables d'environnement -`jean web` expose une API HTTP pour piloter AJEAN à 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é : +| Variable | Rôle | Défaut | +|----------|------|--------| +| `JEAN_HOME` | racine des données | `/etc/jean`, `%ProgramData%\jean` | +| `JEAN_MODEL_DIRS` | dossiers de modèles (`:`, `;` sous Windows) | — | +| `JEAN_SERVICE` | nom de l'unité systemd | `jean` | +| `HF_TOKEN` | token Hugging Face pour les modèles privés | — | +| `JEAN_DL_CONNS` | connexions parallèles au téléchargement | — | +| `EDITOR` | éditeur pour `jean edit` | `nano` / `notepad` | -``` -jean set-web-key # génère une clé aléatoire -jean web 8090 # sert l'API/UI sur :8090 +## Les outils de l'IA + +### Mémoire + +L'IA tient des pages Markdown sous `$JEAN_HOME/MEMORY/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent : + +```bash +jean memory always # (défaut) elle cherche avant de répondre et sauve d'elle-même +jean memory ondemand # outils dispo, mais seulement quand tu le demandes +jean memory off # coupée ``` -Chaque appel `/api/*` doit alors présenter la clé (la page HTML/JS, elle, reste publique car sans secret) : +### Accès internet -``` -Authorization: Bearer -``` - -Endpoints utiles pour un client : - -| Méthode | Endpoint | Rôle | -|---------|----------|------| -| GET | `/api/ping` | vérifie connectivité + validité de la clé (200 / 401) | -| GET | `/api/status` | état du service (active, health, port) | -| GET | `/api/vram` | usage GPU/VRAM | -| GET | `/api/presets` | liste des presets (avec l'actif) | -| POST | `/api/switch` `{"n":}` | switch de modèle/preset | -| POST | `/api/start` · `/api/stop` · `/api/restart` | piloter le service | -| POST | `/api/chat` `{"messages":[…]}` | chat (flux SSE) | - -La clé est stockée dans `$JEAN_HOME/.web_key`, distincte de `.api_key` (pilotage ≠ accès complétions), et relue à chaud à chaque requête. Le pilotage du service est cross-platform (systemd sous Linux, supervision par PID-file sous Windows). - -> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, place AJEAN derrière un reverse-proxy HTTPS (Caddy, nginx) ou un tunnel (Tailscale, Cloudflare Tunnel). - -### Accès internet de l'IA (Crawl4AI) - -Par défaut l'IA n'a pas accès au web. En branchant un serveur [Crawl4AI](https://github.com/unclecode/crawl4ai) (Chrome headless), elle gagne quatre outils : `web_search` (DuckDuckGo), `web_open` (récupère une page → métadonnées + plan), `web_read` (lit une plage de lignes) et `web_grep` (regex dans une page ouverte). - -**AJEAN ne fournit pas le serveur Crawl4AI, il s'y branche** — c'est à toi de l'héberger (sur la même machine ou ailleurs), typiquement en Docker : +Par défaut l'IA n'a pas le web. En branchant un serveur [Crawl4AI](https://github.com/unclecode/crawl4ai), elle gagne `web_search` (DuckDuckGo), `web_open`, `web_read` et `web_grep`. **AJEAN ne fournit pas le serveur, il s'y branche :** ```bash docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest +jean internet url http://localhost:11235 +jean internet on +jean internet status ``` -Puis côté AJEAN : +Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** le serveur joignable — sinon ils n'existent pas, donc il ne peut pas les inventer. + +### Serveurs MCP + +AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : tu branches des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp____`. + +La configuration se fait dans l'interface web (section *Serveurs MCP*) ou en éditant `$JEAN_HOME/mcp.json`, au **même format que Claude Desktop** — un fichier existant se réutilise tel quel : + +```json +{ + "mcpServers": { + "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"] }, + "api": { "url": "https://exemple.com/mcp" } + } +} +``` + +Les transports **stdio** et **HTTP** sont gérés. Comme le terminal, un serveur MCP exécute du code sur ta machine : ses outils ne sont donnés au modèle que si le mode agent est actif. + +## Windows + +- **Pas de systemd** : `jean start` lance le service en arrière-plan (suivi par fichier PID), `stop` / `restart` / `status` / `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés — passe par une tâche planifiée. +- `JEAN_HOME` vaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). +- `jean install` crée seulement le dossier de données et un `config.env` de départ. +- Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et elle écrit ses fichiers par l'outil dédié plutôt que par le shell — c'est ce qui lui permet de produire des scripts contenant des guillemets. + +```powershell +jean install +jean edit # BIN=...\llama-server.exe et MODEL=...\modele.gguf +jean start +jean status +``` + +`jean llamacpp install` compile aussi sous Windows si `git` et `cmake` sont là ; sinon récupère un `llama-server.exe` pré-compilé et pointe `BIN` dessus. + +## macOS + +La page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézippe, glisse dans *Applications*, ouvre : l'UI démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de Terminal, pas d'icône dans le Dock. + +L'app n'est signée qu'en ad-hoc : au premier lancement, **clic droit → Ouvrir**. + +Pour la ligne de commande, prends le binaire nu `jean-darwin-arm64` : hors bundle, il se comporte en CLI classique. Les services passent par **launchd**. + +## Accès distant via ajean.link + +`jean link` ouvre une connexion **sortante** vers le relais : ton serveur reste injoignable de l'extérieur, mais tu y accèdes de partout. ```bash -jean internet url http://localhost:11235 # URL de ton serveur Crawl4AI -jean internet on # active l'accès web -jean internet status # vérifie que le serveur est joignable +jean link # token fourni sur ajean.link +jean link code # code d'appairage à saisir dans le portail ``` -Les outils web ne sont proposés au modèle que si **trois conditions** sont réunies : le **mode agent** est actif (`jean agent on`), l'accès internet est activé, **et** le serveur Crawl4AI répond. Sinon ils n'apparaissent pas — le modèle ne peut donc pas les inventer. Réglable aussi depuis l'interface web (section « Accès internet ») et disponible à distance via ajean.link (les outils s'exécutent sur ton serveur, à travers la boîte noire chiffrée). - -### Mémoire de l'IA (3 modes) - -L'IA peut tenir une mémoire persistante — des pages Markdown sous `$JEAN_HOME/MEMORY/` qu'elle relit et met à jour entre les sessions. Le comportement se règle en trois modes, indépendamment du mode agent : - -```bash -jean memory always # (défaut) l'IA cherche avant de répondre et sauve d'elle-même -jean memory ondemand # outils dispo, mais utilisés seulement quand tu le demandes -jean memory off # mémoire coupée : aucun accès en lecture ni écriture -jean memory status -``` - -### Accès distant via ajean.link - -Plutôt que d'exposer un port, `jean link` ouvre une connexion **sortante** vers le relais [ajean.link](https://ajean.link) : ton serveur reste injoignable depuis l'extérieur, mais tu y accèdes quand même depuis n'importe où — idéal derrière une box ou en CGNAT. - -```bash -jean link # token fourni sur ajean.link ; démarre le lien en arrière-plan -jean link code # un code d'appairage à saisir une fois dans le portail -``` - -`jean link` tourne comme un **service** (au même titre que `jean` lui-même) : la commande rend la main aussitôt, le lien continue en tâche de fond. Gestion : `jean link restart`, `jean link stop`, `jean link status`. (Pas besoin de `jean web` : l'UI est servie dans le tunnel.) - -Une fois lié, tu retrouves depuis le portail : -- l'**interface web** de ton serveur, à distance, avec un **chat chiffré de bout en bout** ; -- la gestion de **plusieurs serveurs** et d'**agents** depuis un tableau de bord ; -- en option, un **endpoint compatible OpenAI** (voir ci-dessous). +Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Pas besoin de `jean web` — l'UI est servie dans le tunnel. Tu retrouves depuis le portail l'interface de ton serveur avec un chat chiffré, la gestion de plusieurs machines, et en option un endpoint compatible OpenAI. C'est un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit. ### Sécurité — boîte noire -`jean link` est conçu pour que le relais ajean.link soit un **pur tube aveugle** : il transporte tes données mais ne peut pas les lire. +Le relais est conçu comme un **tube aveugle** : il transporte tes données sans pouvoir les lire. -- **Chat chiffré de bout en bout.** Le chat entre ton navigateur et ton serveur AJEAN est chiffré (X25519 + AES-GCM) : le relais ne voit ni tes prompts ni les réponses, seulement de l'opaque. La clé est dérivée de ton mot de passe (protocole **OPAQUE**) et ne quitte jamais ton navigateur. -- **Empreinte vérifiée.** `jean link` affiche une empreinte de la clé de la machine, à confirmer une fois dans le portail — ça défait toute tentative d'interception par le relais. -- **Appairage authentifié.** En plus de l'empreinte, tu autorises ton navigateur avec un **code d'appairage** (`jean link code`) : à usage unique et valable 10 min, il garantit que seul *ton* navigateur peut piloter le serveur — même un relais compromis ne peut pas forger de commande. -- **Aucun chat en clair par le relais.** L'ancien chemin en clair est refusé à travers le tunnel ; seul le chemin chiffré transporte du contenu. -- **Code servi hors du relais.** Le portail web est livré par une origine indépendante (GitHub Pages), pas par le relais — donc même compromis, le relais ne peut pas injecter de code piégé pour voler ta clé. +- **Chat chiffré de bout en bout** (X25519 + AES-GCM). La clé est dérivée de ton mot de passe via **OPAQUE** et ne quitte jamais ton navigateur. +- **Empreinte vérifiée.** `jean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail — ça défait toute interception par le relais. +- **Appairage authentifié.** Un code à usage unique (`jean link code`) garantit que seul *ton* navigateur pilote le serveur ; même compromis, le relais ne peut pas forger de commande. +- **Code servi hors du relais.** Le portail vient d'une origine indépendante (GitHub Pages) : le relais ne peut pas injecter de code pour voler ta clé. -> Reste visible du relais : des métadonnées techniques sans contenu (machine en ligne, nom du modèle chargé, VRAM) — jamais tes conversations. +Reste visible du relais : des métadonnées techniques (machine en ligne, modèle chargé, VRAM) — jamais tes conversations. -#### Endpoint OpenAI (`.oai.ajean.link`) — opt-in +### Endpoint OpenAI (opt-in) -Pour brancher des outils tiers (OpenCode, etc.), AJEAN peut exposer un endpoint compatible OpenAI **par machine** : `https://.oai.ajean.link/v1`. L'authentification est la clé API de ton `llama-server` (`jean set-api-key`), présentée en `Authorization: Bearer `. C'est **désactivé par défaut**. +Pour brancher des outils tiers, AJEAN expose au besoin `https://.oai.ajean.link/v1`, authentifié par la clé de ton `llama-server`. **Désactivé par défaut**, activable par machine depuis l'UI (panneau *Accès OpenAI*), sans redémarrage. -Tu l'actives par machine depuis l'**interface web** (panneau « Accès OpenAI ») : le drapeau est relu **en direct** par le tunnel, sans redémarrage. *(Ancien mécanisme : la variable d'environnement `JEAN_LINK_ALLOW_OAI=1` sur le service — encore acceptée pour rétro-compatibilité, mais le toggle de l'UI est désormais la voie recommandée.)* +Le VPS fait un simple **passthrough SNI** : le TLS est terminé sur *ta* machine (Let's Encrypt via TLS-ALPN-01, à travers le tunnel), le relais ne voit que du chiffré. -Contrairement aux premières versions, ce flux **n'est pas en clair chez le relais** : le VPS fait un simple **passthrough SNI** et le TLS est terminé sur *ta* machine (certificat Let's Encrypt obtenu par l'agent en TLS-ALPN-01, à travers le tunnel). Le relais ne voit donc que des octets chiffrés. L'accès reste soumis au service ajean.link (partie hébergée) : la fonctionnalité doit être ouverte pour ton compte. +## API de pilotage -### Variables d'environnement +`jean web` expose une API HTTP pour piloter AJEAN à distance. Protège-la avant de l'exposer : -| Variable | Signification | Défaut | -|----------|---------------|--------| -| `JEAN_HOME` | racine des données | `/etc/jean` (ou `$HOME/JEAN`) | -| `JEAN_SERVICE` | nom de l'unité systemd | `jean` | -| `EDITOR` | éditeur pour `jean edit` | `nano` | +```bash +jean set-web-key # génère une clé +``` + +Chaque appel `/api/*` présente alors `Authorization: Bearer ` : + +| Méthode | Endpoint | Rôle | +|---------|----------|------| +| GET | `/api/ping` | connectivité + validité de la clé | +| GET | `/api/status` · `/api/vram` | état du service · GPU | +| GET | `/api/presets` | liste des presets (avec l'actif) | +| POST | `/api/switch` `{"n":}` | changer de modèle | +| POST | `/api/start` · `/api/stop` · `/api/restart` | piloter le service | +| POST | `/api/chat` `{"messages":[…]}` | chat (flux SSE) | + +> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, mets un reverse-proxy HTTPS devant, ou utilise `jean link`. ## Compiler depuis les sources -Nécessite Go 1.25+. AJEAN est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) : +Go 1.25+. AJEAN est 100 % Go, l'UI est embarquée via `go:embed` : ```bash git clone https://github.com/nathaninline/jean.git cd jean CGO_ENABLED=0 go build -o jean ./cmd/jean - -# cross-compilation, ex. Linux depuis n'importe quel hôte : -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jean-linux-amd64 ./cmd/jean ``` -> Compiler **AJEAN** ne nécessite que Go. Compiler le **backend llama.cpp** (`jean llamacpp install`) nécessite `git`, `cmake`, et le toolkit de ton accélérateur (CUDA, ROCm, etc.). - -## Comment ça marche - -- `jean serve` est l'`ExecStart` de systemd : il lit `config.env`, construit la liste d'arguments de `llama-server`, et fait un `exec` dessus pour que systemd supervise directement llama.cpp. -- `jean llamacpp` gère le checkout llama.cpp à côté de l'endroit où pointe `BIN`, en gérant le piège classique du « dossier de build relocalisé » (cache CMake) et en arrêtant le service pendant la recompilation pour éviter le *Text file busy*. +> Compiler **AJEAN** ne demande que Go. Compiler le **backend llama.cpp** demande `git`, `cmake` et le toolkit de ton accélérateur. ## Arborescence -- `cmd/jean/` — point d'entrée (`main()`) + ressources Windows (.syso, icône, versioninfo). -- `internal/jean/` — tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*` — carte dans `doc.go`). -- `internal/jean/ui/` — UI web embarquée. `index.html` est **généré** : les sources vivent dans `ui/src/` (`styles.css`, `js/NN-*.js` concaténés dans l'ordre alphabétique, `index.tmpl.html`). Pour modifier l'UI : éditer `ui/src/` puis lancer `go generate ./internal/jean` (outil `tools/assemble-ui`, multiplateforme ; le fichier assemblé reste committé car `go:embed` et `ajean-app/build-server-ui.ps1` le lisent tel quel). +- `cmd/jean/` — point d'entrée + ressources Windows (icône, versioninfo). +- `internal/jean/` — tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. +- `internal/jean/ui/` — UI web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/jean`. ## Licence -[MIT](LICENSE). Le fichier `internal/jean/ui/marked.min.js` embarqué est [Marked](https://github.com/markedjs/marked), également MIT. +[MIT](LICENSE). Le `marked.min.js` embarqué est [Marked](https://github.com/markedjs/marked), également MIT. diff --git a/docs/ui.png b/docs/ui.png index eb44b5d2f8a2a9a88ae91236155415ba9a653381..8204acd73234647c9ec5a8e34f63af06134dc3a8 100644 GIT binary patch literal 121015 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 literal 121436 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 Date: Mon, 3 Aug 2026 17:30:06 +0200 Subject: [PATCH 184/279] =?UTF-8?q?README=20:=20vouvoiement=20et=20reposit?= =?UTF-8?q?ionnement=20=E2=80=94=20AJEAN=20est=20une=20application=20d'IA?= =?UTF-8?q?=20locale=20complete,=20llama.cpp=20n'est=20que=20le=20moteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 120 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 03b4124..a44e5d6 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,23 @@ ![Interface web d'AJEAN](docs/ui.png) -**Un seul binaire pour faire tourner [llama.cpp](https://github.com/ggml-org/llama.cpp) chez toi : il compile le backend pour ton matériel, tient le service, et te donne un chat web complet par-dessus.** +**Une application complète pour faire tourner ses IA en local — chat, mémoire persistante, accès web, outils et accès à distance chiffré — dans un seul binaire.** + +AJEAN fournit tout ce qui entoure le modèle : l'interface, l'assistant et ses capacités, la gestion du service et du matériel. Le moteur d'inférence est [llama.cpp](https://github.com/ggml-org/llama.cpp), qu'AJEAN compile lui-même pour la machine sur laquelle il tourne — mais il reste un composant parmi d'autres, pas le sujet. ``` -télécharge le binaire → jean llamacpp install → jean edit → jean start → c'est parti +télécharger le binaire → jean llamacpp install → jean edit → jean start → c'est parti ``` -Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur. Tu obtiens un endpoint compatible OpenAI et une interface web avec chat, mémoire, accès internet et outils. +Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur. À l'arrivée : une interface de chat complète, et un endpoint compatible OpenAI pour les outils tiers. --- -## Ce que ça fait +## Ce que fait AJEAN -**Le backend, tout seul.** `jean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU marche), ROCm, Metal, Vulkan, ou repli CPU. `jean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, et le redémarre. +**Un assistant, pas seulement un modèle.** L'interface web offre un chat avec raisonnement affiché, mémoire persistante, compactage automatique du contexte quand la conversation s'allonge, prompt système éditable, et des réglages d'apparence synchronisés entre appareils. -**Le service.** `jean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Sous Windows et macOS, voir plus bas. - -**Le chat web** (`jean web`, ou `jean app` pour ouvrir le navigateur d'un coup). Changement de modèle et de preset, réglages d'apparence synchronisés entre appareils, prompt système éditable, téléchargement de modèles depuis un lien `.gguf`, et un compactage automatique du contexte quand la conversation devient longue. - -**Le mode agent** (`jean agent on`) — un seul interrupteur qui donne à l'IA ses vrais outils : +**Des outils réels.** `jean agent on` — un seul interrupteur qui donne à l'IA ses vraies capacités sur la machine : | Outil | Rôle | |---|---| @@ -28,9 +26,15 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur | write / edit | écrit un fichier, ou le modifie par remplacement exact | | mem_* | mémoire Markdown persistante entre les sessions | | web_* | recherche et lecture de pages, via Crawl4AI | -| mcp__* | les outils de tes serveurs MCP | +| mcp__* | les outils des serveurs MCP configurés | -**Accès distant chiffré** (`jean link`) — une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et ça marche en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais tes conversations. +**Le matériel et le moteur, gérés pour vous.** `jean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU fonctionne), ROCm, Metal, Vulkan, ou repli CPU. `jean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre. + +**Le service, tenu proprement.** `jean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). + +**Plusieurs modèles, un clic.** Les presets gardent chacun leur configuration complète ; basculer de l'un à l'autre recharge le modèle sans toucher à un fichier. Les `.gguf` peuvent vivre sur n'importe quel disque. + +**Accessible de partout** (`jean link`) — une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et cela fonctionne même en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais les conversations. ## Démarrage @@ -42,23 +46,23 @@ chmod +x jean sudo mv jean /usr/local/bin/jean ``` -(Autres cibles sur la page [Releases](../../releases) : Linux/macOS/Windows, amd64 et arm64.) +(Autres cibles sur la page [Releases](../../releases) : Linux, macOS et Windows, en amd64 et arm64.) -### 2. Installe et compile +### 2. Installation et compilation du moteur ```bash sudo jean install # unité systemd, sudoers, dossiers -jean llamacpp install # compile llama.cpp pour ton GPU +jean llamacpp install # compile llama.cpp pour le GPU présent ``` -Nécessite `git` et `cmake`, plus le toolkit de ton accélérateur (CUDA, ROCm…) pour le GPU. +Nécessite `git` et `cmake`, plus le toolkit de l'accélérateur (CUDA, ROCm…) pour l'accélération GPU. -### 3. Démarre +### 3. Démarrage ```bash -jean edit # règle MODEL=/chemin/vers/ton-modele.gguf +jean edit # régler MODEL=/chemin/vers/le-modele.gguf jean start -jean test # vérifie que le modèle répond +jean test # vérifier que le modèle répond jean web # http://:8090 ``` @@ -75,9 +79,9 @@ Service : status | logs état / logs en direct enable | disable démarrage au boot edit éditer $JEAN_HOME/config.env - test | bench [N] vérifier que ça répond / mesurer les tok/s + test | bench [N] vérifier que le modèle répond / mesurer les tok/s vram | gpu [index…] VRAM / choix des GPU (gpu all = tous) - set-api-key [clé] protéger llama-server (Bearer) + set-api-key [clé] protéger le moteur d'inférence (Bearer) set-web-key [clé] protéger l'API de pilotage Capacités de l'IA : @@ -85,7 +89,7 @@ Capacités de l'IA : memory [off|ondemand|always] mode mémoire internet [on|off|url |key ] accès web via Crawl4AI -Presets et backend : +Presets et moteur : switch [N] changer de preset (configs/) llamacpp install|update|status @@ -97,7 +101,7 @@ Accès distant (ajean.link) : Installation : install | uninstall - update [--check] se mettre à jour depuis les releases GitHub + update [--check] mise à jour depuis les releases GitHub version ``` @@ -121,38 +125,38 @@ Tout vit sous **`$JEAN_HOME`** (`/etc/jean` sous Linux/macOS, `%ProgramData%\jea | `COMPACT` | compactage automatique du contexte (`off` pour couper) | activé | | `MEM_MODE` | mémoire : `off` / `ondemand` / `always` | `always` | | `CRAWL4AI_URL` / `CRAWL4AI_KEY` | serveur d'accès internet | — | -| `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — | +| `EXTRA_ARGS` | ajouté tel quel à la ligne de commande du moteur | — | -La clé API (`jean set-api-key`) vit dans `$JEAN_HOME/.api_key`, à part, pour survivre aux changements de preset. +La clé API (`jean set-api-key`) vit dans `$JEAN_HOME/.api_key`, à part, afin de survivre aux changements de preset. -**Modèles sur un autre disque.** Les `.gguf` n'ont pas à vivre dans `$JEAN_HOME` : dans l'éditeur de preset de l'UI, section *Modèle → Dossiers de modèles*, ajoute le dossier de ton disque externe. Ses modèles apparaissent dans la liste, groupés par dossier. Enregistré dans `model_dirs.json`, donc conservé d'un preset à l'autre. +**Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$JEAN_HOME` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans `model_dirs.json`, donc conservée d'un preset à l'autre. ### Variables d'environnement | Variable | Rôle | Défaut | |----------|------|--------| | `JEAN_HOME` | racine des données | `/etc/jean`, `%ProgramData%\jean` | -| `JEAN_MODEL_DIRS` | dossiers de modèles (`:`, `;` sous Windows) | — | +| `JEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — | | `JEAN_SERVICE` | nom de l'unité systemd | `jean` | | `HF_TOKEN` | token Hugging Face pour les modèles privés | — | | `JEAN_DL_CONNS` | connexions parallèles au téléchargement | — | | `EDITOR` | éditeur pour `jean edit` | `nano` / `notepad` | -## Les outils de l'IA +## Les capacités de l'IA ### Mémoire L'IA tient des pages Markdown sous `$JEAN_HOME/MEMORY/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent : ```bash -jean memory always # (défaut) elle cherche avant de répondre et sauve d'elle-même -jean memory ondemand # outils dispo, mais seulement quand tu le demandes -jean memory off # coupée +jean memory always # (défaut) elle cherche avant de répondre et enregistre d'elle-même +jean memory ondemand # outils disponibles, mais utilisés seulement sur demande +jean memory off # mémoire coupée ``` ### Accès internet -Par défaut l'IA n'a pas le web. En branchant un serveur [Crawl4AI](https://github.com/unclecode/crawl4ai), elle gagne `web_search` (DuckDuckGo), `web_open`, `web_read` et `web_grep`. **AJEAN ne fournit pas le serveur, il s'y branche :** +Par défaut, l'IA n'a pas accès au web. En branchant un serveur [Crawl4AI](https://github.com/unclecode/crawl4ai), elle gagne `web_search` (DuckDuckGo), `web_open`, `web_read` et `web_grep`. **AJEAN ne fournit pas ce serveur, il s'y branche :** ```bash docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest @@ -161,13 +165,13 @@ jean internet on jean internet status ``` -Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** le serveur joignable — sinon ils n'existent pas, donc il ne peut pas les inventer. +Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** le serveur joignable — sinon ils n'existent pas, et le modèle ne peut donc pas les inventer. ### Serveurs MCP -AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : tu branches des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp____`. +AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : on y branche des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp____`. -La configuration se fait dans l'interface web (section *Serveurs MCP*) ou en éditant `$JEAN_HOME/mcp.json`, au **même format que Claude Desktop** — un fichier existant se réutilise tel quel : +La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$JEAN_HOME/mcp.json`, au **même format que Claude Desktop** — un fichier existant se réutilise tel quel : ```json { @@ -178,14 +182,14 @@ La configuration se fait dans l'interface web (section *Serveurs MCP*) ou en éd } ``` -Les transports **stdio** et **HTTP** sont gérés. Comme le terminal, un serveur MCP exécute du code sur ta machine : ses outils ne sont donnés au modèle que si le mode agent est actif. +Les transports **stdio** et **HTTP** sont pris en charge. Comme le terminal, un serveur MCP exécute du code sur la machine hôte : ses outils ne sont donnés au modèle que si le mode agent est actif. ## Windows -- **Pas de systemd** : `jean start` lance le service en arrière-plan (suivi par fichier PID), `stop` / `restart` / `status` / `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés — passe par une tâche planifiée. +- **Pas de systemd** : `jean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés — passer par une tâche planifiée. - `JEAN_HOME` vaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). - `jean install` crée seulement le dossier de données et un `config.env` de départ. -- Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et elle écrit ses fichiers par l'outil dédié plutôt que par le shell — c'est ce qui lui permet de produire des scripts contenant des guillemets. +- Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et écrit ses fichiers par l'outil dédié plutôt que par le shell — c'est ce qui lui permet de produire des scripts contenant des guillemets. ```powershell jean install @@ -194,49 +198,49 @@ jean start jean status ``` -`jean llamacpp install` compile aussi sous Windows si `git` et `cmake` sont là ; sinon récupère un `llama-server.exe` pré-compilé et pointe `BIN` dessus. +`jean llamacpp install` compile également sous Windows si `git` et `cmake` sont présents ; sinon, récupérer un `llama-server.exe` pré-compilé et pointer `BIN` dessus. ## macOS -La page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézippe, glisse dans *Applications*, ouvre : l'UI démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de Terminal, pas d'icône dans le Dock. +La page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock. -L'app n'est signée qu'en ad-hoc : au premier lancement, **clic droit → Ouvrir**. +L'application n'est signée qu'en ad-hoc : au premier lancement, faire **clic droit → Ouvrir**. -Pour la ligne de commande, prends le binaire nu `jean-darwin-arm64` : hors bundle, il se comporte en CLI classique. Les services passent par **launchd**. +Pour un usage en ligne de commande, prendre le binaire nu `jean-darwin-arm64` : hors bundle, il conserve son comportement CLI. Les services passent par **launchd**. ## Accès distant via ajean.link -`jean link` ouvre une connexion **sortante** vers le relais : ton serveur reste injoignable de l'extérieur, mais tu y accèdes de partout. +`jean link` ouvre une connexion **sortante** vers le relais : le serveur reste injoignable depuis l'extérieur, mais reste accessible de partout. ```bash jean link # token fourni sur ajean.link jean link code # code d'appairage à saisir dans le portail ``` -Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Pas besoin de `jean web` — l'UI est servie dans le tunnel. Tu retrouves depuis le portail l'interface de ton serveur avec un chat chiffré, la gestion de plusieurs machines, et en option un endpoint compatible OpenAI. +Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `jean web` — l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. -C'est un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit. +Il s'agit d'un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit. ### Sécurité — boîte noire -Le relais est conçu comme un **tube aveugle** : il transporte tes données sans pouvoir les lire. +Le relais est conçu comme un **tube aveugle** : il transporte les données sans pouvoir les lire. -- **Chat chiffré de bout en bout** (X25519 + AES-GCM). La clé est dérivée de ton mot de passe via **OPAQUE** et ne quitte jamais ton navigateur. -- **Empreinte vérifiée.** `jean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail — ça défait toute interception par le relais. -- **Appairage authentifié.** Un code à usage unique (`jean link code`) garantit que seul *ton* navigateur pilote le serveur ; même compromis, le relais ne peut pas forger de commande. -- **Code servi hors du relais.** Le portail vient d'une origine indépendante (GitHub Pages) : le relais ne peut pas injecter de code pour voler ta clé. +- **Chat chiffré de bout en bout** (X25519 + AES-GCM). La clé est dérivée du mot de passe via **OPAQUE** et ne quitte jamais le navigateur. +- **Empreinte vérifiée.** `jean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail — ce qui défait toute tentative d'interception par le relais. +- **Appairage authentifié.** Un code à usage unique (`jean link code`) garantit qu'un seul navigateur autorisé pilote le serveur ; même compromis, le relais ne peut pas forger de commande. +- **Code servi hors du relais.** Le portail provient d'une origine indépendante (GitHub Pages) : le relais ne peut pas injecter de code pour dérober la clé. -Reste visible du relais : des métadonnées techniques (machine en ligne, modèle chargé, VRAM) — jamais tes conversations. +Reste visible du relais : des métadonnées techniques (machine en ligne, modèle chargé, VRAM) — jamais le contenu des conversations. ### Endpoint OpenAI (opt-in) -Pour brancher des outils tiers, AJEAN expose au besoin `https://.oai.ajean.link/v1`, authentifié par la clé de ton `llama-server`. **Désactivé par défaut**, activable par machine depuis l'UI (panneau *Accès OpenAI*), sans redémarrage. +Pour brancher des outils tiers, AJEAN peut exposer `https://.oai.ajean.link/v1`, authentifié par la clé API du serveur. **Désactivé par défaut**, activable par machine depuis l'interface (panneau *Accès OpenAI*), sans redémarrage. -Le VPS fait un simple **passthrough SNI** : le TLS est terminé sur *ta* machine (Let's Encrypt via TLS-ALPN-01, à travers le tunnel), le relais ne voit que du chiffré. +Le VPS effectue un simple **passthrough SNI** : le TLS est terminé sur la machine hôte (Let's Encrypt via TLS-ALPN-01, à travers le tunnel), le relais ne voit que du chiffré. ## API de pilotage -`jean web` expose une API HTTP pour piloter AJEAN à distance. Protège-la avant de l'exposer : +`jean web` expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition : ```bash jean set-web-key # génère une clé @@ -253,11 +257,11 @@ Chaque appel `/api/*` présente alors `Authorization: Bearer ` : | POST | `/api/start` · `/api/stop` · `/api/restart` | piloter le service | | POST | `/api/chat` `{"messages":[…]}` | chat (flux SSE) | -> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, mets un reverse-proxy HTTPS devant, ou utilise `jean link`. +> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, placer un reverse-proxy HTTPS devant, ou utiliser `jean link`. ## Compiler depuis les sources -Go 1.25+. AJEAN est 100 % Go, l'UI est embarquée via `go:embed` : +Go 1.25+. AJEAN est écrit à 100 % en Go, l'interface est embarquée via `go:embed` : ```bash git clone https://github.com/nathaninline/jean.git @@ -265,13 +269,13 @@ cd jean CGO_ENABLED=0 go build -o jean ./cmd/jean ``` -> Compiler **AJEAN** ne demande que Go. Compiler le **backend llama.cpp** demande `git`, `cmake` et le toolkit de ton accélérateur. +> Compiler **AJEAN** ne demande que Go. Compiler le **moteur llama.cpp** demande `git`, `cmake` et le toolkit de l'accélérateur. ## Arborescence - `cmd/jean/` — point d'entrée + ressources Windows (icône, versioninfo). - `internal/jean/` — tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. -- `internal/jean/ui/` — UI web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/jean`. +- `internal/jean/ui/` — interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/jean`. ## Licence From e0297ddea68109feb30884b918e72ad0690f34e8 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 3 Aug 2026 17:32:37 +0200 Subject: [PATCH 185/279] README : suppression des tirets cadratins dans le texte et de la phrase de trop sur llama.cpp --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a44e5d6..abec769 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ![Interface web d'AJEAN](docs/ui.png) -**Une application complète pour faire tourner ses IA en local — chat, mémoire persistante, accès web, outils et accès à distance chiffré — dans un seul binaire.** +**Une application complète pour faire tourner ses IA en local, dans un seul binaire : chat, mémoire persistante, accès web, outils et accès à distance chiffré.** -AJEAN fournit tout ce qui entoure le modèle : l'interface, l'assistant et ses capacités, la gestion du service et du matériel. Le moteur d'inférence est [llama.cpp](https://github.com/ggml-org/llama.cpp), qu'AJEAN compile lui-même pour la machine sur laquelle il tourne — mais il reste un composant parmi d'autres, pas le sujet. +AJEAN fournit tout ce qui entoure le modèle : l'interface, l'assistant et ses capacités, la gestion du service et du matériel. Le moteur d'inférence est [llama.cpp](https://github.com/ggml-org/llama.cpp), qu'AJEAN compile lui-même pour la machine sur laquelle il tourne. ``` télécharger le binaire → jean llamacpp install → jean edit → jean start → c'est parti @@ -18,7 +18,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur **Un assistant, pas seulement un modèle.** L'interface web offre un chat avec raisonnement affiché, mémoire persistante, compactage automatique du contexte quand la conversation s'allonge, prompt système éditable, et des réglages d'apparence synchronisés entre appareils. -**Des outils réels.** `jean agent on` — un seul interrupteur qui donne à l'IA ses vraies capacités sur la machine : +**Des outils réels.** `jean agent on` active d'un seul coup toutes les capacités de l'IA sur la machine : | Outil | Rôle | |---|---| @@ -34,7 +34,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur **Plusieurs modèles, un clic.** Les presets gardent chacun leur configuration complète ; basculer de l'un à l'autre recharge le modèle sans toucher à un fichier. Les `.gguf` peuvent vivre sur n'importe quel disque. -**Accessible de partout** (`jean link`) — une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et cela fonctionne même en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais les conversations. +**Accessible de partout.** `jean link` ouvre une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et cela fonctionne même en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais les conversations. ## Démarrage @@ -165,13 +165,13 @@ jean internet on jean internet status ``` -Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** le serveur joignable — sinon ils n'existent pas, et le modèle ne peut donc pas les inventer. +Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** le serveur joignable. Sinon ils n'existent pas, et le modèle ne peut donc pas les inventer. ### Serveurs MCP AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : on y branche des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp____`. -La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$JEAN_HOME/mcp.json`, au **même format que Claude Desktop** — un fichier existant se réutilise tel quel : +La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$JEAN_HOME/mcp.json`, au **même format que Claude Desktop**, si bien qu'un fichier existant se réutilise tel quel : ```json { @@ -186,10 +186,10 @@ Les transports **stdio** et **HTTP** sont pris en charge. Comme le terminal, un ## Windows -- **Pas de systemd** : `jean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés — passer par une tâche planifiée. +- **Pas de systemd** : `jean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés, il faut passer par une tâche planifiée. - `JEAN_HOME` vaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). - `jean install` crée seulement le dossier de données et un `config.env` de départ. -- Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et écrit ses fichiers par l'outil dédié plutôt que par le shell — c'est ce qui lui permet de produire des scripts contenant des guillemets. +- Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et écrit ses fichiers par l'outil dédié plutôt que par le shell, ce qui lui permet de produire des scripts contenant des guillemets. ```powershell jean install @@ -217,20 +217,20 @@ jean link # token fourni sur ajean.link jean link code # code d'appairage à saisir dans le portail ``` -Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `jean web` — l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. +Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `jean web`, l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. Il s'agit d'un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit. -### Sécurité — boîte noire +### Sécurité : la boîte noire Le relais est conçu comme un **tube aveugle** : il transporte les données sans pouvoir les lire. - **Chat chiffré de bout en bout** (X25519 + AES-GCM). La clé est dérivée du mot de passe via **OPAQUE** et ne quitte jamais le navigateur. -- **Empreinte vérifiée.** `jean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail — ce qui défait toute tentative d'interception par le relais. +- **Empreinte vérifiée.** `jean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail, ce qui défait toute tentative d'interception par le relais. - **Appairage authentifié.** Un code à usage unique (`jean link code`) garantit qu'un seul navigateur autorisé pilote le serveur ; même compromis, le relais ne peut pas forger de commande. - **Code servi hors du relais.** Le portail provient d'une origine indépendante (GitHub Pages) : le relais ne peut pas injecter de code pour dérober la clé. -Reste visible du relais : des métadonnées techniques (machine en ligne, modèle chargé, VRAM) — jamais le contenu des conversations. +Reste visible du relais : des métadonnées techniques (machine en ligne, modèle chargé, VRAM), jamais le contenu des conversations. ### Endpoint OpenAI (opt-in) @@ -273,9 +273,9 @@ CGO_ENABLED=0 go build -o jean ./cmd/jean ## Arborescence -- `cmd/jean/` — point d'entrée + ressources Windows (icône, versioninfo). -- `internal/jean/` — tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. -- `internal/jean/ui/` — interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/jean`. +- `cmd/jean/` : point d'entrée + ressources Windows (icône, versioninfo). +- `internal/jean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. +- `internal/jean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/jean`. ## Licence From d50a01b4045cb34b9a0e5519d18af6fdc6ebb92b Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 3 Aug 2026 17:35:06 +0200 Subject: [PATCH 186/279] README : formulations de l'introduction --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index abec769..07bc926 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,15 @@ ![Interface web d'AJEAN](docs/ui.png) -**Une application complète pour faire tourner ses IA en local, dans un seul binaire : chat, mémoire persistante, accès web, outils et accès à distance chiffré.** +**Une application complète pour faire tourner vos modèles d'IA en local, dans un seul binaire : chat, mémoire persistante, accès web, outils et accès à distance chiffré.** -AJEAN fournit tout ce qui entoure le modèle : l'interface, l'assistant et ses capacités, la gestion du service et du matériel. Le moteur d'inférence est [llama.cpp](https://github.com/ggml-org/llama.cpp), qu'AJEAN compile lui-même pour la machine sur laquelle il tourne. +AJEAN fournit tout ce qui entoure le modèle : l'interface de chat, les outils de l'assistant, la gestion du service et celle du matériel. Le moteur d'inférence est [llama.cpp](https://github.com/ggml-org/llama.cpp), qu'AJEAN compile lui-même pour la machine sur laquelle il tourne. ``` télécharger le binaire → jean llamacpp install → jean edit → jean start → c'est parti ``` -Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur. À l'arrivée : une interface de chat complète, et un endpoint compatible OpenAI pour les outils tiers. +Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur. Vous obtenez une interface de chat complète et un endpoint compatible OpenAI pour vos outils tiers. --- @@ -18,7 +18,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur **Un assistant, pas seulement un modèle.** L'interface web offre un chat avec raisonnement affiché, mémoire persistante, compactage automatique du contexte quand la conversation s'allonge, prompt système éditable, et des réglages d'apparence synchronisés entre appareils. -**Des outils réels.** `jean agent on` active d'un seul coup toutes les capacités de l'IA sur la machine : +**Des outils réels.** `jean agent on` active d'un seul coup toutes les capacités du modèle sur la machine : | Outil | Rôle | |---|---| @@ -30,7 +30,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur **Le matériel et le moteur, gérés pour vous.** `jean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU fonctionne), ROCm, Metal, Vulkan, ou repli CPU. `jean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre. -**Le service, tenu proprement.** `jean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). +**Un service, pas un script.** `jean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). **Plusieurs modèles, un clic.** Les presets gardent chacun leur configuration complète ; basculer de l'un à l'autre recharge le modèle sans toucher à un fichier. Les `.gguf` peuvent vivre sur n'importe quel disque. From 6e7fe7d4a634f24254625114938e7d4f813a5fe3 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 3 Aug 2026 17:48:10 +0200 Subject: [PATCH 187/279] README : nouvelle accroche --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 07bc926..4b184c6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Interface web d'AJEAN](docs/ui.png) -**Une application complète pour faire tourner vos modèles d'IA en local, dans un seul binaire : chat, mémoire persistante, accès web, outils et accès à distance chiffré.** +**Vos modèles d'IA tournent chez vous, dans un seul binaire : chat, mémoire persistante, accès web, outils et accès à distance chiffré.** AJEAN fournit tout ce qui entoure le modèle : l'interface de chat, les outils de l'assistant, la gestion du service et celle du matériel. Le moteur d'inférence est [llama.cpp](https://github.com/ggml-org/llama.cpp), qu'AJEAN compile lui-même pour la machine sur laquelle il tourne. From 1f3961eaa9e2f32ce5e41c628c3e2bd5cf403da5 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 11:17:43 +0200 Subject: [PATCH 188/279] =?UTF-8?q?v0.6.10=20:=20dossier=20de=20travail=20?= =?UTF-8?q?pour=20l'agent,=20lancement=20Windows=20explicite,=20mise=20?= =?UTF-8?q?=C3=A0=20jour=20lisible,=20ic=C3=B4ne=20noire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mode agent : les outils write/edit/bash résolvent les chemins relatifs dans JEAN_HOME/workspace et le shell y démarre, au lieu du répertoire courant du processus (Bureau, ProgramData\jean\bin). Les chemins absolus demandés explicitement restent honorés. - Windows : le double-clic n'installe plus en silence. Boîte de dialogue unique, raccourcis menu Démarrer + Bureau, puis relance depuis la copie installée — un seul binaire fait foi. Désinstallation : retrait des raccourcis. - update : contrôle des droits AVANT le téléchargement (l'échec tombait sur le fichier temporaire, avec un message brut), message donnant la commande exacte, timeout UI porté de 30 s à 10 min. - jean where + bouton « Où sont mes fichiers ? » : emplacements, et alerte quand le binaire lancé n'est pas celui installé. - icônes : source unique (sys_brand_icon.go) pour la zone de notification, la barre de menus macOS et l'icône du .exe, alignée sur le favicon noir. macOS en icône template (une icône noire figée disparaît sur une barre sombre). - CI : le bundle macOS part de icon.png au lieu de convertir le .ico via sips, dont l'échec était masqué par « || true » ; notes de release versionnées. --- .github/workflows/release.yml | 15 ++- RELEASE_NOTES.md | 33 +++++ cmd/jean/icon.ico | Bin 3070 -> 2730 bytes cmd/jean/icon.png | Bin 0 -> 7918 bytes cmd/jean/main.go | 1 + cmd/jean/resource_windows_amd64.syso | Bin 8340 -> 7544 bytes cmd/jean/resource_windows_arm64.syso | Bin 8340 -> 7544 bytes cmd/jean/versioninfo.json | 6 +- internal/jean/chat_tools.go | 10 +- internal/jean/chat_workspace.go | 87 +++++++++++++ internal/jean/chat_workspace_test.go | 35 ++++++ internal/jean/cli_app.go | 9 +- internal/jean/run.go | 5 +- internal/jean/sys_brand_icon.go | 129 +++++++++++++++++++ internal/jean/sys_firstrun_other.go | 16 +++ internal/jean/sys_firstrun_windows.go | 172 ++++++++++++++++++++++++++ internal/jean/sys_install_windows.go | 29 ++--- internal/jean/sys_paths.go | 79 ++++++++++++ internal/jean/sys_splash_windows.go | 17 +-- internal/jean/sys_tray_darwin.go | 5 +- internal/jean/sys_tray_icon.go | 69 ----------- internal/jean/sys_tray_windows.go | 27 +--- internal/jean/sys_update.go | 40 +++++- internal/jean/ui/index.html | 24 +++- internal/jean/ui/src/index.tmpl.html | 2 + internal/jean/ui/src/js/05-status.js | 22 +++- internal/jean/web_server.go | 1 + tools/gen-icon/main.go | 39 ++++++ 28 files changed, 742 insertions(+), 130 deletions(-) create mode 100644 RELEASE_NOTES.md create mode 100644 cmd/jean/icon.png create mode 100644 internal/jean/chat_workspace.go create mode 100644 internal/jean/chat_workspace_test.go create mode 100644 internal/jean/sys_brand_icon.go create mode 100644 internal/jean/sys_firstrun_other.go create mode 100644 internal/jean/sys_firstrun_windows.go create mode 100644 internal/jean/sys_paths.go delete mode 100644 internal/jean/sys_tray_icon.go create mode 100644 tools/gen-icon/main.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96965d6..0b09f1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,12 +42,14 @@ jobs: # et bascule en mode « application » (UI web + navigateur + icône dans # la barre de menus), comme le double-clic sur jean.exe sous Windows. # LSUIElement : app de barre de menus, aucune icône dans le Dock. + # L'icône part du PNG 1024 généré avec le reste de la marque + # (cmd/jean/icon.png, voir tools/gen-icon) : plus de conversion depuis + # le .ico, dont l'échec silencieux donnait une app sans icône. mkdir -p /tmp/jean.iconset - sips -s format png 'cmd/jean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true for s in 16 32 128 256 512; do - sips -z $s $s /tmp/icon.png --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true + sips -z $s $s 'cmd/jean/icon.png' --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null done - iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns >/dev/null 2>&1 || true + iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns v="${GITHUB_REF_NAME#v}" for arch in amd64 arm64; do app="dist/mac-$arch/Jean.app" @@ -115,4 +117,9 @@ jobs: uses: softprops/action-gh-release@v2 with: files: dist/* - generate_release_notes: true + # Notes rédigées à la main, versionnées avec le code : le tag emporte + # donc ses notes, au lieu de publier une liste de commits auto-générée + # qu'il fallait ensuite remplacer à la main dans l'interface GitHub. + # ⚠️ Réécrire RELEASE_NOTES.md AVANT de poser le tag. + body_path: RELEASE_NOTES.md + generate_release_notes: false diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..5db767a --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,33 @@ +## Corrections + +**L'IA déposait ses fichiers sur votre Bureau.** En mode agent, quand elle créait un fichier — une recherche web mise de côté, un script, des notes — celui-ci atterrissait dans le dossier depuis lequel AJEAN avait été lancé. Le Bureau si vous aviez double-cliqué le fichier téléchargé, `C:\ProgramData\jean\bin` si vous passiez par l'installation. Personne ne s'attend à ce qu'une conversation laisse des fichiers derrière elle. + +L'IA travaille désormais dans un dossier à elle, `workspace`, rangé avec vos réglages. Les fichiers déjà créés ne bougent pas ; le bouton « Où sont mes fichiers ? » vous dit où regarder. Si vous lui demandez explicitement d'écrire à un endroit précis, elle le fait toujours : seuls les fichiers sans destination indiquée sont rangés. + +Merci à Sébastien pour le signalement, et pour avoir tout de suite pointé le lien avec le dossier de lancement. + +**Le bouton de mise à jour échouait sur un message incompréhensible.** Sous Linux, lancé sans `sudo`, il répondait `open /usr/local/bin/.jean-update.tmp: permission denied`. AJEAN prévoyait bien un message expliquant qu'il fallait des privilèges, mais il arrivait trop tard dans le déroulé : l'échec se produisait avant, au moment d'écrire le fichier temporaire. + +Les droits sont maintenant vérifiés avant de télécharger quoi que ce soit, et le message donne la commande exacte à lancer. Le téléchargement dispose par ailleurs de dix minutes au lieu de trente secondes : sur une connexion lente, une mise à jour qui réussissait pouvait être annoncée comme un échec. + +Merci à Emmanuel pour le signalement et pour avoir isolé le rôle de `sudo`. + +## Lancement sous Windows + +**On sait enfin ce que fait le fichier téléchargé.** Le double-clic sur `jean-windows-amd64.exe` déclenchait une installation silencieuse : copie du programme, ajout au PATH, sans le moindre message. Impossible de savoir si l'on venait d'installer AJEAN ou seulement de l'ouvrir. Deux copies du programme coexistaient alors, celle que vous lanciez et celle installée, et la mise à jour ne touchait que la première. + +AJEAN vous pose maintenant la question, une seule fois. Il annonce où il s'installe, où vont vos réglages, ajoute un raccourci au menu Démarrer et au Bureau, puis démarre depuis la copie installée. Un seul programme, à un endroit connu, que la mise à jour retrouve. Répondre non le lance sans rien installer. La désinstallation retire les raccourcis qu'elle a posés. + +## Interface + +**« Où sont mes fichiers ? »** Un bouton dans les actions affiche l'emplacement de vos données, de la configuration, de la mémoire et du dossier de travail de l'IA. Il vous prévient aussi si le programme que vous exécutez n'est pas celui installé. En ligne de commande : `jean where`. + +**Nouvelle icône.** L'icône de l'application, celle de la zone de notification Windows et celle de la barre de menus macOS reprennent le logo noir du site. Elles sont dessinées à partir d'une source unique et ne peuvent plus se retrouver décalées les unes des autres. Sur macOS, l'icône s'adapte au thème clair ou sombre de la barre de menus. + +## Mise à jour + +``` +jean update +``` + +Si vous lancez AJEAN sans privilèges administrateur et que le programme appartient à root, cette version-ci doit encore être installée en ligne de commande avec `sudo jean update`. Les suivantes vous le diront clairement depuis l'interface. diff --git a/cmd/jean/icon.ico b/cmd/jean/icon.ico index 59a25724ab2aa06e89729cb371c021bad7deb492..febca2ce4b8f66981dcad5b22074596f1445c081 100644 GIT binary patch literal 2730 zcmd^9c~nzp7XRK$LNE}(gDeBqq$(~Q!8k6gLcjo}9TwY;u@aadj#3L55FsFtJT(eE zsJJUjS?X9@6g1T)K%k)$%6JA97(}9x5H@9zHGsqr-h2Uij&sIw&dljK^T)h%-}~0e&-w_iFe0!rHGxl>7LS4pD~RR$NlL-) zC=A2Rdys$t%BG`;wNu`=yMfueBphFne%Ewr!-WWjxp}QamIysn>Qb!4qE4UBEmqU* z7$^*l<5OXaoO;a~;O77c@(*E^`*DxFAvhhn>2tvo4;`9-;NX1l7Z-gg7{i<>%f|5o ztP;Oc@`uGL#e*QxZ?>%|32b166JC)?<2_$73zyT+`h>0J*V-c8%Pz0#%E&}!yw?|D zgryolzDxLeM?%t&;9ZD&5W#s5ZZ`Jhz3Yd)5&ycOp6TeTGhOD!WdD{!^}oScEr#*m zmMpA2V9X`UMs=AGe7lgW@<+;1rn#syxKA%1xAn@}^Z8X}FaM%0#OYeD$;wyzizd&x zJC~b;QX_{dwTw>|b)5S2wuLA*_gqKw! zygp&k4eAmUj7qNFO6ui9hCc3%1&QR>3Jx%P@n&PX3KyrX^#v3tV#U9 z_H2vnQHpO?jka$Y-G8xR!gJi3iyQ%@Ar4{Wk!l8eQ_1DrHcdPrUe)_g!asP+}0|3nXn#PBsiud(MDY)czT zm=|)B{nJ{dXIqM|NWED=TC#Gf$Ejf?*QubY?5}$tzZaS|OEwlybHVy(zanq=-xLpn zer&FI3hyY``6zjzcu##pRAKf3&29s;(t;1ZDMlsYY_n>9WA2XoJu)2?9Un|S`4|72 z3ik$n4fdn(>z1@agXM9WqBCM-b>ze8Znx^zGas4zNZL>ORDpV0$MEqq#9p{8Kb%M6 z&ggFNs%){b=aW3u#tssrM2e)mY#eE=e_1Bv?q46CMy%UP*0gtM?^K#ut;XDtI_E+> z&V^Y3x`^|55j_MXpjp=cU>YuMB-=mgPB4TjF7>tuCHNmW#&_SyeZ%YjrHMMZT|AkS zWT5@0!5W5=WwWl%zdF9K$A6ut4A-4u1ZOYsU}*-i!fW>sCh6Q0@)3OTz7#MD3^T2S*mukEF)(bj@vGxjp;|`%I=>MbYkehH^(x&e4RW>Ve+} zCQ$|O_eD;Y+t$6Do)F042*z=oM8h9$UYz>&{(%!5j_SGo;T-q|4u9g2h5l)e2vO~~ z57^GxfUniR-};%qO5`@yXqR?#GZ?*pTU*Z=)r|?BHCqDDya1nC_7z&aGD>3+IJ5go zrrx5x=ZyT`XjJ7kNow8t4e2TE<&N#zOh@n7eV59H$F@;vQ@8u~3S)JO6SQP$f4!vi zi%!MZjuC#gS?zSxiB?s(Bk0Z)?}&>J1}A80MMHRcucxR}p+B_0B^ z>SM=@RiNJT`K^@e9}>7T@h;h0WGjt^iFjEg>ZiN{rYABJ^~s73Qtfo7!!YkL8<@)M zK+$Zxv#aY6irorFM*A(>CK@FXz-o#Q(qAdQH;5DLfGC5Cg$iS)Ym%V82#5?6OiSA( z;HgO<3jJBm*p-$Vwe;GS0VlesOvfs6O}F5Fc}Y_J z3>{4Z#-qs5+c1gT8^3dW+Vu`*T5Eq@DxFNJvBL~;5>**a(KuikaaH22ujyPyUJ@Zkr)b`F*Pb<#((J_7#d|q{4qm#dp#tPla$E;dpGn N+hhr~ng9Ce`aA5;)Ib0L literal 3070 zcmd^BYgAKL7Ctu#BrpjHk5)t^Q5=;iZ37~ps02aK83YAg3Zf>UypgA%AaKP3sYao# zMV^gCd8jBEEANT{6qPqZ1sY!Bs31=@gg{8{96(*JHM8pK8vjk!$vx-Wd!KXn_uYN& zJpdqp2xv3_dj>cI00@VFZS4h|1IGvg0E4lB??IdkF_XD~|q9gMpA}X`WuV`JEv_nR=bX6 zx^viUc5-v9oPUx$5tO}JU%xD!gMO>gxXTEQPmUvM+VuO!$CIBQT+>U5%$yMH@wv`eZ#uT68NtU<3JI%79Sm4kuCGn34ml(= zNlr;x&)#8<#yjEl>}gdr=heroZz&M!$(=;rdUiW%JnkJrO3;PWJd z?LnPFG*Ot3SMEg~EZq``PwufNd*9w2>Dq+qDv84gH}|736%0cu%j~%YUN0c{Bc)*( z0p>i3J-F#a2<|SB7)iHM#Tn5EKUA$6bcxM(Y^A?+!7d&n-Q}1h8inbUI~Z3#`Z1x$ zuxq!wEY=~jIxp=B$e z++l^CXs))fh{Zpvx6``TW}HE1ws=vj3nL@xU%vigj(l{wfp6cE$66mD&w_r|Jo&3W z!M$*UEPhKq!%DT9N!udoR}Z^x)_!S_a`bw{4mYhjlQdVsrp)QUG?D^ae_rE3ZYU)0 z7>FG#-5(z9S6Hr|Q<}3fyE8ujS%qzh@zg2r&T~0M>S@6p?u%|R!+!b|f3wx=#Ei!y zN5}+$%+#PHVIWwbeC4B8lIsTh4Cmd;>#e4ug>56Lct3Jd}h4Hgkfv>u%Oa2k{i z-*S18pzi0@(Jf`)h&35-t$E}7c|%GCuMO;Dd%s#HC%iW`M3G3}yi}W=7_9&m6 zb!PtqVX2=_fX%(|%wO@#|B-(*wBEMNd2nO<9w-uqRm#?#)^n^o{Sie`=tt)jt=Yt> z2LNaAR?+UA4w^fGEk{&JI9|JS!0AxpYhILaYn;{Ktu;q|)1oTJKg%+iZtnK^A(wA? zy3D%2dgtFmC!WM#s4<_D&0vzvq|a;f6*q+g6&5<2O3 z;Pl)1{}GDgemYae%$3fW=JEq^fAVJCxs_V381BVi%|FUamMF{UDLP2#coh}3YJv8Y zE;@y5{5rz|8Bh@VwFZyfo{V`(LJ17xFA*Da7!`K5vcRLeA^z{p`_Yi|N2Yg zh>ajR&op!6#Awgcw!al}G54`X3z;+8#Jg|2(?hit(+Tm2I2Y^m|_ zyU#9)^_<|87i|_uEsdl>w{xroopduPlb^GhDWk8I3Np*ACyq2(dP+Giq0OC!YmKCW z%5}vjGt58@)uX{E%1lW(QM0$CssA^GIV+%)Gz{G9qbbvihaW|pbct00l4seVRL&87 zmDT!iE`oZY;pJtZ9n*Q?%9vj%7F7V4n}xH1{4QC$DTQdKh{)A0o4kEaFj}E_UeH_IKa7sW6Kj$`ql*E zQ6}=X2`s2+%i#|U?>+y^Kp<-+vI`3NeMy_D4^fjGFC_(;n*x&N|7)wDN2B^PGt=@x zeS67-)MSQ4KJ=+I|5MLv?g5!+C*IF%wL`c01KP?@+9YG53)F~9RHR`1#fS(n?BBJK zJzxW2bNzT)3BpPRtWG|VlF@`0zZ}7%0$81YK|N#tlUCn;pgCf zh;J@*gd5K}5=uO$8!0RTyRWHH;0Q<-OTa!b(ssTp>lVTVBlYcRS+gn(-29%}5(t(9 zQTPyVZZBO66esa3vDB&6!lD~JO@+PSgqqN_+xU!GG>!{+M~BJ1v&sw{vsU*Pv*C%z zMd-7}<3mj|U!VaXW$hI2OH)BHlG^NaVR*o-m?oS|>AkvVUspWH-8UQ>IT;wf3~hTH sW4C0+dzCU%^6Errp(I=QUBKvtn;1NiW0-+T-R!n=H|CD;?~i2v2F6V_6951J diff --git a/cmd/jean/icon.png b/cmd/jean/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..597bd606e2e3f2798ac36372c61e263ae554398f GIT binary patch literal 7918 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 Gy#06lJ_4Ko literal 0 HcmV?d00001 diff --git a/cmd/jean/main.go b/cmd/jean/main.go index be024c9..b7559cd 100644 --- a/cmd/jean/main.go +++ b/cmd/jean/main.go @@ -7,6 +7,7 @@ // les .syso après avoir bumpé la version : `go generate ./...` // (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest) // +//go:generate go run github.com/nathaninline/jean/tools/gen-icon icon.ico //go:generate goversioninfo -64 -icon=icon.ico -o resource_windows_amd64.syso versioninfo.json //go:generate goversioninfo -64 -arm -icon=icon.ico -o resource_windows_arm64.syso versioninfo.json package main diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 38393b05f33e15c494ee5cb0ff49e1f2fd6cccac..f0a18da0ad4e7931fed0fc3aafd6fe9382f8f95a 100644 GIT binary patch literal 7544 zcmeGhTT~QRvU(o!9KeGDi^l21M=&cmCPYzG1_cxo!I-QNLBfCv7#N3#JR%IOqL8D} zm_)@#6q8LfDn_CK6bNL;u$)z+EHO?*5ET_vKn2W@0ekEAz@$gu?AbjzXFrxZ-Cd7c zx2kSc-R5$8`6dcTi+B#efQcx#gv5kMj%#>)I2vFkqURBkW{bC+y9m|SgdjxJ1%R9YQmNcBsc3p$kF_gmQ$I2!jz? zAygr>Mwo+8f-nyu0$!uQ0u6W&qbAM3pFapcco2T{ApF!p_&I~{a|huc9)w>!2)}9& zzIG6PQ$N0d%e$7uEfe73Ao0`n8)t~0IZ2{Pqd0wZzq}jdolxEd?+o6U!${}z=wQs( zO&%@B!kA?!PL4|oM9%Z40t=)B$MiK)2z>p310G3Bytur-C}5t%(wUU4#DBieVuWOA*qM_;m$tJC1U~*g-fyMui%>8O zYu!ls`B}PepzO!*`6>deo)@$1*z_v%CsTGTlUZ9=IOk*uwjOcI(=v(=KiQpEPdmy4 z7!HW?5#FPpL>eiUt2{$vcsqWd6 z4=}>f4PaI!{BlYBnilo*NHa_g)c3!C)ij~!;_EPwY4?d_DET>;|{?H)Gn zw^au^Kc4P-d|03)tmSmO>@ADyTi-rulNG!Bq zb4G;6YV`}{lw;m$(%~jgr1n@{3T2Y@8R1XLGJVy;URHP7r*{PIj(M(cgtwM?22Cnh zH{r}i*TkRKJ^2w#pM;3Wnzxzp#pX zGaKTU>bp~dC)ULET&W6V#Fwb(NsNs=k}p^(XaaW!xmjKT%zzJwNTYaWg3AJFQoXlY z&`;bijA32;dC~@nAls3ai)K<^2mLTn>v<*BJFEUxwRpt1(KEKTQoG0RJCpy9mA||c zxb`Xj4Pe=Z9rhcVHG5hyIQ$R}5I+Vb7%qOA*$P|;0<+%uUGom8UF76f?Q!pE(Y^(T z^8|$jo2^PyF794(p|PY(B6xg1`Gddveyy2)E~Sk5KJ&|(wflQ)H%T=&mbJYY_LJ_G zN9pD5i>pt*01C`S2t((?TPMKr&hA741-Cf6&XB?PEcRQ7}uUr?)4i-_Vzg}#+ zT={E$X85YfA!|ivXPGtBU2i{i+RE-l#)CTRx}S}6lkbC@A_Hwf2SB_a$KHh1dgP*+ z)6ci!djd7PZr5ZMvMWN#*a2k+82(?fB%N;*bZ%SIBmGZ}ooh)hc{lOlH&~v(Uso97C>OQAU6<50 zoL-+6tL&=20xvjW5~7AZnU)nf{Zz~DHbLly_|f(E-kG;XqQ>-vbsl#(`>U=)U5;;J zmSx3K_NP~er}0aufBld@G8Az26Pj>%C4gvsZ~fFQ3#eV?Lv{I{Ru$k8#@p-o1rmk^&`T2*6({l^c z&Q6|^o?3T&R9$=asM)bmyYgEf&6P->oNQj18QZ1VB26x8t}HzA{teBeC2c92ttxbi zEthLUM!~J@f+caPg|J0huQ?Zze%mwahDQJ1s+tgXBzSC(5;oLG>ucW}l`#P-ZSS5) zJ^Gq3yj$hAd1}cx!st+ygbC(oKL=e$cA`F6b6s4ayW!lb{3Qs?OB~_AQZSVk{_&>ZdwyK#w@41`SF?P~a5{+>B ze;bX3QmM4PDH|$Plsp&y$V<94IY1H1*ziIxD}+=fJddoA6I}6_(x+JX`~0 zDTeAt%0=PF{xa&?&F2{N3a6t*MV+Z-j!aKnVQoHZ+2G7%#GNUaWp>}0X?gI?<*~C1 zHbPj@eZOrpf7u8-J(uqt``t+^>RL+Egn8ws?1fInnrf=pcSP|wMOUw7;`@kYW1BrX z4@#<2_^&R4543+6+EnCa%f*Uw67f6SMs;dL;K31 zeP!=yA=z0{FbwQ)FG&H-oumqYrVSK1M-_q$&*MlRIYkwLTyCJq8LAi<#z2u1RC2&+ zpvd_wInOt=XZ4$#=(jM@Z)u|6%0xdo`Tt!%9{X}lCv-=+1m1*q@aYSS5%Y&Y2te8& zpMHpwRwinDN=6&Jv@}95u9wVAguSLM7*7)@)RzPa5CckxLTxb+3oB5w5>~=$6nE!j zWZ(`mn2WbN%)ux5<{`(ib5SM^e=ATv2@;VXjW7vRh|hq@_=`e~NyuFT5#R=qocv^z zR&ZPeV#IPR_gsPUt56r=(lCOiQBMMnAiN~xtVWtxqEY*5)R=(!Rv_g93(@1ZU?SR| z5B5gCRGg+XjFN)$5xc<~7GM;;kw8o=B=t zE(Rr)M4gE~;&Buw*(X8f8H1yUmvP*f)!>S@$ee%gS zkyD{%8gYZ4t{Bs1)I`QaAx2{F4i-G0mvDWFJYQv~XDZ%PFoSMf76B$U{iMw>6A2ee7lNKFis z1viv4dIL{U6JNbr3<{H>*+YrxKW4_2NNL7Ndp4^a1~0#^8n)reojeD&HBkc8(Dr_(IoibP2p-)poRV`3Sb*w@ zCqy3j#vx4z^Nm=r5sNlrsYWcvh~*lw!$z#wh*cRetr2T7Vmc$%_ccri5)nbwHXuY`&?qoRg$cw6qo$=RdZ1_aKriTlUfcuyTo3fB9_Wod(A7QAxg@AY zP3xCK4|K;K=NLc$zyh_!&QKe$(3Tm*(-#ZuCd$w?Ar_LMJ{97I`ogi$4fP3e zff(u&+8XOq*ct0JUvNs}g2R_4heA zMZBK$;U)GsAlZSjr(xA3H6l?uduZ0vXV*3T3b=#(?L#;feusqvtNo@eaM;4j43AK^ zDCyKWme#`*yIWqhsB3t~vvosw<@!Ei>hu~>Ekr-=3FX`yCp zIW;lc@jN$ZY(-6+{6uDB^!VdL9L!4Mf>kctGPX_-&vK_z1KET8Y);$Ec1-X-+(lWh zd)q91g1C~}W91NQwr!66>;+BB*z7?pE&d62#LU<#6`iIkV4K!a=U$(h$lg}##|uCC zPU3>g^gxqVIp@$~8coq?Xv@KViUVP{5&088gvcRe{Fuvnnuf?3tshN0y)0N`Y&ajE z9^N=Vr68b!e|NrScLMi}#3gNvy#H~3=aMU*Zme~xo@rjiZ!Qbp@@stcJDwd52m0T* zx@lg=Y1>o92YxkkI(%`X)kk^G2iICBvu#B~D3?~&&r2F}7M0cHX!!nHD<9t(unqc~ z@BYx>v9q;9)zsO=Z8sfCPh*7-rH6G6%uLSVd)3pNa$m6gRwQ3jR^s82!oGE3y8V|| zowNAD@-R#9!o)=W$VYDy|C4u)h^!xQ&Hm5*7a@y?@&Aj66?OR4*00mb@cvqo1 zYqb-gEQY9`cgN(wqY-{7b)yD%a`Z&4u;xICysmZ}ReSNrD+Rn=n--r_6)%s7_W3S8 z`y2Kf|0-B1yD;t8?8Zrzb1_I7&8d#@9}EgO|G^)Eis(z$2aRN!iEHU($prw`i0-)F zc-!CZZI~vhDdwy``})nm;Iv2SNh;s%FURw%hPDR} zBR1btUM%gr^k8Y>ifbmkt}(mp&g5#u>gaj4C2Jpex^%@mRo@LBv9Z2rcK+!KJC(X- zRb=T8knXu9XyG89O8v`@pIR5gS^o9Uqr*}x#C|Cz4G;MBHJhKzP7yCEJle)pBw49y z>wi0R%ue7BD_Dw})m=x&Q#Uy_!)bTwjJcL*C(6dpdIQpz&nBxfI@MhvkC(6QY?D1+ zzT|`M`7f}t6n`Hnt9^e}d)e_N>;Iy%8~aY#FVL`V|HJ)HJ?lv8$f`ZElym)IQqpj( z+fN44PpC;u&d8TD1x%eSiYo{O;Oy_Nv+C0;D&MjfRY=bdR`m~4c(3D0 zHp?3N&hhHk^e{Yh=#jli{tz#%yPyAAhsO2SU-Fwhr{z!?kM3VcJ5dpq5Ys5(d8BVH zxvQxvFO69Kz4%BIXSK!+H zzg{Q!$@Gx+_NSeVL;TuW)GqB@Uhv-?^S_iYk|6iowD0pPrqeT`z-HLrdbK^M*>1gR zk2Q6m`F2wc2>f(c(_EOynR20d$5Bd@38JbTQ1j@Gx4({I(@Lz)SLmRQRcA>RzX3(y zzVCWy4OVWUsp>WOJ}z<}4#Yc?fsIE)pI)*Qam4kPFDV0n>oEWpLetexs&^fx6wq{` za>uS`<^mS8rlv~a4XBB~lTOysm(td)Ij1z4PaIW84%S{Stb;XHD%Ts%o7|FF3P@jh zk5~81RKOCR9r(4|y_Uqj^sP&)?_6$wla7XraZl1mx+;sP%(B4k_inls*{B|8)O|5` zVRb6(TX;{F_*fc0n7(>#?eqcd;loXZZ4cMT3fpp3A4cEben^9Ra*bw$`T1uvwj5-B z5EyJW@e}PO->1F%^xl1X?>?RUPVL>N_wLhs_vs`8z5DdueY$Sr{+G6UJu&z0(~W1`5eGk<;)m)=WhZc zkrpE-45mQST8x}8nBlky13o_!olwa*!cC9HAUyu|EMu z<6js`lp}Q|EQ3)H#PE+sZV5w`V2@~yVxA$$ABnOERRH#ws93a2%FyIUks?ks2~fHe zCB~t=5X2l|CR#iPMxcIQu+!TW%Sc*{QIaq=;xKr@TNp(zqzM>nh~32dWhhUMQsWpe z01jg;D1AJ9P>&4x31=+wMIdJk5oaWiSRBT1>Ntp>5jcxj8O_X*!bsFbe11L>%#6$M z83*zBB%aA|Mil-L%OyJSqG*XjA%$-oe@l)W6;MCw49u0o1-lO-MY20jMC?dctI)>^tCh! zu>^)fd^W|ovv95yf-#msXt}?BhG9NQJYVGfV^p8V6u<{{Qn*SbDE-Bi1TYlWAYh%w zAqVk>WLH-yH~>73l9Pa+HKegYcbd(HOg&9^V$FtPJxzBi&4wyHO?UFl2DP5X?LKv6 ngYG<;4UT%6?#!4CzIvMO+?NgEdKx*y)!JwHp9U0TG4bwqyeUM9 diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 067b298e8331c84a2416b91ca847711686cc5eb1..14c399d26fcc2e237d68c9e973fd85726a22dac0 100644 GIT binary patch literal 7544 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 mKM(aiM}1FsW=w;xo~AqZr6F8TBWJi;`wainfI notes.txt` du modèle ne doit pas atterrir sur le Bureau. + cmd.Dir = agentWorkspace() var stdout, stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -188,6 +194,7 @@ func fileWrite(path, content string) string { if strings.TrimSpace(path) == "" { return "[erreur] chemin vide" } + path = resolveAgentPath(path) if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return "[erreur] " + err.Error() @@ -221,6 +228,7 @@ func fileEdit(path, oldText, newText string) string { if oldText == "" { return "[erreur] old vide" } + path = resolveAgentPath(path) b, err := os.ReadFile(path) if err != nil { return "[erreur] " + err.Error() diff --git a/internal/jean/chat_workspace.go b/internal/jean/chat_workspace.go new file mode 100644 index 0000000..c845175 --- /dev/null +++ b/internal/jean/chat_workspace.go @@ -0,0 +1,87 @@ +package jean + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +// Dossier de travail du mode agent. +// +// Sans ça, les outils write/edit/bash héritent du répertoire courant du PROCESSUS : +// le modèle écrit "meteo.json", et le fichier atterrit là d'où l'utilisateur a +// lancé jean — le Bureau quand on double-clique l'installateur, C:\ProgramData\jean\bin +// quand on lance le binaire installé. Personne ne s'attend à voir un chat déposer +// des fichiers sur son Bureau. On donne donc à l'agent un dossier à lui : les +// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS +// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un +// dossier précis, ça doit marcher. + +const workspaceEnv = "JEAN_WORKSPACE" + +var ( + workspaceOnce sync.Once + workspacePath string +) + +// agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il +// essaie plusieurs emplacements car JeanHome() vaut %ProgramData%\jean sous +// Windows, qui n'est pas inscriptible par un utilisateur non administrateur. +func agentWorkspace() string { + workspaceOnce.Do(func() { + for _, dir := range workspaceCandidates() { + if dir == "" { + continue + } + if err := os.MkdirAll(dir, 0o755); err != nil { + continue + } + // MkdirAll réussit sur un dossier existant même non inscriptible : + // on vérifie l'écriture réelle. + f, err := os.CreateTemp(dir, ".probe-*") + if err != nil { + continue + } + name := f.Name() + f.Close() + os.Remove(name) + workspacePath = dir + return + } + // Dernier recours : le répertoire courant, comportement historique. + workspacePath, _ = os.Getwd() + }) + return workspacePath +} + +func workspaceCandidates() []string { + var c []string + if v := strings.TrimSpace(os.Getenv(workspaceEnv)); v != "" { + c = append(c, v) + } + c = append(c, filepath.Join(JeanHome(), "workspace")) + if home, err := os.UserHomeDir(); err == nil && home != "" { + c = append(c, filepath.Join(home, "jean", "workspace")) + } + c = append(c, filepath.Join(os.TempDir(), "jean-workspace")) + return c +} + +// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ; +// "~/x" → dans le home de l'utilisateur ; relatif → dans le workspace. +func resolveAgentPath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return p + } + if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) { + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, filepath.FromSlash(strings.TrimPrefix(p[1:], "/"))) + } + } + if filepath.IsAbs(p) { + return p + } + return filepath.Join(agentWorkspace(), filepath.FromSlash(p)) +} diff --git a/internal/jean/chat_workspace_test.go b/internal/jean/chat_workspace_test.go new file mode 100644 index 0000000..9ce7481 --- /dev/null +++ b/internal/jean/chat_workspace_test.go @@ -0,0 +1,35 @@ +package jean + +import ( + "path/filepath" + "strings" + "testing" +) + +// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le +// répertoire courant du processus (Bureau, C:\ProgramData\jean\bin…). +func TestResolveAgentPathRelative(t *testing.T) { + got := resolveAgentPath("meteo.json") + want := filepath.Join(agentWorkspace(), "meteo.json") + if got != want { + t.Fatalf("resolveAgentPath = %q, attendu %q", got, want) + } +} + +// Un chemin absolu demandé explicitement reste intouché. +func TestResolveAgentPathAbsolute(t *testing.T) { + abs := filepath.Join(t.TempDir(), "rapport.md") + if got := resolveAgentPath(abs); got != abs { + t.Fatalf("resolveAgentPath = %q, attendu %q", got, abs) + } +} + +func TestResolveAgentPathSubdir(t *testing.T) { + got := resolveAgentPath("notes/2026/a.txt") + if !strings.HasPrefix(got, agentWorkspace()) { + t.Fatalf("%q hors du workspace %q", got, agentWorkspace()) + } + if strings.Contains(got, "/") && filepath.Separator != '/' { + t.Fatalf("séparateurs non normalisés : %q", got) + } +} diff --git a/internal/jean/cli_app.go b/internal/jean/cli_app.go index aa36c04..7900a7a 100644 --- a/internal/jean/cli_app.go +++ b/internal/jean/cli_app.go @@ -12,7 +12,6 @@ import ( "fmt" "net" "net/http" - "os" "time" ) @@ -25,9 +24,11 @@ const appPort = 8090 func cmdApp(args []string) error { url := fmt.Sprintf("http://localhost:%d", appPort) - if _, err := os.Stat(confPath()); err != nil { - // Première fois : installe le minimum (dossiers + config de départ). - _ = cmdInstall(nil) + // Premier lancement : prépare le dossier de données et, sous Windows, propose + // explicitement l'installation (voir sys_firstrun_windows.go). Si l'app a été + // relancée depuis la copie installée, ce process n'a plus rien à faire. + if appFirstRun() { + return nil } addr := fmt.Sprintf("0.0.0.0:%d", appPort) diff --git a/internal/jean/run.go b/internal/jean/run.go index 11b8d20..13ab934 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.9" +const Version = "0.6.10" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { @@ -89,6 +89,8 @@ func Main() { mustExit(cmdLlamacpp(args)) case "update", "upgrade", "self-update": mustExit(cmdUpdate(args)) + case "where", "paths": + mustExit(cmdWhere(args)) case "install": mustExit(cmdInstall(args)) case "uninstall": @@ -153,6 +155,7 @@ Entrypoint (utilisé par jean.service) : serve lit config.env et exec le binaire llama-server Installation: + where affiche où sont le binaire, la config, la mémoire et le dossier de travail de l'agent install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config) uninstall désinstaller update [--check] mettre à jour jean depuis les releases GitHub (--check = signale sans installer) diff --git a/internal/jean/sys_brand_icon.go b/internal/jean/sys_brand_icon.go new file mode 100644 index 0000000..134de41 --- /dev/null +++ b/internal/jean/sys_brand_icon.go @@ -0,0 +1,129 @@ +package jean + +// sys_brand_icon.go — l'icône de la marque AJEAN, rendue à la volée. +// +// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis NOIR + « j » +// blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). Une seule source pour +// tous les usages — zone de notification Windows, barre de menus macOS, icône du +// .exe — pour qu'ils ne puissent plus diverger comme quand le favicon est passé +// au noir en laissant les icônes système en bleu. +// +// Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est +// produite par `go generate ./cmd/jean` (voir tools/gen-icon). + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/png" +) + +const trayIconSize = 32 + +var ( + brandBlack = color.RGBA{0x00, 0x00, 0x00, 0xff} + brandWhite = color.RGBA{0xff, 0xff, 0xff, 0xff} + brandClear = color.RGBA{0, 0, 0, 0} +) + +// glyphRects : les trois rectangles blancs du « j », en unités de la grille 12. +var glyphRects = [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}} + +// brandIconImage dessine l'icône à la taille n. bg peint le carré arrondi, fg le +// « j ». Les deux peuvent être transparents : c'est ce qui produit l'icône +// « template » de macOS (voir brandTemplatePNG). +func brandIconImage(n int, bg, fg color.RGBA) *image.RGBA { + const r = 2.0 // rayon des coins, en unités de la grille 12 + outside := func(gx, gy float64) bool { + corner := func(cx, cy float64) bool { + dx, dy := gx-cx, gy-cy + return dx*dx+dy*dy > r*r + } + switch { + case gx < r && gy < r: + return corner(r, r) + case gx > 12-r && gy < r: + return corner(12-r, r) + case gx < r && gy > 12-r: + return corner(r, 12-r) + case gx > 12-r && gy > 12-r: + return corner(12-r, 12-r) + } + return false + } + img := image.NewRGBA(image.Rect(0, 0, n, n)) + scale := float64(n) / 12 + for y := 0; y < n; y++ { + for x := 0; x < n; x++ { + gx := (float64(x) + 0.5) / scale + gy := (float64(y) + 0.5) / scale + if outside(gx, gy) { + img.Set(x, y, brandClear) + continue + } + c := bg + for _, rc := range glyphRects { + if gx >= rc[0] && gx < rc[0]+rc[2] && gy >= rc[1] && gy < rc[1]+rc[3] { + c = fg + } + } + img.Set(x, y, c) + } + } + return img +} + +func encodePNG(img *image.RGBA) []byte { + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +} + +// BrandIconPNG rend l'icône de marque (noir + « j » blanc) en PNG de n pixels. +// Exporté pour le générateur d'icône du .exe (tools/gen-icon). +func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) } + +// brandTemplatePNG rend la variante « template » attendue par macOS : seule la +// couche alpha compte, le système colore la forme selon le thème de la barre de +// menus. Le « j » est donc DÉCOUPÉ (transparent) dans un carré opaque — sans ça, +// une icône entièrement noire disparaît sur une barre de menus sombre. +func brandTemplatePNG(n int) []byte { + return encodePNG(brandIconImage(n, brandBlack, brandClear)) +} + +// BrandICO emballe une ou plusieurs tailles PNG dans un conteneur .ico. Windows +// accepte le PNG comme image d'une entrée .ico (alpha conservé pour les coins +// arrondis). Plusieurs tailles = un rendu net partout, de la barre des tâches +// (16 px) à la grande tuile de l'explorateur (256 px). +func BrandICO(sizes ...int) []byte { + if len(sizes) == 0 { + sizes = []int{trayIconSize} + } + imgs := make([][]byte, 0, len(sizes)) + for _, n := range sizes { + imgs = append(imgs, BrandIconPNG(n)) + } + var ico bytes.Buffer + binary.Write(&ico, binary.LittleEndian, uint16(0)) // réservé + binary.Write(&ico, binary.LittleEndian, uint16(1)) // type = icône + binary.Write(&ico, binary.LittleEndian, uint16(len(imgs))) // nombre d'images + offset := uint32(6 + 16*len(imgs)) // fin du répertoire + for i, p := range imgs { + n := sizes[i] + // 256 px se code 0 dans un .ico (le champ ne fait qu'un octet). + ico.WriteByte(byte(n % 256)) + ico.WriteByte(byte(n % 256)) + ico.WriteByte(0) // couleurs de la palette (0 = truecolor) + ico.WriteByte(0) // réservé + binary.Write(&ico, binary.LittleEndian, uint16(1)) // plans + binary.Write(&ico, binary.LittleEndian, uint16(32)) // bits/pixel + binary.Write(&ico, binary.LittleEndian, uint32(len(p))) // taille données + binary.Write(&ico, binary.LittleEndian, offset) // offset données + offset += uint32(len(p)) + } + for _, p := range imgs { + ico.Write(p) + } + return ico.Bytes() +} diff --git a/internal/jean/sys_firstrun_other.go b/internal/jean/sys_firstrun_other.go new file mode 100644 index 0000000..5ca594a --- /dev/null +++ b/internal/jean/sys_firstrun_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package jean + +import "os" + +// appFirstRun : hors Windows, `jean app` est lancé depuis un terminal par +// quelqu'un qui a déjà suivi l'installation — pas de double-clic à désambiguïser, +// donc pas de boîte de dialogue. On se contente de provisionner les données au +// premier lancement, comme avant. Ne relance jamais (renvoie toujours false). +func appFirstRun() bool { + if _, err := os.Stat(confPath()); err != nil { + _ = cmdInstall(nil) + } + return false +} diff --git a/internal/jean/sys_firstrun_windows.go b/internal/jean/sys_firstrun_windows.go new file mode 100644 index 0000000..266d20c --- /dev/null +++ b/internal/jean/sys_firstrun_windows.go @@ -0,0 +1,172 @@ +//go:build windows + +package jean + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unsafe" +) + +// Premier lancement sous Windows. +// +// Avant : double-cliquer jean-windows-amd64.exe déclenchait une installation +// SILENCIEUSE (copie du binaire dans %ProgramData%\jean\bin, ajout au PATH) dont +// l'utilisateur ne voyait rien — cmdApp appelait cmdInstall sans console pour en +// afficher la sortie. D'où la confusion légitime : est-ce que ça installe ou est-ce +// que ça lance ? Et surtout, deux copies du binaire coexistaient (celle du Bureau, +// qui tourne, et celle installée) : « mettre à jour » ne touchait que celle lancée, +// tandis que le raccourci et le PATH pointaient sur l'autre, restée en arrière. +// +// Maintenant : on DEMANDE, une bonne fois. Si l'utilisateur accepte, on installe +// pour de bon (copie + PATH + raccourcis menu Démarrer/Bureau) puis on relance +// depuis la copie installée et on quitte — il n'existe alors qu'UN seul binaire qui +// compte, à un emplacement connu, et le bouton de mise à jour agit dessus. S'il +// refuse, on lance l'application telle quelle, sans rien écrire ailleurs que dans +// le dossier de données. + +const ( + mbYesNo = 0x00000004 + mbIconQuestion = 0x00000020 + mbIconInfo = 0x00000040 + mbTopmost = 0x00040000 + idYes = 6 +) + +var pMessageBoxW = u32s.NewProc("MessageBoxW") + +func messageBox(text, title string, flags uintptr) int { + t, _ := syscall.UTF16PtrFromString(text) + c, _ := syscall.UTF16PtrFromString(title) + r, _, _ := pMessageBoxW.Call(0, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(c)), flags|mbTopmost) + return int(r) +} + +// appFirstRun prépare le dossier de données et, si le binaire lancé n'est pas +// celui installé, propose l'installation. Renvoie true si l'app a été relancée +// depuis la copie installée (l'appelant doit alors rendre la main immédiatement). +func appFirstRun() bool { + // Le dossier de données et config.env sont indispensables pour que l'UI + // s'ouvre : on les crée sans rien demander, ils ne surprennent personne. + _ = provisionDataDir() + + exe, err := os.Executable() + if err != nil { + return false + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + target := installedExePath() + if strings.EqualFold(exe, target) { + return false // on EST déjà l'application installée + } + if _, err := os.Stat(target); err == nil { + // Une copie est déjà installée — typiquement par l'installation silencieuse + // des versions ≤ 0.6.9. On le signale UNE SEULE FOIS : répéter la boîte à + // chaque double-clic du fichier téléchargé serait un harcèlement, pas une + // information. + notice := filepath.Join(JeanHome(), ".install_notice") + if _, seen := os.Stat(notice); seen != nil { + messageBox( + "AJEAN est déjà installé sur cet ordinateur :\n\n"+target+ + "\n\nCe fichier-ci n'est qu'une copie téléchargée : vous pouvez la supprimer et lancer AJEAN depuis le menu Démarrer.\n\nCette copie va démarrer normalement.", + "AJEAN", mbIconInfo) + _ = os.WriteFile(notice, []byte(target+"\n"), 0o644) + } + return false + } + + if messageBox( + "Installer AJEAN sur cet ordinateur ?\n\n"+ + "• le programme sera copié dans :\n "+target+"\n"+ + "• un raccourci sera ajouté au menu Démarrer et au Bureau\n"+ + "• vos réglages et modèles seront rangés dans :\n "+JeanHome()+"\n\n"+ + "Vous pourrez ensuite supprimer le fichier que vous venez de télécharger.\n\n"+ + "Répondre Non lance AJEAN sans rien installer.", + "Installation d'AJEAN", mbYesNo|mbIconQuestion) != idYes { + return false + } + + if _, err := installSelf(filepath.Dir(target)); err != nil { + messageBox("Installation impossible :\n\n"+err.Error()+"\n\nAJEAN va démarrer depuis l'emplacement actuel.", "AJEAN", mbIconInfo) + return false + } + _, _ = addToUserPath(filepath.Dir(target)) + shortcuts := createShortcuts(target) + + messageBox("AJEAN est installé.\n\n"+target+"\n\n"+shortcuts+ + "\n\nL'application va démarrer. Vous pouvez supprimer le fichier téléchargé.", + "AJEAN", mbIconInfo) + + // Relance depuis la copie installée : à partir de maintenant, une seule et + // même image du programme se met à jour, se relance et apparaît dans le tray. + cmd := exec.Command(target) + cmd.Dir = filepath.Dir(target) + if err := cmd.Start(); err != nil { + return false // échec du relancement : on continue avec la copie courante + } + return true +} + +// provisionDataDir crée le dossier de données et un config.env de départ. +// Extrait de cmdInstall pour être réutilisable au premier lancement, sans +// embarquer l'installation du binaire ni les messages de console. +func provisionDataDir() error { + jeanHome := JeanHome() + for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + conf := filepath.Join(jeanHome, "config.env") + if _, err := os.Stat(conf); os.IsNotExist(err) { + return os.WriteFile(conf, []byte(configTemplate), 0o644) + } + return nil +} + +// createShortcuts pose un raccourci « AJEAN » dans le menu Démarrer et sur le +// Bureau. Renvoie une phrase décrivant ce qui a été créé (affichée à +// l'utilisateur). Best-effort : un échec n'empêche pas l'installation. +func createShortcuts(target string) string { + ps := fmt.Sprintf(`$t=%s +$w=New-Object -ComObject WScript.Shell +$done=@() +foreach ($d in @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('Desktop'))) { + if (-not $d) { continue } + try { + $s=$w.CreateShortcut((Join-Path $d 'AJEAN.lnk')) + $s.TargetPath=$t + $s.WorkingDirectory=(Split-Path $t) + $s.Description='AJEAN — IA locale' + $s.Save() + $done+=$d + } catch {} +} +Write-Output ($done -join ';')`, psQuote(target)) + cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)) + out, err := cmd.CombinedOutput() + if err != nil || strings.TrimSpace(string(out)) == "" { + return "(raccourcis non créés — lancez AJEAN depuis " + target + ")" + } + return "Raccourci « AJEAN » ajouté au menu Démarrer et au Bureau." +} + +// removeShortcuts efface les raccourcis posés par createShortcuts. Renvoie true +// si au moins un a été supprimé. +func removeShortcuts() bool { + ps := `$n=0 +foreach ($d in @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('Desktop'))) { + if (-not $d) { continue } + $p=Join-Path $d 'AJEAN.lnk' + if (Test-Path $p) { try { Remove-Item $p -Force; $n++ } catch {} } +} +Write-Output $n` + out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).CombinedOutput() + return err == nil && strings.TrimSpace(string(out)) != "0" +} diff --git a/internal/jean/sys_install_windows.go b/internal/jean/sys_install_windows.go index d6fc32a..58bc896 100644 --- a/internal/jean/sys_install_windows.go +++ b/internal/jean/sys_install_windows.go @@ -44,22 +44,18 @@ func cmdInstall(args []string) error { fmt.Printf(" JEAN_HOME = %s\n", jeanHome) fmt.Printf(" service = %s\n", serviceName()) - // 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 + // 1-2. Dossiers + config.env de départ (partagé avec le premier lancement de + // l'app, voir provisionDataDir dans sys_firstrun_windows.go). conf := filepath.Join(jeanHome, "config.env") - if _, err := os.Stat(conf); os.IsNotExist(err) { - if err := os.WriteFile(conf, []byte(configTemplate), 0o644); err != nil { - return err - } - fmt.Printf(" %s écrit %s\n", green("✓"), conf) - } else { + _, statErr := os.Stat(conf) + alreadyThere := statErr == nil + if err := provisionDataDir(); err != nil { + return err + } + if alreadyThere { fmt.Printf(" %s config.env déjà présent — inchangé\n", dim("•")) + } else { + fmt.Printf(" %s écrit %s\n", green("✓"), conf) } // 3. Install the binary into JEAN_HOME\bin and put that dir on the user PATH, @@ -203,6 +199,11 @@ func cmdUninstall(args []string) error { fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir) } + // Raccourcis posés à l'installation (menu Démarrer + Bureau). + if removeShortcuts() { + fmt.Printf(" %s raccourcis « AJEAN » supprimés\n", green("✓")) + } + if !keepData { jeanHome := JeanHome() if err := os.RemoveAll(jeanHome); err != nil { diff --git a/internal/jean/sys_paths.go b/internal/jean/sys_paths.go new file mode 100644 index 0000000..5a05a00 --- /dev/null +++ b/internal/jean/sys_paths.go @@ -0,0 +1,79 @@ +package jean + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" +) + +// « Où sont mes fichiers ? » — la question que personne ne devrait avoir à poser. +// Un binaire unique qui s'auto-installe au premier lancement rend les +// emplacements invisibles : on ne sait plus si le .exe qu'on a double-cliqué EST +// l'application ou seulement un installateur, ni où atterrissent la config, les +// modèles et les fichiers que l'agent écrit. On centralise donc la réponse ici, +// et on l'expose à la fois en ligne de commande (`jean where`) et dans l'UI. + +type jeanPaths struct { + Home string `json:"home"` // JEAN_HOME : racine des données + Config string `json:"config"` // config.env + Exe string `json:"exe"` // binaire en cours d'exécution + Installed string `json:"installed"` // binaire installé (peut différer de Exe) + Workspace string `json:"workspace"` // dossier de travail du mode agent + Memory string `json:"memory"` + Presets string `json:"presets"` +} + +// installedExePath est l'emplacement canonique du binaire après installation. +// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/jean +// (c'est ce que référencent les unités systemd et le plist launchd) ; sous Windows, +// faute d'équivalent, on utilise JEAN_HOME\bin ajouté au PATH utilisateur. +func installedExePath() string { + if runtime.GOOS == "windows" { + return filepath.Join(JeanHome(), "bin", "jean.exe") + } + return "/usr/local/bin/jean" +} + +func currentPaths() jeanPaths { + exe, _ := os.Executable() + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return jeanPaths{ + Home: JeanHome(), + Config: confPath(), + Exe: exe, + Installed: installedExePath(), + Workspace: agentWorkspace(), + Memory: memoryDir(), + Presets: presetsDir(), + } +} + +func cmdWhere(args []string) error { + p := currentPaths() + fmt.Printf("Emplacements AJEAN\n\n") + for _, row := range [][2]string{ + {"données (JEAN_HOME)", p.Home}, + {"configuration", p.Config}, + {"binaire en cours", p.Exe}, + {"binaire installé", p.Installed}, + {"travail de l'agent", p.Workspace}, + {"mémoire", p.Memory}, + {"presets", p.Presets}, + } { + fmt.Printf(" %-20s %s\n", row[0], row[1]) + } + if p.Exe != p.Installed { + fmt.Printf("\n%s tu exécutes une copie qui n'est PAS le binaire installé.\n", dim("[info]")) + fmt.Printf(" Les mises à jour depuis l'application ne modifient que la copie lancée.\n") + } + return nil +} + +// handlePaths (GET /api/paths) alimente le bloc « Emplacements » de l'UI. +func handlePaths(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, currentPaths()) +} diff --git a/internal/jean/sys_splash_windows.go b/internal/jean/sys_splash_windows.go index 2abd239..d1ff561 100644 --- a/internal/jean/sys_splash_windows.go +++ b/internal/jean/sys_splash_windows.go @@ -94,7 +94,9 @@ type wndClassExW struct { } const ( - colBlue = 0x00EB6F1F // COLORREF = 0x00BBGGRR pour #1f6feb + // Fond noir + « j » blanc : même marque que le favicon et que les icônes + // système (voir sys_brand_icon.go). L'écran de démarrage restait bleu. + colBrand = 0x00000000 // COLORREF = 0x00BBGGRR colWhite = 0x00FFFFFF ) @@ -212,18 +214,19 @@ func splashWndProc(hwnd, message, wParam, lParam uintptr) uintptr { } func paintSplash(hdc uintptr) { - // Fond bleu. - blue, _, _ := pCreateSolidBrush.Call(colBlue) + // Fond aux couleurs de la marque. + bg, _, _ := pCreateSolidBrush.Call(colBrand) full := rect{0, 0, splashW, splashH} - pFillRect.Call(hdc, uintptr(unsafe.Pointer(&full)), blue) - pDeleteObject.Call(blue) + pFillRect.Call(hdc, uintptr(unsafe.Pointer(&full)), bg) + pDeleteObject.Call(bg) - // Logo « j » blanc (rects du favicon sur grille 12, mis à l'échelle). + // Logo « j » blanc — mêmes rectangles que l'icône et le favicon + // (glyphRects, sys_brand_icon.go), mis à l'échelle. white, _, _ := pCreateSolidBrush.Call(colWhite) const logo = 58 ox, oy := int32(40), int32((splashH-logo)/2) scale := float64(logo) / 12 - for _, rc := range [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}} { + for _, rc := range glyphRects { r := rect{ left: ox + int32(rc[0]*scale), top: oy + int32(rc[1]*scale), diff --git a/internal/jean/sys_tray_darwin.go b/internal/jean/sys_tray_darwin.go index 9f98f87..038788f 100644 --- a/internal/jean/sys_tray_darwin.go +++ b/internal/jean/sys_tray_darwin.go @@ -22,7 +22,10 @@ import ( // À appeler depuis la goroutine principale : Cocoa exige le thread main. func runTray(url string) { systray.Run(func() { - systray.SetIcon(trayIconPNG()) + // Icône « template » : macOS n'en garde que la forme (couche alpha) et la + // colore selon le thème. Indispensable depuis que la marque est noire — + // une icône noire « en dur » est illisible sur une barre de menus sombre. + systray.SetTemplateIcon(brandTemplatePNG(trayIconSize), BrandIconPNG(trayIconSize)) systray.SetTooltip("AJEAN — votre IA locale") mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface") systray.AddSeparator() diff --git a/internal/jean/sys_tray_icon.go b/internal/jean/sys_tray_icon.go deleted file mode 100644 index 790f224..0000000 --- a/internal/jean/sys_tray_icon.go +++ /dev/null @@ -1,69 +0,0 @@ -//go:build windows || darwin - -package jean - -// sys_tray_icon.go — icône de Jean pour la zone de notification (Windows) et la -// barre de menus (macOS). RÉPLIQUE EXACTE du favicon de l'UI web : carré bleu -// arrondi #1f6feb + « j » blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). -// Générée à la volée — pas d'asset binaire à embarquer. - -import ( - "bytes" - "image" - "image/color" - "image/png" -) - -const trayIconSize = 32 - -// trayIconPNG rend l'icône en PNG 32x32 avec alpha (coins arrondis). -func trayIconPNG() []byte { - const n = 32 - blue := color.RGBA{0x1f, 0x6f, 0xeb, 0xff} - white := color.RGBA{0xff, 0xff, 0xff, 0xff} - clear := color.RGBA{0, 0, 0, 0} - const r = 2.0 // rayon des coins, en unités de la grille 12 - // rects blancs du favicon (x, y, w, h en unités 12) - rects := [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}} - // hors du rectangle à coins arrondis (grille 12) → pixel transparent - outside := func(gx, gy float64) bool { - corner := func(cx, cy float64) bool { - dx, dy := gx-cx, gy-cy - return dx*dx+dy*dy > r*r - } - switch { - case gx < r && gy < r: - return corner(r, r) - case gx > 12-r && gy < r: - return corner(12-r, r) - case gx < r && gy > 12-r: - return corner(r, 12-r) - case gx > 12-r && gy > 12-r: - return corner(12-r, 12-r) - } - return false - } - img := image.NewRGBA(image.Rect(0, 0, n, n)) - scale := float64(n) / 12 - for y := 0; y < n; y++ { - for x := 0; x < n; x++ { - gx := (float64(x) + 0.5) / scale - gy := (float64(y) + 0.5) / scale - if outside(gx, gy) { - img.Set(x, y, clear) - continue - } - c := blue - for _, rc := range rects { - if gx >= rc[0] && gx < rc[0]+rc[2] && gy >= rc[1] && gy < rc[1]+rc[3] { - c = white - } - } - img.Set(x, y, c) - } - } - - var pngBuf bytes.Buffer - _ = png.Encode(&pngBuf, img) - return pngBuf.Bytes() -} diff --git a/internal/jean/sys_tray_windows.go b/internal/jean/sys_tray_windows.go index 13b89a3..655e4e3 100644 --- a/internal/jean/sys_tray_windows.go +++ b/internal/jean/sys_tray_windows.go @@ -12,8 +12,6 @@ package jean // donc CGO) ; les builds Linux ne touchent jamais systray. import ( - "bytes" - "encoding/binary" "os" "github.com/getlantern/systray" @@ -46,24 +44,7 @@ func runTray(url string) { }) } -// trayIcon emballe le PNG de trayIconPNG() dans un conteneur .ico (ICONDIR + -// une ICONDIRENTRY) : Windows accepte un PNG (avec alpha pour les coins -// arrondis) comme image d'un .ico. -func trayIcon() []byte { - p := trayIconPNG() - const n = trayIconSize - var ico bytes.Buffer - binary.Write(&ico, binary.LittleEndian, uint16(0)) // réservé - binary.Write(&ico, binary.LittleEndian, uint16(1)) // type = icône - binary.Write(&ico, binary.LittleEndian, uint16(1)) // nombre d'images - ico.WriteByte(n) // largeur - ico.WriteByte(n) // hauteur - ico.WriteByte(0) // couleurs - ico.WriteByte(0) // réservé - binary.Write(&ico, binary.LittleEndian, uint16(1)) // plans - binary.Write(&ico, binary.LittleEndian, uint16(32)) // bits/pixel - binary.Write(&ico, binary.LittleEndian, uint32(len(p))) // taille données - binary.Write(&ico, binary.LittleEndian, uint32(6+16)) // offset données - ico.Write(p) - return ico.Bytes() -} +// trayIcon : l'icône de marque en .ico, deux tailles pour rester nette selon +// l'échelle d'affichage. Le rendu vit dans sys_brand_icon.go, partagé avec +// l'icône du .exe. +func trayIcon() []byte { return BrandICO(16, trayIconSize) } diff --git a/internal/jean/sys_update.go b/internal/jean/sys_update.go index f45a655..e6abadc 100644 --- a/internal/jean/sys_update.go +++ b/internal/jean/sys_update.go @@ -134,9 +134,20 @@ func applyUpdate() (string, error) { if resolved, err := filepath.EvalSymlinks(exe); err == nil { exe = resolved } + // Contrôle des droits AVANT de télécharger : sans ça, l'échec survient sur le + // os.Create du fichier temporaire et l'utilisateur reçoit un « open + // /usr/local/bin/.jean-update.tmp: permission denied » incompréhensible, + // notamment depuis le bouton de l'UI web (jean web lancé sans sudo). + if err := checkUpdateWritable(exe); err != nil { + return "", err + } tmp := filepath.Join(filepath.Dir(exe), ".jean-update.tmp") if err := downloadTo(url, tmp); err != nil { + os.Remove(tmp) + if os.IsPermission(err) { + return "", updatePermissionError(exe) + } return "", fmt.Errorf("téléchargement : %w", err) } if err := verifyChecksum(rel, want, tmp); err != nil { @@ -158,13 +169,40 @@ func applyUpdate() (string, error) { if err := replaceBinary(exe, tmp); err != nil { os.Remove(tmp) if os.IsPermission(err) { - return "", fmt.Errorf("droits insuffisants pour écrire %s — relance avec privilèges (ex : sudo jean update)", exe) + return "", updatePermissionError(exe) } return "", err } return strings.TrimPrefix(latest, "v"), nil } +// updatePermissionError formule le seul message utile quand jean n'a pas les +// droits d'écrire son propre binaire : la commande exacte à lancer. +func updatePermissionError(exe string) error { + if runtime.GOOS == "windows" { + return fmt.Errorf("droits insuffisants pour remplacer %s — relance AJEAN en administrateur puis réessaie", exe) + } + return fmt.Errorf("droits insuffisants pour remplacer %s (le binaire appartient à root) — lance la mise à jour en ligne de commande : sudo jean update", exe) +} + +// checkUpdateWritable vérifie qu'on peut écrire dans le dossier du binaire, en +// créant réellement un fichier témoin (les bits de permission seuls ne suffisent +// pas : root, groupes, ACL, montage en lecture seule…). +func checkUpdateWritable(exe string) error { + dir := filepath.Dir(exe) + probe, err := os.CreateTemp(dir, ".jean-update-probe-*") + if err != nil { + if os.IsPermission(err) { + return updatePermissionError(exe) + } + return fmt.Errorf("impossible d'écrire dans %s : %w", dir, err) + } + name := probe.Name() + probe.Close() + os.Remove(name) + return nil +} + func cmdUpdate(args []string) error { checkOnly := false for _, a := range args { diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 59ae378..0c0d407 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -1059,6 +1059,8 @@ button:hover{border-color:var(--dim);color:var(--text)}
    +
    +
    @@ -1659,11 +1661,31 @@ async function checkUpdate(){ }catch(e){ msg.textContent='Erreur réseau'; } b.disabled=false; } +// Emplacements : la question « où sont mes fichiers / est-ce que ça a été +// installé ? » revient assez souvent pour mériter une réponse dans l'UI. +async function showPaths(){ + const el=document.getElementById('paths-msg'); + el.textContent='…'; + try{ + const p=await jget('/api/paths'); + const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'agent',p.workspace],['Mémoire',p.memory],['Programme en cours',p.exe]]; + el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); + if(p.exe && p.installed && p.exe!==p.installed){ + el.innerHTML+='
    Vous exécutez une copie du programme, pas celle installée ('+escHtml(p.installed)+'). La mise à jour ne modifiera que la copie lancée.
    '; + } + }catch(e){ el.textContent='Erreur'; } +} async function applyUpdate(){ const msg=document.getElementById('upd-msg'); msg.textContent='Téléchargement et installation…'; try{ - const r=await jpost('/api/update/apply',{}); + // Signal dédié : le timeout par défaut (30 s) coupe le téléchargement du + // binaire sur une connexion lente et fait croire à un échec alors que la + // mise à jour aboutit côté serveur. + const ac=new AbortController(); const t=setTimeout(()=>ac.abort(), 10*60*1000); + let r; + try{ r=await (await jfetch('/api/update/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',signal:ac.signal})).json(); } + finally{ clearTimeout(t); } if(r.ok){ msg.innerHTML='✓ Installé en v'+r.version+'.
    '+(r.restart||''); // Redémarrage auto du service côté serveur : le flux va se couper puis diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 9911af4..fe30c22 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -231,6 +231,8 @@
    +
    +
    diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js index 5a7b7fe..81b8e9e 100644 --- a/internal/jean/ui/src/js/05-status.js +++ b/internal/jean/ui/src/js/05-status.js @@ -61,11 +61,31 @@ async function checkUpdate(){ }catch(e){ msg.textContent='Erreur réseau'; } b.disabled=false; } +// Emplacements : la question « où sont mes fichiers / est-ce que ça a été +// installé ? » revient assez souvent pour mériter une réponse dans l'UI. +async function showPaths(){ + const el=document.getElementById('paths-msg'); + el.textContent='…'; + try{ + const p=await jget('/api/paths'); + const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'agent',p.workspace],['Mémoire',p.memory],['Programme en cours',p.exe]]; + el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); + if(p.exe && p.installed && p.exe!==p.installed){ + el.innerHTML+='
    Vous exécutez une copie du programme, pas celle installée ('+escHtml(p.installed)+'). La mise à jour ne modifiera que la copie lancée.
    '; + } + }catch(e){ el.textContent='Erreur'; } +} async function applyUpdate(){ const msg=document.getElementById('upd-msg'); msg.textContent='Téléchargement et installation…'; try{ - const r=await jpost('/api/update/apply',{}); + // Signal dédié : le timeout par défaut (30 s) coupe le téléchargement du + // binaire sur une connexion lente et fait croire à un échec alors que la + // mise à jour aboutit côté serveur. + const ac=new AbortController(); const t=setTimeout(()=>ac.abort(), 10*60*1000); + let r; + try{ r=await (await jfetch('/api/update/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',signal:ac.signal})).json(); } + finally{ clearTimeout(t); } if(r.ok){ msg.innerHTML='✓ Installé en v'+r.version+'.
    '+(r.restart||''); // Redémarrage auto du service côté serveur : le flux va se couper puis diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index e51fdd8..b13939a 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -89,6 +89,7 @@ func newWebMux() *http.ServeMux { api("/api/ram", handleRam) api("/api/config", handleConfigEnv) api("/api/catalog", handleCatalog) + api("/api/paths", handlePaths) api("/api/update", handleUpdateCheck) api("/api/update/apply", handleUpdateApply) api("/api/models", handleModels) diff --git a/tools/gen-icon/main.go b/tools/gen-icon/main.go new file mode 100644 index 0000000..d90b24f --- /dev/null +++ b/tools/gen-icon/main.go @@ -0,0 +1,39 @@ +// gen-icon écrit cmd/jean/icon.ico à partir de l'icône de marque dessinée dans +// internal/jean (sys_brand_icon.go), pour que l'icône du .exe soit toujours la +// même que le favicon de l'UI et que celle de la zone de notification. +// +// Lancé par `go generate ./cmd/jean`, AVANT goversioninfo qui embarque le .ico +// dans les ressources Windows. +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/nathaninline/jean/internal/jean" +) + +func main() { + out := "icon.ico" + if len(os.Args) > 1 { + out = os.Args[1] + } + // Les tailles attendues par l'explorateur Windows, de la barre des tâches à + // l'aperçu en grandes icônes. + write(out, jean.BrandICO(16, 32, 48, 64, 128, 256)) + + // PNG 1024 pour le bundle macOS : la CI en tire le .icns via sips/iconutil. + // Elle partait auparavant du .ico, ce qui suppose que sips sache lire un ICO — + // et l'échec y est masqué par « || true », donc une app Mac sans icône passait + // inaperçue. Un PNG ne laisse aucune place au doute. + write(strings.TrimSuffix(out, ".ico")+".png", jean.BrandIconPNG(1024)) +} + +func write(path string, data []byte) { + if err := os.WriteFile(path, data, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "gen-icon:", err) + os.Exit(1) + } + fmt.Println("OK ->", path) +} From f541d997d2a8e433aa7643171296d1d03d91d6c6 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 11:27:30 +0200 Subject: [PATCH 189/279] Notes de release : suppression des tirets cadratins --- RELEASE_NOTES.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5db767a..db9ceaf 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,8 @@ ## Corrections -**L'IA déposait ses fichiers sur votre Bureau.** En mode agent, quand elle créait un fichier — une recherche web mise de côté, un script, des notes — celui-ci atterrissait dans le dossier depuis lequel AJEAN avait été lancé. Le Bureau si vous aviez double-cliqué le fichier téléchargé, `C:\ProgramData\jean\bin` si vous passiez par l'installation. Personne ne s'attend à ce qu'une conversation laisse des fichiers derrière elle. +**L'IA déposait ses fichiers sur votre Bureau.** En mode agent, quand elle créait un fichier (une recherche web mise de côté, un script, des notes), celui-ci atterrissait dans le dossier depuis lequel AJEAN avait été lancé. Le Bureau si vous aviez double-cliqué le fichier téléchargé, `C:\ProgramData\jean\bin` si vous passiez par l'installation. Personne ne s'attend à ce qu'une conversation laisse des fichiers derrière elle. -L'IA travaille désormais dans un dossier à elle, `workspace`, rangé avec vos réglages. Les fichiers déjà créés ne bougent pas ; le bouton « Où sont mes fichiers ? » vous dit où regarder. Si vous lui demandez explicitement d'écrire à un endroit précis, elle le fait toujours : seuls les fichiers sans destination indiquée sont rangés. +L'IA travaille désormais dans un dossier à elle, `workspace`, rangé avec vos réglages. Les fichiers déjà créés ne bougent pas, et le bouton « Où sont mes fichiers ? » vous dit où regarder. Si vous lui demandez explicitement d'écrire à un endroit précis, elle le fait toujours : seuls les fichiers sans destination indiquée sont rangés. Merci à Sébastien pour le signalement, et pour avoir tout de suite pointé le lien avec le dossier de lancement. @@ -31,3 +31,5 @@ jean update ``` Si vous lancez AJEAN sans privilèges administrateur et que le programme appartient à root, cette version-ci doit encore être installée en ligne de commande avec `sudo jean update`. Les suivantes vous le diront clairement depuis l'interface. + +L'icône de la barre de menus macOS n'a pas pu être vérifiée sur une vraie machine. From 0d9284e95c09c9be050bd4b96630686208aac4c0 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 11:39:25 +0200 Subject: [PATCH 190/279] v0.6.11 : le .exe telecharge met a jour et demarre, emplacements dans le journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Windows : quand AJEAN est deja installe, lancer le fichier telecharge remplace le binaire installe (s'il est plus recent) puis demarre l'application, sans message. La 0.6.10 annoncait « vous lancez une copie » puis demarrait quand meme : une information inactionnable suivie d'un comportement non annonce. Le refus d'installation est desormais memorise (.install_declined). - la version du binaire installe est lue dans les ressources du fichier (VS_VERSIONINFO). L'implementation evidente (executer `jean version` et lire la sortie) renvoie du VIDE : le binaire est en sous-systeme GUI et n'ecrit pas dans un tuyau, donc la mise a jour ne se serait jamais faite, en silence. Piege documente dans le code + deux tests. - UI : les emplacements passent sous le journal du moteur (clic sur la pastille d'etat) au lieu d'un bouton isole dans les actions. --- RELEASE_NOTES.md | 28 +--- cmd/jean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/jean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/jean/versioninfo.json | 6 +- internal/jean/run.go | 2 +- internal/jean/sys_firstrun_windows.go | 160 +++++++++++++++++---- internal/jean/sys_firstrun_windows_test.go | 39 +++++ internal/jean/ui/index.html | 22 +-- internal/jean/ui/src/index.tmpl.html | 8 +- internal/jean/ui/src/js/05-status.js | 14 +- 10 files changed, 209 insertions(+), 70 deletions(-) create mode 100644 internal/jean/sys_firstrun_windows_test.go diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index db9ceaf..82913ad 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,28 +1,14 @@ -## Corrections - -**L'IA déposait ses fichiers sur votre Bureau.** En mode agent, quand elle créait un fichier (une recherche web mise de côté, un script, des notes), celui-ci atterrissait dans le dossier depuis lequel AJEAN avait été lancé. Le Bureau si vous aviez double-cliqué le fichier téléchargé, `C:\ProgramData\jean\bin` si vous passiez par l'installation. Personne ne s'attend à ce qu'une conversation laisse des fichiers derrière elle. - -L'IA travaille désormais dans un dossier à elle, `workspace`, rangé avec vos réglages. Les fichiers déjà créés ne bougent pas, et le bouton « Où sont mes fichiers ? » vous dit où regarder. Si vous lui demandez explicitement d'écrire à un endroit précis, elle le fait toujours : seuls les fichiers sans destination indiquée sont rangés. - -Merci à Sébastien pour le signalement, et pour avoir tout de suite pointé le lien avec le dossier de lancement. - -**Le bouton de mise à jour échouait sur un message incompréhensible.** Sous Linux, lancé sans `sudo`, il répondait `open /usr/local/bin/.jean-update.tmp: permission denied`. AJEAN prévoyait bien un message expliquant qu'il fallait des privilèges, mais il arrivait trop tard dans le déroulé : l'échec se produisait avant, au moment d'écrire le fichier temporaire. - -Les droits sont maintenant vérifiés avant de télécharger quoi que ce soit, et le message donne la commande exacte à lancer. Le téléchargement dispose par ailleurs de dix minutes au lieu de trente secondes : sur une connexion lente, une mise à jour qui réussissait pouvait être annoncée comme un échec. - -Merci à Emmanuel pour le signalement et pour avoir isolé le rôle de `sudo`. - ## Lancement sous Windows -**On sait enfin ce que fait le fichier téléchargé.** Le double-clic sur `jean-windows-amd64.exe` déclenchait une installation silencieuse : copie du programme, ajout au PATH, sans le moindre message. Impossible de savoir si l'on venait d'installer AJEAN ou seulement de l'ouvrir. Deux copies du programme coexistaient alors, celle que vous lanciez et celle installée, et la mise à jour ne touchait que la première. +**Le fichier téléchargé met AJEAN à jour et démarre.** Si AJEAN est déjà installé sur la machine, lancer `jean-windows-amd64.exe` remplace le programme installé par cette version, puis ouvre l'application. Sans question, sans message : c'est la seule chose qu'on puisse attendre d'un fichier qu'on vient de télécharger. -AJEAN vous pose maintenant la question, une seule fois. Il annonce où il s'installe, où vont vos réglages, ajoute un raccourci au menu Démarrer et au Bureau, puis démarre depuis la copie installée. Un seul programme, à un endroit connu, que la mise à jour retrouve. Répondre non le lance sans rien installer. La désinstallation retire les raccourcis qu'elle a posés. +La 0.6.10 se contentait de signaler qu'AJEAN était déjà installé ailleurs, puis démarrait quand même la copie téléchargée. Une information dont on ne pouvait rien faire, suivie d'un comportement qu'elle n'annonçait pas. + +À la toute première installation, en revanche, AJEAN demande toujours : c'est le seul moment où savoir ce qui est posé sur la machine, et à quel endroit, change quelque chose. Un refus est désormais mémorisé au lieu d'être reposé à chaque lancement. ## Interface -**« Où sont mes fichiers ? »** Un bouton dans les actions affiche l'emplacement de vos données, de la configuration, de la mémoire et du dossier de travail de l'IA. Il vous prévient aussi si le programme que vous exécutez n'est pas celui installé. En ligne de commande : `jean where`. - -**Nouvelle icône.** L'icône de l'application, celle de la zone de notification Windows et celle de la barre de menus macOS reprennent le logo noir du site. Elles sont dessinées à partir d'une source unique et ne peuvent plus se retrouver décalées les unes des autres. Sur macOS, l'icône s'adapte au thème clair ou sombre de la barre de menus. +**Les emplacements ont rejoint le journal du moteur.** Le dossier de vos données, la configuration, la mémoire et le dossier de travail de l'IA s'affichent sous le journal, en cliquant sur la pastille d'état. Ils avaient leur propre bouton dans les actions en 0.6.10, loin de l'endroit où l'on va quand on cherche à comprendre son installation. ## Mise à jour @@ -30,6 +16,6 @@ AJEAN vous pose maintenant la question, une seule fois. Il annonce où il s'inst jean update ``` -Si vous lancez AJEAN sans privilèges administrateur et que le programme appartient à root, cette version-ci doit encore être installée en ligne de commande avec `sudo jean update`. Les suivantes vous le diront clairement depuis l'interface. +Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait désormais le même travail. -L'icône de la barre de menus macOS n'a pas pu être vérifiée sur une vraie machine. +L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas pu être vérifiée sur une vraie machine. diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index f0a18da0ad4e7931fed0fc3aafd6fe9382f8f95a..783dc097c08ea106aa4086eee4da5cb81bf6f42c 100644 GIT binary patch delta 35 ocmexi^}}idmn0`Q0~-SfaBmit6lY>I+^j7f&d6vuxmo5b0F)UBF#rGn delta 35 ocmexi^}}idmn0__0~-SfaBUWr6lY>I*sLub&d6vmxmo5b0F&AXEdT%j diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 14c399d26fcc2e237d68c9e973fd85726a22dac0..6887f3021561d9e70ff80f83611d3f0566b6393a 100644 GIT binary patch delta 35 ocmexi^}}idmn0`Q0~-SfaBmit6lY>I+^j7f&d6vuxmo5b0F)UBF#rGn delta 35 ocmexi^}}idmn0__0~-SfaBUWr6lY>I*sLub&d6vmxmo5b0F&AXEdT%j diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 2b81497..069538f 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 10, + "Patch": 11, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 10, + "Patch": 11, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.10", + "ProductVersion": "0.6.11", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/run.go b/internal/jean/run.go index 13ab934..2b3dc12 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.10" +const Version = "0.6.11" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/sys_firstrun_windows.go b/internal/jean/sys_firstrun_windows.go index 266d20c..bbb479b 100644 --- a/internal/jean/sys_firstrun_windows.go +++ b/internal/jean/sys_firstrun_windows.go @@ -10,6 +10,8 @@ import ( "strings" "syscall" "unsafe" + + "golang.org/x/mod/semver" ) // Premier lancement sous Windows. @@ -22,12 +24,21 @@ import ( // qui tourne, et celle installée) : « mettre à jour » ne touchait que celle lancée, // tandis que le raccourci et le PATH pointaient sur l'autre, restée en arrière. // -// Maintenant : on DEMANDE, une bonne fois. Si l'utilisateur accepte, on installe -// pour de bon (copie + PATH + raccourcis menu Démarrer/Bureau) puis on relance -// depuis la copie installée et on quitte — il n'existe alors qu'UN seul binaire qui -// compte, à un emplacement connu, et le bouton de mise à jour agit dessus. S'il -// refuse, on lance l'application telle quelle, sans rien écrire ailleurs que dans -// le dossier de données. +// Maintenant, deux cas seulement : +// +// 1. AJEAN n'est pas installé. On DEMANDE, une bonne fois : c'est le seul moment +// où l'utilisateur gagne à savoir ce qu'on pose sur sa machine et où. Puis +// copie + PATH + raccourcis, et on démarre depuis la copie installée. Un refus +// est mémorisé, on ne repose pas la question. +// +// 2. AJEAN est déjà installé. Le fichier téléchargé se comporte alors comme un +// installeur de mise à jour : s'il est plus récent, il remplace le binaire +// installé, puis l'application démarre. AUCUN message. Annoncer « vous lancez +// une copie, ça ne sert à rien » puis démarrer quand même n'apprenait rien et +// ne laissait rien à décider. +// +// Dans les deux cas il n'existe au final qu'UN binaire qui compte, à un +// emplacement connu, et c'est celui que le bouton de mise à jour modifie. const ( mbYesNo = 0x00000004 @@ -37,7 +48,14 @@ const ( idYes = 6 ) -var pMessageBoxW = u32s.NewProc("MessageBoxW") +var ( + pMessageBoxW = u32s.NewProc("MessageBoxW") + + verDLL = syscall.NewLazyDLL("version.dll") + pGetFileVersionInfoSizeW = verDLL.NewProc("GetFileVersionInfoSizeW") + pGetFileVersionInfoW = verDLL.NewProc("GetFileVersionInfoW") + pVerQueryValueW = verDLL.NewProc("VerQueryValueW") +) func messageBox(text, title string, flags uintptr) int { t, _ := syscall.UTF16PtrFromString(text) @@ -66,21 +84,21 @@ func appFirstRun() bool { return false // on EST déjà l'application installée } if _, err := os.Stat(target); err == nil { - // Une copie est déjà installée — typiquement par l'installation silencieuse - // des versions ≤ 0.6.9. On le signale UNE SEULE FOIS : répéter la boîte à - // chaque double-clic du fichier téléchargé serait un harcèlement, pas une - // information. - notice := filepath.Join(JeanHome(), ".install_notice") - if _, seen := os.Stat(notice); seen != nil { - messageBox( - "AJEAN est déjà installé sur cet ordinateur :\n\n"+target+ - "\n\nCe fichier-ci n'est qu'une copie téléchargée : vous pouvez la supprimer et lancer AJEAN depuis le menu Démarrer.\n\nCette copie va démarrer normalement.", - "AJEAN", mbIconInfo) - _ = os.WriteFile(notice, []byte(target+"\n"), 0o644) - } - return false + // AJEAN est déjà installé. Lancer le fichier téléchargé doit alors se + // comporter comme un installeur de mise à jour : si cette copie est plus + // récente, elle remplace celle installée, et l'application démarre. Sans + // message : annoncer « vous lancez une copie » puis démarrer quand même + // n'apprend rien à l'utilisateur et ne lui laisse rien à décider. + upgradeInstalled(target) + return launch(target) } + // Première installation : là, une question. C'est le seul moment où + // l'utilisateur a intérêt à savoir ce qui va être posé sur sa machine, et + // c'est l'absence de cette question qui rendait le double-clic illisible. + if declinedInstall() { + return false + } if messageBox( "Installer AJEAN sur cet ordinateur ?\n\n"+ "• le programme sera copié dans :\n "+target+"\n"+ @@ -89,6 +107,9 @@ func appFirstRun() bool { "Vous pourrez ensuite supprimer le fichier que vous venez de télécharger.\n\n"+ "Répondre Non lance AJEAN sans rien installer.", "Installation d'AJEAN", mbYesNo|mbIconQuestion) != idYes { + // Refus mémorisé : reposer la question à chaque double-clic serait + // ignorer la réponse déjà donnée. + _ = os.WriteFile(declineFlag(), []byte("non\n"), 0o644) return false } @@ -103,14 +124,103 @@ func appFirstRun() bool { "\n\nL'application va démarrer. Vous pouvez supprimer le fichier téléchargé.", "AJEAN", mbIconInfo) - // Relance depuis la copie installée : à partir de maintenant, une seule et - // même image du programme se met à jour, se relance et apparaît dans le tray. + return launch(target) +} + +func declineFlag() string { return filepath.Join(JeanHome(), ".install_declined") } + +func declinedInstall() bool { + _, err := os.Stat(declineFlag()) + return err == nil +} + +// launch démarre la copie installée et demande à l'appelant de rendre la main. +// Renvoie false si le lancement échoue, auquel cas l'exécutable courant prend le +// relais : mieux vaut démarrer depuis le mauvais dossier que pas du tout. +func launch(target string) bool { cmd := exec.Command(target) cmd.Dir = filepath.Dir(target) - if err := cmd.Start(); err != nil { - return false // échec du relancement : on continue avec la copie courante + return cmd.Start() == nil +} + +// upgradeInstalled remplace le binaire installé par celui qu'on exécute, quand +// ce dernier est plus récent : lancer le fichier téléchargé met ainsi AJEAN à +// jour, ce qui est la seule chose qu'on puisse raisonnablement en attendre. +// Silencieux et best-effort : si la copie échoue (application en cours +// d'exécution, donc fichier verrouillé), on démarre simplement la version en +// place, qui remontera l'onglet existant. +func upgradeInstalled(target string) { + installed := binaryVersion(target) + if installed == "" || semver.Compare(ensureV(Version), ensureV(installed)) <= 0 { + return // même version, plus ancienne, ou version illisible : on ne touche à rien } - return true + // L'exe installé peut être en cours d'exécution : on l'écarte d'abord, comme + // pour une mise à jour classique (voir replaceBinary). + old := target + ".old" + _ = os.Remove(old) + if err := os.Rename(target, old); err != nil { + return + } + if _, err := installSelf(filepath.Dir(target)); err != nil { + _ = os.Rename(old, target) // rollback + return + } + _ = os.Remove(old) // échoue si l'ancien tourne encore ; nettoyé au prochain lancement +} + +// binaryVersion lit la version dans les ressources du fichier (VS_VERSIONINFO, +// posées par goversioninfo, cf cmd/jean/versioninfo.json), comme le fait +// l'onglet « Détails » des propriétés Windows. +// +// ⚠️ NE PAS remplacer par un `jean version` exécuté : le binaire est compilé en +// sous-système GUI, il s'attache à la console du parent et n'écrit RIEN dans un +// tuyau. La sortie capturée est vide, donc la comparaison de versions échouerait +// toujours en silence et la mise à jour ne se ferait jamais (vérifié). +// +// Renvoie "" si la ressource est absente ou illisible : on s'abstient alors de +// remplacer quoi que ce soit. +func binaryVersion(path string) string { + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return "" + } + size, _, _ := pGetFileVersionInfoSizeW.Call(uintptr(unsafe.Pointer(p)), 0) + if size == 0 { + return "" + } + buf := make([]byte, size) + if r, _, _ := pGetFileVersionInfoW.Call( + uintptr(unsafe.Pointer(p)), 0, size, uintptr(unsafe.Pointer(&buf[0]))); r == 0 { + return "" + } + sub, _ := syscall.UTF16PtrFromString(`\`) + var info *vsFixedFileInfo + var length uint32 + if r, _, _ := pVerQueryValueW.Call( + uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(sub)), + uintptr(unsafe.Pointer(&info)), uintptr(unsafe.Pointer(&length))); r == 0 || info == nil { + return "" + } + return fmt.Sprintf("%d.%d.%d", + info.FileVersionMS>>16, info.FileVersionMS&0xffff, info.FileVersionLS>>16) +} + +// vsFixedFileInfo : bloc racine de VS_VERSIONINFO. Seuls les champs de version +// nous intéressent, mais la structure doit être complète pour l'alignement. +type vsFixedFileInfo struct { + Signature uint32 + StrucVersion uint32 + FileVersionMS uint32 + FileVersionLS uint32 + ProductVersionMS uint32 + ProductVersionLS uint32 + FileFlagsMask uint32 + FileFlags uint32 + FileOS uint32 + FileType uint32 + FileSubtype uint32 + FileDateMS uint32 + FileDateLS uint32 } // provisionDataDir crée le dossier de données et un config.env de départ. diff --git a/internal/jean/sys_firstrun_windows_test.go b/internal/jean/sys_firstrun_windows_test.go new file mode 100644 index 0000000..2275802 --- /dev/null +++ b/internal/jean/sys_firstrun_windows_test.go @@ -0,0 +1,39 @@ +//go:build windows + +package jean + +import ( + "os" + "path/filepath" + "regexp" + "testing" +) + +// binaryVersion pilote la mise à jour automatique du binaire installé quand on +// lance le fichier téléchargé : si elle renvoie du vide, plus aucune mise à jour +// n'a lieu, et en silence. C'est exactement ce qui arrivait avec la première +// implémentation (exécuter `jean version`, dont la sortie est vide pour un +// binaire en sous-système GUI). D'où ces tests sur la plomberie syscall. +func TestBinaryVersionReadsResource(t *testing.T) { + // kernel32.dll porte toujours une ressource de version. + dll := filepath.Join(os.Getenv("SystemRoot"), "System32", "kernel32.dll") + if _, err := os.Stat(dll); err != nil { + t.Skip("kernel32.dll introuvable") + } + v := binaryVersion(dll) + if !regexp.MustCompile(`^\d+\.\d+\.\d+$`).MatchString(v) { + t.Fatalf("binaryVersion(kernel32.dll) = %q, attendu une version x.y.z", v) + } +} + +// Un fichier sans ressource de version doit renvoyer "" : upgradeInstalled s'en +// sert pour s'abstenir plutôt que de remplacer un binaire au hasard. +func TestBinaryVersionWithoutResource(t *testing.T) { + p := filepath.Join(t.TempDir(), "vide.exe") + if err := os.WriteFile(p, []byte("pas un exe"), 0o644); err != nil { + t.Fatal(err) + } + if v := binaryVersion(p); v != "" { + t.Fatalf("binaryVersion sur un fichier sans ressource = %q, attendu \"\"", v) + } +} diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 0c0d407..4574494 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -872,6 +872,12 @@ button:hover{border-color:var(--dim);color:var(--text)}
    
    +    
    +    
    + Emplacements +
    +
    @@ -1059,8 +1065,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
    -
    -
    @@ -1635,7 +1639,7 @@ function toggleSvcLog(){ if(!box) return; const show = box.style.display==='none'; box.style.display = show ? '' : 'none'; - if(show) loadSvcLog(); + if(show){ loadSvcLog(); showPaths(); } } async function loadSvcLog(){ const el=document.getElementById('svc-log'); @@ -1661,18 +1665,16 @@ async function checkUpdate(){ }catch(e){ msg.textContent='Erreur réseau'; } b.disabled=false; } -// Emplacements : la question « où sont mes fichiers / est-ce que ça a été -// installé ? » revient assez souvent pour mériter une réponse dans l'UI. +// Emplacements — affichés avec le journal du moteur : c'est le panneau qu'on +// ouvre quand on cherche à comprendre l'état de son installation. async function showPaths(){ const el=document.getElementById('paths-msg'); + if(!el) return; el.textContent='…'; try{ const p=await jget('/api/paths'); - const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'agent',p.workspace],['Mémoire',p.memory],['Programme en cours',p.exe]]; - el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); - if(p.exe && p.installed && p.exe!==p.installed){ - el.innerHTML+='
    Vous exécutez une copie du programme, pas celle installée ('+escHtml(p.installed)+'). La mise à jour ne modifiera que la copie lancée.
    '; - } + const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'IA',p.workspace],['Mémoire',p.memory],['Programme',p.exe]]; + el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); }catch(e){ el.textContent='Erreur'; } } async function applyUpdate(){ diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index fe30c22..e2a1589 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -44,6 +44,12 @@
    
    +    
    +    
    + Emplacements +
    +
    @@ -231,8 +237,6 @@
    -
    -
    diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js index 81b8e9e..38a3541 100644 --- a/internal/jean/ui/src/js/05-status.js +++ b/internal/jean/ui/src/js/05-status.js @@ -35,7 +35,7 @@ function toggleSvcLog(){ if(!box) return; const show = box.style.display==='none'; box.style.display = show ? '' : 'none'; - if(show) loadSvcLog(); + if(show){ loadSvcLog(); showPaths(); } } async function loadSvcLog(){ const el=document.getElementById('svc-log'); @@ -61,18 +61,16 @@ async function checkUpdate(){ }catch(e){ msg.textContent='Erreur réseau'; } b.disabled=false; } -// Emplacements : la question « où sont mes fichiers / est-ce que ça a été -// installé ? » revient assez souvent pour mériter une réponse dans l'UI. +// Emplacements — affichés avec le journal du moteur : c'est le panneau qu'on +// ouvre quand on cherche à comprendre l'état de son installation. async function showPaths(){ const el=document.getElementById('paths-msg'); + if(!el) return; el.textContent='…'; try{ const p=await jget('/api/paths'); - const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'agent',p.workspace],['Mémoire',p.memory],['Programme en cours',p.exe]]; - el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); - if(p.exe && p.installed && p.exe!==p.installed){ - el.innerHTML+='
    Vous exécutez une copie du programme, pas celle installée ('+escHtml(p.installed)+'). La mise à jour ne modifiera que la copie lancée.
    '; - } + const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'IA',p.workspace],['Mémoire',p.memory],['Programme',p.exe]]; + el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); }catch(e){ el.textContent='Erreur'; } } async function applyUpdate(){ From 1d58a7b1b9afd2b72d9d20ca16a5120e7232f71d Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 12:01:13 +0200 Subject: [PATCH 191/279] =?UTF-8?q?v0.6.12=20:=20le=20.exe=20telecharge=20?= =?UTF-8?q?gere=20le=20cas=20=C2=AB=20AJEAN=20deja=20lance=20=C2=BB=20et?= =?UTF-8?q?=20les=20retours=20en=20arriere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - si AJEAN tourne, remplacer le binaire ne changeait RIEN a l'ecran (l'ancienne version restait en memoire) et rien ne le disait. On demande desormais s'il faut fermer et redemarrer pour appliquer la mise a jour, versions affichees. - si la version installee est PLUS RECENTE que le fichier lance : avertissement, aucun remplacement, choix entre demarrer et fermer. Avant, une version plus ancienne prenait la place sans un mot. - version installee illisible (binaire anterieur aux ressources de version) : traitee comme ancienne donc remplacable. La traiter comme egale condamnait ces installations a ne plus jamais se mettre a jour, en silence. - raccourcis verifies a chaque demarrage et recrees s'ils manquent, et ranges dans le dossier « Programmes » du menu Demarrer (ils etaient a la racine, donc introuvables par la recherche). La desinstallation nettoie les deux endroits. - les instances en cours sont reperees par CHEMIN complet, jamais par nom d'image : un taskkill /IM jean.exe emporterait le processus courant. --- RELEASE_NOTES.md | 16 +- cmd/jean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/jean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/jean/versioninfo.json | 6 +- internal/jean/run.go | 2 +- internal/jean/sys_firstrun_windows.go | 215 +++++++++++++++++---- internal/jean/sys_firstrun_windows_test.go | 30 ++- 7 files changed, 219 insertions(+), 50 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 82913ad..cc47dbb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,14 +1,14 @@ ## Lancement sous Windows -**Le fichier téléchargé met AJEAN à jour et démarre.** Si AJEAN est déjà installé sur la machine, lancer `jean-windows-amd64.exe` remplace le programme installé par cette version, puis ouvre l'application. Sans question, sans message : c'est la seule chose qu'on puisse attendre d'un fichier qu'on vient de télécharger. +Suite de la 0.6.11, où lancer le fichier téléchargé alors qu'AJEAN tournait déjà se contentait d'ouvrir l'application sans rien mettre à jour. -La 0.6.10 se contentait de signaler qu'AJEAN était déjà installé ailleurs, puis démarrait quand même la copie téléchargée. Une information dont on ne pouvait rien faire, suivie d'un comportement qu'elle n'annonçait pas. +**AJEAN en cours d'exécution.** Le nouveau binaire était bien écrit sur le disque, mais l'application affichée restait l'ancienne, toujours en mémoire. Rien ne changeait à l'écran, et rien ne le disait. AJEAN demande maintenant s'il faut le fermer et le redémarrer pour appliquer la mise à jour, en affichant la version en cours et celle du fichier lancé. Si vous refusez, l'application s'ouvre telle quelle, et vous savez qu'aucune mise à jour n'a été appliquée. -À la toute première installation, en revanche, AJEAN demande toujours : c'est le seul moment où savoir ce qui est posé sur la machine, et à quel endroit, change quelque chose. Un refus est désormais mémorisé au lieu d'être reposé à chaque lancement. +**Version installée plus récente que le fichier lancé.** AJEAN vous en avertit, ne remplace rien, et vous laisse choisir entre démarrer l'application ou fermer. Auparavant, une version plus ancienne pouvait prendre la place de la plus récente sans un mot. -## Interface +Le cas courant reste silencieux : fichier plus récent, application arrêtée, le binaire est remplacé et AJEAN démarre. -**Les emplacements ont rejoint le journal du moteur.** Le dossier de vos données, la configuration, la mémoire et le dossier de travail de l'IA s'affichent sous le journal, en cliquant sur la pastille d'état. Ils avaient leur propre bouton dans les actions en 0.6.10, loin de l'endroit où l'on va quand on cherche à comprendre son installation. +**Raccourcis.** Ils sont vérifiés à chaque démarrage et recréés s'ils manquent, au lieu d'être posés une seule fois le jour de l'installation. Le raccourci du menu Démarrer est par ailleurs rangé dans « Programmes », l'endroit où Windows classe les applications installées et où va chercher la recherche du menu Démarrer. Il se trouvait jusqu'ici à la racine du menu, donc difficilement trouvable. ## Mise à jour @@ -16,6 +16,8 @@ La 0.6.10 se contentait de signaler qu'AJEAN était déjà installé ailleurs, p jean update ``` -Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait désormais le même travail. +Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait le même travail. -L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas pu être vérifiée sur une vraie machine. +Ces trois comportements demandent une machine Windows avec une installation existante pour être vérifiés en conditions réelles. Les mécanismes qu'ils utilisent (lecture de la version d'un binaire, repérage des instances en cours par leur chemin, remplacement du fichier) sont couverts par des tests, mais l'enchaînement complet des fenêtres n'a pas été parcouru à la main. + +L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas été vérifiée sur une vraie machine. diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 783dc097c08ea106aa4086eee4da5cb81bf6f42c..1ffa060cb716ee3dfa261f7fbb58309a239d3b16 100644 GIT binary patch delta 35 ocmexi^}}idmn0_-0~-Sf@N5>A6lY>I+N>=d&d6vqxmo5b0F+n=H2?qr delta 35 ocmexi^}}idmn0`Q0~-SfaBmit6lY>I+^j7f&d6vuxmo5b0F)UBF#rGn diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 6887f3021561d9e70ff80f83611d3f0566b6393a..ab39c861cd70e40c1b47576aef69e56c2b4414ba 100644 GIT binary patch delta 35 ocmexi^}}idmn0_-0~-Sf@N5>A6lY>I+N>=d&d6vqxmo5b0F+n=H2?qr delta 35 ocmexi^}}idmn0`Q0~-SfaBmit6lY>I+^j7f&d6vuxmo5b0F)UBF#rGn diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 069538f..e9bdca8 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 11, + "Patch": 12, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 11, + "Patch": 12, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.11", + "ProductVersion": "0.6.12", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/run.go b/internal/jean/run.go index 2b3dc12..de7bc55 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.11" +const Version = "0.6.12" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/sys_firstrun_windows.go b/internal/jean/sys_firstrun_windows.go index bbb479b..22d3a2c 100644 --- a/internal/jean/sys_firstrun_windows.go +++ b/internal/jean/sys_firstrun_windows.go @@ -4,11 +4,14 @@ package jean import ( "fmt" + "net" "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" + "time" "unsafe" "golang.org/x/mod/semver" @@ -31,11 +34,14 @@ import ( // copie + PATH + raccourcis, et on démarre depuis la copie installée. Un refus // est mémorisé, on ne repose pas la question. // -// 2. AJEAN est déjà installé. Le fichier téléchargé se comporte alors comme un -// installeur de mise à jour : s'il est plus récent, il remplace le binaire -// installé, puis l'application démarre. AUCUN message. Annoncer « vous lancez -// une copie, ça ne sert à rien » puis démarrer quand même n'apprenait rien et -// ne laissait rien à décider. +// 2. AJEAN est déjà installé. Le fichier téléchargé se comporte en installeur de +// mise à jour, avec trois situations bien distinctes (voir runAsInstaller) : +// plus récent et application arrêtée, on remplace et on démarre, sans un mot ; +// plus récent mais application EN COURS, on demande s'il faut la fermer pour +// appliquer la mise à jour, sans quoi le nouveau binaire serait écrit sans +// que rien ne change à l'écran ; plus ancien que la version installée, on +// avertit et on laisse choisir entre démarrer et fermer, plutôt que d'imposer +// une régression silencieuse. // // Dans les deux cas il n'existe au final qu'UN binaire qui compte, à un // emplacement connu, et c'est celui que le bouton de mise à jour modifie. @@ -81,16 +87,16 @@ func appFirstRun() bool { } target := installedExePath() if strings.EqualFold(exe, target) { - return false // on EST déjà l'application installée + // On EST l'application installée. On en profite pour garantir que les + // raccourcis existent : sans ça, quelqu'un qui perd son raccourci ne + // retrouve plus AJEAN, et n'a aucune raison de relancer le fichier + // téléchargé (qu'il a souvent supprimé) pour le récupérer. En tâche de + // fond, l'appel PowerShell ne doit pas retarder le démarrage. + go ensureShortcuts(target) + return false } if _, err := os.Stat(target); err == nil { - // AJEAN est déjà installé. Lancer le fichier téléchargé doit alors se - // comporter comme un installeur de mise à jour : si cette copie est plus - // récente, elle remplace celle installée, et l'application démarre. Sans - // message : annoncer « vous lancez une copie » puis démarrer quand même - // n'apprend rien à l'utilisateur et ne lui laisse rien à décider. - upgradeInstalled(target) - return launch(target) + return runAsInstaller(target) } // Première installation : là, une question. C'est le seul moment où @@ -118,7 +124,7 @@ func appFirstRun() bool { return false } _, _ = addToUserPath(filepath.Dir(target)) - shortcuts := createShortcuts(target) + shortcuts := ensureShortcuts(target) messageBox("AJEAN est installé.\n\n"+target+"\n\n"+shortcuts+ "\n\nL'application va démarrer. Vous pouvez supprimer le fichier téléchargé.", @@ -143,29 +149,148 @@ func launch(target string) bool { return cmd.Start() == nil } -// upgradeInstalled remplace le binaire installé par celui qu'on exécute, quand -// ce dernier est plus récent : lancer le fichier téléchargé met ainsi AJEAN à -// jour, ce qui est la seule chose qu'on puisse raisonnablement en attendre. -// Silencieux et best-effort : si la copie échoue (application en cours -// d'exécution, donc fichier verrouillé), on démarre simplement la version en -// place, qui remontera l'onglet existant. -func upgradeInstalled(target string) { +// runAsInstaller : AJEAN est déjà installé et on exécute une AUTRE copie (le +// fichier fraîchement téléchargé). Le fichier joue alors le rôle d'installeur de +// mise à jour. Trois situations, trois comportements distincts, parce qu'elles +// n'appellent pas la même décision de l'utilisateur. +func runAsInstaller(target string) bool { installed := binaryVersion(target) - if installed == "" || semver.Compare(ensureV(Version), ensureV(installed)) <= 0 { - return // même version, plus ancienne, ou version illisible : on ne touche à rien + // Version illisible (binaire antérieur aux ressources de version, fichier + // tronqué par une copie interrompue) : on la traite comme ANCIENNE, donc + // remplaçable. La traiter comme « égale » condamnait ces installations à ne + // plus jamais se mettre à jour en lançant le fichier téléchargé, sans que + // rien ne le signale. + cmp := 1 + if installed != "" { + cmp = semver.Compare(ensureV(Version), ensureV(installed)) } - // L'exe installé peut être en cours d'exécution : on l'écarte d'abord, comme - // pour une mise à jour classique (voir replaceBinary). + running := runningPIDs(target) + + switch { + case cmp < 0: + // Le fichier lancé est PLUS ANCIEN que la version installée. Le remplacer + // serait une régression silencieuse, et démarrer sans rien dire laisserait + // croire qu'on utilise la version qu'on vient de télécharger. + if messageBox( + "Une version plus récente d'AJEAN est déjà installée sur cet ordinateur.\n\n"+ + " installée : "+installed+"\n"+ + " ce fichier : "+Version+"\n\n"+ + "Rien ne sera remplacé.\n\n"+ + "Voulez-vous démarrer AJEAN (version "+installed+") ?\n"+ + "Répondre Non ferme simplement cette fenêtre.", + "AJEAN", mbYesNo|mbIconQuestion) != idYes { + return true // rien à faire, on quitte sans démarrer + } + ensureShortcuts(target) + return launch(target) + + case cmp > 0 && len(running) > 0: + // Mise à jour disponible, mais AJEAN tourne. Écrire le nouveau binaire + // sans redémarrer ne changerait RIEN à ce que l'utilisateur a sous les + // yeux : l'onglet qui s'ouvrirait serait servi par l'ancienne version. On + // pose donc la seule question qui compte. + if messageBox( + "AJEAN est déjà en cours d'exécution.\n\n"+ + " version en cours : "+verLabel(installed)+"\n"+ + " ce fichier : "+Version+" (plus récente)\n\n"+ + "Fermer AJEAN et le redémarrer pour appliquer la mise à jour ?\n\n"+ + "Répondre Non ouvre AJEAN dans sa version actuelle, sans rien mettre à jour.", + "Mise à jour d'AJEAN", mbYesNo|mbIconQuestion) != idYes { + ensureShortcuts(target) + return launch(target) // l'instance en cours reprend la main (port occupé) + } + stopProcesses(running) + if err := replaceInstalled(target); err != nil { + messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.", + "AJEAN", mbIconInfo) + } + ensureShortcuts(target) + return launch(target) + + case cmp > 0: + // Mise à jour, application arrêtée : rien à décider, on remplace et on + // démarre. C'est le cas courant et il doit rester muet. + _ = replaceInstalled(target) + ensureShortcuts(target) + return launch(target) + } + + // Même version (ou version installée illisible) : on démarre l'installée. + ensureShortcuts(target) + return launch(target) +} + +// verLabel évite d'afficher un numéro de version vide dans une boîte de dialogue. +func verLabel(v string) string { + if v == "" { + return "inconnue" + } + return v +} + +// replaceInstalled écrase le binaire installé par celui qu'on exécute. Le +// renommage préalable en .old permet de remplacer un exécutable encore ouvert +// (même mécanique que replaceBinary pour `jean update`). +func replaceInstalled(target string) error { old := target + ".old" _ = os.Remove(old) if err := os.Rename(target, old); err != nil { - return + return err } if _, err := installSelf(filepath.Dir(target)); err != nil { _ = os.Rename(old, target) // rollback - return + return err } - _ = os.Remove(old) // échoue si l'ancien tourne encore ; nettoyé au prochain lancement + _ = os.Remove(old) // échoue tant que l'ancien tourne ; nettoyé au prochain lancement + return nil +} + +// runningPIDs liste les processus qui exécutent exactement ce fichier. On compare +// le CHEMIN, pas le nom : tuer par nom d'image (« jean.exe ») emporterait aussi +// le processus courant et toute autre copie sans rapport. +func runningPIDs(target string) []int { + ps := fmt.Sprintf(`$t=%s +Get-Process -ErrorAction SilentlyContinue | ForEach-Object { + try { if ($_.Path -ieq $t) { $_.Id } } catch { } +}`, psQuote(target)) + out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).Output() + if err != nil { + return nil + } + var pids []int + self := os.Getpid() + for _, line := range strings.Fields(string(out)) { + if n, err := strconv.Atoi(line); err == nil && n != self { + pids = append(pids, n) + } + } + return pids +} + +// stopProcesses arrête les instances listées, puis laisse le port se libérer : +// sans cette attente, l'instance qu'on relance trouve :8090 encore occupé et se +// contente d'ouvrir le navigateur sur une application en train de mourir. +func stopProcesses(pids []int) { + for _, pid := range pids { + if p, err := os.FindProcess(pid); err == nil { + _ = p.Kill() + } + } + for i := 0; i < 40; i++ { // jusqu'à ~4 s + if !portBusy(appPort) { + return + } + time.Sleep(100 * time.Millisecond) + } +} + +func portBusy(port int) bool { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return true + } + _ = ln.Close() + return false } // binaryVersion lit la version dans les ressources du fichier (VS_VERSIONINFO, @@ -240,20 +365,32 @@ func provisionDataDir() error { return nil } -// createShortcuts pose un raccourci « AJEAN » dans le menu Démarrer et sur le -// Bureau. Renvoie une phrase décrivant ce qui a été créé (affichée à -// l'utilisateur). Best-effort : un échec n'empêche pas l'installation. -func createShortcuts(target string) string { +// ensureShortcuts garantit qu'un raccourci « AJEAN » existe dans le menu +// Démarrer et sur le Bureau, et qu'il pointe sur le bon exécutable. Appelée à +// CHAQUE lancement du fichier téléchargé, pas seulement à l'installation : +// un raccourci perdu (nettoyage, migration de profil, dossier Démarrer +// réorganisé) rendait l'application introuvable alors qu'elle était installée. +// Les raccourcis manquants sont recréés ; ceux qui existent sont laissés tels +// quels, pour ne pas défaire un déplacement volontaire. +// +// Le raccourci du menu Démarrer va dans « Programmes », l'endroit où Windows +// range les applications installées et où la recherche du menu Démarrer va +// chercher. +func ensureShortcuts(target string) string { ps := fmt.Sprintf(`$t=%s $w=New-Object -ComObject WScript.Shell $done=@() -foreach ($d in @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('Desktop'))) { +$progs=Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs' +foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) { if (-not $d) { continue } + if (-not (Test-Path $d)) { continue } + $lnk=Join-Path $d 'AJEAN.lnk' + if (Test-Path $lnk) { $done+=$d; continue } try { - $s=$w.CreateShortcut((Join-Path $d 'AJEAN.lnk')) + $s=$w.CreateShortcut($lnk) $s.TargetPath=$t $s.WorkingDirectory=(Split-Path $t) - $s.Description='AJEAN — IA locale' + $s.Description='AJEAN, votre IA locale' $s.Save() $done+=$d } catch {} @@ -267,11 +404,13 @@ Write-Output ($done -join ';')`, psQuote(target)) return "Raccourci « AJEAN » ajouté au menu Démarrer et au Bureau." } -// removeShortcuts efface les raccourcis posés par createShortcuts. Renvoie true -// si au moins un a été supprimé. +// removeShortcuts efface les raccourcis posés par ensureShortcuts. Renvoie true +// si au moins un a été supprimé. Balaie aussi l'ancien emplacement (racine du +// menu Démarrer, utilisé jusqu'en 0.6.11) pour ne pas laisser d'orphelin. func removeShortcuts() bool { ps := `$n=0 -foreach ($d in @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('Desktop'))) { +$sm=[Environment]::GetFolderPath('StartMenu') +foreach ($d in @($sm, (Join-Path $sm 'Programs'), [Environment]::GetFolderPath('Desktop'))) { if (-not $d) { continue } $p=Join-Path $d 'AJEAN.lnk' if (Test-Path $p) { try { Remove-Item $p -Force; $n++ } catch {} } diff --git a/internal/jean/sys_firstrun_windows_test.go b/internal/jean/sys_firstrun_windows_test.go index 2275802..62bd6d8 100644 --- a/internal/jean/sys_firstrun_windows_test.go +++ b/internal/jean/sys_firstrun_windows_test.go @@ -26,7 +26,35 @@ func TestBinaryVersionReadsResource(t *testing.T) { } } -// Un fichier sans ressource de version doit renvoyer "" : upgradeInstalled s'en +// replaceInstalled doit écraser le binaire en place, y compris quand le fichier +// cible existe déjà, et laisser l'ancien de côté sans le confondre avec la cible. +func TestReplaceInstalled(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "jean.exe") + if err := os.WriteFile(target, []byte("ancienne version"), 0o755); err != nil { + t.Fatal(err) + } + if err := replaceInstalled(target); err != nil { + t.Fatalf("replaceInstalled : %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + // installSelf copie l'exécutable courant (ici le binaire de test). + self, err := os.ReadFile(os.Args[0]) + if err != nil { + t.Skip("binaire de test illisible") + } + if len(got) != len(self) { + t.Fatalf("cible = %d octets, attendu %d (le binaire courant)", len(got), len(self)) + } + if _, err := os.Stat(target + ".old"); err == nil { + t.Error(".old subsiste alors que la cible n'était pas verrouillée") + } +} + +// Un fichier sans ressource de version doit renvoyer "" : runAsInstaller s'en // sert pour s'abstenir plutôt que de remplacer un binaire au hasard. func TestBinaryVersionWithoutResource(t *testing.T) { p := filepath.Join(t.TempDir(), "vide.exe") From 5728abf93ef566a713b8a01cf212f11cc2113168 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 12:37:59 +0200 Subject: [PATCH 192/279] Windows : comparer les chemins canonises, pas les chaines brutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows expose le meme fichier sous plusieurs ecritures (forme courte 8.3 « ADMINI~1 » contre « Administrateur », casse, liens). Les deux comparaisons de chemins du premier lancement le supposaient identique des deux cotes : - runningPIDs ratait l'instance en cours -> on remplacait le binaire en croyant l'application arretee, elle continuait de tourner en ancienne version sans que rien ne l'indique. C'est le bug corrige en 0.6.12, revenu par une autre porte. - appFirstRun : l'application installee se serait prise pour une copie telechargee et se serait relancee, en BOUCLE INFINIE. Constate sur banc d'essai Windows (JEAN_HOME sous un chemin en forme courte). --- internal/jean/sys_firstrun_windows.go | 49 +++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/internal/jean/sys_firstrun_windows.go b/internal/jean/sys_firstrun_windows.go index 22d3a2c..b3fae99 100644 --- a/internal/jean/sys_firstrun_windows.go +++ b/internal/jean/sys_firstrun_windows.go @@ -82,11 +82,11 @@ func appFirstRun() bool { if err != nil { return false } - if resolved, err := filepath.EvalSymlinks(exe); err == nil { - exe = resolved - } target := installedExePath() - if strings.EqualFold(exe, target) { + // canonPath des DEUX côtés : si l'égalité rate parce que les chemins sont + // écrits différemment (forme courte 8.3, casse, lien), l'application installée + // se prend pour une copie téléchargée et se relance… en boucle infinie. + if canonPath(exe) == canonPath(target) { // On EST l'application installée. On en profite pour garantir que les // raccourcis existent : sans ça, quelqu'un qui perd son raccourci ne // retrouve plus AJEAN, et n'a aucune raison de relancer le fichier @@ -248,25 +248,54 @@ func replaceInstalled(target string) error { // runningPIDs liste les processus qui exécutent exactement ce fichier. On compare // le CHEMIN, pas le nom : tuer par nom d'image (« jean.exe ») emporterait aussi // le processus courant et toute autre copie sans rapport. +// +// La comparaison se fait ICI, sur des chemins canonisés, et non dans le script +// PowerShell : Windows expose le même fichier sous plusieurs écritures (forme +// courte 8.3 « ADMINI~1 » contre « Administrateur », casse variable, liens). Un +// simple -ieq entre chaînes rate alors l'instance en cours, et on remplace le +// binaire en croyant l'application arrêtée : elle continue de tourner en +// ancienne version, sans que rien ne l'indique. Constaté sur banc d'essai. func runningPIDs(target string) []int { - ps := fmt.Sprintf(`$t=%s -Get-Process -ErrorAction SilentlyContinue | ForEach-Object { - try { if ($_.Path -ieq $t) { $_.Id } } catch { } -}`, psQuote(target)) + want := canonPath(target) + ps := `Get-Process -ErrorAction SilentlyContinue | ForEach-Object { + try { if ($_.Path) { "$($_.Id)|$($_.Path)" } } catch { } +}` out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).Output() if err != nil { return nil } var pids []int self := os.Getpid() - for _, line := range strings.Fields(string(out)) { - if n, err := strconv.Atoi(line); err == nil && n != self { + for _, line := range strings.Split(string(out), "\n") { + i := strings.IndexByte(line, '|') + if i < 0 { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(line[:i])) + if err != nil || n == self { + continue + } + if canonPath(strings.TrimSpace(line[i+1:])) == want { pids = append(pids, n) } } return pids } +// canonPath ramène un chemin Windows à une écriture unique et comparable : +// résolution des liens et de la forme courte 8.3 quand le fichier existe, puis +// minuscules (le système de fichiers est insensible à la casse). +func canonPath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + if long, err := filepath.EvalSymlinks(p); err == nil { + p = long + } + return strings.ToLower(filepath.Clean(p)) +} + // stopProcesses arrête les instances listées, puis laisse le port se libérer : // sans cette attente, l'instance qu'on relance trouve :8090 encore occupé et se // contente d'ouvrir le navigateur sur une application en train de mourir. From 0e94f1e9e2b0bf6e6ef215bd682c63ad0816e167 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 12:50:00 +0200 Subject: [PATCH 193/279] v0.6.13 : chemins Windows canonises avant comparaison Correction du defaut trouve au banc d'essai Windows : voir 5728abf. Notes de release + bump de version. --- RELEASE_NOTES.md | 16 ++++++++-------- cmd/jean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/jean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/jean/versioninfo.json | 6 +++--- internal/jean/run.go | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cc47dbb..f500efa 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,14 +1,16 @@ -## Lancement sous Windows +## Correction -Suite de la 0.6.11, où lancer le fichier téléchargé alors qu'AJEAN tournait déjà se contentait d'ouvrir l'application sans rien mettre à jour. +**Lancer le fichier téléchargé pendant qu'AJEAN tourne.** Windows désigne un même fichier sous plusieurs écritures, par exemple `Administrateur` et sa forme abrégée `ADMINI~1`. AJEAN comparait ces chemins tels quels, sans les ramener à une écriture commune. Il pouvait donc ne pas reconnaître l'application en cours d'exécution, remplacer le programme en la croyant fermée, et vous laisser avec l'ancienne version toujours à l'écran. C'est le comportement corrigé en 0.6.12, qui revenait par un autre chemin. -**AJEAN en cours d'exécution.** Le nouveau binaire était bien écrit sur le disque, mais l'application affichée restait l'ancienne, toujours en mémoire. Rien ne changeait à l'écran, et rien ne le disait. AJEAN demande maintenant s'il faut le fermer et le redémarrer pour appliquer la mise à jour, en affichant la version en cours et celle du fichier lancé. Si vous refusez, l'application s'ouvre telle quelle, et vous savez qu'aucune mise à jour n'a été appliquée. +Le même défaut pouvait, dans le cas où le dossier de données est désigné par une écriture abrégée, amener l'application installée à se prendre pour une copie téléchargée et à se relancer sans fin. -**Version installée plus récente que le fichier lancé.** AJEAN vous en avertit, ne remplace rien, et vous laisse choisir entre démarrer l'application ou fermer. Auparavant, une version plus ancienne pouvait prendre la place de la plus récente sans un mot. +Les chemins sont désormais ramenés à une écriture unique avant d'être comparés. -Le cas courant reste silencieux : fichier plus récent, application arrêtée, le binaire est remplacé et AJEAN démarre. +## Vérification -**Raccourcis.** Ils sont vérifiés à chaque démarrage et recréés s'ils manquent, au lieu d'être posés une seule fois le jour de l'installation. Le raccourci du menu Démarrer est par ailleurs rangé dans « Programmes », l'endroit où Windows classe les applications installées et où va chercher la recherche du menu Démarrer. Il se trouvait jusqu'ici à la racine du menu, donc difficilement trouvable. +Les six situations du lancement sous Windows ont cette fois été jouées sur une machine réelle, avec trois versions distinctes du programme : mise à jour silencieuse application fermée, application en cours avec acceptation puis avec refus, fichier plus ancien que la version installée avec les deux réponses, et recréation des raccourcis supprimés. Le contenu des fenêtres et les numéros de version affichés ont été relus, et chaque essai vérifie que la fenêtre s'est bien refermée avant de conclure. + +C'est ce banc d'essai qui a mis au jour le défaut ci-dessus, que trois relectures du code avaient laissé passer. ## Mise à jour @@ -18,6 +20,4 @@ jean update Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait le même travail. -Ces trois comportements demandent une machine Windows avec une installation existante pour être vérifiés en conditions réelles. Les mécanismes qu'ils utilisent (lecture de la version d'un binaire, repérage des instances en cours par leur chemin, remplacement du fichier) sont couverts par des tests, mais l'enchaînement complet des fenêtres n'a pas été parcouru à la main. - L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas été vérifiée sur une vraie machine. diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 1ffa060cb716ee3dfa261f7fbb58309a239d3b16..8e9d2e2f9e04de1d771788fdd4dda1e8c5b81b72 100644 GIT binary patch delta 35 ocmexi^}}idmn0`I0~-Sf@NO2C6lY>I-mEPh&d6vyxmo5b0F;*qIRF3v delta 35 ocmexi^}}idmn0_-0~-Sf@N5>A6lY>I+N>=d&d6vqxmo5b0F+n=H2?qr diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index ab39c861cd70e40c1b47576aef69e56c2b4414ba..2dff83fb5de8e88ffb2691440f560c6a686eaac4 100644 GIT binary patch delta 35 ocmexi^}}idmn0`I0~-Sf@NO2C6lY>I-mEPh&d6vyxmo5b0F;*qIRF3v delta 35 ocmexi^}}idmn0_-0~-Sf@N5>A6lY>I+N>=d&d6vqxmo5b0F+n=H2?qr diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index e9bdca8..fc58113 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 12, + "Patch": 13, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 12, + "Patch": 13, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.12", + "ProductVersion": "0.6.13", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/run.go b/internal/jean/run.go index de7bc55..ac54215 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.12" +const Version = "0.6.13" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { From 26ce8a9a14ce8b5e4fd19ea187ed417be53b7e0d Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 13:24:42 +0200 Subject: [PATCH 194/279] v0.6.14 : creer le dossier bin a la premiere installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur une machine vierge, le premier lancement du fichier telecharge echouait sur « open C:\ProgramData\jean\bin\jean.exe: The system cannot find the path specified » : appFirstRun creait le dossier de donnees mais pas son sous-dossier bin, cree jusqu'ici par le seul `jean install`. AJEAN demarrait alors depuis le fichier telecharge, sans installation ni raccourcis. MkdirAll deplace dans installSelf, donc valable quel que soit l'appelant. Reproduit puis verifie sur banc d'essai Windows (parcours complet depuis un dossier de donnees inexistant), + test unitaire. --- RELEASE_NOTES.md | 21 ++++++++++++--------- cmd/jean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/jean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/jean/versioninfo.json | 6 +++--- internal/jean/run.go | 2 +- internal/jean/sys_firstrun_windows_test.go | 15 +++++++++++++++ internal/jean/sys_install_windows.go | 9 +++++++++ 7 files changed, 40 insertions(+), 13 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f500efa..c8d957b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,23 +1,26 @@ ## Correction -**Lancer le fichier téléchargé pendant qu'AJEAN tourne.** Windows désigne un même fichier sous plusieurs écritures, par exemple `Administrateur` et sa forme abrégée `ADMINI~1`. AJEAN comparait ces chemins tels quels, sans les ramener à une écriture commune. Il pouvait donc ne pas reconnaître l'application en cours d'exécution, remplacer le programme en la croyant fermée, et vous laisser avec l'ancienne version toujours à l'écran. C'est le comportement corrigé en 0.6.12, qui revenait par un autre chemin. +**L'installation échouait sur un ordinateur vierge.** Au tout premier lancement du fichier téléchargé, sur une machine où AJEAN n'avait jamais été installé, la fenêtre d'installation s'affichait, puis répondait : -Le même défaut pouvait, dans le cas où le dossier de données est désigné par une écriture abrégée, amener l'application installée à se prendre pour une copie téléchargée et à se relancer sans fin. +``` +Installation impossible : +open C:\ProgramData\jean\bin\jean.exe: The system cannot find the path specified +``` -Les chemins sont désormais ramenés à une écriture unique avant d'être comparés. +AJEAN démarrait alors depuis le fichier téléchargé, sans s'être installé : aucun raccourci, aucun programme dans le menu Démarrer, et le fichier téléchargé restait le seul moyen de le lancer. -## Vérification +La cause : le premier lancement créait bien le dossier de réglages, mais pas le sous-dossier destiné à recevoir le programme. Il n'était créé que par l'installation en ligne de commande. Le dossier est désormais créé au moment d'écrire le programme, quel que soit le chemin emprunté. -Les six situations du lancement sous Windows ont cette fois été jouées sur une machine réelle, avec trois versions distinctes du programme : mise à jour silencieuse application fermée, application en cours avec acceptation puis avec refus, fichier plus ancien que la version installée avec les deux réponses, et recréation des raccourcis supprimés. Le contenu des fenêtres et les numéros de version affichés ont été relus, et chaque essai vérifie que la fenêtre s'est bien refermée avant de conclure. - -C'est ce banc d'essai qui a mis au jour le défaut ci-dessus, que trois relectures du code avaient laissé passer. +Ce cas échappait aux essais des versions précédentes, qui partaient toujours d'une installation déjà en place. Le parcours complet sur une machine vierge est maintenant vérifié, fenêtre après fenêtre, jusqu'au démarrage de l'application et à la présence des raccourcis. ## Mise à jour +Si vous êtes dans ce cas, rien n'est à réparer : téléchargez `jean-windows-amd64.exe` depuis cette page et lancez-le, l'installation se fera cette fois. + +Pour une installation déjà en place : + ``` jean update ``` -Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait le même travail. - L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas été vérifiée sur une vraie machine. diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 8e9d2e2f9e04de1d771788fdd4dda1e8c5b81b72..8aee975f3f671ff5d129c1ff781e9a9938a4a3f6 100644 GIT binary patch delta 35 ocmexi^}}idmn0`20~-Sf@NE{B6lY>I*{m%c&d6voxmo5b0F>4UJpcdz delta 35 ocmexi^}}idmn0`I0~-Sf@NO2C6lY>I-mEPh&d6vyxmo5b0F;*qIRF3v diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 2dff83fb5de8e88ffb2691440f560c6a686eaac4..66bd09de0156fffb3422f181adb6efc709897226 100644 GIT binary patch delta 35 ocmexi^}}idmn0`20~-Sf@NE{B6lY>I*{m%c&d6voxmo5b0F>4UJpcdz delta 35 ocmexi^}}idmn0`I0~-Sf@NO2C6lY>I-mEPh&d6vyxmo5b0F;*qIRF3v diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index fc58113..2dd6790 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 13, + "Patch": 14, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 13, + "Patch": 14, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.13", + "ProductVersion": "0.6.14", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/run.go b/internal/jean/run.go index ac54215..e9d7d41 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.13" +const Version = "0.6.14" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/sys_firstrun_windows_test.go b/internal/jean/sys_firstrun_windows_test.go index 62bd6d8..43449ec 100644 --- a/internal/jean/sys_firstrun_windows_test.go +++ b/internal/jean/sys_firstrun_windows_test.go @@ -26,6 +26,21 @@ func TestBinaryVersionReadsResource(t *testing.T) { } } +// Sur une machine vierge, le dossier bin n'existe pas encore : installSelf doit +// le créer. Sans ça, le premier lancement échouait sur « open +// C:\ProgramData\jean\bin\jean.exe: The system cannot find the path specified » +// et AJEAN ne s'installait jamais. +func TestInstallSelfCreatesBinDir(t *testing.T) { + binDir := filepath.Join(t.TempDir(), "jean", "bin") // deux niveaux absents + dst, err := installSelf(binDir) + if err != nil { + t.Fatalf("installSelf sur un dossier absent : %v", err) + } + if _, err := os.Stat(dst); err != nil { + t.Fatalf("binaire non écrit : %v", err) + } +} + // replaceInstalled doit écraser le binaire en place, y compris quand le fichier // cible existe déjà, et laisser l'ancien de côté sans le confondre avec la cible. func TestReplaceInstalled(t *testing.T) { diff --git a/internal/jean/sys_install_windows.go b/internal/jean/sys_install_windows.go index 58bc896..ccce2b6 100644 --- a/internal/jean/sys_install_windows.go +++ b/internal/jean/sys_install_windows.go @@ -100,6 +100,12 @@ func cmdInstall(args []string) error { // installSelf copies the currently running executable into binDir as jean.exe // and returns the destination path. If the running exe already lives there // (re-install), it's a no-op. +// +// binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge, +// le premier lancement passait par appFirstRun, qui ne créait que le dossier de +// données et pas son sous-dossier bin. L'utilisateur recevait alors « open +// C:\ProgramData\jean\bin\jean.exe: The system cannot find the path specified » +// et AJEAN démarrait depuis le fichier téléchargé, sans jamais s'installer. func installSelf(binDir string) (string, error) { src, err := os.Executable() if err != nil { @@ -110,6 +116,9 @@ func installSelf(binDir string) (string, error) { if strings.EqualFold(src, dst) { return dst, nil } + if err := os.MkdirAll(binDir, 0o755); err != nil { + return "", err + } in, err := os.Open(src) if err != nil { return "", err From c1d58b19f2fbdf567f0ad8085c9792d8ed78f1d9 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 13:34:28 +0200 Subject: [PATCH 195/279] v0.6.15 : un refus d'installation n'est plus definitif MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le drapeau .install_declined (0.6.11) supprimait la question POUR TOUJOURS : apres un Non, plus aucun moyen d'installer AJEAN, meme en relancant le fichier telecharge. Un refus vaut « pas maintenant » : la question revient au prochain lancement, et la fenetre l'annonce. Verifie au banc d'essai : refus, relance, question reposee, acceptation, installation + raccourcis + demarrage. --- RELEASE_NOTES.md | 19 +++++----------- cmd/jean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/jean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/jean/versioninfo.json | 6 ++--- internal/jean/run.go | 2 +- internal/jean/sys_firstrun_windows.go | 31 +++++++++++--------------- 6 files changed, 22 insertions(+), 36 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c8d957b..6de4650 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,26 +1,17 @@ ## Correction -**L'installation échouait sur un ordinateur vierge.** Au tout premier lancement du fichier téléchargé, sur une machine où AJEAN n'avait jamais été installé, la fenêtre d'installation s'affichait, puis répondait : +**Refuser l'installation une fois la rendait impossible pour toujours.** Au premier lancement, AJEAN demande s'il doit s'installer sur l'ordinateur. Répondre Non était retenu définitivement : la question ne revenait plus jamais, même en fermant l'application et en relançant le fichier téléchargé. Il ne restait aucun moyen d'installer AJEAN depuis l'application. -``` -Installation impossible : -open C:\ProgramData\jean\bin\jean.exe: The system cannot find the path specified -``` +Ce refus avait été mémorisé pour éviter de reposer la question à chaque lancement. Le remède était pire que le mal. Un Non veut dire « pas maintenant », jamais « plus jamais » : la question revient désormais au prochain lancement du fichier, comme le fait n'importe quel installeur, et la fenêtre le dit explicitement. -AJEAN démarrait alors depuis le fichier téléchargé, sans s'être installé : aucun raccourci, aucun programme dans le menu Démarrer, et le fichier téléchargé restait le seul moyen de le lancer. - -La cause : le premier lancement créait bien le dossier de réglages, mais pas le sous-dossier destiné à recevoir le programme. Il n'était créé que par l'installation en ligne de commande. Le dossier est désormais créé au moment d'écrire le programme, quel que soit le chemin emprunté. - -Ce cas échappait aux essais des versions précédentes, qui partaient toujours d'une installation déjà en place. Le parcours complet sur une machine vierge est maintenant vérifié, fenêtre après fenêtre, jusqu'au démarrage de l'application et à la présence des raccourcis. +Si vous étiez bloqué dans cet état, il n'y a rien à réparer : téléchargez `jean-windows-amd64.exe` depuis cette page et lancez-le, l'installation vous sera de nouveau proposée. ## Mise à jour -Si vous êtes dans ce cas, rien n'est à réparer : téléchargez `jean-windows-amd64.exe` depuis cette page et lancez-le, l'installation se fera cette fois. - -Pour une installation déjà en place : - ``` jean update ``` +Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait le même travail. + L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas été vérifiée sur une vraie machine. diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 8aee975f3f671ff5d129c1ff781e9a9938a4a3f6..b391700735825d70dbf15f5236fb411f86ccf915 100644 GIT binary patch delta 35 ocmexi^}}idmn0`Y0~-Sf@NX8D6lY>I-K;Gg&d6vwxmo5b0F@O8K>z>% delta 35 ocmexi^}}idmn0`20~-Sf@NE{B6lY>I*{m%c&d6voxmo5b0F>4UJpcdz diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 66bd09de0156fffb3422f181adb6efc709897226..a5f6ab8a58d249ff600cdd94b9672e0589cda8ee 100644 GIT binary patch delta 35 ocmexi^}}idmn0`Y0~-Sf@NX8D6lY>I-K;Gg&d6vwxmo5b0F@O8K>z>% delta 35 ocmexi^}}idmn0`20~-Sf@NE{B6lY>I*{m%c&d6voxmo5b0F>4UJpcdz diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 2dd6790..507563d 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 14, + "Patch": 15, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 14, + "Patch": 15, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.14", + "ProductVersion": "0.6.15", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/run.go b/internal/jean/run.go index e9d7d41..a00ec6b 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.14" +const Version = "0.6.15" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/sys_firstrun_windows.go b/internal/jean/sys_firstrun_windows.go index b3fae99..6d42128 100644 --- a/internal/jean/sys_firstrun_windows.go +++ b/internal/jean/sys_firstrun_windows.go @@ -29,10 +29,11 @@ import ( // // Maintenant, deux cas seulement : // -// 1. AJEAN n'est pas installé. On DEMANDE, une bonne fois : c'est le seul moment -// où l'utilisateur gagne à savoir ce qu'on pose sur sa machine et où. Puis -// copie + PATH + raccourcis, et on démarre depuis la copie installée. Un refus -// est mémorisé, on ne repose pas la question. +// 1. AJEAN n'est pas installé. On DEMANDE : c'est le seul moment où +// l'utilisateur gagne à savoir ce qu'on pose sur sa machine et où. Puis +// copie + PATH + raccourcis, et on démarre depuis la copie installée. Un +// refus vaut « pas maintenant » et la question revient au prochain lancement +// du fichier : mémorisé, il ne laissait AUCUN moyen d'installer AJEAN. // // 2. AJEAN est déjà installé. Le fichier téléchargé se comporte en installeur de // mise à jour, avec trois situations bien distinctes (voir runAsInstaller) : @@ -102,20 +103,21 @@ func appFirstRun() bool { // Première installation : là, une question. C'est le seul moment où // l'utilisateur a intérêt à savoir ce qui va être posé sur sa machine, et // c'est l'absence de cette question qui rendait le double-clic illisible. - if declinedInstall() { - return false - } + // + // Un refus n'est PAS mémorisé. Il l'a été en 0.6.11 pour éviter de reposer la + // question à chaque lancement, mais il devenait alors définitif : plus aucun + // moyen d'installer AJEAN, même en relançant le fichier téléchargé. Un « non » + // veut dire « pas maintenant », jamais « plus jamais » : c'est le comportement + // de n'importe quel installeur, qui repropose tant qu'on le relance. if messageBox( "Installer AJEAN sur cet ordinateur ?\n\n"+ "• le programme sera copié dans :\n "+target+"\n"+ "• un raccourci sera ajouté au menu Démarrer et au Bureau\n"+ "• vos réglages et modèles seront rangés dans :\n "+JeanHome()+"\n\n"+ "Vous pourrez ensuite supprimer le fichier que vous venez de télécharger.\n\n"+ - "Répondre Non lance AJEAN sans rien installer.", + "Répondre Non démarre AJEAN sans rien installer. La question reviendra\n"+ + "au prochain lancement de ce fichier.", "Installation d'AJEAN", mbYesNo|mbIconQuestion) != idYes { - // Refus mémorisé : reposer la question à chaque double-clic serait - // ignorer la réponse déjà donnée. - _ = os.WriteFile(declineFlag(), []byte("non\n"), 0o644) return false } @@ -133,13 +135,6 @@ func appFirstRun() bool { return launch(target) } -func declineFlag() string { return filepath.Join(JeanHome(), ".install_declined") } - -func declinedInstall() bool { - _, err := os.Stat(declineFlag()) - return err == nil -} - // launch démarre la copie installée et demande à l'appelant de rendre la main. // Renvoie false si le lancement échoue, auquel cas l'exécutable courant prend le // relais : mieux vaut démarrer depuis le mauvais dossier que pas du tout. From 110458c133507620500afdd242bdefb4d5b71d72 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 13:59:06 +0200 Subject: [PATCH 196/279] Renommage AJEAN (1/3) : module, chemins de donnees et auto-MAJ Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc installe. Trois briques : - module github.com/nathaninline/ajean, cmd/ajean, internal/ajean, package ajean. Purement interne. - AjeanHome() remplace JeanHome(). Les defauts deviennent %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant au premier lancement. La migration est un os.Rename dans le dossier parent de l'ancien chemin : intra-volume, donc atomique et instantane meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue (handle ouvert sous Windows, droits, volume en lecture seule) on CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais de suppression, le pire cas est "rien n'a bouge". $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que /etc/default/ajean et /etc/default/jean : un chemin impose a la main est un ordre, on ne le migre pas. - l'auto-MAJ accepte desormais ajean-- ET jean--, dans cet ordre. Indispensable dans les deux sens : les binaires deja installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir s'installer depuis une release anterieure au renommage. Couvert par des tests, dont le repli quand un handle ouvert bloque le rename sous Windows. --- README.md | 4 +- cmd/{jean => ajean}/icon.ico | Bin cmd/{jean => ajean}/icon.png | Bin cmd/{jean => ajean}/main.go | 8 +- .../resource_windows_amd64.syso | Bin .../resource_windows_arm64.syso | Bin cmd/{jean => ajean}/versioninfo.json | 2 +- go.mod | 2 +- internal/{jean => ajean}/backend_build.go | 2 +- .../backend_buildfilter_test.go | 2 +- internal/{jean => ajean}/backend_catalog.go | 2 +- internal/{jean => ajean}/backend_config.go | 2 +- internal/{jean => ajean}/backend_gpu.go | 2 +- internal/{jean => ajean}/backend_llamacpp.go | 6 +- .../{jean => ajean}/backend_model_dirs.go | 16 +-- .../backend_model_dirs_test.go | 2 +- internal/{jean => ajean}/backend_models.go | 6 +- .../{jean => ajean}/backend_models_dl_test.go | 2 +- internal/{jean => ajean}/backend_prebuilt.go | 4 +- .../{jean => ajean}/backend_prebuilt_test.go | 2 +- internal/{jean => ajean}/backend_presets.go | 2 +- .../{jean => ajean}/backend_presets_test.go | 2 +- internal/{jean => ajean}/backend_serve.go | 6 +- internal/{jean => ajean}/chat_agent.go | 4 +- internal/{jean => ajean}/chat_cmd.go | 2 +- internal/{jean => ajean}/chat_compact.go | 2 +- internal/{jean => ajean}/chat_compact_test.go | 2 +- internal/{jean => ajean}/chat_conversation.go | 8 +- .../{jean => ajean}/chat_conversation_test.go | 2 +- internal/{jean => ajean}/chat_diff.go | 2 +- internal/{jean => ajean}/chat_internet.go | 6 +- .../{jean => ajean}/chat_internet_tools.go | 2 +- internal/{jean => ajean}/chat_memory.go | 2 +- internal/{jean => ajean}/chat_tools.go | 2 +- .../{jean => ajean}/chat_tools_write_test.go | 2 +- internal/{jean => ajean}/chat_workspace.go | 6 +- .../{jean => ajean}/chat_workspace_test.go | 2 +- internal/{jean => ajean}/cli_app.go | 2 +- internal/{jean => ajean}/doc.go | 2 +- internal/{jean => ajean}/llm_bench.go | 6 +- internal/{jean => ajean}/llm_client.go | 2 +- internal/{jean => ajean}/llm_oai.go | 6 +- internal/{jean => ajean}/llm_testcmd.go | 2 +- internal/{jean => ajean}/mcp_client.go | 2 +- internal/{jean => ajean}/mcp_client_test.go | 2 +- internal/{jean => ajean}/mcp_config.go | 4 +- internal/{jean => ajean}/relay_e2e.go | 6 +- internal/{jean => ajean}/relay_e2eauth.go | 12 +-- .../{jean => ajean}/relay_e2eauth_test.go | 2 +- internal/{jean => ajean}/relay_link.go | 20 ++-- internal/{jean => ajean}/run.go | 70 ++++++------ internal/{jean => ajean}/sys_brand_icon.go | 2 +- .../{jean => ajean}/sys_console_darwin.go | 2 +- internal/{jean => ajean}/sys_console_other.go | 2 +- .../{jean => ajean}/sys_console_windows.go | 2 +- .../{jean => ajean}/sys_firstrun_other.go | 2 +- .../{jean => ajean}/sys_firstrun_windows.go | 6 +- .../sys_firstrun_windows_test.go | 2 +- .../{jean => ajean}/sys_install_darwin.go | 2 +- internal/{jean => ajean}/sys_install_linux.go | 2 +- .../{jean => ajean}/sys_install_windows.go | 8 +- internal/ajean/sys_migrate.go | 80 ++++++++++++++ internal/ajean/sys_migrate_test.go | 102 ++++++++++++++++++ internal/{jean => ajean}/sys_paths.go | 12 +-- internal/{jean => ajean}/sys_platform_unix.go | 31 +++--- .../{jean => ajean}/sys_platform_windows.go | 23 ++-- internal/{jean => ajean}/sys_service.go | 2 +- .../{jean => ajean}/sys_service_darwin.go | 12 +-- internal/{jean => ajean}/sys_service_linux.go | 2 +- .../{jean => ajean}/sys_service_windows.go | 10 +- internal/{jean => ajean}/sys_splash_other.go | 2 +- .../{jean => ajean}/sys_splash_windows.go | 2 +- internal/{jean => ajean}/sys_tray_darwin.go | 2 +- internal/{jean => ajean}/sys_tray_other.go | 2 +- internal/{jean => ajean}/sys_tray_windows.go | 2 +- internal/{jean => ajean}/sys_tty.go | 2 +- internal/{jean => ajean}/sys_update.go | 69 +++++++----- internal/ajean/sys_update_asset_test.go | 63 +++++++++++ internal/{jean => ajean}/ui/index.html | 0 internal/{jean => ajean}/ui/marked.min.js | 0 .../{jean => ajean}/ui/src/index.tmpl.html | 0 internal/{jean => ajean}/ui/src/js/00-md.js | 0 .../{jean => ajean}/ui/src/js/01-theme.js | 0 .../{jean => ajean}/ui/src/js/02-prefs.js | 0 .../{jean => ajean}/ui/src/js/03-ui-kit.js | 0 internal/{jean => ajean}/ui/src/js/04-api.js | 0 .../{jean => ajean}/ui/src/js/05-status.js | 0 .../{jean => ajean}/ui/src/js/06-settings.js | 0 .../{jean => ajean}/ui/src/js/07-models.js | 0 .../ui/src/js/08-chat-render.js | 0 .../{jean => ajean}/ui/src/js/09-stream.js | 0 .../{jean => ajean}/ui/src/js/10-llamacpp.js | 0 internal/{jean => ajean}/ui/src/js/12-mcp.js | 0 .../{jean => ajean}/ui/src/js/13-remote.js | 0 internal/{jean => ajean}/ui/src/styles.css | 0 internal/{jean => ajean}/web_api.go | 6 +- internal/{jean => ajean}/web_auth.go | 6 +- internal/{jean => ajean}/web_chat.go | 2 +- internal/{jean => ajean}/web_link_api.go | 2 +- internal/{jean => ajean}/web_llamacpp.go | 4 +- internal/{jean => ajean}/web_prefs.go | 4 +- internal/{jean => ajean}/web_server.go | 2 +- internal/{jean => ajean}/web_sysprompt.go | 4 +- tools/gen-icon/main.go | 6 +- 104 files changed, 514 insertions(+), 226 deletions(-) rename cmd/{jean => ajean}/icon.ico (100%) rename cmd/{jean => ajean}/icon.png (100%) rename cmd/{jean => ajean}/main.go (74%) rename cmd/{jean => ajean}/resource_windows_amd64.syso (100%) rename cmd/{jean => ajean}/resource_windows_arm64.syso (100%) rename cmd/{jean => ajean}/versioninfo.json (90%) rename internal/{jean => ajean}/backend_build.go (99%) rename internal/{jean => ajean}/backend_buildfilter_test.go (99%) rename internal/{jean => ajean}/backend_catalog.go (99%) rename internal/{jean => ajean}/backend_config.go (99%) rename internal/{jean => ajean}/backend_gpu.go (99%) rename internal/{jean => ajean}/backend_llamacpp.go (99%) rename internal/{jean => ajean}/backend_model_dirs.go (92%) rename internal/{jean => ajean}/backend_model_dirs_test.go (99%) rename internal/{jean => ajean}/backend_models.go (99%) rename internal/{jean => ajean}/backend_models_dl_test.go (99%) rename internal/{jean => ajean}/backend_prebuilt.go (99%) rename internal/{jean => ajean}/backend_prebuilt_test.go (99%) rename internal/{jean => ajean}/backend_presets.go (99%) rename internal/{jean => ajean}/backend_presets_test.go (98%) rename internal/{jean => ajean}/backend_serve.go (98%) rename internal/{jean => ajean}/chat_agent.go (97%) rename internal/{jean => ajean}/chat_cmd.go (99%) rename internal/{jean => ajean}/chat_compact.go (99%) rename internal/{jean => ajean}/chat_compact_test.go (99%) rename internal/{jean => ajean}/chat_conversation.go (99%) rename internal/{jean => ajean}/chat_conversation_test.go (99%) rename internal/{jean => ajean}/chat_diff.go (99%) rename internal/{jean => ajean}/chat_internet.go (99%) rename internal/{jean => ajean}/chat_internet_tools.go (99%) rename internal/{jean => ajean}/chat_memory.go (99%) rename internal/{jean => ajean}/chat_tools.go (99%) rename internal/{jean => ajean}/chat_tools_write_test.go (98%) rename internal/{jean => ajean}/chat_workspace.go (94%) rename internal/{jean => ajean}/chat_workspace_test.go (98%) rename internal/{jean => ajean}/cli_app.go (99%) rename internal/{jean => ajean}/doc.go (98%) rename internal/{jean => ajean}/llm_bench.go (98%) rename internal/{jean => ajean}/llm_client.go (99%) rename internal/{jean => ajean}/llm_oai.go (98%) rename internal/{jean => ajean}/llm_testcmd.go (99%) rename internal/{jean => ajean}/mcp_client.go (99%) rename internal/{jean => ajean}/mcp_client_test.go (99%) rename internal/{jean => ajean}/mcp_config.go (99%) rename internal/{jean => ajean}/relay_e2e.go (98%) rename internal/{jean => ajean}/relay_e2eauth.go (96%) rename internal/{jean => ajean}/relay_e2eauth_test.go (99%) rename internal/{jean => ajean}/relay_link.go (97%) rename internal/{jean => ajean}/run.go (79%) rename internal/{jean => ajean}/sys_brand_icon.go (99%) rename internal/{jean => ajean}/sys_console_darwin.go (99%) rename internal/{jean => ajean}/sys_console_other.go (97%) rename internal/{jean => ajean}/sys_console_windows.go (99%) rename internal/{jean => ajean}/sys_firstrun_other.go (97%) rename internal/{jean => ajean}/sys_firstrun_windows.go (99%) rename internal/{jean => ajean}/sys_firstrun_windows_test.go (99%) rename internal/{jean => ajean}/sys_install_darwin.go (99%) rename internal/{jean => ajean}/sys_install_linux.go (99%) rename internal/{jean => ajean}/sys_install_windows.go (98%) create mode 100644 internal/ajean/sys_migrate.go create mode 100644 internal/ajean/sys_migrate_test.go rename internal/{jean => ajean}/sys_paths.go (93%) rename internal/{jean => ajean}/sys_platform_unix.go (92%) rename internal/{jean => ajean}/sys_platform_windows.go (95%) rename internal/{jean => ajean}/sys_service.go (99%) rename internal/{jean => ajean}/sys_service_darwin.go (96%) rename internal/{jean => ajean}/sys_service_linux.go (99%) rename internal/{jean => ajean}/sys_service_windows.go (95%) rename internal/{jean => ajean}/sys_splash_other.go (94%) rename internal/{jean => ajean}/sys_splash_windows.go (99%) rename internal/{jean => ajean}/sys_tray_darwin.go (99%) rename internal/{jean => ajean}/sys_tray_other.go (97%) rename internal/{jean => ajean}/sys_tray_windows.go (99%) rename internal/{jean => ajean}/sys_tty.go (94%) rename internal/{jean => ajean}/sys_update.go (84%) create mode 100644 internal/ajean/sys_update_asset_test.go rename internal/{jean => ajean}/ui/index.html (100%) rename internal/{jean => ajean}/ui/marked.min.js (100%) rename internal/{jean => ajean}/ui/src/index.tmpl.html (100%) rename internal/{jean => ajean}/ui/src/js/00-md.js (100%) rename internal/{jean => ajean}/ui/src/js/01-theme.js (100%) rename internal/{jean => ajean}/ui/src/js/02-prefs.js (100%) rename internal/{jean => ajean}/ui/src/js/03-ui-kit.js (100%) rename internal/{jean => ajean}/ui/src/js/04-api.js (100%) rename internal/{jean => ajean}/ui/src/js/05-status.js (100%) rename internal/{jean => ajean}/ui/src/js/06-settings.js (100%) rename internal/{jean => ajean}/ui/src/js/07-models.js (100%) rename internal/{jean => ajean}/ui/src/js/08-chat-render.js (100%) rename internal/{jean => ajean}/ui/src/js/09-stream.js (100%) rename internal/{jean => ajean}/ui/src/js/10-llamacpp.js (100%) rename internal/{jean => ajean}/ui/src/js/12-mcp.js (100%) rename internal/{jean => ajean}/ui/src/js/13-remote.js (100%) rename internal/{jean => ajean}/ui/src/styles.css (100%) rename internal/{jean => ajean}/web_api.go (99%) rename internal/{jean => ajean}/web_auth.go (96%) rename internal/{jean => ajean}/web_chat.go (99%) rename internal/{jean => ajean}/web_link_api.go (99%) rename internal/{jean => ajean}/web_llamacpp.go (99%) rename internal/{jean => ajean}/web_prefs.go (96%) rename internal/{jean => ajean}/web_server.go (99%) rename internal/{jean => ajean}/web_sysprompt.go (95%) diff --git a/README.md b/README.md index 4b184c6..ed52c0b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur ### 1. Le binaire ```bash -curl -L -o jean https://github.com/nathaninline/jean/releases/latest/download/jean-linux-amd64 +curl -L -o jean https://github.com/nathaninline/ajean/releases/latest/download/jean-linux-amd64 chmod +x jean sudo mv jean /usr/local/bin/jean ``` @@ -264,7 +264,7 @@ Chaque appel `/api/*` présente alors `Authorization: Bearer ` : Go 1.25+. AJEAN est écrit à 100 % en Go, l'interface est embarquée via `go:embed` : ```bash -git clone https://github.com/nathaninline/jean.git +git clone https://github.com/nathaninline/ajean.git cd jean CGO_ENABLED=0 go build -o jean ./cmd/jean ``` diff --git a/cmd/jean/icon.ico b/cmd/ajean/icon.ico similarity index 100% rename from cmd/jean/icon.ico rename to cmd/ajean/icon.ico diff --git a/cmd/jean/icon.png b/cmd/ajean/icon.png similarity index 100% rename from cmd/jean/icon.png rename to cmd/ajean/icon.png diff --git a/cmd/jean/main.go b/cmd/ajean/main.go similarity index 74% rename from cmd/jean/main.go rename to cmd/ajean/main.go index b7559cd..51b0427 100644 --- a/cmd/jean/main.go +++ b/cmd/ajean/main.go @@ -1,4 +1,4 @@ -// Point d'entrée du binaire jean. Tout le code vit dans internal/jean ; ce +// Point d'entrée du binaire ajean. Tout le code vit dans internal/jean ; ce // dossier ne porte que le main() et les ressources Windows (.syso, icône, // versioninfo) qui doivent résider dans le dossier du package main. // @@ -7,11 +7,11 @@ // les .syso après avoir bumpé la version : `go generate ./...` // (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest) // -//go:generate go run github.com/nathaninline/jean/tools/gen-icon icon.ico +//go:generate go run github.com/nathaninline/ajean/tools/gen-icon icon.ico //go:generate goversioninfo -64 -icon=icon.ico -o resource_windows_amd64.syso versioninfo.json //go:generate goversioninfo -64 -arm -icon=icon.ico -o resource_windows_arm64.syso versioninfo.json package main -import "github.com/nathaninline/jean/internal/jean" +import "github.com/nathaninline/ajean/internal/ajean" -func main() { jean.Main() } +func main() { ajean.Main() } diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso similarity index 100% rename from cmd/jean/resource_windows_amd64.syso rename to cmd/ajean/resource_windows_amd64.syso diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso similarity index 100% rename from cmd/jean/resource_windows_arm64.syso rename to cmd/ajean/resource_windows_arm64.syso diff --git a/cmd/jean/versioninfo.json b/cmd/ajean/versioninfo.json similarity index 90% rename from cmd/jean/versioninfo.json rename to cmd/ajean/versioninfo.json index 507563d..3b3f5dd 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -26,7 +26,7 @@ "OriginalFilename": "jean.exe", "ProductName": "AJEAN", "ProductVersion": "0.6.15", - "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" + "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { "Translation": { diff --git a/go.mod b/go.mod index 5e588df..4d7ff1d 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/nathaninline/jean +module github.com/nathaninline/ajean go 1.25.0 diff --git a/internal/jean/backend_build.go b/internal/ajean/backend_build.go similarity index 99% rename from internal/jean/backend_build.go rename to internal/ajean/backend_build.go index 42c7639..f796c83 100644 --- a/internal/jean/backend_build.go +++ b/internal/ajean/backend_build.go @@ -1,6 +1,6 @@ // backend_build.go — machinerie de compilation de llama.cpp : détection du // plan de build (CUDA/ROCm/Metal/Vulkan/CPU), cmake, suivi de progression, logs. -package jean +package ajean import ( "bufio" diff --git a/internal/jean/backend_buildfilter_test.go b/internal/ajean/backend_buildfilter_test.go similarity index 99% rename from internal/jean/backend_buildfilter_test.go rename to internal/ajean/backend_buildfilter_test.go index f1d93b1..a1dd2ab 100644 --- a/internal/jean/backend_buildfilter_test.go +++ b/internal/ajean/backend_buildfilter_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import "testing" diff --git a/internal/jean/backend_catalog.go b/internal/ajean/backend_catalog.go similarity index 99% rename from internal/jean/backend_catalog.go rename to internal/ajean/backend_catalog.go index eb7b13b..d63feb3 100644 --- a/internal/jean/backend_catalog.go +++ b/internal/ajean/backend_catalog.go @@ -1,4 +1,4 @@ -package jean +package ajean // backend_catalog.go — récupère la liste de modèles curatée servie par ajean.link et la // combine avec les infos matérielles locales, pour que l'écran d'accueil (à diff --git a/internal/jean/backend_config.go b/internal/ajean/backend_config.go similarity index 99% rename from internal/jean/backend_config.go rename to internal/ajean/backend_config.go index 75b6f7a..c8df85c 100644 --- a/internal/jean/backend_config.go +++ b/internal/ajean/backend_config.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bufio" diff --git a/internal/jean/backend_gpu.go b/internal/ajean/backend_gpu.go similarity index 99% rename from internal/jean/backend_gpu.go rename to internal/ajean/backend_gpu.go index 9a16692..c70b6c6 100644 --- a/internal/jean/backend_gpu.go +++ b/internal/ajean/backend_gpu.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bufio" diff --git a/internal/jean/backend_llamacpp.go b/internal/ajean/backend_llamacpp.go similarity index 99% rename from internal/jean/backend_llamacpp.go rename to internal/ajean/backend_llamacpp.go index 4ea1805..3748b93 100644 --- a/internal/jean/backend_llamacpp.go +++ b/internal/ajean/backend_llamacpp.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "fmt" @@ -82,7 +82,7 @@ func llamacppRepoDir() string { } func defaultRepoDir() string { - return filepath.Join(JeanHome(), "backends", "llama.cpp") + return filepath.Join(AjeanHome(), "backends", "llama.cpp") } // findRepoRoot walks up from a binary path (…/build/bin/llama-server) looking @@ -510,7 +510,7 @@ func sanitizeBackendName(name string) string { // dossier backends (../, chemin absolu…). func backendDir(name string) (string, error) { name = sanitizeBackendName(name) - root, err := filepath.Abs(filepath.Join(JeanHome(), "backends")) + root, err := filepath.Abs(filepath.Join(AjeanHome(), "backends")) if err != nil { return "", err } diff --git a/internal/jean/backend_model_dirs.go b/internal/ajean/backend_model_dirs.go similarity index 92% rename from internal/jean/backend_model_dirs.go rename to internal/ajean/backend_model_dirs.go index 3bef6f8..c9e843c 100644 --- a/internal/jean/backend_model_dirs.go +++ b/internal/ajean/backend_model_dirs.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "encoding/json" @@ -15,7 +15,7 @@ import ( // (disque dur externe, second SSD, montage réseau…). La liste est persistée // dans $JEAN_HOME/model_dirs.json — PAS dans config.env, que le changement de // preset réécrit — et peut aussi venir de $JEAN_MODEL_DIRS. -func modelDirsPath() string { return filepath.Join(JeanHome(), "model_dirs.json") } +func modelDirsPath() string { return filepath.Join(AjeanHome(), "model_dirs.json") } // modelDirsListSep sépare les dossiers dans $JEAN_MODEL_DIRS (':' sous Unix, // ';' sous Windows, comme le PATH). @@ -39,7 +39,7 @@ func extraModelDirs() []string { if v := strings.TrimSpace(os.Getenv("JEAN_MODEL_DIRS")); v != "" { raw = append(raw, strings.Split(v, modelDirsListSep())...) } - return dedupDirs(raw, JeanHome()) + return dedupDirs(raw, AjeanHome()) } // dedupDirs nettoie une liste de chemins : vides retirés, chemins absolus @@ -82,17 +82,17 @@ func normDir(p string) string { // modelDirs renvoie tous les dossiers où chercher un .gguf : JEAN_HOME d'abord // (il reste la destination des téléchargements), puis les dossiers ajoutés. func modelDirs() []string { - return append([]string{JeanHome()}, extraModelDirs()...) + return append([]string{AjeanHome()}, extraModelDirs()...) } // saveExtraModelDirs écrit la liste des dossiers supplémentaires sur disque. func saveExtraModelDirs(dirs []string) error { - clean := dedupDirs(dirs, JeanHome()) + clean := dedupDirs(dirs, AjeanHome()) b, err := json.MarshalIndent(clean, "", " ") if err != nil { return err } - if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } return os.WriteFile(modelDirsPath(), append(b, '\n'), 0o644) @@ -147,7 +147,7 @@ func resolveModelPath(name string) (string, error) { return p, nil } } - return filepath.Join(JeanHome(), base), nil // introuvable : l'appelant décidera + return filepath.Join(AjeanHome(), base), nil // introuvable : l'appelant décidera } // modelDirCount compte les .gguf lisibles d'un dossier (-1 si illisible). @@ -211,7 +211,7 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true}) return } - out := []map[string]any{{"path": JeanHome(), "home": true, "count": modelDirCount(JeanHome()), "exists": true}} + out := []map[string]any{{"path": AjeanHome(), "home": true, "count": modelDirCount(AjeanHome()), "exists": true}} for _, d := range extraModelDirs() { n := modelDirCount(d) out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0}) diff --git a/internal/jean/backend_model_dirs_test.go b/internal/ajean/backend_model_dirs_test.go similarity index 99% rename from internal/jean/backend_model_dirs_test.go rename to internal/ajean/backend_model_dirs_test.go index f2dc780..f7700a8 100644 --- a/internal/jean/backend_model_dirs_test.go +++ b/internal/ajean/backend_model_dirs_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "os" diff --git a/internal/jean/backend_models.go b/internal/ajean/backend_models.go similarity index 99% rename from internal/jean/backend_models.go rename to internal/ajean/backend_models.go index 379b34d..4d27b18 100644 --- a/internal/jean/backend_models.go +++ b/internal/ajean/backend_models.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "context" @@ -97,7 +97,7 @@ func downloadDestPath(name string) (string, error) { if !strings.HasSuffix(strings.ToLower(base), ".gguf") { return "", fmt.Errorf("seuls les fichiers .gguf sont acceptés") } - return filepath.Join(JeanHome(), base), nil + return filepath.Join(AjeanHome(), base), nil } // modelFromPresetContent extracts the MODEL= value from a preset's config.env @@ -600,7 +600,7 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) { // (its state lived in memory), so the partial file would otherwise sit there // forever eating disk. func cleanStalePartFiles() { - matches, err := filepath.Glob(filepath.Join(JeanHome(), "*.gguf.part")) + matches, err := filepath.Glob(filepath.Join(AjeanHome(), "*.gguf.part")) if err != nil { return } diff --git a/internal/jean/backend_models_dl_test.go b/internal/ajean/backend_models_dl_test.go similarity index 99% rename from internal/jean/backend_models_dl_test.go rename to internal/ajean/backend_models_dl_test.go index b295072..ec8a09f 100644 --- a/internal/jean/backend_models_dl_test.go +++ b/internal/ajean/backend_models_dl_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bytes" diff --git a/internal/jean/backend_prebuilt.go b/internal/ajean/backend_prebuilt.go similarity index 99% rename from internal/jean/backend_prebuilt.go rename to internal/ajean/backend_prebuilt.go index 97aefe1..13cea55 100644 --- a/internal/jean/backend_prebuilt.go +++ b/internal/ajean/backend_prebuilt.go @@ -8,7 +8,7 @@ // CUDA officiel pour Linux (on retombe sur Vulkan — la compilation locale // reste la voie CUDA sous Linux). La compilation locale reste indispensable // pour les forks (ex. PrismML). -package jean +package ajean import ( "archive/tar" @@ -37,7 +37,7 @@ type ghAsset struct { } func prebuiltDir() string { - return filepath.Join(JeanHome(), "backends", "llama.cpp-prebuilt") + return filepath.Join(AjeanHome(), "backends", "llama.cpp-prebuilt") } // prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer" diff --git a/internal/jean/backend_prebuilt_test.go b/internal/ajean/backend_prebuilt_test.go similarity index 99% rename from internal/jean/backend_prebuilt_test.go rename to internal/ajean/backend_prebuilt_test.go index e513cd6..98719b0 100644 --- a/internal/jean/backend_prebuilt_test.go +++ b/internal/ajean/backend_prebuilt_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "archive/tar" diff --git a/internal/jean/backend_presets.go b/internal/ajean/backend_presets.go similarity index 99% rename from internal/jean/backend_presets.go rename to internal/ajean/backend_presets.go index 4a9794e..cddec24 100644 --- a/internal/jean/backend_presets.go +++ b/internal/ajean/backend_presets.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bufio" diff --git a/internal/jean/backend_presets_test.go b/internal/ajean/backend_presets_test.go similarity index 98% rename from internal/jean/backend_presets_test.go rename to internal/ajean/backend_presets_test.go index d6d3dc0..1d896e9 100644 --- a/internal/jean/backend_presets_test.go +++ b/internal/ajean/backend_presets_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import "testing" diff --git a/internal/jean/backend_serve.go b/internal/ajean/backend_serve.go similarity index 98% rename from internal/jean/backend_serve.go rename to internal/ajean/backend_serve.go index d706298..d6bf09c 100644 --- a/internal/jean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "fmt" @@ -34,7 +34,7 @@ func cmdServe(args []string) error { return fmt.Errorf("modèle introuvable : %s", model) } if !filepath.IsAbs(bin) { - bin = filepath.Join(JeanHome(), bin) + bin = filepath.Join(AjeanHome(), bin) } // Make sure llama-server can find its bundled shared libraries (the .so/.dll @@ -102,7 +102,7 @@ func cmdServe(args []string) error { // Working dir = JEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj // mmproj-F16.gguf) still resolve. - _ = os.Chdir(JeanHome()) + _ = os.Chdir(AjeanHome()) fmt.Fprintf(os.Stderr, "[jean serve] %s model=%s port=%s\n", bin, filepath.Base(model), get("PORT", "8080")) diff --git a/internal/jean/chat_agent.go b/internal/ajean/chat_agent.go similarity index 97% rename from internal/jean/chat_agent.go rename to internal/ajean/chat_agent.go index fdbaccd..7ed8921 100644 --- a/internal/jean/chat_agent.go +++ b/internal/ajean/chat_agent.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "fmt" @@ -27,7 +27,7 @@ func agentEnabled() bool { } func setAgentEnabled(on bool) error { - _ = os.MkdirAll(JeanHome(), 0o755) + _ = os.MkdirAll(AjeanHome(), 0o755) // On nettoie systématiquement les anciens drapeaux pour ne pas garder un // état fantôme « à moitié activé » hérité de l'ancien modèle à deux toggles. _ = os.Remove(legacyToolsFlag()) diff --git a/internal/jean/chat_cmd.go b/internal/ajean/chat_cmd.go similarity index 99% rename from internal/jean/chat_cmd.go rename to internal/ajean/chat_cmd.go index a6cb6dd..f58ab3a 100644 --- a/internal/jean/chat_cmd.go +++ b/internal/ajean/chat_cmd.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bufio" diff --git a/internal/jean/chat_compact.go b/internal/ajean/chat_compact.go similarity index 99% rename from internal/jean/chat_compact.go rename to internal/ajean/chat_compact.go index 23be98e..b9509ee 100644 --- a/internal/jean/chat_compact.go +++ b/internal/ajean/chat_compact.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bytes" diff --git a/internal/jean/chat_compact_test.go b/internal/ajean/chat_compact_test.go similarity index 99% rename from internal/jean/chat_compact_test.go rename to internal/ajean/chat_compact_test.go index 91f6dfa..1bf05ae 100644 --- a/internal/jean/chat_compact_test.go +++ b/internal/ajean/chat_compact_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "strings" diff --git a/internal/jean/chat_conversation.go b/internal/ajean/chat_conversation.go similarity index 99% rename from internal/jean/chat_conversation.go rename to internal/ajean/chat_conversation.go index 9a2420b..1656121 100644 --- a/internal/jean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "context" @@ -65,10 +65,10 @@ var conv = func() *Conversation { return c }() -// convPath = fichier de persistance sur la box jean (JeanHome = /etc/jean en +// convPath = fichier de persistance sur la box jean (AjeanHome = /etc/jean en // prod). En clair : la box déchiffre déjà pour lancer le modèle, le relais reste // aveugle — le persister ici ne change rien à la posture E2E. -func convPath() string { return filepath.Join(JeanHome(), "conversation.json") } +func convPath() string { return filepath.Join(AjeanHome(), "conversation.json") } // LoadConversation recharge l'état persisté au démarrage du process. Sans fichier // (première fois) on part d'une conversation vide. @@ -94,7 +94,7 @@ func (c *Conversation) persist() { if err != nil { return } - _ = os.MkdirAll(JeanHome(), 0o755) + _ = os.MkdirAll(AjeanHome(), 0o755) tmp := convPath() + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return diff --git a/internal/jean/chat_conversation_test.go b/internal/ajean/chat_conversation_test.go similarity index 99% rename from internal/jean/chat_conversation_test.go rename to internal/ajean/chat_conversation_test.go index 585b3b8..a46969d 100644 --- a/internal/jean/chat_conversation_test.go +++ b/internal/ajean/chat_conversation_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "context" diff --git a/internal/jean/chat_diff.go b/internal/ajean/chat_diff.go similarity index 99% rename from internal/jean/chat_diff.go rename to internal/ajean/chat_diff.go index 11e852c..bd355a0 100644 --- a/internal/jean/chat_diff.go +++ b/internal/ajean/chat_diff.go @@ -1,4 +1,4 @@ -package jean +package ajean // chat_diff.go — petit diff ligne à ligne pour l'interface : quand l'IA modifie // un fichier (outil `edit`) ou une page de mémoire (`mem_edit`, `mem_add`), on diff --git a/internal/jean/chat_internet.go b/internal/ajean/chat_internet.go similarity index 99% rename from internal/jean/chat_internet.go rename to internal/ajean/chat_internet.go index 3547cb9..0d9c947 100644 --- a/internal/jean/chat_internet.go +++ b/internal/ajean/chat_internet.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bytes" @@ -74,7 +74,7 @@ func writeCrawlKey(key string) error { } return nil } - _ = os.MkdirAll(JeanHome(), 0o755) + _ = os.MkdirAll(AjeanHome(), 0o755) return os.WriteFile(crawlKeyPath(), []byte(key+"\n"), 0o600) } @@ -97,7 +97,7 @@ func internetEnabled() bool { } func setInternetEnabled(on bool) error { - _ = os.MkdirAll(JeanHome(), 0o755) + _ = os.MkdirAll(AjeanHome(), 0o755) if on { f, err := os.Create(internetFlag()) if err != nil { diff --git a/internal/jean/chat_internet_tools.go b/internal/ajean/chat_internet_tools.go similarity index 99% rename from internal/jean/chat_internet_tools.go rename to internal/ajean/chat_internet_tools.go index 9a6c632..a0f6378 100644 --- a/internal/jean/chat_internet_tools.go +++ b/internal/ajean/chat_internet_tools.go @@ -1,6 +1,6 @@ // chat_internet_tools.go — les 4 outils web exposés au modèle (web_search, // web_open, web_read, web_grep) + la sous-commande `jean internet`. -package jean +package ajean import ( "fmt" diff --git a/internal/jean/chat_memory.go b/internal/ajean/chat_memory.go similarity index 99% rename from internal/jean/chat_memory.go rename to internal/ajean/chat_memory.go index c85824e..03be346 100644 --- a/internal/jean/chat_memory.go +++ b/internal/ajean/chat_memory.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "errors" diff --git a/internal/jean/chat_tools.go b/internal/ajean/chat_tools.go similarity index 99% rename from internal/jean/chat_tools.go rename to internal/ajean/chat_tools.go index a3b6d24..4cb81a5 100644 --- a/internal/jean/chat_tools.go +++ b/internal/ajean/chat_tools.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "context" diff --git a/internal/jean/chat_tools_write_test.go b/internal/ajean/chat_tools_write_test.go similarity index 98% rename from internal/jean/chat_tools_write_test.go rename to internal/ajean/chat_tools_write_test.go index cab2195..ab07b4d 100644 --- a/internal/jean/chat_tools_write_test.go +++ b/internal/ajean/chat_tools_write_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "os" diff --git a/internal/jean/chat_workspace.go b/internal/ajean/chat_workspace.go similarity index 94% rename from internal/jean/chat_workspace.go rename to internal/ajean/chat_workspace.go index c845175..3b5f652 100644 --- a/internal/jean/chat_workspace.go +++ b/internal/ajean/chat_workspace.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "os" @@ -26,7 +26,7 @@ var ( ) // agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il -// essaie plusieurs emplacements car JeanHome() vaut %ProgramData%\jean sous +// essaie plusieurs emplacements car AjeanHome() vaut %ProgramData%\jean sous // Windows, qui n'est pas inscriptible par un utilisateur non administrateur. func agentWorkspace() string { workspaceOnce.Do(func() { @@ -60,7 +60,7 @@ func workspaceCandidates() []string { if v := strings.TrimSpace(os.Getenv(workspaceEnv)); v != "" { c = append(c, v) } - c = append(c, filepath.Join(JeanHome(), "workspace")) + c = append(c, filepath.Join(AjeanHome(), "workspace")) if home, err := os.UserHomeDir(); err == nil && home != "" { c = append(c, filepath.Join(home, "jean", "workspace")) } diff --git a/internal/jean/chat_workspace_test.go b/internal/ajean/chat_workspace_test.go similarity index 98% rename from internal/jean/chat_workspace_test.go rename to internal/ajean/chat_workspace_test.go index 9ce7481..7ee8955 100644 --- a/internal/jean/chat_workspace_test.go +++ b/internal/ajean/chat_workspace_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "path/filepath" diff --git a/internal/jean/cli_app.go b/internal/ajean/cli_app.go similarity index 99% rename from internal/jean/cli_app.go rename to internal/ajean/cli_app.go index 7900a7a..32e1314 100644 --- a/internal/jean/cli_app.go +++ b/internal/ajean/cli_app.go @@ -1,4 +1,4 @@ -package jean +package ajean // cli_app.go — expérience « application » de Jean. // diff --git a/internal/jean/doc.go b/internal/ajean/doc.go similarity index 98% rename from internal/jean/doc.go rename to internal/ajean/doc.go index 87090ec..2ca1c5b 100644 --- a/internal/jean/doc.go +++ b/internal/ajean/doc.go @@ -15,4 +15,4 @@ // // Les suffixes _windows/_linux/_darwin/_unix/_other portent les contraintes de // compilation par OS. ui/index.html est GÉNÉRÉ depuis ui/src/ (voir ui/assemble.ps1). -package jean +package ajean diff --git a/internal/jean/llm_bench.go b/internal/ajean/llm_bench.go similarity index 98% rename from internal/jean/llm_bench.go rename to internal/ajean/llm_bench.go index dfe647e..ad9365c 100644 --- a/internal/jean/llm_bench.go +++ b/internal/ajean/llm_bench.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bytes" @@ -138,7 +138,7 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) { // 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") } +func lastBenchPath() string { return filepath.Join(AjeanHome(), ".last_bench.json") } // savedBench is a benchResult plus the model it was run against and a timestamp. type savedBench struct { @@ -170,7 +170,7 @@ func loadLastBench() *savedBench { // 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") } +func benchStorePath() string { return filepath.Join(AjeanHome(), ".bench_presets.json") } // loadBenchStore returns the per-preset bench map (empty if none/unreadable). func loadBenchStore() map[string]savedBench { diff --git a/internal/jean/llm_client.go b/internal/ajean/llm_client.go similarity index 99% rename from internal/jean/llm_client.go rename to internal/ajean/llm_client.go index c4880cb..ad800a9 100644 --- a/internal/jean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bufio" diff --git a/internal/jean/llm_oai.go b/internal/ajean/llm_oai.go similarity index 98% rename from internal/jean/llm_oai.go rename to internal/ajean/llm_oai.go index bc57a7c..ff31642 100644 --- a/internal/jean/llm_oai.go +++ b/internal/ajean/llm_oai.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bufio" @@ -74,7 +74,7 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error { // oaiPublicPath est le drapeau qui active l'accès OpenAI public (piloté par l'UI, // lu en direct → activable/coupable sans redémarrer le service de lien). -func oaiPublicPath() string { return filepath.Join(JeanHome(), ".oai_public") } +func oaiPublicPath() string { return filepath.Join(AjeanHome(), ".oai_public") } // oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette machine. func oaiPublicEnabled() bool { @@ -101,7 +101,7 @@ func setOAIPublic(on bool) error { // $JEAN_HOME/certs et ne quitte jamais la machine. Toujours construite ; c'est le // démux (oaiPublicEnabled, lu en direct) qui décide de router ou non le trafic. func oaiTLSConfig() *tls.Config { - certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(JeanHome(), "certs")} + certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")} certmagic.DefaultACME.Agreed = true certmagic.DefaultACME.Email = strings.TrimSpace(os.Getenv("JEAN_ACME_EMAIL")) certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement diff --git a/internal/jean/llm_testcmd.go b/internal/ajean/llm_testcmd.go similarity index 99% rename from internal/jean/llm_testcmd.go rename to internal/ajean/llm_testcmd.go index 17eb092..5a4dae2 100644 --- a/internal/jean/llm_testcmd.go +++ b/internal/ajean/llm_testcmd.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "context" diff --git a/internal/jean/mcp_client.go b/internal/ajean/mcp_client.go similarity index 99% rename from internal/jean/mcp_client.go rename to internal/ajean/mcp_client.go index c1e271d..a82d63f 100644 --- a/internal/jean/mcp_client.go +++ b/internal/ajean/mcp_client.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "context" diff --git a/internal/jean/mcp_client_test.go b/internal/ajean/mcp_client_test.go similarity index 99% rename from internal/jean/mcp_client_test.go rename to internal/ajean/mcp_client_test.go index 152c968..10bd944 100644 --- a/internal/jean/mcp_client_test.go +++ b/internal/ajean/mcp_client_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "os/exec" diff --git a/internal/jean/mcp_config.go b/internal/ajean/mcp_config.go similarity index 99% rename from internal/jean/mcp_config.go rename to internal/ajean/mcp_config.go index 0dca871..308d3ce 100644 --- a/internal/jean/mcp_config.go +++ b/internal/ajean/mcp_config.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "encoding/json" @@ -149,7 +149,7 @@ func loadMCPConfigLocked() (map[string]MCPServerConfig, error) { // saveMCPConfigLocked écrit atomiquement mcp.json (write temp + rename). func saveMCPConfigLocked(servers map[string]MCPServerConfig) error { - _ = os.MkdirAll(JeanHome(), 0o755) + _ = os.MkdirAll(AjeanHome(), 0o755) f := mcpConfigFile{MCPServers: servers} b, err := json.MarshalIndent(f, "", " ") if err != nil { diff --git a/internal/jean/relay_e2e.go b/internal/ajean/relay_e2e.go similarity index 98% rename from internal/jean/relay_e2e.go rename to internal/ajean/relay_e2e.go index 8e79017..9e52578 100644 --- a/internal/jean/relay_e2e.go +++ b/internal/ajean/relay_e2e.go @@ -1,4 +1,4 @@ -package jean +package ajean // relay_e2e.go — chiffrement bout-en-bout du chat entre le NAVIGATEUR et CET agent, de // sorte que le relais (ajean.link) ne voie que de l'opaque : « boîte noire ». @@ -32,7 +32,7 @@ import ( "sync" ) -func e2eKeyPath() string { return filepath.Join(JeanHome(), ".e2e_key") } +func e2eKeyPath() string { return filepath.Join(AjeanHome(), ".e2e_key") } var ( e2eOnce sync.Once @@ -55,7 +55,7 @@ func e2ePrivateKey() (*ecdh.PrivateKey, error) { e2eErr = err return } - _ = os.MkdirAll(JeanHome(), 0o755) + _ = os.MkdirAll(AjeanHome(), 0o755) if err := os.WriteFile(e2eKeyPath(), []byte(hex.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil { e2eErr = err return diff --git a/internal/jean/relay_e2eauth.go b/internal/ajean/relay_e2eauth.go similarity index 96% rename from internal/jean/relay_e2eauth.go rename to internal/ajean/relay_e2eauth.go index b55b341..41d007a 100644 --- a/internal/jean/relay_e2eauth.go +++ b/internal/ajean/relay_e2eauth.go @@ -1,4 +1,4 @@ -package jean +package ajean // relay_e2eauth.go — authentification MUTUELLE + anti-rejeu du canal chiffré. // @@ -44,7 +44,7 @@ var ( authSet = map[string]bool{} ) -func authUsersPath() string { return filepath.Join(JeanHome(), ".authorized_users") } +func authUsersPath() string { return filepath.Join(AjeanHome(), ".authorized_users") } func loadAuthUsers() { authOnce.Do(func() { @@ -88,7 +88,7 @@ func authorizeUser(uPubHex string) error { return nil } authSet[uPubHex] = true - _ = os.MkdirAll(JeanHome(), 0o755) + _ = os.MkdirAll(AjeanHome(), 0o755) f, err := os.OpenFile(authUsersPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return err @@ -102,7 +102,7 @@ func authorizeUser(uPubHex string) error { // // Générés par « jean link code » (ou affichés par « jean link »), ils sont // partagés avec le worker « jean link --foreground » (systemd, autre process) via -// un fichier JeanHome/.pair_codes — sinon les deux process ne s'accorderaient pas. +// un fichier AjeanHome/.pair_codes — sinon les deux process ne s'accorderaient pas. // Stockés HACHÉS (SHA-256) : le fichier ne révèle aucun code. Chaque code expire // au bout de 10 min et est consommé (retiré) au premier appairage réussi. @@ -113,7 +113,7 @@ type pairEntry struct { Exp int64 `json:"e"` // expiration (unix ms) } -func pairCodesPath() string { return filepath.Join(JeanHome(), ".pair_codes") } +func pairCodesPath() string { return filepath.Join(AjeanHome(), ".pair_codes") } func hashPairCode(code string) string { h := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code)))) @@ -141,7 +141,7 @@ func loadPairEntries() []pairEntry { } func savePairEntries(es []pairEntry) error { - if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } b, _ := json.Marshal(es) diff --git a/internal/jean/relay_e2eauth_test.go b/internal/ajean/relay_e2eauth_test.go similarity index 99% rename from internal/jean/relay_e2eauth_test.go rename to internal/ajean/relay_e2eauth_test.go index 8489860..a75accf 100644 --- a/internal/jean/relay_e2eauth_test.go +++ b/internal/ajean/relay_e2eauth_test.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "strings" diff --git a/internal/jean/relay_link.go b/internal/ajean/relay_link.go similarity index 97% rename from internal/jean/relay_link.go rename to internal/ajean/relay_link.go index a62ab6e..2b02f0e 100644 --- a/internal/jean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -1,4 +1,4 @@ -package jean +package ajean // relay_link.go — `jean link ` : connecte ce serveur Jean au relais public // (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur @@ -42,8 +42,8 @@ import ( // $JEAN_LINK_URL (utile pour tester contre un relais local). const defaultRelayURL = "wss://ajean.link/agent" -func linkTokenPath() string { return filepath.Join(JeanHome(), ".link_token") } -func linkMachinePath() string { return filepath.Join(JeanHome(), ".link_machine") } +func linkTokenPath() string { return filepath.Join(AjeanHome(), ".link_token") } +func linkMachinePath() string { return filepath.Join(AjeanHome(), ".link_machine") } // machineID returns a stable per-machine identifier, creating one on first use. // Permet au relais de regrouper les connexions d'une même machine sous le compte. @@ -56,7 +56,7 @@ func machineID() string { buf := make([]byte, 8) _, _ = rand.Read(buf) id := hex.EncodeToString(buf) - _ = os.MkdirAll(JeanHome(), 0o755) + _ = os.MkdirAll(AjeanHome(), 0o755) _ = os.WriteFile(linkMachinePath(), []byte(id+"\n"), 0o600) return id } @@ -71,7 +71,7 @@ func readLinkToken() string { } func saveLinkToken(tok string) error { - if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } return os.WriteFile(linkTokenPath(), []byte(tok+"\n"), 0o600) @@ -140,7 +140,7 @@ func cmdLink(args []string) error { case "code": code, err := newPairCode() if err != nil { - return fmt.Errorf("génération du code (droits sur %s ?): %w", JeanHome(), err) + return fmt.Errorf("génération du code (droits sur %s ?): %w", AjeanHome(), err) } fmt.Printf("%s code d'appairage (valable 10 min, à usage unique) :\n %s\n", green("[link]"), bold(code)) return nil @@ -209,7 +209,7 @@ func linkPrintIdentity() error { } code, err := newPairCode() if err != nil { - fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), JeanHome(), err) + fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), AjeanHome(), err) fmt.Printf(" Réessaie : sudo jean link code\n") return nil } @@ -375,8 +375,8 @@ func serveLocalWebMux(mux *http.ServeMux) { // linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, // Windows), où Jean est une app de bureau lancée sans droits root. -func linkPIDPath() string { return filepath.Join(JeanHome(), linkServiceName+".pid") } -func linkLogPath() string { return filepath.Join(JeanHome(), linkServiceName+".log") } +func linkPIDPath() string { return filepath.Join(AjeanHome(), linkServiceName+".pid") } +func linkLogPath() string { return filepath.Join(AjeanHome(), linkServiceName+".log") } // linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est // l'unité systemd jean-link (avec sudo non interactif si on n'est pas root) ; @@ -486,7 +486,7 @@ func linkUserSvcCtl(action string) error { if p, err := filepath.EvalSymlinks(self); err == nil { self = p } - if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } logf, err := os.OpenFile(linkLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) diff --git a/internal/jean/run.go b/internal/ajean/run.go similarity index 79% rename from internal/jean/run.go rename to internal/ajean/run.go index a00ec6b..b526c94 100644 --- a/internal/jean/run.go +++ b/internal/ajean/run.go @@ -1,7 +1,7 @@ // jean — single-binary LLM server manager + web UI for llama.cpp deployments. // Point d'entrée réel : Main(), appelé par cmd/jean (les métadonnées Windows // .syso/go:generate vivent là-bas, dans le dossier du package main). -package jean +package ajean import ( "fmt" @@ -175,55 +175,65 @@ func mustExit(err error) { } } -// JeanHome resolves the JEAN data directory. -// Precedence: $JEAN_HOME → /etc/default/jean (unix only) → defaultJeanHome(). -// defaultJeanHome() is platform-specific (see sys_platform_unix.go / sys_platform_windows.go). -func JeanHome() string { +// AjeanHome resolves the AJEAN data directory. +// Precedence: $AJEAN_HOME → $JEAN_HOME (héritage) → /etc/default/ajean puis +// /etc/default/jean (unix only) → migratedDefaultHome(). +// +// Les deux noms de variable sont acceptés indéfiniment : ils sont posés À LA MAIN +// dans les config des utilisateurs et dans des scripts qu'on ne voit pas. Une +// variable explicite est un ordre — on ne migre RIEN quand il y en a une, on obéit. +func AjeanHome() string { + if h := os.Getenv("AJEAN_HOME"); h != "" { + return h + } if h := os.Getenv("JEAN_HOME"); h != "" { return h } if h := readEtcDefault(); h != "" { return h } - return defaultJeanHome() + return migratedDefaultHome() } -// readEtcDefault parses /etc/default/jean for JEAN_HOME=. Quiet on errors. +// readEtcDefault parses /etc/default/ajean (puis /etc/default/jean, hérité) for +// AJEAN_HOME= ou JEAN_HOME=. Quiet on errors. func readEtcDefault() string { - b, err := os.ReadFile("/etc/default/jean") - if err != nil { - return "" - } - for _, line := range strings.Split(string(b), "\n") { - s := strings.TrimSpace(line) - if s == "" || strings.HasPrefix(s, "#") { + for _, path := range []string{"/etc/default/ajean", "/etc/default/jean"} { + b, err := os.ReadFile(path) + if err != nil { continue } - s = strings.TrimPrefix(s, "export ") - if eq := strings.IndexByte(s, '='); eq > 0 { - k := strings.TrimSpace(s[:eq]) - v := strings.Trim(strings.TrimSpace(s[eq+1:]), "\"'") - if k == "JEAN_HOME" { - return v + for _, line := range strings.Split(string(b), "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + s = strings.TrimPrefix(s, "export ") + if eq := strings.IndexByte(s, '='); eq > 0 { + k := strings.TrimSpace(s[:eq]) + v := strings.Trim(strings.TrimSpace(s[eq+1:]), "\"'") + if k == "AJEAN_HOME" || k == "JEAN_HOME" { + return v + } } } } return "" } -func confPath() string { return filepath.Join(JeanHome(), "config.env") } -func presetsDir() string { return filepath.Join(JeanHome(), "configs") } -func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") } -func memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") } -func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") } -func internetFlag() string { return filepath.Join(JeanHome(), ".internet_enabled") } -func mcpConfigPath() string { return filepath.Join(JeanHome(), "mcp.json") } +func confPath() string { return filepath.Join(AjeanHome(), "config.env") } +func presetsDir() string { return filepath.Join(AjeanHome(), "configs") } +func skillsDir() string { return filepath.Join(AjeanHome(), "SKILLS") } +func memoryDir() string { return filepath.Join(AjeanHome(), "MEMORY") } +func agentFlag() string { return filepath.Join(AjeanHome(), ".agent_enabled") } +func internetFlag() string { return filepath.Join(AjeanHome(), ".internet_enabled") } +func mcpConfigPath() string { return filepath.Join(AjeanHome(), "mcp.json") } // Anciens drapeaux séparés, conservés pour la migration vers le mode agent unifié. func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } -func legacyToolsFlag() string { return filepath.Join(JeanHome(), ".tools_enabled") } -func apiKeyPath() string { return filepath.Join(JeanHome(), ".api_key") } -func crawlKeyPath() string { return filepath.Join(JeanHome(), ".crawl4ai_key") } +func legacyToolsFlag() string { return filepath.Join(AjeanHome(), ".tools_enabled") } +func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") } +func crawlKeyPath() string { return filepath.Join(AjeanHome(), ".crawl4ai_key") } func serviceName() string { if n := os.Getenv("JEAN_SERVICE"); n != "" { return n diff --git a/internal/jean/sys_brand_icon.go b/internal/ajean/sys_brand_icon.go similarity index 99% rename from internal/jean/sys_brand_icon.go rename to internal/ajean/sys_brand_icon.go index 134de41..04b70bb 100644 --- a/internal/jean/sys_brand_icon.go +++ b/internal/ajean/sys_brand_icon.go @@ -1,4 +1,4 @@ -package jean +package ajean // sys_brand_icon.go — l'icône de la marque AJEAN, rendue à la volée. // diff --git a/internal/jean/sys_console_darwin.go b/internal/ajean/sys_console_darwin.go similarity index 99% rename from internal/jean/sys_console_darwin.go rename to internal/ajean/sys_console_darwin.go index 40b4409..2d02240 100644 --- a/internal/jean/sys_console_darwin.go +++ b/internal/ajean/sys_console_darwin.go @@ -1,6 +1,6 @@ //go:build darwin -package jean +package ajean // sys_console_darwin.go — détection du lancement « par clic » sous macOS. // diff --git a/internal/jean/sys_console_other.go b/internal/ajean/sys_console_other.go similarity index 97% rename from internal/jean/sys_console_other.go rename to internal/ajean/sys_console_other.go index 1dee73a..63f7a35 100644 --- a/internal/jean/sys_console_other.go +++ b/internal/ajean/sys_console_other.go @@ -1,6 +1,6 @@ //go:build !windows && !darwin -package jean +package ajean // setupConsole : sous Linux, le process a toujours une vraie console/tty // standard (stdout hérité). Rien à faire → on signale simplement « on a une diff --git a/internal/jean/sys_console_windows.go b/internal/ajean/sys_console_windows.go similarity index 99% rename from internal/jean/sys_console_windows.go rename to internal/ajean/sys_console_windows.go index d8e8b56..821c4e6 100644 --- a/internal/jean/sys_console_windows.go +++ b/internal/ajean/sys_console_windows.go @@ -1,6 +1,6 @@ //go:build windows -package jean +package ajean // sys_console_windows.go — gestion de la console sous Windows. // diff --git a/internal/jean/sys_firstrun_other.go b/internal/ajean/sys_firstrun_other.go similarity index 97% rename from internal/jean/sys_firstrun_other.go rename to internal/ajean/sys_firstrun_other.go index 5ca594a..b6eec3c 100644 --- a/internal/jean/sys_firstrun_other.go +++ b/internal/ajean/sys_firstrun_other.go @@ -1,6 +1,6 @@ //go:build !windows -package jean +package ajean import "os" diff --git a/internal/jean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go similarity index 99% rename from internal/jean/sys_firstrun_windows.go rename to internal/ajean/sys_firstrun_windows.go index 6d42128..f9ad683 100644 --- a/internal/jean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -1,6 +1,6 @@ //go:build windows -package jean +package ajean import ( "fmt" @@ -113,7 +113,7 @@ func appFirstRun() bool { "Installer AJEAN sur cet ordinateur ?\n\n"+ "• le programme sera copié dans :\n "+target+"\n"+ "• un raccourci sera ajouté au menu Démarrer et au Bureau\n"+ - "• vos réglages et modèles seront rangés dans :\n "+JeanHome()+"\n\n"+ + "• vos réglages et modèles seront rangés dans :\n "+AjeanHome()+"\n\n"+ "Vous pourrez ensuite supprimer le fichier que vous venez de télécharger.\n\n"+ "Répondre Non démarre AJEAN sans rien installer. La question reviendra\n"+ "au prochain lancement de ce fichier.", @@ -376,7 +376,7 @@ type vsFixedFileInfo struct { // Extrait de cmdInstall pour être réutilisable au premier lancement, sans // embarquer l'installation du binaire ni les messages de console. func provisionDataDir() error { - jeanHome := JeanHome() + jeanHome := AjeanHome() for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { if err := os.MkdirAll(d, 0o755); err != nil { return err diff --git a/internal/jean/sys_firstrun_windows_test.go b/internal/ajean/sys_firstrun_windows_test.go similarity index 99% rename from internal/jean/sys_firstrun_windows_test.go rename to internal/ajean/sys_firstrun_windows_test.go index 43449ec..51a651a 100644 --- a/internal/jean/sys_firstrun_windows_test.go +++ b/internal/ajean/sys_firstrun_windows_test.go @@ -1,6 +1,6 @@ //go:build windows -package jean +package ajean import ( "os" diff --git a/internal/jean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go similarity index 99% rename from internal/jean/sys_install_darwin.go rename to internal/ajean/sys_install_darwin.go index f74001d..56f55d8 100644 --- a/internal/jean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -1,6 +1,6 @@ //go:build darwin -package jean +package ajean import ( "fmt" diff --git a/internal/jean/sys_install_linux.go b/internal/ajean/sys_install_linux.go similarity index 99% rename from internal/jean/sys_install_linux.go rename to internal/ajean/sys_install_linux.go index 154bdce..d53fedc 100644 --- a/internal/jean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -1,6 +1,6 @@ //go:build linux -package jean +package ajean import ( "fmt" diff --git a/internal/jean/sys_install_windows.go b/internal/ajean/sys_install_windows.go similarity index 98% rename from internal/jean/sys_install_windows.go rename to internal/ajean/sys_install_windows.go index ccce2b6..9aae2dc 100644 --- a/internal/jean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -1,6 +1,6 @@ //go:build windows -package jean +package ajean import ( "fmt" @@ -38,7 +38,7 @@ EXTRA_ARGS="" ` func cmdInstall(args []string) error { - jeanHome := JeanHome() + jeanHome := AjeanHome() fmt.Printf("Installation (Windows)\n") fmt.Printf(" JEAN_HOME = %s\n", jeanHome) @@ -203,7 +203,7 @@ func cmdUninstall(args []string) error { _ = svcStop(false) // Pull JEAN_HOME\bin off the user PATH (best-effort). - binDir := filepath.Join(JeanHome(), "bin") + binDir := filepath.Join(AjeanHome(), "bin") if removed, err := removeFromUserPath(binDir); err == nil && removed { fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir) } @@ -214,7 +214,7 @@ func cmdUninstall(args []string) error { } if !keepData { - jeanHome := JeanHome() + jeanHome := AjeanHome() if err := os.RemoveAll(jeanHome); err != nil { return fmt.Errorf("suppression de %s: %w", jeanHome, err) } diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go new file mode 100644 index 0000000..9aa5141 --- /dev/null +++ b/internal/ajean/sys_migrate.go @@ -0,0 +1,80 @@ +package ajean + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +// Migration du dossier de données jean → ajean. +// +// C'est le seul endroit du renommage qui peut faire PERDRE des données : le +// dossier contient la config, les clés, la mémoire, les conversations, et +// surtout des .gguf qui pèsent des dizaines de gigaoctets. Trois règles en +// découlent, et elles expliquent tout le code ci-dessous : +// +// 1. On déplace par os.Rename DANS LE DOSSIER PARENT de l'ancien. Un rename +// intra-volume est atomique et instantané — il ne recopie pas les 40 Go, et +// il n'existe aucun instant où les données seraient à moitié quelque part. +// Choisir le parent de l'ancien chemin (et pas le défaut de la plateforme) +// garantit qu'on reste sur le même volume, donc que le rename est bien un +// rename et non un copy+delete déguisé. +// +// 2. Si le rename échoue — service en cours qui tient des handles sous Windows, +// droits insuffisants, volume monté en lecture seule — on CONTINUE SUR +// L'ANCIEN CHEMIN. Une machine qui n'a pas migré marche exactement comme +// avant ; une machine qui a perdu ses modèles, non. La migration est donc +// retentée à chaque démarrage jusqu'à ce qu'elle passe. +// +// 3. Aucune suppression, jamais. Le pire cas est « rien n'a bougé ». + +var ( + homeOnce sync.Once + homePath string +) + +// migratedDefaultHome renvoie le dossier de données par défaut, en migrant +// l'ancien dossier « jean » vers « ajean » à la première résolution du process. +// Le résultat est mis en cache : deux appels ne doivent jamais désigner deux +// dossiers différents, sinon une moitié du programme écrirait à côté de l'autre. +// +// N'est PAS appelé quand $AJEAN_HOME/$JEAN_HOME ou /etc/default/* imposent un +// chemin : un choix explicite de l'utilisateur ne se migre pas. +func migratedDefaultHome() string { + homeOnce.Do(func() { homePath = resolveDefaultHome() }) + return homePath +} + +func resolveDefaultHome() string { + return migrateHome(defaultAjeanHome(), legacyDefaultHome()) +} + +// migrateHome contient toute la logique de migration, isolée des chemins réels +// de la plateforme pour être testable telle quelle. Renvoie le dossier à +// utiliser — le nouveau si la migration a réussi ou n'était pas nécessaire, +// l'ancien si elle a échoué. +func migrateHome(target, legacy string) string { + if isDir(target) { + return target // déjà migré (ou installation neuve déjà faite) + } + if !isDir(legacy) { + return target // installation neuve : rien à migrer + } + + // Même parent que l'ancien dossier ⇒ même volume ⇒ rename atomique. + sibling := filepath.Join(filepath.Dir(legacy), filepath.Base(target)) + if isDir(sibling) { + return sibling + } + if err := os.Rename(legacy, sibling); err != nil { + // Cas le plus courant sous Windows : un service AJEAN tourne encore et + // tient un handle dans le dossier. On reste sur l'ancien chemin — tout + // fonctionne — et on retentera au prochain démarrage. + fmt.Fprintf(os.Stderr, "[info] dossier de données pas encore migré vers %s (%v) — on continue sur %s\n", + sibling, err, legacy) + return legacy + } + fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling) + return sibling +} diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go new file mode 100644 index 0000000..87f24f4 --- /dev/null +++ b/internal/ajean/sys_migrate_test.go @@ -0,0 +1,102 @@ +package ajean + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// mkHome crée un dossier de données factice contenant un fichier témoin, pour +// vérifier que la migration déplace bien le CONTENU et pas seulement le dossier. +func mkHome(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.env"), []byte("MODEL=x.gguf\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +// Cas nominal : seul l'ancien dossier existe → il est migré, contenu compris. +func TestMigrateHomeMovesLegacy(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + + if got := migrateHome(target, legacy); got != target { + t.Fatalf("got %q, attendu le nouveau chemin %q", got, target) + } + if b, err := os.ReadFile(filepath.Join(target, "config.env")); err != nil || string(b) != "MODEL=x.gguf\n" { + t.Fatalf("contenu non migré: %q (%v)", b, err) + } + if isDir(legacy) { + t.Fatalf("l'ancien dossier existe encore après un rename réussi") + } +} + +// Installation neuve : rien à migrer, on renvoie le nouveau chemin sans rien créer. +func TestMigrateHomeFreshInstall(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "ajean") + if got := migrateHome(target, filepath.Join(root, "jean")); got != target { + t.Fatalf("got %q, attendu %q", got, target) + } +} + +// Déjà migré : le nouveau dossier existe → on ne retouche à rien, même si un +// vieux dossier « jean » traîne encore (utilisateur qui l'a recréé, restauration +// de sauvegarde…). Écraser serait perdre les données courantes. +func TestMigrateHomeKeepsExistingTarget(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + mkHome(t, target) + if err := os.WriteFile(filepath.Join(target, "config.env"), []byte("MODEL=courant.gguf\n"), 0o644); err != nil { + t.Fatal(err) + } + + if got := migrateHome(target, legacy); got != target { + t.Fatalf("got %q, attendu %q", got, target) + } + b, _ := os.ReadFile(filepath.Join(target, "config.env")) + if string(b) != "MODEL=courant.gguf\n" { + t.Fatalf("le dossier courant a été écrasé par l'ancien: %q", b) + } + if !isDir(legacy) { + t.Fatalf("l'ancien dossier a été supprimé — la migration ne doit JAMAIS supprimer") + } +} + +// Rename impossible → on doit retomber sur l'ancien chemin, intact. C'est la +// garantie « au pire, rien n'a bougé », celle qui protège les .gguf. +// +// On reproduit le scénario réel : un service AJEAN tourne encore et tient un +// handle ouvert dans le dossier de données, ce qui fait échouer le rename du +// dossier sous Windows. Unix, lui, renomme sans broncher un dossier dont des +// fichiers sont ouverts — il n'y a donc rien à simuler là-bas. +func TestMigrateHomeFallsBackWhenRenameFails(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("un rename de dossier réussit avec des fichiers ouverts hors Windows") + } + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + + held, err := os.Open(filepath.Join(legacy, "config.env")) + if err != nil { + t.Fatal(err) + } + defer held.Close() + + if got := migrateHome(target, legacy); got != legacy { + t.Fatalf("got %q, attendu un repli sur l'ancien chemin %q", got, legacy) + } + if b, err := os.ReadFile(filepath.Join(legacy, "config.env")); err != nil || string(b) != "MODEL=x.gguf\n" { + t.Fatalf("l'ancien dossier a été abîmé par une migration ratée: %q (%v)", b, err) + } +} diff --git a/internal/jean/sys_paths.go b/internal/ajean/sys_paths.go similarity index 93% rename from internal/jean/sys_paths.go rename to internal/ajean/sys_paths.go index 5a05a00..a7ec1e8 100644 --- a/internal/jean/sys_paths.go +++ b/internal/ajean/sys_paths.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "fmt" @@ -15,7 +15,7 @@ import ( // modèles et les fichiers que l'agent écrit. On centralise donc la réponse ici, // et on l'expose à la fois en ligne de commande (`jean where`) et dans l'UI. -type jeanPaths struct { +type ajeanPaths struct { Home string `json:"home"` // JEAN_HOME : racine des données Config string `json:"config"` // config.env Exe string `json:"exe"` // binaire en cours d'exécution @@ -31,18 +31,18 @@ type jeanPaths struct { // faute d'équivalent, on utilise JEAN_HOME\bin ajouté au PATH utilisateur. func installedExePath() string { if runtime.GOOS == "windows" { - return filepath.Join(JeanHome(), "bin", "jean.exe") + return filepath.Join(AjeanHome(), "bin", "jean.exe") } return "/usr/local/bin/jean" } -func currentPaths() jeanPaths { +func currentPaths() ajeanPaths { exe, _ := os.Executable() if resolved, err := filepath.EvalSymlinks(exe); err == nil { exe = resolved } - return jeanPaths{ - Home: JeanHome(), + return ajeanPaths{ + Home: AjeanHome(), Config: confPath(), Exe: exe, Installed: installedExePath(), diff --git a/internal/jean/sys_platform_unix.go b/internal/ajean/sys_platform_unix.go similarity index 92% rename from internal/jean/sys_platform_unix.go rename to internal/ajean/sys_platform_unix.go index 8a1aa99..b36f497 100644 --- a/internal/jean/sys_platform_unix.go +++ b/internal/ajean/sys_platform_unix.go @@ -1,6 +1,6 @@ //go:build unix -package jean +package ajean import ( "context" @@ -16,25 +16,30 @@ import ( "time" ) -// defaultJeanHome is the data root when neither $JEAN_HOME nor /etc/default/jean -// override it. +// defaultAjeanHome is the data root when neither $AJEAN_HOME/$JEAN_HOME nor +// /etc/default/* override it. legacyDefaultHome est son équivalent d'avant le +// renommage, migré au premier lancement (voir sys_migrate.go). // -// Linux : /etc/jean (machine de prod, service systemd lancé par root). -// macOS : /etc/jean n'est PAS écrivable par une app lancée depuis le Finder -// (« mkdir /etc/jean: permission denied »), et un Mac est une machine de bureau, +// Linux : /etc/ajean (machine de prod, service systemd lancé par root). +// macOS : /etc/ajean n'est PAS écrivable par une app lancée depuis le Finder +// (« mkdir /etc/ajean: permission denied »), et un Mac est une machine de bureau, // pas un serveur — on retombe donc sur le dossier utilisateur standard, sauf si -// un `sudo jean install` a déjà créé /etc/jean à notre nom. -func defaultJeanHome() string { +// un `sudo ajean install` a déjà créé /etc/ajean à notre nom. +func defaultAjeanHome() string { return unixHome("ajean") } + +func legacyDefaultHome() string { return unixHome("jean") } + +func unixHome(name string) string { if runtime.GOOS != "darwin" || os.Geteuid() == 0 { - return "/etc/jean" + return "/etc/" + name } - if isWritableDir("/etc/jean") { - return "/etc/jean" // installé par `sudo jean install` puis chown à l'utilisateur + if isWritableDir("/etc/" + name) { + return "/etc/" + name // installé par `sudo ajean install` puis chown à l'utilisateur } if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, "Library", "Application Support", "jean") + return filepath.Join(home, "Library", "Application Support", name) } - return "/etc/jean" + return "/etc/" + name } // isWritableDir teste qu'un dossier existe ET qu'on peut y écrire (le seul test diff --git a/internal/jean/sys_platform_windows.go b/internal/ajean/sys_platform_windows.go similarity index 95% rename from internal/jean/sys_platform_windows.go rename to internal/ajean/sys_platform_windows.go index 2e6bbb3..c7d77ec 100644 --- a/internal/jean/sys_platform_windows.go +++ b/internal/ajean/sys_platform_windows.go @@ -1,6 +1,6 @@ //go:build windows -package jean +package ajean import ( "context" @@ -40,19 +40,26 @@ func init() { } } -// defaultJeanHome is the data root when $JEAN_HOME is unset. We use -// %ProgramData%\jean (machine-wide, the closest analogue to /etc/jean), falling -// back to %LOCALAPPDATA%\jean for unprivileged setups. -func defaultJeanHome() string { +// homeRoot is the parent directory of the data root: %ProgramData% (machine-wide, +// the closest analogue to /etc), falling back to %LOCALAPPDATA% for unprivileged +// setups. Ancien et nouveau dossier partagent ce parent, ce qui garantit que la +// migration jean → ajean est un rename intra-volume (voir sys_migrate.go). +func homeRoot() string { if pd := os.Getenv("ProgramData"); pd != "" { - return filepath.Join(pd, "jean") + return pd } if la := os.Getenv("LOCALAPPDATA"); la != "" { - return filepath.Join(la, "jean") + return la } - return filepath.Join(os.TempDir(), "jean") + return os.TempDir() } +// defaultAjeanHome is the data root when no AJEAN_HOME/JEAN_HOME is set. +func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") } + +// legacyDefaultHome is the pre-rename data root, migrated on first run. +func legacyDefaultHome() string { return filepath.Join(homeRoot(), "jean") } + // defaultEditor is used by `jean edit` when $EDITOR is unset. func defaultEditor() string { return "notepad" } diff --git a/internal/jean/sys_service.go b/internal/ajean/sys_service.go similarity index 99% rename from internal/jean/sys_service.go rename to internal/ajean/sys_service.go index a5b6fb3..fa3d46f 100644 --- a/internal/jean/sys_service.go +++ b/internal/ajean/sys_service.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "fmt" diff --git a/internal/jean/sys_service_darwin.go b/internal/ajean/sys_service_darwin.go similarity index 96% rename from internal/jean/sys_service_darwin.go rename to internal/ajean/sys_service_darwin.go index ca9e609..4a167f5 100644 --- a/internal/jean/sys_service_darwin.go +++ b/internal/ajean/sys_service_darwin.go @@ -1,6 +1,6 @@ //go:build darwin -package jean +package ajean import ( "fmt" @@ -29,7 +29,7 @@ func launchdPlistPath(svc string) string { // launchdLogPath : sortie standard/erreur du service, sous JEAN_HOME (accessible // en écriture par l'utilisateur du service après le chown de l'installation). -func launchdLogPath() string { return filepath.Join(JeanHome(), serviceName()+".log") } +func launchdLogPath() string { return filepath.Join(AjeanHome(), serviceName()+".log") } // serviceAction mappe start/stop/restart/enable/disable sur launchctl. `load -w` // (re)active le service ET le rend persistant au boot ; `unload -w` le désactive. @@ -133,8 +133,8 @@ func serviceIsActive() bool { // la fermeture de Jean.app, et permet de tuer tout le groupe (llama-server // enfant compris) d'un seul signal. -func pidFilePath() string { return filepath.Join(JeanHome(), serviceName()+".pid") } -func logFilePath() string { return filepath.Join(JeanHome(), serviceName()+".log") } +func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") } +func logFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".log") } func userSvcAction(action string) error { switch action { @@ -173,7 +173,7 @@ func userSvcStart() error { if err != nil { return err } - if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) @@ -187,7 +187,7 @@ func userSvcStart() error { // Même répertoire de travail que sous systemd/launchd : les chemins relatifs // de config.env (MODEL=…gguf) se résolvent depuis JEAN_HOME, pas depuis « / » // que nous hérite le Finder. - cmd.Dir = JeanHome() + cmd.Dir = AjeanHome() cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { return fmt.Errorf("démarrage de 'jean serve': %w", err) diff --git a/internal/jean/sys_service_linux.go b/internal/ajean/sys_service_linux.go similarity index 99% rename from internal/jean/sys_service_linux.go rename to internal/ajean/sys_service_linux.go index ede3a77..0558e22 100644 --- a/internal/jean/sys_service_linux.go +++ b/internal/ajean/sys_service_linux.go @@ -1,6 +1,6 @@ //go:build linux -package jean +package ajean import ( "fmt" diff --git a/internal/jean/sys_service_windows.go b/internal/ajean/sys_service_windows.go similarity index 95% rename from internal/jean/sys_service_windows.go rename to internal/ajean/sys_service_windows.go index ccf50a2..da9f807 100644 --- a/internal/jean/sys_service_windows.go +++ b/internal/ajean/sys_service_windows.go @@ -1,6 +1,6 @@ //go:build windows -package jean +package ajean import ( "fmt" @@ -26,8 +26,8 @@ const ( createNoWindow = 0x08000000 // CREATE_NO_WINDOW (aucune console pour l'enfant) ) -func pidFilePath() string { return filepath.Join(JeanHome(), serviceName()+".pid") } -func logFilePath() string { return filepath.Join(JeanHome(), serviceName()+".log") } +func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") } +func logFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".log") } func serviceAction(action string) error { switch action { @@ -59,7 +59,7 @@ func svcStart() error { if err != nil { return err } - if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) @@ -71,7 +71,7 @@ func svcStart() error { cmd := exec.Command(self, "serve") cmd.Stdout = logf cmd.Stderr = logf - cmd.Dir = JeanHome() // les chemins relatifs de config.env se résolvent depuis JEAN_HOME + cmd.Dir = AjeanHome() // les chemins relatifs de config.env se résolvent depuis JEAN_HOME // createNoWindow + HideWindow : le service enfant (`jean serve`) ne doit JAMAIS // faire clignoter de console noire quand Jean est lancé en mode app (double-clic). cmd.SysProcAttr = &syscall.SysProcAttr{ diff --git a/internal/jean/sys_splash_other.go b/internal/ajean/sys_splash_other.go similarity index 94% rename from internal/jean/sys_splash_other.go rename to internal/ajean/sys_splash_other.go index 8dc5b26..f91f5bc 100644 --- a/internal/jean/sys_splash_other.go +++ b/internal/ajean/sys_splash_other.go @@ -1,6 +1,6 @@ //go:build !windows -package jean +package ajean // sys_splash_other.go — pas de fenêtre de démarrage hors Windows (no-op). diff --git a/internal/jean/sys_splash_windows.go b/internal/ajean/sys_splash_windows.go similarity index 99% rename from internal/jean/sys_splash_windows.go rename to internal/ajean/sys_splash_windows.go index d1ff561..4a96703 100644 --- a/internal/jean/sys_splash_windows.go +++ b/internal/ajean/sys_splash_windows.go @@ -1,6 +1,6 @@ //go:build windows -package jean +package ajean // sys_splash_windows.go — écran de démarrage « Lancement de Jean… ». // diff --git a/internal/jean/sys_tray_darwin.go b/internal/ajean/sys_tray_darwin.go similarity index 99% rename from internal/jean/sys_tray_darwin.go rename to internal/ajean/sys_tray_darwin.go index 038788f..0a2e56b 100644 --- a/internal/jean/sys_tray_darwin.go +++ b/internal/ajean/sys_tray_darwin.go @@ -1,6 +1,6 @@ //go:build darwin -package jean +package ajean // sys_tray_darwin.go — icône de Jean dans la barre de menus macOS (en haut à // droite, à côté de l'horloge), pendant exact du system tray Windows : quand on diff --git a/internal/jean/sys_tray_other.go b/internal/ajean/sys_tray_other.go similarity index 97% rename from internal/jean/sys_tray_other.go rename to internal/ajean/sys_tray_other.go index 9824225..d1992c8 100644 --- a/internal/jean/sys_tray_other.go +++ b/internal/ajean/sys_tray_other.go @@ -1,6 +1,6 @@ //go:build !windows && !darwin -package jean +package ajean // sys_tray_other.go — pas d'icône de zone de notification sous Linux (serveur // sans session graphique la plupart du temps). On se contente d'ouvrir l'UI dans diff --git a/internal/jean/sys_tray_windows.go b/internal/ajean/sys_tray_windows.go similarity index 99% rename from internal/jean/sys_tray_windows.go rename to internal/ajean/sys_tray_windows.go index 655e4e3..6fb8ed2 100644 --- a/internal/jean/sys_tray_windows.go +++ b/internal/ajean/sys_tray_windows.go @@ -1,6 +1,6 @@ //go:build windows -package jean +package ajean // sys_tray_windows.go — icône de Jean dans la zone de notification Windows (system // tray). Quand on lance l'app (double-clic ou `jean app`), l'UI web s'ouvre dans diff --git a/internal/jean/sys_tty.go b/internal/ajean/sys_tty.go similarity index 94% rename from internal/jean/sys_tty.go rename to internal/ajean/sys_tty.go index b5b24d7..d3d6329 100644 --- a/internal/jean/sys_tty.go +++ b/internal/ajean/sys_tty.go @@ -1,4 +1,4 @@ -package jean +package ajean import "os" diff --git a/internal/jean/sys_update.go b/internal/ajean/sys_update.go similarity index 84% rename from internal/jean/sys_update.go rename to internal/ajean/sys_update.go index e6abadc..f91e5c0 100644 --- a/internal/jean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "crypto/sha256" @@ -22,7 +22,10 @@ import ( // l'OS/arch courant, remplace le binaire en place, puis affiche quoi redémarrer. // AUCUN redémarrage de service automatique (choix volontaire, plus sûr). -const updateRepo = "nathaninline/jean" +// Le dépôt a été renommé jean → ajean ; GitHub redirige les anciennes URL et +// l'API REST, donc les binaires déjà installés qui pointent encore sur +// « nathaninline/jean » continuent de trouver les releases. +const updateRepo = "nathaninline/ajean" type ghRelease struct { TagName string `json:"tag_name"` @@ -34,14 +37,39 @@ type ghRelease struct { } `json:"assets"` } -// updateAssetName reconstruit le nom d'asset attendu pour la plateforme courante, -// suivant la convention des releases : jean--[.exe]. -func updateAssetName() string { - n := "jean-" + runtime.GOOS + "-" + runtime.GOARCH +// updateAssetNames reconstruit les noms d'asset acceptables pour la plateforme +// courante, par ordre de préférence : ajean--[.exe] puis, en repli, +// l'ancien nom jean--[.exe]. +// +// Le repli n'est pas de la coquetterie : pendant la transition, les releases +// publient les DEUX noms (mêmes binaires) pour que le parc déjà installé — qui +// ne connaît que « jean-* » — continue de se mettre à jour. Ce binaire-ci sait +// lire les deux, il fonctionne donc aussi bien avec une release de transition +// qu'avec une release future qui n'aura plus que « ajean-* ». +func updateAssetNames() []string { + suffix := runtime.GOOS + "-" + runtime.GOARCH if runtime.GOOS == "windows" { - n += ".exe" + suffix += ".exe" } - return n + return []string{"ajean-" + suffix, "jean-" + suffix} +} + +// updateAssetName renvoie le nom d'asset préféré (usage : messages à l'écran). +func updateAssetName() string { return updateAssetNames()[0] } + +// pickAsset choisit dans la release le premier asset correspondant à la +// plateforme, en respectant l'ordre de préférence. Renvoie son nom, son URL et +// sa taille — le nom retourné est celui qui servira à vérifier le SHA-256, qui +// doit donc être celui réellement téléchargé, pas celui qu'on préférait. +func pickAsset(rel *ghRelease) (name, url string, size int64) { + for _, want := range updateAssetNames() { + for _, a := range rel.Assets { + if a.Name == want { + return a.Name, a.BrowserDownloadURL, a.Size + } + } + } + return "", "", 0 } // ensureV préfixe "v" si absent, pour comparer via golang.org/x/mod/semver. @@ -56,7 +84,7 @@ func ensureV(s string) string { func fetchLatestRelease() (*ghRelease, error) { req, _ := http.NewRequest("GET", "https://api.github.com/repos/"+updateRepo+"/releases/latest", nil) req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "jean-update") + req.Header.Set("User-Agent", "ajean-update") resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return nil, err @@ -114,17 +142,10 @@ func applyUpdate() (string, error) { return Version, fmt.Errorf("déjà à jour (%s)", Version) } - want := updateAssetName() - var url string - var size int64 - for _, a := range rel.Assets { - if a.Name == want { - url, size = a.BrowserDownloadURL, a.Size - break - } - } + want, url, size := pickAsset(rel) if url == "" { - return "", fmt.Errorf("aucun binaire %q dans la release %s (os/arch %s/%s)", want, latest, runtime.GOOS, runtime.GOARCH) + return "", fmt.Errorf("aucun binaire %s dans la release %s (os/arch %s/%s)", + strings.Join(updateAssetNames(), " ni "), latest, runtime.GOOS, runtime.GOARCH) } exe, err := os.Executable() @@ -136,12 +157,12 @@ func applyUpdate() (string, error) { } // Contrôle des droits AVANT de télécharger : sans ça, l'échec survient sur le // os.Create du fichier temporaire et l'utilisateur reçoit un « open - // /usr/local/bin/.jean-update.tmp: permission denied » incompréhensible, + // /usr/local/bin/.ajean-update.tmp: permission denied » incompréhensible, // notamment depuis le bouton de l'UI web (jean web lancé sans sudo). if err := checkUpdateWritable(exe); err != nil { return "", err } - tmp := filepath.Join(filepath.Dir(exe), ".jean-update.tmp") + tmp := filepath.Join(filepath.Dir(exe), ".ajean-update.tmp") if err := downloadTo(url, tmp); err != nil { os.Remove(tmp) @@ -190,7 +211,7 @@ func updatePermissionError(exe string) error { // pas : root, groupes, ACL, montage en lecture seule…). func checkUpdateWritable(exe string) error { dir := filepath.Dir(exe) - probe, err := os.CreateTemp(dir, ".jean-update-probe-*") + probe, err := os.CreateTemp(dir, ".ajean-update-probe-*") if err != nil { if os.IsPermission(err) { return updatePermissionError(exe) @@ -237,7 +258,7 @@ func cmdUpdate(args []string) error { func downloadTo(url, dst string) error { req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("User-Agent", "jean-update") + req.Header.Set("User-Agent", "ajean-update") req.Header.Set("Accept", "application/octet-stream") resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req) if err != nil { @@ -274,7 +295,7 @@ func verifyChecksum(rel *ghRelease, assetName, path string) error { return nil } req, _ := http.NewRequest("GET", sumsURL, nil) - req.Header.Set("User-Agent", "jean-update") + req.Header.Set("User-Agent", "ajean-update") resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return fmt.Errorf("téléchargement des sommes de contrôle : %w", err) diff --git a/internal/ajean/sys_update_asset_test.go b/internal/ajean/sys_update_asset_test.go new file mode 100644 index 0000000..db8eb02 --- /dev/null +++ b/internal/ajean/sys_update_asset_test.go @@ -0,0 +1,63 @@ +package ajean + +import "testing" + +// relWith fabrique une release ne contenant que les assets nommés. +func relWith(names ...string) *ghRelease { + rel := &ghRelease{TagName: "v9.9.9"} + for _, n := range names { + rel.Assets = append(rel.Assets, struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` + }{Name: n, BrowserDownloadURL: "https://example.invalid/" + n, Size: 42}) + } + return rel +} + +// Une release de transition publie les deux noms : on doit préférer ajean-*. +func TestPickAssetPrefersAjean(t *testing.T) { + names := updateAssetNames() + got, url, _ := pickAsset(relWith(names[1], names[0])) + if got != names[0] { + t.Fatalf("got %q, attendu la préférence %q", got, names[0]) + } + if url == "" { + t.Fatal("URL vide") + } +} + +// Release future, purgée des anciens noms : ajean-* seul suffit. +func TestPickAssetAjeanOnly(t *testing.T) { + names := updateAssetNames() + if got, _, _ := pickAsset(relWith(names[0])); got != names[0] { + t.Fatalf("got %q, attendu %q", got, names[0]) + } +} + +// Ancienne release, antérieure au renommage : le repli sur jean-* doit marcher, +// sinon un client neuf ne pourrait pas s'installer depuis une release existante. +func TestPickAssetFallsBackToLegacy(t *testing.T) { + names := updateAssetNames() + got, _, _ := pickAsset(relWith(names[1])) + if got != names[1] { + t.Fatalf("got %q, attendu le repli %q", got, names[1]) + } +} + +// Le nom renvoyé sert à vérifier le SHA-256 : il doit être celui réellement +// choisi, pas le nom préféré, sinon la vérification échoue sur un repli. +func TestPickAssetReturnsCheckedName(t *testing.T) { + names := updateAssetNames() + got, url, size := pickAsset(relWith(names[1])) + if got != names[1] || url != "https://example.invalid/"+names[1] || size != 42 { + t.Fatalf("incohérence nom/URL/taille: %q %q %d", got, url, size) + } +} + +// Aucun asset pour cette plateforme → pas d'URL, l'appelant doit pouvoir le voir. +func TestPickAssetNoMatch(t *testing.T) { + if _, url, _ := pickAsset(relWith("ajean-plan9-386", "SHA256SUMS")); url != "" { + t.Fatalf("URL inattendue: %q", url) + } +} diff --git a/internal/jean/ui/index.html b/internal/ajean/ui/index.html similarity index 100% rename from internal/jean/ui/index.html rename to internal/ajean/ui/index.html diff --git a/internal/jean/ui/marked.min.js b/internal/ajean/ui/marked.min.js similarity index 100% rename from internal/jean/ui/marked.min.js rename to internal/ajean/ui/marked.min.js diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html similarity index 100% rename from internal/jean/ui/src/index.tmpl.html rename to internal/ajean/ui/src/index.tmpl.html diff --git a/internal/jean/ui/src/js/00-md.js b/internal/ajean/ui/src/js/00-md.js similarity index 100% rename from internal/jean/ui/src/js/00-md.js rename to internal/ajean/ui/src/js/00-md.js diff --git a/internal/jean/ui/src/js/01-theme.js b/internal/ajean/ui/src/js/01-theme.js similarity index 100% rename from internal/jean/ui/src/js/01-theme.js rename to internal/ajean/ui/src/js/01-theme.js diff --git a/internal/jean/ui/src/js/02-prefs.js b/internal/ajean/ui/src/js/02-prefs.js similarity index 100% rename from internal/jean/ui/src/js/02-prefs.js rename to internal/ajean/ui/src/js/02-prefs.js diff --git a/internal/jean/ui/src/js/03-ui-kit.js b/internal/ajean/ui/src/js/03-ui-kit.js similarity index 100% rename from internal/jean/ui/src/js/03-ui-kit.js rename to internal/ajean/ui/src/js/03-ui-kit.js diff --git a/internal/jean/ui/src/js/04-api.js b/internal/ajean/ui/src/js/04-api.js similarity index 100% rename from internal/jean/ui/src/js/04-api.js rename to internal/ajean/ui/src/js/04-api.js diff --git a/internal/jean/ui/src/js/05-status.js b/internal/ajean/ui/src/js/05-status.js similarity index 100% rename from internal/jean/ui/src/js/05-status.js rename to internal/ajean/ui/src/js/05-status.js diff --git a/internal/jean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js similarity index 100% rename from internal/jean/ui/src/js/06-settings.js rename to internal/ajean/ui/src/js/06-settings.js diff --git a/internal/jean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js similarity index 100% rename from internal/jean/ui/src/js/07-models.js rename to internal/ajean/ui/src/js/07-models.js diff --git a/internal/jean/ui/src/js/08-chat-render.js b/internal/ajean/ui/src/js/08-chat-render.js similarity index 100% rename from internal/jean/ui/src/js/08-chat-render.js rename to internal/ajean/ui/src/js/08-chat-render.js diff --git a/internal/jean/ui/src/js/09-stream.js b/internal/ajean/ui/src/js/09-stream.js similarity index 100% rename from internal/jean/ui/src/js/09-stream.js rename to internal/ajean/ui/src/js/09-stream.js diff --git a/internal/jean/ui/src/js/10-llamacpp.js b/internal/ajean/ui/src/js/10-llamacpp.js similarity index 100% rename from internal/jean/ui/src/js/10-llamacpp.js rename to internal/ajean/ui/src/js/10-llamacpp.js diff --git a/internal/jean/ui/src/js/12-mcp.js b/internal/ajean/ui/src/js/12-mcp.js similarity index 100% rename from internal/jean/ui/src/js/12-mcp.js rename to internal/ajean/ui/src/js/12-mcp.js diff --git a/internal/jean/ui/src/js/13-remote.js b/internal/ajean/ui/src/js/13-remote.js similarity index 100% rename from internal/jean/ui/src/js/13-remote.js rename to internal/ajean/ui/src/js/13-remote.js diff --git a/internal/jean/ui/src/styles.css b/internal/ajean/ui/src/styles.css similarity index 100% rename from internal/jean/ui/src/styles.css rename to internal/ajean/ui/src/styles.css diff --git a/internal/jean/web_api.go b/internal/ajean/web_api.go similarity index 99% rename from internal/jean/web_api.go rename to internal/ajean/web_api.go index 70d35f8..fd0b98c 100644 --- a/internal/jean/web_api.go +++ b/internal/ajean/web_api.go @@ -1,6 +1,6 @@ // web_api.go — handlers REST /api/* (statut, config, presets, modèles, // mémoire, agent, clés, bench…) du serveur web local. -package jean +package ajean import ( "encoding/json" @@ -169,7 +169,7 @@ func handleBackends(w http.ResponseWriter, r *http.Request) { // (essaie les sous-chemins des différents générateurs CMake). Renvoie // [{name, path}] — un par dossier de backend contenant un binaire. func listBackendBins() []map[string]any { - root := JeanHome() + "/backends" + root := AjeanHome() + "/backends" entries, err := os.ReadDir(root) if err != nil { return []map[string]any{} @@ -269,7 +269,7 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) { func handleModels(w http.ResponseWriter, r *http.Request) { out := []map[string]any{} seen := map[string]bool{} - home := JeanHome() + home := AjeanHome() for _, dir := range modelDirs() { entries, err := os.ReadDir(dir) if err != nil { diff --git a/internal/jean/web_auth.go b/internal/ajean/web_auth.go similarity index 96% rename from internal/jean/web_auth.go rename to internal/ajean/web_auth.go index 66013e8..ae0e7bd 100644 --- a/internal/jean/web_auth.go +++ b/internal/ajean/web_auth.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "crypto/rand" @@ -22,7 +22,7 @@ import ( // 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") } +func webKeyPath() string { return filepath.Join(AjeanHome(), ".web_key") } // readWebKey returns the trimmed contents of $JEAN_HOME/.web_key, or "" if the // file is absent/empty. @@ -98,7 +98,7 @@ func cmdSetWebKey(args []string) error { 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 { + if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } if err := os.WriteFile(webKeyPath(), []byte(key+"\n"), 0o600); err != nil { diff --git a/internal/jean/web_chat.go b/internal/ajean/web_chat.go similarity index 99% rename from internal/jean/web_chat.go rename to internal/ajean/web_chat.go index ec7bb87..aa211c0 100644 --- a/internal/jean/web_chat.go +++ b/internal/ajean/web_chat.go @@ -1,6 +1,6 @@ // web_chat.go — endpoints de chat du serveur web local : envoi/stop/reset, // flux SSE d'abonnement à la conversation serveur (voir chat_conversation.go). -package jean +package ajean import ( "context" diff --git a/internal/jean/web_link_api.go b/internal/ajean/web_link_api.go similarity index 99% rename from internal/jean/web_link_api.go rename to internal/ajean/web_link_api.go index 9b137ea..ef71886 100644 --- a/internal/jean/web_link_api.go +++ b/internal/ajean/web_link_api.go @@ -1,4 +1,4 @@ -package jean +package ajean // web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI // web de Jean, pour éviter le terminal. Le panneau « Accès distant » ouvre une diff --git a/internal/jean/web_llamacpp.go b/internal/ajean/web_llamacpp.go similarity index 99% rename from internal/jean/web_llamacpp.go rename to internal/ajean/web_llamacpp.go index 0232ac1..c983a6b 100644 --- a/internal/jean/web_llamacpp.go +++ b/internal/ajean/web_llamacpp.go @@ -8,7 +8,7 @@ // POST /api/llamacpp/install → job d'installation {force?} // POST /api/llamacpp/update → job de mise à jour {clean?} // GET /api/llamacpp/job?from= → progression du job (phase, lignes, erreur) -package jean +package ajean import ( "encoding/json" @@ -166,7 +166,7 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) { "bin": pbBin, "in_use": pbBin != "" && samePath(pbBin, cfgBin), } - out["backends_dir"] = filepath.Join(JeanHome(), "backends") + out["backends_dir"] = filepath.Join(AjeanHome(), "backends") out["job"] = lcJobSnapshot(0, false) sendJSON(w, 200, out) } diff --git a/internal/jean/web_prefs.go b/internal/ajean/web_prefs.go similarity index 96% rename from internal/jean/web_prefs.go rename to internal/ajean/web_prefs.go index 424e0be..9c10988 100644 --- a/internal/jean/web_prefs.go +++ b/internal/ajean/web_prefs.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "encoding/json" @@ -15,7 +15,7 @@ import ( // Avant ça, elles ne vivaient qu'en localStorage, donc par navigateur/appareil. // webPrefsPath est le fichier JSON des préférences d'apparence. -func webPrefsPath() string { return filepath.Join(JeanHome(), "webprefs.json") } +func webPrefsPath() string { return filepath.Join(AjeanHome(), "webprefs.json") } var webPrefsMu sync.Mutex diff --git a/internal/jean/web_server.go b/internal/ajean/web_server.go similarity index 99% rename from internal/jean/web_server.go rename to internal/ajean/web_server.go index b13939a..0b8d514 100644 --- a/internal/jean/web_server.go +++ b/internal/ajean/web_server.go @@ -1,4 +1,4 @@ -package jean +package ajean import ( "bufio" diff --git a/internal/jean/web_sysprompt.go b/internal/ajean/web_sysprompt.go similarity index 95% rename from internal/jean/web_sysprompt.go rename to internal/ajean/web_sysprompt.go index 1902821..5722740 100644 --- a/internal/jean/web_sysprompt.go +++ b/internal/ajean/web_sysprompt.go @@ -5,7 +5,7 @@ // /api/chat/send ne porte que le message → le champ de l'UI n'avait plus aucun // effet. Il est maintenant lu ici par la génération (chat_conversation.go), et // InjectSkills (llm_client.go) le fusionne avec le préambule agent. -package jean +package ajean import ( "encoding/json" @@ -15,7 +15,7 @@ import ( "strings" ) -func sysPromptPath() string { return filepath.Join(JeanHome(), "sysprompt.txt") } +func sysPromptPath() string { return filepath.Join(AjeanHome(), "sysprompt.txt") } // readSysPrompt renvoie le prompt système personnalisé ("" si absent). func readSysPrompt() string { diff --git a/tools/gen-icon/main.go b/tools/gen-icon/main.go index d90b24f..469a2c3 100644 --- a/tools/gen-icon/main.go +++ b/tools/gen-icon/main.go @@ -11,7 +11,7 @@ import ( "os" "strings" - "github.com/nathaninline/jean/internal/jean" + "github.com/nathaninline/ajean/internal/ajean" ) func main() { @@ -21,13 +21,13 @@ func main() { } // Les tailles attendues par l'explorateur Windows, de la barre des tâches à // l'aperçu en grandes icônes. - write(out, jean.BrandICO(16, 32, 48, 64, 128, 256)) + write(out, ajean.BrandICO(16, 32, 48, 64, 128, 256)) // PNG 1024 pour le bundle macOS : la CI en tire le .icns via sips/iconutil. // Elle partait auparavant du .ico, ce qui suppose que sips sache lire un ICO — // et l'échec y est masqué par « || true », donc une app Mac sans icône passait // inaperçue. Un PNG ne laisse aucune place au doute. - write(strings.TrimSuffix(out, ".ico")+".png", jean.BrandIconPNG(1024)) + write(strings.TrimSuffix(out, ".ico")+".png", ajean.BrandIconPNG(1024)) } func write(path string, data []byte) { From 3720b9017365cecf4b24e7c15b6bfb7756611069 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 14:07:44 +0200 Subject: [PATCH 197/279] Renommage AJEAN (2/3) : services, binaire et releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite du renommage. Principe directeur : le code s'adapte a ce qui est DEJA installe sur la machine, plutot que d'exiger une migration privilegiee qui pourrait echouer et laisser un serveur sans service. Services (sys_unitname.go). Une mise a jour remplace le binaire, pas les unites systemd : un serveur deja installe tourne encore sous jean.service et jean-link.service. serviceName() et linkServiceName() resolvent donc le nom a l'usage — l'unite qui existe reellement — au lieu de supposer "ajean*". Sans ca, le premier systemctl restart apres MAJ echouerait, et sur un serveur distant ca veut dire l'acces coupe sans terminal pour reparer. Meme logique pour le label launchd, ou le prefixe com.jean. est conserve tant que c'est lui qui est charge. Le fichier PID du worker de lien est repris au passage (jean-link.pid -> ajean-link.pid) : c'est lui qui dit si un worker tourne, et sans reprise la premiere version renommee en aurait demarre un second, donnant deux tunnels concurrents vers le relais. Binaire. installedExePath vise desormais ajean(.exe), et l'installation pose un alias "jean" a cote — lien symbolique sous Unix, copie sous Windows ou les liens demandent des droits qu'on n'a pas toujours. La commande d'hier reste tapable : on ne controle ni les alias shell, ni les cron, ni les scripts de deploiement, ni les tutos deja ecrits. /etc/default/ajean est ecrit avec AJEAN_HOME ET JEAN_HOME, et l'ancien /etc/default/jean est laisse en place plutot que supprime : des scripts d'utilisateurs le sourcent. Releases. Les workflows produisent ajean-- puis en copient un jeu jean-- identique, une fois les binaires macOS recuperes. Les deux jeux sont dans SHA256SUMS. Ces copies sont indispensables : le parc installe ne sait chercher que jean-*, il ne pourrait donc meme pas atteindre la version qui apprend a lire ajean-*. A retirer quand le parc aura bascule. Le bundle devient AJEAN.app, avec le zip publie sous les deux noms pour ne pas casser les liens de telechargement deja diffuses. La detection du mode application se basant sur /Contents/MacOS et non sur le nom du bundle, elle n'est pas affectee. --- .github/workflows/mac-build.yml | 24 +++++------ .github/workflows/release.yml | 52 +++++++++++++++-------- internal/ajean/relay_link.go | 59 ++++++++++++++++++++------- internal/ajean/run.go | 14 ++++++- internal/ajean/sys_install_darwin.go | 31 +++++++------- internal/ajean/sys_install_linux.go | 51 +++++++++++++++-------- internal/ajean/sys_install_windows.go | 51 ++++++++++++++++------- internal/ajean/sys_paths.go | 18 +++++--- internal/ajean/sys_service_darwin.go | 21 +++++++++- internal/ajean/sys_unitname.go | 49 ++++++++++++++++++++++ internal/ajean/sys_update.go | 4 +- 11 files changed, 275 insertions(+), 99 deletions(-) create mode 100644 internal/ajean/sys_unitname.go diff --git a/.github/workflows/mac-build.yml b/.github/workflows/mac-build.yml index caee71d..52fb76a 100644 --- a/.github/workflows/mac-build.yml +++ b/.github/workflows/mac-build.yml @@ -1,4 +1,4 @@ -# Build macOS à la demande, SANS rien publier : produit Jean.app en artifact +# Build macOS à la demande, SANS rien publier : produit AJEAN.app en artifact # téléchargeable depuis l'onglet Actions (visible seulement par le dépôt). # Sert à tester le bundle avant de décider d'une vraie release (release.yml). name: mac-build @@ -13,38 +13,38 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: build + bundle Jean.app (arm64) + - name: build + bundle AJEAN.app (arm64) run: | mkdir -p dist CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ - go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-arm64 ./cmd/jean + go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean mkdir -p /tmp/jean.iconset sips -s format png 'cmd/jean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true for s in 16 32 128 256 512; do sips -z $s $s /tmp/icon.png --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true done iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns >/dev/null 2>&1 || true - app="dist/Jean.app" + app="dist/AJEAN.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" - cp dist/jean-darwin-arm64 "$app/Contents/MacOS/jean" - chmod +x "$app/Contents/MacOS/jean" + cp dist/ajean-darwin-arm64 "$app/Contents/MacOS/ajean" + chmod +x "$app/Contents/MacOS/ajean" cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true printf '%s\n' \ '' \ '' \ '' \ - 'CFBundleNameJean' \ - 'CFBundleDisplayNameJean' \ - 'CFBundleExecutablejean' \ - 'CFBundleIdentifierlink.ajean.jean' \ - 'CFBundleIconFilejean' \ + 'CFBundleNameAJEAN' \ + 'CFBundleDisplayNameAJEAN' \ + 'CFBundleExecutableajean' \ + 'CFBundleIdentifierlink.ajean.app' \ + 'CFBundleIconFileajean' \ 'CFBundlePackageTypeAPPL' \ 'LSMinimumSystemVersion11.0' \ 'LSUIElement' \ 'NSHighResolutionCapable' \ '' > "$app/Contents/Info.plist" codesign --force --deep -s - "$app" || true - (cd dist && zip -qry Jean-macos-arm64.zip Jean.app && rm -rf Jean.app) + (cd dist && zip -qry AJEAN-macos-arm64.zip AJEAN.app && rm -rf AJEAN.app) ls -l dist - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b09f1f..c98b884 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,11 +29,11 @@ jobs: mkdir -p dist # arm64 = natif ; amd64 = cross avec le SDK universel (-arch x86_64). CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ - go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-arm64 ./cmd/jean + go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \ CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \ - go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-amd64 ./cmd/jean - file dist/jean-darwin-* + go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-amd64 ./cmd/ajean + file dist/ajean-darwin-* - name: bundles Jean.app run: | # Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc @@ -43,29 +43,29 @@ jobs: # la barre de menus), comme le double-clic sur jean.exe sous Windows. # LSUIElement : app de barre de menus, aucune icône dans le Dock. # L'icône part du PNG 1024 généré avec le reste de la marque - # (cmd/jean/icon.png, voir tools/gen-icon) : plus de conversion depuis + # (cmd/ajean/icon.png, voir tools/gen-icon) : plus de conversion depuis # le .ico, dont l'échec silencieux donnait une app sans icône. mkdir -p /tmp/jean.iconset for s in 16 32 128 256 512; do - sips -z $s $s 'cmd/jean/icon.png' --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null + sips -z $s $s 'cmd/ajean/icon.png' --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null done iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns v="${GITHUB_REF_NAME#v}" for arch in amd64 arm64; do - app="dist/mac-$arch/Jean.app" + app="dist/mac-$arch/AJEAN.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" - cp "dist/jean-darwin-$arch" "$app/Contents/MacOS/jean" - chmod +x "$app/Contents/MacOS/jean" - cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true + cp "dist/ajean-darwin-$arch" "$app/Contents/MacOS/ajean" + chmod +x "$app/Contents/MacOS/ajean" + cp /tmp/jean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true printf '%s\n' \ '' \ '' \ '' \ - 'CFBundleNameJean' \ - 'CFBundleDisplayNameJean' \ - 'CFBundleExecutablejean' \ - 'CFBundleIdentifierlink.ajean.jean' \ - 'CFBundleIconFilejean' \ + 'CFBundleNameAJEAN' \ + 'CFBundleDisplayNameAJEAN' \ + 'CFBundleExecutableajean' \ + 'CFBundleIdentifierlink.ajean.app' \ + 'CFBundleIconFileajean' \ 'CFBundlePackageTypeAPPL' \ "CFBundleShortVersionString$v" \ "CFBundleVersion$v" \ @@ -76,7 +76,11 @@ jobs: # Signature ad-hoc : sans elle, macOS refuse purement et simplement # de lancer un binaire arm64 non signé (pas juste un avertissement). codesign --force --deep -s - "$app" || true - (cd "dist/mac-$arch" && zip -qry "../Jean-macos-$arch.zip" Jean.app) + (cd "dist/mac-$arch" && zip -qry "../AJEAN-macos-$arch.zip" AJEAN.app) + # Nom hérité conservé : les liens de téléchargement déjà publiés + # (site, mails, messages) pointent dessus et doivent continuer de + # fonctionner. Même contenu, deux noms. + cp "dist/AJEAN-macos-$arch.zip" "dist/Jean-macos-$arch.zip" rm -rf "dist/mac-$arch" done ls -l dist @@ -102,16 +106,28 @@ jobs: # (l'app se rattache à la console du terminal parent si lancée en CLI). [ "$os" = windows ] && { ext=".exe"; ldflags="$ldflags -H=windowsgui"; } GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ - go build -trimpath -ldflags="$ldflags" -o "dist/jean-$os-$arch$ext" ./cmd/jean + go build -trimpath -ldflags="$ldflags" -o "dist/ajean-$os-$arch$ext" ./cmd/ajean done - uses: actions/download-artifact@v4 with: name: macos path: dist + - name: assets hérités jean-* (transition) + run: | + # Le parc déjà installé ne sait chercher QUE « jean-- » : sans + # ces copies, aucun des utilisateurs actuels ne pourrait se mettre à + # jour — pas même vers la version qui apprend à lire « ajean-* ». + # Après cette étape SEULEMENT, donc une fois les binaires macOS + # récupérés, les deux jeux de noms sont complets. + # À supprimer quand le parc aura basculé (quelques versions). + for f in dist/ajean-*; do + cp "$f" "dist/jean-${f#dist/ajean-}" + done + ls -l dist - name: SHA256SUMS run: | - chmod +x dist/jean-darwin-* - (cd dist && sha256sum jean-* Jean-macos-*.zip > SHA256SUMS) + chmod +x dist/ajean-darwin-* dist/jean-darwin-* + (cd dist && sha256sum ajean-* jean-* *.zip > SHA256SUMS) ls -l dist && cat dist/SHA256SUMS - name: publier la release uses: softprops/action-gh-release@v2 diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 2b02f0e..1b2580b 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -93,8 +93,16 @@ func relayURL() string { return defaultRelayURL } -// linkServiceName est l'unité systemd qui exécute le worker « jean link --foreground ». -const linkServiceName = "jean-link" +// linkUnitName / legacyLinkUnitName : l'unité systemd qui exécute le worker +// « ajean link --foreground », et son nom d'avant le renommage. +const ( + linkUnitName = "ajean-link" + legacyLinkUnitName = "jean-link" +) + +// linkServiceName renvoie l'unité de lien réellement installée sur la machine +// (voir sys_unitname.go : un serveur mis à jour tourne encore sous jean-link). +func linkServiceName() string { return resolveUnitName(linkUnitName, legacyLinkUnitName) } func cmdLink(args []string) error { sub := "" @@ -121,9 +129,9 @@ func cmdLink(args []string) error { } fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) if linkServiceActive() { - fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName) + fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName()) } else { - fmt.Printf("%s service %s: arrêté (jean link pour démarrer)\n", yellow("[info]"), linkServiceName) + fmt.Printf("%s service %s: arrêté (jean link pour démarrer)\n", yellow("[info]"), linkServiceName()) } return nil case "logout": @@ -174,7 +182,7 @@ func startLink(force bool) error { return err } case linkServiceActive(): - fmt.Printf("%s service %s déjà en cours — « jean link restart » pour le relancer\n", yellow("[info]"), linkServiceName) + fmt.Printf("%s service %s déjà en cours — « jean link restart » pour le relancer\n", yellow("[info]"), linkServiceName()) default: if err := linkServiceCtl("start"); err != nil { return err @@ -375,8 +383,29 @@ func serveLocalWebMux(mux *http.ServeMux) { // linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, // Windows), où Jean est une app de bureau lancée sans droits root. -func linkPIDPath() string { return filepath.Join(AjeanHome(), linkServiceName+".pid") } -func linkLogPath() string { return filepath.Join(AjeanHome(), linkServiceName+".log") } +func linkPIDPath() string { return adoptLegacyLinkFile(".pid") } +func linkLogPath() string { return adoptLegacyLinkFile(".log") } + +// adoptLegacyLinkFile renvoie le chemin « ajean-link » en reprenant au +// passage le « jean-link » laissé par une version antérieure. +// +// Reprendre le fichier PID n'est pas cosmétique : c'est LUI qui dit si un worker +// tourne déjà. Sans cette reprise, la première version renommée ne verrait plus +// le worker en cours, en démarrerait un second, et la machine se retrouverait +// avec deux tunnels concurrents vers le relais. Idempotent, silencieux en cas +// d'échec (on retombe simplement sur le nouveau nom). +func adoptLegacyLinkFile(ext string) string { + newPath := filepath.Join(AjeanHome(), linkUnitName+ext) + if _, err := os.Stat(newPath); err == nil { + return newPath + } + legacy := filepath.Join(AjeanHome(), legacyLinkUnitName+ext) + if _, err := os.Stat(legacy); err != nil { + return newPath + } + _ = os.Rename(legacy, newPath) + return newPath +} // linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est // l'unité systemd jean-link (avec sudo non interactif si on n'est pas root) ; @@ -411,16 +440,16 @@ func linkServiceCtl(action string) error { if os.Geteuid() != 0 { bin, pre = "sudo", []string{"-n", "systemctl"} } - cmd := exec.Command(bin, append(pre, action, linkServiceName)...) + cmd := exec.Command(bin, append(pre, action, linkServiceName())...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("systemctl %s %s: %w", action, linkServiceName, err) + return fmt.Errorf("systemctl %s %s: %w", action, linkServiceName(), err) } switch action { case "start", "restart": - fmt.Printf("%s service %s %s\n", green("[ok]"), linkServiceName, action+"é") + fmt.Printf("%s service %s %s\n", green("[ok]"), linkServiceName(), action+"é") case "stop": - fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName) + fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName()) } return nil } @@ -434,7 +463,7 @@ func linkServiceActive() bool { } return linkUserPID() > 0 } - out, _ := exec.Command("systemctl", "is-active", linkServiceName).Output() + out, _ := exec.Command("systemctl", "is-active", linkServiceName()).Output() return strings.TrimSpace(string(out)) == "active" } @@ -467,12 +496,12 @@ func linkUserSvcCtl(action string) error { } _ = os.Remove(linkPIDPath()) if action == "stop" { - fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName) + fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName()) return nil } case "start": if linkUserPID() > 0 { - fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName) + fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName()) return nil } default: @@ -516,7 +545,7 @@ func linkUserSvcCtl(action string) error { _ = os.Remove(linkPIDPath()) return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", linkLogPath()) } - fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), linkServiceName, pid) + fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), linkServiceName(), pid) return nil } diff --git a/internal/ajean/run.go b/internal/ajean/run.go index b526c94..cfc0f97 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -234,13 +234,25 @@ func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } func legacyToolsFlag() string { return filepath.Join(AjeanHome(), ".tools_enabled") } func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") } func crawlKeyPath() string { return filepath.Join(AjeanHome(), ".crawl4ai_key") } +// serviceName est le nom de l'unité qui exécute llama-server. AJEAN_SERVICE et +// JEAN_SERVICE sont tous deux honorés : le second est posé à la main sur des +// machines qu'on ne voit pas, le renommage ne doit pas les casser. func serviceName() string { + if n := os.Getenv("AJEAN_SERVICE"); n != "" { + return n + } if n := os.Getenv("JEAN_SERVICE"); n != "" { return n } - return "jean" + // Pas de nom imposé : on prend l'unité réellement installée. Un serveur + // simplement mis à jour tourne encore sous « jean.service » — voir + // sys_unitname.go. + return resolveUnitName("ajean", legacyServiceName()) } +// legacyServiceName est le nom d'avant le renommage. +func legacyServiceName() string { return "jean" } + // Color helpers (ANSI). Disabled when stdout is not a TTY. var colorOn = isTerminal() diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 56f55d8..404e83d 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -81,25 +81,25 @@ func cmdInstall(args []string) error { if err != nil { return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) } - jeanHome := defaultJeanHome() + ajeanHome := defaultAjeanHome() if v := os.Getenv("JEAN_HOME"); v != "" { - jeanHome = v + ajeanHome = v } svc := serviceName() fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" JEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s (launchd)\n", svc) // 1. Répertoires - for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + for _, d := range []string{ajeanHome, filepath.Join(ajeanHome, "configs"), filepath.Join(ajeanHome, "SKILLS")} { if err := os.MkdirAll(d, 0o755); err != nil { return err } } // 2. config.env si absent - conf := filepath.Join(jeanHome, "config.env") + conf := filepath.Join(ajeanHome, "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 { @@ -137,17 +137,19 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) } - // 4. /etc/default/jean pour que les invocations CLI résolvent JEAN_HOME. + // 4. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME. + // Les deux clés sont écrites : des scripts d'utilisateurs sourcent ce fichier + // et lisent encore $JEAN_HOME. _ = os.MkdirAll("/etc/default", 0o755) - 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 { + defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome) + if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { return err } - fmt.Printf(" %s /etc/default/jean\n", green("✓")) + fmt.Printf(" %s /etc/default/ajean\n", green("✓")) // 5. LaunchDaemon plist (le log va sous JEAN_HOME, accessible au user cible) - logPath := filepath.Join(jeanHome, svc+".log") - plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, jeanHome, jeanHome, logPath, logPath) + logPath := filepath.Join(ajeanHome, svc+".log") + plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, ajeanHome, ajeanHome, logPath, logPath) plistPath := launchdPlistPath(svc) if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { return err @@ -155,7 +157,7 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s\n", green("✓"), plistPath) // 6. chown JEAN_HOME au user cible - chown(jeanHome, u) + chown(ajeanHome, u) fmt.Println() fmt.Printf("%s installation terminée.\n", green("[ok]")) @@ -175,8 +177,9 @@ func cmdUninstall(args []string) error { _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run() for _, p := range []string{ launchdPlistPath(svc), - "/etc/default/jean", - "/usr/local/bin/jean", + "/etc/default/ajean", + installedExePath(), + legacyExePath(), } { if err := os.Remove(p); err == nil { fmt.Printf(" %s %s\n", green("✓"), p) diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index d53fedc..2d3f0b0 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -76,25 +76,25 @@ func cmdInstall(args []string) error { if err != nil { return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) } - jeanHome := defaultJeanHome() + ajeanHome := defaultAjeanHome() if v := os.Getenv("JEAN_HOME"); v != "" { - jeanHome = v + ajeanHome = v } svc := serviceName() fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" JEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s\n", svc) // 1. Create directories - for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + for _, d := range []string{ajeanHome, filepath.Join(ajeanHome, "configs"), filepath.Join(ajeanHome, "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") + conf := filepath.Join(ajeanHome, "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 { @@ -111,7 +111,7 @@ func cmdInstall(args []string) error { if rp, e := filepath.EvalSymlinks(self); e == nil { self = rp } - target := "/usr/local/bin/jean" + target := installedExePath() // Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a // posé le binaire dans /usr/local/bin/jean puis lancé `sudo jean install`), // ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire @@ -134,15 +134,34 @@ func cmdInstall(args []string) error { 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 { + // 3 bis. Alias hérité : `jean` doit rester tapable. Des utilisateurs ont des + // alias shell, des cron, des scripts de déploiement et des tutos qui appellent + // « jean » — on ne contrôle rien de tout ça, et un binaire renommé qui casse + // la commande d'hier est une régression pour eux. Le lien pointe sur le + // nouveau nom, donc il suit les mises à jour sans entretien. + if alias := legacyExePath(); alias != target { + if rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self { + _ = os.Remove(alias) + if err := os.Symlink(target, alias); err != nil { + fmt.Printf(" %s alias %s non créé (%v) — « ajean » reste disponible\n", yellow("[info]"), alias, err) + } else { + fmt.Printf(" %s %s -> %s (alias hérité)\n", green("✓"), alias, target) + } + } + } + + // 4. Drop /etc/default/ajean so root invocations resolve AJEAN_HOME correctly. + // On écrit les DEUX clés : des scripts d'utilisateurs sourcent ce fichier et + // lisent $JEAN_HOME. L'ancien /etc/default/jean est laissé en place — le + // supprimer casserait ces mêmes scripts, et AjeanHome() sait déjà le lire. + defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome) + if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { return err } - fmt.Printf(" %s /etc/default/jean\n", green("✓")) + fmt.Printf(" %s /etc/default/ajean\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") + // 5. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed) + unit := fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve") unitPath := "/etc/systemd/system/" + svc + ".service" if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { return err @@ -151,14 +170,14 @@ func cmdInstall(args []string) error { // 5. Sudoers drop-in sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) - sudoersPath := "/etc/sudoers.d/jean-" + svc + sudoersPath := "/etc/sudoers.d/ajean-" + 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) + chown(ajeanHome, u) // 7. systemd reload _ = exec.Command("systemctl", "daemon-reload").Run() @@ -191,9 +210,9 @@ func cmdUninstall(args []string) error { _ = exec.Command("systemctl", "disable", svc).Run() for _, p := range []string{ "/etc/systemd/system/" + svc + ".service", - "/etc/sudoers.d/jean-" + svc, + "/etc/sudoers.d/ajean-" + svc, "/etc/default/jean", - "/usr/local/bin/jean", + installedExePath(), } { if err := os.Remove(p); err == nil { fmt.Printf(" %s %s\n", green("✓"), p) diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index 9aae2dc..4cfd47e 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -97,14 +97,15 @@ func cmdInstall(args []string) error { return nil } -// installSelf copies the currently running executable into binDir as jean.exe +// installSelf copies the currently running executable into binDir as ajean.exe // and returns the destination path. If the running exe already lives there -// (re-install), it's a no-op. +// (re-install), it's a no-op. Une copie « jean.exe » est posée à côté pour que +// l'ancienne commande reste tapable (voir installLegacyAlias). // // binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge, // le premier lancement passait par appFirstRun, qui ne créait que le dossier de // données et pas son sous-dossier bin. L'utilisateur recevait alors « open -// C:\ProgramData\jean\bin\jean.exe: The system cannot find the path specified » +// C:\ProgramData\ajean\bin\ajean.exe: The system cannot find the path specified » // et AJEAN démarrait depuis le fichier téléchargé, sans jamais s'installer. func installSelf(binDir string) (string, error) { src, err := os.Executable() @@ -112,32 +113,54 @@ func installSelf(binDir string) (string, error) { return "", err } src, _ = filepath.EvalSymlinks(src) - dst := filepath.Join(binDir, "jean.exe") + dst := filepath.Join(binDir, "ajean.exe") if strings.EqualFold(src, dst) { + installLegacyAlias(binDir, src) return dst, nil } if err := os.MkdirAll(binDir, 0o755); err != nil { return "", err } - in, err := os.Open(src) - if err != nil { + if err := copyExe(src, dst); err != nil { return "", err } + installLegacyAlias(binDir, dst) + return dst, nil +} + +// installLegacyAlias pose une copie « jean.exe » à côté de « ajean.exe ». +// +// Une copie et pas un lien : créer un lien symbolique sous Windows demande des +// droits particuliers qu'on n'a pas toujours, et un binaire de quelques dizaines +// de Mo dupliqué est un prix dérisoire pour ne casser ni les raccourcis du menu +// Démarrer, ni les tâches planifiées, ni les scripts que les utilisateurs ont +// écrits eux-mêmes. Best-effort : un échec ne compromet pas l'installation, +// « ajean » reste disponible. +func installLegacyAlias(binDir, src string) { + alias := filepath.Join(binDir, "jean.exe") + if strings.EqualFold(src, alias) { + return + } + // Échoue si un « jean.exe » est en cours d'exécution — sans gravité, il s'agit + // alors déjà d'une version d'AJEAN, et la copie repassera au prochain lancement. + _ = copyExe(src, alias) +} + +func copyExe(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } defer in.Close() - // Can't overwrite a running exe, but jean.exe in binDir isn't the one we're - // running (src != dst here), so a plain create is fine. out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) if err != nil { - return "", err + return err } if _, err := io.Copy(out, in); err != nil { out.Close() - return "", err + return err } - if err := out.Close(); err != nil { - return "", err - } - return dst, nil + return out.Close() } // addToUserPath appends dir to the per-user PATH (HKCU\Environment) persistently, diff --git a/internal/ajean/sys_paths.go b/internal/ajean/sys_paths.go index a7ec1e8..7ef148d 100644 --- a/internal/ajean/sys_paths.go +++ b/internal/ajean/sys_paths.go @@ -26,14 +26,22 @@ type ajeanPaths struct { } // installedExePath est l'emplacement canonique du binaire après installation. -// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/jean +// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/ajean // (c'est ce que référencent les unités systemd et le plist launchd) ; sous Windows, -// faute d'équivalent, on utilise JEAN_HOME\bin ajouté au PATH utilisateur. -func installedExePath() string { +// faute d'équivalent, on utilise AJEAN_HOME\bin ajouté au PATH utilisateur. +func installedExePath() string { return exePathNamed("ajean") } + +// legacyExePath est l'emplacement d'avant le renommage. L'installation continue +// d'y poser un alias : `jean` reste tapable indéfiniment. Ça ne coûte qu'un lien +// et ça préserve les habitudes, les scripts cron, les alias shell et les tutos +// déjà écrits par les utilisateurs — dont on ne contrôle aucun. +func legacyExePath() string { return exePathNamed("jean") } + +func exePathNamed(name string) string { if runtime.GOOS == "windows" { - return filepath.Join(AjeanHome(), "bin", "jean.exe") + return filepath.Join(AjeanHome(), "bin", name+".exe") } - return "/usr/local/bin/jean" + return "/usr/local/bin/" + name } func currentPaths() ajeanPaths { diff --git a/internal/ajean/sys_service_darwin.go b/internal/ajean/sys_service_darwin.go index 4a167f5..c277e46 100644 --- a/internal/ajean/sys_service_darwin.go +++ b/internal/ajean/sys_service_darwin.go @@ -18,8 +18,25 @@ import ( // le support macOS était absent (le code systemd/Linux était utilisé par erreur, // cf. issue #4). Implémentation prudente basée sur launchctl load/unload/list. -// launchdLabel dérive le label launchd du service (ex. "com.jean.jean"). -func launchdLabel(svc string) string { return "com.jean." + svc } +// launchdLabel dérive le label launchd du service (ex. "com.ajean.ajean"), en +// conservant le préfixe hérité « com.jean. » tant que c'est LUI qui est +// réellement installé : le plist d'un daemon déjà chargé ne se renomme pas tout +// seul, et viser le mauvais label reviendrait à ne plus voir le service. +func launchdLabel(svc string) string { + modern := "com.ajean." + svc + if _, err := os.Stat("/Library/LaunchDaemons/" + modern + ".plist"); err == nil { + return modern + } + if legacy := "com.jean." + svc; fileExists("/Library/LaunchDaemons/" + legacy + ".plist") { + return legacy + } + return modern +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} // launchdPlistPath : chemin du LaunchDaemon (domaine système, exécuté par root // puis abaissé à l'utilisateur cible via la clé UserName du plist). diff --git a/internal/ajean/sys_unitname.go b/internal/ajean/sys_unitname.go new file mode 100644 index 0000000..711e81f --- /dev/null +++ b/internal/ajean/sys_unitname.go @@ -0,0 +1,49 @@ +package ajean + +import "os" + +// Nom des unités système, pendant et après le renommage jean → ajean. +// +// Le problème : une mise à jour remplace le BINAIRE, pas les unités systemd. +// Un serveur déjà installé continue donc de tourner sous « jean.service » et +// « jean-link.service », qui pointent sur /usr/local/bin/jean. Si le binaire +// renommé partait du principe que les unités s'appellent désormais « ajean* », +// le premier `systemctl restart` échouerait — et sur un serveur distant, ça veut +// dire l'accès coupé, sans terminal pour réparer. +// +// La solution retenue n'est PAS de migrer les unités au démarrage (il faudrait +// root, et un échec laisserait la machine sans service). C'est de résoudre le +// nom à l'usage : on utilise l'unité qui existe réellement. Une machine ancienne +// reste sur ses unités « jean* » et fonctionne ; une machine fraîchement +// installée — ou réinstallée via `ajean install` — utilise les « ajean* ». +// Aucune fenêtre pendant laquelle plus rien ne tourne. + +// unitExists dit si un service de ce nom est installé sur la machine : unité +// systemd sous Linux, daemon launchd sous macOS (repéré par son plist, sous +// l'un ou l'autre des deux préfixes de label). Toujours faux sous Windows, où +// le « service » est un simple processus détaché suivi par un fichier PID. +func unitExists(name string) bool { + paths := []string{ + "/etc/systemd/system/" + name + ".service", + "/Library/LaunchDaemons/com.ajean." + name + ".plist", + "/Library/LaunchDaemons/com.jean." + name + ".plist", + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +// resolveUnitName renvoie le nom d'unité à utiliser : le nouveau s'il est +// installé, sinon l'ancien s'il l'est, sinon le nouveau (installation à venir). +func resolveUnitName(modern, legacy string) string { + if unitExists(modern) { + return modern + } + if unitExists(legacy) { + return legacy + } + return modern +} diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index f91e5c0..9717199 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -420,9 +420,9 @@ func restartAfterUpdate() (bool, string) { time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client // --no-block : on enregistre le job puis on rend la main ; systemd exécute le // stop/start même si ce process (et le client systemctl) sont tués entre-temps. - _ = exec.Command("systemctl", "--no-block", "restart", linkServiceName).Run() + _ = exec.Command("systemctl", "--no-block", "restart", linkServiceName()).Run() }() - return true, "Service " + linkServiceName + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)." + return true, "Service " + linkServiceName() + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)." } func restartHintText() string { From 993be881d56254756867bae7e170ec5f905ebbe6 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 14:16:12 +0200 Subject: [PATCH 198/279] Renommage AJEAN (3/3) : textes, aide, README et variables d'environnement Surface visible passee en AJEAN : aide de la CLI, messages, invite du chat terminal, commentaires, README, metadonnees Windows du binaire (InternalName / OriginalFilename). Les reglages s'appellent desormais AJEAN_* (AJEAN_HOME, AJEAN_MODEL_DIRS, AJEAN_DL_CONNS, AJEAN_LINK_URL, AJEAN_ACME_EMAIL, AJEAN_SERVICE), avec l'ancien nom JEAN_* toujours lu en second via envAny(). Ces variables sont posees a la main dans des shells, des units, des docker-compose et des scripts de deploiement qu'on ne peut pas retrouver : cesser de les lire ferait retomber silencieusement un reglage sur sa valeur par defaut. Migration one-shot du dossier de donnees retentee dans la fenetre ou l'installateur Windows a arrete toutes les instances, seule fenetre ou le rename peut aboutir. Le chemin du binaire est recalcule juste apres, sinon on reinstallerait dans le dossier qu'on vient de deplacer. Deux choses volontairement NON renommees, parce que les renommer casserait des utilisateurs sans rien apporter : - "model": "jean", le nom expose sur l'endpoint compatible OpenAI. Des integrations externes le configurent en dur. - les cles localStorage (jean-theme, jean.sys...). Les renommer reinitialiserait theme, preferences d'affichage et prompt systeme dans le navigateur de chaque utilisateur. Le plist launchd recevait /usr/local/bin/jean en dur : le chemin est desormais injecte, sinon le daemon macOS aurait refuse de demarrer apres le renommage. index.html regenere depuis ui/src via go generate. --- README.md | 110 ++++++++++---------- RELEASE_NOTES.md | 2 +- cmd/ajean/versioninfo.json | 4 +- internal/ajean/backend_build.go | 6 +- internal/ajean/backend_config.go | 8 +- internal/ajean/backend_gpu.go | 14 +-- internal/ajean/backend_llamacpp.go | 22 ++-- internal/ajean/backend_model_dirs.go | 4 +- internal/ajean/backend_models.go | 2 +- internal/ajean/backend_serve.go | 4 +- internal/ajean/chat_agent.go | 2 +- internal/ajean/chat_cmd.go | 10 +- internal/ajean/chat_internet_tools.go | 14 +-- internal/ajean/chat_memory.go | 4 +- internal/ajean/chat_workspace.go | 5 +- internal/ajean/llm_oai.go | 10 +- internal/ajean/llm_testcmd.go | 2 +- internal/ajean/relay_e2e.go | 2 +- internal/ajean/relay_e2eauth.go | 6 +- internal/ajean/relay_link.go | 58 +++++------ internal/ajean/run.go | 32 +++--- internal/ajean/sys_console_windows.go | 2 +- internal/ajean/sys_env.go | 23 ++++ internal/ajean/sys_firstrun_other.go | 2 +- internal/ajean/sys_firstrun_windows.go | 21 +++- internal/ajean/sys_firstrun_windows_test.go | 2 +- internal/ajean/sys_install_darwin.go | 46 +++++--- internal/ajean/sys_install_linux.go | 16 +-- internal/ajean/sys_install_windows.go | 16 +-- internal/ajean/sys_migrate.go | 20 ++++ internal/ajean/sys_paths.go | 2 +- internal/ajean/sys_platform_unix.go | 2 +- internal/ajean/sys_platform_windows.go | 8 +- internal/ajean/sys_service.go | 2 +- internal/ajean/sys_service_darwin.go | 16 +-- internal/ajean/sys_service_linux.go | 2 +- internal/ajean/sys_service_windows.go | 10 +- internal/ajean/sys_tray_windows.go | 2 +- internal/ajean/sys_update.go | 10 +- internal/ajean/ui/index.html | 2 +- internal/ajean/ui/src/index.tmpl.html | 2 +- internal/ajean/web_auth.go | 10 +- internal/ajean/web_link_api.go | 6 +- internal/ajean/web_server.go | 12 +-- 44 files changed, 319 insertions(+), 236 deletions(-) create mode 100644 internal/ajean/sys_env.go diff --git a/README.md b/README.md index ed52c0b..51d92e7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ AJEAN fournit tout ce qui entoure le modèle : l'interface de chat, les outils de l'assistant, la gestion du service et celle du matériel. Le moteur d'inférence est [llama.cpp](https://github.com/ggml-org/llama.cpp), qu'AJEAN compile lui-même pour la machine sur laquelle il tourne. ``` -télécharger le binaire → jean llamacpp install → jean edit → jean start → c'est parti +télécharger le binaire → ajean llamacpp install → ajean edit → ajean start → c'est parti ``` Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur. Vous obtenez une interface de chat complète et un endpoint compatible OpenAI pour vos outils tiers. @@ -18,7 +18,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur **Un assistant, pas seulement un modèle.** L'interface web offre un chat avec raisonnement affiché, mémoire persistante, compactage automatique du contexte quand la conversation s'allonge, prompt système éditable, et des réglages d'apparence synchronisés entre appareils. -**Des outils réels.** `jean agent on` active d'un seul coup toutes les capacités du modèle sur la machine : +**Des outils réels.** `ajean agent on` active d'un seul coup toutes les capacités du modèle sur la machine : | Outil | Rôle | |---|---| @@ -28,13 +28,13 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur | web_* | recherche et lecture de pages, via Crawl4AI | | mcp__* | les outils des serveurs MCP configurés | -**Le matériel et le moteur, gérés pour vous.** `jean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU fonctionne), ROCm, Metal, Vulkan, ou repli CPU. `jean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre. +**Le matériel et le moteur, gérés pour vous.** `ajean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU fonctionne), ROCm, Metal, Vulkan, ou repli CPU. `ajean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre. -**Un service, pas un script.** `jean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). +**Un service, pas un script.** `ajean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). **Plusieurs modèles, un clic.** Les presets gardent chacun leur configuration complète ; basculer de l'un à l'autre recharge le modèle sans toucher à un fichier. Les `.gguf` peuvent vivre sur n'importe quel disque. -**Accessible de partout.** `jean link` ouvre une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et cela fonctionne même en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais les conversations. +**Accessible de partout.** `ajean link` ouvre une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et cela fonctionne même en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais les conversations. ## Démarrage @@ -42,8 +42,8 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur ```bash curl -L -o jean https://github.com/nathaninline/ajean/releases/latest/download/jean-linux-amd64 -chmod +x jean -sudo mv jean /usr/local/bin/jean +chmod +x ajean +sudo mv ajean /usr/local/bin/ajean ``` (Autres cibles sur la page [Releases](../../releases) : Linux, macOS et Windows, en amd64 et arm64.) @@ -51,8 +51,8 @@ sudo mv jean /usr/local/bin/jean ### 2. Installation et compilation du moteur ```bash -sudo jean install # unité systemd, sudoers, dossiers -jean llamacpp install # compile llama.cpp pour le GPU présent +sudo ajean install # unité systemd, sudoers, dossiers +ajean llamacpp install # compile llama.cpp pour le GPU présent ``` Nécessite `git` et `cmake`, plus le toolkit de l'accélérateur (CUDA, ROCm…) pour l'accélération GPU. @@ -60,10 +60,10 @@ Nécessite `git` et `cmake`, plus le toolkit de l'accélérateur (CUDA, ROCm…) ### 3. Démarrage ```bash -jean edit # régler MODEL=/chemin/vers/le-modele.gguf -jean start -jean test # vérifier que le modèle répond -jean web # http://:8090 +ajean edit # régler MODEL=/chemin/vers/le-modele.gguf +ajean start +ajean test # vérifier que le modèle répond +ajean web # http://:8090 ``` ## Commandes @@ -78,7 +78,7 @@ Service : start | stop | restart gérer le service status | logs état / logs en direct enable | disable démarrage au boot - edit éditer $JEAN_HOME/config.env + edit éditer $AJEAN_HOME/config.env test | bench [N] vérifier que le modèle répond / mesurer les tok/s vram | gpu [index…] VRAM / choix des GPU (gpu all = tous) set-api-key [clé] protéger le moteur d'inférence (Bearer) @@ -107,7 +107,7 @@ Installation : ## Configuration -Tout vit sous **`$JEAN_HOME`** (`/etc/jean` sous Linux/macOS, `%ProgramData%\jean` sous Windows). Le service lit `config.env` : +Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\ajean` sous Windows). Le service lit `config.env` : | Clé | Signification | Défaut | |-----|---------------|--------| @@ -119,7 +119,7 @@ Tout vit sous **`$JEAN_HOME`** (`/etc/jean` sous Linux/macOS, `%ProgramData%\jea | `BATCH` / `UBATCH` | batch / micro-batch | `2048` / `512` | | `THREADS` / `THREADS_BATCH` | threads CPU | `0` (auto) | | `KV_TYPE` (`_K` / `_V`) | quantization du cache KV | — | -| `CUDA_VISIBLE_DEVICES` | GPU utilisés (réglé par `jean gpu`) | tous | +| `CUDA_VISIBLE_DEVICES` | GPU utilisés (réglé par `ajean gpu`) | tous | | `REASONING` | passthrough du mode raisonnement | — | | `REASONING_BUDGET` | plafond de tokens de réflexion ; `-1` = illimité | `-1` | | `COMPACT` | compactage automatique du contexte (`off` pour couper) | activé | @@ -127,31 +127,31 @@ Tout vit sous **`$JEAN_HOME`** (`/etc/jean` sous Linux/macOS, `%ProgramData%\jea | `CRAWL4AI_URL` / `CRAWL4AI_KEY` | serveur d'accès internet | — | | `EXTRA_ARGS` | ajouté tel quel à la ligne de commande du moteur | — | -La clé API (`jean set-api-key`) vit dans `$JEAN_HOME/.api_key`, à part, afin de survivre aux changements de preset. +La clé API (`ajean set-api-key`) vit dans `$AJEAN_HOME/.api_key`, à part, afin de survivre aux changements de preset. -**Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$JEAN_HOME` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans `model_dirs.json`, donc conservée d'un preset à l'autre. +**Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$AJEAN_HOME` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans `model_dirs.json`, donc conservée d'un preset à l'autre. ### Variables d'environnement | Variable | Rôle | Défaut | |----------|------|--------| -| `JEAN_HOME` | racine des données | `/etc/jean`, `%ProgramData%\jean` | -| `JEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — | -| `JEAN_SERVICE` | nom de l'unité systemd | `jean` | +| `AJEAN_HOME` | racine des données | `/etc/ajean`, `%ProgramData%\ajean` | +| `AJEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — | +| `JEAN_SERVICE` | nom de l'unité systemd | `ajean` | | `HF_TOKEN` | token Hugging Face pour les modèles privés | — | -| `JEAN_DL_CONNS` | connexions parallèles au téléchargement | — | -| `EDITOR` | éditeur pour `jean edit` | `nano` / `notepad` | +| `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — | +| `EDITOR` | éditeur pour `ajean edit` | `nano` / `notepad` | ## Les capacités de l'IA ### Mémoire -L'IA tient des pages Markdown sous `$JEAN_HOME/MEMORY/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent : +L'IA tient des pages Markdown sous `$AJEAN_HOME/MEMORY/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent : ```bash -jean memory always # (défaut) elle cherche avant de répondre et enregistre d'elle-même -jean memory ondemand # outils disponibles, mais utilisés seulement sur demande -jean memory off # mémoire coupée +ajean memory always # (défaut) elle cherche avant de répondre et enregistre d'elle-même +ajean memory ondemand # outils disponibles, mais utilisés seulement sur demande +ajean memory off # mémoire coupée ``` ### Accès internet @@ -160,9 +160,9 @@ Par défaut, l'IA n'a pas accès au web. En branchant un serveur [Crawl4AI](http ```bash docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest -jean internet url http://localhost:11235 -jean internet on -jean internet status +ajean internet url http://localhost:11235 +ajean internet on +ajean internet status ``` Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** le serveur joignable. Sinon ils n'existent pas, et le modèle ne peut donc pas les inventer. @@ -171,7 +171,7 @@ Les outils web ne sont proposés au modèle que si le mode agent est actif, l'ac AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : on y branche des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp____`. -La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$JEAN_HOME/mcp.json`, au **même format que Claude Desktop**, si bien qu'un fichier existant se réutilise tel quel : +La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$AJEAN_HOME/mcp.json`, au **même format que Claude Desktop**, si bien qu'un fichier existant se réutilise tel quel : ```json { @@ -186,38 +186,38 @@ Les transports **stdio** et **HTTP** sont pris en charge. Comme le terminal, un ## Windows -- **Pas de systemd** : `jean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés, il faut passer par une tâche planifiée. -- `JEAN_HOME` vaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). -- `jean install` crée seulement le dossier de données et un `config.env` de départ. +- **Pas de systemd** : `ajean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés, il faut passer par une tâche planifiée. +- `AJEAN_HOME` vaut `%ProgramData%\ajean` (repli `%LOCALAPPDATA%\ajean`). +- `ajean install` crée seulement le dossier de données et un `config.env` de départ. - Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et écrit ses fichiers par l'outil dédié plutôt que par le shell, ce qui lui permet de produire des scripts contenant des guillemets. ```powershell -jean install -jean edit # BIN=...\llama-server.exe et MODEL=...\modele.gguf -jean start -jean status +ajean install +ajean edit # BIN=...\llama-server.exe et MODEL=...\modele.gguf +ajean start +ajean status ``` -`jean llamacpp install` compile également sous Windows si `git` et `cmake` sont présents ; sinon, récupérer un `llama-server.exe` pré-compilé et pointer `BIN` dessus. +`ajean llamacpp install` compile également sous Windows si `git` et `cmake` sont présents ; sinon, récupérer un `llama-server.exe` pré-compilé et pointer `BIN` dessus. ## macOS -La page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock. +La page [Releases](../../releases) publie `AJEAN-macos-arm64.zip` / `AJEAN-macos-amd64.zip`, un bundle **`AJEAN.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock. L'application n'est signée qu'en ad-hoc : au premier lancement, faire **clic droit → Ouvrir**. -Pour un usage en ligne de commande, prendre le binaire nu `jean-darwin-arm64` : hors bundle, il conserve son comportement CLI. Les services passent par **launchd**. +Pour un usage en ligne de commande, prendre le binaire nu `ajean-darwin-arm64` : hors bundle, il conserve son comportement CLI. Les services passent par **launchd**. ## Accès distant via ajean.link -`jean link` ouvre une connexion **sortante** vers le relais : le serveur reste injoignable depuis l'extérieur, mais reste accessible de partout. +`ajean link` ouvre une connexion **sortante** vers le relais : le serveur reste injoignable depuis l'extérieur, mais reste accessible de partout. ```bash -jean link # token fourni sur ajean.link -jean link code # code d'appairage à saisir dans le portail +ajean link # token fourni sur ajean.link +ajean link code # code d'appairage à saisir dans le portail ``` -Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `jean web`, l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. +Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `ajean web`, l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. Il s'agit d'un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit. @@ -226,8 +226,8 @@ Il s'agit d'un service optionnel et payant ; tout le reste d'AJEAN est et rester Le relais est conçu comme un **tube aveugle** : il transporte les données sans pouvoir les lire. - **Chat chiffré de bout en bout** (X25519 + AES-GCM). La clé est dérivée du mot de passe via **OPAQUE** et ne quitte jamais le navigateur. -- **Empreinte vérifiée.** `jean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail, ce qui défait toute tentative d'interception par le relais. -- **Appairage authentifié.** Un code à usage unique (`jean link code`) garantit qu'un seul navigateur autorisé pilote le serveur ; même compromis, le relais ne peut pas forger de commande. +- **Empreinte vérifiée.** `ajean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail, ce qui défait toute tentative d'interception par le relais. +- **Appairage authentifié.** Un code à usage unique (`ajean link code`) garantit qu'un seul navigateur autorisé pilote le serveur ; même compromis, le relais ne peut pas forger de commande. - **Code servi hors du relais.** Le portail provient d'une origine indépendante (GitHub Pages) : le relais ne peut pas injecter de code pour dérober la clé. Reste visible du relais : des métadonnées techniques (machine en ligne, modèle chargé, VRAM), jamais le contenu des conversations. @@ -240,10 +240,10 @@ Le VPS effectue un simple **passthrough SNI** : le TLS est terminé sur la machi ## API de pilotage -`jean web` expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition : +`ajean web` expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition : ```bash -jean set-web-key # génère une clé +ajean set-web-key # génère une clé ``` Chaque appel `/api/*` présente alors `Authorization: Bearer ` : @@ -257,7 +257,7 @@ Chaque appel `/api/*` présente alors `Authorization: Bearer ` : | POST | `/api/start` · `/api/stop` · `/api/restart` | piloter le service | | POST | `/api/chat` `{"messages":[…]}` | chat (flux SSE) | -> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, placer un reverse-proxy HTTPS devant, ou utiliser `jean link`. +> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, placer un reverse-proxy HTTPS devant, ou utiliser `ajean link`. ## Compiler depuis les sources @@ -265,17 +265,17 @@ Go 1.25+. AJEAN est écrit à 100 % en Go, l'interface est embarquée via `go:em ```bash git clone https://github.com/nathaninline/ajean.git -cd jean -CGO_ENABLED=0 go build -o jean ./cmd/jean +cd ajean +CGO_ENABLED=0 go build -o ajean ./cmd/ajean ``` > Compiler **AJEAN** ne demande que Go. Compiler le **moteur llama.cpp** demande `git`, `cmake` et le toolkit de l'accélérateur. ## Arborescence -- `cmd/jean/` : point d'entrée + ressources Windows (icône, versioninfo). -- `internal/jean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. -- `internal/jean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/jean`. +- `cmd/ajean/` : point d'entrée + ressources Windows (icône, versioninfo). +- `internal/ajean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. +- `internal/ajean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/ajean`. ## Licence diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6de4650..857fec7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -9,7 +9,7 @@ Si vous étiez bloqué dans cet état, il n'y a rien à réparer : téléchargez ## Mise à jour ``` -jean update +ajean update ``` Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait le même travail. diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 3b3f5dd..9ab5a4c 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -21,9 +21,9 @@ "StringFileInfo": { "CompanyName": "AJEAN contributors", "FileDescription": "AJEAN — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés", - "InternalName": "jean", + "InternalName": "ajean", "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", - "OriginalFilename": "jean.exe", + "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", "ProductVersion": "0.6.15", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" diff --git a/internal/ajean/backend_build.go b/internal/ajean/backend_build.go index f796c83..6d5fb9f 100644 --- a/internal/ajean/backend_build.go +++ b/internal/ajean/backend_build.go @@ -228,15 +228,15 @@ func hintMissingBuildDep(p buildPlan, cfgLog string) { fmt.Printf(" Relance l'installeur du CUDA Toolkit (installation personnalisée) en cochant « CUDA → Visual Studio Integration »\n") fmt.Printf(" (Visual Studio avec le workload C++ doit déjà être installé), ou copie les fichiers de\n") fmt.Printf(" \\extras\\visual_studio_integration\\MSBuildExtensions vers\n") - fmt.Printf(" \\MSBuild\\Microsoft\\VC\\\\BuildCustomizations, puis relance %s.\n", bold("jean llamacpp install")) + fmt.Printf(" \\MSBuild\\Microsoft\\VC\\\\BuildCustomizations, puis relance %s.\n", bold("ajean llamacpp install")) } if p.backend == "vulkan" && strings.Contains(log, "SPIRV-Headers") { fmt.Printf("\n%s dépendance manquante pour le backend %s : les en-têtes SPIR-V (paquet « SPIRV-Headers ») sont introuvables.\n", yellow("[dépendance]"), green("Vulkan")) if cmd := pkgInstallHint("spirv-headers"); cmd != "" { - fmt.Printf(" installe-les puis relance %s : %s\n", bold("jean llamacpp install"), bold(cmd)) + fmt.Printf(" installe-les puis relance %s : %s\n", bold("ajean llamacpp install"), bold(cmd)) } else { - fmt.Printf(" installe le paquet de développement « SPIRV-Headers » de ta distribution, puis relance %s.\n", bold("jean llamacpp install")) + fmt.Printf(" installe le paquet de développement « SPIRV-Headers » de ta distribution, puis relance %s.\n", bold("ajean llamacpp install")) } } } diff --git a/internal/ajean/backend_config.go b/internal/ajean/backend_config.go index c8df85c..5cb1e5f 100644 --- a/internal/ajean/backend_config.go +++ b/internal/ajean/backend_config.go @@ -69,9 +69,9 @@ func maskAPIKey(k string) string { // exposed on the internet, llama-server requires "Authorization: Bearer " // for every call. // -// jean set-api-key définit la clé -// jean set-api-key génère une clé aléatoire -// jean set-api-key "" supprime la protection +// ajean set-api-key définit la clé +// ajean set-api-key génère une clé aléatoire +// ajean set-api-key "" supprime la protection func cmdSetAPIKey(args []string) error { var key string switch { @@ -97,7 +97,7 @@ func cmdSetAPIKey(args []string) error { fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] ")) sc := bufio.NewScanner(os.Stdin) if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") { - fmt.Println(dim("[info] pense à lancer 'jean restart'")) + fmt.Println(dim("[info] pense à lancer 'ajean restart'")) return nil } return serviceAction("restart") diff --git a/internal/ajean/backend_gpu.go b/internal/ajean/backend_gpu.go index c70b6c6..b041582 100644 --- a/internal/ajean/backend_gpu.go +++ b/internal/ajean/backend_gpu.go @@ -11,10 +11,10 @@ import ( // backend_gpu.go — sélection du/des GPU utilisés par llama-server. // -// jean gpu liste les GPU et montre la sélection courante -// jean gpu 1 n'utilise que le GPU d'index 1 -// jean gpu 0 1 utilise les GPU 0 et 1 -// jean gpu all réinitialise (tous les GPU visibles) +// ajean gpu liste les GPU et montre la sélection courante +// ajean gpu 1 n'utilise que le GPU d'index 1 +// ajean gpu 0 1 utilise les GPU 0 et 1 +// ajean gpu all réinitialise (tous les GPU visibles) // // La sélection est stockée dans config.env sous CUDA_VISIBLE_DEVICES ; backend_serve.go // l'exporte (avec CUDA_DEVICE_ORDER=PCI_BUS_ID pour que les index correspondent @@ -53,7 +53,7 @@ func cmdGPU(args []string) error { return fmt.Errorf("index GPU invalide: %q (attendu un nombre)", tok) } if n < 0 || n >= len(gpus) { - return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « jean gpu »", n, len(gpus)-1) + return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « ajean gpu »", n, len(gpus)-1) } idx = append(idx, strconv.Itoa(n)) } @@ -102,7 +102,7 @@ func gpuList() error { } else { fmt.Printf(" Sélection : %s (CUDA_VISIBLE_DEVICES=%s)\n", bold(sel), sel) } - fmt.Printf(" %s jean gpu pour choisir, jean gpu all pour réinitialiser\n", dim("→")) + fmt.Printf(" %s ajean gpu pour choisir, ajean gpu all pour réinitialiser\n", dim("→")) return nil } @@ -120,7 +120,7 @@ func gpuSet(value string) error { fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] ")) sc := bufio.NewScanner(os.Stdin) if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") { - fmt.Println(dim("[info] pense à lancer 'jean restart'")) + fmt.Println(dim("[info] pense à lancer 'ajean restart'")) return nil } return serviceAction("restart") diff --git a/internal/ajean/backend_llamacpp.go b/internal/ajean/backend_llamacpp.go index 3748b93..4208ccf 100644 --- a/internal/ajean/backend_llamacpp.go +++ b/internal/ajean/backend_llamacpp.go @@ -9,12 +9,12 @@ import ( // backend_llamacpp.go — gestion du backend llama.cpp (clone, build, mise à jour). // -// `jean llamacpp install` installe un build neuf, détecte automatiquement +// `ajean llamacpp install` installe un build neuf, détecte automatiquement // l'accélérateur (CUDA / ROCm / Metal / CPU) et la // compute capability du GPU, puis pointe BIN dessus. -// `jean llamacpp update` met à jour le dépôt existant (git pull) et recompile +// `ajean llamacpp update` met à jour le dépôt existant (git pull) et recompile // avec la bonne config, sans intervention. -// `jean llamacpp status` montre le commit courant, le backend détecté et le +// `ajean llamacpp status` montre le commit courant, le backend détecté et le // retard éventuel sur origin. const llamacppRepoURL = "https://github.com/ggml-org/llama.cpp.git" @@ -55,7 +55,7 @@ func cmdLlamacpp(args []string) error { if err := SetConfigKey("BIN", bin); err != nil { return fmt.Errorf("binaires installés mais échec écriture BIN dans config.env: %w", err) } - fmt.Printf("%s BIN mis à jour dans %s — %s pour appliquer\n", green("✓"), confPath(), bold("jean restart")) + fmt.Printf("%s BIN mis à jour dans %s — %s pour appliquer\n", green("✓"), confPath(), bold("ajean restart")) return nil default: return fmt.Errorf("sous-commande inconnue: %s (install | update | prebuilt | status)", sub) @@ -161,7 +161,7 @@ func llamacppInstall(args []string) error { if isDir(filepath.Join(repo, ".git")) { if !force { fmt.Printf("%s dépôt déjà présent dans %s\n", yellow("[info]"), repo) - fmt.Printf(" → %s pour le mettre à jour, ou --force pour repartir de zéro\n", bold("jean llamacpp update")) + fmt.Printf(" → %s pour le mettre à jour, ou --force pour repartir de zéro\n", bold("ajean llamacpp update")) return nil } fmt.Printf("%s --force : suppression de %s\n", yellow("[info]"), repo) @@ -208,7 +208,7 @@ func llamacppInstall(args []string) error { } fmt.Printf("%s BIN mis à jour dans %s\n", green("✓"), confPath()) fmt.Printf("\nProchaines étapes :\n 1. renseigne MODEL : %s\n 2. démarre : %s\n", - bold("jean edit"), bold("jean restart")) + bold("ajean edit"), bold("ajean restart")) return nil } @@ -246,7 +246,7 @@ func llamacppUpdate(args []string) error { repo := llamacppRepoDir() if !isDir(filepath.Join(repo, ".git")) { - return fmt.Errorf("aucun dépôt llama.cpp trouvé (%s).\n → lance d'abord %s", repo, bold("jean llamacpp install")) + return fmt.Errorf("aucun dépôt llama.cpp trouvé (%s).\n → lance d'abord %s", repo, bold("ajean llamacpp install")) } fmt.Printf("%s dépôt : %s\n", cyan("▶"), repo) @@ -317,14 +317,14 @@ func llamacppUpdate(args []string) error { fmt.Printf("\n%s mis à jour : %s → %s\n", green("✓"), oldCommit, newCommit) if noRestart { - fmt.Printf("%s --no-restart : pense à lancer %s\n", dim("[info]"), bold("jean restart")) + fmt.Printf("%s --no-restart : pense à lancer %s\n", dim("[info]"), bold("ajean restart")) return nil } if svcWasUp { fmt.Printf("%s redémarrage du service…\n", cyan("▶")) return serviceAction("start") } - fmt.Printf("%s service non démarré auparavant — lance %s quand tu veux\n", dim("[info]"), bold("jean start")) + fmt.Printf("%s service non démarré auparavant — lance %s quand tu veux\n", dim("[info]"), bold("ajean start")) return nil } @@ -337,7 +337,7 @@ func llamacppStatus(args []string) error { fmt.Printf("%s\n", bold("llama.cpp")) fmt.Printf(" dépôt : %s\n", repo) if !isDir(filepath.Join(repo, ".git")) { - fmt.Printf(" %s pas encore installé — %s\n", yellow("état"), bold("jean llamacpp install")) + fmt.Printf(" %s pas encore installé — %s\n", yellow("état"), bold("ajean llamacpp install")) return nil } commit := gitOutput(repo, "log", "-1", "--format=%h %ci %s") @@ -354,7 +354,7 @@ func llamacppStatus(args []string) error { // Retard sur origin (best-effort, sans fetch réseau). if branch != "" && branch != "HEAD" { if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); behind != "" && behind != "0" { - fmt.Printf(" maj : %s commit(s) de retard sur origin/%s — %s\n", yellow(behind), branch, bold("jean llamacpp update")) + fmt.Printf(" maj : %s commit(s) de retard sur origin/%s — %s\n", yellow(behind), branch, bold("ajean llamacpp update")) } } diff --git a/internal/ajean/backend_model_dirs.go b/internal/ajean/backend_model_dirs.go index c9e843c..e485763 100644 --- a/internal/ajean/backend_model_dirs.go +++ b/internal/ajean/backend_model_dirs.go @@ -36,7 +36,7 @@ func extraModelDirs() []string { raw = append(raw, dirs...) } } - if v := strings.TrimSpace(os.Getenv("JEAN_MODEL_DIRS")); v != "" { + if v := envAny("AJEAN_MODEL_DIRS", "JEAN_MODEL_DIRS"); v != "" { raw = append(raw, strings.Split(v, modelDirsListSep())...) } return dedupDirs(raw, AjeanHome()) @@ -216,5 +216,5 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) { n := modelDirCount(d) out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0}) } - sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("JEAN_MODEL_DIRS") != ""}) + sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": envAny("AJEAN_MODEL_DIRS", "JEAN_MODEL_DIRS") != ""}) } diff --git a/internal/ajean/backend_models.go b/internal/ajean/backend_models.go index 4d27b18..45e9117 100644 --- a/internal/ajean/backend_models.go +++ b/internal/ajean/backend_models.go @@ -197,7 +197,7 @@ var dlClient = &http.Client{ // Overridable with JEAN_DL_CONNS (1 disables parallelism). func dlConns() int { n := 8 - if v := strings.TrimSpace(os.Getenv("JEAN_DL_CONNS")); v != "" { + if v := envAny("AJEAN_DL_CONNS", "JEAN_DL_CONNS"); v != "" { if p, err := strconv.Atoi(v); err == nil && p > 0 { n = p } diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index d6bf09c..096b3c5 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -42,7 +42,7 @@ func cmdServe(args []string) error { // Linux, PATH on Windows — handled inside execServer. setLibraryPath(filepath.Dir(bin)) - // Sélection GPU (jean gpu) : on filtre les devices visibles par llama-server. + // Sélection GPU (ajean gpu) : on filtre les devices visibles par llama-server. // CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux // affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide"). if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" { @@ -104,7 +104,7 @@ func cmdServe(args []string) error { // mmproj-F16.gguf) still resolve. _ = os.Chdir(AjeanHome()) - fmt.Fprintf(os.Stderr, "[jean serve] %s model=%s port=%s\n", + fmt.Fprintf(os.Stderr, "[ajean serve] %s model=%s port=%s\n", bin, filepath.Base(model), get("PORT", "8080")) // Hand off to the llama-server process. On Unix this replaces the current diff --git a/internal/ajean/chat_agent.go b/internal/ajean/chat_agent.go index 7ed8921..48b5b62 100644 --- a/internal/ajean/chat_agent.go +++ b/internal/ajean/chat_agent.go @@ -78,7 +78,7 @@ func cmdAgent(args []string) error { fmt.Printf(" %s %s\n", bold(p.Name), p.Title) } default: - return fmt.Errorf("usage: jean agent [on|off|status]") + return fmt.Errorf("usage: ajean agent [on|off|status]") } return nil } diff --git a/internal/ajean/chat_cmd.go b/internal/ajean/chat_cmd.go index f58ab3a..653a852 100644 --- a/internal/ajean/chat_cmd.go +++ b/internal/ajean/chat_cmd.go @@ -12,14 +12,14 @@ import ( // First positional arg (if any) becomes the system prompt. func cmdChat(args []string) error { if !healthCheck() { - return fmt.Errorf("serveur injoignable sur :%d — jean start d'abord", LLMPort()) + return fmt.Errorf("serveur injoignable sur :%d — ajean start d'abord", LLMPort()) } sysPrompt := strings.Join(args, " ") msgs := []Message{} if sysPrompt != "" { msgs = append(msgs, Message{Role: "system", Content: sysPrompt}) } - fmt.Printf("\n%s — /reset pour vider, /sys pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("jean chat")) + fmt.Printf("\n%s — /reset pour vider, /sys pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("ajean chat")) if sysPrompt != "" { fmt.Println(dim("system: " + sysPrompt)) } @@ -65,7 +65,7 @@ func cmdChat(args []string) error { inReason := false var stats *StatsEvent // Print the assistant prefix once; reasoning is shown inline with a tag. - fmt.Print(cyan("jean") + " > ") + fmt.Print(cyan("ajean") + " > ") caps := globalCaps() // Compaction proactive (façon Hermes) : on résume les vieux tours quand // l'historique dépasse le seuil, au lieu d'imposer un /reset. @@ -120,7 +120,7 @@ func cmdChat(args []string) error { fmt.Print("\n") inReason = false } - fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("jean")+" >") + fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("ajean")+" >") case ev.Reasoning != "": if !inReason { fmt.Print(magenta("[reasoning] ") + dim("")) @@ -129,7 +129,7 @@ func cmdChat(args []string) error { fmt.Print(dim(ev.Reasoning)) case ev.Content != "": if inReason { - fmt.Print("\n" + cyan("jean") + " > ") + fmt.Print("\n" + cyan("ajean") + " > ") inReason = false } full.WriteString(ev.Content) diff --git a/internal/ajean/chat_internet_tools.go b/internal/ajean/chat_internet_tools.go index a0f6378..bb74c70 100644 --- a/internal/ajean/chat_internet_tools.go +++ b/internal/ajean/chat_internet_tools.go @@ -1,5 +1,5 @@ // chat_internet_tools.go — les 4 outils web exposés au modèle (web_search, -// web_open, web_read, web_grep) + la sous-commande `jean internet`. +// web_open, web_read, web_grep) + la sous-commande `ajean internet`. package ajean import ( @@ -251,7 +251,7 @@ func toolWebGrep(args map[string]any) string { entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n")) } -// ─── CLI : jean internet [on|off|status|url ] ────────────────────────── +// ─── CLI : ajean internet [on|off|status|url ] ────────────────────────── func cmdInternet(args []string) error { sub := "" @@ -261,7 +261,7 @@ func cmdInternet(args []string) error { switch sub { case "on": if crawl4aiURL() == "" { - return fmt.Errorf("configure d'abord l'URL : jean internet url ") + return fmt.Errorf("configure d'abord l'URL : ajean internet url ") } if err := setInternetEnabled(true); err != nil { return err @@ -274,7 +274,7 @@ func cmdInternet(args []string) error { fmt.Println(green("[ok]") + " accès internet désactivé") case "url": if len(args) < 2 { - return fmt.Errorf("usage: jean internet url (ex: http://localhost:11235)") + return fmt.Errorf("usage: ajean internet url (ex: http://localhost:11235)") } u := strings.TrimRight(strings.TrimSpace(args[1]), "/") if err := SetConfigKey("CRAWL4AI_URL", u); err != nil { @@ -286,7 +286,7 @@ func cmdInternet(args []string) error { fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u)) case "key": if len(args) < 2 { - return fmt.Errorf("usage: jean internet key (vide pour l'enlever : jean internet key \"\")") + return fmt.Errorf("usage: ajean internet key (vide pour l'enlever : ajean internet key \"\")") } k := strings.TrimSpace(args[1]) if err := writeCrawlKey(k); err != nil { @@ -308,7 +308,7 @@ func cmdInternet(args []string) error { fmt.Printf("%s état: %s\n", cyan("Accès internet"), state) u := crawl4aiURL() if u == "" { - fmt.Printf(" serveur : %s — configure : jean internet url \n", dim("(non configuré)")) + fmt.Printf(" serveur : %s — configure : ajean internet url \n", dim("(non configuré)")) return nil } reach := red("injoignable") @@ -318,7 +318,7 @@ func cmdInternet(args []string) error { fmt.Printf(" serveur : %s (%s)\n", bold(u), reach) fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n") default: - return fmt.Errorf("usage: jean internet [on|off|status|url ]") + return fmt.Errorf("usage: ajean internet [on|off|status|url ]") } return nil } diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go index 03be346..8eb9ff4 100644 --- a/internal/ajean/chat_memory.go +++ b/internal/ajean/chat_memory.go @@ -372,7 +372,7 @@ func setMemMode(m MemMode) error { return SetConfigKey("MEM_MODE", string(m)) } -// cmdMemory : jean memory [off|ondemand|always|status] +// cmdMemory : ajean memory [off|ondemand|always|status] func cmdMemory(args []string) error { sub := "" if len(args) > 0 { @@ -403,7 +403,7 @@ func cmdMemory(args []string) error { fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir()) return nil default: - return fmt.Errorf("usage: jean memory [off|ondemand|always|status]") + return fmt.Errorf("usage: ajean memory [off|ondemand|always|status]") } m := memMode() fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m]) diff --git a/internal/ajean/chat_workspace.go b/internal/ajean/chat_workspace.go index 3b5f652..dc9f2ad 100644 --- a/internal/ajean/chat_workspace.go +++ b/internal/ajean/chat_workspace.go @@ -62,9 +62,12 @@ func workspaceCandidates() []string { } c = append(c, filepath.Join(AjeanHome(), "workspace")) if home, err := os.UserHomeDir(); err == nil && home != "" { + // Le nouveau nom d'abord, l'ancien ensuite : un dossier ~/jean/workspace + // déjà rempli par l'agent reste utilisable au lieu de repartir de zéro. + c = append(c, filepath.Join(home, "ajean", "workspace")) c = append(c, filepath.Join(home, "jean", "workspace")) } - c = append(c, filepath.Join(os.TempDir(), "jean-workspace")) + c = append(c, filepath.Join(os.TempDir(), "ajean-workspace")) return c } diff --git a/internal/ajean/llm_oai.go b/internal/ajean/llm_oai.go index ff31642..cf5d3d3 100644 --- a/internal/ajean/llm_oai.go +++ b/internal/ajean/llm_oai.go @@ -103,7 +103,7 @@ func setOAIPublic(on bool) error { func oaiTLSConfig() *tls.Config { certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")} certmagic.DefaultACME.Agreed = true - certmagic.DefaultACME.Email = strings.TrimSpace(os.Getenv("JEAN_ACME_EMAIL")) + certmagic.DefaultACME.Email = envAny("AJEAN_ACME_EMAIL", "JEAN_ACME_EMAIL") certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement magic := certmagic.NewDefault() magic.OnDemand = &certmagic.OnDemandConfig{ @@ -221,7 +221,7 @@ func selfSignedTLSConfig(host string) (*tls.Config, error) { // cmdOAI pilote l'accès OpenAI public côté agent. // -// jean oai serve [port] [host] (test local) termine le TLS sur :port avec un +// ajean oai serve [port] [host] (test local) termine le TLS sur :port avec un // cert auto-signé et proxifie vers llama /v1. func cmdOAI(args []string) error { sub := "" @@ -251,11 +251,11 @@ func cmdOAI(args []string) error { if err != nil { return err } - fmt.Printf("[jean oai] front TLS (test, auto-signé) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort()) + fmt.Printf("[ajean oai] front TLS (test, auto-signé) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort()) return runOAIFront(ln, tlsCfg) default: - fmt.Println("usage: jean oai serve [port] [host] (front TLS de test → llama /v1)") - fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (jean link)") + fmt.Println("usage: ajean oai serve [port] [host] (front TLS de test → llama /v1)") + fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (ajean link)") fmt.Println(" quand JEAN_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.") return nil } diff --git a/internal/ajean/llm_testcmd.go b/internal/ajean/llm_testcmd.go index 5a4dae2..081d2bf 100644 --- a/internal/ajean/llm_testcmd.go +++ b/internal/ajean/llm_testcmd.go @@ -14,7 +14,7 @@ func cmdTest(args []string) error { fmt.Printf("→ GET http://localhost:%d/health … ", port) if !healthCheck() { fmt.Println(red("ko")) - return fmt.Errorf("/health ne répond pas — jean start d'abord") + return fmt.Errorf("/health ne répond pas — ajean start d'abord") } fmt.Println(green("ok")) diff --git a/internal/ajean/relay_e2e.go b/internal/ajean/relay_e2e.go index 9e52578..735ba25 100644 --- a/internal/ajean/relay_e2e.go +++ b/internal/ajean/relay_e2e.go @@ -5,7 +5,7 @@ package ajean // // Modèle : cet agent a une paire X25519 long-terme (clé privée locale, jamais // transmise). Sa clé publique est publiée au relais et son EMPREINTE est affichée -// au `jean link` — l'utilisateur la confirme dans le portail, ce qui défait tout +// au `ajean link` — l'utilisateur la confirme dans le portail, ce qui défait tout // MITM du relais. Le navigateur dérive une racine R de son mot de passe (exportKey // OPAQUE), la SCELLE vers la clé publique de l'agent (le relais ne peut pas // l'ouvrir), puis chiffre/déchiffre le chat avec une clé dérivée de R. diff --git a/internal/ajean/relay_e2eauth.go b/internal/ajean/relay_e2eauth.go index 41d007a..8b372b8 100644 --- a/internal/ajean/relay_e2eauth.go +++ b/internal/ajean/relay_e2eauth.go @@ -9,7 +9,7 @@ package ajean // Modèle : l'utilisateur a une identité X25519 dérivée de façon déterministe de son // mot de passe (exportKey OPAQUE → racine R → uPriv) — le relais ne connaît pas R, // donc ne peut PAS reproduire cette identité. La clé publique uPub est APPAIRÉE une -// fois à l'agent via un code affiché par « jean link » (canal hors-bande : le log du +// fois à l'agent via un code affiché par « ajean link » (canal hors-bande : le log du // serveur, que le relais ne voit pas ; le code voyage scellé vers l'agent). Ensuite, // chaque requête est chiffrée avec K = SHA256(ECDH(uPriv, agentPriv) || "authchan"), // liée à uPub+horodatage (AAD) et protégée contre le rejeu. Seul le vrai utilisateur @@ -100,8 +100,8 @@ func authorizeUser(uPubHex string) error { // ---- Codes d'appairage : à la demande, usage unique, TTL 10 min -------------- // -// Générés par « jean link code » (ou affichés par « jean link »), ils sont -// partagés avec le worker « jean link --foreground » (systemd, autre process) via +// Générés par « ajean link code » (ou affichés par « ajean link »), ils sont +// partagés avec le worker « ajean link --foreground » (systemd, autre process) via // un fichier AjeanHome/.pair_codes — sinon les deux process ne s'accorderaient pas. // Stockés HACHÉS (SHA-256) : le fichier ne révèle aucun code. Chaque code expire // au bout de 10 min et est consommé (retiré) au premier appairage réussi. diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 1b2580b..161fb2a 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -1,16 +1,16 @@ package ajean -// relay_link.go — `jean link ` : connecte ce serveur Jean au relais public +// relay_link.go — `ajean link ` : connecte ce serveur Jean au relais public // (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur // y accède depuis n'importe où sans ouvrir de port (CGNAT, box, etc.). // // Principe : l'agent ouvre un WebSocket vers le relais, l'authentifie avec le // token d'abonnement, puis multiplexe (yamux) ce lien unique en un stream par -// requête navigateur. Chaque stream est reverse-proxyfié vers le `jean web` +// requête navigateur. Chaque stream est reverse-proxyfié vers le `ajean web` // local. Keepalive + reconnexion automatique avec backoff. // // Le token est fourni par la boutique à l'achat. Il est mémorisé dans -// $JEAN_HOME/.link_token pour que `jean link` (sans argument) reprenne la +// $JEAN_HOME/.link_token pour que `ajean link` (sans argument) reprenne la // connexion. import ( @@ -87,7 +87,7 @@ func removeLinkToken() error { // relayURL resolves the relay WebSocket endpoint (env override → default). func relayURL() string { - if u := strings.TrimSpace(os.Getenv("JEAN_LINK_URL")); u != "" { + if u := envAny("AJEAN_LINK_URL", "JEAN_LINK_URL"); u != "" { return u } return defaultRelayURL @@ -111,12 +111,12 @@ func cmdLink(args []string) error { } switch sub { case "": - // `jean link` seul : afficher l'aide des sous-commandes (NE démarre pas — + // `ajean link` seul : afficher l'aide des sous-commandes (NE démarre pas — // éviter de prendre un mot pour un token et d'écraser le vrai). printLinkHelp() return nil case "serve", "--foreground", "fg": - // Le worker réel (boucle de connexion au relais), pendant de `jean serve`. + // Le worker réel (boucle de connexion au relais), pendant de `ajean serve`. // C'est ce que lance l'unité systemd ; il NE doit PAS rendre la main. return runLinkForeground() case "start": @@ -124,14 +124,14 @@ func cmdLink(args []string) error { case "status": tok := readLinkToken() if tok == "" { - fmt.Println(yellow("[info]") + " aucun token enregistré — lance: jean link ") + fmt.Println(yellow("[info]") + " aucun token enregistré — lance: ajean link ") return nil } fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) if linkServiceActive() { fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName()) } else { - fmt.Printf("%s service %s: arrêté (jean link pour démarrer)\n", yellow("[info]"), linkServiceName()) + fmt.Printf("%s service %s: arrêté (ajean link pour démarrer)\n", yellow("[info]"), linkServiceName()) } return nil case "logout": @@ -174,7 +174,7 @@ func cmdLink(args []string) error { // on le signale au lieu d'un faux « démarré ». func startLink(force bool) error { if readLinkToken() == "" { - return fmt.Errorf("aucun token. Usage: jean link (token fourni à l'achat sur la boutique)") + return fmt.Errorf("aucun token. Usage: ajean link (token fourni à l'achat sur la boutique)") } switch { case force: @@ -182,7 +182,7 @@ func startLink(force bool) error { return err } case linkServiceActive(): - fmt.Printf("%s service %s déjà en cours — « jean link restart » pour le relancer\n", yellow("[info]"), linkServiceName()) + fmt.Printf("%s service %s déjà en cours — « ajean link restart » pour le relancer\n", yellow("[info]"), linkServiceName()) default: if err := linkServiceCtl("start"); err != nil { return err @@ -191,20 +191,20 @@ func startLink(force bool) error { return linkPrintIdentity() } -// printLinkHelp liste les sous-commandes de `jean link`. +// printLinkHelp liste les sous-commandes de `ajean link`. func printLinkHelp() { - fmt.Print(`jean link — accès distant via le relais ajean.link + fmt.Print(`ajean link — accès distant via le relais ajean.link Usage : - jean link enregistre le token (1re fois / pour le changer) et démarre le lien - jean link start démarre le lien en arrière-plan (service) - jean link restart redémarre le service de lien - jean link stop arrête le service de lien - jean link status état du token et du service - jean link code génère un code d'appairage (valable 10 min, à usage unique) - jean link logout oublie le token enregistré + ajean link enregistre le token (1re fois / pour le changer) et démarre le lien + ajean link start démarre le lien en arrière-plan (service) + ajean link restart redémarre le service de lien + ajean link stop arrête le service de lien + ajean link status état du token et du service + ajean link code génère un code d'appairage (valable 10 min, à usage unique) + ajean link logout oublie le token enregistré -Le token est fourni sur ajean.link. « jean link » seul affiche cette aide. +Le token est fourni sur ajean.link. « ajean link » seul affiche cette aide. `) } @@ -218,7 +218,7 @@ func linkPrintIdentity() error { code, err := newPairCode() if err != nil { fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), AjeanHome(), err) - fmt.Printf(" Réessaie : sudo jean link code\n") + fmt.Printf(" Réessaie : sudo ajean link code\n") return nil } fmt.Printf(" Code d'appairage (valable 10 min, usage unique) : %s\n\n", bold(code)) @@ -230,17 +230,17 @@ func linkPrintIdentity() error { func runLinkForeground() error { token := readLinkToken() if token == "" { - return fmt.Errorf("aucun token. Usage: jean link ") + return fmt.Errorf("aucun token. Usage: ajean link ") } fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL()) - fmt.Printf(" (UI AJEAN + endpoint OpenAI servis dans le tunnel — pas besoin de 'jean web')\n") + fmt.Printf(" (UI AJEAN + endpoint OpenAI servis dans le tunnel — pas besoin de 'ajean web')\n") if fp := e2eFingerprint(); fp != "" { - fmt.Printf("%s empreinte E2E : %s (code d'appairage : jean link code)\n", green("[e2e]"), bold(fp)) + fmt.Printf("%s empreinte E2E : %s (code d'appairage : ajean link code)\n", green("[e2e]"), bold(fp)) } // UNE seule instance du mux web → UNE seule conversation en mémoire, PARTAGÉE // entre l'UI locale (:8090, jean.n27.fr) et le tunnel (app.ajean.link). Avant, - // `jean web` et `jean link` étaient deux process distincts avec chacun leur + // `ajean web` et `ajean link` étaient deux process distincts avec chacun leur // historique → confusion. Désormais le process jean-link est l'unique // propriétaire de la conversation et sert les DEUX surfaces. webMux := newWebMux() @@ -369,12 +369,12 @@ func killForeignLinkWorker() { // serveLocalWebMux sert l'UI web locale sur :8090 avec le mux fourni — le MÊME // que celui exposé dans le tunnel. Le process jean-link devient ainsi l'unique // propriétaire de la conversation, partagée entre l'accès local et l'accès -// distant. Remplace le `jean web` autonome (à ne plus lancer séparément). +// distant. Remplace le `ajean web` autonome (à ne plus lancer séparément). func serveLocalWebMux(mux *http.ServeMux) { addr := "0.0.0.0:8090" ln, err := net.Listen("tcp", addr) if err != nil { - fmt.Printf("%s UI locale non démarrée (%s occupé ? tue le vieux « jean web ») : %v\n", yellow("[web]"), addr, err) + fmt.Printf("%s UI locale non démarrée (%s occupé ? tue le vieux « ajean web ») : %v\n", yellow("[web]"), addr, err) return } fmt.Printf("%s UI locale sur http://%s (même conversation que le tunnel)\n", green("[web]"), addr) @@ -410,7 +410,7 @@ func adoptLegacyLinkFile(ext string) string { // linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est // l'unité systemd jean-link (avec sudo non interactif si on n'est pas root) ; // ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance -// « jean link serve » en processus détaché suivi par un fichier PID, comme le +// « ajean link serve » en processus détaché suivi par un fichier PID, comme le // fait déjà le service principal. Sans ça, l'accès distant restait définitivement // « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne // composait jamais le tunnel. @@ -527,7 +527,7 @@ func linkUserSvcCtl(action string) error { cmd := spawnDetached(self, "link", "serve") cmd.Stdout, cmd.Stderr = logf, logf if err := cmd.Start(); err != nil { - return fmt.Errorf("démarrage de « jean link serve »: %w", err) + return fmt.Errorf("démarrage de « ajean link serve »: %w", err) } pid := cmd.Process.Pid // PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce diff --git a/internal/ajean/run.go b/internal/ajean/run.go index cfc0f97..d97c4ec 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -1,5 +1,5 @@ -// jean — single-binary LLM server manager + web UI for llama.cpp deployments. -// Point d'entrée réel : Main(), appelé par cmd/jean (les métadonnées Windows +// ajean — single-binary LLM server manager + web UI for llama.cpp deployments. +// Point d'entrée réel : Main(), appelé par cmd/ajean (les métadonnées Windows // .syso/go:generate vivent là-bas, dans le dossier du package main). package ajean @@ -12,7 +12,7 @@ import ( const Version = "0.6.15" -// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). +// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { // Rattache la console du terminal parent si on est lancé depuis un shell // (Windows : binaire GUI). Retourne false au double-clic (aucune console) → @@ -37,7 +37,7 @@ func Main() { // lance l'expérience « application » (UI web + navigateur + icône tray) plutôt // que d'afficher l'aide. Le binaire étant compilé en sous-système GUI, il n'y a // AUCUNE console à ce stade (donc plus de fenêtre noire). Lancé depuis un shell, - // `jean` sans argument garde son comportement d'aide. + // `ajean` sans argument garde son comportement d'aide. if noArgs && !haveConsole { mustExit(cmdApp(args)) return @@ -96,7 +96,7 @@ func Main() { case "uninstall": mustExit(cmdUninstall(args)) case "version", "-v", "--version": - fmt.Println("jean", Version) + fmt.Println("ajean", Version) case "help", "-h", "--help", "": printHelp() default: @@ -107,9 +107,9 @@ func Main() { } func printHelp() { - fmt.Printf(`jean %s — manager llama.cpp + UI web (single binary) + fmt.Printf(`ajean %s — manager llama.cpp + UI web (single binary) -Usage: jean [args] +Usage: ajean [args] Application: app lance l'UI web + ouvre le navigateur (auto au double-clic du binaire) @@ -118,9 +118,9 @@ Service: start | stop | restart gérer le service (systemd sous Linux, processus en arrière-plan sous Windows) status | logs état / logs en direct enable | disable auto-démarrage au boot - edit éditer $JEAN_HOME/config.env + edit éditer $AJEAN_HOME/config.env 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 + set-web-key [clé] protéger l'API de pilotage 'ajean 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) @@ -141,7 +141,7 @@ Accès distant (ajean.link) : link code génère un code d'appairage (valable 10 min, à usage unique) pour le portail link status | logout état du lien / oublier le token link (sans argument) affiche l'aide des sous-commandes link - link serve exécute le worker au premier plan (utilisé par jean-link.service ; pendant de 'jean serve') + link serve exécute le worker au premier plan (utilisé par jean-link.service ; pendant de 'ajean serve') Mode agent: agent [on|off|status] active TOUS les outils de l'IA (shell complet + mémoire) — un seul interrupteur @@ -151,20 +151,21 @@ Backend llama.cpp : llamacpp update git pull + recompile le backend existant (arrête/redémarre le service) llamacpp status commit courant, backend détecté, retard sur origin -Entrypoint (utilisé par jean.service) : +Entrypoint (utilisé par ajean.service) : serve lit config.env et exec le binaire llama-server Installation: where affiche où sont le binaire, la config, la mémoire et le dossier de travail de l'agent install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config) uninstall désinstaller - update [--check] mettre à jour jean depuis les releases GitHub (--check = signale sans installer) + update [--check] mettre à jour AJEAN depuis les releases GitHub (--check = signale sans installer) Env: - JEAN_HOME racine (défaut: /etc/jean sur Linux/macOS, %%ProgramData%%\jean sur Windows) - EDITOR éditeur pour 'jean edit' (défaut: nano sur Unix, notepad sur Windows) + AJEAN_HOME racine (défaut: /etc/ajean sur Linux/macOS, %%ProgramData%%\ajean sur Windows) + JEAN_HOME reste accepté (héritage) + EDITOR éditeur pour 'ajean edit' (défaut: nano sur Unix, notepad sur Windows) -Config: $JEAN_HOME/config.env +Config: $AJEAN_HOME/config.env `, Version) } @@ -234,6 +235,7 @@ func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } func legacyToolsFlag() string { return filepath.Join(AjeanHome(), ".tools_enabled") } func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") } func crawlKeyPath() string { return filepath.Join(AjeanHome(), ".crawl4ai_key") } + // serviceName est le nom de l'unité qui exécute llama-server. AJEAN_SERVICE et // JEAN_SERVICE sont tous deux honorés : le second est posé à la main sur des // machines qu'on ne voit pas, le renommage ne doit pas les casser. diff --git a/internal/ajean/sys_console_windows.go b/internal/ajean/sys_console_windows.go index 821c4e6..5d0eacf 100644 --- a/internal/ajean/sys_console_windows.go +++ b/internal/ajean/sys_console_windows.go @@ -9,7 +9,7 @@ package ajean // qui s'ouvre et se ferme. En contrepartie, un binaire GUI lancé depuis un // terminal (cmd/PowerShell) n'écrit nulle part par défaut : on se rattache alors // explicitement à la console du parent et on réouvre stdout/stderr/stdin dessus, -// pour que l'usage CLI (`jean web`, `jean status`, …) reste lisible. +// pour que l'usage CLI (`ajean web`, `ajean status`, …) reste lisible. import ( "os" diff --git a/internal/ajean/sys_env.go b/internal/ajean/sys_env.go new file mode 100644 index 0000000..f0aa377 --- /dev/null +++ b/internal/ajean/sys_env.go @@ -0,0 +1,23 @@ +package ajean + +import ( + "os" + "strings" +) + +// envAny lit la première variable d'environnement renseignée parmi names. +// +// Sert au renommage jean → AJEAN : chaque réglage s'appelle désormais AJEAN_*, +// mais l'ancien nom JEAN_* reste lu en second. Ces variables sont posées à la +// main — dans un shell, un fichier d'unité, un docker-compose, un script de +// déploiement — et rien ne nous permet de les retrouver pour les réécrire. Les +// cesser de les lire, c'est casser silencieusement des installations qui +// marchaient, avec un réglage qui redevient sa valeur par défaut sans le dire. +func envAny(names ...string) string { + for _, n := range names { + if v := strings.TrimSpace(os.Getenv(n)); v != "" { + return v + } + } + return "" +} diff --git a/internal/ajean/sys_firstrun_other.go b/internal/ajean/sys_firstrun_other.go index b6eec3c..b99bf1a 100644 --- a/internal/ajean/sys_firstrun_other.go +++ b/internal/ajean/sys_firstrun_other.go @@ -4,7 +4,7 @@ package ajean import "os" -// appFirstRun : hors Windows, `jean app` est lancé depuis un terminal par +// appFirstRun : hors Windows, `ajean app` est lancé depuis un terminal par // quelqu'un qui a déjà suivi l'installation — pas de double-clic à désambiguïser, // donc pas de boîte de dialogue. On se contente de provisionner les données au // premier lancement, comme avant. Ne relance jamais (renvoie toujours false). diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index f9ad683..8c4562c 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -195,6 +195,7 @@ func runAsInstaller(target string) bool { return launch(target) // l'instance en cours reprend la main (port occupé) } stopProcesses(running) + target = migrateThenResolveTarget(target) if err := replaceInstalled(target); err != nil { messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.", "AJEAN", mbIconInfo) @@ -205,6 +206,7 @@ func runAsInstaller(target string) bool { case cmp > 0: // Mise à jour, application arrêtée : rien à décider, on remplace et on // démarre. C'est le cas courant et il doit rester muet. + target = migrateThenResolveTarget(target) _ = replaceInstalled(target) ensureShortcuts(target) return launch(target) @@ -225,7 +227,22 @@ func verLabel(v string) string { // replaceInstalled écrase le binaire installé par celui qu'on exécute. Le // renommage préalable en .old permet de remplacer un exécutable encore ouvert -// (même mécanique que replaceBinary pour `jean update`). +// (même mécanique que replaceBinary pour `ajean update`). +// migrateThenResolveTarget tente la migration du dossier de données maintenant +// qu'aucune instance ne tient plus de handle, puis renvoie le chemin du binaire +// installé DANS le dossier résultant. +// +// Recalculer le chemin est indispensable et pas cosmétique : `target` a été +// calculé avant la migration, il désigne donc encore …\jean\bin\ajean.exe. Sans +// ce recalcul, on réinstallerait le binaire dans l'ancien dossier qu'on vient +// tout juste de déplacer, en recréant au passage une arborescence fantôme. +func migrateThenResolveTarget(target string) string { + if !retryHomeMigration() { + return target + } + return installedExePath() +} + func replaceInstalled(target string) error { old := target + ".old" _ = os.Remove(old) @@ -321,7 +338,7 @@ func portBusy(port int) bool { // posées par goversioninfo, cf cmd/jean/versioninfo.json), comme le fait // l'onglet « Détails » des propriétés Windows. // -// ⚠️ NE PAS remplacer par un `jean version` exécuté : le binaire est compilé en +// ⚠️ NE PAS remplacer par un `ajean version` exécuté : le binaire est compilé en // sous-système GUI, il s'attache à la console du parent et n'écrit RIEN dans un // tuyau. La sortie capturée est vide, donc la comparaison de versions échouerait // toujours en silence et la mise à jour ne se ferait jamais (vérifié). diff --git a/internal/ajean/sys_firstrun_windows_test.go b/internal/ajean/sys_firstrun_windows_test.go index 51a651a..b5cde1e 100644 --- a/internal/ajean/sys_firstrun_windows_test.go +++ b/internal/ajean/sys_firstrun_windows_test.go @@ -12,7 +12,7 @@ import ( // binaryVersion pilote la mise à jour automatique du binaire installé quand on // lance le fichier téléchargé : si elle renvoie du vide, plus aucune mise à jour // n'a lieu, et en silence. C'est exactement ce qui arrivait avec la première -// implémentation (exécuter `jean version`, dont la sortie est vide pour un +// implémentation (exécuter `ajean version`, dont la sortie est vide pour un // binaire en sous-système GUI). D'où ces tests sur la plomberie syscall. func TestBinaryVersionReadsResource(t *testing.T) { // kernel32.dll porte toujours une ressource de version. diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 404e83d..03c5609 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -17,7 +17,7 @@ import ( // sur « systemctl: command not found » / « /etc/default/jean », cf. issue #4). // Implémentation prudente ; à valider sur une machine Apple. -const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart +const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart # Le service launchd lit ce fichier et lance ton binaire llama.cpp. # Chemins @@ -38,9 +38,15 @@ NGL="999" EXTRA_ARGS="" ` -// launchdPlistTemplate — champs formatés : Label, UserName, WorkingDirectory, -// JEAN_HOME, StandardOutPath, StandardErrorPath. KeepAlive/SuccessfulExit=false -// ≈ Restart=on-failure ; RunAtLoad relance au boot une fois chargé avec `-w`. +// launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName, +// WorkingDirectory, AJEAN_HOME, JEAN_HOME, StandardOutPath, StandardErrorPath. +// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au +// boot une fois chargé avec `-w`. +// +// Le chemin du binaire est injecté et non codé en dur : il a changé avec le +// renommage, et un plist figé sur l'ancien chemin donnerait un daemon qui refuse +// de démarrer. Les deux variables d'environnement sont posées car le code lit +// encore JEAN_HOME en héritage. const launchdPlistTemplate = ` @@ -48,13 +54,13 @@ const launchdPlistTemplate = ` Label%s ProgramArguments - /usr/local/bin/jean + %s serve UserName%s WorkingDirectory%s EnvironmentVariables - JEAN_HOME%s + AJEAN_HOME%sJEAN_HOME%s RunAtLoad KeepAlive SuccessfulExit @@ -66,7 +72,7 @@ const launchdPlistTemplate = ` func cmdInstall(args []string) error { if os.Geteuid() != 0 { - return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)") + return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)") } targetUser := os.Getenv("SUDO_USER") if targetUser == "" { @@ -108,7 +114,7 @@ func cmdInstall(args []string) error { fmt.Printf(" %s écrit %s\n", green("✓"), conf) } - // 3. Symlink idempotent vers /usr/local/bin/jean — même garde-fou que Linux + // 3. Symlink idempotent vers /usr/local/bin/ajean — même garde-fou que Linux // (issue #5 : ne pas se lier sur soi-même si on tourne déjà depuis la cible). self, err := os.Executable() if err != nil { @@ -117,7 +123,7 @@ func cmdInstall(args []string) error { if rp, e := filepath.EvalSymlinks(self); e == nil { self = rp } - target := "/usr/local/bin/jean" + target := installedExePath() if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } @@ -137,6 +143,18 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) } + // 3 bis. Alias hérité : `jean` doit rester tapable (voir sys_paths.go). + if alias := legacyExePath(); alias != target { + if rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self { + _ = os.Remove(alias) + if err := os.Symlink(target, alias); err != nil { + fmt.Printf(" %s alias %s non créé (%v) — « ajean » reste disponible\n", yellow("[info]"), alias, err) + } else { + fmt.Printf(" %s %s -> %s (alias hérité)\n", green("✓"), alias, target) + } + } + } + // 4. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME. // Les deux clés sont écrites : des scripts d'utilisateurs sourcent ce fichier // et lisent encore $JEAN_HOME. @@ -149,7 +167,7 @@ func cmdInstall(args []string) error { // 5. LaunchDaemon plist (le log va sous JEAN_HOME, accessible au user cible) logPath := filepath.Join(ajeanHome, svc+".log") - plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, ajeanHome, ajeanHome, logPath, logPath) + plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, ajeanHome, logPath, logPath) plistPath := launchdPlistPath(svc) if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { return err @@ -162,16 +180,16 @@ func cmdInstall(args []string) error { 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(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" ajean edit")) fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") - fmt.Printf(" 2. démarre le service: %s\n", bold("sudo jean start")) - fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web")) + fmt.Printf(" 2. démarre le service: %s\n", bold("sudo ajean start")) + fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web")) return nil } func cmdUninstall(args []string) error { if os.Geteuid() != 0 { - return fmt.Errorf("jean uninstall doit être exécuté en root") + return fmt.Errorf("ajean uninstall doit être exécuté en root") } svc := serviceName() _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run() diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 2d3f0b0..6441c82 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -11,7 +11,7 @@ import ( "strings" ) -const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart +const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart # Le service systemd lit ce fichier et lance ton binaire llama.cpp. # Chemins @@ -61,7 +61,7 @@ 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)") + return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)") } targetUser := os.Getenv("SUDO_USER") if targetUser == "" { @@ -113,7 +113,7 @@ func cmdInstall(args []string) error { } target := installedExePath() // Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a - // posé le binaire dans /usr/local/bin/jean puis lancé `sudo jean install`), + // posé le binaire dans /usr/local/bin/jean puis lancé `sudo ajean install`), // ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire // et créerait /usr/local/bin/jean -> /usr/local/bin/jean (« Too many levels // of symbolic links »). On compare les chemins réels (résolus). @@ -134,7 +134,7 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) } - // 3 bis. Alias hérité : `jean` doit rester tapable. Des utilisateurs ont des + // 3 bis. Alias hérité : `ajean` doit rester tapable. Des utilisateurs ont des // alias shell, des cron, des scripts de déploiement et des tutos qui appellent // « jean » — on ne contrôle rien de tout ça, et un binaire renommé qui casse // la commande d'hier est une régression pour eux. Le lien pointe sur le @@ -185,16 +185,16 @@ func cmdInstall(args []string) error { 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(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" ajean 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")) + fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" ajean start")) + fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web")) return nil } func cmdUninstall(args []string) error { if os.Geteuid() != 0 { - return fmt.Errorf("jean uninstall doit être exécuté en root") + return fmt.Errorf("ajean uninstall doit être exécuté en root") } svc := serviceName() keepData := false diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index 4cfd47e..6d90438 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -12,12 +12,12 @@ import ( ) // On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate. -// `jean install` simply provisions the data directory and a starter config; the +// `ajean install` simply provisions the data directory and a starter config; the // service itself is managed by the PID-file supervisor in sys_service_windows.go -// (jean start / stop / status), which needs no admin rights. +// (ajean start / stop / status), which needs no admin rights. -const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart -# jean serve lit ce fichier et lance ton binaire llama.cpp. +const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart +# ajean serve lit ce fichier et lance ton binaire llama.cpp. # Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent) BIN="C:/llama.cpp/build/bin/Release/llama-server.exe" @@ -59,7 +59,7 @@ func cmdInstall(args []string) error { } // 3. Install the binary into JEAN_HOME\bin and put that dir on the user PATH, - // so `jean` is callable from any shell (this is the Windows analogue of the + // so `ajean` is callable from any shell (this is the Windows analogue of the // /usr/local/bin symlink the Unix installer creates). binDir := filepath.Join(jeanHome, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { @@ -86,9 +86,9 @@ func cmdInstall(args []string) error { fmt.Println() fmt.Printf("%s installation terminée.\n", green("[ok]")) fmt.Printf("\nProchaines étapes :\n") - fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("jean edit")) - fmt.Printf(" 2. démarre le service: %s\n", bold("jean start")) - fmt.Printf(" 3. UI web : %s\n", bold("jean web")) + fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("ajean edit")) + fmt.Printf(" 2. démarre le service: %s\n", bold("ajean start")) + fmt.Printf(" 3. UI web : %s\n", bold("ajean web")) if onPath { fmt.Printf("\n%s ouvre un NOUVEAU terminal pour que 'jean' soit reconnu (le PATH n'est lu qu'au démarrage du shell).\n", dim("[info]")) } else { diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go index 9aa5141..d56389d 100644 --- a/internal/ajean/sys_migrate.go +++ b/internal/ajean/sys_migrate.go @@ -50,6 +50,26 @@ func resolveDefaultHome() string { return migrateHome(defaultAjeanHome(), legacyDefaultHome()) } +// retryHomeMigration relance la résolution — donc la migration — après avoir +// arrêté ce qui tournait. Renvoie true si le dossier a effectivement changé. +// +// Raison d'être : sous Windows, un rename de dossier échoue tant qu'un process +// y tient un handle. Or le service AJEAN détaché en tient en permanence, si bien +// que la migration tentée au démarrage échouerait indéfiniment et que la machine +// resterait sur l'ancien chemin. L'installateur, lui, a une fenêtre où tout est +// arrêté : c'est là qu'on retente. +// +// À N'APPELER QUE dans cette fenêtre, et avant que quoi que ce soit d'autre +// n'ait ouvert de fichier de données : réinitialiser le cache n'est ni atomique +// ni sûr vis-à-vis des goroutines, et surtout les chemins déjà calculés par +// l'appelant deviennent obsolètes (voir migrateThenResolveTarget). +func retryHomeMigration() bool { + before := migratedDefaultHome() + homeOnce = sync.Once{} + homePath = "" + return migratedDefaultHome() != before +} + // migrateHome contient toute la logique de migration, isolée des chemins réels // de la plateforme pour être testable telle quelle. Renvoie le dossier à // utiliser — le nouveau si la migration a réussi ou n'était pas nécessaire, diff --git a/internal/ajean/sys_paths.go b/internal/ajean/sys_paths.go index 7ef148d..1a1264f 100644 --- a/internal/ajean/sys_paths.go +++ b/internal/ajean/sys_paths.go @@ -13,7 +13,7 @@ import ( // emplacements invisibles : on ne sait plus si le .exe qu'on a double-cliqué EST // l'application ou seulement un installateur, ni où atterrissent la config, les // modèles et les fichiers que l'agent écrit. On centralise donc la réponse ici, -// et on l'expose à la fois en ligne de commande (`jean where`) et dans l'UI. +// et on l'expose à la fois en ligne de commande (`ajean where`) et dans l'UI. type ajeanPaths struct { Home string `json:"home"` // JEAN_HOME : racine des données diff --git a/internal/ajean/sys_platform_unix.go b/internal/ajean/sys_platform_unix.go index b36f497..a2d4ab2 100644 --- a/internal/ajean/sys_platform_unix.go +++ b/internal/ajean/sys_platform_unix.go @@ -59,7 +59,7 @@ func isWritableDir(dir string) bool { return true } -// defaultEditor is used by `jean edit` when $EDITOR is unset. +// defaultEditor is used by `ajean edit` when $EDITOR is unset. func defaultEditor() string { return "nano" } // hideCmd : no-op sur Unix (pas de fenêtre de console à masquer). diff --git a/internal/ajean/sys_platform_windows.go b/internal/ajean/sys_platform_windows.go index c7d77ec..bb3fff3 100644 --- a/internal/ajean/sys_platform_windows.go +++ b/internal/ajean/sys_platform_windows.go @@ -60,7 +60,7 @@ func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") } // legacyDefaultHome is the pre-rename data root, migrated on first run. func legacyDefaultHome() string { return filepath.Join(homeRoot(), "jean") } -// defaultEditor is used by `jean edit` when $EDITOR is unset. +// defaultEditor is used by `ajean edit` when $EDITOR is unset. func defaultEditor() string { return "notepad" } // openBrowser ouvre l'URL dans le navigateur par défaut. rundll32 évite les @@ -130,7 +130,7 @@ func setLibraryPath(dir string) { } // execServer runs llama-server as a child process and waits for it. Windows has -// no exec() that replaces the current image, so `jean serve` stays alive as the +// no exec() that replaces the current image, so `ajean serve` stays alive as the // parent (this is the detached process the service supervisor tracks). // args[0] is the binary path; the rest are its arguments. func execServer(bin string, args []string) error { @@ -232,7 +232,7 @@ func msvcGenerator() string { // ensureCompiler makes sure an MSVC C++ toolchain is available, installing the // Visual Studio 2022 Build Tools (VCTools workload) via winget if not. This is a -// large download but keeps `jean llamacpp install` fully unattended on a bare +// large download but keeps `ajean llamacpp install` fully unattended on a bare // Windows box. func ensureCompiler() error { if msvcInstallVersion() != "" { @@ -354,7 +354,7 @@ Sans elle, CMake échoue sur « No CUDA toolset found ». Pour corriger, au choi 1. relance l'installeur du CUDA Toolkit (installation personnalisée) et coche « CUDA → Visual Studio Integration » — Visual Studio doit déjà être installé à ce moment-là ; 2. ou copie (en admin) les fichiers de %s - vers le dossier BuildCustomizations ci-dessus, puis relance jean llamacpp install`, installPath, src) + vers le dossier BuildCustomizations ci-dessus, puis relance ajean llamacpp install`, installPath, src) } // cudaPathEnv returns the CUDA toolkit env vars the MSBuild CUDA integration diff --git a/internal/ajean/sys_service.go b/internal/ajean/sys_service.go index fa3d46f..3a688c4 100644 --- a/internal/ajean/sys_service.go +++ b/internal/ajean/sys_service.go @@ -28,7 +28,7 @@ func editConfig() error { if err := cmd.Run(); err != nil { return err } - fmt.Println(dim("[info] jean restart pour appliquer")) + fmt.Println(dim("[info] ajean restart pour appliquer")) return nil } diff --git a/internal/ajean/sys_service_darwin.go b/internal/ajean/sys_service_darwin.go index c277e46..d5e947a 100644 --- a/internal/ajean/sys_service_darwin.go +++ b/internal/ajean/sys_service_darwin.go @@ -54,8 +54,8 @@ func serviceAction(action string) error { svc := serviceName() plist := launchdPlistPath(svc) // Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où Jean tourne - // via Jean.app sans jamais passer par `sudo jean install` : on gère alors le - // service comme sous Windows, avec un `jean serve` détaché suivi par un fichier + // via Jean.app sans jamais passer par `sudo ajean install` : on gère alors le + // service comme sous Windows, avec un `ajean serve` détaché suivi par un fichier // PID. Aucun droit root requis. Sans ça, `start` échouait sur un plist absent // et l'UI affichait éternellement « service arrêté ». if _, err := os.Stat(plist); err != nil { @@ -117,7 +117,7 @@ func checkStarted(svc string) error { fmt.Println(strings.Join(lines, "\n")) } fmt.Println("------------------------------------------------") - fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n") return fmt.Errorf("service %s non démarré", svc) } @@ -144,7 +144,7 @@ func serviceIsActive() bool { } // --------------------------------------------------------------------------- -// Mode utilisateur (sans launchd) — même approche que Windows : `jean serve` est +// Mode utilisateur (sans launchd) — même approche que Windows : `ajean serve` est // lancé détaché, son PID va dans JEAN_HOME/.pid et sa sortie dans // JEAN_HOME/.log. Setsid le détache de notre session pour qu'il survive à // la fermeture de Jean.app, et permet de tuer tout le groupe (llama-server @@ -175,7 +175,7 @@ func userSvcAction(action string) error { return nil case "enable", "disable": fmt.Printf("%s '%s' réclame un service système : lance %s pour installer le LaunchDaemon.\n", - yellow("[info]"), action, bold("sudo jean install")) + yellow("[info]"), action, bold("sudo ajean install")) return nil } return fmt.Errorf("action inconnue: %s", action) @@ -207,7 +207,7 @@ func userSvcStart() error { cmd.Dir = AjeanHome() cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { - return fmt.Errorf("démarrage de 'jean serve': %w", err) + return fmt.Errorf("démarrage de 'ajean serve': %w", err) } pid := cmd.Process.Pid if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil { @@ -221,14 +221,14 @@ func userCheckStarted(pid int) error { time.Sleep(2 * time.Second) if processAlive(pid) { fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid) - fmt.Printf(" logs: %s (jean logs pour suivre)\n", dim(logFilePath())) + fmt.Printf(" logs: %s (ajean logs pour suivre)\n", dim(logFilePath())) return nil } fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName()) fmt.Println("------------------------------------------------") fmt.Print(tailFile(logFilePath(), 20)) fmt.Println("------------------------------------------------") - fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n") _ = os.Remove(pidFilePath()) return fmt.Errorf("service %s non démarré", serviceName()) } diff --git a/internal/ajean/sys_service_linux.go b/internal/ajean/sys_service_linux.go index 0558e22..00c16b4 100644 --- a/internal/ajean/sys_service_linux.go +++ b/internal/ajean/sys_service_linux.go @@ -55,7 +55,7 @@ func checkStarted(svc string) error { logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output() fmt.Print(string(logs)) fmt.Println("------------------------------------------------") - fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n") return fmt.Errorf("service %s non démarré", svc) } diff --git a/internal/ajean/sys_service_windows.go b/internal/ajean/sys_service_windows.go index da9f807..fe05806 100644 --- a/internal/ajean/sys_service_windows.go +++ b/internal/ajean/sys_service_windows.go @@ -16,7 +16,7 @@ import ( ) // On Windows there is no systemd, so "the service" is a background copy of -// `jean serve` that this process launches detached. We track it with a PID file +// `ajean serve` that this process launches detached. We track it with a PID file // and stream its output to a log file under JEAN_HOME. This needs no admin // rights and no external tools beyond the always-present tasklist/taskkill. @@ -72,14 +72,14 @@ func svcStart() error { cmd.Stdout = logf cmd.Stderr = logf cmd.Dir = AjeanHome() // les chemins relatifs de config.env se résolvent depuis JEAN_HOME - // createNoWindow + HideWindow : le service enfant (`jean serve`) ne doit JAMAIS + // createNoWindow + HideWindow : le service enfant (`ajean serve`) ne doit JAMAIS // faire clignoter de console noire quand Jean est lancé en mode app (double-clic). cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow, } if err := cmd.Start(); err != nil { - return fmt.Errorf("démarrage de 'jean serve': %w", err) + return fmt.Errorf("démarrage de 'ajean serve': %w", err) } pid := cmd.Process.Pid if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil { @@ -94,14 +94,14 @@ func checkStarted(pid int) error { time.Sleep(2 * time.Second) if processAlive(pid) { fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid) - fmt.Printf(" logs: %s (jean logs pour suivre)\n", dim(logFilePath())) + fmt.Printf(" logs: %s (ajean logs pour suivre)\n", dim(logFilePath())) return nil } fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName()) fmt.Println("------------------------------------------------") fmt.Print(tailFile(logFilePath(), 20)) fmt.Println("------------------------------------------------") - fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n") _ = os.Remove(pidFilePath()) return fmt.Errorf("service %s non démarré", serviceName()) } diff --git a/internal/ajean/sys_tray_windows.go b/internal/ajean/sys_tray_windows.go index 6fb8ed2..9ce58f1 100644 --- a/internal/ajean/sys_tray_windows.go +++ b/internal/ajean/sys_tray_windows.go @@ -3,7 +3,7 @@ package ajean // sys_tray_windows.go — icône de Jean dans la zone de notification Windows (system -// tray). Quand on lance l'app (double-clic ou `jean app`), l'UI web s'ouvre dans +// tray). Quand on lance l'app (double-clic ou `ajean app`), l'UI web s'ouvre dans // le navigateur ET une petite icône apparaît près de l'horloge : elle montre que // Jean tourne et offre un menu « Ouvrir Jean » / « Quitter ». // diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index 9717199..f1a8bd8 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -17,7 +17,7 @@ import ( "golang.org/x/mod/semver" ) -// jean update — met à jour le binaire depuis les releases GitHub du projet. +// ajean update — met à jour le binaire depuis les releases GitHub du projet. // Périmètre minimal : compare la version, télécharge l'asset correspondant à // l'OS/arch courant, remplace le binaire en place, puis affiche quoi redémarrer. // AUCUN redémarrage de service automatique (choix volontaire, plus sûr). @@ -109,7 +109,7 @@ type updateInfo struct { } // checkForUpdate interroge GitHub et compare à la version courante. Réutilisé -// par `jean update` (CLI) et par l'endpoint web /api/update. +// par `ajean update` (CLI) et par l'endpoint web /api/update. func checkForUpdate() (updateInfo, error) { info := updateInfo{Current: Version} rel, err := fetchLatestRelease() @@ -158,7 +158,7 @@ func applyUpdate() (string, error) { // Contrôle des droits AVANT de télécharger : sans ça, l'échec survient sur le // os.Create du fichier temporaire et l'utilisateur reçoit un « open // /usr/local/bin/.ajean-update.tmp: permission denied » incompréhensible, - // notamment depuis le bouton de l'UI web (jean web lancé sans sudo). + // notamment depuis le bouton de l'UI web (ajean web lancé sans sudo). if err := checkUpdateWritable(exe); err != nil { return "", err } @@ -203,7 +203,7 @@ func updatePermissionError(exe string) error { if runtime.GOOS == "windows" { return fmt.Errorf("droits insuffisants pour remplacer %s — relance AJEAN en administrateur puis réessaie", exe) } - return fmt.Errorf("droits insuffisants pour remplacer %s (le binaire appartient à root) — lance la mise à jour en ligne de commande : sudo jean update", exe) + return fmt.Errorf("droits insuffisants pour remplacer %s (le binaire appartient à root) — lance la mise à jour en ligne de commande : sudo ajean update", exe) } // checkUpdateWritable vérifie qu'on peut écrire dans le dossier du binaire, en @@ -243,7 +243,7 @@ func cmdUpdate(args []string) error { fmt.Printf("nouvelle version disponible : %s (actuelle : %s)\n", info.Latest, Version) fmt.Printf(" %s\n", info.URL) if checkOnly { - fmt.Println("lance 'jean update' pour l'installer.") + fmt.Println("lance 'ajean update' pour l'installer.") return nil } fmt.Printf("téléchargement de %s…\n", updateAssetName()) diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 4574494..abfe9c7 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1256,7 +1256,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
    -
    Les téléchargements, eux, vont toujours dans le dossier jean.
    +
    Les téléchargements, eux, vont toujours dans le dossier AJEAN.
    diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index e2a1589..df3a247 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -428,7 +428,7 @@
    -
    Les téléchargements, eux, vont toujours dans le dossier jean.
    +
    Les téléchargements, eux, vont toujours dans le dossier AJEAN.
    diff --git a/internal/ajean/web_auth.go b/internal/ajean/web_auth.go index ae0e7bd..a7f6e3f 100644 --- a/internal/ajean/web_auth.go +++ b/internal/ajean/web_auth.go @@ -11,7 +11,7 @@ import ( "strings" ) -// web_auth.go protège l'API de pilotage (jean web) quand elle est exposée sur +// web_auth.go protège l'API de pilotage (ajean 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. // @@ -70,9 +70,9 @@ func checkBearer(r *http.Request, key string) bool { // cmdSetWebKey sets (or clears) the control-API key in $JEAN_HOME/.web_key. // -// jean set-web-key définit la clé -// jean set-web-key génère une clé aléatoire -// jean set-web-key "" supprime la protection (API ouverte) +// ajean set-web-key définit la clé +// ajean set-web-key génère une clé aléatoire +// ajean 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. @@ -106,6 +106,6 @@ func cmdSetWebKey(args []string) error { } 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") + fmt.Printf(" (relance 'ajean web' si le serveur web tourne déjà — non requis, lu à chaud)\n") return nil } diff --git a/internal/ajean/web_link_api.go b/internal/ajean/web_link_api.go index ef71886..0c6f420 100644 --- a/internal/ajean/web_link_api.go +++ b/internal/ajean/web_link_api.go @@ -3,7 +3,7 @@ package ajean // web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI // web de Jean, pour éviter le terminal. Le panneau « Accès distant » ouvre une // popup app.ajean.link/connect.html qui gère compte + paiement puis renvoie une -// clé de liaison (jl_…) ; l'UI la POSTe ici, et Jean fait le `jean link` tout +// clé de liaison (jl_…) ; l'UI la POSTe ici, et Jean fait le `ajean link` tout // seul (écrit le token + (re)démarre le service). // // Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais, @@ -79,7 +79,7 @@ func handleLinkStart(w http.ResponseWriter, r *http.Request) { } // handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie -// la clé (équivalent `jean link stop` + `jean link logout`). +// la clé (équivalent `ajean link stop` + `ajean link logout`). func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) { _ = linkServiceCtl("stop") _ = removeLinkToken() @@ -89,7 +89,7 @@ func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) { // handleLinkPairCode (POST /api/link/paircode) : génère un code d'appairage frais // (usage unique, TTL 10 min) + l'empreinte E2E, à saisir UNE fois dans le portail // distant lors de la première connexion (confirmation de la boîte noire). Évite le -// détour par `jean link code` en terminal. +// détour par `ajean link code` en terminal. func handleLinkPairCode(w http.ResponseWriter, r *http.Request) { code, err := newPairCode() if err != nil { diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index 0b8d514..fe04fa0 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -45,10 +45,10 @@ func cmdWeb(args []string) error { return err } } - fmt.Printf("[jean web] http://%s (Ctrl-C pour arrêter)\n", addr) + fmt.Printf("[ajean 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")) + fmt.Printf(" %s\n", bold("ajean set-web-key")) } else { fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]")) } @@ -56,13 +56,13 @@ func cmdWeb(args []string) error { } // newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être -// réutilisé par `jean link`, qui sert ce même mux à travers le tunnel sans +// réutilisé par `ajean link`, qui sert ce même mux à travers le tunnel sans // repasser par un écouteur TCP local. var convLoadOnce sync.Once func newWebMux() *http.ServeMux { - // Charge l'état de conversation persisté (une fois par process : jean web ET - // jean link serve appellent newWebMux). + // Charge l'état de conversation persisté (une fois par process : ajean web ET + // ajean link serve appellent newWebMux). convLoadOnce.Do(LoadConversation) // Pré-chauffe les serveurs MCP en tâche de fond : sinon le handshake (plusieurs // secondes pour un serveur lancé via npx) est payé par le premier message. @@ -120,7 +120,7 @@ func newWebMux() *http.ServeMux { api("/api/apikey", handleAPIKey) api("/api/oai/public", handleOAIPublic) api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link) - api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → jean link + api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → ajean link api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion From 8a2b5c66876c1d9d6d51cd584ccd1070bc6f999e Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 14:24:53 +0200 Subject: [PATCH 199/279] Migration : reecrire les chemins absolus pointant vers l'ancien dossier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug trouve en testant la migration sur une vraie machine (5,4 Go, 51 351 fichiers). Le rename du dossier reussissait, mais config.env contenait BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe soit un chemin ABSOLU vers le dossier qu'on venait de renommer. Resultat : le dossier migre intact, et llama-server ne demarre plus. Ca touche tous ceux qui ont fait un `llamacpp install`, donc le cas nominal, pas un cas limite. Les presets de configs/ portent le meme BIN et etaient touches pareil (deux presets concernes sur la machine de test). Apres un rename reussi, on reecrit donc les references a l'ancien chemin dans les fichiers de config qu'on ecrit nous-memes : config.env, model_dirs.json, mcp.json, webprefs.json et les presets de configs/. Les deux ecritures de separateur sont couvertes, Windows acceptant \ et / dans une meme valeur. Restreint a ces fichiers texte : on ne reecrit pas a l'aveugle les 51 000 fichiers d'un dossier de donnees. Les fichiers d'etat du service sont repris au passage (jean.pid/jean.log -> ajean.pid/ajean.log), meme raison que pour le worker de lien : le PID dit si le service tourne, l'ignorer ferait demarrer un second service. Non traite volontairement : l'arborescence de build llama.cpp (CMakeCache.txt et consorts) contient aussi des chemins absolus. Elle est regenerable par `ajean llamacpp update` et la reecrire en masse serait plus risque qu'utile. Verifie de bout en bout sur la machine de test : service demarre, llama-server chargé depuis le nouveau chemin, /health ok, completion reelle ok. Deux tests ajoutes reproduisant le cas. --- internal/ajean/sys_migrate.go | 82 ++++++++++++++++++++++++++++++ internal/ajean/sys_migrate_test.go | 72 ++++++++++++++++++++++++++ internal/ajean/sys_paths.go | 4 +- 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go index d56389d..dfb7d7c 100644 --- a/internal/ajean/sys_migrate.go +++ b/internal/ajean/sys_migrate.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" ) @@ -96,5 +97,86 @@ func migrateHome(target, legacy string) string { return legacy } fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling) + rewriteHomeReferences(legacy, sibling) return sibling } + +// configFilesToRewrite liste les fichiers de configuration susceptibles de +// contenir un chemin ABSOLU vers le dossier de données. Volontairement restreint +// à des fichiers texte, petits et écrits par nous : on ne réécrit pas à l'aveugle +// les 51 000 fichiers d'un dossier de données. +func configFilesToRewrite(home string) []string { + files := []string{ + filepath.Join(home, "config.env"), + filepath.Join(home, "model_dirs.json"), + filepath.Join(home, "mcp.json"), + filepath.Join(home, "webprefs.json"), + } + // Les presets sont des config.env alternatives : ils portent le même BIN. + presets, _ := filepath.Glob(filepath.Join(home, "configs", "*")) + return append(files, presets...) +} + +// rewriteHomeReferences réécrit les chemins absolus pointant vers l'ANCIEN +// dossier de données dans les fichiers de configuration. +// +// Sans ça, la migration casse l'installation qu'elle est censée préserver : +// `config.env` contient typiquement +// +// BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe +// +// c'est-à-dire un chemin absolu VERS le dossier qu'on vient de renommer. Le +// dossier a bougé, la ligne pointe dans le vide, et llama-server ne démarre plus. +// Ça concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal. +// +// On couvre les deux écritures de séparateur, Windows acceptant indifféremment +// « \ » et « / » dans une même valeur. Best-effort par fichier : un fichier +// illisible est sauté sans compromettre les autres. +func rewriteHomeReferences(oldHome, newHome string) { + variants := [][2]string{{oldHome, newHome}} + if slash := filepath.ToSlash(oldHome); slash != oldHome { + variants = append(variants, [2]string{slash, filepath.ToSlash(newHome)}) + } + for _, path := range configFilesToRewrite(newHome) { + b, err := os.ReadFile(path) + if err != nil { + continue + } + out := string(b) + for _, v := range variants { + out = strings.ReplaceAll(out, v[0], v[1]) + } + if out == string(b) { + continue + } + fi, err := os.Stat(path) + mode := os.FileMode(0o644) + if err == nil { + mode = fi.Mode() + } + if err := os.WriteFile(path, []byte(out), mode); err != nil { + fmt.Fprintf(os.Stderr, "[warn] %s non réécrit (%v) — vérifie les chemins absolus qu'il contient\n", path, err) + continue + } + fmt.Fprintf(os.Stderr, "[ok] chemins mis à jour dans %s\n", filepath.Base(path)) + } + adoptLegacyStateFiles(newHome) +} + +// adoptLegacyStateFiles reprend les fichiers d'état nommés d'après le service +// (jean.pid / jean.log → ajean.pid / ajean.log). Le fichier PID dit si le +// service tourne : ne pas le reprendre reviendrait à croire qu'il est arrêté et +// à en démarrer un second. +func adoptLegacyStateFiles(home string) { + for _, ext := range []string{".pid", ".log"} { + from := filepath.Join(home, legacyServiceName()+ext) + to := filepath.Join(home, "ajean"+ext) + if _, err := os.Stat(to); err == nil { + continue + } + if _, err := os.Stat(from); err != nil { + continue + } + _ = os.Rename(from, to) + } +} diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go index 87f24f4..ec1501f 100644 --- a/internal/ajean/sys_migrate_test.go +++ b/internal/ajean/sys_migrate_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -100,3 +101,74 @@ func TestMigrateHomeFallsBackWhenRenameFails(t *testing.T) { t.Fatalf("l'ancien dossier a été abîmé par une migration ratée: %q (%v)", b, err) } } + +// Le cas qui a casse une vraie machine : config.env contient un chemin ABSOLU +// vers le dossier de donnees (BIN=\backends\...\llama-server.exe). Apres +// migration ce chemin doit suivre, sinon llama-server ne demarre plus — et ca +// concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal. +func TestMigrateHomeRewritesAbsolutePaths(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + if err := os.MkdirAll(filepath.Join(legacy, "configs"), 0o755); err != nil { + t.Fatal(err) + } + + bin := filepath.Join(legacy, "backends", "llama.cpp", "build", "bin", "llama-server") + write := func(rel, body string) { + if err := os.WriteFile(filepath.Join(legacy, rel), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("config.env", "BIN="+bin+"\nMODEL=\"x.gguf\"\n") + // Les presets portent le meme BIN et doivent suivre aussi. + write(filepath.Join("configs", "gros.env"), "BIN="+bin+"\n") + // Ecriture avec des slashs : Windows accepte les deux formes. + write("model_dirs.json", `{"dirs":["`+filepath.ToSlash(legacy)+`/models"]}`) + + if got := migrateHome(target, legacy); got != target { + t.Fatalf("migration echouee: %q", got) + } + + for _, rel := range []string{"config.env", filepath.Join("configs", "gros.env"), "model_dirs.json"} { + b, err := os.ReadFile(filepath.Join(target, rel)) + if err != nil { + t.Fatalf("%s illisible: %v", rel, err) + } + if strings.Contains(string(b), legacy) || strings.Contains(string(b), filepath.ToSlash(legacy)) { + t.Errorf("%s reference encore l'ancien dossier:\n%s", rel, b) + } + if !strings.Contains(string(b), target) && !strings.Contains(string(b), filepath.ToSlash(target)) { + t.Errorf("%s ne pointe pas vers le nouveau dossier:\n%s", rel, b) + } + } + + // Le BIN reecrit doit designer un chemin reellement atteignable. + newBin := filepath.Join(target, "backends", "llama.cpp", "build", "bin", "llama-server") + if b, _ := os.ReadFile(filepath.Join(target, "config.env")); !strings.Contains(string(b), newBin) { + t.Errorf("BIN ne pointe pas sur %s:\n%s", newBin, b) + } +} + +// Les fichiers d'etat du service (jean.pid/jean.log) doivent etre repris : le +// PID dit si le service tourne, l'ignorer ferait demarrer un second service. +func TestMigrateHomeAdoptsServiceStateFiles(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + if err := os.WriteFile(filepath.Join(legacy, "jean.pid"), []byte("4242\n"), 0o644); err != nil { + t.Fatal(err) + } + + migrateHome(target, legacy) + + b, err := os.ReadFile(filepath.Join(target, "ajean.pid")) + if err != nil { + t.Fatalf("ajean.pid absent apres migration: %v", err) + } + if strings.TrimSpace(string(b)) != "4242" { + t.Fatalf("PID perdu: %q", b) + } +} diff --git a/internal/ajean/sys_paths.go b/internal/ajean/sys_paths.go index 1a1264f..2eb99f4 100644 --- a/internal/ajean/sys_paths.go +++ b/internal/ajean/sys_paths.go @@ -16,7 +16,7 @@ import ( // et on l'expose à la fois en ligne de commande (`ajean where`) et dans l'UI. type ajeanPaths struct { - Home string `json:"home"` // JEAN_HOME : racine des données + Home string `json:"home"` // AJEAN_HOME : racine des données Config string `json:"config"` // config.env Exe string `json:"exe"` // binaire en cours d'exécution Installed string `json:"installed"` // binaire installé (peut différer de Exe) @@ -64,7 +64,7 @@ func cmdWhere(args []string) error { p := currentPaths() fmt.Printf("Emplacements AJEAN\n\n") for _, row := range [][2]string{ - {"données (JEAN_HOME)", p.Home}, + {"données (AJEAN_HOME)", p.Home}, {"configuration", p.Config}, {"binaire en cours", p.Exe}, {"binaire installé", p.Installed}, From a58103ff7e4ededaccf9e42601d2c3a3338ae965 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 14:33:03 +0200 Subject: [PATCH 200/279] Migration : reecrire aussi les chemins sous leur forme JSON echappee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trouve en rejouant la migration dans un bac a sable isole. Les fichiers JSON du dossier de donnees (model_dirs.json, mcp.json, webprefs.json) sont ecrits par json.Marshal : sous Windows chaque antislash y est DOUBLE. La reecriture ne cherchait que la forme native et la forme a slashs, elle laissait donc ces fichiers pointer vers l'ancien dossier. Pire, la fonction d'echappement etait ecrite avec des litteraux bruts et remplacait en fait un antislash par lui-meme : elle ne doublait rien. Le remplacement natif matchait alors la forme JSON et produisait un fichier invalide (« invalid character 'U' in string escape code »), c'est-a-dire la liste des dossiers de modeles perdue. Un bug pire que celui qu'on corrigeait. jsonEscapePath utilise desormais des litteraux entre guillemets, avec un commentaire explicite sur le piege ("\\" est UN antislash, "\\\\" en fait deux). Test dedie qui verifie que le chemin est bien reecrit ET que le JSON reste analysable apres coup. Verifie en bac a sable sur les trois ecritures simultanement (native, slashs, JSON echappe) : les trois sont reecrites, le JSON se reparse et pointe sur le nouveau dossier. --- internal/ajean/sys_migrate.go | 27 ++++++++++++++++-- internal/ajean/sys_migrate_test.go | 44 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go index dfb7d7c..73cf727 100644 --- a/internal/ajean/sys_migrate.go +++ b/internal/ajean/sys_migrate.go @@ -129,13 +129,22 @@ func configFilesToRewrite(home string) []string { // dossier a bougé, la ligne pointe dans le vide, et llama-server ne démarre plus. // Ça concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal. // -// On couvre les deux écritures de séparateur, Windows acceptant indifféremment -// « \ » et « / » dans une même valeur. Best-effort par fichier : un fichier -// illisible est sauté sans compromettre les autres. +// Trois écritures d'un même chemin doivent être couvertes, sans quoi on croit +// avoir tout réécrit alors qu'il reste des références mortes : +// +// C:\ProgramData\jean forme native, dans config.env +// C:/ProgramData/jean Windows accepte les deux séparateurs dans une valeur +// C:\\ProgramData\\jean forme JSON, où l'antislash est échappé (model_dirs.json, +// mcp.json, webprefs.json sont écrits par json.Marshal) +// +// Best-effort par fichier : un fichier illisible est sauté sans compromettre +// les autres. func rewriteHomeReferences(oldHome, newHome string) { variants := [][2]string{{oldHome, newHome}} if slash := filepath.ToSlash(oldHome); slash != oldHome { variants = append(variants, [2]string{slash, filepath.ToSlash(newHome)}) + // Forme JSON : chaque antislash est doublé. + variants = append(variants, [2]string{jsonEscapePath(oldHome), jsonEscapePath(newHome)}) } for _, path := range configFilesToRewrite(newHome) { b, err := os.ReadFile(path) @@ -163,6 +172,18 @@ func rewriteHomeReferences(oldHome, newHome string) { adoptLegacyStateFiles(newHome) } +// jsonEscapePath renvoie l'écriture d'un chemin telle qu'elle apparaît DANS un +// fichier JSON : sous Windows, json.Marshal double chaque antislash. Le +// remplacement littéral doit donc chercher cette forme-là, sinon model_dirs.json +// et mcp.json gardent des chemins morts. +// +// Attention en modifiant : "\\" est UN antislash et "\\\\" en est deux. Une +// version antérieure utilisait des littéraux bruts et remplaçait en fait un +// antislash par lui-même, ce qui laissait passer le remplacement natif et +// produisait un JSON invalide (« invalid character 'U' in string escape code »), +// donc une liste de dossiers de modèles perdue. Couvert par un test. +func jsonEscapePath(p string) string { return strings.ReplaceAll(p, "\\", "\\\\") } + // adoptLegacyStateFiles reprend les fichiers d'état nommés d'après le service // (jean.pid / jean.log → ajean.pid / ajean.log). Le fichier PID dit si le // service tourne : ne pas le reprendre reviendrait à croire qu'il est arrêté et diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go index ec1501f..da4b885 100644 --- a/internal/ajean/sys_migrate_test.go +++ b/internal/ajean/sys_migrate_test.go @@ -1,6 +1,7 @@ package ajean import ( + "encoding/json" "os" "path/filepath" "runtime" @@ -172,3 +173,46 @@ func TestMigrateHomeAdoptsServiceStateFiles(t *testing.T) { t.Fatalf("PID perdu: %q", b) } } + +// Les fichiers JSON (model_dirs.json, mcp.json, webprefs.json) sont ecrits par +// json.Marshal : sous Windows chaque antislash y est DOUBLE. Chercher la forme +// native ne les trouve donc pas, et on croirait avoir tout reecrit en laissant +// des references mortes. Constate en bac a sable. +func TestMigrateHomeRewritesJSONEscapedPaths(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("l'echappement JSON des separateurs ne concerne que Windows") + } + root := t.TempDir() + legacy := filepath.Join(root, "jean") + target := filepath.Join(root, "ajean") + mkHome(t, legacy) + + esc := jsonEscapePath + body := `{"dirs":["` + esc(filepath.Join(legacy, "models")) + `"]}` + if err := os.WriteFile(filepath.Join(legacy, "model_dirs.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + migrateHome(target, legacy) + + b, err := os.ReadFile(filepath.Join(target, "model_dirs.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), esc(legacy)) { + t.Errorf("reference JSON a l'ancien dossier non reecrite:\n%s", b) + } + if !strings.Contains(string(b), esc(target)) { + t.Errorf("le JSON ne pointe pas vers le nouveau dossier:\n%s", b) + } + // Le fichier doit rester du JSON valide apres reecriture. + var parsed struct { + Dirs []string `json:"dirs"` + } + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatalf("JSON casse par la reecriture: %v\n%s", err, b) + } + if len(parsed.Dirs) != 1 || !strings.HasPrefix(parsed.Dirs[0], target) { + t.Fatalf("chemin decode inattendu: %+v", parsed.Dirs) + } +} From aa452f97993cbb0fb721b4299d973771b23353e7 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 15:59:41 +0200 Subject: [PATCH 201/279] v0.7.0 : Jean devient AJEAN Bump de version et notes de release. Le renommage complet jean -> AJEAN est decrit dans RELEASE_NOTES.md ; les commits precedents en portent le detail technique. --- RELEASE_NOTES.md | 28 +++++++++++++++++++++----- cmd/ajean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/ajean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/ajean/versioninfo.json | 10 ++++----- internal/ajean/run.go | 2 +- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 857fec7..3dcc955 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,10 +1,26 @@ -## Correction +Jean devient **AJEAN**, partout et plus seulement dans l'interface. Le dépôt, la commande, les fichiers, les dossiers et les services portent désormais le même nom. -**Refuser l'installation une fois la rendait impossible pour toujours.** Au premier lancement, AJEAN demande s'il doit s'installer sur l'ordinateur. Répondre Non était retenu définitivement : la question ne revenait plus jamais, même en fermant l'application et en relançant le fichier téléchargé. Il ne restait aucun moyen d'installer AJEAN depuis l'application. +Cette version a été construite autour d'une contrainte simple : **rien de ce qui fonctionne aujourd'hui ne doit cesser de fonctionner**. Il n'y a rien à réinstaller et rien à reconfigurer. -Ce refus avait été mémorisé pour éviter de reposer la question à chaque lancement. Le remède était pire que le mal. Un Non veut dire « pas maintenant », jamais « plus jamais » : la question revient désormais au prochain lancement du fichier, comme le fait n'importe quel installeur, et la fenêtre le dit explicitement. +## La commande `jean` continue de marcher -Si vous étiez bloqué dans cet état, il n'y a rien à réparer : téléchargez `jean-windows-amd64.exe` depuis cette page et lancez-le, l'installation vous sera de nouveau proposée. +`ajean` devient la commande principale, mais `jean` reste installée à côté et fait exactement la même chose. Les alias, les scripts, les tâches planifiées et les raccourcis existants ne bougent pas. + +De même, `JEAN_HOME` reste lu s'il a été défini à la main, au même titre que le nouveau `AJEAN_HOME`. Un emplacement choisi explicitement est un choix, pas un héritage à corriger. + +## Le dossier de données se renomme tout seul + +Au premier lancement, `%ProgramData%\jean` devient `%ProgramData%\ajean` (`/etc/jean` → `/etc/ajean` sous Linux et macOS). + +Le déplacement est instantané, même avec 200 Go de modèles : rien n'est recopié, le dossier est renommé sur place. Les chemins absolus qui pointaient dedans — dont le `--chat-template-file` des presets — sont mis à jour dans la foulée. + +Si le renommage ne peut pas aboutir, par exemple parce qu'AJEAN tourne encore en tâche de fond, **il ne se passe rien du tout** : l'ancien dossier reste en place et continue de servir. Rien n'est jamais copié ni supprimé, et la tentative est reprise au lancement suivant. + +## Les serveurs Linux ne perdent pas la main + +Une mise à jour remplace le binaire, pas les unités systemd. AJEAN utilise donc le service réellement installé sur la machine : un serveur qui tourne sous `jean.service` continue sous `jean.service`. + +Sans cette précaution, le premier redémarrage après mise à jour aurait échoué — sur une machine distante, cela signifie l'accès coupé sans moyen simple de le rétablir. ## Mise à jour @@ -12,6 +28,8 @@ Si vous étiez bloqué dans cet état, il n'y a rien à réparer : téléchargez ajean update ``` -Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait le même travail. +Les binaires restent publiés sous leurs deux noms, `ajean-*` et `jean-*`, le temps que le parc bascule. Les installations existantes se mettent donc à jour normalement, sans intervention. + +Sous Windows, télécharger `ajean-windows-amd64.exe` depuis cette page et le lancer fait le même travail : le fichier détecte l'installation existante et propose de la remplacer. L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas été vérifiée sur une vraie machine. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index b391700735825d70dbf15f5236fb411f86ccf915..b6574cb8223c25e926128c1bdba7db9b80a4b6d3 100644 GIT binary patch delta 209 zcmexi^}}jI1T*88&5_L2qKscAUzO0;WoLi@7;O*bvoNqQFhbcdDrB;uq_VUDGXqe@ zfWZWaox!vdkYr$(!{9hMUow<2aq?kF$H`1m|CkLJI3_boEApE&=mAxLFav`Q1KVVC zX<5!hhAf6uhD3(E$+6NC8M!9EmsS;oiviW9GE^|6PF9uC2b#h*Ib24S6>Q4nRvC8| Lkb5Sdky!%(kjW)t delta 210 zcmexi^}}jI1T*80&5_L2qKrQ#UzO0;Wn%yVeg+tA59Tv4umB+=lntXICL2mBOPeq= z0A&mqOn}%KOgjNd28J074wLgGLnj}TbePN`^@rJnfpapuw4$&XgC2t+gDH>%sbXNz zVPKtXE-lNM#gNL7$dJdtFgaFwA|vPI_tL6@Ffl!#Tm=v_OjecA2b#n_Ib24S6J`>_ NQ4nRvC8| Lkb5Sdky!%(kjW)t delta 210 zcmexi^}}jI1T*80&5_L2qKrQ#UzO0;Wn%yVeg+tA59Tv4umB+=lntXICL2mBOPeq= z0A&mqOn}%KOgjNd28J074wLgGLnj}TbePN`^@rJnfpapuw4$&XgC2t+gDH>%sbXNz zVPKtXE-lNM#gNL7$dJdtFgaFwA|vPI_tL6@Ffl!#Tm=v_OjecA2b#n_Ib24S6J`>_ N Date: Tue, 4 Aug 2026 16:23:20 +0200 Subject: [PATCH 202/279] Migration automatique de l'agencement systeme (Unix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans ca, le renommage n'atteignait pas les machines Linux installees. Le dossier de donnees y est epingle par /etc/default/jean, que notre propre installeur ecrit ; le code traitant un chemin explicite comme un ordre, il ne migrait jamais. Constate en deployant 0.7.0 sur un vrai serveur : les chemins sont restes en /etc/jean et il a fallu un script separe. La regle qui debloque : distinguer NOTRE artefact du CHOIX de l'utilisateur. Si le chemin epingle vaut exactement l'ancien emplacement par defaut, c'est notre installeur qui l'a ecrit et on migre. Toute autre valeur est une decision de l'utilisateur et reste intacte. Le dossier, /etc/default et l'unite systemd bougent ENSEMBLE : les separer donnerait le pire cas, des donnees deplacees et un service qui ne redemarre plus. Les chemins sont reecrits par parcours du dossier plutot que d'apres une liste devinee — l'experience montre qu'ils se cachent dans EXTRA_ARGS, les copies .bak et jusque dans des notes de MEMORY. backends/ et les .gguf sont sautes. Deux moments de declenchement, tous deux root et deliberes : `install`, et juste apres une mise a jour reussie. Jamais au demarrage ordinaire — un service qui reecrit des unites systemd a chaque boot finirait par en casser une, et sur un parc entier ca veut dire des machines qui ne redemarrent plus. La variante post-mise-a-jour n'arrete AUCUN service : on s'execute dans jean-link, l'arreter reviendrait a se tuer au milieu de la migration, dossier deplace et chemins pas encore reecrits. Sans danger, un rename de dossier sous Linux tolere les fichiers ouverts et un process garde son repertoire de travail par son inode. Sauvegarde prealable bloquante, et retour a l'etat initial si une etape echoue apres le deplacement. Couvert par 6 tests sur un faux /etc, dont le respect d'un chemin impose et la garantie qu'aucun service n'est touche par la variante post-MAJ. --- RELEASE_NOTES.md | 14 +- internal/ajean/sys_install_darwin.go | 9 + internal/ajean/sys_install_linux.go | 9 + internal/ajean/sys_layout.go | 298 +++++++++++++++++++++++++++ internal/ajean/sys_layout_test.go | 243 ++++++++++++++++++++++ internal/ajean/sys_update.go | 9 + 6 files changed, 577 insertions(+), 5 deletions(-) create mode 100644 internal/ajean/sys_layout.go create mode 100644 internal/ajean/sys_layout_test.go diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3dcc955..1456e2c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,17 +10,21 @@ De même, `JEAN_HOME` reste lu s'il a été défini à la main, au même titre q ## Le dossier de données se renomme tout seul -Au premier lancement, `%ProgramData%\jean` devient `%ProgramData%\ajean` (`/etc/jean` → `/etc/ajean` sous Linux et macOS). +`%ProgramData%\jean` devient `%ProgramData%\ajean`, et `/etc/jean` devient `/etc/ajean` sous Linux et macOS. Vous n'avez aucune commande à lancer : la migration se fait pendant la mise à jour. -Le déplacement est instantané, même avec 200 Go de modèles : rien n'est recopié, le dossier est renommé sur place. Les chemins absolus qui pointaient dedans — dont le `--chat-template-file` des presets — sont mis à jour dans la foulée. +Le déplacement est instantané, même avec 200 Go de modèles — rien n'est recopié, le dossier est renommé sur place. Tout ce qui pointait dedans suit : les chemins absolus de `config.env`, ceux de vos presets (dont le `--chat-template-file`), le fichier `/etc/default`, et le répertoire de travail de l'unité systemd. -Si le renommage ne peut pas aboutir, par exemple parce qu'AJEAN tourne encore en tâche de fond, **il ne se passe rien du tout** : l'ancien dossier reste en place et continue de servir. Rien n'est jamais copié ni supprimé, et la tentative est reprise au lancement suivant. +Votre clé d'accès distant est déplacée avec le reste : **aucun ré-appairage**, le tunnel se reconnecte seul. + +**Si vous aviez choisi vous-même l'emplacement** — un `JEAN_HOME` sur un autre disque, par exemple — il n'est pas touché. Un chemin défini à la main est une décision, pas un héritage à corriger. + +Et si le renommage ne peut pas aboutir, par exemple parce qu'AJEAN tourne encore en tâche de fond sous Windows, **il ne se passe rien du tout** : l'ancien dossier reste en place et continue de servir, la tentative sera reprise plus tard. Rien n'est jamais copié ni supprimé. ## Les serveurs Linux ne perdent pas la main -Une mise à jour remplace le binaire, pas les unités systemd. AJEAN utilise donc le service réellement installé sur la machine : un serveur qui tourne sous `jean.service` continue sous `jean.service`. +Les unités systemd **ne sont pas renommées**. Un serveur qui tourne sous `jean.service` continue sous `jean.service` : vos commandes `systemctl`, vos règles `sudoers` et vos scripts de supervision restent valables. Seuls les chemins qu'elles contiennent sont corrigés, et une copie de l'unité d'origine est conservée à côté. -Sans cette précaution, le premier redémarrage après mise à jour aurait échoué — sur une machine distante, cela signifie l'accès coupé sans moyen simple de le rétablir. +Une sauvegarde de la configuration, des presets et de la mémoire est déposée dans `/root` avant toute modification. En cas d'échec à n'importe quelle étape, la machine est remise dans son état initial et les services relancés. ## Mise à jour diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 03c5609..4512712 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -9,6 +9,7 @@ import ( "os/user" "path/filepath" "strings" + "sync" ) // sys_install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent @@ -87,6 +88,14 @@ func cmdInstall(args []string) error { if err != nil { return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) } + // Migration de l'agencement jean -> ajean AVANT de résoudre les chemins : + // `install` est root et délibéré, c'est le bon moment. Ne fait rien si la + // machine est déjà en ajean, ou si l'utilisateur a imposé un chemin. + if err := migrateLayout(defaultLayoutPlan()); err != nil { + return err + } + homeOnce, homePath = sync.Once{}, "" // le dossier a pu changer à l'instant + ajeanHome := defaultAjeanHome() if v := os.Getenv("JEAN_HOME"); v != "" { ajeanHome = v diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 6441c82..93b6910 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -9,6 +9,7 @@ import ( "os/user" "path/filepath" "strings" + "sync" ) const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart @@ -76,6 +77,14 @@ func cmdInstall(args []string) error { if err != nil { return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) } + // Migration de l'agencement jean -> ajean AVANT de résoudre les chemins : + // `install` est root et délibéré, c'est le bon moment. Ne fait rien si la + // machine est déjà en ajean, ou si l'utilisateur a imposé un chemin. + if err := migrateLayout(defaultLayoutPlan()); err != nil { + return err + } + homeOnce, homePath = sync.Once{}, "" // le dossier a pu changer à l'instant + ajeanHome := defaultAjeanHome() if v := os.Getenv("JEAN_HOME"); v != "" { ajeanHome = v diff --git a/internal/ajean/sys_layout.go b/internal/ajean/sys_layout.go new file mode 100644 index 0000000..41e2563 --- /dev/null +++ b/internal/ajean/sys_layout.go @@ -0,0 +1,298 @@ +package ajean + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Migration complète de l'agencement système jean → ajean (Unix). +// +// Renommer le dossier de données ne suffit pas sur une machine installée : le +// chemin est aussi inscrit dans /etc/default/jean et dans l'unité systemd. Les +// trois doivent bouger ensemble, sinon on obtient le pire des cas — des données +// déplacées et un service qui ne redémarre plus. +// +// QUAND. Jamais au démarrage ordinaire : un service qui réécrit des unités +// systemd à chaque boot finirait par en casser une, et sur un parc entier ça +// veut dire des machines qui ne redémarrent plus. Uniquement à deux moments +// délibérés et déjà privilégiés : `ajean install`, et juste après une mise à +// jour réussie — le service est de toute façon sur le point d'être relancé. +// +// CE QU'ON S'AUTORISE À DÉPLACER. Un chemin inscrit à la main est un ordre. On +// ne migre donc QUE si le chemin actuel est exactement l'ancien emplacement par +// défaut, celui que notre propre installeur a écrit. Un utilisateur qui a pointé +// JEAN_HOME vers son gros disque garde son choix, intact et silencieux. + +// layoutPlan décrit une migration d'agencement. Les chemins sont des champs +// plutôt que des constantes pour que la logique soit testable sur un faux +// système de fichiers, sans toucher au vrai /etc. +type layoutPlan struct { + Old string // /etc/jean + New string // /etc/ajean + DefaultDir string // /etc/default + UnitDir string // /etc/systemd/system + Units []string // unités à arrêter puis relancer + BackupDir string // où déposer l'archive de sécurité + + // stopUnit / startUnit / reloadUnits sont injectables pour les tests. + stopUnit func(string) error + startUnit func(string) error + unitActive func(string) bool + reloadUnits func() error + isRoot func() bool + backup func(layoutPlan) error +} + +func defaultLayoutPlan() layoutPlan { + return layoutPlan{ + Old: legacyDefaultHome(), + New: defaultAjeanHome(), + DefaultDir: "/etc/default", + UnitDir: "/etc/systemd/system", + Units: []string{serviceName(), linkServiceName()}, + BackupDir: "/root", + stopUnit: func(u string) error { return exec.Command("systemctl", "stop", u).Run() }, + startUnit: func(u string) error { return exec.Command("systemctl", "start", u).Run() }, + unitActive: func(u string) bool { + out, _ := exec.Command("systemctl", "is-active", u).Output() + return strings.TrimSpace(string(out)) == "active" + }, + reloadUnits: func() error { return exec.Command("systemctl", "daemon-reload").Run() }, + isRoot: func() bool { return os.Geteuid() == 0 }, + backup: backupLayout, + } +} + +// updateLayoutPlan est la variante utilisée juste après une mise à jour, quand +// on s'exécute DANS le service de lien. Elle n'arrête et ne relance rien. +// +// Deux raisons, et la première est fatale : +// +// 1. Arrêter jean-link depuis jean-link, c'est se tuer soi-même au milieu de la +// migration — dossier déplacé, chemins pas encore réécrits, machine cassée. +// 2. Arrêter le service d'inférence rechargerait un modèle de plusieurs +// dizaines de Go, ce qui n'a rien à faire derrière un bouton « mettre à jour ». +// +// C'est sans danger : sous Linux un rename de dossier fonctionne même avec des +// fichiers ouverts, et un process en cours garde son répertoire de travail par +// son inode. L'unité est réécrite dans la foulée, donc le prochain redémarrage — +// celui de jean-link, immédiat, puis celui du service d'inférence, plus tard — +// repart sur les bons chemins. +func updateLayoutPlan() layoutPlan { + p := defaultLayoutPlan() + p.unitActive = func(string) bool { return false } + return p +} + +// pinnedHome lit le chemin impose par /etc/default/ajean ou /etc/default/jean. +// Renvoie ("", "") si aucun des deux ne fixe de valeur. +func (p layoutPlan) pinnedHome() (file, value string) { + for _, name := range []string{"ajean", "jean"} { + f := filepath.Join(p.DefaultDir, name) + b, err := os.ReadFile(f) + if err != nil { + continue + } + for _, line := range strings.Split(string(b), "\n") { + s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "export ")) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + k, v, ok := strings.Cut(s, "=") + if !ok { + continue + } + if k = strings.TrimSpace(k); k == "AJEAN_HOME" || k == "JEAN_HOME" { + return f, strings.Trim(strings.TrimSpace(v), "\"'") + } + } + } + return "", "" +} + +// layoutNeedsMigration dit si une migration est possible ET legitime, avec la +// raison quand elle ne l'est pas (affichée telle quelle par `ajean install`). +func (p layoutPlan) layoutNeedsMigration() (bool, string) { + if !isDir(p.Old) { + return false, "" // rien à migrer : machine neuve ou déjà migrée + } + if isDir(p.New) { + return false, fmt.Sprintf("%s et %s existent tous les deux — migration à faire à la main", p.Old, p.New) + } + // Le point délicat : distinguer notre artefact d'un choix de l'utilisateur. + if file, value := p.pinnedHome(); value != "" && value != p.Old { + return false, fmt.Sprintf("%s fixe le dossier à %s — chemin choisi explicitement, laissé intact", file, value) + } + return true, "" +} + +// migrateLayout exécute la migration complète. Renvoie nil sans rien faire +// quand il n'y a rien à migrer. En cas d'échec après le déplacement, remet la +// machine dans son état initial et relance ce qui tournait. +func migrateLayout(p layoutPlan) error { + ok, why := p.layoutNeedsMigration() + if !ok { + if why != "" { + fmt.Printf("%s %s\n", dim("[info]"), why) + } + return nil + } + if !p.isRoot() { + fmt.Printf("%s dossier de données encore en %s — relance en root pour le migrer\n", yellow("[info]"), p.Old) + return nil + } + + fmt.Printf("%s migration de l'agencement %s → %s\n", cyan("[migration]"), p.Old, p.New) + + if err := p.backup(p); err != nil { + return fmt.Errorf("sauvegarde impossible, migration annulée : %w", err) + } + + var stopped []string + for _, u := range p.Units { + if p.unitActive(u) { + _ = p.stopUnit(u) + stopped = append(stopped, u) + fmt.Printf(" %s arrêté %s\n", green("✓"), u) + } + } + restart := func() { + for _, u := range stopped { + _ = p.startUnit(u) + } + } + + // Déplacement atomique : même volume, donc instantané même avec 200 Go de + // modèles, et jamais un état où les données sont à moitié quelque part. + if err := os.Rename(p.Old, p.New); err != nil { + restart() + return fmt.Errorf("déplacement impossible (%w) — rien n'a été modifié", err) + } + + // À partir d'ici, toute erreur doit rendre la machine à son état d'origine. + rollback := func(cause error) error { + _ = os.Rename(p.New, p.Old) + restart() + return fmt.Errorf("%w — état initial restauré", cause) + } + + if err := rewriteTree(p.New, p.Old, p.New); err != nil { + return rollback(err) + } + if err := writeDefaults(p); err != nil { + return rollback(err) + } + if err := rewriteUnits(p); err != nil { + return rollback(err) + } + _ = p.reloadUnits() + restart() + fmt.Printf(" %s dossier de données : %s\n", green("✓"), p.New) + return nil +} + +// backupLayout archive les petits fichiers (config, presets, mémoire, clés). +// Les .gguf et backends/ sont exclus : des dizaines de Go qui se retéléchargent, +// et qui ne sont de toute façon pas menacés — ils ne font que changer de nom de +// dossier parent. +func backupLayout(p layoutPlan) error { + if _, err := exec.LookPath("tar"); err != nil { + return err + } + if err := os.MkdirAll(p.BackupDir, 0o755); err != nil { + return err + } + dst := filepath.Join(p.BackupDir, "ajean-sauvegarde-"+time.Now().Format("20060102-150405")+".tar.gz") + cmd := exec.Command("tar", "czf", dst, "-C", p.Old, + "--exclude=./backends", "--exclude=*.gguf", "--exclude=*.gguf.part", "--exclude=*.log", ".") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) + } + fmt.Printf(" %s sauvegarde %s\n", green("✓"), dst) + return nil +} + +// rewriteTree remplace toute mention de oldPath par newPath dans les fichiers +// texte de root. On PARCOURT au lieu de deviner une liste : l'expérience montre +// que les chemins absolus se cachent partout — EXTRA_ARGS d'un preset, copies +// .bak, notes de MEMORY. Les .gguf et backends/ sont sautés (aucun chemin à y +// trouver, et les lire prendrait des heures). +func rewriteTree(root, oldPath, newPath string) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // un fichier illisible ne doit pas faire échouer la migration + } + if info.IsDir() { + if info.Name() == "backends" { + return filepath.SkipDir + } + return nil + } + switch { + case strings.HasSuffix(path, ".gguf"), strings.HasSuffix(path, ".gguf.part"): + return nil + case info.Size() > 4<<20: // au-delà, ce n'est plus de la configuration + return nil + } + b, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(b), oldPath) { + return nil + } + out := strings.ReplaceAll(string(b), oldPath, newPath) + mode := info.Mode().Perm() + if err := os.WriteFile(path, []byte(out), mode); err != nil { + return fmt.Errorf("réécriture de %s : %w", path, err) + } + fmt.Printf(" %s chemins mis à jour : %s\n", green("✓"), strings.TrimPrefix(path, root+string(os.PathSeparator))) + return nil + }) +} + +// writeDefaults pose /etc/default/ajean et repointe l'ancien fichier au lieu de +// le supprimer : des scripts d'utilisateurs le sourcent pour lire $JEAN_HOME. +func writeDefaults(p layoutPlan) error { + if err := os.MkdirAll(p.DefaultDir, 0o755); err != nil { + return err + } + body := fmt.Sprintf("# Généré par ajean — racine des données\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", p.New, p.New) + if err := os.WriteFile(filepath.Join(p.DefaultDir, "ajean"), []byte(body), 0o644); err != nil { + return err + } + legacy := filepath.Join(p.DefaultDir, "jean") + if b, err := os.ReadFile(legacy); err == nil { + if out := strings.ReplaceAll(string(b), p.Old, p.New); out != string(b) { + if err := os.WriteFile(legacy, []byte(out), 0o644); err != nil { + return err + } + } + } + return nil +} + +// rewriteUnits corrige les chemins DANS les unités, sans les renommer. Renommer +// imposerait un disable/enable et une fenêtre où plus rien n'est actif, pour un +// gain invisible : le code sait déjà utiliser l'unité réellement installée. +func rewriteUnits(p layoutPlan) error { + entries, err := filepath.Glob(filepath.Join(p.UnitDir, "*.service")) + if err != nil { + return err + } + for _, unit := range entries { + b, err := os.ReadFile(unit) + if err != nil || !strings.Contains(string(b), p.Old) { + continue + } + if err := os.WriteFile(unit+".avant-ajean", b, 0o644); err != nil { + return err + } + if err := os.WriteFile(unit, []byte(strings.ReplaceAll(string(b), p.Old, p.New)), 0o644); err != nil { + return err + } + fmt.Printf(" %s unité corrigée : %s\n", green("✓"), filepath.Base(unit)) + } + return nil +} diff --git a/internal/ajean/sys_layout_test.go b/internal/ajean/sys_layout_test.go new file mode 100644 index 0000000..81404b6 --- /dev/null +++ b/internal/ajean/sys_layout_test.go @@ -0,0 +1,243 @@ +package ajean + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeLayout monte un faux /etc : dossier de donnees installe, /etc/default et +// une unite systemd, exactement l'agencement d'une machine ou `jean install` est +// passe. Les commandes systemctl sont remplacees par des mouchards. +type fakeLayout struct { + plan layoutPlan + root string + stopped []string + started []string + active map[string]bool + backedUp bool +} + +func newFakeLayout(t *testing.T) *fakeLayout { + t.Helper() + root := t.TempDir() + f := &fakeLayout{ + root: root, + active: map[string]bool{"jean": true, "jean-link": true}, + } + old := filepath.Join(root, "etc", "jean") + for _, d := range []string{ + filepath.Join(old, "configs"), + filepath.Join(old, "MEMORY"), + filepath.Join(old, "backends", "llama.cpp", "build"), + filepath.Join(root, "etc", "default"), + filepath.Join(root, "etc", "systemd", "system"), + } { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + write := func(p, body string) { + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + // Un BIN externe (donc a NE PAS toucher) et un jinja interne (a suivre). + write(filepath.Join(old, "config.env"), + "BIN=\"/home/n/llama.cpp/llama-server\"\nEXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n") + write(filepath.Join(old, "fix.jinja"), "template") + write(filepath.Join(old, "configs", "GROS MODELE (2).env"), "EXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n") + write(filepath.Join(old, "MEMORY", "note.md"), "voir "+old+"/dataset.jsonl\n") + write(filepath.Join(old, ".e2e_key"), "cle-precieuse\n") + // Un faux modele et un gros fichier de build : ni l'un ni l'autre ne doit + // etre reecrit ni meme lu. + write(filepath.Join(old, "modele.gguf"), "binaire") + write(filepath.Join(old, "backends", "llama.cpp", "build", "cache.txt"), old+"/quelque-chose\n") + write(filepath.Join(root, "etc", "default", "jean"), "# Generated by jean install\nJEAN_HOME="+old+"\n") + write(filepath.Join(root, "etc", "systemd", "system", "jean.service"), + "[Service]\nWorkingDirectory="+old+"\nExecStart=/usr/local/bin/jean serve\n") + write(filepath.Join(root, "etc", "systemd", "system", "jean-link.service"), + "[Service]\nExecStart=/usr/local/bin/jean link serve\n") + + f.plan = layoutPlan{ + Old: old, + New: filepath.Join(root, "etc", "ajean"), + DefaultDir: filepath.Join(root, "etc", "default"), + UnitDir: filepath.Join(root, "etc", "systemd", "system"), + Units: []string{"jean", "jean-link"}, + BackupDir: filepath.Join(root, "sauvegardes"), + stopUnit: func(u string) error { f.stopped = append(f.stopped, u); f.active[u] = false; return nil }, + startUnit: func(u string) error { f.started = append(f.started, u); f.active[u] = true; return nil }, + unitActive: func(u string) bool { return f.active[u] }, + reloadUnits: func() error { return nil }, + isRoot: func() bool { return true }, + // tar refuse un chemin contenant « C: » sous Windows (il y voit un hote + // distant) : on remplace la sauvegarde par un mouchard, elle est verifiee + // pour ce qu'elle est — un prealable bloquant — dans son propre test. + backup: func(layoutPlan) error { f.backedUp = true; return nil }, + } + return f +} + +func (f *fakeLayout) read(t *testing.T, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(f.plan.New, rel)) + if err != nil { + t.Fatalf("%s illisible: %v", rel, err) + } + return string(b) +} + +// Cas nominal complet : donnees deplacees, chemins reecrits partout, /etc/default +// et unite corriges, services arretes puis relances. +func TestMigrateLayoutNominal(t *testing.T) { + f := newFakeLayout(t) + if err := migrateLayout(f.plan); err != nil { + t.Fatalf("migration echouee: %v", err) + } + + if isDir(f.plan.Old) { + t.Error("l'ancien dossier existe encore") + } + if !isDir(f.plan.New) { + t.Fatal("le nouveau dossier est absent") + } + // Le jinja interne suit, le BIN externe ne bouge pas. + conf := f.read(t, "config.env") + if !strings.Contains(conf, f.plan.New+"/fix.jinja") { + t.Errorf("le chat-template-file n'a pas suivi:\n%s", conf) + } + if !strings.Contains(conf, "/home/n/llama.cpp/llama-server") { + t.Errorf("le BIN externe a ete modifie a tort:\n%s", conf) + } + // Preset avec espaces/parentheses, et note de MEMORY. + if strings.Contains(f.read(t, filepath.Join("configs", "GROS MODELE (2).env")), f.plan.Old) { + t.Error("preset non reecrit") + } + if strings.Contains(f.read(t, filepath.Join("MEMORY", "note.md")), f.plan.Old) { + t.Error("note MEMORY non reecrite") + } + if f.read(t, ".e2e_key") != "cle-precieuse\n" { + t.Error("la cle E2E a ete alteree") + } + // backends/ doit etre saute : le reecrire ferait perdre des heures pour rien. + if !strings.Contains(f.read(t, filepath.Join("backends", "llama.cpp", "build", "cache.txt")), f.plan.Old) { + t.Error("backends/ a ete parcouru alors qu'il doit etre saute") + } + + // /etc/default : nouveau fichier ET ancien repointe (des scripts le sourcent). + newDef, err := os.ReadFile(filepath.Join(f.plan.DefaultDir, "ajean")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(newDef), "AJEAN_HOME="+f.plan.New) || !strings.Contains(string(newDef), "JEAN_HOME="+f.plan.New) { + t.Errorf("/etc/default/ajean incomplet:\n%s", newDef) + } + oldDef, _ := os.ReadFile(filepath.Join(f.plan.DefaultDir, "jean")) + if strings.Contains(string(oldDef), f.plan.Old) { + t.Errorf("/etc/default/jean pointe encore sur l'ancien chemin:\n%s", oldDef) + } + + // Unite corrigee, copie de securite conservee, nom inchange. + unit, _ := os.ReadFile(filepath.Join(f.plan.UnitDir, "jean.service")) + if strings.Contains(string(unit), f.plan.Old) { + t.Errorf("unite non corrigee:\n%s", unit) + } + if _, err := os.Stat(filepath.Join(f.plan.UnitDir, "jean.service.avant-ajean")); err != nil { + t.Error("copie de securite de l'unite absente") + } + + if !f.backedUp { + t.Error("aucune sauvegarde avant migration") + } + if len(f.stopped) != 2 || len(f.started) != 2 { + t.Errorf("services: arretes=%v relances=%v", f.stopped, f.started) + } +} + +// Chemin choisi explicitement par l'utilisateur : on ne migre RIEN. +func TestMigrateLayoutRespectsUserChoice(t *testing.T) { + f := newFakeLayout(t) + custom := filepath.Join(f.root, "mnt", "gros-disque", "jean") + if err := os.WriteFile(filepath.Join(f.plan.DefaultDir, "jean"), + []byte("JEAN_HOME="+custom+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + if ok, why := f.plan.layoutNeedsMigration(); ok { + t.Fatal("une migration a ete jugee legitime malgre un chemin impose") + } else if !strings.Contains(why, custom) { + t.Errorf("raison peu claire: %q", why) + } + if err := migrateLayout(f.plan); err != nil { + t.Fatal(err) + } + if !isDir(f.plan.Old) || isDir(f.plan.New) { + t.Error("le dossier a bouge alors que l'utilisateur avait impose un chemin") + } + if len(f.stopped) != 0 { + t.Error("des services ont ete arretes pour rien") + } +} + +// Sans les droits root, on n'entreprend rien et on ne casse rien. +func TestMigrateLayoutNonRoot(t *testing.T) { + f := newFakeLayout(t) + f.plan.isRoot = func() bool { return false } + if err := migrateLayout(f.plan); err != nil { + t.Fatal(err) + } + if !isDir(f.plan.Old) || isDir(f.plan.New) { + t.Error("migration effectuee sans les droits root") + } +} + +// Les deux dossiers presents : situation ambigue, on s'abstient. +func TestMigrateLayoutBothPresent(t *testing.T) { + f := newFakeLayout(t) + if err := os.MkdirAll(f.plan.New, 0o755); err != nil { + t.Fatal(err) + } + if ok, why := f.plan.layoutNeedsMigration(); ok || why == "" { + t.Fatalf("attendu un refus explicite, got ok=%v why=%q", ok, why) + } +} + +// Deja migre (plus d'ancien dossier) : silencieux, aucun message inquietant. +func TestMigrateLayoutAlreadyDone(t *testing.T) { + f := newFakeLayout(t) + if err := os.Rename(f.plan.Old, f.plan.New); err != nil { + t.Fatal(err) + } + ok, why := f.plan.layoutNeedsMigration() + if ok || why != "" { + t.Fatalf("attendu un silence, got ok=%v why=%q", ok, why) + } +} + +// La variante « apres mise a jour » ne doit toucher a AUCUN service : on tourne +// dans jean-link, l'arreter reviendrait a se tuer au milieu de la migration. +func TestUpdateLayoutPlanNeverStopsServices(t *testing.T) { + f := newFakeLayout(t) + p := f.plan + p.unitActive = updateLayoutPlan().unitActive // seule difference testee + + if err := migrateLayout(p); err != nil { + t.Fatalf("migration echouee: %v", err) + } + if len(f.stopped) != 0 || len(f.started) != 0 { + t.Fatalf("services touches: arretes=%v relances=%v", f.stopped, f.started) + } + // La migration doit rester complete malgre tout. + if isDir(p.Old) || !isDir(p.New) { + t.Fatal("le dossier n'a pas ete migre") + } + if strings.Contains(f.read(t, "config.env"), p.Old) { + t.Error("chemins non reecrits") + } + unit, _ := os.ReadFile(filepath.Join(p.UnitDir, "jean.service")) + if strings.Contains(string(unit), p.Old) { + t.Error("unite non corrigee : le prochain redemarrage echouerait") + } +} diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index f1a8bd8..ff6b9b2 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -416,6 +416,15 @@ func restartAfterUpdate() (bool, string) { if runtime.GOOS != "linux" || !linkServiceActive() { return false, "" } + // Le seul moment où l'on peut migrer l'agencement d'une machine déjà + // installée : on est root (jean-link tourne en root), le binaire vient + // d'être remplacé, et le service va être relancé dans la foulée. Les + // utilisateurs ne relancent jamais `install`, donc ne rien faire ici + // reviendrait à ne jamais migrer un serveur Linux. Sans effet si la machine + // est déjà en ajean ou si un chemin a été imposé à la main. + if err := migrateLayout(updateLayoutPlan()); err != nil { + fmt.Fprintf(os.Stderr, "[warn] migration de l'agencement non effectuée : %v\n", err) + } go func() { time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client // --no-block : on enregistre le job puis on rend la main ; systemd exécute le From 2b20158e7c936c77294637ed1c2ac53b0bcefddb Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 4 Aug 2026 16:31:43 +0200 Subject: [PATCH 203/279] CI : chemins cmd/jean -> cmd/ajean oublies dans les workflows Le renommage des dossiers avait ete repercute dans release.yml mais pas dans ci.yml, qui compilait encore ./cmd/jean : les deux jobs echouaient. Une occurrence trainait aussi dans mac-build.yml (cmd/jean/icon.ico), que le remplacement precedent n'avait attrapee que pour icon.png. Un cas n'etait pas qu'un commentaire : tools/assemble-ui utilisait "internal/jean/ui" comme chemin par defaut. La directive go:generate passe le dossier en argument, donc la generation fonctionnait, mais l'invocation directe documentee dans son propre en-tete (go run ./tools/assemble-ui) aurait echoue. Le reste etait des commentaires designant les anciens chemins, corriges pour que le depot ne mente plus sur sa propre structure. Job ci rejoue en entier en local : gofmt, vet, build, tests et les quatre cross-compilations de release. --- .github/workflows/ci.yml | 6 +++--- .github/workflows/mac-build.yml | 2 +- cmd/ajean/main.go | 2 +- internal/ajean/doc.go | 2 +- internal/ajean/sys_brand_icon.go | 2 +- internal/ajean/sys_firstrun_windows.go | 2 +- internal/ajean/ui/index.html | 2 +- internal/ajean/ui/src/index.tmpl.html | 2 +- tools/assemble-ui/main.go | 8 ++++---- tools/gen-icon/main.go | 6 +++--- 10 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4c6fe4..ee379a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: # par Cocoa (CGO) → les cibles macOS se compilent sur un runner Apple # (job « macos » ci-dessous, comme dans release.yml). for t in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do - GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/jean + GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/ajean done macos: @@ -41,7 +41,7 @@ jobs: - run: go test ./... - name: build macOS (CGO, arm64 + amd64) run: | - CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o /dev/null ./cmd/jean + CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o /dev/null ./cmd/ajean CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \ CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \ - go build -o /dev/null ./cmd/jean + go build -o /dev/null ./cmd/ajean diff --git a/.github/workflows/mac-build.yml b/.github/workflows/mac-build.yml index 52fb76a..16a3235 100644 --- a/.github/workflows/mac-build.yml +++ b/.github/workflows/mac-build.yml @@ -19,7 +19,7 @@ jobs: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean mkdir -p /tmp/jean.iconset - sips -s format png 'cmd/jean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true + sips -s format png 'cmd/ajean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true for s in 16 32 128 256 512; do sips -z $s $s /tmp/icon.png --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true done diff --git a/cmd/ajean/main.go b/cmd/ajean/main.go index 51b0427..2846d3b 100644 --- a/cmd/ajean/main.go +++ b/cmd/ajean/main.go @@ -1,4 +1,4 @@ -// Point d'entrée du binaire ajean. Tout le code vit dans internal/jean ; ce +// Point d'entrée du binaire ajean. Tout le code vit dans internal/ajean ; ce // dossier ne porte que le main() et les ressources Windows (.syso, icône, // versioninfo) qui doivent résider dans le dossier du package main. // diff --git a/internal/ajean/doc.go b/internal/ajean/doc.go index 2ca1c5b..354d8b5 100644 --- a/internal/ajean/doc.go +++ b/internal/ajean/doc.go @@ -1,4 +1,4 @@ -// Jean — cœur du binaire (package jean, appelé par cmd/jean). Les fichiers sont préfixés par domaine +// Jean — cœur du binaire (package jean, appelé par cmd/ajean). Les fichiers sont préfixés par domaine // (Go n'autorise pas de sous-dossiers dans un même package) : // // main.go point d'entrée : dispatch des sous-commandes, chemins (JeanHome…) diff --git a/internal/ajean/sys_brand_icon.go b/internal/ajean/sys_brand_icon.go index 04b70bb..be375f5 100644 --- a/internal/ajean/sys_brand_icon.go +++ b/internal/ajean/sys_brand_icon.go @@ -9,7 +9,7 @@ package ajean // au noir en laissant les icônes système en bleu. // // Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est -// produite par `go generate ./cmd/jean` (voir tools/gen-icon). +// produite par `go generate ./cmd/ajean` (voir tools/gen-icon). import ( "bytes" diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index 8c4562c..8e11192 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -335,7 +335,7 @@ func portBusy(port int) bool { } // binaryVersion lit la version dans les ressources du fichier (VS_VERSIONINFO, -// posées par goversioninfo, cf cmd/jean/versioninfo.json), comme le fait +// posées par goversioninfo, cf cmd/ajean/versioninfo.json), comme le fait // l'onglet « Détails » des propriétés Windows. // // ⚠️ NE PAS remplacer par un `ajean version` exécuté : le binaire est compilé en diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index abfe9c7..a0bde28 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1,5 +1,5 @@ - + + +
    + +
    + -
    - +
    +
    + +
    + +
    + + +
    +
    enregistré automatiquement en quittant le champ
    +
    - -
    - - -
    -
    enregistré automatiquement en quittant le champ
    -
    @@ -1895,15 +1906,21 @@ async function setMemMode(){ renderMemModeDesc(r.mode||mode); } // Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte. -let internetOn=false; +let internetOn=false, webEngine='go'; function renderInternet(s){ internetOn = !!s.enabled; + webEngine = s.engine || 'go'; document.getElementById('internet-toggle').checked = internetOn; + const sel=document.getElementById('web-engine'); + if(sel && document.activeElement!==sel) sel.value = webEngine; + // Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré. + const cf=document.getElementById('crawl-fields'); + if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none'; if(document.activeElement !== document.getElementById('crawl-url')) document.getElementById('crawl-url').value = s.url || ''; // Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les // outils web ne sont pas proposés au modèle dans ce cas). - if(internetOn && s.url && !s.reachable) setBadge('internet-badge','warn','injoignable'); + if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable'); else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif'); // Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée), // on indique seulement si une clé est enregistrée. @@ -1916,7 +1933,8 @@ function renderInternet(s){ if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)'; } const st=document.getElementById('internet-status'); - if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } + if(webEngine!=='crawl4ai'){ st.innerHTML='✓ moteur intégré — aucune installation requise (pas de rendu JavaScript)'; } + else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } else if(s.reachable){ st.innerHTML='✓ serveur joignable — outils web actifs'; } else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; } } @@ -1989,9 +2007,16 @@ async function apiKeySet(){ async function toggleInternet(){ const on=document.getElementById('internet-toggle').checked; const url=document.getElementById('crawl-url').value.trim(); - if(on && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } + // Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a + // besoin d'aucun réglage : on active directement. + if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } renderInternet(await jpost('/api/internet',{enabled:on, url})); } +async function saveWebEngine(){ + const engine=document.getElementById('web-engine').value; + renderInternet(await jpost('/api/internet',{engine})); + toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné'); +} async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 78cc5ad..9f14db2 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -88,22 +88,33 @@
    -
    Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest). Actifs seulement si le mode agent est actif et que le serveur répond.
    +
    Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep. Le moteur intégré ne demande aucune installation. Le moteur Crawl4AI exige un serveur que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest) mais exécute le JavaScript des pages. Actifs seulement si le mode agent est actif.
    - +
    + +
    + -
    - +
    +
    + +
    + +
    + + +
    +
    enregistré automatiquement en quittant le champ
    +
    - -
    - - -
    -
    enregistré automatiquement en quittant le champ
    -
    diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index f80a319..b308c6d 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -132,15 +132,21 @@ async function setMemMode(){ renderMemModeDesc(r.mode||mode); } // Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte. -let internetOn=false; +let internetOn=false, webEngine='go'; function renderInternet(s){ internetOn = !!s.enabled; + webEngine = s.engine || 'go'; document.getElementById('internet-toggle').checked = internetOn; + const sel=document.getElementById('web-engine'); + if(sel && document.activeElement!==sel) sel.value = webEngine; + // Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré. + const cf=document.getElementById('crawl-fields'); + if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none'; if(document.activeElement !== document.getElementById('crawl-url')) document.getElementById('crawl-url').value = s.url || ''; // Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les // outils web ne sont pas proposés au modèle dans ce cas). - if(internetOn && s.url && !s.reachable) setBadge('internet-badge','warn','injoignable'); + if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable'); else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif'); // Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée), // on indique seulement si une clé est enregistrée. @@ -153,7 +159,8 @@ function renderInternet(s){ if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)'; } const st=document.getElementById('internet-status'); - if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } + if(webEngine!=='crawl4ai'){ st.innerHTML='✓ moteur intégré — aucune installation requise (pas de rendu JavaScript)'; } + else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } else if(s.reachable){ st.innerHTML='✓ serveur joignable — outils web actifs'; } else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; } } @@ -226,9 +233,16 @@ async function apiKeySet(){ async function toggleInternet(){ const on=document.getElementById('internet-toggle').checked; const url=document.getElementById('crawl-url').value.trim(); - if(on && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } + // Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a + // besoin d'aucun réglage : on active directement. + if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } renderInternet(await jpost('/api/internet',{enabled:on, url})); } +async function saveWebEngine(){ + const engine=document.getElementById('web-engine').value; + renderInternet(await jpost('/api/internet',{engine})); + toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné'); +} async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index fd0b98c..7583f5e 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -582,8 +582,17 @@ func handleInternet(w http.ResponseWriter, r *http.Request) { Enabled *bool `json:"enabled"` URL *string `json:"url"` Key *string `json:"key"` + Engine *string `json:"engine"` } _ = json.NewDecoder(r.Body).Decode(&req) + // Moteur web : "go" (intégré, rien à installer) ou "crawl4ai" (serveur + // externe, rendu JavaScript). + if req.Engine != nil { + if err := setWebEngine(strings.ToLower(strings.TrimSpace(*req.Engine))); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + } // Clé d'accès au serveur Crawl4AI : chaîne vide = on l'enlève. if req.Key != nil { if err := writeCrawlKey(strings.TrimSpace(*req.Key)); err != nil { @@ -622,6 +631,7 @@ func handleInternet(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{ "ok": true, "enabled": internetEnabled(), + "engine": webEngine(), "url": crawl4aiURL(), "reachable": crawlReachable(), "key_set": key != "", diff --git a/internal/ajean/web_fetch_go.go b/internal/ajean/web_fetch_go.go new file mode 100644 index 0000000..ac4ca7e --- /dev/null +++ b/internal/ajean/web_fetch_go.go @@ -0,0 +1,390 @@ +package ajean + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + readability "codeberg.org/readeck/go-readability/v2" + htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" + "github.com/JohannesKaufmann/html-to-markdown/v2/converter" + "golang.org/x/net/html" + "golang.org/x/net/html/charset" +) + +// Moteur web INTÉGRÉ (pur Go) — alternative à Crawl4AI, sans rien à installer. +// +// Crawl4AI = Chromium headless piloté par Playwright : il exécute le JS de la +// page avant d'en extraire le markdown. C'est puissant mais ça demande à +// l'utilisateur de faire tourner un serveur Python/Docker — rédhibitoire pour +// un utilisateur non technique. +// +// Ce moteur-ci remplace UNIQUEMENT l'étape « URL → markdown » (runCrwl) par du +// Go pur : +// +// http.Get → readability (port Go de Readability de Firefox, qui vire nav, +// pubs, pieds de page) → html-to-markdown +// +// Ce qu'on perd : le rendu JavaScript. Une page 100 % React sans SSR ressort +// vide, et les options js_code / wait_for / actions n'ont plus de sens (pas de +// DOM vivant). En pratique, les sources que l'IA lit — docs, articles, blogs, +// Wikipédia, GitHub, forums — sont servies en HTML et passent très bien. +// +// Le choix se fait par la clé de config WEB_ENGINE ("go" ou "crawl4ai"). Voir +// webEngine(). Crawl4AI reste entièrement supporté. + +// ─── sélection du moteur ──────────────────────────────────────────────────── + +const ( + engineGo = "go" + engineCrawl = "crawl4ai" +) + +// webEngine renvoie le moteur web actif : engineGo ou engineCrawl. +// +// Par défaut on prend le moteur intégré. L'exception est l'installation qui a +// DÉJÀ une URL Crawl4AI configurée sans avoir jamais choisi de moteur : elle +// tournait sur Crawl4AI, on ne change pas son comportement dans son dos. +func webEngine() string { + switch strings.ToLower(strings.TrimSpace(ReadConfig()["WEB_ENGINE"])) { + case engineGo: + return engineGo + case engineCrawl: + return engineCrawl + } + if crawl4aiURL() != "" { + return engineCrawl + } + return engineGo +} + +// setWebEngine enregistre le moteur web choisi et invalide le cache de +// reachability (l'état affiché dépend du moteur). +func setWebEngine(engine string) error { + if engine != engineGo && engine != engineCrawl { + return fmt.Errorf("moteur web inconnu : %q", engine) + } + if err := SetConfigKey("WEB_ENGINE", engine); err != nil { + return err + } + reachMu.Lock() + reachURL = "" + reachMu.Unlock() + return nil +} + +// ─── récupération HTTP ────────────────────────────────────────────────────── + +// Un vrai User-Agent : beaucoup de sites servent une page dégradée (ou un 403) +// aux clients qui s'annoncent comme des robots. +const goFetchUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + +const ( + goFetchTimeout = 30 * time.Second + goFetchMaxBytes = 8 << 20 // 8 Mo : au-delà ce n'est plus une page à lire +) + +// goHTTPClient : client dédié au moteur web. Timeout global et plafond de +// redirections. On ne touche pas à http.DefaultClient (utilisé ailleurs). +var goHTTPClient = &http.Client{ + Timeout: goFetchTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("trop de redirections") + } + return nil + }, +} + +// goFetch effectue le GET et renvoie le corps (plafonné), le Content-Type et +// l'URL finale après redirections. +func goFetch(ctx context.Context, target string) (body []byte, contentType, finalURL string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, "", "", err + } + req.Header.Set("User-Agent", goFetchUA) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7") + req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8") + // Pas d'Accept-Encoding manuel : le transport gère gzip tout seul. + + resp, err := goHTTPClient.Do(req) + if err != nil { + return nil, "", "", fmt.Errorf("échec de la requête : %v", err) + } + defer resp.Body.Close() + + b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes)) + if err != nil { + return nil, "", "", fmt.Errorf("lecture interrompue : %v", err) + } + if resp.StatusCode >= 400 { + return nil, "", "", fmt.Errorf("HTTP %d sur %s", resp.StatusCode, target) + } + return b, resp.Header.Get("Content-Type"), resp.Request.URL.String(), nil +} + +// goParseHTML décode le corps selon le charset annoncé (ou détecté) puis le +// parse en arbre DOM. +func goParseHTML(body []byte, contentType string) (*html.Node, error) { + r, err := charset.NewReader(strings.NewReader(string(body)), contentType) + if err != nil { + // Charset inconnu : on tente en l'état plutôt que d'abandonner. + r = strings.NewReader(string(body)) + } + return html.Parse(r) +} + +// ─── URL → markdown ───────────────────────────────────────────────────────── + +// goFetchMarkdown est l'équivalent pur Go de runCrwl : elle récupère une URL et +// en renvoie le contenu en markdown. +// +// Les contenus non-HTML (texte brut, markdown, JSON…) sont renvoyés tels quels : +// c'est le cas des README GitHub, vers lesquels normalizeCrawlURL redirige. +func goFetchMarkdown(target string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) + defer cancel() + + body, contentType, finalURL, err := goFetch(ctx, target) + if err != nil { + return "", err + } + + ct := strings.ToLower(contentType) + isHTML := strings.Contains(ct, "html") || strings.Contains(ct, "xml") + if ct == "" { + // Certains serveurs n'annoncent rien : on devine sur le contenu. + isHTML = strings.Contains(strings.ToLower(string(body[:min(len(body), 1024)])), " jsShellHTMLMin { + return "", fmt.Errorf("page récupérée (%s de HTML) mais seulement %d caractères de texte : "+ + "elle est très probablement rendue en JavaScript, ce que le moteur web intégré n'exécute pas. "+ + "Ne réessaie PAS cette URL — cherche une autre source (documentation officielle, dépôt du projet, "+ + "article qui en parle)", formatBytes(len(body)), len(strings.TrimSpace(md))) + } + return md, nil +} + +// Seuils du diagnostic « rendu JavaScript » (voir goFetchMarkdown). +const ( + jsShellTextMax = 200 // texte extrait en dessous duquel il n'y a rien à lire + jsShellHTMLMin = 15000 // HTML au-dessus duquel « rien à lire » devient suspect +) + +// goArticleMarkdown applique Readability puis convertit le résultat. Renvoie du +// markdown vide (sans erreur) si la page n'a pas de contenu d'article isolable. +func goArticleMarkdown(doc *html.Node, base *url.URL) (md, title string) { + // Readability mute l'arbre qu'on lui donne ; on lui passe une copie pour + // pouvoir retomber sur le document complet en cas d'échec. + clone, err := goCloneDoc(doc) + if err != nil { + return "", "" + } + art, err := readability.FromDocument(clone, base) + if err != nil || art.Node == nil { + return "", "" + } + out, err := goConvert(art.Node, baseString(base)) + if err != nil { + return "", "" + } + return out, strings.TrimSpace(art.Title()) +} + +// goConvert rend un nœud HTML en markdown, en réécrivant les liens et images +// relatifs en URLs absolues (sinon l'IA ne peut pas les rouvrir). +func goConvert(node *html.Node, baseURL string) (string, error) { + opts := []converter.ConvertOptionFunc{} + if baseURL != "" { + opts = append(opts, converter.WithDomain(baseURL)) + } + b, err := htmltomarkdown.ConvertNode(node, opts...) + if err != nil { + return "", fmt.Errorf("conversion markdown : %v", err) + } + return string(b), nil +} + +// goCloneDoc duplique un document HTML en le re-parsant depuis son rendu. +func goCloneDoc(doc *html.Node) (*html.Node, error) { + var sb strings.Builder + if err := html.Render(&sb, doc); err != nil { + return nil, err + } + return html.Parse(strings.NewReader(sb.String())) +} + +func baseString(u *url.URL) string { + if u == nil { + return "" + } + return u.Scheme + "://" + u.Host +} + +// ─── recherche DuckDuckGo (parsing HTML direct) ───────────────────────────── + +// goSearch interroge le point d'entrée HTML de DuckDuckGo et lit les résultats +// directement dans le DOM. +// +// Le chemin Crawl4AI passe par une conversion en markdown avant de reparser le +// texte à coups de regex (duckduckgoSearch) ; ici on lit la structure réelle +// (.result__a, .result__snippet), ce qui est nettement plus robuste. +func goSearch(query string, limit int) ([]searchResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) + defer cancel() + + target := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query) + body, contentType, _, err := goFetch(ctx, target) + if err != nil { + return nil, err + } + if strings.Contains(string(body), "anomaly-modal") || strings.Contains(string(body), "anomaly.js") { + return nil, fmt.Errorf("DuckDuckGo a renvoyé un défi anti-bot") + } + doc, err := goParseHTML(body, contentType) + if err != nil { + return nil, fmt.Errorf("HTML illisible : %v", err) + } + + var results []searchResult + seen := map[string]bool{} + for _, block := range goFindByClass(doc, "result") { + if len(results) >= limit { + break + } + link := goFirstByClass(block, "result__a") + if link == nil { + continue + } + href := decodeUddg(goAttr(link, "href")) + title := goText(link) + if title == "" || href == "" || strings.Contains(href, "duckduckgo.com") || seen[href] { + continue + } + snippet := "" + if s := goFirstByClass(block, "result__snippet"); s != nil { + snippet = goText(s) + } + seen[href] = true + results = append(results, searchResult{Title: title, URL: href, Snippet: snippet}) + } + return results, nil +} + +// ─── petits helpers DOM (x/net/html, sans sélecteur CSS) ──────────────────── + +func goHasClass(n *html.Node, class string) bool { + if n.Type != html.ElementNode { + return false + } + for _, f := range strings.Fields(goAttr(n, "class")) { + if f == class { + return true + } + } + return false +} + +func goAttr(n *html.Node, name string) string { + if n == nil { + return "" + } + for _, a := range n.Attr { + if a.Key == name { + return a.Val + } + } + return "" +} + +// goFindByClass renvoie tous les nœuds portant la classe, sans descendre dans +// un nœud déjà retenu (les résultats DDG ne s'imbriquent pas). +func goFindByClass(root *html.Node, class string) []*html.Node { + var out []*html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if goHasClass(n, class) { + out = append(out, n) + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + return out +} + +func goFirstByClass(root *html.Node, class string) *html.Node { + var found *html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if found != nil { + return + } + if goHasClass(n, class) { + found = n + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + return found +} + +// goText concatène le texte d'un sous-arbre, espaces normalisés. +func goText(n *html.Node) string { + var sb strings.Builder + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.TextNode { + sb.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(wsRe.ReplaceAllString(sb.String(), " ")) +} From d604398f734c6e54e92e03326c6aab349b7ecde8 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 5 Aug 2026 11:53:18 +0200 Subject: [PATCH 213/279] Moteur web integre : passer les murs de consentement Cookie jar en memoire (les murs posent un cookie puis redirigent), en-tetes DNT et Sec-GPC, et cookies marquant le bandeau comme traite. Les valeurs envoyees signalent un REFUS du pistage, jamais une acceptation : le but est de lire la page, pas d'ouvrir des traceurs au nom de l'utilisateur. Mesure : reuters.com passe de HTTP 401 a 2318 caracteres extraits, aucune regression sur lemonde/lefigaro/20minutes/theguardian/spiegel/wikipedia. --- internal/ajean/web_fetch_go.go | 41 ++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/ajean/web_fetch_go.go b/internal/ajean/web_fetch_go.go index ac4ca7e..2a508df 100644 --- a/internal/ajean/web_fetch_go.go +++ b/internal/ajean/web_fetch_go.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/http/cookiejar" "net/url" "strings" "time" @@ -88,10 +89,14 @@ const ( goFetchMaxBytes = 8 << 20 // 8 Mo : au-delà ce n'est plus une page à lire ) -// goHTTPClient : client dédié au moteur web. Timeout global et plafond de -// redirections. On ne touche pas à http.DefaultClient (utilisé ailleurs). +// goHTTPClient : client dédié au moteur web. Timeout global, plafond de +// redirections, et un cookie jar — indispensable pour les sites qui posent un +// cookie de session puis redirigent (murs de consentement typiques). Le jar est +// en mémoire : il disparaît à l'arrêt du process, on ne persiste rien. +// On ne touche pas à http.DefaultClient (utilisé ailleurs). var goHTTPClient = &http.Client{ Timeout: goFetchTimeout, + Jar: newCookieJar(), CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("trop de redirections") @@ -100,6 +105,32 @@ var goHTTPClient = &http.Client{ }, } +func newCookieJar() http.CookieJar { + j, err := cookiejar.New(nil) + if err != nil { + return nil // un client sans jar reste fonctionnel + } + return j +} + +// consentCookies : cookies qui marquent le bandeau de consentement comme DÉJÀ +// TRAITÉ, afin d'atteindre la page derrière le mur. +// +// ⚠ On signale un REFUS, jamais une acceptation : ces valeurs disent « pas de +// suivi, bandeau fermé ». Le but est de lire la page, pas d'ouvrir des traceurs +// au nom de l'utilisateur. Les en-têtes DNT et Sec-GPC (refus de vente/partage, +// juridiquement reconnu en Californie et respecté par une partie des sites) +// vont dans le même sens. +// +// Envoyés à tout le monde : un cookie inconnu d'un site est simplement ignoré. +var consentCookies = []string{ + "gdpr=0", + "gdpr_consent=0", + "cookieconsent_status=deny", + "OptanonAlertBoxClosed=2024-01-01T00:00:00.000Z", + "SOCS=CAESHAgBEhJnd3NfMjAyNDAxMDEtMF9SQzEaAmZyIAEaBgiA_LyaBg", // Google : « refuser tout » +} + // goFetch effectue le GET et renvoie le corps (plafonné), le Content-Type et // l'URL finale après redirections. func goFetch(ctx context.Context, target string) (body []byte, contentType, finalURL string, err error) { @@ -110,6 +141,12 @@ func goFetch(ctx context.Context, target string) (body []byte, contentType, fina req.Header.Set("User-Agent", goFetchUA) req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7") req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8") + // Signaux de refus du pistage (voir consentCookies). + req.Header.Set("DNT", "1") + req.Header.Set("Sec-GPC", "1") + for _, c := range consentCookies { + req.Header.Add("Cookie", c) + } // Pas d'Accept-Encoding manuel : le transport gère gzip tout seul. resp, err := goHTTPClient.Do(req) From 726f065d258715a0ee4d725f4da9d243e67f5833 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 5 Aug 2026 12:00:30 +0200 Subject: [PATCH 214/279] UI : nommer les moteurs web Moteur Integre et Crawl4AI, sans suffixe --- internal/ajean/ui/index.html | 4 ++-- internal/ajean/ui/src/index.tmpl.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index a67711f..ab79818 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -922,8 +922,8 @@ button:hover{border-color:var(--dim);color:var(--text)} JavaScript) · « crawl4ai » = serveur externe avec Chromium headless. -->
    +
    @@ -1290,8 +1334,22 @@ button:hover{border-color:var(--dim);color:var(--text)}
    + + +
    + - -
    - Réglages avancés -
    -
    -
    BATCH
    -
    UBATCH
    -
    Threads (batch)0 = auto
    -
    Experts MoE sur CPUvide = non
    -
    Charger tout en mémoiredésactive le mmap
    -
    +
    + Charger tout en mémoiredésactive le mmap +
    -
    +
    Configuration
    - Éditer le fichier .envconfiguration brute — pour les réglages avancés + Éditer le fichier de configurationtexte brut
    + + +
    + - -
    - Réglages avancés -
    -
    -
    BATCH
    -
    UBATCH
    -
    Threads (batch)0 = auto
    -
    Experts MoE sur CPUvide = non
    -
    Charger tout en mémoiredésactive le mmap
    -
    +
    + Charger tout en mémoiredésactive le mmap +
    -
    +
    Configuration
    - Éditer le fichier .envconfiguration brute — pour les réglages avancés + Éditer le fichier de configurationtexte brut
    Presets -
    +
    Mode agent
    - -
    + +
    Mémoire?
    - -
    - ▸ - Pages - -
    - -
    +
    Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep. Le moteur intégré ne demande aucune installation. Le moteur Crawl4AI exige un serveur que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest) mais exécute le JavaScript des pages. Actifs seulement si le mode agent est actif.
    - - -
    - -
    - -
    -
    - +
    + + +
    +
    + +
    +
    +
    + +
    + +
    + + +
    +
    enregistré automatiquement en quittant le champ
    +
    +
    +
    - -
    - - -
    -
    enregistré automatiquement en quittant le champ
    -
    -
    -
    +
    Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
    -
    - +
    +
    +
    + +
    +
    -
    +
    Paramètres
    - +
    + +
    Apparence @@ -1020,73 +1153,82 @@ button:hover{border-color:var(--dim);color:var(--text)}
    System prompt - +
    + +
    Accès OpenAI
    Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
    -
    Modèle : jean
    - -
    -
    Adresse locale (réseau)
    -
    - - -
    - -
    -
    -
    -
    Accès distant
    ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
    -
    -

    Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

    +
    +

    Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

    -
    - +
    +
    Moteur -
    …
    +
    Installation
    + +
    +
    …
    llama.cpp précompilé conseillé
    @@ -1104,33 +1246,36 @@ button:hover{border-color:var(--dim);color:var(--text)}
    -
    Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
    -
    -
    +
    +
    -
    +
    Actions -
    -
    +
    +
    + +
    -
    +
    -
    +
    @@ -1138,6 +1283,16 @@ button:hover{border-color:var(--dim);color:var(--text)}
    chargement de la conversation…
    + +
    @@ -1175,6 +1330,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
    + +
    @@ -1276,6 +1435,11 @@ button:hover{border-color:var(--dim);color:var(--text)} Preset
    + +
    Nom
    @@ -1456,10 +1620,13 @@ button:hover{border-color:var(--dim);color:var(--text)}
    +
    - - +
    @@ -1629,16 +1796,39 @@ function setBadge(id, state, text){ el.className = 'sbadge' + (state===true ? ' on' : (state==='warn' || state==='err') ? ' '+state : ''); el.textContent = state===null || state===undefined ? '' : (text||''); } +// Ouverture/fermeture des modales. Le display seul apparaissait d'un bloc : on +// pose la classe .show une frame APRÈS l'affichage pour que la transition CSS +// parte de son état initial (sans ce reflow forcé, le navigateur applique tout +// d'un coup et l'animation ne joue pas). À la fermeture on attend la fin de la +// transition avant de repasser en display:none. +function showModal(id){ + const el = typeof id==='string' ? document.getElementById(id) : id; + if(!el) return; + el.style.display='flex'; + void el.offsetHeight; + el.classList.add('show'); +} +function hideModal(id){ + const el = typeof id==='string' ? document.getElementById(id) : id; + if(!el) return; + el.classList.remove('show'); + // Rouverte entre-temps ? On ne la cache surtout pas. + setTimeout(()=>{ if(!el.classList.contains('show')) el.style.display='none'; }, 180); +} function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); } // Modales natives (askConfirm/askPrompt/askAlert) — remplacent confirm()/prompt()/ // alert() par de vraies boîtes stylées. Chacune renvoie une Promise : askConfirm → // bool, askPrompt → string|null (null si annulé), askAlert → void. Échap/clic dehors // = annuler ; Entrée = valider (sur prompt aussi). -let _askResolver=null, _askKind='confirm'; +let _askResolver=null, _askKind='confirm', _askCheck=false; +// État de la case optionnelle de la DERNIÈRE confirmation (opts.check). Lu par +// l'appelant juste après le await — la Promise, elle, ne renvoie que oui/non. +function askChecked(){ return _askCheck; } function askResolve(ok){ if(!_askResolver) return; const r=_askResolver; _askResolver=null; - document.getElementById('ask-modal').style.display='none'; + _askCheck = ok && document.getElementById('ask-check-input').checked; + hideModal('ask-modal'); document.removeEventListener('keydown', _askKey, true); if(_askKind==='prompt') r(ok ? document.getElementById('ask-input').value : null); else r(ok); @@ -1659,12 +1849,18 @@ function _openAsk(kind, message, opts){ const inp=document.getElementById('ask-input'); if(kind==='prompt'){ inp.style.display=''; inp.value=opts.default||''; inp.placeholder=opts.placeholder||''; } else inp.style.display='none'; + // Case facultative (ex. « supprimer aussi le fichier .gguf »). + const chk=document.getElementById('ask-check'); + chk.style.display = opts.check ? 'inline-flex' : 'none'; + document.getElementById('ask-check-label').textContent = opts.check || ''; + document.getElementById('ask-check-input').checked = false; + _askCheck=false; const cancel=document.getElementById('ask-cancel'), ok=document.getElementById('ask-ok'); cancel.style.display = kind==='alert' ? 'none' : ''; cancel.textContent = opts.cancelText || 'Annuler'; ok.textContent = opts.okText || (kind==='alert'?'OK':kind==='prompt'?'Valider':'Confirmer'); ok.classList.toggle('danger', !!opts.danger); - document.getElementById('ask-modal').style.display='flex'; + showModal('ask-modal'); document.addEventListener('keydown', _askKey, true); setTimeout(()=>{ const f = kind==='prompt'?inp:ok; f.focus(); if(kind==='prompt') inp.select(); }, 30); return new Promise(res=>{ _askResolver=res; }); @@ -1721,7 +1917,7 @@ async function loadStatus(){ else if(s.load_error){ cls='err'; txt='modèle incompatible'; } else if(s.active){ cls='loading'; txt='chargement…'; } el.className='statuspill '+cls; - el.innerHTML=''+txt+' :'+s.port+''; + el.innerHTML=''+txt; MODEL_READY = !!(s.active && s.health); if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); } if(s.version){ @@ -1911,7 +2107,10 @@ async function loadPresets(){ info.appendChild(nm); if(meta.children.length) info.appendChild(meta); const edit=document.createElement('button'); - edit.className='preset-edit'; edit.title='éditer'; edit.textContent='✎'; + // Engrenage (et non plus un crayon) : la silhouette est ronde, donc elle se + // lit comme centrée dans son coin, là où le crayon penché tirait de travers. + edit.className='preset-edit'; edit.title='réglages du preset'; + edit.innerHTML=''; edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); }; row.appendChild(info); row.appendChild(edit); cont.appendChild(row); @@ -1931,7 +2130,7 @@ async function loadAgent(){ memPages = (s.pages || s.skills || []).slice().sort((a,b)=>a.name.localeCompare(b.name)); memShown = MEM_PAGE; document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : ''; - document.getElementById('mem-search').style.display = memPages.length > MEM_PAGE ? '' : 'none'; + document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none'; renderMemList(); } // Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni @@ -2134,8 +2333,8 @@ async function clearCrawlKey(){ } async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } -function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; } -function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; } +function openBenchModal(){ showModal('bench-modal'); } +function closeBenchModal(){ hideModal('bench-modal'); } async function runBenchUI(){ const btn = document.getElementById('btn-bench'); const rerun = document.getElementById('bench-rerun'); @@ -2192,20 +2391,27 @@ const KINDS = { preset: {label:'Preset', param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, mem: {label:'Page', param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()}, }; +// ⚠️ La modale s'ouvre AVANT d'aller chercher quoi que ce soit. Elle attendait +// auparavant la fin de 3 requêtes (contenu + backends + modèles disponibles) : +// en accès distant, ça faisait un clic sans réaction pendant une seconde, comme +// si le bouton était mort. On affiche la coquille tout de suite, puis on la +// remplit. `openSeq` protège du cas « deux ouvertures coup sur coup » : une +// réponse en retard ne doit jamais écraser la modale ouverte après elle. +// Pendant ce remplissage la modale est en `.loading` : son corps est estompé et +// inerte, puis révélé d'un coup une fois TOUT en place — sinon on voyait les +// champs se peupler un par un, ce qui donnait une impression de bricolage. +let openSeq = 0; async function openItem(kind, key){ const K = KINDS[kind]; - const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); - const d = await r.json(); + const seq = ++openSeq; 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 = key ? 'inline-block' : 'none'; + document.getElementById('modal-title').textContent = key ? (K.label + ' · ' + key) : ('Nouveau ' + K.label.toLowerCase()); + document.getElementById('m-name').value = key || ''; + document.getElementById('m-content').value = ''; + document.getElementById('m-del').style.display = key ? 'inline-flex' : 'none'; // Model picker is preset-only: it edits the MODEL= line of config.env. const modelRow = document.getElementById('m-model-row'); const settingsRow = document.getElementById('m-settings-row'); - const delModelWrap = document.getElementById('m-del-model-wrap'); const rawHead = document.getElementById('m-raw-head'); const rawToggle = document.getElementById('m-raw-toggle'); const rawBody = document.getElementById('m-raw-body'); @@ -2215,11 +2421,6 @@ async function openItem(kind, key){ settingsRow.style.display = 'flex'; document.getElementById('m-hf-url').value = ''; resetDlUI(); - attachDownload(); // téléchargement encore en cours côté serveur ? - document.getElementById('m-del-model').checked = false; - document.getElementById('m-quant').value = currentQuantInTextarea(); - populateSettings(); - delModelWrap.style.display = key ? 'inline-flex' : 'none'; // Preset : la config brute est une ligne repliable, fermée par défaut. rawHead.textContent = 'Configuration'; rawToggle.style.display = ''; @@ -2228,17 +2429,33 @@ async function openItem(kind, key){ // Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture. document.getElementById('m-dir-path').value = ''; setModelDirsOpen(false); - await Promise.all([populateBackend(), populateModelPicker()]); } else { modelRow.style.display = 'none'; settingsRow.style.display = 'none'; - delModelWrap.style.display = 'none'; // Page mémoire : le contenu EST le champ principal — affiché en clair. rawHead.textContent = 'Contenu'; rawToggle.style.display = 'none'; rawBody.style.display = ''; } - document.getElementById('modal').style.display = 'flex'; + document.getElementById('modal').classList.add('loading'); + showModal('modal'); + // --- Remplissage, une fois la modale à l'écran ----------------------------- + const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); + const d = await r.json(); + if(seq !== openSeq) return; // une autre ouverture a pris la main + 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 || ''; + if(kind === 'preset'){ + // Ces deux-là LISENT le contenu : elles doivent passer après son arrivée. + document.getElementById('m-quant').value = currentQuantInTextarea(); + populateSettings(); + attachDownload(); // téléchargement encore en cours côté serveur ? + await Promise.all([populateBackend(), populateModelPicker()]); + if(seq !== openSeq) return; + } + document.getElementById('modal').classList.remove('loading'); } // Pretty-print bytes — handy for the dropdown options. @@ -2784,7 +3001,7 @@ const openPreset = (id)=>openItem('preset', id); const openMem = (n)=>openItem('mem', n); // Fermer la modale n'annule PAS un téléchargement en cours (il vit côté // serveur) : on arrête juste de l'interroger. -function closeModal(){ document.getElementById('modal').style.display = 'none'; stopDlPoll(); } +function closeModal(){ hideModal('modal'); document.getElementById('modal').classList.remove('loading'); stopDlPoll(); } async function saveItem(){ const K = KINDS[editingKind]; const name = document.getElementById('m-name').value.trim(); @@ -2803,10 +3020,16 @@ async function delItem(){ if(!editingKey) return; const K = KINDS[editingKind]; 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(!await askConfirm(msg, {title:'Suppression', okText:'Supprimer', danger:true})) return; + // Le choix « supprimer aussi le .gguf » est posé DANS la confirmation : il n'a + // de sens qu'au moment de supprimer, et il occupait le pied de l'éditeur en + // permanence. Décoché à chaque ouverture — jamais de modèle effacé parce que + // la case serait restée cochée d'une fois sur l'autre. + const msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?' + + (editingKind==='preset' ? '\n\nLe fichier .gguf du modèle est conservé, sauf si vous cochez ci-dessous (irréversible).' : ''); + const opts = {title:'Suppression', okText:'Supprimer', danger:true}; + if(editingKind==='preset') opts.check = 'supprimer aussi le fichier .gguf'; + if(!await askConfirm(msg, opts)) return; + const delModel = editingKind==='preset' && askChecked(); const payload = editingKind==='preset' ? {id: editingKey, deleteModel: delModel} : {name: editingKey}; @@ -3225,6 +3448,38 @@ function setChatLoading(msg){ document.getElementById('chat-loading-text').textContent=msg; el.classList.add('show'); } +// --- Accueil du fil vide --------------------------------------------------- +// Le logo n'est pas dupliqué dans le HTML : on clone celui de la barre latérale +// (#brand) en retirant ses id (un id ne peut exister qu'une fois) et le numéro +// de version. Les couleurs sont reprises par les classes .ce-*. +function fillEmptyLogo(){ + const box=document.getElementById('ce-logo'), brand=document.getElementById('brand'); + if(!box || !brand || box.childElementCount) return; + ['brand-a','brand-word'].forEach(id=>{ + const src=brand.querySelector('#'+id); if(!src) return; + const el=src.cloneNode(true); el.removeAttribute('id'); + if(id==='brand-word') el.classList.add('ce-word'); + box.appendChild(el); + }); +} +// Affiché seulement quand le fil ne contient AUCUNE bulle et que le replay est +// terminé — sinon il apparaîtrait une fraction de seconde à chaque chargement, +// juste avant que les messages rejoués n'arrivent. +function syncChatEmpty(){ + const box=document.getElementById('chat-empty'); if(!box) return; + fillEmptyLogo(); + const empty = !REPLAYING && !chatEl().querySelector('.msg'); + box.classList.toggle('show', empty); +} +document.addEventListener('DOMContentLoaded', ()=>{ + const c=chatEl(); if(!c) return; + // Le fil est peuplé par des dizaines de chemins différents (replay, direct, + // reset, effacement). On observe donc le DOM plutôt que d'appeler la synchro + // depuis chacun d'eux — le coût est nul, le callback est groupé et sort tout + // de suite pendant le replay. + new MutationObserver(()=>syncChatEmpty()).observe(c, {childList:true}); + syncChatEmpty(); +}); function restoreChat(){ // On masque le chat le temps du replay pour ne pas voir défiler le haut puis // sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au @@ -3328,8 +3583,16 @@ function addCompactMark(){ else chatEl().appendChild(el); scrollMaybe(); } -function killTyping(){ if(!T.typingEl) return; if(simpleMode()) return; removeTyping(); } -function showTyping(){ if(!simpleMode()) return; const c=document.getElementById('chat'); +// L'indicateur « … » est retiré dès que quelque chose de VISIBLE le remplace. +// Il doit donc survivre quand la bulle qui arrive ne sera pas affichée : mode +// simplifié, mais aussi raisonnement/outils masqués par les préférences — sinon +// le fil reste totalement vide pendant que le modèle travaille (rien à voir, et +// aucun signe que ça tourne). +const typingKept=(kind)=>simpleMode() + || (kind==='reasoning' && viewOn('hide-reasoning')) + || (kind==='tool' && viewOn('hide-tools')); +function killTyping(kind){ if(!T.typingEl) return; if(typingKept(kind)) return; removeTyping(); } +function showTyping(kind){ if(!typingKept(kind)) return; const c=document.getElementById('chat'); if(!T.typingEl){ T.typingEl=addTyping(); } else if(c.lastElementChild!==T.typingEl){ c.appendChild(T.typingEl); } } // Label de vitesse rendu depuis les valeurs (fonctionne aussi bien en direct // qu'au replay — pas de timer performance.now, qui n'a pas de sens hors-ligne). @@ -3373,6 +3636,9 @@ function handleDelta(d){ // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). setChatLoading(null); if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; } + // Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra + // déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil. + syncChatEmpty(); return; } if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; } if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; } @@ -3391,23 +3657,25 @@ function handleDelta(d){ if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); } if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; } if(d.tool_used){ - killTyping(); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used; + killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used; if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); } renderToolMsg(T.pendingToolEl, tu); - if(!tu.done) showTyping(); + // Outils masqués : on garde l'indicateur même quand l'appel est terminé (le + // tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé. + if(!tu.done || viewOn('hide-tools')) showTyping('tool'); if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); } return; } if(d.drop_reasoning){ if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; } return; } if(d.reasoning_content){ - killTyping(); + killTyping('reasoning'); if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; } - showTyping(); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); + showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1); @@ -3667,12 +3935,12 @@ async function lcRenderCustomCard(){ } function openCustomBackends(){ - document.getElementById('lc-custom-modal').style.display = 'flex'; + showModal('lc-custom-modal'); document.getElementById('lc-custom-url').value = ''; document.getElementById('lc-custom-name').value = ''; loadCustomBackends(); } -function closeCustomBackends(){ document.getElementById('lc-custom-modal').style.display = 'none'; } +function closeCustomBackends(){ hideModal('lc-custom-modal'); } // Liste les backends custom dans le modal, avec un bouton supprimer par ligne. async function loadCustomBackends(){ @@ -3873,7 +4141,7 @@ function openMcp(name){ else if(s && s.connected){ st.innerHTML = '✓ connecté — '+(s.tools?s.tools.length:0)+' outil(s)'; } document.getElementById('mcp-del').style.display = s ? '' : 'none'; renderMcpTools(s); - document.getElementById('mcp-modal').style.display = 'flex'; + showModal('mcp-modal'); } // Liste des outils découverts du serveur, avec une case par outil pour le @@ -3908,7 +4176,7 @@ async function toggleMcpTool(server, tool, on){ if(s && s.tools) document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)'; } -function closeMcp(){ document.getElementById('mcp-modal').style.display = 'none'; mcpEditing = null; } +function closeMcp(){ hideModal('mcp-modal'); mcpEditing = null; } function mcpTransportUI(t){ document.getElementById('mcp-stdio-fields').style.display = t==='stdio' ? '' : 'none'; diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index f66f369..9c2431d 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -67,68 +67,86 @@
    …
    Presets -
    +
    Mode agent
    - -
    + +
    Mémoire?
    - -
    - ▸ - Pages - -
    - -
    +
    Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep. Le moteur intégré ne demande aucune installation. Le moteur Crawl4AI exige un serveur que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest) mais exécute le JavaScript des pages. Actifs seulement si le mode agent est actif.
    - - -
    - -
    - -
    -
    - +
    + + +
    +
    + +
    +
    +
    + +
    + +
    + + +
    +
    enregistré automatiquement en quittant le champ
    +
    +
    +
    - -
    - - -
    -
    enregistré automatiquement en quittant le champ
    -
    -
    -
    +
    Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
    -
    - +
    +
    +
    + +
    +
    -
    +
    Paramètres
    - +
    + +
    Apparence @@ -147,73 +165,82 @@
    System prompt - +
    + +
    Accès OpenAI
    Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
    -
    Modèle : jean
    - -
    -
    Adresse locale (réseau)
    -
    - - -
    - -
    -
    -
    -
    Accès distant
    ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
    -
    -

    Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

    +
    +

    Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

    -
    - +
    +
    Moteur -
    …
    +
    Installation
    + +
    +
    …
    llama.cpp précompilé conseillé
    @@ -231,33 +258,36 @@
    -
    Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
    -
    -
    +
    +
    -
    +
    Actions -
    -
    +
    +
    + +
    -
    +
    -
    +
    @@ -265,6 +295,16 @@
    chargement de la conversation…
    + +
    @@ -302,6 +342,10 @@
    + +
    @@ -403,6 +447,11 @@ Preset
    + +
    Nom
    @@ -583,10 +632,13 @@
    +
    - - +
    diff --git a/internal/ajean/ui/src/js/03-ui-kit.js b/internal/ajean/ui/src/js/03-ui-kit.js index c0eefcd..12acac1 100644 --- a/internal/ajean/ui/src/js/03-ui-kit.js +++ b/internal/ajean/ui/src/js/03-ui-kit.js @@ -7,16 +7,39 @@ function setBadge(id, state, text){ el.className = 'sbadge' + (state===true ? ' on' : (state==='warn' || state==='err') ? ' '+state : ''); el.textContent = state===null || state===undefined ? '' : (text||''); } +// Ouverture/fermeture des modales. Le display seul apparaissait d'un bloc : on +// pose la classe .show une frame APRÈS l'affichage pour que la transition CSS +// parte de son état initial (sans ce reflow forcé, le navigateur applique tout +// d'un coup et l'animation ne joue pas). À la fermeture on attend la fin de la +// transition avant de repasser en display:none. +function showModal(id){ + const el = typeof id==='string' ? document.getElementById(id) : id; + if(!el) return; + el.style.display='flex'; + void el.offsetHeight; + el.classList.add('show'); +} +function hideModal(id){ + const el = typeof id==='string' ? document.getElementById(id) : id; + if(!el) return; + el.classList.remove('show'); + // Rouverte entre-temps ? On ne la cache surtout pas. + setTimeout(()=>{ if(!el.classList.contains('show')) el.style.display='none'; }, 180); +} function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); } // Modales natives (askConfirm/askPrompt/askAlert) — remplacent confirm()/prompt()/ // alert() par de vraies boîtes stylées. Chacune renvoie une Promise : askConfirm → // bool, askPrompt → string|null (null si annulé), askAlert → void. Échap/clic dehors // = annuler ; Entrée = valider (sur prompt aussi). -let _askResolver=null, _askKind='confirm'; +let _askResolver=null, _askKind='confirm', _askCheck=false; +// État de la case optionnelle de la DERNIÈRE confirmation (opts.check). Lu par +// l'appelant juste après le await — la Promise, elle, ne renvoie que oui/non. +function askChecked(){ return _askCheck; } function askResolve(ok){ if(!_askResolver) return; const r=_askResolver; _askResolver=null; - document.getElementById('ask-modal').style.display='none'; + _askCheck = ok && document.getElementById('ask-check-input').checked; + hideModal('ask-modal'); document.removeEventListener('keydown', _askKey, true); if(_askKind==='prompt') r(ok ? document.getElementById('ask-input').value : null); else r(ok); @@ -37,12 +60,18 @@ function _openAsk(kind, message, opts){ const inp=document.getElementById('ask-input'); if(kind==='prompt'){ inp.style.display=''; inp.value=opts.default||''; inp.placeholder=opts.placeholder||''; } else inp.style.display='none'; + // Case facultative (ex. « supprimer aussi le fichier .gguf »). + const chk=document.getElementById('ask-check'); + chk.style.display = opts.check ? 'inline-flex' : 'none'; + document.getElementById('ask-check-label').textContent = opts.check || ''; + document.getElementById('ask-check-input').checked = false; + _askCheck=false; const cancel=document.getElementById('ask-cancel'), ok=document.getElementById('ask-ok'); cancel.style.display = kind==='alert' ? 'none' : ''; cancel.textContent = opts.cancelText || 'Annuler'; ok.textContent = opts.okText || (kind==='alert'?'OK':kind==='prompt'?'Valider':'Confirmer'); ok.classList.toggle('danger', !!opts.danger); - document.getElementById('ask-modal').style.display='flex'; + showModal('ask-modal'); document.addEventListener('keydown', _askKey, true); setTimeout(()=>{ const f = kind==='prompt'?inp:ok; f.focus(); if(kind==='prompt') inp.select(); }, 30); return new Promise(res=>{ _askResolver=res; }); diff --git a/internal/ajean/ui/src/js/05-status.js b/internal/ajean/ui/src/js/05-status.js index 38a3541..89d91d2 100644 --- a/internal/ajean/ui/src/js/05-status.js +++ b/internal/ajean/ui/src/js/05-status.js @@ -8,7 +8,7 @@ async function loadStatus(){ else if(s.load_error){ cls='err'; txt='modèle incompatible'; } else if(s.active){ cls='loading'; txt='chargement…'; } el.className='statuspill '+cls; - el.innerHTML=''+txt+' :'+s.port+''; + el.innerHTML=''+txt; MODEL_READY = !!(s.active && s.health); if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); } if(s.version){ diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index b308c6d..0275dd1 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -39,7 +39,10 @@ async function loadPresets(){ info.appendChild(nm); if(meta.children.length) info.appendChild(meta); const edit=document.createElement('button'); - edit.className='preset-edit'; edit.title='éditer'; edit.textContent='✎'; + // Engrenage (et non plus un crayon) : la silhouette est ronde, donc elle se + // lit comme centrée dans son coin, là où le crayon penché tirait de travers. + edit.className='preset-edit'; edit.title='réglages du preset'; + edit.innerHTML=''; edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); }; row.appendChild(info); row.appendChild(edit); cont.appendChild(row); @@ -59,7 +62,7 @@ async function loadAgent(){ memPages = (s.pages || s.skills || []).slice().sort((a,b)=>a.name.localeCompare(b.name)); memShown = MEM_PAGE; document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : ''; - document.getElementById('mem-search').style.display = memPages.length > MEM_PAGE ? '' : 'none'; + document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none'; renderMemList(); } // Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index bed5a60..b1399ec 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -1,5 +1,5 @@ -function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; } -function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; } +function openBenchModal(){ showModal('bench-modal'); } +function closeBenchModal(){ hideModal('bench-modal'); } async function runBenchUI(){ const btn = document.getElementById('btn-bench'); const rerun = document.getElementById('bench-rerun'); @@ -56,20 +56,27 @@ const KINDS = { preset: {label:'Preset', param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, mem: {label:'Page', param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()}, }; +// ⚠️ La modale s'ouvre AVANT d'aller chercher quoi que ce soit. Elle attendait +// auparavant la fin de 3 requêtes (contenu + backends + modèles disponibles) : +// en accès distant, ça faisait un clic sans réaction pendant une seconde, comme +// si le bouton était mort. On affiche la coquille tout de suite, puis on la +// remplit. `openSeq` protège du cas « deux ouvertures coup sur coup » : une +// réponse en retard ne doit jamais écraser la modale ouverte après elle. +// Pendant ce remplissage la modale est en `.loading` : son corps est estompé et +// inerte, puis révélé d'un coup une fois TOUT en place — sinon on voyait les +// champs se peupler un par un, ce qui donnait une impression de bricolage. +let openSeq = 0; async function openItem(kind, key){ const K = KINDS[kind]; - const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); - const d = await r.json(); + const seq = ++openSeq; 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 = key ? 'inline-block' : 'none'; + document.getElementById('modal-title').textContent = key ? (K.label + ' · ' + key) : ('Nouveau ' + K.label.toLowerCase()); + document.getElementById('m-name').value = key || ''; + document.getElementById('m-content').value = ''; + document.getElementById('m-del').style.display = key ? 'inline-flex' : 'none'; // Model picker is preset-only: it edits the MODEL= line of config.env. const modelRow = document.getElementById('m-model-row'); const settingsRow = document.getElementById('m-settings-row'); - const delModelWrap = document.getElementById('m-del-model-wrap'); const rawHead = document.getElementById('m-raw-head'); const rawToggle = document.getElementById('m-raw-toggle'); const rawBody = document.getElementById('m-raw-body'); @@ -79,11 +86,6 @@ async function openItem(kind, key){ settingsRow.style.display = 'flex'; document.getElementById('m-hf-url').value = ''; resetDlUI(); - attachDownload(); // téléchargement encore en cours côté serveur ? - document.getElementById('m-del-model').checked = false; - document.getElementById('m-quant').value = currentQuantInTextarea(); - populateSettings(); - delModelWrap.style.display = key ? 'inline-flex' : 'none'; // Preset : la config brute est une ligne repliable, fermée par défaut. rawHead.textContent = 'Configuration'; rawToggle.style.display = ''; @@ -92,17 +94,33 @@ async function openItem(kind, key){ // Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture. document.getElementById('m-dir-path').value = ''; setModelDirsOpen(false); - await Promise.all([populateBackend(), populateModelPicker()]); } else { modelRow.style.display = 'none'; settingsRow.style.display = 'none'; - delModelWrap.style.display = 'none'; // Page mémoire : le contenu EST le champ principal — affiché en clair. rawHead.textContent = 'Contenu'; rawToggle.style.display = 'none'; rawBody.style.display = ''; } - document.getElementById('modal').style.display = 'flex'; + document.getElementById('modal').classList.add('loading'); + showModal('modal'); + // --- Remplissage, une fois la modale à l'écran ----------------------------- + const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); + const d = await r.json(); + if(seq !== openSeq) return; // une autre ouverture a pris la main + 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 || ''; + if(kind === 'preset'){ + // Ces deux-là LISENT le contenu : elles doivent passer après son arrivée. + document.getElementById('m-quant').value = currentQuantInTextarea(); + populateSettings(); + attachDownload(); // téléchargement encore en cours côté serveur ? + await Promise.all([populateBackend(), populateModelPicker()]); + if(seq !== openSeq) return; + } + document.getElementById('modal').classList.remove('loading'); } // Pretty-print bytes — handy for the dropdown options. @@ -648,7 +666,7 @@ const openPreset = (id)=>openItem('preset', id); const openMem = (n)=>openItem('mem', n); // Fermer la modale n'annule PAS un téléchargement en cours (il vit côté // serveur) : on arrête juste de l'interroger. -function closeModal(){ document.getElementById('modal').style.display = 'none'; stopDlPoll(); } +function closeModal(){ hideModal('modal'); document.getElementById('modal').classList.remove('loading'); stopDlPoll(); } async function saveItem(){ const K = KINDS[editingKind]; const name = document.getElementById('m-name').value.trim(); @@ -667,10 +685,16 @@ async function delItem(){ if(!editingKey) return; const K = KINDS[editingKind]; 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(!await askConfirm(msg, {title:'Suppression', okText:'Supprimer', danger:true})) return; + // Le choix « supprimer aussi le .gguf » est posé DANS la confirmation : il n'a + // de sens qu'au moment de supprimer, et il occupait le pied de l'éditeur en + // permanence. Décoché à chaque ouverture — jamais de modèle effacé parce que + // la case serait restée cochée d'une fois sur l'autre. + const msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?' + + (editingKind==='preset' ? '\n\nLe fichier .gguf du modèle est conservé, sauf si vous cochez ci-dessous (irréversible).' : ''); + const opts = {title:'Suppression', okText:'Supprimer', danger:true}; + if(editingKind==='preset') opts.check = 'supprimer aussi le fichier .gguf'; + if(!await askConfirm(msg, opts)) return; + const delModel = editingKind==='preset' && askChecked(); const payload = editingKind==='preset' ? {id: editingKey, deleteModel: delModel} : {name: editingKey}; diff --git a/internal/ajean/ui/src/js/08-chat-render.js b/internal/ajean/ui/src/js/08-chat-render.js index 0aacd60..992e869 100644 --- a/internal/ajean/ui/src/js/08-chat-render.js +++ b/internal/ajean/ui/src/js/08-chat-render.js @@ -295,6 +295,38 @@ function setChatLoading(msg){ document.getElementById('chat-loading-text').textContent=msg; el.classList.add('show'); } +// --- Accueil du fil vide --------------------------------------------------- +// Le logo n'est pas dupliqué dans le HTML : on clone celui de la barre latérale +// (#brand) en retirant ses id (un id ne peut exister qu'une fois) et le numéro +// de version. Les couleurs sont reprises par les classes .ce-*. +function fillEmptyLogo(){ + const box=document.getElementById('ce-logo'), brand=document.getElementById('brand'); + if(!box || !brand || box.childElementCount) return; + ['brand-a','brand-word'].forEach(id=>{ + const src=brand.querySelector('#'+id); if(!src) return; + const el=src.cloneNode(true); el.removeAttribute('id'); + if(id==='brand-word') el.classList.add('ce-word'); + box.appendChild(el); + }); +} +// Affiché seulement quand le fil ne contient AUCUNE bulle et que le replay est +// terminé — sinon il apparaîtrait une fraction de seconde à chaque chargement, +// juste avant que les messages rejoués n'arrivent. +function syncChatEmpty(){ + const box=document.getElementById('chat-empty'); if(!box) return; + fillEmptyLogo(); + const empty = !REPLAYING && !chatEl().querySelector('.msg'); + box.classList.toggle('show', empty); +} +document.addEventListener('DOMContentLoaded', ()=>{ + const c=chatEl(); if(!c) return; + // Le fil est peuplé par des dizaines de chemins différents (replay, direct, + // reset, effacement). On observe donc le DOM plutôt que d'appeler la synchro + // depuis chacun d'eux — le coût est nul, le callback est groupé et sort tout + // de suite pendant le replay. + new MutationObserver(()=>syncChatEmpty()).observe(c, {childList:true}); + syncChatEmpty(); +}); function restoreChat(){ // On masque le chat le temps du replay pour ne pas voir défiler le haut puis // sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au diff --git a/internal/ajean/ui/src/js/09-stream.js b/internal/ajean/ui/src/js/09-stream.js index 252bef8..a57b5ae 100644 --- a/internal/ajean/ui/src/js/09-stream.js +++ b/internal/ajean/ui/src/js/09-stream.js @@ -74,8 +74,16 @@ function addCompactMark(){ else chatEl().appendChild(el); scrollMaybe(); } -function killTyping(){ if(!T.typingEl) return; if(simpleMode()) return; removeTyping(); } -function showTyping(){ if(!simpleMode()) return; const c=document.getElementById('chat'); +// L'indicateur « … » est retiré dès que quelque chose de VISIBLE le remplace. +// Il doit donc survivre quand la bulle qui arrive ne sera pas affichée : mode +// simplifié, mais aussi raisonnement/outils masqués par les préférences — sinon +// le fil reste totalement vide pendant que le modèle travaille (rien à voir, et +// aucun signe que ça tourne). +const typingKept=(kind)=>simpleMode() + || (kind==='reasoning' && viewOn('hide-reasoning')) + || (kind==='tool' && viewOn('hide-tools')); +function killTyping(kind){ if(!T.typingEl) return; if(typingKept(kind)) return; removeTyping(); } +function showTyping(kind){ if(!typingKept(kind)) return; const c=document.getElementById('chat'); if(!T.typingEl){ T.typingEl=addTyping(); } else if(c.lastElementChild!==T.typingEl){ c.appendChild(T.typingEl); } } // Label de vitesse rendu depuis les valeurs (fonctionne aussi bien en direct // qu'au replay — pas de timer performance.now, qui n'a pas de sens hors-ligne). @@ -119,6 +127,9 @@ function handleDelta(d){ // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). setChatLoading(null); if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; } + // Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra + // déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil. + syncChatEmpty(); return; } if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; } if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; } @@ -137,23 +148,25 @@ function handleDelta(d){ if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); } if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; } if(d.tool_used){ - killTyping(); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used; + killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used; if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); } renderToolMsg(T.pendingToolEl, tu); - if(!tu.done) showTyping(); + // Outils masqués : on garde l'indicateur même quand l'appel est terminé (le + // tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé. + if(!tu.done || viewOn('hide-tools')) showTyping('tool'); if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); } return; } if(d.drop_reasoning){ if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; } return; } if(d.reasoning_content){ - killTyping(); + killTyping('reasoning'); if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; } - showTyping(); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); + showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1); diff --git a/internal/ajean/ui/src/js/10-llamacpp.js b/internal/ajean/ui/src/js/10-llamacpp.js index bbc493d..b7301ce 100644 --- a/internal/ajean/ui/src/js/10-llamacpp.js +++ b/internal/ajean/ui/src/js/10-llamacpp.js @@ -163,12 +163,12 @@ async function lcRenderCustomCard(){ } function openCustomBackends(){ - document.getElementById('lc-custom-modal').style.display = 'flex'; + showModal('lc-custom-modal'); document.getElementById('lc-custom-url').value = ''; document.getElementById('lc-custom-name').value = ''; loadCustomBackends(); } -function closeCustomBackends(){ document.getElementById('lc-custom-modal').style.display = 'none'; } +function closeCustomBackends(){ hideModal('lc-custom-modal'); } // Liste les backends custom dans le modal, avec un bouton supprimer par ligne. async function loadCustomBackends(){ diff --git a/internal/ajean/ui/src/js/12-mcp.js b/internal/ajean/ui/src/js/12-mcp.js index cae0522..4ae7eed 100644 --- a/internal/ajean/ui/src/js/12-mcp.js +++ b/internal/ajean/ui/src/js/12-mcp.js @@ -99,7 +99,7 @@ function openMcp(name){ else if(s && s.connected){ st.innerHTML = '✓ connecté — '+(s.tools?s.tools.length:0)+' outil(s)'; } document.getElementById('mcp-del').style.display = s ? '' : 'none'; renderMcpTools(s); - document.getElementById('mcp-modal').style.display = 'flex'; + showModal('mcp-modal'); } // Liste des outils découverts du serveur, avec une case par outil pour le @@ -134,7 +134,7 @@ async function toggleMcpTool(server, tool, on){ if(s && s.tools) document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)'; } -function closeMcp(){ document.getElementById('mcp-modal').style.display = 'none'; mcpEditing = null; } +function closeMcp(){ hideModal('mcp-modal'); mcpEditing = null; } function mcpTransportUI(t){ document.getElementById('mcp-stdio-fields').style.display = t==='stdio' ? '' : 'none'; diff --git a/internal/ajean/ui/src/styles.css b/internal/ajean/ui/src/styles.css index 4720597..028e243 100644 --- a/internal/ajean/ui/src/styles.css +++ b/internal/ajean/ui/src/styles.css @@ -4,7 +4,11 @@ passent par ces variables ; rien d'autre ne code une couleur en dur. */ :root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)} [data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)} -*{box-sizing:border-box} +/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien, + résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors + de notre charte et impossible à styler : on le coupe. Nos états :active/:focus + restent, eux, gérés élément par élément. */ +*{box-sizing:border-box;-webkit-tap-highlight-color:transparent} /* ⚠️ overflow:hidden sur html/body est INTERDIT EN PWA (tenté puis retiré le 2026-07-28) : en PWA iOS, si le document ne peut plus défiler, WebKit décale TOUTE la mise en page à l'ouverture du clavier pour montrer le champ actif — @@ -109,18 +113,18 @@ h1{margin:0;font-size:18px;color:var(--accent)} .statuspill.loading .dot{animation:pulse 1s ease-in-out infinite} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}} .statuspill .dot{width:7px;height:7px;border-radius:50%;background:currentColor} -.statuspill .port{opacity:.6;font-weight:400} /* en-tête + bouton d'ajout de skill (toujours visible) */ .skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px} .skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600} -.addbtn{font-size:11px;padding:3px 11px 4px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;line-height:1.2} -.addbtn:hover{border-color:var(--accent);color:var(--accent)} +.addbtn{font-size:11px;padding:4px 11px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:999px;cursor:pointer;line-height:1.3;letter-spacing:.02em} +.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)} .mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px} /* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait (le trait est réservé aux sections principales — cf. `details`). La séparation - visuelle vient de la carte englobante .gated-block. */ + visuelle vient de la carte de lignes qui suit (.slist). */ /* Barre repliable « Pages » : un seul chevron, bouton + ajouter aligné à droite. */ .pages-bar{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;padding:7px 0;margin-top:4px} +.pages-bar .pages-caret{width:9px;text-align:center} .pages-bar .pages-caret{flex-shrink:0;font-size:9px;color:var(--dim);transition:transform .12s} .pages-bar.open .pages-caret{transform:rotate(90deg)} .pages-bar .pages-title{font-size:11.5px;color:var(--dim);font-weight:600} @@ -128,11 +132,9 @@ h1{margin:0;font-size:18px;color:var(--accent)} .mem-search{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font:inherit;font-size:12px;margin:0 0 6px} .mem-search:focus{outline:none;border-color:var(--accent)} .mem-list{max-height:240px;overflow-y:auto;overscroll-behavior:contain} -.mem-list .preset{margin:3px 0;padding:5px 9px} .mem-more{font-size:11px;color:var(--dim);text-align:center;padding:6px 0 2px} .ask-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:60;display:flex;align-items:center;justify-content:center;padding:20px} -.ask-box{background:var(--bg);border:1px solid var(--border);border-radius:10px;width:100%;max-width:420px;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.18);animation:askpop .12s ease-out} -@keyframes askpop{from{opacity:0;transform:translateY(6px) scale(.98)}to{opacity:1;transform:none}} +.ask-box{background:var(--bg);border:1px solid var(--border);border-radius:10px;width:100%;max-width:420px;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.18)} .ask-title{font-size:15px;font-weight:600;color:var(--text);margin-bottom:8px} .ask-msg{font-size:13px;color:var(--dim);line-height:1.5;white-space:pre-wrap;word-break:break-word} .ask-input{width:100%;box-sizing:border-box;margin-top:12px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:9px 11px;font:inherit;font-size:13px} @@ -147,17 +149,15 @@ h1{margin:0;font-size:18px;color:var(--accent)} pastille dans les réglages. */ #brand.agent #brand-a{color:var(--accent)} /* interrupteur (switch) */ -/* Sous-réglage du mode agent inaccessible quand l'agent est off : grisé + inerte. */ -/* Sous-réglages du mode agent présentés en CARTES (bordure + fond) pour ne pas - les confondre avec les traits des sections principales. */ -/* Bloc du mode agent = carte de liste : le rembourrage est porté par les - lignes (comme .slist), pas par la carte, sinon les retraits se cumulent. */ -/* ⚠️ PAS d'overflow:hidden ici : les bulles d'aide (« ? ») sont positionnées en - absolu par rapport à la ligne, donc un débordement caché les TRONQUE — c'est - ce qui rendait plusieurs infobulles illisibles. */ -.gated-block{margin:6px 0;border:1px solid var(--border);border-radius:10px;padding:0 0 10px;background:var(--panel);overflow:visible;transition:opacity .15s} -.gated-block>.switchrow:first-of-type{padding-top:0} -.gated-block.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)} +/* Sous-groupe du mode agent (Mémoire, Accès internet, Serveurs MCP, Paramètres). + MÊME grammaire que « Machine » ou « Apparence » : étiquette de groupe NUE + au-dessus, puis UNE carte .slist qui porte toutes les lignes. Auparavant + chaque sous-groupe était une carte qui embarquait son propre titre et du + contenu libre aux marges variables — quatre îlots décollés au lieu d'une + colonne régulière. Le grisé « agent désactivé » se pose sur le GROUPE + (étiquette comprise), plus sur la carte seule. */ +.gated-group{margin:0 0 10px;transition:opacity .15s} +.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)} .switch{position:relative;display:inline-block;width:36px;height:20px;flex:none} .switch input{position:absolute;opacity:0;width:0;height:0} /* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se @@ -184,10 +184,27 @@ button:disabled{opacity:.5;cursor:not-allowed} .preset.active{border-color:var(--accent);color:var(--accent);border-width:1.5px;padding:5.5px 9.5px} .preset>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .preset>button{flex-shrink:0} +/* Liste de lignes cliquables (presets, pages mémoire) posée à fleur de carte : + une pile de petites cartes bordées faisait « trop de carrés » là où le reste + du menu aligne des lignes séparées par un filet. L'actif se signale par un + voile et un liseré à gauche, plus par un cadre propre. */ +.rowlist>.preset{border:0;border-radius:0;margin:0;padding:6px 10px;background:transparent} +.rowlist>.preset:not(:last-child){border-bottom:1px solid var(--border)} +.rowlist>.preset:hover{background:var(--accent-bg)} +.rowlist>.preset.active{background:var(--accent-bg);color:var(--text);font-weight:600; + box-shadow:inset 2px 0 0 var(--text)} +/* Ligne à deux étages (titre + étiquettes) : l'interligne interne suffit, on + resserre donc le haut et le bas. */ +.rowlist>.preset .preset-meta{margin-top:4px} +/* Messages et « voir plus » de la liste : alignés sur le retrait des lignes. */ +.rowlist>.muted,.rowlist>.mem-more{padding:9px 10px} .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:-3px -4px 0 0;padding:2px 4px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.6} -.preset-edit:hover{opacity:1;color:var(--accent)} +/* Icône d'engrenage : centrée verticalement sur la ligne (sa silhouette ronde se + lit bien au milieu de son coin, là où le crayon penché tirait de travers). */ +.preset-edit{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center; + margin:0 -2px 0 0;padding:4px;line-height:0;background:transparent;border:none;opacity:.55} +.preset-edit:hover{opacity:1;color:var(--accent);background:transparent} .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} @@ -302,6 +319,21 @@ button:disabled{opacity:.5;cursor:not-allowed} align-items:center;justify-content:center;gap:12px;background:var(--bg); color:var(--dim);font-size:12px;letter-spacing:.04em;pointer-events:none} #chat-loading.show{display:flex} +/* Accueil du fil vide. Posé en surimpression du chat (et non DANS le fil) pour ne + pas perturber le défilement ni le rendu du replay ; pointer-events:none pour + qu'il ne vole jamais un clic à la zone de saisie. Il vit sous le voile de + chargement (z-index plus bas) : jamais les deux visibles en même temps. */ +#chat-empty{position:absolute;inset:0 0 150px;z-index:3;display:none;flex-direction:column; + align-items:center;justify-content:center;gap:10px;pointer-events:none; + opacity:0;transition:opacity .35s ease} +#chat-empty.show{display:flex;opacity:1} +/* Le clone perd les règles d'id de #brand (les id sont retirés), dont l'espace + entre le « A » et le mot : on le repose ici, à l'échelle du logo agrandi + (2 px pour 10 px de haut → 4 px pour 20 px), sinon le A s'encastre dans le J. */ +#ce-logo{display:inline-flex;align-items:center;gap:4px;color:var(--text)} +#ce-logo svg{height:20px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor} +#ce-logo .ce-word{display:inline-flex;color:var(--accent)} +#ce-title{font-size:14px;color:var(--dim);letter-spacing:.03em} .cl-dots{display:inline-flex;gap:5px;align-items:center} .cl-dots span{width:7px;height:7px;border-radius:50%;background:var(--dim);animation:typingdot 1.2s infinite ease-in-out} .cl-dots span:nth-child(2){animation-delay:.16s} @@ -316,6 +348,15 @@ button:disabled{opacity:.5;cursor:not-allowed} défiler. Un seul gabarit, repris des listes de la modale de preset. Hauteurs : en-tête de section 34 px, ligne de réglage 34 px. */ .side{padding:10px 12px} +/* ⚠️ Le raccourci `padding` ci-dessus écrase les esquives d'encoche posées plus + haut (règles tiroir). Quand la barre est un TIROIR (téléphone, ou mode + « barre en tiroir » sur ordinateur), elle est en position:fixed : le padding + du ne la protège pas, il faut donc reposer les env() ici — sinon + « AJEAN » et la pastille d'état passent sous l'encoche en PWA iOS. */ +@media (max-width:720px){ + .side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))} +} +[data-hide-side="1"] .side{padding:calc(10px + env(safe-area-inset-top)) 12px calc(10px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))} .sidehead{margin-bottom:10px} /* --- En-tête de section --- */ @@ -362,25 +403,40 @@ details[open]>summary::after{transform:rotate(45deg)} .subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{position:relative;display:flex;align-items:center; font-size:11px;font-weight:600;color:var(--dim);margin:10px 2px 5px} .io-cap{margin:0 2px 5px} -/* Premier élément d'une carte : sa marge haute s'ajoutait au rembourrage de la - carte (18 px de vide au-dessus de « Mémoire », « Accès internet », etc.). */ -.gated-block>:first-child{margin-top:0} +/* Étiquette de groupe en tête d'un sous-groupe du mode agent : elle suit une + carte, elle a donc besoin de son air normal — mais pas d'un cumul de marges. */ +.gated-group>.subhead:first-child{margin-top:12px} /* Une étiquette de groupe qui suit immédiatement le titre de section n'a pas besoin de reprendre de l'air : le titre en a déjà donné. */ details>summary+.subhead{margin-top:4px} .io-group{margin-top:8px} /* --- Carte de liste --- */ -/* overflow VISIBLE : cf. la note sur .gated-block — les infobulles doivent +/* overflow VISIBLE : les infobulles (« ? ») sont positionnées en absolu par + rapport à la ligne, un débordement caché les TRONQUERAIT — elles doivent pouvoir déborder de la carte. Les lignes n'ont pas de fond propre, l'arrondi n'a donc rien à rogner. */ -.slist,.gated-block{background:var(--panel);border:1px solid var(--border);border-radius:9px; +.slist{background:var(--panel);border:1px solid var(--border);border-radius:9px; overflow:visible;margin:0 0 8px} -.slist:last-child,.gated-block:last-child{margin-bottom:0} +.slist:last-child{margin-bottom:0} /* Contenu libre (champ + boutons) : le rembourrage passe sur la carte. */ -.slist.pad,.gated-block{padding:8px 10px} +.slist.pad{padding:8px 10px} .slist.pad>:first-child{margin-top:0} .slist.pad>:last-child{margin-bottom:0} +/* Contenu libre posé DANS une carte de lignes (champs Crawl4AI, liste MCP, + pages mémoire) : même rembourrage et même filet de séparation qu'une ligne, + pour qu'il ne flotte pas au milieu de la carte. */ +.slist>.rowbody{padding:9px 10px} +.slist>.rowbody:not(:first-child),.slist>.pages-bar:not(:first-child){border-top:1px solid var(--border)} +/* …sauf juste après une ligne de réglage : elle porte DÉJÀ son filet bas, les + deux se cumuleraient en un trait double. */ +.slist>.switchrow+.rowbody,.slist>.switchrow+.pages-bar{border-top:0} +.slist>.rowbody>:first-child{margin-top:0} +.slist>.rowbody>:last-child{margin-bottom:0} +.slist>.pages-bar{min-height:34px;padding:4px 10px;margin-top:0} +/* Contrôle posé à droite d'une ligne (liste « mode » de la mémoire) : même place + que l'interrupteur, largeur ajustée au contenu. */ +.switchrow>.sctl{margin-left:auto;order:2;width:auto;max-width:62%} /* --- Ligne de réglage : hauteur constante, contrôle à droite --- */ .switchrow{position:relative;display:flex;align-items:center;gap:10px;min-height:34px;padding:6px 0; @@ -412,9 +468,17 @@ details>summary+.subhead{margin-top:4px} border-radius:7px;padding:7px 9px;font:inherit;font-size:12.5px} .sctl:focus,.io-input:focus{outline:none;border-color:var(--dim)} textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} -.side button:not(.preset-edit):not(.addbtn):not(.preset-add):not(.copybtn){font-size:12px;padding:6px 10px;border-radius:7px} +.side button:not(.preset-edit):not(.addbtn):not(.preset-add):not(.copybtn):not(.iconbtn){font-size:12px;padding:6px 10px;border-radius:7px} .side .row{display:flex;gap:6px;align-items:center;margin:6px 0} .side .row .sctl,.side .row .io-input{flex:1;min-width:0} +/* Bouton d'action à côté d'un champ (copier, ouvrir) : un CARRÉ à la hauteur du + champ, icône au trait centrée. Le glyphe seul (⧉, ↗) dans un bouton texte se + posait de travers et changeait de largeur d'une police à l'autre. */ +.iconbtn{flex:none;display:inline-flex;align-items:center;justify-content:center; + width:34px;height:34px;padding:0;margin:0;color:var(--dim);background:var(--panel); + border:1px solid var(--border);border-radius:9px;cursor:pointer} +.iconbtn:hover{color:var(--text);border-color:var(--dim);background:var(--accent-bg)} +.iconbtn svg{width:15px;height:15px;display:block} .side .btn-danger-o{border-color:var(--err);color:var(--err)} .side .btn-danger-o:hover{background:var(--danger-bg);border-color:var(--err);color:var(--err)} .side .muted{font-size:11.5px;line-height:1.5} @@ -512,11 +576,16 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} #lc-chip{height:32px;font-size:11px;left:calc(46px + env(safe-area-inset-left));top:calc(6px + env(safe-area-inset-top))} } /* --- Backend llama.cpp (2 modes : rapide / optimisée) --------------------- */ -.lc-head{font-size:12px;line-height:1.5;margin-bottom:10px} -.lc-modes{display:flex;flex-direction:column;gap:8px} +.lc-head{font-size:12px;line-height:1.5} +/* Les trois modes sont des LIGNES de la carte d'installation (elles étaient des + cartes dans une carte) : pas de cadre propre, un filet entre elles, comme + n'importe quelle ligne de réglage du menu. */ +.lc-modes{display:flex;flex-direction:column} .lc-modes.busy{opacity:.5;pointer-events:none} -.lc-mode{border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .12s,background .12s} -.lc-mode:hover{border-color:var(--dim)} +.slist>.lc-modes:not(:first-child){border-top:1px solid var(--border)} +.lc-mode{padding:10px;cursor:pointer;transition:background .12s} +.lc-mode:not(:last-child){border-bottom:1px solid var(--border)} +.lc-mode:hover{background:var(--accent-bg)} .lc-mode-t{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px} /* « conseillé » : pilule PLEINE (texte inversé). En contour, elle héritait du gris atténué du thème monochrome et devenait illisible en mode sombre. */ @@ -664,19 +733,51 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .pe-adv.pe-raw-plain>summary{display:none} .pe-adv.pe-raw-plain .pe-adv-body{padding:0} /* Pied de modale. */ -.pe-foot{padding:13px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap} -.pe-foot .foot-r{display:flex;gap:8px} -.pe-btn{margin:0;padding:9px 18px;border-radius:9px;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--panel);color:var(--text)} +.pe-foot{padding:9px 12px;border-top:1px solid var(--border);display:flex;justify-content:space-between; + align-items:center;gap:8px;flex-wrap:nowrap} +.pe-foot .foot-l{display:flex;align-items:center;gap:8px;min-width:0} +.pe-foot .foot-r{display:flex;gap:8px;flex:none} +.pe-btn{margin:0;padding:7px 15px;font-size:12.5px;border-radius:8px;font-weight:600;cursor:pointer; + border:1px solid var(--border);background:var(--panel);color:var(--text);white-space:nowrap} .pe-btn:hover{border-color:var(--dim)} .pe-btn.primary{border-color:var(--accent);background:var(--accent);color:var(--bg)} .pe-btn.primary:hover{filter:brightness(1.08)} -.pe-btn.ghost-danger{border-color:transparent;background:transparent;color:var(--err);padding:9px 10px} -.pe-btn.ghost-danger:hover{background:var(--danger-bg)} -.pe-delmodel{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dim);cursor:pointer} -.pe-delmodel input{margin:0} +/* Suppression : icône seule, en retrait. Un bouton plein « supprimer » pesait + autant que « enregistrer » alors que c'est l'action rare — et à trois boutons + larges, le pied passait à deux lignes sur téléphone. */ +.pe-del{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px; + margin:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--err);cursor:pointer} +.pe-del svg{width:15px;height:15px;display:block} +.pe-del:hover{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 40%,transparent)} +/* Option d'une confirmation : une PASTILLE à cocher, pas une case système suivie + d'un bout de texte. Éteinte : contour discret. Allumée : elle passe au rouge, + comme l'action destructrice qu'elle étend. Vit dans la boîte de confirmation + (#ask-check), plus dans le pied de l'éditeur. */ +.pe-delmodel{align-self:flex-start;margin-top:14px;display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600; + letter-spacing:.02em;color:var(--dim);cursor:pointer;white-space:nowrap; + padding:5px 11px;border:1px solid var(--border);border-radius:999px;background:var(--panel); + transition:color .12s,border-color .12s,background .12s} +.pe-delmodel input{position:absolute;opacity:0;width:0;height:0} +/* Le point tient lieu de case à cocher : creux au repos, plein une fois coché. */ +.pe-delmodel::before{content:"";width:6px;height:6px;border-radius:50%;flex:none; + border:1px solid currentColor;background:transparent} +.pe-delmodel:hover{color:var(--text);border-color:var(--dim)} +.pe-delmodel:has(input:checked){color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent);background:var(--danger-bg)} +.pe-delmodel:has(input:checked)::before{background:currentColor} /* Éditeur de preset/mémoire — mise en forme propre du modale */ .modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px} -.modal-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)} +/* Ouverture/fermeture animées. L'état par défaut est l'état FERMÉ ; la classe + .show (posée par showModal/hideModal) fait jouer la transition dans les deux + sens — une @keyframes n'aurait animé que l'ouverture. */ +.modal-ov,.ask-overlay{opacity:0;transition:opacity .16s ease} +.modal-ov.show,.ask-overlay.show{opacity:1} +.modal-card,.ask-box{transform:translateY(10px) scale(.985);opacity:0; + transition:transform .18s cubic-bezier(.2,.8,.3,1),opacity .18s ease} +.modal-ov.show .modal-card,.ask-overlay.show .ask-box{transform:none;opacity:1} +@media (prefers-reduced-motion:reduce){ + .modal-ov,.ask-overlay,.modal-card,.ask-box{transition:none} +} +.modal-card{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:12px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)} .modal-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center} .modal-head strong{font-size:14px} /* Croix de fermeture : un CARRÉ, pas un rectangle. Avec un padding asymétrique @@ -684,6 +785,25 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} clic était basse. 32px, glyphe centré, taille de cible correcte au doigt. */ .modal-x{margin:0;padding:0;width:32px;height:32px;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:19px;line-height:1;color:var(--dim)} .modal-body{padding:16px;overflow:auto;flex:1} +/* Modale ouverte mais pas encore remplie : on affiche le voile de chargement À LA + PLACE du formulaire et du pied. Estomper le formulaire ne suffisait pas — on + voyait quand même les champs s'écrire un par un derrière le voile. */ +/* Fond TRANSPARENT : le formulaire est déjà rendu invisible par `visibility`, + un fond opaque ne servirait qu'à masquer aussi le titre de la modale. */ +.modal-load{display:none;position:absolute;inset:0;z-index:2;align-items:center;justify-content:center; + pointer-events:none} +.modal-ov.loading .modal-load{display:flex} +/* visibility (et non display) : le formulaire garde sa place, donc la carte a + déjà sa taille finale — la révéler ne provoque plus d'à-coup de hauteur. */ +.modal-ov.loading .modal-body, +.modal-ov.loading .pe-body, +.modal-ov.loading .pe-foot, +.modal-ov.loading .modal-foot{visibility:hidden} +/* Rond qui tourne — les trois points sont la signature du CHAT, pas des modales. */ +.spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--border); + border-top-color:var(--dim);animation:spin .7s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +@media (prefers-reduced-motion:reduce){.spinner{animation-duration:2s}} .modal-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap} .foot-l{display:flex;align-items:center;gap:10px;flex-wrap:wrap} .foot-r{display:flex;gap:8px} @@ -714,7 +834,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .btn-save{border-color:var(--accent);color:var(--accent);margin:0} .del-model{align-items:center;gap:5px;font-size:11px;color:var(--dim)} .del-model input{margin:0} -.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:12px;color:var(--dim);font-weight:600;padding:6px 0;margin-top:4px} +.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:12px;color:var(--dim);font-weight:600;padding:6px 0;margin-top:2px} .lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} .lc-logbar.open #lc-log-caret{transform:rotate(90deg)} #lc-log{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0} @@ -748,11 +868,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} compacts, pour une présentation homogène et sans surcharge visuelle. */ .io-input{flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:9px;padding:8px 10px;font:inherit;font-size:13px} -/* Toggles de la sous-section « Paramètres » du mode agent : chacun dans sa - propre carte bordée et arrondie (comme les serveurs MCP), plus lisible. */ -#param-block .switchrow{border:1px solid var(--border);border-radius:8px;padding:9px 11px;background:var(--bg)} -#param-block .switchrow+.switchrow{margin-top:8px} - /* Modale MCP (ajout/édition) : les champs des groupes stdio/http n'avaient aucun espacement vertical (le conteneur n'est pas un flex à gap) → ils se touchaient. On aligne leur rythme sur celui du reste du formulaire (.mform). */ @@ -840,13 +955,13 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} #ctx-fill{background:var(--text);opacity:.75} #sendhint{order:4;padding:12px var(--col) 2px} /* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */ -button,.addbtn,.mem-search{border-radius:10px} +button,.mem-search{border-radius:10px} /* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */ .pe-inpbtn .pe-inp{border-radius:10px 0 0 10px} .pe-inpbtn button{border-radius:0 10px 10px 0} .input-btn .ctl{border-radius:10px 0 0 10px} .input-btn .btn-in{border-radius:0 10px 10px 0} -.preset,.gated-block,.lc-mode{border-radius:12px} +.preset{border-radius:12px} /* Les presets restent des cartes (fond + bordure) : à plat, sans fond ni cadre, la liste ne se lisait plus comme des lignes distinctes. L'actif se signale par sa bordure — un simple voile d'accent (7 % de blanc sur fond From d15d40eb7c3563101fb4f8849cf4ada715b83d25 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 5 Aug 2026 21:29:33 +0200 Subject: [PATCH 219/279] Modale preset : ouverture en haut et bloc moteur stabilise, pastilles redondantes retirees --- internal/ajean/ui/index.html | 29 +++++++++++++++---------- internal/ajean/ui/src/js/06-settings.js | 7 +++--- internal/ajean/ui/src/js/07-models.js | 15 +++++++++---- internal/ajean/ui/src/js/12-mcp.js | 7 +++--- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index aaa9e44..0b788f2 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -2215,10 +2215,11 @@ function renderInternet(s){ if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none'; if(document.activeElement !== document.getElementById('crawl-url')) document.getElementById('crawl-url').value = s.url || ''; - // Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les - // outils web ne sont pas proposés au modèle dans ce cas). + // La pastille ne redit PAS ce que l'interrupteur montre déjà (actif/inactif) : + // elle ne sert qu'à signaler l'anomalie — actif mais serveur injoignable, cas + // où les outils web ne sont pas proposés au modèle. if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable'); - else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif'); + else setBadge('internet-badge', null); // Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée), // on indique seulement si une clé est enregistrée. const ks=document.getElementById('crawl-key-state'), kc=document.getElementById('crawl-key-clear'); @@ -2397,9 +2398,9 @@ const KINDS = { // si le bouton était mort. On affiche la coquille tout de suite, puis on la // remplit. `openSeq` protège du cas « deux ouvertures coup sur coup » : une // réponse en retard ne doit jamais écraser la modale ouverte après elle. -// Pendant ce remplissage la modale est en `.loading` : son corps est estompé et -// inerte, puis révélé d'un coup une fois TOUT en place — sinon on voyait les -// champs se peupler un par un, ce qui donnait une impression de bricolage. +// Pendant ce remplissage la modale est en `.loading` : un rond tourne à la place +// du formulaire, qui n'est révélé qu'une fois TOUT en place (contenu, réglages, +// backends, GPU) — sinon on voyait les champs se peupler un par un. let openSeq = 0; async function openItem(kind, key){ const K = KINDS[kind]; @@ -2438,6 +2439,10 @@ async function openItem(kind, key){ rawBody.style.display = ''; } document.getElementById('modal').classList.add('loading'); + // Le corps de la modale est un conteneur défilant RÉUTILISÉ d'une ouverture à + // l'autre : sans cette remise à zéro, ouvrir un preset après en avoir fait + // défiler un autre rouvrait la fiche au milieu. + const peBody=document.querySelector('#modal .pe-body'); if(peBody) peBody.scrollTop=0; showModal('modal'); // --- Remplissage, une fois la modale à l'écran ----------------------------- const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); @@ -2640,7 +2645,10 @@ async function populateBackend(){ if(radio) radio.checked = true; toggleBackendCustom(mode); if(mode === 'custom') document.getElementById('m-backend-path').value = cur; - loadGpuDevices(); + // Attendu (et non lancé dans le vide) : la liste des GPU change la hauteur du + // bloc « moteur ». Sans ce await, elle arrivait APRÈS la levée du voile de + // chargement et on voyait le bloc bouger tout seul. + await loadGpuDevices(); } function toggleBackendCustom(mode){ document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none'; @@ -4051,10 +4059,9 @@ async function loadMCP(){ renderMCP(await jget('/api/mcp')); } function renderMCP(r){ mcpServers = (r && r.servers) || []; - const enabled = mcpServers.filter(s=>s.enabled).length; - if(enabled) setBadge('mcp-badge', true, enabled+(enabled>1?' actifs':' actif')); - else if(mcpServers.length) setBadge('mcp-badge', false, 'aucun actif'); - else setBadge('mcp-badge', null); + // Pas de pastille de comptage : chaque serveur affiche déjà son propre état + // dans la liste juste en dessous. + setBadge('mcp-badge', null); const list = document.getElementById('mcp-list'); list.textContent = ''; if(!mcpServers.length){ diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index 0275dd1..1f1ebd7 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -147,10 +147,11 @@ function renderInternet(s){ if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none'; if(document.activeElement !== document.getElementById('crawl-url')) document.getElementById('crawl-url').value = s.url || ''; - // Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les - // outils web ne sont pas proposés au modèle dans ce cas). + // La pastille ne redit PAS ce que l'interrupteur montre déjà (actif/inactif) : + // elle ne sert qu'à signaler l'anomalie — actif mais serveur injoignable, cas + // où les outils web ne sont pas proposés au modèle. if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable'); - else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif'); + else setBadge('internet-badge', null); // Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée), // on indique seulement si une clé est enregistrée. const ks=document.getElementById('crawl-key-state'), kc=document.getElementById('crawl-key-clear'); diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index b1399ec..f71cdf9 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -62,9 +62,9 @@ const KINDS = { // si le bouton était mort. On affiche la coquille tout de suite, puis on la // remplit. `openSeq` protège du cas « deux ouvertures coup sur coup » : une // réponse en retard ne doit jamais écraser la modale ouverte après elle. -// Pendant ce remplissage la modale est en `.loading` : son corps est estompé et -// inerte, puis révélé d'un coup une fois TOUT en place — sinon on voyait les -// champs se peupler un par un, ce qui donnait une impression de bricolage. +// Pendant ce remplissage la modale est en `.loading` : un rond tourne à la place +// du formulaire, qui n'est révélé qu'une fois TOUT en place (contenu, réglages, +// backends, GPU) — sinon on voyait les champs se peupler un par un. let openSeq = 0; async function openItem(kind, key){ const K = KINDS[kind]; @@ -103,6 +103,10 @@ async function openItem(kind, key){ rawBody.style.display = ''; } document.getElementById('modal').classList.add('loading'); + // Le corps de la modale est un conteneur défilant RÉUTILISÉ d'une ouverture à + // l'autre : sans cette remise à zéro, ouvrir un preset après en avoir fait + // défiler un autre rouvrait la fiche au milieu. + const peBody=document.querySelector('#modal .pe-body'); if(peBody) peBody.scrollTop=0; showModal('modal'); // --- Remplissage, une fois la modale à l'écran ----------------------------- const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); @@ -305,7 +309,10 @@ async function populateBackend(){ if(radio) radio.checked = true; toggleBackendCustom(mode); if(mode === 'custom') document.getElementById('m-backend-path').value = cur; - loadGpuDevices(); + // Attendu (et non lancé dans le vide) : la liste des GPU change la hauteur du + // bloc « moteur ». Sans ce await, elle arrivait APRÈS la levée du voile de + // chargement et on voyait le bloc bouger tout seul. + await loadGpuDevices(); } function toggleBackendCustom(mode){ document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none'; diff --git a/internal/ajean/ui/src/js/12-mcp.js b/internal/ajean/ui/src/js/12-mcp.js index 4ae7eed..da5a746 100644 --- a/internal/ajean/ui/src/js/12-mcp.js +++ b/internal/ajean/ui/src/js/12-mcp.js @@ -9,10 +9,9 @@ async function loadMCP(){ renderMCP(await jget('/api/mcp')); } function renderMCP(r){ mcpServers = (r && r.servers) || []; - const enabled = mcpServers.filter(s=>s.enabled).length; - if(enabled) setBadge('mcp-badge', true, enabled+(enabled>1?' actifs':' actif')); - else if(mcpServers.length) setBadge('mcp-badge', false, 'aucun actif'); - else setBadge('mcp-badge', null); + // Pas de pastille de comptage : chaque serveur affiche déjà son propre état + // dans la liste juste en dessous. + setBadge('mcp-badge', null); const list = document.getElementById('mcp-list'); list.textContent = ''; if(!mcpServers.length){ From a63a66bef8b73f09ded3231f0a4a040f2c0b4213 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 5 Aug 2026 21:36:51 +0200 Subject: [PATCH 220/279] Corrige la position d'ouverture de la modale et le saut des champs Crawl4AI --- internal/ajean/ui/index.html | 17 ++++++++++++----- internal/ajean/ui/src/index.tmpl.html | 5 ++++- internal/ajean/ui/src/js/07-models.js | 12 ++++++++---- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 0b788f2..d26fff8 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1103,7 +1103,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
    -
    + + -
    + +
    Moteur -
    Installation
    - -
    Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle.
    +
    -
    llama.cpp précompilé conseillé
    -
    binaire officiel téléchargé tel quel, prêt en ~2 min
    +
    llama.cpp précompilé
    +
    binaire officiel téléchargé tel quel, prêt en ~2 min
    -
    llama.cpp compilé
    -
    compilé ici pour votre machine — plus rapide, plus long à installer
    +
    llama.cpp compilé
    +
    compilé ici pour votre machine — plus rapide, plus long à installer
    @@ -1536,6 +1535,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
    +
    + Destination + +
    Dossiers de modèleschercher les .gguf ailleurs (disque externe…) @@ -1546,7 +1549,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
    -
    Les téléchargements, eux, vont toujours dans le dossier AJEAN.
    +
    Ces dossiers servent aussi de destination de téléchargement.
    @@ -2618,7 +2621,7 @@ async function openItem(kind, key){ document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); attachDownload(); // téléchargement encore en cours côté serveur ? - await Promise.all([populateBackend(), populateModelPicker()]); + await Promise.all([populateBackend(), populateModelPicker(), populateDlDirs()]); if(seq !== openSeq) return; } // On lève le voile SANS transition (voir la note dans styles.css), puis on rend @@ -2717,7 +2720,8 @@ async function populateModelDirs(){ const info = document.createElement('span'); info.className = 'muted'; const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; - info.textContent = '(' + n + (x.home ? ', dossier jean' : '') + ')'; + const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : ''; + info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')'; row.append(p, info); if(!x.home){ const del = document.createElement('button'); @@ -2738,13 +2742,13 @@ async function addModelDir(){ if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } inp.value = ''; toast('dossier ajouté'); - await Promise.all([populateModelDirs(), populateModelPicker()]); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } async function removeModelDir(p){ const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } toast('dossier retiré'); - await Promise.all([populateModelDirs(), populateModelPicker()]); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } function onPickModel(){ const val = document.getElementById('m-model').value; @@ -3237,17 +3241,50 @@ function resetDlUI(){ e.prog.style.display='none'; e.bar.style.display='none'; e.cancel.style.display='none'; e.btn.disabled=false; } +// Remplit le sélecteur de destination avec les dossiers de modèles connus. +// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment +// de choisir, et ça évite une ligne de plus dans une modale déjà chargée. +// Même source que « Dossiers de modèles » : ajouter un disque externe là-bas le +// rend aussitôt disponible ici. +let dlDirList = []; +async function populateDlDirs(){ + const sel = document.getElementById('m-hf-dir'); + if(!sel) return; + let d = {}; + try{ d = await jget('/api/models/dirs'); }catch(_){ return; } + dlDirList = d.dirs || []; + const prev = sel.value; + sel.innerHTML = ''; + for(const x of dlDirList){ + const o = document.createElement('option'); + o.value = x.path; + o.textContent = x.path + (x.free >= 0 ? ' — ' + fmtSize(x.free) + ' libres' : ''); + sel.append(o); + } + if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev; +} async function startDownload(){ const url = document.getElementById('m-hf-url').value.trim(); if(!url){ toast('colle un lien .gguf'); return; } + const dir = (document.getElementById('m-hf-dir')||{}).value || ''; const e = dlEls(); e.btn.disabled = true; e.prog.style.display = 'block'; - e.prog.textContent = 'démarrage…'; + e.prog.textContent = 'vérification de la taille et de l’espace disque…'; e.bar.style.display = 'block'; e.bar.className = 'pe-bar indet'; e.bar.firstElementChild.style.width = ''; - const r = await jpost('/api/models/download', {url}); + // Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant + // AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert. + const p = await jpost('/api/models/download/probe', {url, dir}); + if(!p.ok || !p.enough){ + e.prog.innerHTML = 'erreur : '+escHtml(p.error||'espace insuffisant')+''; + e.bar.style.display = 'none'; e.btn.disabled=false; + await populateDlDirs(); + return; + } + e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres'; + const r = await jpost('/api/models/download', {url, dir}); if(!r.ok){ e.prog.innerHTML = 'erreur : '+(r.error||'')+''; e.bar.style.display = 'none'; e.btn.disabled=false; return; @@ -3290,8 +3327,12 @@ function watchDownload(fname){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); - await populateModelPicker(); - document.getElementById('m-model').value = fname; onPickModel(); + await Promise.all([populateModelPicker(), populateDlDirs()]); + // Le modèle a pu atterrir hors du dossier jean : l'option porte alors le + // chemin complet, pas le simple nom de fichier. + const sel = document.getElementById('m-model'); + const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); + if(opt){ sel.value = opt.value; onPickModel(); } return; } const pct = st.total>0 ? st.done*100/st.total : 0; @@ -3977,12 +4018,7 @@ async function loadLlamacpp(){ const fastInstalled = !!pb.bin; const optInstalled = !!s.bin; - const head = document.getElementById('lc-status'); - if(!fastInstalled && !optInstalled){ - head.innerHTML = 'Installez le moteur d\'IA. Le précompilé convient à presque tout le monde.'; - } else { - head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ⚙).'; - } + lcRenderReco(s.reco); lcRenderMode('fast', fastInstalled); lcRenderMode('opt', optInstalled); lcRenderCustomCard(); @@ -4040,6 +4076,23 @@ function lcChipOpen(){ lcChipSync(null); } +// Place la pastille « conseillé » sur la carte que le SERVEUR recommande pour +// cette machine (voir recommendedMode) : le précompilé partout, sauf sur Linux +// avec une carte NVIDIA où il ne donnerait que du Vulkan. La raison est ajoutée +// à la description de la carte, pour expliquer plutôt que d'imposer. +function lcRenderReco(reco){ + const mode = (reco && reco.mode) || 'fast'; + for(const m of ['fast','opt']){ + const badge = document.getElementById('lc-reco-'+m); + if(badge) badge.hidden = (m !== mode); + } + // La raison REMPLACE la description générique de la carte conseillée : elle + // dit déjà ce que fait l'option et pourquoi c'est le bon choix ici. (loadAll + // repasse par là, donc pas d'accumulation possible.) + const desc = document.getElementById('lc-desc-'+mode); + if(desc && reco && reco.why) desc.textContent = reco.why; +} + function lcRenderMode(mode, installed){ const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 627d6f8..43b6646 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -239,24 +239,23 @@
    Moteur -
    Installation
    - -
    Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle.
    +
    -
    llama.cpp précompilé conseillé
    -
    binaire officiel téléchargé tel quel, prêt en ~2 min
    +
    llama.cpp précompilé
    +
    binaire officiel téléchargé tel quel, prêt en ~2 min
    -
    llama.cpp compilé
    -
    compilé ici pour votre machine — plus rapide, plus long à installer
    +
    llama.cpp compilé
    +
    compilé ici pour votre machine — plus rapide, plus long à installer
    @@ -489,6 +488,10 @@
    +
    + Destination + +
    Dossiers de modèleschercher les .gguf ailleurs (disque externe…) @@ -499,7 +502,7 @@
    -
    Les téléchargements, eux, vont toujours dans le dossier AJEAN.
    +
    Ces dossiers servent aussi de destination de téléchargement.
    diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 5f400f8..6b20b8b 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -153,7 +153,7 @@ async function openItem(kind, key){ document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); attachDownload(); // téléchargement encore en cours côté serveur ? - await Promise.all([populateBackend(), populateModelPicker()]); + await Promise.all([populateBackend(), populateModelPicker(), populateDlDirs()]); if(seq !== openSeq) return; } // On lève le voile SANS transition (voir la note dans styles.css), puis on rend @@ -252,7 +252,8 @@ async function populateModelDirs(){ const info = document.createElement('span'); info.className = 'muted'; const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; - info.textContent = '(' + n + (x.home ? ', dossier jean' : '') + ')'; + const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : ''; + info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')'; row.append(p, info); if(!x.home){ const del = document.createElement('button'); @@ -273,13 +274,13 @@ async function addModelDir(){ if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } inp.value = ''; toast('dossier ajouté'); - await Promise.all([populateModelDirs(), populateModelPicker()]); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } async function removeModelDir(p){ const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } toast('dossier retiré'); - await Promise.all([populateModelDirs(), populateModelPicker()]); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } function onPickModel(){ const val = document.getElementById('m-model').value; @@ -772,17 +773,50 @@ function resetDlUI(){ e.prog.style.display='none'; e.bar.style.display='none'; e.cancel.style.display='none'; e.btn.disabled=false; } +// Remplit le sélecteur de destination avec les dossiers de modèles connus. +// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment +// de choisir, et ça évite une ligne de plus dans une modale déjà chargée. +// Même source que « Dossiers de modèles » : ajouter un disque externe là-bas le +// rend aussitôt disponible ici. +let dlDirList = []; +async function populateDlDirs(){ + const sel = document.getElementById('m-hf-dir'); + if(!sel) return; + let d = {}; + try{ d = await jget('/api/models/dirs'); }catch(_){ return; } + dlDirList = d.dirs || []; + const prev = sel.value; + sel.innerHTML = ''; + for(const x of dlDirList){ + const o = document.createElement('option'); + o.value = x.path; + o.textContent = x.path + (x.free >= 0 ? ' — ' + fmtSize(x.free) + ' libres' : ''); + sel.append(o); + } + if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev; +} async function startDownload(){ const url = document.getElementById('m-hf-url').value.trim(); if(!url){ toast('colle un lien .gguf'); return; } + const dir = (document.getElementById('m-hf-dir')||{}).value || ''; const e = dlEls(); e.btn.disabled = true; e.prog.style.display = 'block'; - e.prog.textContent = 'démarrage…'; + e.prog.textContent = 'vérification de la taille et de l’espace disque…'; e.bar.style.display = 'block'; e.bar.className = 'pe-bar indet'; e.bar.firstElementChild.style.width = ''; - const r = await jpost('/api/models/download', {url}); + // Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant + // AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert. + const p = await jpost('/api/models/download/probe', {url, dir}); + if(!p.ok || !p.enough){ + e.prog.innerHTML = 'erreur : '+escHtml(p.error||'espace insuffisant')+''; + e.bar.style.display = 'none'; e.btn.disabled=false; + await populateDlDirs(); + return; + } + e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres'; + const r = await jpost('/api/models/download', {url, dir}); if(!r.ok){ e.prog.innerHTML = 'erreur : '+(r.error||'')+''; e.bar.style.display = 'none'; e.btn.disabled=false; return; @@ -825,8 +859,12 @@ function watchDownload(fname){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); - await populateModelPicker(); - document.getElementById('m-model').value = fname; onPickModel(); + await Promise.all([populateModelPicker(), populateDlDirs()]); + // Le modèle a pu atterrir hors du dossier jean : l'option porte alors le + // chemin complet, pas le simple nom de fichier. + const sel = document.getElementById('m-model'); + const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); + if(opt){ sel.value = opt.value; onPickModel(); } return; } const pct = st.total>0 ? st.done*100/st.total : 0; diff --git a/internal/ajean/ui/src/js/10-llamacpp.js b/internal/ajean/ui/src/js/10-llamacpp.js index 7864516..6a91ffc 100644 --- a/internal/ajean/ui/src/js/10-llamacpp.js +++ b/internal/ajean/ui/src/js/10-llamacpp.js @@ -15,12 +15,7 @@ async function loadLlamacpp(){ const fastInstalled = !!pb.bin; const optInstalled = !!s.bin; - const head = document.getElementById('lc-status'); - if(!fastInstalled && !optInstalled){ - head.innerHTML = 'Installez le moteur d\'IA. Le précompilé convient à presque tout le monde.'; - } else { - head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ⚙).'; - } + lcRenderReco(s.reco); lcRenderMode('fast', fastInstalled); lcRenderMode('opt', optInstalled); lcRenderCustomCard(); @@ -78,6 +73,23 @@ function lcChipOpen(){ lcChipSync(null); } +// Place la pastille « conseillé » sur la carte que le SERVEUR recommande pour +// cette machine (voir recommendedMode) : le précompilé partout, sauf sur Linux +// avec une carte NVIDIA où il ne donnerait que du Vulkan. La raison est ajoutée +// à la description de la carte, pour expliquer plutôt que d'imposer. +function lcRenderReco(reco){ + const mode = (reco && reco.mode) || 'fast'; + for(const m of ['fast','opt']){ + const badge = document.getElementById('lc-reco-'+m); + if(badge) badge.hidden = (m !== mode); + } + // La raison REMPLACE la description générique de la carte conseillée : elle + // dit déjà ce que fait l'option et pourquoi c'est le bon choix ici. (loadAll + // repasse par là, donc pas d'accumulation possible.) + const desc = document.getElementById('lc-desc-'+mode); + if(desc && reco && reco.why) desc.textContent = reco.why; +} + function lcRenderMode(mode, installed){ const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 08d9d6f..3d27069 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -15,7 +15,7 @@ import ( ) func handlePing(w http.ResponseWriter, r *http.Request) { - sendJSON(w, 200, map[string]any{"ok": true, "service": "jean", "version": Version}) + sendJSON(w, 200, map[string]any{"ok": true, "service": "ajean", "version": Version}) } // handleStatus reports service state cross-platform via serviceIsActive @@ -159,14 +159,14 @@ func handleConfigEnv(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, ReadConfig()) } -// handleBackends scans JEAN_HOME/backends// for a llama-server binary, +// handleBackends scans AJEAN_HOME/backends// for a llama-server binary, // trying common build subpaths (build/bin, build-sm120/bin, bin, .). // Returns [{name, path}]. func handleBackends(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, listBackendBins()) } -// listBackendBins scanne JEAN_HOME/backends// pour un binaire llama-server +// listBackendBins scanne AJEAN_HOME/backends// pour un binaire llama-server // (essaie les sous-chemins des différents générateurs CMake). Renvoie // [{name, path}] — un par dossier de backend contenant un binaire. func listBackendBins() []map[string]any { @@ -263,9 +263,9 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) { } // handleModels lists *.gguf files (size in bytes) for the preset editor's model -// picker, dans JEAN_HOME ET dans les dossiers supplémentaires déclarés (disque +// picker, dans AJEAN_HOME ET dans les dossiers supplémentaires déclarés (disque // externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de -// fichier pour JEAN_HOME (compatibilité avec l'existant), le chemin complet +// fichier pour AJEAN_HOME (compatibilité avec l'existant), le chemin complet // pour un dossier externe. func handleModels(w http.ResponseWriter, r *http.Request) { out := []map[string]any{} @@ -337,9 +337,9 @@ func handlePresets(w http.ResponseWriter, r *http.Request) { func handlePreset(w http.ResponseWriter, r *http.Request) { 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{"id": "", "name": "", "content": string(b)}) + // nouveau preset → pré-rempli avec la configuration active, pour ajuster + // plutôt que partir d'une page blanche + sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(ReadConfig())}) return } content, err := ReadPreset(id) @@ -775,7 +775,7 @@ func handleMCPTest(w http.ResponseWriter, r *http.Request) { func handleMem(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.URL.Query().Get("name")) if name == "" { - sendJSON(w, 200, map[string]any{"name": "", "content": "# nouvelle page\n\nNote ici ce que jean doit retenir entre les sessions.\n"}) + sendJSON(w, 200, map[string]any{"name": "", "content": "# nouvelle page\n\nNote ici ce que ajean doit retenir entre les sessions.\n"}) return } c := MemContent(name) @@ -852,7 +852,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) { // svcHandler returns an HTTP handler that triggers a start/stop/restart through // the cross-platform serviceAction (systemd sous Linux, supervision PID-file // sous Windows — voir sys_service_*.go). C'est ce qui permet à un client distant -// de relancer Jean. +// de relancer AJEAN. func svcHandler(action string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { err := serviceAction(action) diff --git a/internal/ajean/web_auth.go b/internal/ajean/web_auth.go index a7f6e3f..4ee3874 100644 --- a/internal/ajean/web_auth.go +++ b/internal/ajean/web_auth.go @@ -6,8 +6,6 @@ import ( "encoding/hex" "fmt" "net/http" - "os" - "path/filepath" "strings" ) @@ -18,21 +16,11 @@ import ( // 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. +// et inversement. Elle est relue à 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(AjeanHome(), ".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)) -} +// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie. +func readWebKey() string { return getStr(bkState, "web_key") } // requireWebAuth wraps an HTTP handler, rejecting requests that don't present // the configured Bearer token. When no key is configured the handler is left @@ -45,7 +33,7 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc { return } if !checkBearer(r, key) { - w.Header().Set("WWW-Authenticate", `Bearer realm="jean"`) + w.Header().Set("WWW-Authenticate", `Bearer realm="ajean"`) sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"}) return } @@ -68,7 +56,7 @@ func checkBearer(r *http.Request, key string) bool { return false } -// cmdSetWebKey sets (or clears) the control-API key in $JEAN_HOME/.web_key. +// cmdSetWebKey sets (or clears) the control-API key in $AJEAN_HOME/.web_key. // // ajean set-web-key définit la clé // ajean set-web-key génère une clé aléatoire @@ -84,27 +72,21 @@ func cmdSetWebKey(args []string) error { if _, err := rand.Read(buf); err != nil { return err } - key = "jean-web-" + hex.EncodeToString(buf) + key = "ajean-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 err := putStr(bkState, "web_key", key); err != nil { + return err + } 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(AjeanHome(), 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("%s clé de pilotage enregistrée\n", green("[ok]")) fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key)) fmt.Printf(" (relance 'ajean web' si le serveur web tourne déjà — non requis, lu à chaud)\n") return nil diff --git a/internal/ajean/web_link_api.go b/internal/ajean/web_link_api.go index 0c6f420..fe0f7ca 100644 --- a/internal/ajean/web_link_api.go +++ b/internal/ajean/web_link_api.go @@ -1,9 +1,9 @@ package ajean // web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI -// web de Jean, pour éviter le terminal. Le panneau « Accès distant » ouvre une +// web de AJEAN, pour éviter le terminal. Le panneau « Accès distant » ouvre une // popup app.ajean.link/connect.html qui gère compte + paiement puis renvoie une -// clé de liaison (jl_…) ; l'UI la POSTe ici, et Jean fait le `ajean link` tout +// clé de liaison (jl_…) ; l'UI la POSTe ici, et AJEAN fait le `ajean link` tout // seul (écrit le token + (re)démarre le service). // // Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais, diff --git a/internal/ajean/web_llamacpp.go b/internal/ajean/web_llamacpp.go index f615287..3709404 100644 --- a/internal/ajean/web_llamacpp.go +++ b/internal/ajean/web_llamacpp.go @@ -181,6 +181,7 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) { "in_use": pbBin != "" && prebuiltOwns(cfgBin), } out["backends_dir"] = filepath.Join(AjeanHome(), "backends") + out["reco"] = recommendedMode(plan.backend) out["job"] = lcJobSnapshot(0, false) sendJSON(w, 200, out) } @@ -344,9 +345,9 @@ func lcRunPrebuilt() { bin, err := prebuiltInstall(lcAppend, lcPhase) if err == nil { if serr := SetConfigKey("BIN", bin); serr != nil { - err = fmt.Errorf("binaires installés mais échec écriture BIN dans config.env : %w", serr) + err = fmt.Errorf("binaires installés mais échec d'écriture de BIN : %w", serr) } else { - lcAppend("BIN mis à jour dans " + confPath()) + lcAppend("BIN mis à jour") } } if svcWasUp { @@ -583,10 +584,10 @@ func lcBuildAndSwitch(repo string, clean bool) bool { } lcAppend("binaire compilé : " + bin) if err := SetConfigKey("BIN", bin); err != nil { - lcFail(fmt.Errorf("build ok mais échec écriture BIN dans config.env : %w", err)) + lcFail(fmt.Errorf("build ok mais échec d'écriture de BIN : %w", err)) return false } - lcAppend("BIN mis à jour dans " + confPath()) + lcAppend("BIN mis à jour") return true } diff --git a/internal/ajean/web_prefs.go b/internal/ajean/web_prefs.go index 9c10988..ec89a1a 100644 --- a/internal/ajean/web_prefs.go +++ b/internal/ajean/web_prefs.go @@ -3,28 +3,20 @@ package ajean import ( "encoding/json" "net/http" - "os" - "path/filepath" "strings" "sync" ) // Préférences d'apparence de l'UI web (thème, mode d'affichage). Stockées côté -// serveur — sur la machine jean — pour être partagées entre tous les appareils +// serveur — sur la machine AJEAN — pour être partagées entre tous les appareils // qui pilotent la même instance (téléphone, laptop, accès distant ajean.link). // Avant ça, elles ne vivaient qu'en localStorage, donc par navigateur/appareil. -// webPrefsPath est le fichier JSON des préférences d'apparence. -func webPrefsPath() string { return filepath.Join(AjeanHome(), "webprefs.json") } - var webPrefsMu sync.Mutex // webPrefsAllowed liste les clés de préférence acceptées et, pour chacune, les // valeurs valides. On ne stocke que ce qui est connu (pas de champ libre). var webPrefsAllowed = map[string]map[string]bool{ - // Un seul thème, deux variantes. Les anciennes valeurs (ajean, gpt-dark…) - // ne sont plus acceptées : le client les normalise en clair/sombre au - // chargement puis réenregistre la valeur normalisée. "theme": {"light": true, "dark": true}, "hide_reasoning": {"0": true, "1": true}, "hide_tools": {"0": true, "1": true}, @@ -33,30 +25,20 @@ var webPrefsAllowed = map[string]map[string]bool{ "hide_stats": {"0": true, "1": true}, } -// loadWebPrefs lit les préférences enregistrées (map vide si absent/illisible). +// loadWebPrefs lit les préférences enregistrées (map vide si aucune). func loadWebPrefs() map[string]string { out := map[string]string{} - b, err := os.ReadFile(webPrefsPath()) - if err != nil { - return out + for k, v := range allKV(bkPrefs) { + out[k] = v } - _ = json.Unmarshal(b, &out) return out } -// saveWebPrefs fusionne les valeurs valides de `in` dans le fichier et l'écrit. +// saveWebPrefs fusionne les valeurs valides de `in` puis enregistre le tout. func saveWebPrefs(in map[string]string) (map[string]string, error) { webPrefsMu.Lock() defer webPrefsMu.Unlock() prefs := loadWebPrefs() - // Purge les clés/valeurs devenues invalides (anciens thèmes, réglage de - // police retiré) : sans ça, un fichier écrit par une version précédente - // renverrait indéfiniment des valeurs que le client ne sait plus traiter. - for k, v := range prefs { - if allowed, ok := webPrefsAllowed[k]; !ok || !allowed[v] { - delete(prefs, k) - } - } for k, v := range in { allowed, ok := webPrefsAllowed[k] if !ok { @@ -68,14 +50,7 @@ func saveWebPrefs(in map[string]string) (map[string]string, error) { } prefs[k] = v } - b, err := json.MarshalIndent(prefs, "", " ") - if err != nil { - return prefs, err - } - if err := os.WriteFile(webPrefsPath(), b, 0o600); err != nil { - return prefs, err - } - return prefs, nil + return prefs, replaceKV(bkPrefs, prefs) } // handleWebPrefs expose les préférences d'apparence partagées entre appareils. diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index 3f5d5c5..245f8e5 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -100,6 +100,7 @@ func newWebMux() *http.ServeMux { api("/api/models/delete", handleModelDelete) api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…) api("/api/models/download", handleModelDownload) + api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer api("/api/models/download/status", handleModelDownloadStatus) api("/api/models/download/cancel", handleModelDownloadCancel) api("/api/backends", handleBackends) @@ -139,7 +140,7 @@ func newWebMux() *http.ServeMux { api("/api/memory", handleMemoryMode) api("/api/prefs", handleWebPrefs) api("/api/sysprompt", handleSysPrompt) - // Alias rétro-compat : l'ancien portail ajean.link (dépôt jean-relay) pilote + // Alias rétro-compat : l'ancien portail ajean.link (dépôt ajean-relay) pilote // encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E. // On les mappe sur le mode agent unifié le temps que le portail soit mis à jour. api("/api/tools", handleAgent) @@ -261,7 +262,7 @@ func pidOnPort(port int) (int, string) { // processName returns a short command name for a PID, or "?" if unknown. func processName(pid int) string { if runtime.GOOS == "windows" { - // tasklist CSV : "jean.exe","1234","Console","1","12 345 K" + // tasklist CSV : "ajean.exe","1234","Console","1","12 345 K" out, err := hideCmd(exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH")).Output() if err == nil { if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 { diff --git a/internal/ajean/web_sysprompt.go b/internal/ajean/web_sysprompt.go index 5722740..06c461b 100644 --- a/internal/ajean/web_sysprompt.go +++ b/internal/ajean/web_sysprompt.go @@ -1,5 +1,5 @@ // web_sysprompt.go — prompt système personnalisé de l'utilisateur, persisté -// CÔTÉ SERVEUR ($JEAN_HOME/sysprompt.txt) et partagé entre appareils, comme la +// CÔTÉ SERVEUR ($AJEAN_HOME/sysprompt.txt) et partagé entre appareils, comme la // conversation elle-même. Historique : avant la conversation serveur (v0.4.x), // l'UI envoyait son prompt système dans chaque requête /api/chat ; depuis, // /api/chat/send ne porte que le message → le champ de l'UI n'avait plus aucun From 6de356f6492e5b46fb61e2ef465b115c57d6431e Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 16:57:52 +0200 Subject: [PATCH 229/279] Arborescence unifiee et installateurs simplifies - provisionDataDir unique pour les trois plateformes (six dossiers, config par defaut) - plus de modele config.env duplique dans chaque installateur - plus d'alias jean pose a l'installation --- internal/ajean/backend_llamacpp.go | 4 +- internal/ajean/backend_prebuilt.go | 2 +- internal/ajean/chat_workspace.go | 5 +- internal/ajean/sys_datadir.go | 51 +++++++++++++++ internal/ajean/sys_firstrun_other.go | 6 +- internal/ajean/sys_firstrun_windows.go | 20 ------ internal/ajean/sys_install_darwin.go | 87 ++++---------------------- internal/ajean/sys_install_linux.go | 75 +++------------------- internal/ajean/sys_install_windows.go | 83 +++++------------------- 9 files changed, 93 insertions(+), 240 deletions(-) create mode 100644 internal/ajean/sys_datadir.go diff --git a/internal/ajean/backend_llamacpp.go b/internal/ajean/backend_llamacpp.go index c84f664..1eb70fd 100644 --- a/internal/ajean/backend_llamacpp.go +++ b/internal/ajean/backend_llamacpp.go @@ -82,7 +82,7 @@ func llamacppRepoDir() string { } func defaultRepoDir() string { - return filepath.Join(AjeanHome(), "backends", "llama.cpp") + return filepath.Join(backendsDir(), "llama.cpp") } // findRepoRoot walks up from a binary path (…/build/bin/llama-server) looking @@ -510,7 +510,7 @@ func sanitizeBackendName(name string) string { // dossier backends (../, chemin absolu…). func backendDir(name string) (string, error) { name = sanitizeBackendName(name) - root, err := filepath.Abs(filepath.Join(AjeanHome(), "backends")) + root, err := filepath.Abs(backendsDir()) if err != nil { return "", err } diff --git a/internal/ajean/backend_prebuilt.go b/internal/ajean/backend_prebuilt.go index 8330b8f..3a4e415 100644 --- a/internal/ajean/backend_prebuilt.go +++ b/internal/ajean/backend_prebuilt.go @@ -38,7 +38,7 @@ type ghAsset struct { } func prebuiltDir() string { - return filepath.Join(AjeanHome(), "backends", "llama.cpp-prebuilt") + return filepath.Join(backendsDir(), "llama.cpp-prebuilt") } // prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer" diff --git a/internal/ajean/chat_workspace.go b/internal/ajean/chat_workspace.go index 687b8fb..20016fb 100644 --- a/internal/ajean/chat_workspace.go +++ b/internal/ajean/chat_workspace.go @@ -60,11 +60,8 @@ func workspaceCandidates() []string { if v := strings.TrimSpace(os.Getenv(workspaceEnv)); v != "" { c = append(c, v) } - c = append(c, filepath.Join(AjeanHome(), "workspace")) + c = append(c, workspaceDir()) if home, err := os.UserHomeDir(); err == nil && home != "" { - // Le nouveau nom d'abord, l'ancien ensuite : un dossier ~/ajean/workspace - // déjà rempli par l'agent reste utilisable au lieu de repartir de zéro. - c = append(c, filepath.Join(home, "ajean", "workspace")) c = append(c, filepath.Join(home, "ajean", "workspace")) } c = append(c, filepath.Join(os.TempDir(), "ajean-workspace")) diff --git a/internal/ajean/sys_datadir.go b/internal/ajean/sys_datadir.go new file mode 100644 index 0000000..0d3f0ff --- /dev/null +++ b/internal/ajean/sys_datadir.go @@ -0,0 +1,51 @@ +package ajean + +import ( + "os" + "runtime" +) + +// sys_datadir.go — création du dossier de données, identique sur les trois +// plateformes. Avant, chaque installateur (Linux, macOS, Windows, plus le +// premier lancement de l'app) créait ses dossiers et écrivait son propre +// modèle de config.env : quatre copies qui divergeaient à la première +// modification. Il n'y en a plus qu'une. + +// dataDirs est l'arborescence complète de $AJEAN_HOME. Rien d'autre n'y est +// créé : tout le reste vit dans ajean.db. +func dataDirs() []string { + return []string{AjeanHome(), backendsDir(), binDir(), presetsDir(), memoryDir(), modelsDir(), workspaceDir()} +} + +// defaultConfig est la configuration de départ d'une installation neuve. Les +// valeurs sont volontairement incomplètes (BIN et MODEL sont vides) : c'est +// l'écran d'accueil, ou « ajean llamacpp install », qui les renseigne. +func defaultConfig() map[string]string { + host := "0.0.0.0" + if runtime.GOOS == "windows" { + host = "127.0.0.1" + } + return map[string]string{ + "PORT": "8080", + "HOST": host, + "CTX": "32768", + "BATCH": "2048", + "UBATCH": "512", + "NGL": "999", + } +} + +// provisionDataDir crée l'arborescence et, sur une installation neuve, pose la +// configuration de départ. Idempotente : une configuration existante n'est +// jamais écrasée. +func provisionDataDir() error { + for _, d := range dataDirs() { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + if len(ReadConfig()) > 0 { + return nil + } + return WriteConfig(defaultConfig()) +} diff --git a/internal/ajean/sys_firstrun_other.go b/internal/ajean/sys_firstrun_other.go index b99bf1a..51df785 100644 --- a/internal/ajean/sys_firstrun_other.go +++ b/internal/ajean/sys_firstrun_other.go @@ -2,14 +2,12 @@ package ajean -import "os" - // appFirstRun : hors Windows, `ajean app` est lancé depuis un terminal par // quelqu'un qui a déjà suivi l'installation — pas de double-clic à désambiguïser, // donc pas de boîte de dialogue. On se contente de provisionner les données au -// premier lancement, comme avant. Ne relance jamais (renvoie toujours false). +// premier lancement. Ne relance jamais (renvoie toujours false). func appFirstRun() bool { - if _, err := os.Stat(confPath()); err != nil { + if len(ReadConfig()) == 0 { _ = cmdInstall(nil) } return false diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index fe54e78..d38e353 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -372,26 +372,6 @@ type vsFixedFileInfo struct { FileDateLS uint32 } -// provisionDataDir crée le dossier de données et un config.env de départ. -// Extrait de cmdInstall pour être réutilisable au premier lancement, sans -// embarquer l'installation du binaire ni les messages de console. -func provisionDataDir() error { - ajeanHome := AjeanHome() - // Plus de dossier SKILLS : les skills ont ete fondus dans la memoire. - // On ne le CREE plus, mais on continue de le LIRE une fois au demarrage - // pour reprendre ceux d'une ancienne installation (migrateSkillsToMemory). - for _, d := range []string{ajeanHome, filepath.Join(ajeanHome, "configs")} { - if err := os.MkdirAll(d, 0o755); err != nil { - return err - } - } - conf := filepath.Join(ajeanHome, "config.env") - if _, err := os.Stat(conf); os.IsNotExist(err) { - return os.WriteFile(conf, []byte(configTemplate), 0o644) - } - return nil -} - // ensureShortcuts garantit qu'un raccourci « AJEAN » existe dans le menu // Démarrer et sur le Bureau, et qu'il pointe sur le bon exécutable. Appelée à // CHAQUE lancement du fichier téléchargé, pas seulement à l'installation : diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 3931718..96953a0 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -17,36 +17,11 @@ import ( // sur « systemctl: command not found » / « /etc/default/ajean », cf. issue #4). // Implémentation prudente ; à valider sur une machine Apple. -const configTemplate = `# Configuration AJEAN — édite-moi puis: ajean restart -# Le service launchd lit ce fichier et lance ton binaire llama.cpp. - -# Chemins -BIN="/usr/local/bin/llama-server" -MODEL="/Users/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="" -` // launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName, -// WorkingDirectory, AJEAN_HOME, AJEAN_HOME, StandardOutPath, StandardErrorPath. +// WorkingDirectory, AJEAN_HOME, StandardOutPath, StandardErrorPath. // KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au // boot une fois chargé avec `-w`. -// -// Le chemin du binaire est injecté et non codé en dur : il a changé avec le -// renommage, et un plist figé sur l'ancien chemin donnerait un daemon qui refuse -// de démarrer. Les deux variables d'environnement sont posées car le code lit -// encore AJEAN_HOME en héritage. const launchdPlistTemplate = ` @@ -60,7 +35,7 @@ const launchdPlistTemplate = ` UserName%s WorkingDirectory%s EnvironmentVariables - AJEAN_HOME%sAJEAN_HOME%s + AJEAN_HOME%s RunAtLoad KeepAlive SuccessfulExit @@ -87,14 +62,6 @@ func cmdInstall(args []string) error { if err != nil { return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) } - // Migration de l'agencement ajean -> ajean AVANT de résoudre les chemins : - // `install` est root et délibéré, c'est le bon moment. Ne fait rien si la - // machine est déjà en ajean, ou si l'utilisateur a imposé un chemin. - if err := migrateLayout(defaultLayoutPlan()); err != nil { - return err - } - resetResolvedHome() // le dossier a pu changer à l'instant - ajeanHome := defaultAjeanHome() if v := os.Getenv("AJEAN_HOME"); v != "" { ajeanHome = v @@ -105,27 +72,12 @@ func cmdInstall(args []string) error { fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s (launchd)\n", svc) - // 1. Répertoires - // Plus de dossier SKILLS : les skills ont ete fondus dans la memoire. - // On ne le CREE plus, mais on continue de le LIRE une fois au demarrage - // pour reprendre ceux d'une ancienne installation (migrateSkillsToMemory). - for _, d := range []string{ajeanHome, filepath.Join(ajeanHome, "configs")} { - if err := os.MkdirAll(d, 0o755); err != nil { - return err - } + // 1. Arborescence + configuration de départ + if err := provisionDataDir(); err != nil { + return err } - // 2. config.env si absent - conf := filepath.Join(ajeanHome, "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 idempotent vers /usr/local/bin/ajean — même garde-fou que Linux + // 2. Symlink idempotent vers /usr/local/bin/ajean — même garde-fou que Linux // (issue #5 : ne pas se lier sur soi-même si on tourne déjà depuis la cible). self, err := os.Executable() if err != nil { @@ -154,38 +106,24 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) } - // 3 bis. Alias hérité : `ajean` doit rester tapable (voir sys_paths.go). - if alias := legacyExePath(); alias != target { - if rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self { - _ = os.Remove(alias) - if err := os.Symlink(target, alias); err != nil { - fmt.Printf(" %s alias %s non créé (%v) — « ajean » reste disponible\n", yellow("[info]"), alias, err) - } else { - fmt.Printf(" %s %s -> %s (alias hérité)\n", green("✓"), alias, target) - } - } - } - - // 4. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME. - // Les deux clés sont écrites : des scripts d'utilisateurs sourcent ce fichier - // et lisent encore $AJEAN_HOME. + // 3. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME. _ = os.MkdirAll("/etc/default", 0o755) - defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome) + defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome) if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { return err } fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - // 5. LaunchDaemon plist (le log va sous AJEAN_HOME, accessible au user cible) + // 4. LaunchDaemon plist (le log va sous AJEAN_HOME, accessible au user cible) logPath := filepath.Join(ajeanHome, svc+".log") - plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, ajeanHome, logPath, logPath) + plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, logPath, logPath) plistPath := launchdPlistPath(svc) if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { return err } fmt.Printf(" %s %s\n", green("✓"), plistPath) - // 6. chown AJEAN_HOME au user cible + // 5. chown AJEAN_HOME au user cible chown(ajeanHome, u) fmt.Println() @@ -208,13 +146,12 @@ func cmdUninstall(args []string) error { launchdPlistPath(svc), "/etc/default/ajean", installedExePath(), - legacyExePath(), } { if err := os.Remove(p); err == nil { fmt.Printf(" %s %s\n", green("✓"), p) } } - // On ne supprime jamais AJEAN_HOME automatiquement (modèles, configs). + // On ne supprime jamais AJEAN_HOME automatiquement (modèles, presets). fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)")) fmt.Println(green("[ok]") + " désinstallé") return nil diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index b782e5a..022f2f5 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -11,26 +11,6 @@ import ( "strings" ) -const configTemplate = `# Configuration AJEAN — édite-moi puis: ajean 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 ajean). %s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s @@ -76,14 +56,6 @@ func cmdInstall(args []string) error { if err != nil { return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) } - // Migration de l'agencement ajean -> ajean AVANT de résoudre les chemins : - // `install` est root et délibéré, c'est le bon moment. Ne fait rien si la - // machine est déjà en ajean, ou si l'utilisateur a imposé un chemin. - if err := migrateLayout(defaultLayoutPlan()); err != nil { - return err - } - resetResolvedHome() // le dossier a pu changer à l'instant - ajeanHome := defaultAjeanHome() if v := os.Getenv("AJEAN_HOME"); v != "" { ajeanHome = v @@ -94,27 +66,13 @@ func cmdInstall(args []string) error { fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s\n", svc) - // 1. Create directories - // Plus de dossier SKILLS : les skills ont ete fondus dans la memoire. - // On ne le CREE plus, mais on continue de le LIRE une fois au demarrage - // pour reprendre ceux d'une ancienne installation (migrateSkillsToMemory). - for _, d := range []string{ajeanHome, filepath.Join(ajeanHome, "configs")} { - if err := os.MkdirAll(d, 0o755); err != nil { - return err - } + // 1. Arborescence + configuration de départ + if err := provisionDataDir(); err != nil { + return err } + fmt.Printf(" %s arborescence prête\n", green("✓")) - // 2. Drop a config.env if none exists - conf := filepath.Join(ajeanHome, "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/ajean + // 2. Symlink current binary to /usr/local/bin/ajean self, err := os.Executable() if err != nil { return err @@ -145,27 +103,8 @@ func cmdInstall(args []string) error { fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) } - // 3 bis. Alias hérité : `ajean` doit rester tapable. Des utilisateurs ont des - // alias shell, des cron, des scripts de déploiement et des tutos qui appellent - // « ajean » — on ne contrôle rien de tout ça, et un binaire renommé qui casse - // la commande d'hier est une régression pour eux. Le lien pointe sur le - // nouveau nom, donc il suit les mises à jour sans entretien. - if alias := legacyExePath(); alias != target { - if rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self { - _ = os.Remove(alias) - if err := os.Symlink(target, alias); err != nil { - fmt.Printf(" %s alias %s non créé (%v) — « ajean » reste disponible\n", yellow("[info]"), alias, err) - } else { - fmt.Printf(" %s %s -> %s (alias hérité)\n", green("✓"), alias, target) - } - } - } - - // 4. Drop /etc/default/ajean so root invocations resolve AJEAN_HOME correctly. - // On écrit les DEUX clés : des scripts d'utilisateurs sourcent ce fichier et - // lisent $AJEAN_HOME. L'ancien /etc/default/ajean est laissé en place — le - // supprimer casserait ces mêmes scripts, et AjeanHome() sait déjà le lire. - defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome) + // 3. /etc/default/ajean, pour que les invocations root résolvent AJEAN_HOME. + defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome) if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { return err } diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index 93a186a..a216c0a 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -16,26 +16,6 @@ import ( // service itself is managed by the PID-file supervisor in sys_service_windows.go // (ajean start / stop / status), which needs no admin rights. -const configTemplate = `# Configuration AJEAN — édite-moi puis: ajean restart -# ajean serve lit ce fichier et lance ton binaire llama.cpp. - -# Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent) -BIN="C:/llama.cpp/build/bin/Release/llama-server.exe" -MODEL="C:/models/your-model.gguf" - -# Serveur -PORT="8080" -HOST="127.0.0.1" - -# Inference -CTX="32768" -BATCH="2048" -UBATCH="512" -NGL="999" - -# Args supplémentaires passés à llama-server -EXTRA_ARGS="" -` func cmdInstall(args []string) error { ajeanHome := AjeanHome() @@ -44,41 +24,31 @@ func cmdInstall(args []string) error { fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s\n", serviceName()) - // 1-2. Dossiers + config.env de départ (partagé avec le premier lancement de - // l'app, voir provisionDataDir dans sys_firstrun_windows.go). - conf := filepath.Join(ajeanHome, "config.env") - _, statErr := os.Stat(conf) - alreadyThere := statErr == nil + // 1. Arborescence + configuration de départ (partagé avec le premier + // lancement de l'app, voir sys_datadir.go). if err := provisionDataDir(); err != nil { return err } - if alreadyThere { - fmt.Printf(" %s config.env déjà présent — inchangé\n", dim("•")) - } else { - fmt.Printf(" %s écrit %s\n", green("✓"), conf) - } + fmt.Printf(" %s arborescence prête\n", green("✓")) - // 3. Install the binary into AJEAN_HOME\bin and put that dir on the user PATH, - // so `ajean` is callable from any shell (this is the Windows analogue of the - // /usr/local/bin symlink the Unix installer creates). - binDir := filepath.Join(ajeanHome, "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - return err - } + // 2. Le binaire va dans AJEAN_HOME\bin, ajouté au PATH utilisateur, pour que + // `ajean` soit appelable depuis n'importe quel shell (pendant Windows du + // lien /usr/local/bin créé par l'installateur Unix). + dir := binDir() onPath := false - if dst, err := installSelf(binDir); err != nil { + if dst, err := installSelf(dir); err != nil { fmt.Printf(" %s copie du binaire impossible (%v) — ajoute-le au PATH à la main\n", dim("•"), err) } else { fmt.Printf(" %s binaire installé %s\n", green("✓"), dst) - added, err := addToUserPath(binDir) + added, err := addToUserPath(dir) switch { case err != nil: fmt.Printf(" %s mise à jour du PATH impossible (%v)\n", dim("•"), err) case added: - fmt.Printf(" %s %s ajouté au PATH utilisateur\n", green("✓"), binDir) + fmt.Printf(" %s %s ajouté au PATH utilisateur\n", green("✓"), dir) onPath = true default: - fmt.Printf(" %s %s déjà dans le PATH\n", dim("•"), binDir) + fmt.Printf(" %s %s déjà dans le PATH\n", dim("•"), dir) onPath = true } } @@ -97,10 +67,9 @@ func cmdInstall(args []string) error { return nil } -// installSelf copies the currently running executable into binDir as ajean.exe -// and returns the destination path. If the running exe already lives there -// (re-install), it's a no-op. Une copie « ajean.exe » est posée à côté pour que -// l'ancienne commande reste tapable (voir installLegacyAlias). +// installSelf copie l'exécutable en cours dans binDir sous le nom ajean.exe et +// renvoie le chemin de destination. Sans effet si l'exe tourne déjà depuis là +// (ré-installation). // // binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge, // le premier lancement passait par appFirstRun, qui ne créait que le dossier de @@ -115,7 +84,6 @@ func installSelf(binDir string) (string, error) { src, _ = filepath.EvalSymlinks(src) dst := filepath.Join(binDir, "ajean.exe") if strings.EqualFold(src, dst) { - installLegacyAlias(binDir, src) return dst, nil } if err := os.MkdirAll(binDir, 0o755); err != nil { @@ -126,26 +94,9 @@ func installSelf(binDir string) (string, error) { if err := replaceExe(src, dst); err != nil { return "", err } - installLegacyAlias(binDir, dst) return dst, nil } -// installLegacyAlias pose une copie « ajean.exe » à côté de « ajean.exe ». -// -// Une copie et pas un lien : créer un lien symbolique sous Windows demande des -// droits particuliers qu'on n'a pas toujours, et un binaire de quelques dizaines -// de Mo dupliqué est un prix dérisoire pour ne casser ni les raccourcis du menu -// Démarrer, ni les tâches planifiées, ni les scripts que les utilisateurs ont -// écrits eux-mêmes. Best-effort : un échec ne compromet pas l'installation, -// « ajean » reste disponible. -func installLegacyAlias(binDir, src string) { - alias := filepath.Join(binDir, "ajean.exe") - if strings.EqualFold(src, alias) { - return - } - _ = replaceExe(src, alias) -} - // replaceExe copie src vers dst, y compris quand dst est un exécutable EN COURS. // // Windows refuse d'écraser un .exe en cours d'exécution, mais accepte de le @@ -253,9 +204,9 @@ func cmdUninstall(args []string) error { _ = svcStop(false) // Pull AJEAN_HOME\bin off the user PATH (best-effort). - binDir := filepath.Join(AjeanHome(), "bin") - if removed, err := removeFromUserPath(binDir); err == nil && removed { - fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir) + dir := binDir() + if removed, err := removeFromUserPath(dir); err == nil && removed { + fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), dir) } // Raccourcis posés à l'installation (menu Démarrer + Bureau). From 62ad4155a29927837eb49063307d58890f1afedf Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:03:27 +0200 Subject: [PATCH 230/279] CLI elaguee et tests remis sur la base - suppression des alias de commandes herites (skills, machine, tools, mem, upgrade...) - aide reecrite et regroupee - helper de test testHome + fermeture de la base (verrou bbolt sous Windows) - tests des dossiers de modeles alignes sur models/ --- internal/ajean/backend_buildfilter_test.go | 2 +- internal/ajean/backend_llamacpp.go | 4 +- internal/ajean/backend_model_dirs_test.go | 19 +++--- internal/ajean/backend_models_dest_test.go | 11 ++- internal/ajean/backend_models_dl_test.go | 7 +- internal/ajean/backend_prebuilt_test.go | 3 +- internal/ajean/backend_presets_test.go | 20 ++++-- internal/ajean/chat_agent.go | 2 +- internal/ajean/chat_memory.go | 10 +-- internal/ajean/mcp_client_test.go | 4 +- internal/ajean/relay_link.go | 2 +- internal/ajean/run.go | 74 ++++++++------------ internal/ajean/store.go | 78 ++++++++++++++-------- internal/ajean/store_test.go | 67 +++++++++++++++++++ internal/ajean/sys_install_darwin.go | 1 - internal/ajean/sys_install_linux.go | 3 +- internal/ajean/sys_install_windows.go | 1 - internal/ajean/sys_update_asset_test.go | 47 +++---------- 18 files changed, 202 insertions(+), 153 deletions(-) create mode 100644 internal/ajean/store_test.go diff --git a/internal/ajean/backend_buildfilter_test.go b/internal/ajean/backend_buildfilter_test.go index e2a2739..491f147 100644 --- a/internal/ajean/backend_buildfilter_test.go +++ b/internal/ajean/backend_buildfilter_test.go @@ -12,7 +12,7 @@ func TestCompiledFile(t *testing.T) { `[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu", // La ligne de commande nvcc géante ne doit PAS être prise pour un fichier. ` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "", - `Building Custom Rule C:/ProgramData/ajean/...`: "", + `Building Custom Rule C:/ProgramData/ajean/...`: "", `-- UI: running npm install`: "", } for in, want := range cases { diff --git a/internal/ajean/backend_llamacpp.go b/internal/ajean/backend_llamacpp.go index 1eb70fd..25834fc 100644 --- a/internal/ajean/backend_llamacpp.go +++ b/internal/ajean/backend_llamacpp.go @@ -39,9 +39,9 @@ func cmdLlamacpp(args []string) error { switch sub { case "install": return llamacppInstall(args) - case "update", "upgrade": + case "update": return llamacppUpdate(args) - case "status", "info", "": + case "status", "": return llamacppStatus(args) case "prebuilt": // Binaires officiels précompilés (aucune compilation) — voir backend_prebuilt.go. diff --git a/internal/ajean/backend_model_dirs_test.go b/internal/ajean/backend_model_dirs_test.go index 91cbb3a..fbad3f1 100644 --- a/internal/ajean/backend_model_dirs_test.go +++ b/internal/ajean/backend_model_dirs_test.go @@ -6,12 +6,11 @@ import ( "testing" ) -// Un modèle posé hors de AJEAN_HOME (disque externe) doit être utilisable dès +// Un modèle posé hors de models/ (disque externe) doit être utilisable dès // que son dossier est déclaré — et refusé tant qu'il ne l'est pas. func TestResolveModelPathExtraDir(t *testing.T) { - home := t.TempDir() + testHome(t) ext := t.TempDir() - t.Setenv("AJEAN_HOME", home) t.Setenv("AJEAN_MODEL_DIRS", "") extModel := filepath.Join(ext, "gros.gguf") @@ -39,20 +38,22 @@ func TestResolveModelPathExtraDir(t *testing.T) { } } -// AJEAN_HOME garde la priorité et les non-.gguf restent refusés. +// models/ garde la priorité et les non-.gguf restent refusés. func TestResolveModelPathHomeFirst(t *testing.T) { - home := t.TempDir() + testHome(t) ext := t.TempDir() - t.Setenv("AJEAN_HOME", home) t.Setenv("AJEAN_MODEL_DIRS", ext) - for _, d := range []string{home, ext} { + if err := os.MkdirAll(modelsDir(), 0o755); err != nil { + t.Fatal(err) + } + for _, d := range []string{modelsDir(), ext} { if err := os.WriteFile(filepath.Join(d, "m.gguf"), []byte("x"), 0o644); err != nil { t.Fatal(err) } } got, err := resolveModelPath("m.gguf") - if err != nil || got != filepath.Join(home, "m.gguf") { - t.Fatalf("got %q (%v), attendu le fichier de AJEAN_HOME", got, err) + if err != nil || got != filepath.Join(modelsDir(), "m.gguf") { + t.Fatalf("got %q (%v), attendu le fichier de models/", got, err) } if _, err := resolveModelPath("/etc/passwd"); err == nil { t.Fatal("un fichier non-.gguf devrait être refusé") diff --git a/internal/ajean/backend_models_dest_test.go b/internal/ajean/backend_models_dest_test.go index 5493a98..ff45b90 100644 --- a/internal/ajean/backend_models_dest_test.go +++ b/internal/ajean/backend_models_dest_test.go @@ -12,17 +12,16 @@ import ( ) func TestDownloadDestPathDir(t *testing.T) { - home := t.TempDir() + testHome(t) extra := t.TempDir() - t.Setenv("AJEAN_HOME", home) t.Setenv("AJEAN_MODEL_DIRS", extra) - // Dossier vide → AJEAN_HOME. + // Dossier vide → models/. got, err := downloadDestPath("m.gguf", "") if err != nil { t.Fatal(err) } - if want := filepath.Join(home, "m.gguf"); got != want { + if want := filepath.Join(modelsDir(), "m.gguf"); got != want { t.Fatalf("défaut = %s, attendu %s", got, want) } @@ -76,8 +75,8 @@ func TestDiskFreeAndCheck(t *testing.T) { // Les .part orphelins doivent aussi être nettoyés dans les dossiers ajoutés. func TestCleanStalePartFilesExtraDirs(t *testing.T) { - home, extra := t.TempDir(), t.TempDir() - t.Setenv("AJEAN_HOME", home) + testHome(t) + extra := t.TempDir() t.Setenv("AJEAN_MODEL_DIRS", extra) stale := filepath.Join(extra, "coupe.gguf.part") if err := os.WriteFile(stale, []byte("x"), 0o644); err != nil { diff --git a/internal/ajean/backend_models_dl_test.go b/internal/ajean/backend_models_dl_test.go index 4c0cd1f..ad4a0d7 100644 --- a/internal/ajean/backend_models_dl_test.go +++ b/internal/ajean/backend_models_dl_test.go @@ -75,8 +75,11 @@ func TestRunDownloadCancelLeavesNothing(t *testing.T) { // cleanStalePartFiles doit balayer les .part orphelins sans toucher aux .gguf. func TestCleanStalePartFiles(t *testing.T) { - dir := t.TempDir() - t.Setenv("AJEAN_HOME", dir) + testHome(t) + dir := modelsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } keep := filepath.Join(dir, "bon.gguf") stale := filepath.Join(dir, "coupe.gguf.part") for _, p := range []string{keep, stale} { diff --git a/internal/ajean/backend_prebuilt_test.go b/internal/ajean/backend_prebuilt_test.go index 326e35e..fb4a50e 100644 --- a/internal/ajean/backend_prebuilt_test.go +++ b/internal/ajean/backend_prebuilt_test.go @@ -67,8 +67,7 @@ func TestExtractArchiveSymlink(t *testing.T) { // comme sur une machine mise à jour plusieurs fois. func fakePrebuilt(t *testing.T, version string, tags ...string) string { t.Helper() - home := t.TempDir() - t.Setenv("AJEAN_HOME", home) + home := testHome(t) dir := filepath.Join(home, "backends", "llama.cpp-prebuilt") name := "llama-server" if runtime.GOOS == "windows" { diff --git a/internal/ajean/backend_presets_test.go b/internal/ajean/backend_presets_test.go index b002666..385bd3a 100644 --- a/internal/ajean/backend_presets_test.go +++ b/internal/ajean/backend_presets_test.go @@ -6,6 +6,14 @@ import ( "testing" ) +// setConfig installe une configuration décrite au format des presets. +func setConfig(t *testing.T, body string) { + t.Helper() + if err := WriteConfig(parseEnv(body)); err != nil { + t.Fatal(err) + } +} + // TestPresetFingerprintIgnoresCosmetics vérifie que la détection du preset actif // ne dépend QUE de la config effective (ensemble KEY=VALUE), pas de la mise en // forme : ordre des lignes, commentaires (dont `# NAME=`), lignes vides, `export`, @@ -32,15 +40,14 @@ func TestPresetFingerprintIgnoresCosmetics(t *testing.T) { // suivante l'écrasait, llama.cpp revoyait les deux cartes et réétalait le modèle // sur la petite — pendant que `ajean gpu` réaffichait « auto ». func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) { - home := t.TempDir() - t.Setenv("AJEAN_HOME", home) + home := testHome(t) write := func(p, body string) { if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } - // config.env courante : un preset + les réglages machine. - write(confPath(), "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n") + // Configuration courante : un preset + les réglages machine. + setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n") target := filepath.Join(home, "cible.env") write(target, "MODEL=nouveau.gguf\nCTX=8192\n") @@ -66,14 +73,13 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) { // --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait // mourir le chargement sur « cudaMalloc failed: out of memory ». func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) { - home := t.TempDir() - t.Setenv("AJEAN_HOME", home) + home := testHome(t) write := func(p, body string) { if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } - write(confPath(), "MODEL=ancien.gguf\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\n") + setConfig(t, "MODEL=ancien.gguf\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\n") deuxGPU := filepath.Join(home, "deux-gpu.env") write(deuxGPU, "MODEL=fable.gguf\nCUDA_VISIBLE_DEVICES=1,0\n") if err := applyPresetFile(deuxGPU); err != nil { diff --git a/internal/ajean/chat_agent.go b/internal/ajean/chat_agent.go index 768d1fa..4eb6aba 100644 --- a/internal/ajean/chat_agent.go +++ b/internal/ajean/chat_agent.go @@ -26,7 +26,7 @@ func cmdAgent(args []string) error { return err } fmt.Println(green("[ok]") + " mode agent désactivé") - case "", "status", "list": + case "", "status": state := dim("off") if agentEnabled() { state = green("on") diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go index 248e07a..dead68e 100644 --- a/internal/ajean/chat_memory.go +++ b/internal/ajean/chat_memory.go @@ -317,9 +317,9 @@ const ( // actuel). Toute valeur inconnue retombe sur always. func memMode() MemMode { switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) { - case "off", "0", "false", "none", "no", "non": + case "off": return MemOff - case "ondemand", "on-demand", "demand", "manual", "manuel": + case "ondemand": return MemOnDemand default: // "always", "auto", "" et inconnus return MemAlways @@ -343,7 +343,7 @@ func cmdMemory(args []string) error { MemAlways: "auto (l'IA cherche et sauve d'elle-même)", } switch sub { - case "off", "none", "0", "false": + case "off": if err := setMemMode(MemOff); err != nil { return err } @@ -351,11 +351,11 @@ func cmdMemory(args []string) error { if err := setMemMode(MemOnDemand); err != nil { return err } - case "always", "auto", "on": + case "always": if err := setMemMode(MemAlways); err != nil { return err } - case "", "status", "list": + case "", "status": m := memMode() fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m]) pages := MemList() diff --git a/internal/ajean/mcp_client_test.go b/internal/ajean/mcp_client_test.go index 682a9d5..c65207e 100644 --- a/internal/ajean/mcp_client_test.go +++ b/internal/ajean/mcp_client_test.go @@ -12,7 +12,7 @@ import ( // tentative de connexion : sans ça, connecter en parallèle multiplierait les // process stdio lancés pour un même serveur. func TestMCPEnsureDeduplicatesConcurrentConnects(t *testing.T) { - t.Setenv("AJEAN_HOME", t.TempDir()) + testHome(t) t.Cleanup(mcpCloseAll) // Commande inexistante : la connexion échoue, mais l'entrée du pool est bien @@ -53,7 +53,7 @@ func TestMCPEndToEnd(t *testing.T) { if _, err := exec.LookPath("npx"); err != nil { t.Skip("npx indisponible — test MCP e2e ignoré") } - t.Setenv("AJEAN_HOME", t.TempDir()) + testHome(t) cfg := MCPServerConfig{ Command: "npx", diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 3edcc56..2fbaeaf 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -87,7 +87,7 @@ func cmdLink(args []string) error { // éviter de prendre un mot pour un token et d'écraser le vrai). printLinkHelp() return nil - case "serve", "--foreground", "fg": + case "serve": // Le worker réel (boucle de connexion au relais), pendant de `ajean serve`. // C'est ce que lance l'unité systemd ; il NE doit PAS rendre la main. return runLinkForeground() diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 63019d8..da539f8 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -66,15 +66,11 @@ func Main() { mustExit(cmdLink(args)) case "oai": mustExit(cmdOAI(args)) - case "agent", "skills", "machine", "tools": - // « mode agent » unifie l'ancien couple machine + skills : un seul - // interrupteur active TOUS les outils de l'IA (shell + mémoire ; les - // skills ont été fondus dans la mémoire). Les anciens noms restent - // acceptés comme alias rétro-compatibles. + case "agent": mustExit(cmdAgent(args)) - case "internet", "web-access": + case "internet": mustExit(cmdInternet(args)) - case "memory", "mem": + case "memory": mustExit(cmdMemory(args)) case "serve": mustExit(cmdServe(args)) @@ -82,15 +78,15 @@ func Main() { mustExit(cmdTest(args)) case "bench": mustExit(cmdBench(args)) - case "llamacpp", "llama": + case "llamacpp": mustExit(cmdLlamacpp(args)) - case "update", "upgrade", "self-update": + case "update": mustExit(cmdUpdate(args)) - case "where", "paths": + case "where": mustExit(cmdWhere(args)) case restartArg: - // Sous-commande interne, absente de l'aide : l'accompagnateur detache qui - // attend la fermeture de l'app, migre, puis la relance. + // Sous-commande interne, absente de l'aide : l'accompagnateur détaché qui + // attend la fermeture de l'app puis la relance. mustExit(cmdRestartAfterUpdate(args)) case "install": mustExit(cmdInstall(args)) @@ -116,58 +112,46 @@ Application: app lance l'UI web + ouvre le navigateur (auto au double-clic du binaire) Service: - start | stop | restart gérer le service (systemd sous Linux, processus en arrière-plan sous Windows) + start | stop | restart gérer le service status | logs état / logs en direct enable | disable auto-démarrage au boot - edit éditer $AJEAN_HOME/config.env - 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 'ajean web'; vide = générer, "" = retirer + edit éditer la configuration dans $EDITOR + switch [N] activer un preset de presets/ (interactif ou par numéro) + test | bench [N] vérifier que l'IA répond / mesurer prefill + decode tok/s 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) - bench [N] mesure prefill + decode tok/s (prompt 2000 tok, N=200 par défaut) - -Presets: - switch [N] choisir un preset dans configs/ (interactif ou par numéro) + 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; vide = générer, "" = retirer Interaction: chat [system-prompt] chat terminal streamé web [PORT] UI web (défaut :8090) — chat + presets + mode agent + agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire) + memory [off|ondemand|always|status] mode mémoire de l'IA internet [on|off|status|engine |url |key ] - accès web de l'IA (web_search/open/read/grep) — moteur intégré ou serveur Crawl4AI - memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto) + accès web de l'IA (moteur intégré ou serveur Crawl4AI) Accès distant (ajean.link) : - link enregistre le token et démarre le lien au relais (token = 1re fois / pour le changer) - link start | restart | stop démarre / redémarre / arrête le service de lien - link code génère un code d'appairage (valable 10 min, à usage unique) pour le portail + link enregistre le token et démarre le lien au relais + link start | restart | stop pilote le service de lien + link code code d'appairage (10 min, usage unique) pour le portail link status | logout état du lien / oublier le token - link (sans argument) affiche l'aide des sous-commandes link - link serve exécute le worker au premier plan (utilisé par ajean-link.service ; pendant de 'ajean serve') - -Mode agent: - agent [on|off|status] active TOUS les outils de l'IA (shell complet + mémoire) — un seul interrupteur + link serve worker au premier plan (lancé par ajean-link.service) Backend llama.cpp : - llamacpp install clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), pointe BIN dessus - llamacpp update git pull + recompile le backend existant (arrête/redémarre le service) + llamacpp install clone + compile llama.cpp (CUDA/ROCm/Metal/CPU), pointe BIN dessus + llamacpp update git pull + recompile le backend existant llamacpp status commit courant, backend détecté, retard sur origin -Entrypoint (utilisé par ajean.service) : - serve lit config.env et exec le binaire llama-server - Installation: - where affiche où sont le binaire, la config, la mémoire et le dossier de travail de l'agent - install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config) - uninstall désinstaller - update [--check] mettre à jour AJEAN depuis les releases GitHub (--check = signale sans installer) + where affiche l'emplacement du binaire, de la base et des dossiers + install | uninstall installer / désinstaller + update [--check] mettre à jour depuis les releases GitHub + serve entrypoint du service : exec llama-server (usage interne) Env: - AJEAN_HOME racine (défaut: /etc/ajean sur Linux/macOS, %%ProgramData%%\ajean sur Windows) - AJEAN_HOME reste accepté (héritage) - EDITOR éditeur pour 'ajean edit' (défaut: nano sur Unix, notepad sur Windows) - -Config: $AJEAN_HOME/config.env + AJEAN_HOME racine des données (défaut : /etc/ajean, %%ProgramData%%\ajean sous Windows) + EDITOR éditeur pour 'ajean edit' (défaut : nano, notepad sous Windows) `, Version) } diff --git a/internal/ajean/store.go b/internal/ajean/store.go index 13a671b..faf74f9 100644 --- a/internal/ajean/store.go +++ b/internal/ajean/store.go @@ -33,49 +33,69 @@ const ( bkChat = "chat" // conversation partagée ) +// Les connexions sont mises en cache PAR CHEMIN, et non dans un singleton. +// $AJEAN_HOME peut désigner un autre dossier d'un appel à l'autre — c'est le cas +// dans les tests, qui donnent à chacun son dossier temporaire. Un singleton +// figerait la toute première base et tous les appels suivants écriraient dans le +// mauvais fichier, sans erreur et sans bruit. var ( - dbOnce sync.Once - dbConn *bolt.DB - dbErr error + dbMu sync.Mutex + dbConns = map[string]*bolt.DB{} ) func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") } -// db ouvre la base à la première utilisation et la garde ouverte pour la durée -// du process. bbolt prend un verrou exclusif sur le fichier : le délai d'attente -// évite qu'un second process (l'app lancée deux fois) se bloque indéfiniment. +// db ouvre la base (ou renvoie celle déjà ouverte pour ce chemin) et la garde +// ouverte pour la durée du process. bbolt prend un verrou exclusif sur le +// fichier : le délai d'attente évite qu'un second process (l'app lancée deux +// fois) se bloque indéfiniment. func db() (*bolt.DB, error) { - dbOnce.Do(func() { - if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { - dbErr = err - return - } - d, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 3 * time.Second}) - if err != nil { - dbErr = fmt.Errorf("base %s inaccessible : %w", dbPath(), err) - return - } - err = d.Update(func(tx *bolt.Tx) error { - for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} { - if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil { - return err - } + path := dbPath() + + dbMu.Lock() + defer dbMu.Unlock() + if d, ok := dbConns[path]; ok { + return d, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 3 * time.Second}) + if err != nil { + return nil, fmt.Errorf("base %s inaccessible : %w", path, err) + } + err = d.Update(func(tx *bolt.Tx) error { + for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} { + if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil { + return err } - return nil - }) - if err != nil { - d.Close() - dbErr = err - return } - dbConn = d + return nil }) - return dbConn, dbErr + if err != nil { + d.Close() + return nil, err + } + dbConns[path] = d + return d, nil } // getBytes lit une valeur. Une base inaccessible se comporte comme une base // vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile // face à une erreur d'E/S, et tous ont déjà un défaut. +// closeDB ferme la base ouverte pour le dossier courant, s'il y en a une. bbolt +// garde un verrou exclusif sur son fichier tant qu'il est ouvert : sans ça, +// impossible de supprimer ou déplacer le dossier de données sous Windows. +func closeDB() { + path := dbPath() + dbMu.Lock() + defer dbMu.Unlock() + if d, ok := dbConns[path]; ok { + _ = d.Close() + delete(dbConns, path) + } +} + func getBytes(bucket, key string) []byte { d, err := db() if err != nil { diff --git a/internal/ajean/store_test.go b/internal/ajean/store_test.go new file mode 100644 index 0000000..2862036 --- /dev/null +++ b/internal/ajean/store_test.go @@ -0,0 +1,67 @@ +package ajean + +import "testing" + +// testHome donne au test un $AJEAN_HOME à lui et ferme la base à la fin. +// +// La fermeture n'est pas une politesse : bbolt garde un verrou exclusif sur son +// fichier, et sous Windows le ménage de t.TempDir() échoue tant qu'il est ouvert. +func testHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("AJEAN_HOME", home) + t.Cleanup(closeDB) + return home +} + +// La configuration écrite doit se relire à l'identique, et une clé vidée +// disparaître au lieu de rester à "". +func TestConfigAllerRetour(t *testing.T) { + testHome(t) + if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "CTX": "4096"}); err != nil { + t.Fatal(err) + } + if cfg := ReadConfig(); cfg["MODEL"] != "m.gguf" || cfg["CTX"] != "4096" { + t.Fatalf("configuration relue incorrecte : %v", cfg) + } + if err := SetConfigKey("CTX", ""); err != nil { + t.Fatal(err) + } + if _, ok := ReadConfig()["CTX"]; ok { + t.Error("CTX vidée mais toujours présente") + } +} + +// WriteConfig REMPLACE : aucune clé de l'ancienne configuration ne doit +// survivre, sans quoi une bascule de preset laisserait des réglages fantômes. +func TestWriteConfigRemplaceTout(t *testing.T) { + testHome(t) + if err := WriteConfig(map[string]string{"A": "1", "B": "2"}); err != nil { + t.Fatal(err) + } + if err := WriteConfig(map[string]string{"B": "3"}); err != nil { + t.Fatal(err) + } + cfg := ReadConfig() + if _, ok := cfg["A"]; ok { + t.Error("A a survécu au remplacement") + } + if cfg["B"] != "3" { + t.Errorf("B = %q, attendu 3", cfg["B"]) + } +} + +// Une base absente se comporte comme une base vide : les lecteurs ont tous un +// défaut, aucun ne doit paniquer. +func TestLecturesSurBaseVide(t *testing.T) { + testHome(t) + if len(ReadConfig()) != 0 { + t.Error("configuration non vide sur une base neuve") + } + if agentEnabled() || internetEnabled() { + t.Error("interrupteurs actifs sur une base neuve") + } + if readAPIKey() != "" || readWebKey() != "" || readLinkToken() != "" { + t.Error("clés non vides sur une base neuve") + } +} diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 96953a0..2fdce9b 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -17,7 +17,6 @@ import ( // sur « systemctl: command not found » / « /etc/default/ajean », cf. issue #4). // Implémentation prudente ; à valider sur une machine Apple. - // launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName, // WorkingDirectory, AJEAN_HOME, StandardOutPath, StandardErrorPath. // KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 022f2f5..f29b82c 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -11,7 +11,6 @@ import ( "strings" ) - const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by ajean). %s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s ` @@ -110,7 +109,7 @@ func cmdInstall(args []string) error { } fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - // 5. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed) + // 4. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed) unit := fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve") unitPath := "/etc/systemd/system/" + svc + ".service" if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index a216c0a..765d886 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -16,7 +16,6 @@ import ( // service itself is managed by the PID-file supervisor in sys_service_windows.go // (ajean start / stop / status), which needs no admin rights. - func cmdInstall(args []string) error { ajeanHome := AjeanHome() diff --git a/internal/ajean/sys_update_asset_test.go b/internal/ajean/sys_update_asset_test.go index 13a88b0..8dbdd59 100644 --- a/internal/ajean/sys_update_asset_test.go +++ b/internal/ajean/sys_update_asset_test.go @@ -15,48 +15,21 @@ func relWith(names ...string) *ghRelease { return rel } -// Une release de transition publie les deux noms : on doit préférer ajean-*. -func TestPickAssetPrefersAjean(t *testing.T) { - names := updateAssetNames() - got, url, _ := pickAsset(relWith(names[1], names[0])) - if got != names[0] { - t.Fatalf("got %q, attendu la préférence %q", got, names[0]) +// L'asset de la plateforme est trouvé au milieu des autres, et le nom renvoyé +// est bien celui qui servira à vérifier le SHA-256. +func TestPickAssetTrouveSaPlateforme(t *testing.T) { + want := updateAssetName() + got, url, size := pickAsset(relWith("SHA256SUMS", "ajean-plan9-386", want)) + if got != want { + t.Fatalf("got %q, attendu %q", got, want) } - if url == "" { - t.Fatal("URL vide") - } -} - -// Release future, purgée des anciens noms : ajean-* seul suffit. -func TestPickAssetAjeanOnly(t *testing.T) { - names := updateAssetNames() - if got, _, _ := pickAsset(relWith(names[0])); got != names[0] { - t.Fatalf("got %q, attendu %q", got, names[0]) - } -} - -// Ancienne release, antérieure au renommage : le repli sur ajean-* doit marcher, -// sinon un client neuf ne pourrait pas s'installer depuis une release existante. -func TestPickAssetFallsBackToLegacy(t *testing.T) { - names := updateAssetNames() - got, _, _ := pickAsset(relWith(names[1])) - if got != names[1] { - t.Fatalf("got %q, attendu le repli %q", got, names[1]) - } -} - -// Le nom renvoyé sert à vérifier le SHA-256 : il doit être celui réellement -// choisi, pas le nom préféré, sinon la vérification échoue sur un repli. -func TestPickAssetReturnsCheckedName(t *testing.T) { - names := updateAssetNames() - got, url, size := pickAsset(relWith(names[1])) - if got != names[1] || url != "https://example.invalid/"+names[1] || size != 42 { - t.Fatalf("incohérence nom/URL/taille: %q %q %d", got, url, size) + if url != "https://example.invalid/"+want || size != 42 { + t.Fatalf("incohérence URL/taille : %q %d", url, size) } } // Aucun asset pour cette plateforme → pas d'URL, l'appelant doit pouvoir le voir. -func TestPickAssetNoMatch(t *testing.T) { +func TestPickAssetSansCorrespondance(t *testing.T) { if _, url, _ := pickAsset(relWith("ajean-plan9-386", "SHA256SUMS")); url != "" { t.Fatalf("URL inattendue: %q", url) } From 687ec810236a2fc4fa37b0fdeac54eabde49d379 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:12:16 +0200 Subject: [PATCH 231/279] v0.8.0 : code mort supprime, UI et docs en ajean, release unique - suppression des fonctions et types inutilises (staticcheck U1000) - sources UI, workflows, README passes en ajean - release.yml ne publie plus que les binaires ajean-* - notes de release redigees --- .github/workflows/mac-build.yml | 12 ++-- .github/workflows/release.yml | 38 ++++-------- README.md | 24 +++++--- RELEASE_NOTES.md | 34 ++++------ internal/ajean/backend_serve.go | 4 +- internal/ajean/chat_internet.go | 1 - internal/ajean/chat_memory.go | 20 ------ internal/ajean/mcp_config.go | 5 -- internal/ajean/relay_e2e.go | 31 ---------- internal/ajean/run.go | 2 +- internal/ajean/sys_brand_icon.go | 10 +-- internal/ajean/sys_platform_unix.go | 7 +-- internal/ajean/sys_platform_windows.go | 5 +- internal/ajean/ui/index.html | 72 +++++++++++----------- internal/ajean/ui/src/index.tmpl.html | 6 +- internal/ajean/ui/src/js/00-md.js | 4 +- internal/ajean/ui/src/js/01-theme.js | 8 +-- internal/ajean/ui/src/js/02-prefs.js | 6 +- internal/ajean/ui/src/js/03-ui-kit.js | 2 +- internal/ajean/ui/src/js/04-api.js | 8 +-- internal/ajean/ui/src/js/06-settings.js | 6 +- internal/ajean/ui/src/js/07-models.js | 16 ++--- internal/ajean/ui/src/js/08-chat-render.js | 4 +- internal/ajean/ui/src/js/09-stream.js | 4 +- internal/ajean/ui/src/js/13-remote.js | 2 +- internal/ajean/ui/src/styles.css | 6 +- 26 files changed, 123 insertions(+), 214 deletions(-) diff --git a/.github/workflows/mac-build.yml b/.github/workflows/mac-build.yml index 16a3235..390047a 100644 --- a/.github/workflows/mac-build.yml +++ b/.github/workflows/mac-build.yml @@ -18,17 +18,17 @@ jobs: mkdir -p dist CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean - mkdir -p /tmp/jean.iconset + mkdir -p /tmp/ajean.iconset sips -s format png 'cmd/ajean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true for s in 16 32 128 256 512; do - sips -z $s $s /tmp/icon.png --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true + sips -z $s $s /tmp/icon.png --out "/tmp/ajean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true done - iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns >/dev/null 2>&1 || true + iconutil -c icns /tmp/ajean.iconset -o /tmp/ajean.icns >/dev/null 2>&1 || true app="dist/AJEAN.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" cp dist/ajean-darwin-arm64 "$app/Contents/MacOS/ajean" chmod +x "$app/Contents/MacOS/ajean" - cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true + cp /tmp/ajean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true printf '%s\n' \ '' \ '' \ @@ -48,5 +48,5 @@ jobs: ls -l dist - uses: actions/upload-artifact@v4 with: - name: Jean-macos-arm64 - path: dist/Jean-macos-arm64.zip + name: AJEAN-macos-arm64 + path: dist/AJEAN-macos-arm64.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c98b884..5635688 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ # Release : sur tag vX.Y.Z, compile les 6 cibles et publie la release GitHub # avec les binaires (mêmes noms d'assets que les releases manuelles historiques) -# ET le fichier SHA256SUMS que `jean update` (v0.4.6+) vérifie avant d'installer. +# ET le fichier SHA256SUMS que `ajean update` (v0.4.6+) vérifie avant d'installer. # ⚠️ NE PAS uploader de binaires à la main en plus : ce workflow écrase les # assets du même nom (softprops remplace), et des sommes qui ne correspondent -# plus aux binaires font échouer `jean update` (vécu sur v0.4.7). +# plus aux binaires font échouer `ajean update` (vécu sur v0.4.7). # # Deux jobs de build : macOS a besoin d'un vrai runner Apple (l'icône de barre de # menus passe par Cocoa → CGO, pas cross-compilable depuis Linux) ; Linux et @@ -34,29 +34,29 @@ jobs: CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \ go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-amd64 ./cmd/ajean file dist/ajean-darwin-* - - name: bundles Jean.app + - name: bundles AJEAN.app run: | # Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc - # aussi un vrai bundle Jean.app (zippé pour garder le bit exécutable) : - # au clic, le Finder lance Contents/MacOS/jean, qui détecte le bundle + # aussi un vrai bundle AJEAN.app (zippé pour garder le bit exécutable) : + # au clic, le Finder lance Contents/MacOS/ajean, qui détecte le bundle # et bascule en mode « application » (UI web + navigateur + icône dans - # la barre de menus), comme le double-clic sur jean.exe sous Windows. + # la barre de menus), comme le double-clic sur ajean.exe sous Windows. # LSUIElement : app de barre de menus, aucune icône dans le Dock. # L'icône part du PNG 1024 généré avec le reste de la marque # (cmd/ajean/icon.png, voir tools/gen-icon) : plus de conversion depuis # le .ico, dont l'échec silencieux donnait une app sans icône. - mkdir -p /tmp/jean.iconset + mkdir -p /tmp/ajean.iconset for s in 16 32 128 256 512; do - sips -z $s $s 'cmd/ajean/icon.png' --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null + sips -z $s $s 'cmd/ajean/icon.png' --out "/tmp/ajean.iconset/icon_${s}x${s}.png" >/dev/null done - iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns + iconutil -c icns /tmp/ajean.iconset -o /tmp/ajean.icns v="${GITHUB_REF_NAME#v}" for arch in amd64 arm64; do app="dist/mac-$arch/AJEAN.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" cp "dist/ajean-darwin-$arch" "$app/Contents/MacOS/ajean" chmod +x "$app/Contents/MacOS/ajean" - cp /tmp/jean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true + cp /tmp/ajean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true printf '%s\n' \ '' \ '' \ @@ -80,7 +80,7 @@ jobs: # Nom hérité conservé : les liens de téléchargement déjà publiés # (site, mails, messages) pointent dessus et doivent continuer de # fonctionner. Même contenu, deux noms. - cp "dist/AJEAN-macos-$arch.zip" "dist/Jean-macos-$arch.zip" + cp "dist/AJEAN-macos-$arch.zip" "dist/AJEAN-macos-$arch.zip" rm -rf "dist/mac-$arch" done ls -l dist @@ -112,22 +112,10 @@ jobs: with: name: macos path: dist - - name: assets hérités jean-* (transition) - run: | - # Le parc déjà installé ne sait chercher QUE « jean-- » : sans - # ces copies, aucun des utilisateurs actuels ne pourrait se mettre à - # jour — pas même vers la version qui apprend à lire « ajean-* ». - # Après cette étape SEULEMENT, donc une fois les binaires macOS - # récupérés, les deux jeux de noms sont complets. - # À supprimer quand le parc aura basculé (quelques versions). - for f in dist/ajean-*; do - cp "$f" "dist/jean-${f#dist/ajean-}" - done - ls -l dist - name: SHA256SUMS run: | - chmod +x dist/ajean-darwin-* dist/jean-darwin-* - (cd dist && sha256sum ajean-* jean-* *.zip > SHA256SUMS) + chmod +x dist/ajean-darwin-* + (cd dist && sha256sum ajean-* *.zip > SHA256SUMS) ls -l dist && cat dist/SHA256SUMS - name: publier la release uses: softprops/action-gh-release@v2 diff --git a/README.md b/README.md index 7483e50..cd6679b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur ### 1. Le binaire ```bash -curl -L -o jean https://github.com/nathaninline/ajean/releases/latest/download/jean-linux-amd64 +curl -L -o ajean https://github.com/nathaninline/ajean/releases/latest/download/ajean-linux-amd64 chmod +x ajean sudo mv ajean /usr/local/bin/ajean ``` @@ -78,7 +78,7 @@ Service : start | stop | restart gérer le service status | logs état / logs en direct enable | disable démarrage au boot - edit éditer $AJEAN_HOME/config.env + edit éditer la configuration dans $EDITOR test | bench [N] vérifier que le modèle répond / mesurer les tok/s vram | gpu [index…] VRAM / choix des GPU (gpu all = tous) set-api-key [clé] protéger le moteur d'inférence (Bearer) @@ -90,7 +90,7 @@ Capacités de l'IA : internet [on|off|engine |url |key ] accès web Presets et moteur : - switch [N] changer de preset (configs/) + switch [N] changer de preset (presets/) llamacpp install|update|status Accès distant (ajean.link) : @@ -107,7 +107,11 @@ Installation : ## Configuration -Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\ajean` sous Windows). Le service lit `config.env` : +Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\ajean` sous Windows), qui contient exactement six dossiers — `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/` — et la base `ajean.db`. + +Cette base, un unique fichier [bbolt](https://github.com/etcd-io/bbolt), remplace la dizaine de fichiers d'état d'autrefois : configuration, préférences de l'interface, conversation, clés et interrupteurs y vivent désormais ensemble. Restent des fichiers ce qui se lit et s'édite à la main : les presets (`presets/*.env`), les pages de mémoire (`memory/*.md`) et, bien sûr, les modèles. + +La configuration du moteur s'édite avec `ajean edit`, qui la déroule au format `clé=valeur` dans `$EDITOR` : | Clé | Signification | Défaut | |-----|---------------|--------| @@ -127,9 +131,9 @@ Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\a | `CRAWL4AI_URL` / `CRAWL4AI_KEY` | serveur d'accès internet | — | | `EXTRA_ARGS` | ajouté tel quel à la ligne de commande du moteur | — | -La clé API (`ajean set-api-key`) vit dans `$AJEAN_HOME/.api_key`, à part, afin de survivre aux changements de preset. +La clé API (`ajean set-api-key`) est rangée hors de la configuration, afin de survivre aux changements de preset. -**Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$AJEAN_HOME` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans `model_dirs.json`, donc conservée d'un preset à l'autre. +**Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$AJEAN_HOME/models` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans la base, donc conservée d'un preset à l'autre. ### Variables d'environnement @@ -137,7 +141,7 @@ La clé API (`ajean set-api-key`) vit dans `$AJEAN_HOME/.api_key`, à part, afin |----------|------|--------| | `AJEAN_HOME` | racine des données | `/etc/ajean`, `%ProgramData%\ajean` | | `AJEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — | -| `JEAN_SERVICE` | nom de l'unité systemd | `ajean` | +| `AJEAN_SERVICE` | nom de l'unité systemd | `ajean` | | `HF_TOKEN` | token Hugging Face pour les modèles privés | — | | `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — | | `EDITOR` | éditeur pour `ajean edit` | `nano` / `notepad` | @@ -146,7 +150,7 @@ La clé API (`ajean set-api-key`) vit dans `$AJEAN_HOME/.api_key`, à part, afin ### Mémoire -L'IA tient des pages Markdown sous `$AJEAN_HOME/MEMORY/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent : +L'IA tient des pages Markdown sous `$AJEAN_HOME/memory/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent : ```bash ajean memory always # (défaut) elle cherche avant de répondre et enregistre d'elle-même @@ -182,7 +186,7 @@ Les outils web ne sont proposés au modèle que si le mode agent est actif, l'ac AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : on y branche des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp____`. -La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$AJEAN_HOME/mcp.json`, au **même format que Claude Desktop**, si bien qu'un fichier existant se réutilise tel quel : +La configuration se fait depuis l'interface web (section *Serveurs MCP*) (section *Serveurs MCP*). Le format des serveurs est celui de Claude Desktop, si bien qu'une configuration existante se recopie telle quelle : ```json { @@ -199,7 +203,7 @@ Les transports **stdio** et **HTTP** sont pris en charge. Comme le terminal, un - **Pas de systemd** : `ajean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés, il faut passer par une tâche planifiée. - `AJEAN_HOME` vaut `%ProgramData%\ajean` (repli `%LOCALAPPDATA%\ajean`). -- `ajean install` crée seulement le dossier de données et un `config.env` de départ. +- `ajean install` crée seulement l'arborescence de données et une configuration de départ. - Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et écrit ses fichiers par l'outil dédié plutôt que par le shell, ce qui lui permet de produire des scripts contenant des guillemets. ```powershell diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ff93edc..14dc299 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,33 +1,23 @@ -Correctifs d'interface dans la foulée de la 0.7.11. Tout ce qui bougeait, sautait ou clignotait après un chargement se tient enfin tranquille. +Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base. -## L'éditeur de preset ne s'ouvre plus au milieu +**Cette version ne reprend pas les installations précédentes.** Il n'y a aucune migration : ni des dossiers, ni des unités système, ni des réglages. Réinstallez, puis retéléchargez vos modèles ou repointez-les avec *Dossiers de modèles*. -Ouvrir un preset après en avoir fait défiler un autre rouvrait la fiche à la position précédente. La remise à zéro du défilement existait, mais elle était faite avant l'affichage : écrire une position de défilement sur un élément non affiché ne fait rien du tout, le navigateur restaurait donc l'ancienne. Elle se fait maintenant après l'affichage, puis une seconde fois une fois le contenu en place. +## Une base à la place des fichiers d'état -## Le sélecteur de moteur ne glisse plus tout seul +`config.env`, `webprefs.json`, `conversation.json`, `model_dirs.json`, `mcp.json`, `.api_key`, `.web_key`, `.link_token`, `.agent_enabled`, `.internet_enabled` et le reste ont disparu. Tout cela vit maintenant dans **`ajean.db`**, un fichier unique en [bbolt](https://github.com/etcd-io/bbolt) — pur Go, transactionnel, sans dépendance système. -En passant d'un preset à l'autre, on voyait le curseur du sélecteur (précompilé, compilé, personnalisé) glisser de l'ancienne valeur vers la nouvelle une fois le chargement terminé. Le changement d'état avait pourtant lieu pendant que le formulaire était caché : WebKit diffère les transitions des éléments non rendus et les rejoue à leur retour à l'écran. Les transitions du formulaire sont donc coupées tant qu'il est masqué, et pendant l'image où il réapparaît. +Ce n'est pas qu'un rangement. Chaque fichier avait sa façon d'être écrit, et donc sa façon de rater une écriture concurrente : l'interface et un tour de chat qui touchaient au même réglage au même instant pouvaient en perdre un. Une transaction remplace tout ça. Une bascule de preset, en particulier, remplace la configuration d'un bloc : il n'existe plus d'instant où elle serait à moitié l'ancienne et à moitié la nouvelle. -Dans le même esprit, le voile de chargement de la modale est devenu opaque, sous le titre resté lisible. Rien du preset précédent ne peut plus apparaître une fraction de seconde. +Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardés à la main : les presets, les pages de mémoire, les modèles. `ajean edit` déroule donc la configuration au format `clé=valeur` dans votre éditeur, puis la relit — le fichier n'existe que le temps de l'édition. -## Le menu ne saute plus au chargement +## Six dossiers, et rien d'autre -Plusieurs blocs (config active, jauges machine, liste des presets, état de l'accès internet, serveurs MCP) sont vides tant que le serveur n'a pas répondu, puis prennent leur taille réelle : tout le menu se décalait d'un cran, ce qui se voyait surtout sur les sections du bas. Leur hauteur est désormais mémorisée d'une session à l'autre et réservée dès le démarrage, puis rendue quand le vrai contenu arrive. La réserve tient la place, elle n'affiche jamais d'information fausse. +`$AJEAN_HOME` contient exactement `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. -Les champs Crawl4AI, eux, étaient visibles au départ puis repliés dès que l'état arrivait, le moteur par défaut étant l'intégré. Ils partent maintenant masqués. La section Moteur ne grandit plus non plus : son texte d'en-tête part à sa taille définitive et la ligne d'état des trois moteurs a sa hauteur réservée. +## Ce qui a été retiré -Au passage, un chargement en échec (accès distant coupé par exemple) n'interrompt plus les autres. +Tout le code écrit pour ménager les installations « jean » : la migration du dossier de données et ses reprises après échec, la migration de l'agencement système (unités, `/etc/default`, réécriture des chemins), l'élévation Windows qu'elle demandait, la résolution du nom d'unité réellement installée, la reprise des fichiers PID et des skills, les alias `jean` posés à l'installation, les variables `JEAN_*` lues en second. -## Détails +La CLI perd ses alias hérités — `skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama` — et son aide est réorganisée. Chaque commande a désormais un seul nom. -Les pastilles « actif » et « inactif » disparaissent de l'accès internet et des serveurs MCP : l'interrupteur et la liste le disent déjà. Reste la seule information utile, l'anomalie « injoignable », qui signale que les outils web ne sont pas fournis au modèle. - -Dans la mémoire, la ligne « Voir la mémoire » perd son chevron et son libellé est enfin centré. Créer une page annonçait « Nouveau Page » et demandait un « Nom du preset » : chaque type a maintenant ses propres libellés. - -## Mise à jour - -``` -ajean update -``` - -Chaque correction a été vérifiée en mesurant le rendu réel, position et hauteur des éléments avant et après chargement, pas seulement à l'oeil. +Les releases ne publient plus qu'un jeu de binaires, `ajean--`. La double publication qui accompagnait la transition n'a plus d'objet. diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index 5a2dc95..ad40de5 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -100,9 +100,7 @@ func cmdServe(args []string) error { llmArgs = append(llmArgs, "--api-key", k) } // EXTRA_ARGS is appended verbatim, split on whitespace like the shell would. - for _, a := range trimSplit(cfg["EXTRA_ARGS"], " ") { - llmArgs = append(llmArgs, a) - } + llmArgs = append(llmArgs, trimSplit(cfg["EXTRA_ARGS"], " ")...) // Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj // mmproj-F16.gguf) still resolve. diff --git a/internal/ajean/chat_internet.go b/internal/ajean/chat_internet.go index dd82c0e..061d155 100644 --- a/internal/ajean/chat_internet.go +++ b/internal/ajean/chat_internet.go @@ -471,7 +471,6 @@ type searchResult struct { } var ( - htmlTagRe = regexp.MustCompile(`<[^>]+>`) wsRe = regexp.MustCompile(`\s+`) numEntityRe = regexp.MustCompile(`&#(\d+);`) ddgHeadRe = regexp.MustCompile(`^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$`) diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go index dead68e..57f8b21 100644 --- a/internal/ajean/chat_memory.go +++ b/internal/ajean/chat_memory.go @@ -368,23 +368,3 @@ func cmdMemory(args []string) error { fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m]) return nil } - -// memorySystemPrompt liste les pages mémoire à injecter quand le mode agent est -// actif, pour que l'IA sache ce qu'elle a déjà retenu. -func memorySystemPrompt(caps Caps) string { - if !caps.Agent { - return "" - } - list := MemList() - var b strings.Builder - b.WriteString("Memory: persistent Markdown pages under MEMORY/. Use mem_search FIRST whenever the user asks about something you might already know (preferences, ongoing projects, past decisions). Open the most relevant page with mem_read. Save anything worth keeping across sessions with mem_add (one topic per page, descriptive kebab-case name); update a page with mem_edit (old must match exactly once). The first content line is a short title (#).\n") - if len(list) == 0 { - b.WriteString("Pages: none yet.") - return b.String() - } - b.WriteString("Pages:\n") - for _, p := range list { - fmt.Fprintf(&b, "- %s: %s\n", p.Name, p.Title) - } - return strings.TrimRight(b.String(), "\n") -} diff --git a/internal/ajean/mcp_config.go b/internal/ajean/mcp_config.go index 4c2c97f..678de75 100644 --- a/internal/ajean/mcp_config.go +++ b/internal/ajean/mcp_config.go @@ -112,11 +112,6 @@ func (c MCPServerConfig) Validate() error { return nil } -// mcpConfigFile est la représentation sur disque de mcp.json. -type mcpConfigFile struct { - MCPServers map[string]MCPServerConfig `json:"mcpServers"` -} - // mcpConfigMu sérialise les accès concurrents à la déclaration des serveurs // MCP (l'UI web et les tours de chat peuvent lire/écrire en parallèle). var mcpConfigMu sync.Mutex diff --git a/internal/ajean/relay_e2e.go b/internal/ajean/relay_e2e.go index 735ba25..3c8f0c7 100644 --- a/internal/ajean/relay_e2e.go +++ b/internal/ajean/relay_e2e.go @@ -257,34 +257,3 @@ func writeE2EResp(w http.ResponseWriter, gcm cipher.AEAD, status int, body []byt w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(sealed))) } - -// e2eSeal (côté agent : utilisé uniquement pour les tests) scelle un secret vers -// une clé publique X25519. La version navigateur est en WASM (wasmclient). -func e2eSeal(agentPub, secret []byte) ([]byte, error) { - pub, err := ecdh.X25519().NewPublicKey(agentPub) - if err != nil { - return nil, err - } - eph, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - return nil, err - } - shared, err := eph.ECDH(pub) - if err != nil { - return nil, err - } - key := sealKey(shared, eph.PublicKey().Bytes(), agentPub) - gcm, err := newGCM(key) - if err != nil { - return nil, err - } - nonce := make([]byte, 12) - if _, err := rand.Read(nonce); err != nil { - return nil, err - } - ct := gcm.Seal(nil, nonce, secret, nil) - out := append([]byte{}, eph.PublicKey().Bytes()...) - out = append(out, nonce...) - out = append(out, ct...) - return out, nil -} diff --git a/internal/ajean/run.go b/internal/ajean/run.go index da539f8..2d2eb1d 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.7.13" +const Version = "0.8.0" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/sys_brand_icon.go b/internal/ajean/sys_brand_icon.go index be375f5..1a63afc 100644 --- a/internal/ajean/sys_brand_icon.go +++ b/internal/ajean/sys_brand_icon.go @@ -32,7 +32,7 @@ var glyphRects = [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}} // brandIconImage dessine l'icône à la taille n. bg peint le carré arrondi, fg le // « j ». Les deux peuvent être transparents : c'est ce qui produit l'icône -// « template » de macOS (voir brandTemplatePNG). +// « template » de macOS. func brandIconImage(n int, bg, fg color.RGBA) *image.RGBA { const r = 2.0 // rayon des coins, en unités de la grille 12 outside := func(gx, gy float64) bool { @@ -84,14 +84,6 @@ func encodePNG(img *image.RGBA) []byte { // Exporté pour le générateur d'icône du .exe (tools/gen-icon). func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) } -// brandTemplatePNG rend la variante « template » attendue par macOS : seule la -// couche alpha compte, le système colore la forme selon le thème de la barre de -// menus. Le « j » est donc DÉCOUPÉ (transparent) dans un carré opaque — sans ça, -// une icône entièrement noire disparaît sur une barre de menus sombre. -func brandTemplatePNG(n int) []byte { - return encodePNG(brandIconImage(n, brandBlack, brandClear)) -} - // BrandICO emballe une ou plusieurs tailles PNG dans un conteneur .ico. Windows // accepte le PNG comme image d'une entrée .ico (alpha conservé pour les coins // arrondis). Plusieurs tailles = un rendu net partout, de la barre des tâches diff --git a/internal/ajean/sys_platform_unix.go b/internal/ajean/sys_platform_unix.go index 42416e6..103f7d1 100644 --- a/internal/ajean/sys_platform_unix.go +++ b/internal/ajean/sys_platform_unix.go @@ -16,9 +16,8 @@ import ( "time" ) -// defaultAjeanHome is the data root when neither $AJEAN_HOME/$AJEAN_HOME nor -// /etc/default/* override it. legacyDefaultHome est son équivalent d'avant le -// renommage, migré au premier lancement (voir sys_migrate.go). +// defaultAjeanHome est la racine des données quand ni $AJEAN_HOME ni +// /etc/default/ajean n'en imposent une. // // Linux : /etc/ajean (machine de prod, service systemd lancé par root). // macOS : /etc/ajean n'est PAS écrivable par une app lancée depuis le Finder @@ -27,8 +26,6 @@ import ( // un `sudo ajean install` a déjà créé /etc/ajean à notre nom. func defaultAjeanHome() string { return unixHome("ajean") } -func legacyDefaultHome() string { return unixHome("ajean") } - func unixHome(name string) string { if runtime.GOOS != "darwin" || os.Geteuid() == 0 { return "/etc/" + name diff --git a/internal/ajean/sys_platform_windows.go b/internal/ajean/sys_platform_windows.go index 32bfe7c..2064385 100644 --- a/internal/ajean/sys_platform_windows.go +++ b/internal/ajean/sys_platform_windows.go @@ -54,12 +54,9 @@ func homeRoot() string { return os.TempDir() } -// defaultAjeanHome is the data root when no AJEAN_HOME/AJEAN_HOME is set. +// defaultAjeanHome est la racine des données quand $AJEAN_HOME est absent. func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") } -// legacyDefaultHome is the pre-rename data root, migrated on first run. -func legacyDefaultHome() string { return filepath.Join(homeRoot(), "ajean") } - // defaultEditor is used by `ajean edit` when $EDITOR is unset. func defaultEditor() string { return "notepad" } diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index c03f443..d3edac5 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -22,7 +22,7 @@ garde `display-mode` en second pour les PWA Android/desktop à manifeste. --> - + @@ -180,7 +180,7 @@ trois îlots pour un même réglage. -->
    -
    Adresse locale (réseau) — modèle jean
    +
    Adresse locale (réseau) — modèle ajean
    @@ -210,7 +210,7 @@
    Accès distant -
    ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
    +
    ajean.link?Accédez à ce serveur AJEAN depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.

    Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

    diff --git a/internal/ajean/ui/src/js/00-md.js b/internal/ajean/ui/src/js/00-md.js index 5576741..a8c52a2 100644 --- a/internal/ajean/ui/src/js/00-md.js +++ b/internal/ajean/ui/src/js/00-md.js @@ -56,13 +56,13 @@ function toggleSide(){ document.getElementById('side').classList.toggle('open'); let _sysT = null; function saveSys(){ const v = document.getElementById('sysprompt').value; - localStorage.setItem('jean.sys', v); + localStorage.setItem('ajean.sys', v); clearTimeout(_sysT); _sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600); } async function loadSys(){ try{ const d = await jget('/api/sysprompt'); - if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('jean.sys', d.text || ''); } + if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('ajean.sys', d.text || ''); } }catch(e){} } diff --git a/internal/ajean/ui/src/js/01-theme.js b/internal/ajean/ui/src/js/01-theme.js index ef59a87..90bf3b1 100644 --- a/internal/ajean/ui/src/js/01-theme.js +++ b/internal/ajean/ui/src/js/01-theme.js @@ -9,7 +9,7 @@ function applyTheme(id){ // désormais la variante, déduite du suffixe. const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark')); document.documentElement.setAttribute('data-theme', dark?'dark':'light'); - try{ localStorage.setItem('jean-theme', dark?'dark':'light'); }catch(e){} + try{ localStorage.setItem('ajean-theme', dark?'dark':'light'); }catch(e){} const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark; } function toggleThemeDark(){ @@ -17,7 +17,7 @@ function toggleThemeDark(){ savePrefs(); } function initTheme(){ - let id='light'; try{ id=localStorage.getItem('jean-theme')||'light'; }catch(e){} + let id='light'; try{ id=localStorage.getItem('ajean-theme')||'light'; }catch(e){} applyTheme(id); } // ===== Affichage ============================================================ @@ -34,7 +34,7 @@ const VIEW_OPTS=[ function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; } function applyView(id, on){ document.documentElement.setAttribute('data-'+id, on?'1':'0'); - try{ localStorage.setItem('jean-'+id, on?'1':'0'); }catch(e){} + try{ localStorage.setItem('ajean-'+id, on?'1':'0'); }catch(e){} const box=document.getElementById('view-'+id); if(box) box.checked=!!on; // La barre latérale peut rester ouverte quand on repasse en mode fixe. if(id==='hide-side' && !on){ @@ -46,7 +46,7 @@ function applyView(id, on){ function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); } function initView(){ VIEW_OPTS.forEach(o=>{ - let v=null; try{ v=localStorage.getItem('jean-'+o.id); }catch(e){} + let v=null; try{ v=localStorage.getItem('ajean-'+o.id); }catch(e){} applyView(o.id, v==='1'); }); } diff --git a/internal/ajean/ui/src/js/02-prefs.js b/internal/ajean/ui/src/js/02-prefs.js index 072ee7a..cab0b73 100644 --- a/internal/ajean/ui/src/js/02-prefs.js +++ b/internal/ajean/ui/src/js/02-prefs.js @@ -1,11 +1,11 @@ // ===== Persistance côté serveur (partagée entre appareils) ================== -// L'apparence est aussi enregistrée sur la machine jean (/api/prefs) : ainsi le +// L'apparence est aussi enregistrée sur la machine ajean (/api/prefs) : ainsi le // thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le // localStorage reste utilisé pour appliquer instantanément au chargement (sans // flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité). function savePrefs(){ let theme='light'; - try{ theme=localStorage.getItem('jean-theme')||'light'; }catch(e){} + try{ theme=localStorage.getItem('ajean-theme')||'light'; }catch(e){} const p={theme}; VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; }); jpost('/api/prefs', p).catch(()=>{}); @@ -27,4 +27,4 @@ async function loadPrefs(){ } }catch(e){} } -document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); }); +document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('ajean.sys') || ''; loadSys(); restoreChat(); }); diff --git a/internal/ajean/ui/src/js/03-ui-kit.js b/internal/ajean/ui/src/js/03-ui-kit.js index 12acac1..6c3560a 100644 --- a/internal/ajean/ui/src/js/03-ui-kit.js +++ b/internal/ajean/ui/src/js/03-ui-kit.js @@ -49,7 +49,7 @@ function _askKey(e){ else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); } } function _openAsk(kind, message, opts){ - // Compat 2 conventions : (message, opts) [UI jean] ET l'objet unique + // Compat 2 conventions : (message, opts) [UI ajean] ET l'objet unique // {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html, // où cette fonction remplace le window.askConfirm du bootstrap boîte noire). if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; } diff --git a/internal/ajean/ui/src/js/04-api.js b/internal/ajean/ui/src/js/04-api.js index ede9e28..6b3dcbb 100644 --- a/internal/ajean/ui/src/js/04-api.js +++ b/internal/ajean/ui/src/js/04-api.js @@ -1,13 +1,13 @@ // 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') || ''; +let TOKEN = localStorage.getItem('ajean.key') || ''; function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; } // Base d'URL : "" en local (page à "/"), "/u/" servie via le relais ajean.link. // Garde les chemins absolus (/api/…) corrects derrière le tunnel. const API_BASE = location.pathname.replace(/\/(index\.html)?$/, ''); // Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le // navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint -// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS : +// dès qu'on laisse traîner plusieurs onglets AJEAN) reste suspendue POUR TOUJOURS : // ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher. // Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal // et ne sont donc jamais concernés. @@ -30,8 +30,8 @@ async function jfetch(u, opts){ } finally{ if(timer) clearTimeout(timer); } if(r.status === 401){ - const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'}); - if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } + const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'}); + if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } } return r; } diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index 0b22d1d..09bd086 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -9,7 +9,7 @@ const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list function reserveHeights(){ HRESERVE.forEach(id=>{ try{ - const h=parseInt(localStorage.getItem('jean-h-'+id)||'0',10); + const h=parseInt(localStorage.getItem('ajean-h-'+id)||'0',10); const el=document.getElementById(id); if(el && h>0) el.style.minHeight=h+'px'; }catch(e){} @@ -19,7 +19,7 @@ function releaseHeights(){ HRESERVE.forEach(id=>{ const el=document.getElementById(id); if(!el) return; el.style.minHeight=''; - try{ localStorage.setItem('jean-h-'+id, String(el.offsetHeight||0)); }catch(e){} + try{ localStorage.setItem('ajean-h-'+id, String(el.offsetHeight||0)); }catch(e){} }); } document.addEventListener('DOMContentLoaded', reserveHeights); @@ -98,7 +98,7 @@ async function loadPresets(){ }); } // « Mode agent » = accès machine + skills réunis en un seul interrupteur. -// Quand il est actif, un « a » blanc apparaît en fondu devant « jean » → « ajean ». +// Quand il est actif, un « a » blanc apparaît en fondu devant « ajean » → « ajean ». async function loadAgent(){ const s=await jget('/api/agent'); const on = s.enabled; diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 6b20b8b..cffd025 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -194,7 +194,7 @@ async function populateModelPicker(){ const list = await jget('/api/models'); const cur = currentModelInTextarea(); // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les - // regroupe par dossier, JEAN_HOME d'abord (l'API les renvoie dans cet ordre). + // regroupe par dossier, AJEAN_HOME d'abord (l'API les renvoie dans cet ordre). const groups = []; for(const m of (list||[])){ let g = groups.find(x => x.dir === m.dir); @@ -213,7 +213,7 @@ async function populateModelPicker(){ opts += ''; } html += groups.length > 1 - ? ''+opts+'' + ? ''+opts+'' : opts; } // MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré : @@ -224,7 +224,7 @@ async function populateModelPicker(){ sel.innerHTML = html; if(cur && !matched) setModelDirsOpen(true); } -// ---- Dossiers de modèles : JEAN_HOME + dossiers ajoutés (disque externe…) --- +// ---- Dossiers de modèles : AJEAN_HOME + dossiers ajoutés (disque externe…) --- // Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. function setModelDirsOpen(open){ const b = document.getElementById('m-dirs-body'); @@ -253,7 +253,7 @@ async function populateModelDirs(){ info.className = 'muted'; const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : ''; - info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')'; + info.textContent = '(' + n + free + (x.home ? ', dossier ajean' : '') + ')'; row.append(p, info); if(!x.home){ const del = document.createElement('button'); @@ -324,13 +324,13 @@ function underDir(p, dir){ } let beFastPath = '', beOptPath = '', beFastDir = ''; async function populateBackend(){ - // Chemins des deux moteurs gérés + liste des backends détectés (dossier jean). + // Chemins des deux moteurs gérés + liste des backends détectés (dossier ajean). let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){} beFastPath = (lc.prebuilt && lc.prebuilt.bin) || ''; beFastDir = (lc.prebuilt && lc.prebuilt.dir) || ''; beOptPath = lc.bin || ''; // Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans - // le dossier backends de jean (l'utilisateur peut y déposer son propre build). + // le dossier backends de ajean (l'utilisateur peut y déposer son propre build). const detected = await jget('/api/backends'); const sel = document.getElementById('m-backend-detected'); let html = ''; @@ -664,7 +664,7 @@ function populateSettings(){ // --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ---------------- // Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée // par des virgules ; une valeur composée (ou un type sorti après cette version -// de jean) ne correspondrait à aucune option et serait silencieusement effacée +// de ajean) ne correspondrait à aucune option et serait silencieusement effacée // au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de // la perdre. function setSpecType(v){ @@ -860,7 +860,7 @@ function watchDownload(fname){ e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); await Promise.all([populateModelPicker(), populateDlDirs()]); - // Le modèle a pu atterrir hors du dossier jean : l'option porte alors le + // Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le // chemin complet, pas le simple nom de fichier. const sel = document.getElementById('m-model'); const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); diff --git a/internal/ajean/ui/src/js/08-chat-render.js b/internal/ajean/ui/src/js/08-chat-render.js index 992e869..1f5e890 100644 --- a/internal/ajean/ui/src/js/08-chat-render.js +++ b/internal/ajean/ui/src/js/08-chat-render.js @@ -115,7 +115,7 @@ function setLabelCounts(el, add, del){ if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); lab.appendChild(cnt); } -// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN +// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/AJEAN // sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait // avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée // par la préférence d'affichage « masquer la vitesse de génération » (CSS). @@ -276,7 +276,7 @@ async function compactContext(){ } // Persistance de la conversation : on garde user+assistant en localStorage pour // survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées). -function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)); }catch(e){} } +function saveChat(){ try{ localStorage.setItem('ajean.chat', JSON.stringify(msgs)); }catch(e){} } // Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement // permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte, // appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de diff --git a/internal/ajean/ui/src/js/09-stream.js b/internal/ajean/ui/src/js/09-stream.js index e7c422a..0e8777b 100644 --- a/internal/ajean/ui/src/js/09-stream.js +++ b/internal/ajean/ui/src/js/09-stream.js @@ -1,5 +1,5 @@ // ===== Conversation SERVEUR (source de vérité, partagée entre appareils) ===== -// L'historique et la génération vivent sur le serveur jean. Le client ouvre un +// L'historique et la génération vivent sur le serveur ajean. Le client ouvre un // flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis // suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // serveur) ; se reconnecter rejoue tout le fil, détails compris. @@ -197,7 +197,7 @@ function handleDelta(d){ } // Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de // re-téléchargement complet après une coupure / bascule d'appareil). -// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé +// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet AJEAN laissé // ouvert monopolise une des ~6 connexions simultanées autorisées par domaine : // au-delà, toute requête (journal, installation du moteur…) reste en file // d'attente sans jamais partir ni échouer — un blocage silencieux très diff --git a/internal/ajean/ui/src/js/13-remote.js b/internal/ajean/ui/src/js/13-remote.js index cc158fa..ac2f4fa 100644 --- a/internal/ajean/ui/src/js/13-remote.js +++ b/internal/ajean/ui/src/js/13-remote.js @@ -1,7 +1,7 @@ // Accès distant (ajean.link) — piloté depuis l'UI, sans terminal. // « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte + // abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à -// l'agent local (/api/link/connect) qui fait le `jean link` (token + service). +// l'agent local (/api/link/connect) qui fait le `ajean link` (token + service). const AJEAN_APP_ORIGIN = 'https://app.ajean.link'; diff --git a/internal/ajean/ui/src/styles.css b/internal/ajean/ui/src/styles.css index a4a8704..1f8f7a6 100644 --- a/internal/ajean/ui/src/styles.css +++ b/internal/ajean/ui/src/styles.css @@ -146,7 +146,7 @@ h1{margin:0;font-size:18px;color:var(--accent)} .ask-ok.danger{border-color:var(--err);color:var(--err)} #brand-a{display:inline-flex;line-height:0;color:var(--text);margin-right:2px} /* Mode agent : le A passe en couleur d'accent (il était masqué hors agent, - la marque se lisant alors « JEAN »). L'état agent reste signalé par sa + la marque se lisant alors « AJEAN »). L'état agent reste signalé par sa pastille dans les réglages. */ #brand.agent #brand-a{color:var(--accent)} /* interrupteur (switch) */ @@ -935,7 +935,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} /* ===== Mise en page de la conversation ============================= Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond), question de l'utilisateur en pastille arrondie à droite, composer en - « pilule », pas d'étiquettes VOUS/JEAN. Ces règles surchargent la mise en + « pilule », pas d'étiquettes VOUS/AJEAN. Ces règles surchargent la mise en page générique plus haut : elles doivent donc rester APRÈS elle. */ body{font-size:15px} h1{color:var(--text);font-weight:600;letter-spacing:-.01em} @@ -949,7 +949,7 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em} /* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */ #chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6} #chat .msg.user .label{color:var(--dim)} -/* Étiquettes VOUS / JEAN : inutiles ici (l'alignement suffit), sauf pour les +/* Étiquettes VOUS / AJEAN : inutiles ici (l'alignement suffit), sauf pour les bulles repliables (raisonnement / outils) où le label est le bouton de repli. */ #chat .msg .label{display:none} #chat .msg.collapsible .label{display:block} From 47c27499ae24c5bfc56e48d3867eeadc8ab14963 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:22:31 +0200 Subject: [PATCH 232/279] Base ouverte a la demande, pas gardee ouverte bbolt pose un verrou exclusif sur son fichier : le service de lien, qui tourne en permanence, empechait toute commande CLI de s'executer a cote. La base est desormais ouverte pour la duree d'une operation puis refermee, et les buckets ne sont crees qu'a l'ecriture (une lecture ne coute plus un fsync : 3,8 ms -> 0,55 ms). Test de concurrence a l'appui. --- internal/ajean/store.go | 149 ++++++++++++++---------------- internal/ajean/store_conc_test.go | 35 +++++++ internal/ajean/store_test.go | 6 +- 3 files changed, 104 insertions(+), 86 deletions(-) create mode 100644 internal/ajean/store_conc_test.go diff --git a/internal/ajean/store.go b/internal/ajean/store.go index faf74f9..f020a86 100644 --- a/internal/ajean/store.go +++ b/internal/ajean/store.go @@ -33,77 +33,75 @@ const ( bkChat = "chat" // conversation partagée ) -// Les connexions sont mises en cache PAR CHEMIN, et non dans un singleton. -// $AJEAN_HOME peut désigner un autre dossier d'un appel à l'autre — c'est le cas -// dans les tests, qui donnent à chacun son dossier temporaire. Un singleton -// figerait la toute première base et tous les appels suivants écriraient dans le -// mauvais fichier, sans erreur et sans bruit. -var ( - dbMu sync.Mutex - dbConns = map[string]*bolt.DB{} -) +// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré. +// +// bbolt pose un verrou EXCLUSIF sur son fichier tant qu'il est ouvert. Or une +// machine installée fait tourner en permanence le service de lien, qui sert le +// tunnel et l'UI : s'il gardait la base ouverte, plus une seule commande ne +// fonctionnerait à côté — « ajean status », « ajean switch », « ajean edit » +// échoueraient toutes sur un délai d'attente, sur la machine même où tout est +// censé marcher. On ouvre donc pour la durée d'une opération, puis on referme. +// +// Le coût est celui d'un open+close sur un fichier de quelques dizaines de Ko, +// négligeable devant le moindre appel au modèle. Le délai d'attente absorbe la +// contention entre process ; dbMu la sérialise à l'intérieur du process. +var dbMu sync.Mutex func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") } -// db ouvre la base (ou renvoie celle déjà ouverte pour ce chemin) et la garde -// ouverte pour la durée du process. bbolt prend un verrou exclusif sur le -// fichier : le délai d'attente évite qu'un second process (l'app lancée deux -// fois) se bloque indéfiniment. -func db() (*bolt.DB, error) { +// withDB ouvre la base, exécute fn, puis referme — toujours, même en erreur. +func withDB(fn func(*bolt.DB) error) error { path := dbPath() dbMu.Lock() defer dbMu.Unlock() - if d, ok := dbConns[path]; ok { - return d, nil - } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, err + return err } - d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 3 * time.Second}) + d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 5 * time.Second}) if err != nil { - return nil, fmt.Errorf("base %s inaccessible : %w", path, err) + return fmt.Errorf("base %s inaccessible : %w", path, err) } - err = d.Update(func(tx *bolt.Tx) error { - for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} { - if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil { + defer d.Close() + return fn(d) +} + +// view exécute une lecture. Un bucket encore absent (base neuve) est traité +// comme vide : les buckets ne sont créés QU'À l'écriture, pour qu'une simple +// lecture n'ouvre jamais de transaction d'écriture — elle coûterait un fsync, +// et les lectures sont de loin les plus fréquentes. +func view(bucket string, fn func(b *bolt.Bucket) error) error { + return withDB(func(d *bolt.DB) error { + return d.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucket)) + if b == nil { + return nil + } + return fn(b) + }) + }) +} + +// update exécute une écriture, en créant le bucket au besoin. +func update(bucket string, fn func(b *bolt.Bucket) error) error { + return withDB(func(d *bolt.DB) error { + return d.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(bucket)) + if err != nil { return err } - } - return nil + return fn(b) + }) }) - if err != nil { - d.Close() - return nil, err - } - dbConns[path] = d - return d, nil } // getBytes lit une valeur. Une base inaccessible se comporte comme une base // vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile // face à une erreur d'E/S, et tous ont déjà un défaut. -// closeDB ferme la base ouverte pour le dossier courant, s'il y en a une. bbolt -// garde un verrou exclusif sur son fichier tant qu'il est ouvert : sans ça, -// impossible de supprimer ou déplacer le dossier de données sous Windows. -func closeDB() { - path := dbPath() - dbMu.Lock() - defer dbMu.Unlock() - if d, ok := dbConns[path]; ok { - _ = d.Close() - delete(dbConns, path) - } -} - func getBytes(bucket, key string) []byte { - d, err := db() - if err != nil { - return nil - } var out []byte - _ = d.View(func(tx *bolt.Tx) error { - if v := tx.Bucket([]byte(bucket)).Get([]byte(key)); v != nil { + _ = view(bucket, func(b *bolt.Bucket) error { + if v := b.Get([]byte(key)); v != nil { out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction } return nil @@ -113,12 +111,7 @@ func getBytes(bucket, key string) []byte { // putBytes écrit une valeur. Une valeur nil supprime la clé. func putBytes(bucket, key string, val []byte) error { - d, err := db() - if err != nil { - return err - } - return d.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(bucket)) + return update(bucket, func(b *bolt.Bucket) error { if val == nil { return b.Delete([]byte(key)) } @@ -168,12 +161,8 @@ func putJSON(bucket, key string, v any) error { // les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts). func allKV(bucket string) map[string]string { m := map[string]string{} - d, err := db() - if err != nil { - return m - } - _ = d.View(func(tx *bolt.Tx) error { - return tx.Bucket([]byte(bucket)).ForEach(func(k, v []byte) error { + _ = view(bucket, func(b *bolt.Bucket) error { + return b.ForEach(func(k, v []byte) error { m[string(k)] = string(v) return nil }) @@ -185,26 +174,24 @@ func allKV(bucket string) map[string]string { // C'est ce qu'exige l'application d'un preset : à aucun instant la config ne // doit être un mélange de l'ancienne et de la nouvelle. func replaceKV(bucket string, m map[string]string) error { - d, err := db() - if err != nil { - return err - } - return d.Update(func(tx *bolt.Tx) error { - if err := tx.DeleteBucket([]byte(bucket)); err != nil { - return err - } - b, err := tx.CreateBucket([]byte(bucket)) - if err != nil { - return err - } - for k, v := range m { - if v == "" { - continue - } - if err := b.Put([]byte(k), []byte(v)); err != nil { + return withDB(func(d *bolt.DB) error { + return d.Update(func(tx *bolt.Tx) error { + if err := tx.DeleteBucket([]byte(bucket)); err != nil && err != bolt.ErrBucketNotFound { return err } - } - return nil + b, err := tx.CreateBucket([]byte(bucket)) + if err != nil { + return err + } + for k, v := range m { + if v == "" { + continue + } + if err := b.Put([]byte(k), []byte(v)); err != nil { + return err + } + } + return nil + }) }) } diff --git a/internal/ajean/store_conc_test.go b/internal/ajean/store_conc_test.go new file mode 100644 index 0000000..50eda6e --- /dev/null +++ b/internal/ajean/store_conc_test.go @@ -0,0 +1,35 @@ +package ajean + +import ( + "sync" + "testing" + "time" +) + +// Deux « process » logiques qui martèlent la base en parallèle ne doivent ni +// s'exclure ni perdre d'écriture : c'est le scénario du serveur, où le service +// de lien tourne pendant qu'on tape des commandes. +func TestAccesConcurrent(t *testing.T) { + testHome(t) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + for j := 0; j < 20; j++ { + _ = SetConfigKey("K", "v") + _ = ReadConfig() + } + }(i) + } + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("blocage sous accès concurrent") + } + if ReadConfig()["K"] != "v" { + t.Fatal("écriture perdue") + } +} diff --git a/internal/ajean/store_test.go b/internal/ajean/store_test.go index 2862036..fcd9ec8 100644 --- a/internal/ajean/store_test.go +++ b/internal/ajean/store_test.go @@ -2,15 +2,11 @@ package ajean import "testing" -// testHome donne au test un $AJEAN_HOME à lui et ferme la base à la fin. -// -// La fermeture n'est pas une politesse : bbolt garde un verrou exclusif sur son -// fichier, et sous Windows le ménage de t.TempDir() échoue tant qu'il est ouvert. +// testHome donne au test un $AJEAN_HOME à lui. func testHome(t *testing.T) string { t.Helper() home := t.TempDir() t.Setenv("AJEAN_HOME", home) - t.Cleanup(closeDB) return home } From 24ff2f0a258decacd5d7f565e831d8ac1b89ebe9 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:39:46 +0200 Subject: [PATCH 233/279] Deux services nommes ajean-engine et ajean-ui, une seule porte web - ajean.service -> ajean-engine, ajean-link.service -> ajean-ui - 'ajean web' est desormais l'unique serveur d'interface : il ouvre aussi le tunnel quand un jeton est enregistre (une seule conversation, plus de conflit sur :8090 ni de fils divergents) - 'ajean link' ne gere plus que le compte ; 'ajean ui' pilote le service - l'installateur Linux pose les deux unites et les deux regles sudoers - 'app' sort de l'aide --- README.md | 40 ++--- RELEASE_NOTES.md | 12 +- internal/ajean/cli_app.go | 2 +- internal/ajean/relay_link.go | 241 +++++++++++----------------- internal/ajean/run.go | 25 +-- internal/ajean/sys_install_linux.go | 66 ++++++-- internal/ajean/sys_update.go | 6 +- internal/ajean/web_link_api.go | 20 +-- internal/ajean/web_server.go | 17 ++ 9 files changed, 220 insertions(+), 209 deletions(-) diff --git a/README.md b/README.md index cd6679b..0437fec 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur **Le matériel et le moteur, gérés pour vous.** `ajean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU fonctionne), ROCm, Metal, Vulkan, ou repli CPU. `ajean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre. -**Un service, pas un script.** `ajean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). +**Des services, pas des scripts.** `ajean install` écrit les deux unités systemd, les règles sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). **Plusieurs modèles, un clic.** Les presets gardent chacun leur configuration complète ; basculer de l'un à l'autre recharge le modèle sans toucher à un fichier. Les `.gguf` peuvent vivre sur n'importe quel disque. @@ -61,48 +61,50 @@ Nécessite `git` et `cmake`, plus le toolkit de l'accélérateur (CUDA, ROCm…) ```bash ajean edit # régler MODEL=/chemin/vers/le-modele.gguf -ajean start +ajean start # démarre le moteur (ajean-engine) ajean test # vérifier que le modèle répond -ajean web # http://:8090 +ajean ui start # démarre l'interface (ajean-ui) sur http://:8090 ``` +AJEAN tourne en **deux services** : `ajean-engine`, qui exécute le modèle, et +`ajean-ui`, qui sert l'interface web, le tunnel d'accès distant et l'endpoint +OpenAI. Les séparer permet de redémarrer l'interface — ce qui est instantané — +sans recharger des dizaines de gigaoctets de modèle. + ## Commandes ``` -Application : - app lance l'UI web + ouvre le navigateur (auto au double-clic) - web [PORT] UI web seule (défaut :8090) - chat [prompt-système] chat dans le terminal - -Service : +Moteur (ajean-engine) : start | stop | restart gérer le service status | logs état / logs en direct enable | disable démarrage au boot edit éditer la configuration dans $EDITOR + switch [N] changer de preset (presets/) test | bench [N] vérifier que le modèle répond / mesurer les tok/s vram | gpu [index…] VRAM / choix des GPU (gpu all = tous) set-api-key [clé] protéger le moteur d'inférence (Bearer) + llamacpp install|update|status + +Interface (ajean-ui) : + ui [start|stop|restart|status] piloter le service d'interface + web [PORT] servir l'interface au premier plan (défaut :8090) set-web-key [clé] protéger l'API de pilotage Capacités de l'IA : + chat [prompt-système] chat dans le terminal agent [on|off|status] active TOUS les outils (terminal, fichiers, mémoire) memory [off|ondemand|always] mode mémoire internet [on|off|engine |url |key ] accès web -Presets et moteur : - switch [N] changer de preset (presets/) - llamacpp install|update|status - Accès distant (ajean.link) : - link enregistre le token et démarre le lien - link start|restart|stop gérer le service de lien + link enregistre le jeton et ouvre le tunnel link code code d'appairage (10 min, usage unique) link status|logout Installation : install | uninstall update [--check] mise à jour depuis les releases GitHub - version + where | version ``` ## Configuration @@ -141,7 +143,7 @@ La clé API (`ajean set-api-key`) est rangée hors de la configuration, afin de |----------|------|--------| | `AJEAN_HOME` | racine des données | `/etc/ajean`, `%ProgramData%\ajean` | | `AJEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — | -| `AJEAN_SERVICE` | nom de l'unité systemd | `ajean` | +| `AJEAN_SERVICE` | nom de l'unité du moteur | `ajean-engine` | | `HF_TOKEN` | token Hugging Face pour les modèles privés | — | | `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — | | `EDITOR` | éditeur pour `ajean edit` | `nano` / `notepad` | @@ -232,7 +234,7 @@ ajean link # token fourni sur ajean.link ajean link code # code d'appairage à saisir dans le portail ``` -Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `ajean web`, l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. +Le tunnel n'est pas un service à part : il est ouvert par `ajean-ui`, le service qui sert déjà l'interface, dès qu'un jeton est enregistré. Un seul process sert donc l'interface locale et l'accès distant — c'est ce qui garantit **une seule conversation**, identique sur les deux. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. Il s'agit d'un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit. @@ -255,7 +257,7 @@ Le VPS effectue un simple **passthrough SNI** : le TLS est terminé sur la machi ## API de pilotage -`ajean web` expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition : +Le service d'interface expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition : ```bash ajean set-web-key # génère une clé diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 14dc299..d69c6c0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -14,10 +14,20 @@ Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardé `$AJEAN_HOME` contient exactement `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. +## Deux services qui portent enfin leur nom + +`ajean.service` et `ajean-link.service` deviennent **`ajean-engine`** et **`ajean-ui`**. Le premier exécute le modèle, le second sert l'interface web, le tunnel d'accès distant et l'endpoint OpenAI. Le nom « link » cachait l'essentiel : ce service est d'abord le serveur web. + +Surtout, il n'y a plus **qu'une seule façon** de servir l'interface. Avant, `ajean web` et `ajean link serve` savaient tous deux le faire, ce qui posait un piège permanent : lancer les deux, c'était un conflit sur le port 8090 et surtout deux fils de conversation qui divergeaient — la conversation vit en mémoire, deux process qui la servent finissent par s'écraser l'un l'autre. La consigne « ne jamais lancer `ajean web` » circulait comme une règle à retenir ; elle n'existait que parce que le code offrait deux portes pour la même pièce. + +Désormais `ajean web` est cette porte unique : il sert l'interface et, si un jeton de liaison est enregistré, ouvre le tunnel dans le même process. `ajean link` ne s'occupe plus que du compte — jeton, code d'appairage, état — et `ajean ui start|stop|restart|status` pilote le service. `ajean link serve`, `link start`, `link stop` et `link restart` disparaissent. + +Séparer les deux services garde son intérêt : redémarrer l'interface est instantané, alors que redémarrer le moteur recharge des dizaines de gigaoctets. + ## Ce qui a été retiré Tout le code écrit pour ménager les installations « jean » : la migration du dossier de données et ses reprises après échec, la migration de l'agencement système (unités, `/etc/default`, réécriture des chemins), l'élévation Windows qu'elle demandait, la résolution du nom d'unité réellement installée, la reprise des fichiers PID et des skills, les alias `jean` posés à l'installation, les variables `JEAN_*` lues en second. -La CLI perd ses alias hérités — `skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama` — et son aide est réorganisée. Chaque commande a désormais un seul nom. +La CLI perd ses alias hérités — `skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama` — et son aide est réorganisée autour des deux services. Chaque commande a désormais un seul nom. `app` quitte l'aide : c'est le comportement du double-clic, qu'on n'atteint pas en tapant son nom. Les releases ne publient plus qu'un jeu de binaires, `ajean--`. La double publication qui accompagnait la transition n'a plus d'objet. diff --git a/internal/ajean/cli_app.go b/internal/ajean/cli_app.go index cb0d71a..9b74f44 100644 --- a/internal/ajean/cli_app.go +++ b/internal/ajean/cli_app.go @@ -56,7 +56,7 @@ func cmdApp(args []string) error { // Worker détaché rescapé (version antérieure, autre copie de l'app) : il // possède SA propre conversation et écrase la nôtre. On le supprime avant // de prendre la main. - killForeignLinkWorker() + killForeignUIWorker() startAppLink(appWebMux) }() diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 2fbaeaf..022e905 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -71,11 +71,16 @@ func relayURL() string { return defaultRelayURL } -// linkUnitName est l'unité qui exécute le worker « ajean link serve ». -const linkUnitName = "ajean-link" +// uiUnitName est l'unité qui exécute « ajean web » : l'UI locale, le tunnel du +// relais et l'endpoint OpenAI, dans un seul et même process. +const uiUnitName = "ajean-ui" -func linkServiceName() string { return linkUnitName } +func uiServiceName() string { return uiUnitName } +// cmdLink ne gère QUE le compte d'accès distant : le jeton, l'appairage, l'état. +// Le tunnel n'est plus un service à part — il est ouvert par « ajean web » dès +// qu'un jeton est enregistré. Piloter le tunnel, c'est donc piloter le service +// d'interface (voir cmdUI). func cmdLink(args []string) error { sub := "" if len(args) > 0 { @@ -83,40 +88,29 @@ func cmdLink(args []string) error { } switch sub { case "": - // `ajean link` seul : afficher l'aide des sous-commandes (NE démarre pas — - // éviter de prendre un mot pour un token et d'écraser le vrai). + // `ajean link` seul : afficher l'aide (NE démarre rien — éviter de prendre + // un mot pour un jeton et d'écraser le vrai). printLinkHelp() return nil - case "serve": - // Le worker réel (boucle de connexion au relais), pendant de `ajean serve`. - // C'est ce que lance l'unité systemd ; il NE doit PAS rendre la main. - return runLinkForeground() - case "start": - return startLink(false) case "status": tok := readLinkToken() if tok == "" { - fmt.Println(yellow("[info]") + " aucun token enregistré — lance: ajean link ") + fmt.Println(yellow("[info]") + " aucun jeton enregistré — lance: ajean link ") return nil } - fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) - if linkServiceActive() { - fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName()) + fmt.Printf("%s jeton enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) + if uiServiceActive() { + fmt.Printf("%s service %s actif — le tunnel est ouvert\n", green("[ok]"), uiServiceName()) } else { - fmt.Printf("%s service %s: arrêté (ajean link pour démarrer)\n", yellow("[info]"), linkServiceName()) + fmt.Printf("%s service %s arrêté — pas de tunnel (ajean ui start)\n", yellow("[info]"), uiServiceName()) } return nil case "logout": - _ = removeLinkToken() - fmt.Println(green("[ok]") + " token supprimé") - return nil - case "stop": - return linkServiceCtl("stop") - case "restart": - if err := linkServiceCtl("restart"); err != nil { + if err := removeLinkToken(); err != nil { return err } - return linkPrintIdentity() + fmt.Println(green("[ok]") + " jeton supprimé — « ajean ui restart » pour fermer le tunnel") + return nil case "code": code, err := newPairCode() if err != nil { @@ -126,9 +120,9 @@ func cmdLink(args []string) error { return nil } - // Un argument restant n'est traité comme TOKEN que s'il en a la forme (`jl_…`). + // Un argument restant n'est traité comme JETON que s'il en a la forme (`jl_…`). // Sinon c'est une faute de frappe / sous-commande inconnue : on REFUSE, sans - // jamais écraser le token enregistré (le bug qui rendait le serveur injoignable). + // jamais écraser le jeton enregistré (le bug qui rendait le serveur injoignable). if !strings.HasPrefix(sub, "jl_") { fmt.Fprintf(os.Stderr, "%s sous-commande inconnue : %q\n\n", yellow("[link]"), sub) printLinkHelp() @@ -137,46 +131,52 @@ func cmdLink(args []string) error { if err := saveLinkToken(strings.TrimSpace(sub)); err != nil { return err } - // Nouveau token → on (re)démarre le worker pour qu'il le prenne en compte. - return startLink(true) -} - -// startLink démarre (ou redémarre si force) le service de lien puis affiche -// l'identité (empreinte + code). Si le service tourne déjà et qu'on ne force pas, -// on le signale au lieu d'un faux « démarré ». -func startLink(force bool) error { - if readLinkToken() == "" { - return fmt.Errorf("aucun token. Usage: ajean link (token fourni à l'achat sur la boutique)") - } - switch { - case force: - if err := linkServiceCtl("restart"); err != nil { - return err - } - case linkServiceActive(): - fmt.Printf("%s service %s déjà en cours — « ajean link restart » pour le relancer\n", yellow("[info]"), linkServiceName()) - default: - if err := linkServiceCtl("start"); err != nil { - return err - } + // Nouveau jeton : le service d'interface doit le relire pour ouvrir le tunnel. + if err := uiServiceCtl("restart"); err != nil { + return err } return linkPrintIdentity() } +// cmdUI pilote le service d'interface — « ajean web » en arrière-plan : l'UI +// locale, le tunnel du relais et l'endpoint OpenAI, servis par le MÊME process. +// C'est ce qui garantit une conversation unique, identique en local et à distance. +func cmdUI(args []string) error { + action := "status" + if len(args) > 0 && args[0] != "" { + action = args[0] + } + switch action { + case "start", "stop", "restart": + return uiServiceCtl(action) + case "status": + if uiServiceActive() { + fmt.Printf("%s service %s : actif\n", green("[ok]"), uiServiceName()) + } else { + fmt.Printf("%s service %s : arrêté\n", yellow("[info]"), uiServiceName()) + } + if readLinkToken() != "" { + fmt.Printf(" accès distant : jeton enregistré — le tunnel s'ouvre avec le service\n") + } + return nil + default: + return fmt.Errorf("usage: ajean ui [start|stop|restart|status]") + } +} + // printLinkHelp liste les sous-commandes de `ajean link`. func printLinkHelp() { fmt.Print(`ajean link — accès distant via le relais ajean.link Usage : - ajean link enregistre le token (1re fois / pour le changer) et démarre le lien - ajean link start démarre le lien en arrière-plan (service) - ajean link restart redémarre le service de lien - ajean link stop arrête le service de lien - ajean link status état du token et du service + ajean link enregistre le jeton (1re fois / pour le changer) et ouvre le tunnel + ajean link status état du jeton et du tunnel ajean link code génère un code d'appairage (valable 10 min, à usage unique) - ajean link logout oublie le token enregistré + ajean link logout oublie le jeton enregistré -Le token est fourni sur ajean.link. « ajean link » seul affiche cette aide. +Le jeton est fourni sur ajean.link. Le tunnel est ouvert par le service +d'interface (« ajean ui ») dès qu'un jeton est enregistré : il n'y a pas de +service séparé pour l'accès distant. `) } @@ -197,50 +197,6 @@ func linkPrintIdentity() error { return nil } -// runLinkForeground exécute la boucle de connexion au relais (le worker supervisé -// par systemd). Reconnexion automatique avec backoff. Ne rend jamais la main. -func runLinkForeground() error { - token := readLinkToken() - if token == "" { - return fmt.Errorf("aucun token. Usage: ajean link ") - } - fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL()) - fmt.Printf(" (UI AJEAN + endpoint OpenAI servis dans le tunnel — pas besoin de 'ajean web')\n") - if fp := e2eFingerprint(); fp != "" { - fmt.Printf("%s empreinte E2E : %s (code d'appairage : ajean link code)\n", green("[e2e]"), bold(fp)) - } - - // UNE seule instance du mux web → UNE seule conversation en mémoire, PARTAGÉE - // entre l'UI locale (:8090, ajean.n27.fr) et le tunnel (app.ajean.link). Avant, - // `ajean web` et `ajean link` étaient deux process distincts avec chacun leur - // historique → confusion. Désormais le process ajean-link est l'unique - // propriétaire de la conversation et sert les DEUX surfaces. - webMux := newWebMux() - go serveLocalWebMux(webMux) - // On construit le handler une seule fois ; il est servi à travers chaque tunnel. - handler := newLinkHandler(webMux) - // Front TLS de l'accès OpenAI public. Toujours prêt ; c'est le drapeau - // oaiPublicEnabled (piloté par l'UI, lu en direct au démux) qui autorise ou non - // le trafic. Cert obtenu à la demande via TLS-ALPN-01 à travers le tunnel. - oaiTLS := oaiTLSConfig() - fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled()) - - backoff := time.Second - for { - err := runLinkSession(token, handler, oaiTLS) - if err != nil { - fmt.Printf("%s lien perdu: %v — reconnexion dans %s\n", yellow("[link]"), err, backoff) - } - time.Sleep(backoff) - if backoff < 30*time.Second { - backoff *= 2 - if backoff > 30*time.Second { - backoff = 30 * time.Second - } - } - } -} - // --------------------------------------------------------------------------- // Tunnel DANS le process de l'app (macOS/Windows). // @@ -262,7 +218,7 @@ var appLink struct { } // appOwnsLink : vrai dans le process de l'app de bureau, qui pilote le tunnel -// en interne. linkServiceCtl s'y adapte pour ne PAS lancer de worker concurrent. +// en interne. uiServiceCtl s'y adapte pour ne PAS lancer de worker concurrent. // appWebMux est le mux servi par l'app — réutilisé pour le tunnel afin que les // deux surfaces partagent la même conversation. var ( @@ -327,45 +283,30 @@ func appLinkRunning() bool { return appLink.running } -// killForeignLinkWorker tue un worker de lien détaché encore en vie (laissé par +// killForeignUIWorker tue un worker de lien détaché encore en vie (laissé par // une version antérieure ou une autre copie de l'app). Indispensable AVANT que // l'app ne prenne la main : sinon deux process servent la même conversation et // les fils divergent entre l'UI locale et app.ajean.link. -func killForeignLinkWorker() { - if pid := linkUserPID(); pid > 0 { +func killForeignUIWorker() { + if pid := uiUserPID(); pid > 0 { killTree(pid) - _ = os.Remove(linkPIDPath()) + _ = os.Remove(uiPIDPath()) } } -// serveLocalWebMux sert l'UI web locale sur :8090 avec le mux fourni — le MÊME -// que celui exposé dans le tunnel. Le process ajean-link devient ainsi l'unique -// propriétaire de la conversation, partagée entre l'accès local et l'accès -// distant. Remplace le `ajean web` autonome (à ne plus lancer séparément). -func serveLocalWebMux(mux *http.ServeMux) { - addr := "0.0.0.0:8090" - ln, err := net.Listen("tcp", addr) - if err != nil { - fmt.Printf("%s UI locale non démarrée (%s occupé ? tue le vieux « ajean web ») : %v\n", yellow("[web]"), addr, err) - return - } - fmt.Printf("%s UI locale sur http://%s (même conversation que le tunnel)\n", green("[web]"), addr) - _ = http.Serve(ln, mux) -} - -// linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, +// uiPIDPath / uiLogPath : suivi du worker de lien hors systemd (macOS, // Windows), où AJEAN est une app de bureau lancée sans droits root. -func linkPIDPath() string { return filepath.Join(AjeanHome(), linkUnitName+".pid") } -func linkLogPath() string { return filepath.Join(AjeanHome(), linkUnitName+".log") } +func uiPIDPath() string { return filepath.Join(AjeanHome(), uiUnitName+".pid") } +func uiLogPath() string { return filepath.Join(AjeanHome(), uiUnitName+".log") } -// linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est +// uiServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est // l'unité systemd ajean-link (avec sudo non interactif si on n'est pas root) ; // ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance // « ajean link serve » en processus détaché suivi par un fichier PID, comme le // fait déjà le service principal. Sans ça, l'accès distant restait définitivement // « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne // composait jamais le tunnel. -func linkServiceCtl(action string) error { +func uiServiceCtl(action string) error { if runtime.GOOS != "linux" { // Dans l'app de bureau, le tunnel tourne DANS ce process (conversation // unique) : on ne lance surtout pas un worker séparé. @@ -375,7 +316,7 @@ func linkServiceCtl(action string) error { stopAppLink() case "start", "restart": stopAppLink() - killForeignLinkWorker() + killForeignUIWorker() startAppLink(appWebMux) if !appLinkRunning() { return fmt.Errorf("tunnel non démarré (clé de liaison absente ?)") @@ -385,43 +326,43 @@ func linkServiceCtl(action string) error { } return nil } - return linkUserSvcCtl(action) + return uiUserSvcCtl(action) } bin, pre := "systemctl", []string{} if os.Geteuid() != 0 { bin, pre = "sudo", []string{"-n", "systemctl"} } - cmd := exec.Command(bin, append(pre, action, linkServiceName())...) + cmd := exec.Command(bin, append(pre, action, uiServiceName())...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("systemctl %s %s: %w", action, linkServiceName(), err) + return fmt.Errorf("systemctl %s %s: %w", action, uiServiceName(), err) } switch action { case "start", "restart": - fmt.Printf("%s service %s %s\n", green("[ok]"), linkServiceName(), action+"é") + fmt.Printf("%s service %s %s\n", green("[ok]"), uiServiceName(), action+"é") case "stop": - fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName()) + fmt.Printf("%s service %s arrêté\n", green("[ok]"), uiServiceName()) } return nil } -// linkServiceActive indique si le worker de lien tourne (unité systemd sous +// uiServiceActive indique si le worker de lien tourne (unité systemd sous // Linux, processus suivi par fichier PID ailleurs). -func linkServiceActive() bool { +func uiServiceActive() bool { if runtime.GOOS != "linux" { if appOwnsLink { return appLinkRunning() } - return linkUserPID() > 0 + return uiUserPID() > 0 } - out, _ := exec.Command("systemctl", "is-active", linkServiceName()).Output() + out, _ := exec.Command("systemctl", "is-active", uiServiceName()).Output() return strings.TrimSpace(string(out)) == "active" } -// linkUserPID renvoie le PID du worker de lien s'il tourne vraiment, 0 sinon +// uiUserPID renvoie le PID du worker de lien s'il tourne vraiment, 0 sinon // (fichier absent, illisible, ou process mort → on nettoie le fichier obsolète). -func linkUserPID() int { - b, err := os.ReadFile(linkPIDPath()) +func uiUserPID() int { + b, err := os.ReadFile(uiPIDPath()) if err != nil { return 0 } @@ -435,24 +376,24 @@ func linkUserPID() int { return pid } -// linkUserSvcCtl : équivalent de systemctl pour les OS sans systemd. -func linkUserSvcCtl(action string) error { +// uiUserSvcCtl : équivalent de systemctl pour les OS sans systemd. +func uiUserSvcCtl(action string) error { switch action { case "stop", "restart": - if pid := linkUserPID(); pid > 0 { + if pid := uiUserPID(); pid > 0 { killTree(pid) for i := 0; i < 30 && pidAlive(pid); i++ { time.Sleep(100 * time.Millisecond) } } - _ = os.Remove(linkPIDPath()) + _ = os.Remove(uiPIDPath()) if action == "stop" { - fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName()) + fmt.Printf("%s service %s arrêté\n", green("[ok]"), uiServiceName()) return nil } case "start": - if linkUserPID() > 0 { - fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName()) + if uiUserPID() > 0 { + fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), uiServiceName()) return nil } default: @@ -469,9 +410,9 @@ func linkUserSvcCtl(action string) error { if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } - logf, err := os.OpenFile(linkLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + logf, err := os.OpenFile(uiLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { - return fmt.Errorf("ouverture du log %s: %w", linkLogPath(), err) + return fmt.Errorf("ouverture du log %s: %w", uiLogPath(), err) } defer logf.Close() @@ -483,7 +424,7 @@ func linkUserSvcCtl(action string) error { pid := cmd.Process.Pid // PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce // worker — précieux pour diagnostiquer un process rescapé d'une autre version. - if err := os.WriteFile(linkPIDPath(), []byte(strconv.Itoa(pid)+"\n"+self+"\n"), 0o644); err != nil { + if err := os.WriteFile(uiPIDPath(), []byte(strconv.Itoa(pid)+"\n"+self+"\n"), 0o644); err != nil { return fmt.Errorf("écriture du PID: %w", err) } _ = cmd.Process.Release() @@ -493,10 +434,10 @@ func linkUserSvcCtl(action string) error { // « en ligne » sur un process déjà mort. time.Sleep(1500 * time.Millisecond) if !pidAlive(pid) { - _ = os.Remove(linkPIDPath()) - return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", linkLogPath()) + _ = os.Remove(uiPIDPath()) + return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", uiLogPath()) } - fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), linkServiceName(), pid) + fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), uiServiceName(), pid) return nil } diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 2d2eb1d..deff654 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -64,6 +64,8 @@ func Main() { mustExit(cmdWeb(args)) case "link": mustExit(cmdLink(args)) + case "ui": + mustExit(cmdUI(args)) case "oai": mustExit(cmdOAI(args)) case "agent": @@ -108,10 +110,10 @@ func printHelp() { Usage: ajean [args] -Application: - app lance l'UI web + ouvre le navigateur (auto au double-clic du binaire) +AJEAN tourne en deux services : ajean-engine (le modèle) et ajean-ui +(l'interface web, le tunnel d'accès distant et l'endpoint OpenAI). -Service: +Moteur (ajean-engine) : start | stop | restart gérer le service status | logs état / logs en direct enable | disable auto-démarrage au boot @@ -121,22 +123,24 @@ Service: vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer + +Interface (ajean-ui) : + ui [start|stop|restart|status] pilote le service d'interface + web [PORT] sert l'interface au premier plan (défaut :8090) — + ouvre aussi le tunnel si un jeton est enregistré set-web-key [clé] protéger l'API de pilotage; vide = générer, "" = retirer Interaction: chat [system-prompt] chat terminal streamé - web [PORT] UI web (défaut :8090) — chat + presets + mode agent agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire) memory [off|ondemand|always|status] mode mémoire de l'IA internet [on|off|status|engine |url |key ] accès web de l'IA (moteur intégré ou serveur Crawl4AI) Accès distant (ajean.link) : - link enregistre le token et démarre le lien au relais - link start | restart | stop pilote le service de lien + link enregistre le jeton et ouvre le tunnel link code code d'appairage (10 min, usage unique) pour le portail - link status | logout état du lien / oublier le token - link serve worker au premier plan (lancé par ajean-link.service) + link status | logout état du jeton / l'oublier Backend llama.cpp : llamacpp install clone + compile llama.cpp (CUDA/ROCm/Metal/CPU), pointe BIN dessus @@ -203,12 +207,13 @@ func memoryDir() string { return filepath.Join(AjeanHome(), "memory") } func modelsDir() string { return filepath.Join(AjeanHome(), "models") } func workspaceDir() string { return filepath.Join(AjeanHome(), "workspace") } -// serviceName est le nom de l'unité qui exécute llama-server. +// serviceName est le nom de l'unité qui exécute llama-server. Son pendant est +// uiUnitName (« ajean-ui »), qui sert l'interface et le tunnel. func serviceName() string { if n := os.Getenv("AJEAN_SERVICE"); n != "" { return n } - return "ajean" + return "ajean-engine" } // Color helpers (ANSI). Disabled when stdout is not a TTY. diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index f29b82c..3d69cf1 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -11,10 +11,34 @@ import ( "strings" ) -const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by ajean). +// sudoersTemplate autorise l'utilisateur à piloter UNE unité sans mot de passe. +// Posé pour les deux unités : l'interface, qui tourne sans privilèges, doit +// pouvoir redémarrer le moteur (bascule de preset) comme elle-même (nouveau +// jeton de liaison). +const sudoersTemplate = `# Permet à %s de piloter l'unité %s sans mot de passe (posé par ajean install). %s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s ` +// uiUnitTemplate — le service d'interface : UI web locale, tunnel du relais et +// endpoint OpenAI, servis par un seul process (donc une seule conversation). +// Champs formatés : User, WorkingDirectory, ExecStart. +const uiUnitTemplate = `[Unit] +Description=AJEAN interface web + accès distant +After=network-online.target %s.service +Wants=network-online.target + +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=%s +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +` + const serviceUnitTemplate = `[Unit] Description=AJEAN llama.cpp server After=network.target @@ -109,21 +133,29 @@ func cmdInstall(args []string) error { } fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - // 4. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed) - unit := fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve") - unitPath := "/etc/systemd/system/" + svc + ".service" - if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { - return err + // 4. Les deux unités : le moteur (exec llama-server) et l'interface. + units := map[string]string{ + svc: fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve"), + uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, installedExePath()+" web"), + } + for name, body := range units { + path := "/etc/systemd/system/" + name + ".service" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), path) } - 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/ajean-" + svc - if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { - return err + // 5. Sudoers : une règle par unité, pour que l'interface (sans privilèges) + // puisse redémarrer le moteur ET elle-même. + for _, name := range []string{svc, uiUnitName} { + sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name, targetUser, name, name, name, name, name) + path := "/etc/sudoers.d/ajean-" + name + if err := os.WriteFile(path, []byte(sudoers), 0o440); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), path) } - fmt.Printf(" %s %s\n", green("✓"), sudoersPath) // 6. chown AJEAN_HOME contents to target user chown(ajeanHome, u) @@ -155,11 +187,15 @@ func cmdUninstall(args []string) error { keepData = true } } - _ = exec.Command("systemctl", "stop", svc).Run() - _ = exec.Command("systemctl", "disable", svc).Run() + for _, name := range []string{uiUnitName, svc} { + _ = exec.Command("systemctl", "stop", name).Run() + _ = exec.Command("systemctl", "disable", name).Run() + } for _, p := range []string{ "/etc/systemd/system/" + svc + ".service", + "/etc/systemd/system/" + uiUnitName + ".service", "/etc/sudoers.d/ajean-" + svc, + "/etc/sudoers.d/ajean-" + uiUnitName, "/etc/default/ajean", installedExePath(), } { diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index 5fd8c88..a9428bf 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -440,16 +440,16 @@ func restartAfterUpdate() (bool, string) { if runtime.GOOS == "windows" { return scheduleAppRestart() } - if runtime.GOOS != "linux" || !linkServiceActive() { + if runtime.GOOS != "linux" || !uiServiceActive() { return false, "" } go func() { time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client // --no-block : on enregistre le job puis on rend la main ; systemd exécute le // stop/start même si ce process (et le client systemctl) sont tués entre-temps. - _ = exec.Command("systemctl", "--no-block", "restart", linkServiceName()).Run() + _ = exec.Command("systemctl", "--no-block", "restart", uiServiceName()).Run() }() - return true, "Service " + linkServiceName() + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)." + return true, "Service " + uiServiceName() + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)." } func restartHintText() string { diff --git a/internal/ajean/web_link_api.go b/internal/ajean/web_link_api.go index fe0f7ca..4cce3e5 100644 --- a/internal/ajean/web_link_api.go +++ b/internal/ajean/web_link_api.go @@ -27,7 +27,7 @@ func handleLinkStatus(w http.ResponseWriter, r *http.Request) { tok := readLinkToken() sendJSON(w, http.StatusOK, map[string]any{ "linked": tok != "", - "active": linkServiceActive(), + "active": uiServiceActive(), "machineURL": remoteServerURL(), "fingerprint": e2eFingerprint(), }) @@ -47,17 +47,17 @@ func handleLinkConnect(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) return } - // (re)démarre le worker pour qu'il prenne la nouvelle clé. Sur une machine sans - // systemd (dev/Windows), le token est quand même enregistré : on renvoie l'info - // mais on signale que le service n'a pas pu démarrer ici. + // Redémarre le service d'interface pour qu'il relise la clé et ouvre le + // tunnel. Sur une machine sans systemd (dev/Windows), la clé est quand même + // enregistrée : on renvoie l'info en signalant que le service n'a pas démarré. svcErr := "" - if err := startLink(true); err != nil { + if err := uiServiceCtl("restart"); err != nil { svcErr = err.Error() } sendJSON(w, http.StatusOK, map[string]any{ "ok": true, "linked": true, - "active": linkServiceActive(), + "active": uiServiceActive(), "machineURL": remoteServerURL(), "serviceErr": svcErr, }) @@ -71,17 +71,17 @@ func handleLinkStart(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusBadRequest, map[string]any{"error": "aucune clé de liaison enregistrée"}) return } - if err := linkServiceCtl("restart"); err != nil { - sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "active": linkServiceActive()}) + if err := uiServiceCtl("restart"); err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "active": uiServiceActive()}) return } - sendJSON(w, http.StatusOK, map[string]any{"ok": true, "active": linkServiceActive()}) + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "active": uiServiceActive()}) } // handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie // la clé (équivalent `ajean link stop` + `ajean link logout`). func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) { - _ = linkServiceCtl("stop") + _ = uiServiceCtl("stop") _ = removeLinkToken() sendJSON(w, http.StatusOK, map[string]any{"ok": true, "linked": false, "active": false}) } diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index 245f8e5..c94123f 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -52,6 +52,23 @@ func cmdWeb(args []string) error { } else { fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]")) } + + // Accès distant : le tunnel vers le relais est ouvert ICI, dans le process qui + // sert déjà l'UI, et avec le MÊME mux. C'est la condition d'une conversation + // unique — elle vit en mémoire (voir chat_conversation.go), donc deux process + // qui la servent, c'est deux fils qui divergent et s'écrasent l'un l'autre. + // Sans jeton enregistré, startAppLink ne fait rien : l'UI locale suffit. + appOwnsLink = true + appWebMux = mux + if readLinkToken() != "" { + killForeignUIWorker() // rescapé d'une version antérieure ou d'une autre copie + fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL()) + if fp := e2eFingerprint(); fp != "" { + fmt.Printf("%s empreinte E2E : %s (code d'appairage : ajean link code)\n", green("[e2e]"), bold(fp)) + } + fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled()) + startAppLink(mux) + } return http.Serve(ln, mux) } From 46d1b9c8cdeae84d07b8a13e232ffca0e5f0ad74 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:44:53 +0200 Subject: [PATCH 234/279] UI : panneau Emplacements aligne sur la nouvelle arborescence Le champ 'config' de /api/paths est devenu 'database' : la ligne Configuration s'affichait vide. Le panneau montre desormais la base, les modeles, les presets, la memoire, le workspace et les backends. --- internal/ajean/ui/index.html | 6 +++--- internal/ajean/ui/src/js/05-status.js | 2 +- internal/ajean/ui/src/js/07-models.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index d3edac5..634cd6f 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -2051,7 +2051,7 @@ async function showPaths(){ el.textContent='…'; try{ const p=await jget('/api/paths'); - const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'IA',p.workspace],['Mémoire',p.memory],['Programme',p.exe]]; + const rows=[['Données',p.home],['Base (config, préférences, conversation)',p.database],['Modèles',p.models],['Presets',p.presets],['Mémoire',p.memory],['Fichiers créés par l\'IA',p.workspace],['Moteur llama.cpp',p.backends],['Programme',p.exe]]; el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); }catch(e){ el.textContent='Erreur'; } } @@ -2519,7 +2519,7 @@ async function switchTo(n,name){ const r=await jpost('/api/switch',{n:n}); if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; } toast('switched'); - // Le preset ACTIF, c'est celui dont l'empreinte est dans config.env : le serveur + // Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur // l'écrit AVANT de répondre (et relance le service en arrière-plan, voir // handleSwitch), donc un rafraîchissement immédiat suffit — la barre passe au // blanc tout de suite. Le chargement du modèle par llama-server continue @@ -2566,7 +2566,7 @@ async function openItem(kind, key){ document.getElementById('m-name').placeholder = K.nameHint; document.getElementById('m-content').value = ''; document.getElementById('m-del').style.display = key ? 'inline-flex' : 'none'; - // Model picker is preset-only: it edits the MODEL= line of config.env. + // Model picker is preset-only: it edits the MODEL= line of the preset. const modelRow = document.getElementById('m-model-row'); const settingsRow = document.getElementById('m-settings-row'); const rawHead = document.getElementById('m-raw-head'); diff --git a/internal/ajean/ui/src/js/05-status.js b/internal/ajean/ui/src/js/05-status.js index 2cb43a8..4e4a78c 100644 --- a/internal/ajean/ui/src/js/05-status.js +++ b/internal/ajean/ui/src/js/05-status.js @@ -73,7 +73,7 @@ async function showPaths(){ el.textContent='…'; try{ const p=await jget('/api/paths'); - const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'IA',p.workspace],['Mémoire',p.memory],['Programme',p.exe]]; + const rows=[['Données',p.home],['Base (config, préférences, conversation)',p.database],['Modèles',p.models],['Presets',p.presets],['Mémoire',p.memory],['Fichiers créés par l\'IA',p.workspace],['Moteur llama.cpp',p.backends],['Programme',p.exe]]; el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); }catch(e){ el.textContent='Erreur'; } } diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index cffd025..7baaff9 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -51,7 +51,7 @@ async function switchTo(n,name){ const r=await jpost('/api/switch',{n:n}); if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; } toast('switched'); - // Le preset ACTIF, c'est celui dont l'empreinte est dans config.env : le serveur + // Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur // l'écrit AVANT de répondre (et relance le service en arrière-plan, voir // handleSwitch), donc un rafraîchissement immédiat suffit — la barre passe au // blanc tout de suite. Le chargement du modèle par llama-server continue @@ -98,7 +98,7 @@ async function openItem(kind, key){ document.getElementById('m-name').placeholder = K.nameHint; document.getElementById('m-content').value = ''; document.getElementById('m-del').style.display = key ? 'inline-flex' : 'none'; - // Model picker is preset-only: it edits the MODEL= line of config.env. + // Model picker is preset-only: it edits the MODEL= line of the preset. const modelRow = document.getElementById('m-model-row'); const settingsRow = document.getElementById('m-settings-row'); const rawHead = document.getElementById('m-raw-head'); From 29bfe236cdc81dd3297428aa5b2227b6aa3132f7 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:48:05 +0200 Subject: [PATCH 235/279] go mod tidy : bbolt en dependance directe --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8c0093c..39cfd71 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/getlantern/systray v1.2.2 github.com/hashicorp/yamux v0.1.2 github.com/modelcontextprotocol/go-sdk v1.6.1 + go.etcd.io/bbolt v1.5.0 golang.org/x/mod v0.35.0 golang.org/x/net v0.55.0 ) @@ -38,7 +39,6 @@ require ( github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/blake3 v0.2.4 // indirect - go.etcd.io/bbolt v1.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect diff --git a/go.sum b/go.sum index f2dfb1c..b8ded5f 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= From 7ecd869da0e9a6b2d6fda4ff8a4f58b32317722b Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:51:59 +0200 Subject: [PATCH 236/279] Etat residuel en base, carte du package et docs remises d'aplomb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sysprompt, utilisateurs appairés, codes d'appairage et etat du job de build passent en base ; ne restent des fichiers que la cle E2E, les certificats et les journaux (le log de build reste un fichier : des milliers de lignes) - doc.go remis a jour (run.go, store.go, les deux services, tools/assemble-ui) - README et notes de release ne pretendent plus que la racine ne contient que six dossiers : c'etait faux --- README.md | 16 +++++++-- RELEASE_NOTES.md | 4 ++- internal/ajean/doc.go | 35 +++++++++++++------ internal/ajean/relay_e2eauth.go | 56 +++++++++--------------------- internal/ajean/web_llamacpp_job.go | 14 ++++---- internal/ajean/web_sysprompt.go | 26 +++----------- 6 files changed, 67 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 0437fec..ba2a079 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,21 @@ Installation : ## Configuration -Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\ajean` sous Windows), qui contient exactement six dossiers — `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/` — et la base `ajean.db`. +Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\ajean` sous Windows) : -Cette base, un unique fichier [bbolt](https://github.com/etcd-io/bbolt), remplace la dizaine de fichiers d'état d'autrefois : configuration, préférences de l'interface, conversation, clés et interrupteurs y vivent désormais ensemble. Restent des fichiers ce qui se lit et s'édite à la main : les presets (`presets/*.env`), les pages de mémoire (`memory/*.md`) et, bien sûr, les modèles. +| | | +|---|---| +| `backends/` | llama.cpp, compilé ou téléchargé | +| `bin/` | le binaire installé (Windows) | +| `models/` | les `.gguf` | +| `presets/` | un `.env` par preset | +| `memory/` | les pages de mémoire de l'IA (`.md`) | +| `workspace/` | ce que l'IA écrit en mode agent | +| `ajean.db` | tout l'état : configuration, préférences, conversation, clés, interrupteurs | + +S'y ajoutent à la racine les quelques fichiers qui ne peuvent pas aller ailleurs : `.e2e_key` (clé privée du chiffrement de bout en bout), `certs/` (certificats TLS gérés par certmagic), et les journaux et fichiers PID des services. + +La base, un unique fichier [bbolt](https://github.com/etcd-io/bbolt), remplace la dizaine de fichiers d'état d'autrefois. Restent des fichiers ce qui se lit et s'édite à la main : les presets, les pages de mémoire et, bien sûr, les modèles. La configuration du moteur s'édite avec `ajean edit`, qui la déroule au format `clé=valeur` dans `$EDITOR` : diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d69c6c0..d4cd118 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -12,7 +12,9 @@ Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardé ## Six dossiers, et rien d'autre -`$AJEAN_HOME` contient exactement `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. +`$AJEAN_HOME` contient `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. + +Ne restent à la racine que ce qui ne peut pas aller ailleurs : la clé privée du chiffrement de bout en bout, le dossier des certificats TLS, et les journaux et fichiers PID des services. ## Deux services qui portent enfin leur nom diff --git a/internal/ajean/doc.go b/internal/ajean/doc.go index f595dba..d6128d2 100644 --- a/internal/ajean/doc.go +++ b/internal/ajean/doc.go @@ -1,18 +1,31 @@ -// AJEAN — cœur du binaire (package ajean, appelé par cmd/ajean). Les fichiers sont préfixés par domaine -// (Go n'autorise pas de sous-dossiers dans un même package) : +// AJEAN — cœur du binaire (package ajean, appelé par cmd/ajean). Go n'autorise +// pas de sous-dossiers dans un même package : les fichiers sont donc préfixés +// par domaine. // -// main.go point d'entrée : dispatch des sous-commandes, chemins (AJEANHome…) +// run.go point d'entrée : dispatch des sous-commandes, AjeanHome() et +// l'arborescence de données +// store.go LA base (bbolt) : configuration, préférences, conversation, +// clés, jetons, interrupteurs — tout l'état non éditable à la main // cli_* expérience « application » (double-clic : UI + tray + splash) -// web_* serveur HTTP local :8090 (UI embarquée via go:embed ui/, auth, prefs) +// web_* serveur HTTP :8090 (UI embarquée via go:embed ui/, auth, prefs) // chat_* chat CLI + conversation serveur partagée, compaction, mémoire, -// outils de l'agent (dont accès internet via Crawl4AI) +// outils de l'agent (dont accès internet) // llm_* client llama-server (complétions, endpoint OpenAI, bench, test) -// backend_* gestion llama.cpp : build (llamacpp), GPU, serve (ExecStart), -// modèles/téléchargements, catalogue, presets, config.env -// relay_* accès distant ajean.link : tunnel (link), chiffrement E2E, appairage -// sys_* intégration OS : install, services (systemd/launchd/Windows), -// plateforme, process, tray, splash, tty, auto-update +// backend_* gestion llama.cpp : build, GPU, serve (ExecStart), modèles et +// téléchargements, catalogue, presets, configuration +// relay_* accès distant ajean.link : tunnel, chiffrement E2E, appairage +// sys_* intégration OS : install, services, plateforme, process, tray, +// splash, tty, auto-update // // Les suffixes _windows/_linux/_darwin/_unix/_other portent les contraintes de -// compilation par OS. ui/index.html est GÉNÉRÉ depuis ui/src/ (voir ui/assemble.ps1). +// compilation par OS. +// +// DEUX SERVICES à l'exécution, un seul binaire : +// - ajean-engine (« ajean serve ») exec llama-server ; +// - ajean-ui (« ajean web ») sert l'UI locale, le tunnel du relais et +// l'endpoint OpenAI depuis un SEUL process — condition d'une conversation +// unique, celle-ci vivant en mémoire (voir chat_conversation.go). +// +// ui/index.html est GÉNÉRÉ depuis ui/src/ : éditer les sources puis +// `go generate ./internal/ajean` (voir tools/assemble-ui). package ajean diff --git a/internal/ajean/relay_e2eauth.go b/internal/ajean/relay_e2eauth.go index 8b372b8..d56a460 100644 --- a/internal/ajean/relay_e2eauth.go +++ b/internal/ajean/relay_e2eauth.go @@ -25,8 +25,7 @@ import ( "encoding/json" "fmt" "net/http" - "os" - "path/filepath" + "sort" "strings" "sync" "time" @@ -44,17 +43,12 @@ var ( authSet = map[string]bool{} ) -func authUsersPath() string { return filepath.Join(AjeanHome(), ".authorized_users") } - func loadAuthUsers() { authOnce.Do(func() { - b, err := os.ReadFile(authUsersPath()) - if err != nil { - return - } - for _, line := range strings.Split(string(b), "\n") { - h := strings.ToLower(strings.TrimSpace(line)) - if isHexPub(h) { + var list []string + getJSON(bkState, "authorized_users", &list) + for _, h := range list { + if h = strings.ToLower(strings.TrimSpace(h)); isHexPub(h) { authSet[h] = true } } @@ -88,23 +82,21 @@ func authorizeUser(uPubHex string) error { return nil } authSet[uPubHex] = true - _ = os.MkdirAll(AjeanHome(), 0o755) - f, err := os.OpenFile(authUsersPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - return err + list := make([]string, 0, len(authSet)) + for h := range authSet { + list = append(list, h) } - defer f.Close() - _, err = f.WriteString(uPubHex + "\n") - return err + sort.Strings(list) + return putJSON(bkState, "authorized_users", list) } // ---- Codes d'appairage : à la demande, usage unique, TTL 10 min -------------- // // Générés par « ajean link code » (ou affichés par « ajean link »), ils sont -// partagés avec le worker « ajean link --foreground » (systemd, autre process) via -// un fichier AjeanHome/.pair_codes — sinon les deux process ne s'accorderaient pas. -// Stockés HACHÉS (SHA-256) : le fichier ne révèle aucun code. Chaque code expire -// au bout de 10 min et est consommé (retiré) au premier appairage réussi. +// partagés avec le service d'interface (autre process) via la base — sinon les +// deux ne s'accorderaient pas. Stockés HACHÉS (SHA-256) : la base ne révèle +// aucun code. Chaque code expire au bout de 10 min et est consommé (retiré) au +// premier appairage réussi. const pairCodeTTL = 10 * time.Minute @@ -113,8 +105,6 @@ type pairEntry struct { Exp int64 `json:"e"` // expiration (unix ms) } -func pairCodesPath() string { return filepath.Join(AjeanHome(), ".pair_codes") } - func hashPairCode(code string) string { h := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code)))) return hex.EncodeToString(h[:]) @@ -122,12 +112,8 @@ func hashPairCode(code string) string { // loadPairEntries lit le store et purge au passage les entrées expirées. func loadPairEntries() []pairEntry { - b, err := os.ReadFile(pairCodesPath()) - if err != nil { - return nil - } var es []pairEntry - if json.Unmarshal(b, &es) != nil { + if !getJSON(bkState, "pair_codes", &es) { return nil } now := time.Now().UnixMilli() @@ -140,17 +126,7 @@ func loadPairEntries() []pairEntry { return kept } -func savePairEntries(es []pairEntry) error { - if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { - return err - } - b, _ := json.Marshal(es) - tmp := pairCodesPath() + ".tmp" - if err := os.WriteFile(tmp, b, 0o600); err != nil { - return err - } - return os.Rename(tmp, pairCodesPath()) // remplacement atomique -} +func savePairEntries(es []pairEntry) error { return putJSON(bkState, "pair_codes", es) } // newPairCode génère un code frais (usage unique, 10 min), le persiste haché et // le retourne en clair. diff --git a/internal/ajean/web_llamacpp_job.go b/internal/ajean/web_llamacpp_job.go index cd66b81..61c651e 100644 --- a/internal/ajean/web_llamacpp_job.go +++ b/internal/ajean/web_llamacpp_job.go @@ -19,8 +19,10 @@ import ( "time" ) -func lcJobStatePath() string { return filepath.Join(AjeanHome(), ".lc_job.json") } -func lcJobLogPath() string { return filepath.Join(AjeanHome(), ".lc_job.log") } +// L'ENTÊTE du job vit en base ; ses LIGNES restent un fichier, écrit en append. +// Une compilation en produit des milliers : les faire transiter par une +// transaction chacune coûterait un fsync par ligne. +func lcJobLogPath() string { return filepath.Join(AjeanHome(), "ajean-build.log") } // lcPersisted est la forme sur disque : l'entête du job, sans les lignes (elles // vivent dans le .log à côté, en append). @@ -60,7 +62,7 @@ func lcSave(force bool) { OldCommit: lcCur.OldCommit, NewCommit: lcCur.NewCommit, }) if err == nil { - _ = os.WriteFile(lcJobStatePath(), b, 0o644) + _ = putBytes(bkState, "build_job", b) } } @@ -82,12 +84,8 @@ func lcResetLog() { _ = os.Remove(lcJobLogPath()) } // compilation est morte avec le process précédent, on le marque interrompu // plutôt que de le faire passer pour vivant ou de l'effacer en silence. func lcRestore() { - b, err := os.ReadFile(lcJobStatePath()) - if err != nil { - return - } var p lcPersisted - if json.Unmarshal(b, &p) != nil || p.Action == "" { + if !getJSON(bkState, "build_job", &p) || p.Action == "" { return } j := &lcJob{ diff --git a/internal/ajean/web_sysprompt.go b/internal/ajean/web_sysprompt.go index 06c461b..052fbed 100644 --- a/internal/ajean/web_sysprompt.go +++ b/internal/ajean/web_sysprompt.go @@ -1,6 +1,6 @@ // web_sysprompt.go — prompt système personnalisé de l'utilisateur, persisté -// CÔTÉ SERVEUR ($AJEAN_HOME/sysprompt.txt) et partagé entre appareils, comme la -// conversation elle-même. Historique : avant la conversation serveur (v0.4.x), +// CÔTÉ SERVEUR (en base) et partagé entre appareils, comme la conversation +// elle-même. Historique : avant la conversation serveur (v0.4.x), // l'UI envoyait son prompt système dans chaque requête /api/chat ; depuis, // /api/chat/send ne porte que le message → le champ de l'UI n'avait plus aucun // effet. Il est maintenant lu ici par la génération (chat_conversation.go), et @@ -10,32 +10,14 @@ package ajean import ( "encoding/json" "net/http" - "os" - "path/filepath" "strings" ) -func sysPromptPath() string { return filepath.Join(AjeanHome(), "sysprompt.txt") } - // readSysPrompt renvoie le prompt système personnalisé ("" si absent). -func readSysPrompt() string { - b, err := os.ReadFile(sysPromptPath()) - if err != nil { - return "" - } - return strings.TrimSpace(string(b)) -} +func readSysPrompt() string { return getStr(bkState, "sysprompt") } func saveSysPrompt(text string) error { - text = strings.TrimSpace(text) - if text == "" { - err := os.Remove(sysPromptPath()) - if os.IsNotExist(err) { - return nil - } - return err - } - return os.WriteFile(sysPromptPath(), []byte(text+"\n"), 0o644) + return putStr(bkState, "sysprompt", strings.TrimSpace(text)) } // handleSysPrompt : From 1aabc0f9c7d91c6cf929ef2b2afd70eba65f514c Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:57:51 +0200 Subject: [PATCH 237/279] Installateurs Unix fusionnes, staticcheck sans avertissement - sys_install_unix.go porte le parcours commun (arborescence, lien du binaire, /etc/default, chown) ; Linux et macOS ne gardent que leur declaration de services derriere installServices/uninstallServices (395 -> 339 lignes, et surtout une seule copie a corriger) - l'installateur macOS pose desormais les DEUX services (il n'en posait qu'un) - API bbolt depreciee remplacee, message d'erreur duplique factorise - staticcheck.conf : ST1005 ecartee, avec la raison (messages francais montres a l'utilisateur). Le projet est a zero avertissement. --- internal/ajean/chat_conversation.go | 8 +- internal/ajean/store.go | 4 +- internal/ajean/sys_install_darwin.go | 156 +++++---------------- internal/ajean/sys_install_linux.go | 195 ++++++--------------------- internal/ajean/sys_install_unix.go | 153 +++++++++++++++++++++ staticcheck.conf | 9 ++ 6 files changed, 242 insertions(+), 283 deletions(-) create mode 100644 internal/ajean/sys_install_unix.go create mode 100644 staticcheck.conf diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index c84dc6f..24e0a32 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -208,12 +208,16 @@ func (c *Conversation) state() map[string]any { // ErrBusy : une génération est déjà en cours (un seul tour à la fois). var ErrBusy = fmt.Errorf("génération en cours") +// errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce +// n'est pas un défaut mais une attente, et le message doit le dire. +var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") + // StartTurn ajoute le message utilisateur et lance la génération EN ARRIÈRE-PLAN // (context.Background, détaché de toute connexion HTTP). Renvoie ErrBusy si un // tour est déjà en cours, ou une erreur si le modèle n'est pas prêt. func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) error { if !healthCheck() { - return fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") + return errModelLoading } c.mu.Lock() if c.Generating { @@ -440,7 +444,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // progression. Renvoie ErrBusy si un tour est déjà en cours. func (c *Conversation) CompactNow() error { if !healthCheck() { - return fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") + return errModelLoading } c.mu.Lock() if c.Generating { diff --git a/internal/ajean/store.go b/internal/ajean/store.go index f020a86..65dcf68 100644 --- a/internal/ajean/store.go +++ b/internal/ajean/store.go @@ -2,6 +2,7 @@ package ajean import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "time" bolt "go.etcd.io/bbolt" + bolterrors "go.etcd.io/bbolt/errors" ) // store.go — l'unique endroit où AJEAN écrit son état. @@ -176,7 +178,7 @@ func allKV(bucket string) map[string]string { func replaceKV(bucket string, m map[string]string) error { return withDB(func(d *bolt.DB) error { return d.Update(func(tx *bolt.Tx) error { - if err := tx.DeleteBucket([]byte(bucket)); err != nil && err != bolt.ErrBucketNotFound { + if err := tx.DeleteBucket([]byte(bucket)); err != nil && !errors.Is(err, bolterrors.ErrBucketNotFound) { return err } b, err := tx.CreateBucket([]byte(bucket)) diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 2fdce9b..a555121 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -6,18 +6,17 @@ import ( "fmt" "os" "os/exec" - "os/user" "path/filepath" - "strings" ) -// sys_install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent -// de sys_install_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : le support macOS -// était totalement absent (le code systemd/Linux tournait par erreur et échouait -// sur « systemctl: command not found » / « /etc/default/ajean », cf. issue #4). -// Implémentation prudente ; à valider sur une machine Apple. +// sys_install_darwin.go — la part launchd de l'installation. Le parcours commun +// (dossiers, lien du binaire, /etc/default, chown) vit dans sys_install_unix.go. +// +// ⚠️ NON TESTÉ sur un vrai Mac. Le support macOS a été ajouté parce que le code +// systemd tournait par erreur et échouait sur « systemctl: command not found ». +// Implémentation prudente, à valider sur une machine Apple. -// launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName, +// launchdPlistTemplate — champs : Label, binaire, argument, UserName, // WorkingDirectory, AJEAN_HOME, StandardOutPath, StandardErrorPath. // KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au // boot une fois chargé avec `-w`. @@ -29,7 +28,7 @@ const launchdPlistTemplate = ` ProgramArguments %s - serve + %s UserName%s WorkingDirectory%s @@ -44,127 +43,34 @@ const launchdPlistTemplate = ` ` -func cmdInstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)") - } - targetUser := os.Getenv("SUDO_USER") - if targetUser == "" { - targetUser = "root" - } - for _, a := range args { - if strings.HasPrefix(a, "--user=") { - targetUser = strings.TrimPrefix(a, "--user=") +// unitArgs associe chaque service à la sous-commande qu'il exécute. +func unitArgs() map[string]string { + return map[string]string{serviceName(): "serve", uiUnitName: "web"} +} + +func installServices(targetUser, ajeanHome string) error { + exe := installedExePath() + for name, arg := range unitArgs() { + logPath := filepath.Join(ajeanHome, name+".log") + plist := fmt.Sprintf(launchdPlistTemplate, + launchdLabel(name), exe, arg, targetUser, ajeanHome, ajeanHome, logPath, logPath) + path := launchdPlistPath(name) + if err := os.WriteFile(path, []byte(plist), 0o644); err != nil { + return err } + fmt.Printf(" %s %s\n", green("✓"), path) } - u, err := user.Lookup(targetUser) - if err != nil { - return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) - } - ajeanHome := defaultAjeanHome() - if v := os.Getenv("AJEAN_HOME"); v != "" { - ajeanHome = v - } - svc := serviceName() - - fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) - fmt.Printf(" service = %s (launchd)\n", svc) - - // 1. Arborescence + configuration de départ - if err := provisionDataDir(); err != nil { - return err - } - - // 2. Symlink idempotent vers /usr/local/bin/ajean — même garde-fou que Linux - // (issue #5 : ne pas se lier sur soi-même si on tourne déjà depuis la cible). - self, err := os.Executable() - if err != nil { - return err - } - if rp, e := filepath.EvalSymlinks(self); e == nil { - self = rp - } - target := installedExePath() - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - alreadyInPlace := false - if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self { - alreadyInPlace = true - } - if alreadyInPlace { - fmt.Printf(" %s %s est déjà le binaire installé (aucun lien à créer)\n", green("✓"), target) - } else { - _ = os.Remove(target) - if err := os.Symlink(self, target); err != nil { - if data, err := os.ReadFile(self); err == nil { - _ = os.WriteFile(target, data, 0o755) - } - } - fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) - } - - // 3. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME. - _ = os.MkdirAll("/etc/default", 0o755) - defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome) - if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { - return err - } - fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - - // 4. LaunchDaemon plist (le log va sous AJEAN_HOME, accessible au user cible) - logPath := filepath.Join(ajeanHome, svc+".log") - plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, logPath, logPath) - plistPath := launchdPlistPath(svc) - if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { - return err - } - fmt.Printf(" %s %s\n", green("✓"), plistPath) - - // 5. chown AJEAN_HOME au user cible - chown(ajeanHome, u) - - 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+" ajean edit")) - fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") - fmt.Printf(" 2. démarre le service: %s\n", bold("sudo ajean start")) - fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web")) return nil } -func cmdUninstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("ajean uninstall doit être exécuté en root") - } - svc := serviceName() - _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run() - for _, p := range []string{ - launchdPlistPath(svc), - "/etc/default/ajean", - installedExePath(), - } { - if err := os.Remove(p); err == nil { - fmt.Printf(" %s %s\n", green("✓"), p) +// uninstallServices décharge et efface les deux daemons. L'interface passe +// AVANT le moteur : elle sait le redémarrer, l'ordre inverse pourrait le +// relancer sous nos pieds. +func uninstallServices() { + for _, name := range []string{uiUnitName, serviceName()} { + _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(name)).Run() + if err := os.Remove(launchdPlistPath(name)); err == nil { + fmt.Printf(" %s %s\n", green("✓"), launchdPlistPath(name)) } } - // On ne supprime jamais AJEAN_HOME automatiquement (modèles, presets). - fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)")) - fmt.Println(green("[ok]") + " désinstallé") - return nil -} - -// chown recursively changes ownership of path to the given user/group. -func chown(path string, u *user.User) { - var uid, gid int - fmt.Sscanf(u.Uid, "%d", &uid) - fmt.Sscanf(u.Gid, "%d", &gid) - filepath.Walk(path, func(p string, info os.FileInfo, err error) error { - if err == nil { - _ = os.Chown(p, uid, gid) - } - return nil - }) } diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 3d69cf1..9df5dc0 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -6,41 +6,22 @@ import ( "fmt" "os" "os/exec" - "os/user" - "path/filepath" - "strings" ) +// sys_install_linux.go — la part systemd de l'installation. Le parcours commun +// (dossiers, lien du binaire, /etc/default, chown) vit dans sys_install_unix.go. + // sudoersTemplate autorise l'utilisateur à piloter UNE unité sans mot de passe. -// Posé pour les deux unités : l'interface, qui tourne sans privilèges, doit -// pouvoir redémarrer le moteur (bascule de preset) comme elle-même (nouveau -// jeton de liaison). -const sudoersTemplate = `# Permet à %s de piloter l'unité %s sans mot de passe (posé par ajean install). -%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s +// Posé pour les deux : l'interface, qui tourne sans privilèges, doit pouvoir +// redémarrer le moteur (bascule de preset) comme elle-même (nouveau jeton). +const sudoersTemplate = `# Permet à %[1]s de piloter l'unité %[2]s sans mot de passe (posé par ajean install). +%[1]s ALL=(root) NOPASSWD: /bin/systemctl start %[2]s, /bin/systemctl stop %[2]s, /bin/systemctl restart %[2]s, /bin/systemctl enable %[2]s, /bin/systemctl disable %[2]s ` -// uiUnitTemplate — le service d'interface : UI web locale, tunnel du relais et -// endpoint OpenAI, servis par un seul process (donc une seule conversation). -// Champs formatés : User, WorkingDirectory, ExecStart. -const uiUnitTemplate = `[Unit] -Description=AJEAN interface web + accès distant -After=network-online.target %s.service -Wants=network-online.target - -[Service] -Type=simple -User=%s -WorkingDirectory=%s -ExecStart=%s -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -` - -const serviceUnitTemplate = `[Unit] -Description=AJEAN llama.cpp server +// engineUnitTemplate — le moteur : exec llama-server, supervisé directement par +// systemd. Champs : User, WorkingDirectory, ExecStart. +const engineUnitTemplate = `[Unit] +Description=AJEAN — moteur llama.cpp After=network.target [Service] @@ -62,81 +43,31 @@ CPUAccounting=yes WantedBy=multi-user.target ` -func cmdInstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean 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) - } - ajeanHome := defaultAjeanHome() - if v := os.Getenv("AJEAN_HOME"); v != "" { - ajeanHome = v - } - svc := serviceName() +// uiUnitTemplate — l'interface : UI web locale, tunnel du relais et endpoint +// OpenAI, servis par un SEUL process (donc une seule conversation). +// Champs : unité du moteur (dépendance), User, WorkingDirectory, ExecStart. +const uiUnitTemplate = `[Unit] +Description=AJEAN — interface web + accès distant +After=network-online.target %s.service +Wants=network-online.target - fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) - fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) - fmt.Printf(" service = %s\n", svc) +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=%s +Restart=always +RestartSec=5 - // 1. Arborescence + configuration de départ - if err := provisionDataDir(); err != nil { - return err - } - fmt.Printf(" %s arborescence prête\n", green("✓")) +[Install] +WantedBy=multi-user.target +` - // 2. Symlink current binary to /usr/local/bin/ajean - self, err := os.Executable() - if err != nil { - return err - } - if rp, e := filepath.EvalSymlinks(self); e == nil { - self = rp - } - target := installedExePath() - // Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a - // posé le binaire dans /usr/local/bin/ajean puis lancé `sudo ajean install`), - // ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire - // et créerait /usr/local/bin/ajean -> /usr/local/bin/ajean (« Too many levels - // of symbolic links »). On compare les chemins réels (résolus). - alreadyInPlace := false - if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self { - alreadyInPlace = true - } - if alreadyInPlace { - fmt.Printf(" %s %s est déjà le binaire installé (aucun lien à créer)\n", green("✓"), target) - } else { - _ = 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) - } - - // 3. /etc/default/ajean, pour que les invocations root résolvent AJEAN_HOME. - defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome) - if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { - return err - } - fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - - // 4. Les deux unités : le moteur (exec llama-server) et l'interface. +func installServices(targetUser, ajeanHome string) error { + svc, exe := serviceName(), installedExePath() units := map[string]string{ - svc: fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve"), - uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, installedExePath()+" web"), + svc: fmt.Sprintf(engineUnitTemplate, targetUser, ajeanHome, exe+" serve"), + uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, exe+" web"), } for name, body := range units { path := "/etc/systemd/system/" + name + ".service" @@ -144,49 +75,23 @@ func cmdInstall(args []string) error { return err } fmt.Printf(" %s %s\n", green("✓"), path) - } - // 5. Sudoers : une règle par unité, pour que l'interface (sans privilèges) - // puisse redémarrer le moteur ET elle-même. - for _, name := range []string{svc, uiUnitName} { - sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name, targetUser, name, name, name, name, name) - path := "/etc/sudoers.d/ajean-" + name - if err := os.WriteFile(path, []byte(sudoers), 0o440); err != nil { + sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name) + sudoersPath := "/etc/sudoers.d/ajean-" + name + if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { return err } - fmt.Printf(" %s %s\n", green("✓"), path) + fmt.Printf(" %s %s\n", green("✓"), sudoersPath) } - - // 6. chown AJEAN_HOME contents to target user - chown(ajeanHome, 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+" ajean edit")) - fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") - fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" ajean start")) - fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web")) return nil } -func cmdUninstall(args []string) error { - if os.Geteuid() != 0 { - return fmt.Errorf("ajean uninstall doit être exécuté en root") - } +// uninstallServices arrête, désactive et efface les deux unités. L'interface +// passe AVANT le moteur : elle sait le redémarrer, l'ordre inverse pourrait le +// relancer sous nos pieds. +func uninstallServices() { svc := serviceName() - keepData := false - for _, a := range args { - if a == "--purge" { - keepData = false - } - if a == "--keep-data" { - keepData = true - } - } for _, name := range []string{uiUnitName, svc} { _ = exec.Command("systemctl", "stop", name).Run() _ = exec.Command("systemctl", "disable", name).Run() @@ -196,30 +101,10 @@ func cmdUninstall(args []string) error { "/etc/systemd/system/" + uiUnitName + ".service", "/etc/sudoers.d/ajean-" + svc, "/etc/sudoers.d/ajean-" + uiUnitName, - "/etc/default/ajean", - installedExePath(), } { 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 $AJEAN_HOME manuellement si tu veux purger)")) - } - fmt.Println(green("[ok]") + " désinstallé") - return nil -} - -// chown recursively changes ownership of path to the given user/group. -func chown(path string, u *user.User) { - var uid, gid int - fmt.Sscanf(u.Uid, "%d", &uid) - fmt.Sscanf(u.Gid, "%d", &gid) - filepath.Walk(path, func(p string, info os.FileInfo, err error) error { - if err == nil { - _ = os.Chown(p, uid, gid) - } - return nil - }) } diff --git a/internal/ajean/sys_install_unix.go b/internal/ajean/sys_install_unix.go new file mode 100644 index 0000000..c02c7be --- /dev/null +++ b/internal/ajean/sys_install_unix.go @@ -0,0 +1,153 @@ +//go:build !windows + +package ajean + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strings" +) + +// sys_install_unix.go — installation et désinstallation, partie commune à Linux +// et macOS. Les deux plateformes font exactement la même chose, à un détail +// près : la façon de déclarer les services (systemd d'un côté, launchd de +// l'autre). Ce détail vit dans sys_install_linux.go / sys_install_darwin.go, +// derrière deux fonctions : installServices et uninstallServices. +// +// Avant, chaque plateforme avait sa copie intégrale du parcours : deux fichiers +// identiques à 60 %, donc toute correction à faire deux fois. Comme le support +// macOS n'est pas testé sur une vraie machine, c'est précisément là qu'une +// divergence passait inaperçue. + +func cmdInstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean 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) + } + ajeanHome := defaultAjeanHome() + if v := os.Getenv("AJEAN_HOME"); v != "" { + ajeanHome = v + } + + fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) + fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) + fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName) + + // 1. Arborescence + configuration de départ. + if err := provisionDataDir(); err != nil { + return err + } + fmt.Printf(" %s arborescence prête\n", green("✓")) + + // 2. Lien vers /usr/local/bin/ajean. + if err := linkInstalledExe(); err != nil { + return err + } + + // 3. /etc/default/ajean, pour que les invocations root résolvent AJEAN_HOME. + if err := os.MkdirAll("/etc/default", 0o755); err != nil { + return err + } + defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome) + if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { + return err + } + fmt.Printf(" %s /etc/default/ajean\n", green("✓")) + + // 4. Déclaration des services (systemd ou launchd selon la plateforme). + if err := installServices(targetUser, ajeanHome); err != nil { + return err + } + + // 5. AJEAN_HOME appartient à l'utilisateur cible : les deux services et les + // commandes qu'il tape doivent pouvoir écrire dans la base. + chown(ajeanHome, u) + + fmt.Println() + fmt.Printf("%s installation terminée.\n", green("[ok]")) + fmt.Printf("\nProchaines étapes :\n") + fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("sudo -u "+targetUser+" ajean edit")) + fmt.Printf(" 2. démarre le moteur : %s\n", bold("sudo ajean start")) + fmt.Printf(" 3. démarre l'interface: %s\n", bold("sudo ajean ui start")) + return nil +} + +// linkInstalledExe pose /usr/local/bin/ajean sur le binaire courant. +// +// Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible — l'utilisateur a +// posé le binaire là puis lancé `sudo ajean install` — il ne faut surtout pas +// Remove+Symlink sur soi-même : ça effacerait le vrai binaire et créerait un +// lien vers lui-même (« Too many levels of symbolic links »). On compare les +// chemins réels, résolus. +func linkInstalledExe() error { + self, err := os.Executable() + if err != nil { + return err + } + if rp, e := filepath.EvalSymlinks(self); e == nil { + self = rp + } + target := installedExePath() + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self { + fmt.Printf(" %s %s est déjà le binaire installé (aucun lien à créer)\n", green("✓"), target) + return nil + } + _ = os.Remove(target) + if err := os.Symlink(self, target); err != nil { + // Repli sur une copie si le lien échoue (volumes différents). + data, rerr := os.ReadFile(self) + if rerr != nil { + return rerr + } + if werr := os.WriteFile(target, data, 0o755); werr != nil { + return werr + } + } + fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) + return nil +} + +func cmdUninstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("ajean uninstall doit être exécuté en root") + } + uninstallServices() + for _, p := range []string{"/etc/default/ajean", installedExePath()} { + if err := os.Remove(p); err == nil { + fmt.Printf(" %s %s\n", green("✓"), p) + } + } + fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)")) + fmt.Println(green("[ok]") + " désinstallé") + return nil +} + +// chown donne récursivement path à l'utilisateur/groupe indiqué. +func chown(path string, u *user.User) { + var uid, gid int + fmt.Sscanf(u.Uid, "%d", &uid) + fmt.Sscanf(u.Gid, "%d", &gid) + _ = filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err == nil { + _ = os.Chown(p, uid, gid) + } + return nil + }) +} diff --git a/staticcheck.conf b/staticcheck.conf new file mode 100644 index 0000000..9b77ab6 --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1,9 @@ +# ST1005 exige des messages d'erreur en minuscule et sans ponctuation finale. +# La règle suppose des erreurs anglaises destinées à être enchâssées dans une +# phrase plus longue. Ici, une bonne partie des erreurs sont des messages +# FRANÇAIS affichés tels quels à l'utilisateur, dans le chat ou dans le +# terminal (« ⏳ Le modèle est encore en train de charger — réessaie dans +# quelques secondes. »). Les rendre conformes reviendrait à dégrader le produit +# pour satisfaire l'outil. On désactive donc la règle, plutôt que de laisser +# traîner des avertissements permanents qu'on apprendrait à ignorer. +checks = ["inherit", "-ST1005"] From 5daf6fb0ebed7e4063a6086d7b7f5d9a49677d77 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:59:46 +0200 Subject: [PATCH 238/279] Sudoers nommes d'apres l'unite (ajean-engine, ajean-ui) --- internal/ajean/sys_install_linux.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 9df5dc0..769f004 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -77,7 +77,7 @@ func installServices(targetUser, ajeanHome string) error { fmt.Printf(" %s %s\n", green("✓"), path) sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name) - sudoersPath := "/etc/sudoers.d/ajean-" + name + sudoersPath := "/etc/sudoers.d/" + name if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { return err } @@ -99,8 +99,8 @@ func uninstallServices() { for _, p := range []string{ "/etc/systemd/system/" + svc + ".service", "/etc/systemd/system/" + uiUnitName + ".service", - "/etc/sudoers.d/ajean-" + svc, - "/etc/sudoers.d/ajean-" + uiUnitName, + "/etc/sudoers.d/" + svc, + "/etc/sudoers.d/" + uiUnitName, } { if err := os.Remove(p); err == nil { fmt.Printf(" %s %s\n", green("✓"), p) From 35edc631a9721939a6c18b53e1e49feb9bc2df69 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 18:07:33 +0200 Subject: [PATCH 239/279] Correctif du worker detache, outil de migration 0.7 dans le depot, CI staticcheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uiUserSvcCtl lancait encore « ajean link serve », sous-commande supprimee : l'acces distant etait casse sur macOS et Windows (hors systemd) - tools/migrate-0.7 : reprise d'une installation 0.7.x, hors du binaire pour ne pas reintroduire de compat dans le produit ; procedure complete au README - CI : staticcheck ajoute (le projet est a zero avertissement, il doit le rester) - .gitignore purge de ses entrees mortes (jean, config.env, .api_key...) --- .github/workflows/ci.yml | 4 + .gitignore | 11 +-- README.md | 38 +++++++- internal/ajean/relay_link.go | 4 +- tools/migrate-0.7/main.go | 183 +++++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 tools/migrate-0.7/main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee379a4..cdd8895 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,10 @@ jobs: - run: go vet ./... - run: go build ./... - run: go test ./... + # staticcheck : le projet est à zéro avertissement, et doit le rester. + # Les règles écartées et leur raison vivent dans staticcheck.conf. + - name: staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... - name: cross-compile (cibles release Linux + Windows) run: | # darwin n'est pas ici : depuis v0.5.4 l'icône de barre de menus passe diff --git a/.gitignore b/.gitignore index aa7cc33..3710cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Binaires compilés -/jean -/jean.exe +/ajean +/ajean.exe /dist/ # Artefacts de build Go @@ -8,13 +8,6 @@ *.test *.out -# Config locale / secrets runtime (générés à l'install, jamais versionnés) -config.env -.api_key -.web_key -.link_token -.link_machine - # OS / éditeurs .DS_Store Thumbs.db diff --git a/README.md b/README.md index ba2a079..af61ab9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ sudo mv ajean /usr/local/bin/ajean ### 2. Installation et compilation du moteur ```bash -sudo ajean install # unité systemd, sudoers, dossiers +sudo ajean install # deux unités systemd, sudoers, dossiers ajean llamacpp install # compile llama.cpp pour le GPU présent ``` @@ -305,6 +305,42 @@ CGO_ENABLED=0 go build -o ajean ./cmd/ajean - `cmd/ajean/` : point d'entrée + ressources Windows (icône, versioninfo). - `internal/ajean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. - `internal/ajean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/ajean`. +- `tools/` : outils hors binaire — `assemble-ui` (génère `index.html`), `gen-icon` (icônes Windows), `migrate-0.7` (reprise d'une installation 0.7.x, voir ci-dessous). + +## Migrer depuis la 0.7.x + +La 0.8 ne contient **aucun code de compatibilité** : elle ignore `config.env`, `configs/`, `MEMORY/` et les fichiers d'état de la 0.7. Sur une machine déjà installée, la reprise se fait une fois, à la main. Rien n'est supprimé : les anciens fichiers restent en place, et un retour arrière consiste à réactiver les anciennes unités. + +```bash +# 0. Sauvegarde (petits fichiers ; les .gguf ne risquent rien, ils ne bougent pas de volume) +sudo tar czf /root/ajean-avant-0.8.tar.gz -C /etc/ajean --exclude=backends --exclude='*.gguf' --exclude='*.log' . + +sudo systemctl stop jean-link jean # anciens noms de services + +# 1. Nouvelle arborescence — on COPIE presets et mémoire, on DÉPLACE les modèles +sudo mkdir -p /etc/ajean/{presets,memory,models,bin} +sudo cp -a /etc/ajean/configs/. /etc/ajean/presets/ +sudo cp -a /etc/ajean/MEMORY/. /etc/ajean/memory/ +sudo mv /etc/ajean/*.gguf /etc/ajean/models/ + +# 2. Le binaire, puis les réglages dans la base +sudo install -m755 ajean /usr/local/bin/ajean +go run ./tools/migrate-0.7 /etc/ajean # ou le binaire pré-compilé de l'outil +sudo chown -R : /etc/ajean + +# 3. Les deux services (remplacent jean.service et jean-link.service) +sudo systemctl disable --now jean jean-link +sudo ajean install # écrit ajean-engine + ajean-ui, sudoers, /etc/default +sudo systemctl enable --now ajean-engine ajean-ui +``` + +`migrate-0.7` reprend la configuration, les préférences de l'interface, la conversation, les clés (API, pilotage, Crawl4AI), le jeton de liaison et l'identifiant machine, les interrupteurs agent/internet/OpenAI, les serveurs MCP et les benchmarks. Il ne touche ni aux presets, ni à la mémoire, ni aux modèles — ce sont des fichiers, ils ont juste changé de dossier. + +**`.e2e_key` ne doit pas être supprimé** : l'empreinte de chiffrement de bout en bout confirmée dans le portail en dépend. Il reste à sa place, la migration ne le touche pas. + +Vérification : `ajean where`, `ajean switch` (le preset actif doit être détecté), `ajean test`, puis `journalctl -u ajean-ui -n 20` doit afficher « lien établi ✓ » et l'empreinte E2E inchangée. + +Si `ajean install` écrase une unité que vous aviez réglée à la main (priorité CPU, `CUDA_VISIBLE_DEVICES`, utilisateur dédié), rétablissez vos directives dans `/etc/systemd/system/ajean-engine.service` puis `systemctl daemon-reload`. ## Licence diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 022e905..65e1bbe 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -416,10 +416,10 @@ func uiUserSvcCtl(action string) error { } defer logf.Close() - cmd := spawnDetached(self, "link", "serve") + cmd := spawnDetached(self, "web") cmd.Stdout, cmd.Stderr = logf, logf if err := cmd.Start(); err != nil { - return fmt.Errorf("démarrage de « ajean link serve »: %w", err) + return fmt.Errorf("démarrage de « ajean web »: %w", err) } pid := cmd.Process.Pid // PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce diff --git a/tools/migrate-0.7/main.go b/tools/migrate-0.7/main.go new file mode 100644 index 0000000..d69cbb5 --- /dev/null +++ b/tools/migrate-0.7/main.go @@ -0,0 +1,183 @@ +// migrate-0.7 — reprend une installation AJEAN 0.7.x dans la base de la 0.8. +// +// USAGE UNIQUE, et volontairement HORS du binaire ajean. La 0.8 ne contient +// aucun code de compatibilité : c'est tout l'intérêt de la version. Mais une +// machine déjà installée ne doit pas pour autant perdre ses réglages, sa +// conversation et ses clés. D'où cet outil séparé, qu'on lance une fois puis +// qu'on oublie — le produit reste propre, la migration reste possible. +// +// go run ./tools/migrate-0.7 [$AJEAN_HOME] (défaut : /etc/ajean) +// +// Il lit l'ancienne disposition (config.env, webprefs.json, conversation.json, +// .api_key, .link_token…) et la recopie dans ajean.db, aux mêmes buckets et aux +// mêmes clés que internal/ajean/store.go. +// +// Il ne SUPPRIME rien : les anciens fichiers restent en place, intacts. Si la +// migration s'avère fausse, il suffit d'effacer ajean.db et de la relancer. +// +// Ce qu'il ne fait PAS, parce que ça se fait mieux au shell (voir le README) : +// déplacer configs/ vers presets/, MEMORY/ vers memory/, les .gguf vers +// models/, et remplacer les unités systemd. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + bolt "go.etcd.io/bbolt" +) + +const ( + bkConfig = "config" + bkPrefs = "prefs" + bkState = "state" + bkChat = "chat" +) + +var home = "/etc/ajean" + +func main() { + if len(os.Args) > 1 { + home = os.Args[1] + } + db, err := bolt.Open(filepath.Join(home, "ajean.db"), 0o600, &bolt.Options{Timeout: 5 * time.Second}) + must(err) + defer db.Close() + must(db.Update(func(tx *bolt.Tx) error { + for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} { + if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil { + return err + } + } + return nil + })) + + // Configuration du moteur. + if cfg := parseEnv(read("config.env")); len(cfg) > 0 { + must(putAll(db, bkConfig, cfg)) + fmt.Printf("config : %d clés\n", len(cfg)) + } else { + fmt.Println("config : ABSENTE — le service ne démarrera pas") + } + + // Préférences de l'interface web. + var prefs map[string]string + if b := read("webprefs.json"); b != "" && json.Unmarshal([]byte(b), &prefs) == nil { + must(putAll(db, bkPrefs, prefs)) + fmt.Printf("prefs : %d clés\n", len(prefs)) + } + + // Clés, jetons et interrupteurs. + for key, file := range map[string]string{ + "api_key": ".api_key", + "web_key": ".web_key", + "crawl_key": ".crawl4ai_key", + "link_token": ".link_token", + "link_machine": ".link_machine", + } { + if v := strings.TrimSpace(read(file)); v != "" { + must(put(db, bkState, key, []byte(v))) + fmt.Printf("%-12s: repris\n", key) + } + } + // Les drapeaux étaient des fichiers dont seule l'EXISTENCE comptait. + for key, file := range map[string]string{ + "agent": ".agent_enabled", + "internet": ".internet_enabled", + "oai_public": ".oai_public", + } { + if _, err := os.Stat(filepath.Join(home, file)); err == nil { + must(put(db, bkState, key, []byte("1"))) + fmt.Printf("%-12s: actif\n", key) + } + } + + // Serveurs MCP : on ne garde que la map interne, la clé « mcpServers » du + // fichier n'a plus de raison d'être. + if b := read("mcp.json"); b != "" { + var f struct { + MCPServers json.RawMessage `json:"mcpServers"` + } + if json.Unmarshal([]byte(b), &f) == nil && len(f.MCPServers) > 0 { + must(put(db, bkState, "mcp", f.MCPServers)) + fmt.Println("mcp : repris") + } + } + + // Benchmarks et dossiers de modèles : recopiés tels quels (même forme JSON). + for key, file := range map[string]string{ + "last_bench": ".last_bench.json", + "bench_presets": ".bench_presets.json", + "model_dirs": "model_dirs.json", + } { + if b := read(file); b != "" { + must(put(db, bkState, key, []byte(b))) + fmt.Printf("%-12s: repris\n", key) + } + } + + // Conversation partagée. + if b := read("conversation.json"); b != "" { + must(put(db, bkChat, "conversation", []byte(b))) + fmt.Printf("conversation: %d octets\n", len(b)) + } + + fmt.Println("\nTerminé — aucun ancien fichier n'a été supprimé.") +} + +func read(name string) string { + b, err := os.ReadFile(filepath.Join(home, name)) + if err != nil { + return "" + } + return string(b) +} + +func put(db *bolt.DB, bucket, key string, val []byte) error { + return db.Update(func(tx *bolt.Tx) error { + return tx.Bucket([]byte(bucket)).Put([]byte(key), val) + }) +} + +func putAll(db *bolt.DB, bucket string, m map[string]string) error { + return db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucket)) + for k, v := range m { + if v == "" { + continue + } + if err := b.Put([]byte(k), []byte(v)); err != nil { + return err + } + } + return nil + }) +} + +// parseEnv reprend à l'identique la lecture de backend_config.go. +func parseEnv(text string) map[string]string { + m := map[string]string{} + for _, line := range strings.Split(text, "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + k, v, ok := strings.Cut(strings.TrimPrefix(s, "export "), "=") + if !ok { + continue + } + m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"") + } + return m +} + +func must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "migration:", err) + os.Exit(1) + } +} From 664b13753b545306233a6946b6e6fe47ef71d397 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 19:16:55 +0200 Subject: [PATCH 240/279] Migration 0.7 automatique dans 'ajean install' Un seul fichier, migrate_07.go, sans dependance entrante : le supprimer et retirer les deux appels suffira le jour venu. Declenchee UNIQUEMENT par install (root, delibere) et non au demarrage, contrairement a ce qui existait en 0.7 et qui pouvait laisser une machine distante sans service. Deplace configs/->presets/, MEMORY/->memory/, les .gguf->models/, reprend l'etat en base, range les anciens fichiers dans avant-0.8/ et desactive les unites jean* (sans quoi un second llama-server demarrait au boot suivant). Trois tests : le cas nominal, l'installation neuve, et la machine deja migree a la main. Ils ont trouve deux bugs reels de casse de fichiers (MEMORY/memory se confondent sur Windows et macOS). --- README.md | 39 ++-- RELEASE_NOTES.md | 2 +- internal/ajean/migrate_07.go | 274 ++++++++++++++++++++++++++ internal/ajean/migrate_07_test.go | 150 ++++++++++++++ internal/ajean/sys_install_unix.go | 7 + internal/ajean/sys_install_windows.go | 7 + tools/migrate-0.7/main.go | 183 ----------------- 7 files changed, 452 insertions(+), 210 deletions(-) create mode 100644 internal/ajean/migrate_07.go create mode 100644 internal/ajean/migrate_07_test.go delete mode 100644 tools/migrate-0.7/main.go diff --git a/README.md b/README.md index af61ab9..a475509 100644 --- a/README.md +++ b/README.md @@ -305,42 +305,29 @@ CGO_ENABLED=0 go build -o ajean ./cmd/ajean - `cmd/ajean/` : point d'entrée + ressources Windows (icône, versioninfo). - `internal/ajean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`. - `internal/ajean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/ajean`. -- `tools/` : outils hors binaire — `assemble-ui` (génère `index.html`), `gen-icon` (icônes Windows), `migrate-0.7` (reprise d'une installation 0.7.x, voir ci-dessous). +- `tools/` : outils hors binaire — `assemble-ui` (génère `index.html`) et `gen-icon` (icônes Windows). ## Migrer depuis la 0.7.x -La 0.8 ne contient **aucun code de compatibilité** : elle ignore `config.env`, `configs/`, `MEMORY/` et les fichiers d'état de la 0.7. Sur une machine déjà installée, la reprise se fait une fois, à la main. Rien n'est supprimé : les anciens fichiers restent en place, et un retour arrière consiste à réactiver les anciennes unités. +Une seule commande : ```bash -# 0. Sauvegarde (petits fichiers ; les .gguf ne risquent rien, ils ne bougent pas de volume) -sudo tar czf /root/ajean-avant-0.8.tar.gz -C /etc/ajean --exclude=backends --exclude='*.gguf' --exclude='*.log' . - -sudo systemctl stop jean-link jean # anciens noms de services - -# 1. Nouvelle arborescence — on COPIE presets et mémoire, on DÉPLACE les modèles -sudo mkdir -p /etc/ajean/{presets,memory,models,bin} -sudo cp -a /etc/ajean/configs/. /etc/ajean/presets/ -sudo cp -a /etc/ajean/MEMORY/. /etc/ajean/memory/ -sudo mv /etc/ajean/*.gguf /etc/ajean/models/ - -# 2. Le binaire, puis les réglages dans la base -sudo install -m755 ajean /usr/local/bin/ajean -go run ./tools/migrate-0.7 /etc/ajean # ou le binaire pré-compilé de l'outil -sudo chown -R : /etc/ajean - -# 3. Les deux services (remplacent jean.service et jean-link.service) -sudo systemctl disable --now jean jean-link -sudo ajean install # écrit ajean-engine + ajean-ui, sudoers, /etc/default -sudo systemctl enable --now ajean-engine ajean-ui +sudo ajean install ``` -`migrate-0.7` reprend la configuration, les préférences de l'interface, la conversation, les clés (API, pilotage, Crawl4AI), le jeton de liaison et l'identifiant machine, les interrupteurs agent/internet/OpenAI, les serveurs MCP et les benchmarks. Il ne touche ni aux presets, ni à la mémoire, ni aux modèles — ce sont des fichiers, ils ont juste changé de dossier. +`install` détecte l'ancienne disposition et fait la reprise complète : il arrête et **désactive** les anciens services (`jean`, `jean-link`), déplace `configs/` vers `presets/`, `MEMORY/` vers `memory/` et les `.gguf` vers `models/`, reprend en base la configuration, les préférences, la conversation, les clés, le jeton de liaison, les interrupteurs, les serveurs MCP et les benchmarks — puis installe les deux nouvelles unités. -**`.e2e_key` ne doit pas être supprimé** : l'empreinte de chiffrement de bout en bout confirmée dans le portail en dépend. Il reste à sa place, la migration ne le touche pas. +Désactiver les anciennes unités n'est pas un détail : une `jean.service` restée active relancerait un **second** `llama-server` au prochain démarrage, sur le même port et la même VRAM. -Vérification : `ajean where`, `ajean switch` (le preset actif doit être détecté), `ajean test`, puis `journalctl -u ajean-ui -n 20` doit afficher « lien établi ✓ » et l'empreinte E2E inchangée. +Ce qui est garanti : -Si `ajean install` écrase une unité que vous aviez réglée à la main (priorité CPU, `CUDA_VISIBLE_DEVICES`, utilisateur dédié), rétablissez vos directives dans `/etc/systemd/system/ajean-engine.service` puis `systemctl daemon-reload`. +- **Rien n'est supprimé.** Presets, mémoire et modèles sont *déplacés*, jamais copiés ni effacés. Les petits fichiers d'état sont lus puis rangés dans `avant-0.8/` — supprimez ce dossier quand tout va bien. +- **`.e2e_key` n'est pas touché**, donc l'empreinte de chiffrement confirmée dans le portail reste valable. +- **Une machine déjà migrée à la main** n'est pas écrasée : si `configs/` et `presets/` existent tous les deux, l'ancien est laissé tel quel et signalé. + +Après coup : `ajean where`, `ajean switch` (le preset actif doit être détecté) et `ajean test`. Si vous aviez réglé l'unité du moteur à la main (priorité CPU, `CUDA_VISIBLE_DEVICES`, utilisateur dédié), `install` la réécrit : reportez vos directives dans `/etc/systemd/system/ajean-engine.service` puis `systemctl daemon-reload`. + +Le code de cette reprise vit dans un fichier unique, `internal/ajean/migrate_07.go`, et sera supprimé d'ici une version ou deux. ## Licence diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d4cd118..9478d9a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,6 @@ Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base. -**Cette version ne reprend pas les installations précédentes.** Il n'y a aucune migration : ni des dossiers, ni des unités système, ni des réglages. Réinstallez, puis retéléchargez vos modèles ou repointez-les avec *Dossiers de modèles*. +**La reprise d'une installation 0.7 tient en une commande : `sudo ajean install`.** Il déplace les dossiers, reprend les réglages en base, désactive les anciens services et installe les nouveaux. Rien n'est supprimé : presets, mémoire et modèles sont déplacés, les anciens fichiers d'état rangés dans `avant-0.8/`. C'est le seul code de compatibilité de la version, isolé dans un fichier prévu pour être supprimé. ## Une base à la place des fichiers d'état diff --git a/internal/ajean/migrate_07.go b/internal/ajean/migrate_07.go new file mode 100644 index 0000000..0cfd203 --- /dev/null +++ b/internal/ajean/migrate_07.go @@ -0,0 +1,274 @@ +package ajean + +// migrate_07.go — CODE TEMPORAIRE, à supprimer d'ici une version ou deux. +// +// Reprend une installation 0.7.x au moment de `ajean install`. Tout est ici, et +// rien d'autre n'en dépend : le jour où le parc aura basculé, supprimer ce +// fichier et les deux appels à migrateFrom07() (sys_install_unix.go et +// sys_install_windows.go) suffit. C'est la seule raison d'être de ce fichier — +// la 0.8 ne contient AUCUN autre code de compatibilité. +// +// POURQUOI ICI, ET PAS AU DÉMARRAGE. La version précédente migrait à chaque +// lancement. Sur un parc, ça veut dire une réécriture d'unités systemd à chaque +// boot, et un échec laisse une machine distante sans service et sans terminal +// pour réparer. `install` est le seul bon moment : root, délibéré, et il écrit +// déjà les unités. +// +// CE QU'ON NE DÉTRUIT PAS. Les presets, la mémoire et les modèles sont +// DÉPLACÉS, jamais copiés ni effacés. Les petits fichiers d'état sont lus puis +// rangés dans avant-0.8/ — ils ne servent plus, mais ils sont là si quelque +// chose a mal tourné. + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// legacyUnits : les noms de services qu'a portés le produit avant la 0.8. +// +// Les désactiver n'est pas cosmétique. Une unité `jean.service` restée active +// relance un SECOND llama-server au prochain boot : deux moteurs sur le port +// 8080, la VRAM prise deux fois, et un démarrage qui échoue à moitié sans +// raison apparente. +var legacyUnits = []string{"jean", "jean-link", "ajean", "ajean-link"} + +// hasEntry dit si home contient une entrée portant EXACTEMENT ce nom. +// +// os.Stat ne convient pas : sous Windows et macOS le système de fichiers est +// insensible à la casse, donc Stat("MEMORY") réussit sur un dossier nommé +// « memory ». Une installation 0.8 toute neuve passait ainsi pour une 0.7, et +// la migration se redéclenchait à chaque `install`. On compare donc les noms +// réels, tels que le système de fichiers les rapporte. +func hasEntry(home, name string) bool { + entries, err := os.ReadDir(home) + if err != nil { + return false + } + for _, e := range entries { + if e.Name() == name { + return true + } + } + return false +} + +// sameDir dit si deux chemins désignent le MÊME dossier — ce qui arrive quand +// ils ne diffèrent que par la casse sur un système de fichiers qui l'ignore. +func sameDir(a, b string) bool { + ai, err := os.Stat(a) + if err != nil { + return false + } + bi, err := os.Stat(b) + if err != nil { + return false + } + return os.SameFile(ai, bi) +} + +// needsMigration07 dit si le dossier de données est encore dans l'ancienne +// disposition. On se fie à ce qui ne peut PAS exister en 0.8. +func needsMigration07(home string) bool { + return hasEntry(home, "config.env") || hasEntry(home, "configs") || hasEntry(home, "MEMORY") +} + +// migrateFrom07 reprend une installation 0.7.x. Sans effet si le dossier est +// déjà en 0.8. Doit être appelée AVANT provisionDataDir : elle déplace des +// dossiers entiers, ce qui suppose que la destination n'existe pas encore. +func migrateFrom07(home string) error { + if !needsMigration07(home) { + return nil + } + fmt.Printf("\n%s installation 0.7 détectée — reprise en cours\n", cyan("[migration]")) + + // 1. Arrêter les anciens services : ils tiennent les fichiers qu'on déplace, + // et sous Windows un .gguf ouvert par llama-server refuse d'être renommé. + stopLegacyUnits() + + // 2. Les dossiers. Un rename sur le même volume est instantané, même pour + // 130 Go de modèles, et atomique : jamais d'état à moitié déplacé. + for _, m := range []struct{ from, to string }{ + {"configs", "presets"}, + {"MEMORY", "memory"}, + } { + if !hasEntry(home, m.from) { // nom exact : « MEMORY » n'est pas « memory » + continue + } + src, dst := filepath.Join(home, m.from), filepath.Join(home, m.to) + // « configs » et « presets » ne se confondent pas, mais « MEMORY » et + // « memory » si : sur un système insensible à la casse, ouvrir la + // destination ouvre en réalité la source. On ne considère donc la + // destination comme distincte que si c'est un AUTRE dossier. + if !sameDir(src, dst) { + // Destination déjà remplie : la machine a été migrée à la main et + // l'ancien dossier n'a pas été retiré. Écraser détruirait le travail + // fait, fusionner devinerait qui gagne. On laisse les deux et on le + // dit — c'est le seul cas où quelqu'un doit trancher. + if n, _ := os.ReadDir(dst); len(n) > 0 { + fmt.Printf(" %s %s/ ET %s/ existent tous les deux — %s/ laissé tel quel, à supprimer à la main\n", + yellow("[info]"), m.from, m.to, m.from) + continue + } + _ = os.Remove(dst) // destination vide créée par une install précédente + } + if err := os.Rename(src, dst); err != nil { + return fmt.Errorf("%s → %s : %w", m.from, m.to, err) + } + fmt.Printf(" %s %s/ → %s/\n", green("✓"), m.from, m.to) + } + if n, err := moveModels(home); err != nil { + return err + } else if n > 0 { + fmt.Printf(" %s %d modèle(s) → models/\n", green("✓"), n) + } + + // 3. Les réglages, vers la base. + if err := importLegacyState(home); err != nil { + return err + } + fmt.Printf("%s reprise terminée — les anciens fichiers sont dans avant-0.8/\n\n", green("[ok]")) + return nil +} + +// stopLegacyUnits est une variable pour que le test de migration n'aille pas +// invoquer systemctl sur la machine qui exécute les tests. +var stopLegacyUnits = stopLegacyUnitsReal + +// stopLegacyUnitsReal arrête et désactive les services d'avant la 0.8, +// silencieux sur ceux qui n'existent pas (le cas courant). +func stopLegacyUnitsReal() { + for _, u := range legacyUnits { + switch runtime.GOOS { + case "linux": + _ = exec.Command("systemctl", "stop", u).Run() + _ = exec.Command("systemctl", "disable", u).Run() + case "darwin": + for _, label := range []string{"com.jean." + u, "com.ajean." + u} { + _ = exec.Command("launchctl", "unload", "-w", "/Library/LaunchDaemons/"+label+".plist").Run() + } + } + } + if runtime.GOOS == "linux" { + _ = exec.Command("systemctl", "daemon-reload").Run() + } +} + +// moveModels range les .gguf de la racine dans models/. Renvoie le nombre +// déplacé. Un fichier qui résiste (encore ouvert) interrompt la migration : le +// laisser derrière donnerait une installation à laquelle il manque un modèle, +// sans le dire. +func moveModels(home string) (int, error) { + entries, err := os.ReadDir(home) + if err != nil { + return 0, err + } + dst := filepath.Join(home, "models") + n := 0 + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".gguf") { + continue + } + if n == 0 { + if err := os.MkdirAll(dst, 0o755); err != nil { + return 0, err + } + } + if err := os.Rename(filepath.Join(home, name), filepath.Join(dst, name)); err != nil { + return n, fmt.Errorf("déplacement de %s : %w", name, err) + } + n++ + } + return n, nil +} + +// importLegacyState recopie les anciens fichiers d'état dans la base, puis les +// range dans avant-0.8/. Les clés et les buckets sont ceux de store.go. +func importLegacyState(home string) error { + old := filepath.Join(home, "avant-0.8") + if err := os.MkdirAll(old, 0o755); err != nil { + return err + } + consumed := []string{} + read := func(name string) string { + b, err := os.ReadFile(filepath.Join(home, name)) + if err != nil { + return "" + } + consumed = append(consumed, name) + return string(b) + } + + // La configuration d'abord : sans elle le moteur ne démarre pas. + if cfg := parseEnv(read("config.env")); len(cfg) > 0 { + if err := WriteConfig(cfg); err != nil { + return err + } + fmt.Printf(" %s configuration (%d clés)\n", green("✓"), len(cfg)) + } + if prefs := map[string]string{}; json.Unmarshal([]byte(read("webprefs.json")), &prefs) == nil && len(prefs) > 0 { + _ = replaceKV(bkPrefs, prefs) + } + if conv := read("conversation.json"); conv != "" { + _ = putBytes(bkChat, "conversation", []byte(conv)) + fmt.Printf(" %s conversation\n", green("✓")) + } + + // Clés et jetons : le contenu du fichier, débarrassé de sa fin de ligne. + for key, name := range map[string]string{ + "api_key": ".api_key", + "web_key": ".web_key", + "crawl_key": ".crawl4ai_key", + "link_token": ".link_token", + "link_machine": ".link_machine", + } { + if v := strings.TrimSpace(read(name)); v != "" { + _ = putStr(bkState, key, v) + } + } + // Drapeaux : seule l'EXISTENCE du fichier comptait. + for key, name := range map[string]string{ + "agent": ".agent_enabled", + "internet": ".internet_enabled", + "oai_public": ".oai_public", + } { + if _, err := os.Stat(filepath.Join(home, name)); err == nil { + _ = putBool(bkState, key, true) + consumed = append(consumed, name) + } + } + // Documents JSON repris tels quels. + for key, name := range map[string]string{ + "model_dirs": "model_dirs.json", + "last_bench": ".last_bench.json", + "bench_presets": ".bench_presets.json", + "sysprompt": "sysprompt.txt", + } { + if v := read(name); v != "" { + if key == "sysprompt" { + _ = putStr(bkState, key, strings.TrimSpace(v)) + } else { + _ = putBytes(bkState, key, []byte(v)) + } + } + } + // mcp.json : on ne garde que la map interne, la clé « mcpServers » disparaît. + if v := read("mcp.json"); v != "" { + var f struct { + MCPServers json.RawMessage `json:"mcpServers"` + } + if json.Unmarshal([]byte(v), &f) == nil && len(f.MCPServers) > 0 { + _ = putBytes(bkState, "mcp", f.MCPServers) + } + } + + for _, name := range consumed { + _ = os.Rename(filepath.Join(home, name), filepath.Join(old, name)) + } + return nil +} diff --git a/internal/ajean/migrate_07_test.go b/internal/ajean/migrate_07_test.go new file mode 100644 index 0000000..1c663fa --- /dev/null +++ b/internal/ajean/migrate_07_test.go @@ -0,0 +1,150 @@ +package ajean + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Test de la reprise 0.7 → 0.8. À supprimer avec migrate_07.go. +// +// Ce code déplace les presets, la mémoire et des modèles de plusieurs dizaines +// de gigaoctets sur la machine de quelqu'un : il ne peut pas ne pas être testé. + +// fake07Home fabrique un dossier de données dans l'ancienne disposition. +func fake07Home(t *testing.T) string { + t.Helper() + home := testHome(t) + stopLegacyUnits = func() {} // pas de systemctl pendant les tests + t.Cleanup(func() { stopLegacyUnits = stopLegacyUnitsReal }) + + write := func(rel, body string) { + p := filepath.Join(home, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("config.env", "MODEL=\"m.gguf\"\nCTX=4096\nBIN=/usr/bin/llama-server\n") + write("configs/MON PRESET.env", "# NAME=MON PRESET\nMODEL=\"m.gguf\"\nCTX=8192\n") + write("MEMORY/user-preferences.md", "# Préférences\nfuseau: Europe/Paris\n") + write("m.gguf", "gguf") + write(".api_key", "sk-jean-secret\n") + write(".link_token", "jl_abcdef123456\n") + write(".agent_enabled", "") + write("webprefs.json", `{"theme":"dark"}`) + write("mcp.json", `{"mcpServers":{"fs":{"command":"npx","enabled":true}}}`) + write("sysprompt.txt", "Tu es AJEAN.\n") + write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`) + return home +} + +func TestMigration07(t *testing.T) { + home := fake07Home(t) + if !needsMigration07(home) { + t.Fatal("ancienne disposition non détectée") + } + if err := migrateFrom07(home); err != nil { + t.Fatal(err) + } + + // Les dossiers ont bougé, pas disparu. + for _, c := range []struct{ path, want string }{ + {filepath.Join(home, "presets", "MON PRESET.env"), "CTX=8192"}, + {filepath.Join(home, "memory", "user-preferences.md"), "Europe/Paris"}, + {filepath.Join(home, "models", "m.gguf"), "gguf"}, + } { + b, err := os.ReadFile(c.path) + if err != nil { + t.Fatalf("%s absent : %v", c.path, err) + } + if !strings.Contains(string(b), c.want) { + t.Errorf("%s : contenu inattendu %q", c.path, string(b)) + } + } + // hasEntry et non os.Stat : sur un système de fichiers insensible à la casse, + // Stat("MEMORY") réussit sur le dossier « memory » fraîchement créé. + for _, gone := range []string{"configs", "MEMORY", "m.gguf", "config.env"} { + if hasEntry(home, gone) { + t.Errorf("%s traîne encore à la racine", gone) + } + } + + // Les réglages sont en base. + cfg := ReadConfig() + if cfg["MODEL"] != "m.gguf" || cfg["CTX"] != "4096" { + t.Errorf("configuration non reprise : %v", cfg) + } + if readAPIKey() != "sk-jean-secret" { + t.Errorf("clé API = %q", readAPIKey()) + } + if readLinkToken() != "jl_abcdef123456" { + t.Errorf("jeton de liaison = %q", readLinkToken()) + } + if !agentEnabled() { + t.Error("mode agent perdu") + } + if readSysPrompt() != "Tu es AJEAN." { + t.Errorf("prompt système = %q", readSysPrompt()) + } + if loadWebPrefs()["theme"] != "dark" { + t.Error("préférences perdues") + } + if servers, _ := LoadMCPConfig(); servers["fs"].Command != "npx" { + t.Errorf("serveurs MCP perdus : %v", servers) + } + LoadConversation() + if conv.Seq != 42 { + t.Errorf("conversation perdue (seq %d)", conv.Seq) + } + + // Les fichiers consommés sont rangés, pas détruits. + if _, err := os.Stat(filepath.Join(home, "avant-0.8", "config.env")); err != nil { + t.Error("config.env n'a pas été conservée dans avant-0.8/") + } + + // Idempotence : relancer ne doit plus rien voir à migrer. + if needsMigration07(home) { + t.Error("la migration se redéclencherait au prochain install") + } + if err := migrateFrom07(home); err != nil { + t.Fatalf("second appel : %v", err) + } +} + +// Une installation 0.8 neuve ne doit rien déclencher. +func TestMigration07IgnoreUneInstallationNeuve(t *testing.T) { + home := testHome(t) + if err := provisionDataDir(); err != nil { + t.Fatal(err) + } + if needsMigration07(home) { + t.Fatal("migration déclenchée sur une installation neuve") + } +} + +// Machine migrée à la main : les nouveaux dossiers sont remplis ET les anciens +// traînent encore. L'install ne doit rien écraser, ni échouer. +func TestMigration07NEcrasePasUnDossierDejaRempli(t *testing.T) { + home := fake07Home(t) + precieux := filepath.Join(home, "presets", "DEJA LA.env") + if err := os.MkdirAll(filepath.Dir(precieux), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(precieux, []byte("CTX=1\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := migrateFrom07(home); err != nil { + t.Fatalf("la migration doit se poursuivre, pas échouer : %v", err) + } + b, err := os.ReadFile(precieux) + if err != nil || !strings.Contains(string(b), "CTX=1") { + t.Error("le preset déjà migré a été écrasé") + } + if !hasEntry(home, "configs") { + t.Error("l'ancien dossier a été supprimé alors qu'il fallait le laisser") + } +} diff --git a/internal/ajean/sys_install_unix.go b/internal/ajean/sys_install_unix.go index c02c7be..39c4f64 100644 --- a/internal/ajean/sys_install_unix.go +++ b/internal/ajean/sys_install_unix.go @@ -47,6 +47,13 @@ func cmdInstall(args []string) error { fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName) + // 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir : + // elle déplace des dossiers entiers, donc la destination ne doit pas + // encore exister. Code temporaire, voir migrate_07.go. + if err := migrateFrom07(ajeanHome); err != nil { + return fmt.Errorf("reprise de l'installation 0.7 : %w", err) + } + // 1. Arborescence + configuration de départ. if err := provisionDataDir(); err != nil { return err diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index 765d886..1ccd722 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -23,6 +23,13 @@ func cmdInstall(args []string) error { fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome) fmt.Printf(" service = %s\n", serviceName()) + // 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir : + // elle déplace des dossiers entiers, donc la destination ne doit pas + // encore exister. Code temporaire, voir migrate_07.go. + if err := migrateFrom07(ajeanHome); err != nil { + return fmt.Errorf("reprise de l'installation 0.7 : %w", err) + } + // 1. Arborescence + configuration de départ (partagé avec le premier // lancement de l'app, voir sys_datadir.go). if err := provisionDataDir(); err != nil { diff --git a/tools/migrate-0.7/main.go b/tools/migrate-0.7/main.go deleted file mode 100644 index d69cbb5..0000000 --- a/tools/migrate-0.7/main.go +++ /dev/null @@ -1,183 +0,0 @@ -// migrate-0.7 — reprend une installation AJEAN 0.7.x dans la base de la 0.8. -// -// USAGE UNIQUE, et volontairement HORS du binaire ajean. La 0.8 ne contient -// aucun code de compatibilité : c'est tout l'intérêt de la version. Mais une -// machine déjà installée ne doit pas pour autant perdre ses réglages, sa -// conversation et ses clés. D'où cet outil séparé, qu'on lance une fois puis -// qu'on oublie — le produit reste propre, la migration reste possible. -// -// go run ./tools/migrate-0.7 [$AJEAN_HOME] (défaut : /etc/ajean) -// -// Il lit l'ancienne disposition (config.env, webprefs.json, conversation.json, -// .api_key, .link_token…) et la recopie dans ajean.db, aux mêmes buckets et aux -// mêmes clés que internal/ajean/store.go. -// -// Il ne SUPPRIME rien : les anciens fichiers restent en place, intacts. Si la -// migration s'avère fausse, il suffit d'effacer ajean.db et de la relancer. -// -// Ce qu'il ne fait PAS, parce que ça se fait mieux au shell (voir le README) : -// déplacer configs/ vers presets/, MEMORY/ vers memory/, les .gguf vers -// models/, et remplacer les unités systemd. -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - bolt "go.etcd.io/bbolt" -) - -const ( - bkConfig = "config" - bkPrefs = "prefs" - bkState = "state" - bkChat = "chat" -) - -var home = "/etc/ajean" - -func main() { - if len(os.Args) > 1 { - home = os.Args[1] - } - db, err := bolt.Open(filepath.Join(home, "ajean.db"), 0o600, &bolt.Options{Timeout: 5 * time.Second}) - must(err) - defer db.Close() - must(db.Update(func(tx *bolt.Tx) error { - for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} { - if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil { - return err - } - } - return nil - })) - - // Configuration du moteur. - if cfg := parseEnv(read("config.env")); len(cfg) > 0 { - must(putAll(db, bkConfig, cfg)) - fmt.Printf("config : %d clés\n", len(cfg)) - } else { - fmt.Println("config : ABSENTE — le service ne démarrera pas") - } - - // Préférences de l'interface web. - var prefs map[string]string - if b := read("webprefs.json"); b != "" && json.Unmarshal([]byte(b), &prefs) == nil { - must(putAll(db, bkPrefs, prefs)) - fmt.Printf("prefs : %d clés\n", len(prefs)) - } - - // Clés, jetons et interrupteurs. - for key, file := range map[string]string{ - "api_key": ".api_key", - "web_key": ".web_key", - "crawl_key": ".crawl4ai_key", - "link_token": ".link_token", - "link_machine": ".link_machine", - } { - if v := strings.TrimSpace(read(file)); v != "" { - must(put(db, bkState, key, []byte(v))) - fmt.Printf("%-12s: repris\n", key) - } - } - // Les drapeaux étaient des fichiers dont seule l'EXISTENCE comptait. - for key, file := range map[string]string{ - "agent": ".agent_enabled", - "internet": ".internet_enabled", - "oai_public": ".oai_public", - } { - if _, err := os.Stat(filepath.Join(home, file)); err == nil { - must(put(db, bkState, key, []byte("1"))) - fmt.Printf("%-12s: actif\n", key) - } - } - - // Serveurs MCP : on ne garde que la map interne, la clé « mcpServers » du - // fichier n'a plus de raison d'être. - if b := read("mcp.json"); b != "" { - var f struct { - MCPServers json.RawMessage `json:"mcpServers"` - } - if json.Unmarshal([]byte(b), &f) == nil && len(f.MCPServers) > 0 { - must(put(db, bkState, "mcp", f.MCPServers)) - fmt.Println("mcp : repris") - } - } - - // Benchmarks et dossiers de modèles : recopiés tels quels (même forme JSON). - for key, file := range map[string]string{ - "last_bench": ".last_bench.json", - "bench_presets": ".bench_presets.json", - "model_dirs": "model_dirs.json", - } { - if b := read(file); b != "" { - must(put(db, bkState, key, []byte(b))) - fmt.Printf("%-12s: repris\n", key) - } - } - - // Conversation partagée. - if b := read("conversation.json"); b != "" { - must(put(db, bkChat, "conversation", []byte(b))) - fmt.Printf("conversation: %d octets\n", len(b)) - } - - fmt.Println("\nTerminé — aucun ancien fichier n'a été supprimé.") -} - -func read(name string) string { - b, err := os.ReadFile(filepath.Join(home, name)) - if err != nil { - return "" - } - return string(b) -} - -func put(db *bolt.DB, bucket, key string, val []byte) error { - return db.Update(func(tx *bolt.Tx) error { - return tx.Bucket([]byte(bucket)).Put([]byte(key), val) - }) -} - -func putAll(db *bolt.DB, bucket string, m map[string]string) error { - return db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(bucket)) - for k, v := range m { - if v == "" { - continue - } - if err := b.Put([]byte(k), []byte(v)); err != nil { - return err - } - } - return nil - }) -} - -// parseEnv reprend à l'identique la lecture de backend_config.go. -func parseEnv(text string) map[string]string { - m := map[string]string{} - for _, line := range strings.Split(text, "\n") { - s := strings.TrimSpace(line) - if s == "" || strings.HasPrefix(s, "#") { - continue - } - k, v, ok := strings.Cut(strings.TrimPrefix(s, "export "), "=") - if !ok { - continue - } - m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"") - } - return m -} - -func must(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, "migration:", err) - os.Exit(1) - } -} From 071caea516c397698ad7efe04bde625a1e83d769 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 19:30:26 +0200 Subject: [PATCH 241/279] Noms de release lisibles, et incompatibles avec l'updater 0.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ajean-linux, ajean-linux-arm, ajean-macos, ajean-macos-arm, ajean-windows.exe, ajean-windows-arm.exe. Le schema ne correspond plus a ajean-- que cherchent les 0.7 : leur mise a jour automatique ne trouve aucun asset et echoue sans rien remplacer. C'est voulu — sinon elle installait le binaire 0.8 sans migrer, le service de lien repartait en boucle d'echec sur « link serve » disparu et le moteur perdait sa configuration. assetNameFor est isolee pour etre testee sur les six plateformes, plus un test qui verifie explicitement l'absence de collision avec le schema 0.7. --- .github/workflows/mac-build.yml | 10 +++---- .github/workflows/release.yml | 37 +++++++++++++------------ README.md | 8 +++--- RELEASE_NOTES.md | 6 ++-- internal/ajean/sys_update.go | 32 +++++++++++++++++---- internal/ajean/sys_update_asset_test.go | 34 ++++++++++++++++++++++- 6 files changed, 92 insertions(+), 35 deletions(-) diff --git a/.github/workflows/mac-build.yml b/.github/workflows/mac-build.yml index 390047a..5386ae3 100644 --- a/.github/workflows/mac-build.yml +++ b/.github/workflows/mac-build.yml @@ -17,7 +17,7 @@ jobs: run: | mkdir -p dist CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ - go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean + go build -trimpath -ldflags="-s -w" -o dist/ajean-macos-arm ./cmd/ajean mkdir -p /tmp/ajean.iconset sips -s format png 'cmd/ajean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true for s in 16 32 128 256 512; do @@ -26,7 +26,7 @@ jobs: iconutil -c icns /tmp/ajean.iconset -o /tmp/ajean.icns >/dev/null 2>&1 || true app="dist/AJEAN.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" - cp dist/ajean-darwin-arm64 "$app/Contents/MacOS/ajean" + cp dist/ajean-macos-arm "$app/Contents/MacOS/ajean" chmod +x "$app/Contents/MacOS/ajean" cp /tmp/ajean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true printf '%s\n' \ @@ -44,9 +44,9 @@ jobs: 'NSHighResolutionCapable' \ '' > "$app/Contents/Info.plist" codesign --force --deep -s - "$app" || true - (cd dist && zip -qry AJEAN-macos-arm64.zip AJEAN.app && rm -rf AJEAN.app) + (cd dist && zip -qry AJEAN-macos-arm.zip AJEAN.app && rm -rf AJEAN.app) ls -l dist - uses: actions/upload-artifact@v4 with: - name: AJEAN-macos-arm64 - path: dist/AJEAN-macos-arm64.zip + name: AJEAN-macos-arm + path: dist/AJEAN-macos-arm.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5635688..1e76fe5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,11 +29,11 @@ jobs: mkdir -p dist # arm64 = natif ; amd64 = cross avec le SDK universel (-arch x86_64). CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ - go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean + go build -trimpath -ldflags="-s -w" -o dist/ajean-macos-arm ./cmd/ajean CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \ CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \ - go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-amd64 ./cmd/ajean - file dist/ajean-darwin-* + go build -trimpath -ldflags="-s -w" -o dist/ajean-macos ./cmd/ajean + file dist/ajean-macos* - name: bundles AJEAN.app run: | # Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc @@ -51,10 +51,10 @@ jobs: done iconutil -c icns /tmp/ajean.iconset -o /tmp/ajean.icns v="${GITHUB_REF_NAME#v}" - for arch in amd64 arm64; do - app="dist/mac-$arch/AJEAN.app" + for bin in ajean-macos ajean-macos-arm; do + app="dist/bundle-$bin/AJEAN.app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" - cp "dist/ajean-darwin-$arch" "$app/Contents/MacOS/ajean" + cp "dist/$bin" "$app/Contents/MacOS/ajean" chmod +x "$app/Contents/MacOS/ajean" cp /tmp/ajean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true printf '%s\n' \ @@ -76,12 +76,8 @@ jobs: # Signature ad-hoc : sans elle, macOS refuse purement et simplement # de lancer un binaire arm64 non signé (pas juste un avertissement). codesign --force --deep -s - "$app" || true - (cd "dist/mac-$arch" && zip -qry "../AJEAN-macos-$arch.zip" AJEAN.app) - # Nom hérité conservé : les liens de téléchargement déjà publiés - # (site, mails, messages) pointent dessus et doivent continuer de - # fonctionner. Même contenu, deux noms. - cp "dist/AJEAN-macos-$arch.zip" "dist/AJEAN-macos-$arch.zip" - rm -rf "dist/mac-$arch" + (cd "dist/bundle-$bin" && zip -qry "../AJEAN-${bin#ajean-}.zip" AJEAN.app) + rm -rf "dist/bundle-$bin" done ls -l dist - uses: actions/upload-artifact@v4 @@ -100,13 +96,20 @@ jobs: - name: build Linux + Windows run: | mkdir -p dist - for t in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do - os=${t%/*}; arch=${t#*/}; ext=""; ldflags="-s -w" + # Noms d'asset : ajean-[-arm][.exe]. Volontairement lisibles, et + # volontairement DIFFÉRENTS du schéma ajean-- des 0.7 : + # leur mise à jour automatique ne trouve alors aucun asset et échoue + # sans rien remplacer, au lieu d'installer un binaire 0.8 sur une + # machine encore agencée en 0.7 (voir updateAssetName, sys_update.go). + for t in linux/amd64:ajean-linux linux/arm64:ajean-linux-arm \ + windows/amd64:ajean-windows.exe windows/arm64:ajean-windows-arm.exe; do + target=${t%%:*}; name=${t#*:} + os=${target%/*}; arch=${target#*/}; ldflags="-s -w" # Windows : sous-système GUI → aucune console noire au double-clic # (l'app se rattache à la console du terminal parent si lancée en CLI). - [ "$os" = windows ] && { ext=".exe"; ldflags="$ldflags -H=windowsgui"; } + [ "$os" = windows ] && ldflags="$ldflags -H=windowsgui" GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ - go build -trimpath -ldflags="$ldflags" -o "dist/ajean-$os-$arch$ext" ./cmd/ajean + go build -trimpath -ldflags="$ldflags" -o "dist/$name" ./cmd/ajean done - uses: actions/download-artifact@v4 with: @@ -114,7 +117,7 @@ jobs: path: dist - name: SHA256SUMS run: | - chmod +x dist/ajean-darwin-* + chmod +x dist/ajean-macos* (cd dist && sha256sum ajean-* *.zip > SHA256SUMS) ls -l dist && cat dist/SHA256SUMS - name: publier la release diff --git a/README.md b/README.md index a475509..9513c87 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,12 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur ### 1. Le binaire ```bash -curl -L -o ajean https://github.com/nathaninline/ajean/releases/latest/download/ajean-linux-amd64 +curl -L -o ajean https://github.com/nathaninline/ajean/releases/latest/download/ajean-linux chmod +x ajean sudo mv ajean /usr/local/bin/ajean ``` -(Autres cibles sur la page [Releases](../../releases) : Linux, macOS et Windows, en amd64 et arm64.) +Les binaires publiés : `ajean-linux`, `ajean-linux-arm`, `ajean-macos`, `ajean-macos-arm`, `ajean-windows.exe`, `ajean-windows-arm.exe` — le suffixe `-arm` désigne l'arm64, l'absence de suffixe l'x86-64. ### 2. Installation et compilation du moteur @@ -231,11 +231,11 @@ ajean status ## macOS -La page [Releases](../../releases) publie `AJEAN-macos-arm64.zip` / `AJEAN-macos-amd64.zip`, un bundle **`AJEAN.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock. +La page [Releases](../../releases) publie `AJEAN-macos-arm.zip` (Apple Silicon) et `AJEAN-macos.zip` (Intel), un bundle **`AJEAN.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock. L'application n'est signée qu'en ad-hoc : au premier lancement, faire **clic droit → Ouvrir**. -Pour un usage en ligne de commande, prendre le binaire nu `ajean-darwin-arm64` : hors bundle, il conserve son comportement CLI. Les services passent par **launchd**. +Pour un usage en ligne de commande, prendre le binaire nu `ajean-macos-arm` : hors bundle, il conserve son comportement CLI. Les services passent par **launchd**. ## Accès distant via ajean.link diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9478d9a..f10f655 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,6 @@ Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base. -**La reprise d'une installation 0.7 tient en une commande : `sudo ajean install`.** Il déplace les dossiers, reprend les réglages en base, désactive les anciens services et installe les nouveaux. Rien n'est supprimé : presets, mémoire et modèles sont déplacés, les anciens fichiers d'état rangés dans `avant-0.8/`. C'est le seul code de compatibilité de la version, isolé dans un fichier prévu pour être supprimé. +**Une installation 0.7 doit être RÉINSTALLÉE : ne passez pas par le bouton de mise à jour.** Téléchargez le binaire 0.8, puis `sudo ajean install` — il fait la reprise complète. Il déplace les dossiers, reprend les réglages en base, désactive les anciens services et installe les nouveaux. Rien n'est supprimé : presets, mémoire et modèles sont déplacés, les anciens fichiers d'état rangés dans `avant-0.8/`. C'est le seul code de compatibilité de la version, isolé dans un fichier prévu pour être supprimé. ## Une base à la place des fichiers d'état @@ -32,4 +32,6 @@ Tout le code écrit pour ménager les installations « jean » : la migration du La CLI perd ses alias hérités — `skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama` — et son aide est réorganisée autour des deux services. Chaque commande a désormais un seul nom. `app` quitte l'aide : c'est le comportement du double-clic, qu'on n'atteint pas en tapant son nom. -Les releases ne publient plus qu'un jeu de binaires, `ajean--`. La double publication qui accompagnait la transition n'a plus d'objet. +Les releases ne publient plus qu'un jeu de binaires, et sous des noms lisibles : `ajean-linux`, `ajean-linux-arm`, `ajean-macos`, `ajean-macos-arm`, `ajean-windows.exe`, `ajean-windows-arm.exe`. La double publication qui accompagnait la transition n'a plus d'objet. + +Ce changement de noms n'est pas cosmétique. Les versions 0.7 cherchent leur mise à jour sous la forme `ajean--` : ne trouvant aucun asset qui corresponde, leur bouton « mettre à jour » échoue proprement, sans rien remplacer. C'est délibéré. Laisser la 0.7 installer ce binaire aurait remplacé l'exécutable sans migrer les données ni les unités : le service de lien serait reparti en boucle d'échec sur une sous-commande disparue, et le moteur n'aurait plus trouvé sa configuration — une machine à réparer en SSH après un clic dans un navigateur. diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index a9428bf..095c76b 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -35,13 +35,33 @@ type ghRelease struct { } // updateAssetName est le nom de l'asset de release pour la plateforme courante : -// ajean--[.exe]. Un seul nom publié, un seul nom cherché. -func updateAssetName() string { - suffix := runtime.GOOS + "-" + runtime.GOARCH - if runtime.GOOS == "windows" { - suffix += ".exe" +// ajean-linux, ajean-linux-arm, ajean-macos, ajean-macos-arm, ajean-windows.exe, +// ajean-windows-arm.exe. Un seul nom publié, un seul nom cherché. +// +// Ces noms sont volontairement lisibles — « macos » plutôt que « darwin », pas +// de « amd64 » pour le cas courant — et volontairement DIFFÉRENTS du schéma +// ajean-- qu'utilisaient les versions 0.7 : leur mise à jour +// automatique ne trouve donc aucun asset et échoue sans rien remplacer, au lieu +// d'installer un binaire 0.8 sur une machine encore agencée en 0.7 — ce qui +// laissait le service de lien en boucle d'échec. La 0.7 se met à jour à la main, +// par réinstallation. +func updateAssetName() string { return assetNameFor(runtime.GOOS, runtime.GOARCH) } + +// assetNameFor est séparée pour être vérifiable sur les six plateformes, et pas +// seulement sur celle qui exécute les tests. +func assetNameFor(goos, goarch string) string { + name := "ajean-" + map[string]string{ + "darwin": "macos", + "linux": "linux", + "windows": "windows", + }[goos] + if goarch == "arm64" { + name += "-arm" } - return "ajean-" + suffix + if goos == "windows" { + name += ".exe" + } + return name } // pickAsset trouve dans la release l'asset de cette plateforme. Renvoie son nom diff --git a/internal/ajean/sys_update_asset_test.go b/internal/ajean/sys_update_asset_test.go index 8dbdd59..ce87a7a 100644 --- a/internal/ajean/sys_update_asset_test.go +++ b/internal/ajean/sys_update_asset_test.go @@ -1,6 +1,9 @@ package ajean -import "testing" +import ( + "runtime" + "testing" +) // relWith fabrique une release ne contenant que les assets nommés. func relWith(names ...string) *ghRelease { @@ -34,3 +37,32 @@ func TestPickAssetSansCorrespondance(t *testing.T) { t.Fatalf("URL inattendue: %q", url) } } + +// Le schéma de nommage ne doit PAS coïncider avec ajean-- : c'est +// ce qui empêche la mise à jour automatique d'une 0.7 de s'installer sur une +// machine encore agencée en 0.7. +func TestNomDAssetDistinctDuSchema07(t *testing.T) { + legacy := "ajean-" + runtime.GOOS + "-" + runtime.GOARCH + if runtime.GOOS == "windows" { + legacy += ".exe" + } + if got := updateAssetName(); got == legacy { + t.Fatalf("le nom %q est celui que cherche la 0.7 — sa mise à jour casserait l'installation", got) + } +} + +// Les six noms publiés par la release, vérifiés un par un. +func TestNomsDAssetParPlateforme(t *testing.T) { + for _, c := range []struct{ goos, goarch, want string }{ + {"linux", "amd64", "ajean-linux"}, + {"linux", "arm64", "ajean-linux-arm"}, + {"darwin", "amd64", "ajean-macos"}, + {"darwin", "arm64", "ajean-macos-arm"}, + {"windows", "amd64", "ajean-windows.exe"}, + {"windows", "arm64", "ajean-windows-arm.exe"}, + } { + if got := assetNameFor(c.goos, c.goarch); got != c.want { + t.Errorf("%s/%s → %q, attendu %q", c.goos, c.goarch, got, c.want) + } + } +} From f1c0917276bc0e98b57f18231b56ed388df0a3cf Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 19:38:36 +0200 Subject: [PATCH 242/279] Notes de release conformes, SKILLS archive a la migration - notes reecrites sans tiret cadratin (convention du projet), avec la marche a suivre d'installation et la section de ce qui n'a pas ete teste - migrate_07 archive aussi SKILLS/ (dossier mort depuis la fusion dans memory) - commentaire d'en-tete de release.yml remis a jour --- .github/workflows/release.yml | 6 +++-- RELEASE_NOTES.md | 38 +++++++++++++++++++++---------- internal/ajean/migrate_07.go | 6 +++++ internal/ajean/migrate_07_test.go | 10 ++++++-- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e76fe5..8c3f3e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,8 @@ # Release : sur tag vX.Y.Z, compile les 6 cibles et publie la release GitHub -# avec les binaires (mêmes noms d'assets que les releases manuelles historiques) -# ET le fichier SHA256SUMS que `ajean update` (v0.4.6+) vérifie avant d'installer. +# avec les binaires (ajean-linux, ajean-macos, ajean-windows.exe et leurs +# variantes -arm) ET le fichier SHA256SUMS que `ajean update` vérifie avant +# d'installer. Ces noms doivent rester en phase avec assetNameFor (sys_update.go), +# sinon plus aucune machine ne se met à jour. # ⚠️ NE PAS uploader de binaires à la main en plus : ce workflow écrase les # assets du même nom (softprops remplace), et des sommes qui ne correspondent # plus aux binaires font échouer `ajean update` (vécu sur v0.4.7). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f10f655..fa8c618 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,28 +1,40 @@ Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base. -**Une installation 0.7 doit être RÉINSTALLÉE : ne passez pas par le bouton de mise à jour.** Téléchargez le binaire 0.8, puis `sudo ajean install` — il fait la reprise complète. Il déplace les dossiers, reprend les réglages en base, désactive les anciens services et installe les nouveaux. Rien n'est supprimé : presets, mémoire et modèles sont déplacés, les anciens fichiers d'état rangés dans `avant-0.8/`. C'est le seul code de compatibilité de la version, isolé dans un fichier prévu pour être supprimé. +## Mise à jour : il faut réinstaller + +**Ne passez pas par le bouton « mettre à jour » de la 0.7.** Il ne trouvera d'ailleurs rien : les binaires de cette version portent de nouveaux noms, que la 0.7 ne sait pas chercher. C'est délibéré. La laisser installer ce binaire aurait remplacé l'exécutable sans migrer ni les données ni les unités : le service de lien serait reparti en boucle d'échec sur une sous-commande disparue, et le moteur n'aurait plus trouvé sa configuration. Une machine à réparer en SSH après un clic dans un navigateur. + +La marche à suivre, sur une machine déjà installée : + +```bash +curl -L -o ajean https://github.com/nathaninline/ajean/releases/latest/download/ajean-linux +chmod +x ajean && sudo mv ajean /usr/local/bin/ajean +sudo ajean install +``` + +`install` fait la reprise complète : il arrête et désactive les anciens services, déplace `configs/` vers `presets/`, `MEMORY/` vers `memory/` et les `.gguf` vers `models/`, reprend en base la configuration, les préférences, la conversation, les clés, le jeton de liaison, les interrupteurs, les serveurs MCP et les benchmarks, puis installe les deux nouvelles unités. + +Rien n'est supprimé. Les presets, la mémoire et les modèles sont déplacés, jamais copiés ni effacés. Les anciens fichiers d'état sont rangés dans `avant-0.8/`, que vous pourrez supprimer quand tout ira bien. La clé du chiffrement de bout en bout n'est pas touchée, donc l'empreinte confirmée dans le portail reste valable. ## Une base à la place des fichiers d'état -`config.env`, `webprefs.json`, `conversation.json`, `model_dirs.json`, `mcp.json`, `.api_key`, `.web_key`, `.link_token`, `.agent_enabled`, `.internet_enabled` et le reste ont disparu. Tout cela vit maintenant dans **`ajean.db`**, un fichier unique en [bbolt](https://github.com/etcd-io/bbolt) — pur Go, transactionnel, sans dépendance système. +`config.env`, `webprefs.json`, `conversation.json`, `model_dirs.json`, `mcp.json`, `.api_key`, `.web_key`, `.link_token`, `.agent_enabled`, `.internet_enabled` et le reste ont disparu. Tout cela vit maintenant dans **`ajean.db`**, un fichier unique en [bbolt](https://github.com/etcd-io/bbolt), pur Go, transactionnel, sans dépendance système. Ce n'est pas qu'un rangement. Chaque fichier avait sa façon d'être écrit, et donc sa façon de rater une écriture concurrente : l'interface et un tour de chat qui touchaient au même réglage au même instant pouvaient en perdre un. Une transaction remplace tout ça. Une bascule de preset, en particulier, remplace la configuration d'un bloc : il n'existe plus d'instant où elle serait à moitié l'ancienne et à moitié la nouvelle. -Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardés à la main : les presets, les pages de mémoire, les modèles. `ajean edit` déroule donc la configuration au format `clé=valeur` dans votre éditeur, puis la relit — le fichier n'existe que le temps de l'édition. +Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardés à la main : les presets, les pages de mémoire, les modèles. `ajean edit` déroule donc la configuration au format `clé=valeur` dans votre éditeur, puis la relit ; le fichier n'existe que le temps de l'édition. -## Six dossiers, et rien d'autre +## Six dossiers -`$AJEAN_HOME` contient `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. - -Ne restent à la racine que ce qui ne peut pas aller ailleurs : la clé privée du chiffrement de bout en bout, le dossier des certificats TLS, et les journaux et fichiers PID des services. +`$AJEAN_HOME` contient `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. Ne restent à côté que ce qui ne peut pas aller ailleurs : la clé privée du chiffrement de bout en bout, le dossier des certificats TLS, les journaux et les fichiers PID des services. ## Deux services qui portent enfin leur nom `ajean.service` et `ajean-link.service` deviennent **`ajean-engine`** et **`ajean-ui`**. Le premier exécute le modèle, le second sert l'interface web, le tunnel d'accès distant et l'endpoint OpenAI. Le nom « link » cachait l'essentiel : ce service est d'abord le serveur web. -Surtout, il n'y a plus **qu'une seule façon** de servir l'interface. Avant, `ajean web` et `ajean link serve` savaient tous deux le faire, ce qui posait un piège permanent : lancer les deux, c'était un conflit sur le port 8090 et surtout deux fils de conversation qui divergeaient — la conversation vit en mémoire, deux process qui la servent finissent par s'écraser l'un l'autre. La consigne « ne jamais lancer `ajean web` » circulait comme une règle à retenir ; elle n'existait que parce que le code offrait deux portes pour la même pièce. +Surtout, il n'y a plus **qu'une seule façon** de servir l'interface. Avant, `ajean web` et `ajean link serve` savaient tous deux le faire, ce qui posait un piège permanent : lancer les deux, c'était un conflit sur le port 8090 et surtout deux fils de conversation qui divergeaient (la conversation vit en mémoire, deux process qui la servent finissent par s'écraser l'un l'autre). La consigne « ne jamais lancer `ajean web` » circulait comme une règle à retenir ; elle n'existait que parce que le code offrait deux portes pour la même pièce. -Désormais `ajean web` est cette porte unique : il sert l'interface et, si un jeton de liaison est enregistré, ouvre le tunnel dans le même process. `ajean link` ne s'occupe plus que du compte — jeton, code d'appairage, état — et `ajean ui start|stop|restart|status` pilote le service. `ajean link serve`, `link start`, `link stop` et `link restart` disparaissent. +Désormais `ajean web` est cette porte unique : il sert l'interface et, si un jeton de liaison est enregistré, ouvre le tunnel dans le même process. `ajean link` ne s'occupe plus que du compte (jeton, code d'appairage, état), et `ajean ui start|stop|restart|status` pilote le service. `ajean link serve`, `link start`, `link stop` et `link restart` disparaissent. Séparer les deux services garde son intérêt : redémarrer l'interface est instantané, alors que redémarrer le moteur recharge des dizaines de gigaoctets. @@ -30,8 +42,10 @@ Séparer les deux services garde son intérêt : redémarrer l'interface est ins Tout le code écrit pour ménager les installations « jean » : la migration du dossier de données et ses reprises après échec, la migration de l'agencement système (unités, `/etc/default`, réécriture des chemins), l'élévation Windows qu'elle demandait, la résolution du nom d'unité réellement installée, la reprise des fichiers PID et des skills, les alias `jean` posés à l'installation, les variables `JEAN_*` lues en second. -La CLI perd ses alias hérités — `skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama` — et son aide est réorganisée autour des deux services. Chaque commande a désormais un seul nom. `app` quitte l'aide : c'est le comportement du double-clic, qu'on n'atteint pas en tapant son nom. +La CLI perd ses alias hérités (`skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama`) et son aide est réorganisée autour des deux services. Chaque commande a désormais un seul nom. `app` quitte l'aide : c'est le comportement du double-clic, qu'on n'atteint pas en tapant son nom. -Les releases ne publient plus qu'un jeu de binaires, et sous des noms lisibles : `ajean-linux`, `ajean-linux-arm`, `ajean-macos`, `ajean-macos-arm`, `ajean-windows.exe`, `ajean-windows-arm.exe`. La double publication qui accompagnait la transition n'a plus d'objet. +Les binaires publiés prennent des noms lisibles : `ajean-linux`, `ajean-linux-arm`, `ajean-macos`, `ajean-macos-arm`, `ajean-windows.exe`, `ajean-windows-arm.exe`. Le suffixe `-arm` désigne l'arm64, son absence l'x86-64. -Ce changement de noms n'est pas cosmétique. Les versions 0.7 cherchent leur mise à jour sous la forme `ajean--` : ne trouvant aucun asset qui corresponde, leur bouton « mettre à jour » échoue proprement, sans rien remplacer. C'est délibéré. Laisser la 0.7 installer ce binaire aurait remplacé l'exécutable sans migrer les données ni les unités : le service de lien serait reparti en boucle d'échec sur une sous-commande disparue, et le moteur n'aurait plus trouvé sa configuration — une machine à réparer en SSH après un clic dans un navigateur. +## Ce qui n'a pas été testé + +La reprise 0.7 vers 0.8 a été vérifiée sur Linux (un serveur réel, avec 9 presets, 24 pages de mémoire et 8 modèles) et sur Windows (dossier de test complet), plus par trois tests automatisés. **Elle n'a pas été essayée sur macOS**, faute de machine : le support macOS reste globalement non validé sur du matériel Apple. Sauvegardez `$AJEAN_HOME` avant de vous lancer. diff --git a/internal/ajean/migrate_07.go b/internal/ajean/migrate_07.go index 0cfd203..777fa9e 100644 --- a/internal/ajean/migrate_07.go +++ b/internal/ajean/migrate_07.go @@ -131,6 +131,12 @@ func migrateFrom07(home string) error { if err := importLegacyState(home); err != nil { return err } + // SKILLS/ : les skills ont été fondus dans la mémoire avant la 0.8, le + // dossier ne sert plus à rien mais peut contenir du travail de l'utilisateur. + // On l'archive au lieu de le laisser traîner ou de l'effacer. + if hasEntry(home, "SKILLS") { + _ = os.Rename(filepath.Join(home, "SKILLS"), filepath.Join(home, "avant-0.8", "SKILLS")) + } fmt.Printf("%s reprise terminée — les anciens fichiers sont dans avant-0.8/\n\n", green("[ok]")) return nil } diff --git a/internal/ajean/migrate_07_test.go b/internal/ajean/migrate_07_test.go index 1c663fa..f36a115 100644 --- a/internal/ajean/migrate_07_test.go +++ b/internal/ajean/migrate_07_test.go @@ -39,6 +39,7 @@ func fake07Home(t *testing.T) string { write("mcp.json", `{"mcpServers":{"fs":{"command":"npx","enabled":true}}}`) write("sysprompt.txt", "Tu es AJEAN.\n") write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`) + write("SKILLS/vieux-skill/SKILL.md", "# un skill d'avant\n") return home } @@ -102,8 +103,13 @@ func TestMigration07(t *testing.T) { } // Les fichiers consommés sont rangés, pas détruits. - if _, err := os.Stat(filepath.Join(home, "avant-0.8", "config.env")); err != nil { - t.Error("config.env n'a pas été conservée dans avant-0.8/") + for _, archive := range []string{"config.env", "SKILLS"} { + if _, err := os.Stat(filepath.Join(home, "avant-0.8", archive)); err != nil { + t.Errorf("%s n'a pas été conservé dans avant-0.8/", archive) + } + } + if hasEntry(home, "SKILLS") { + t.Error("SKILLS/ traîne encore à la racine") } // Idempotence : relancer ne doit plus rien voir à migrer. From 98ee293a49753705deceb0ca114f408c07b0c9cf Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 20:00:36 +0200 Subject: [PATCH 243/279] Correction du build macOS casse par le nettoyage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brandTemplatePNG n'etait pas du code mort : sys_tray_darwin.go l'utilise pour l'icone de barre de menus. Ce fichier ne compile qu'avec CGO, donc ni la compilation croisee depuis Windows ni staticcheck sans CGO ne le voyaient — la fonction passait pour inutilisee. Restauree. sys_service_darwin.go affichait encore le chemin de config.env, disparu avec le passage en base. Le piege est documente dans doc.go : seul le job macos de la CI attrape ce genre de regression. --- internal/ajean/doc.go | 7 +++++++ internal/ajean/sys_brand_icon.go | 12 ++++++++++++ internal/ajean/sys_service_darwin.go | 2 -- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/ajean/doc.go b/internal/ajean/doc.go index d6128d2..1b31409 100644 --- a/internal/ajean/doc.go +++ b/internal/ajean/doc.go @@ -20,6 +20,13 @@ // Les suffixes _windows/_linux/_darwin/_unix/_other portent les contraintes de // compilation par OS. // +// ⚠️ Les fichiers macOS ne se compilent qu'AVEC CGO (l'icône de barre de menus +// passe par Cocoa). Une compilation croisée depuis Windows ou Linux, comme les +// analyseurs lancés sans CGO, ne les voit PAS : un symbole qu'eux seuls +// utilisent passe pour du code mort, et le supprimer casse le build macOS sans +// aucun avertissement local. Seul le job « macos » de la CI le détecte. Avant de +// supprimer un symbole jugé inutilisé, vérifier `grep` sur les fichiers _darwin. +// // DEUX SERVICES à l'exécution, un seul binaire : // - ajean-engine (« ajean serve ») exec llama-server ; // - ajean-ui (« ajean web ») sert l'UI locale, le tunnel du relais et diff --git a/internal/ajean/sys_brand_icon.go b/internal/ajean/sys_brand_icon.go index 1a63afc..295310c 100644 --- a/internal/ajean/sys_brand_icon.go +++ b/internal/ajean/sys_brand_icon.go @@ -84,6 +84,18 @@ func encodePNG(img *image.RGBA) []byte { // Exporté pour le générateur d'icône du .exe (tools/gen-icon). func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) } +// brandTemplatePNG rend la variante « template » attendue par macOS : seule la +// couche alpha compte, le système colore la forme selon le thème de la barre de +// menus. Le « j » est donc DÉCOUPÉ (transparent) dans un carré opaque, sans quoi +// une icône entièrement noire disparaît sur une barre de menus sombre. +// +// Utilisée par sys_tray_darwin.go — un fichier que seule une compilation avec +// CGO voit. Les analyseurs lancés sans CGO la croient morte : elle avait été +// supprimée à ce titre, ce qui a cassé la compilation macOS en CI. +func brandTemplatePNG(n int) []byte { + return encodePNG(brandIconImage(n, brandBlack, brandClear)) +} + // BrandICO emballe une ou plusieurs tailles PNG dans un conteneur .ico. Windows // accepte le PNG comme image d'une entrée .ico (alpha conservé pour les coins // arrondis). Plusieurs tailles = un rendu net partout, de la barre des tâches diff --git a/internal/ajean/sys_service_darwin.go b/internal/ajean/sys_service_darwin.go index 25170b6..3a5f105 100644 --- a/internal/ajean/sys_service_darwin.go +++ b/internal/ajean/sys_service_darwin.go @@ -95,7 +95,6 @@ func serviceAction(action string) error { fmt.Printf("%s %s: arrêté\n", yellow("[info]"), svc) } fmt.Printf(" logs : %s\n", launchdLogPath()) - fmt.Printf(" config : %s\n", confPath()) return nil } return fmt.Errorf("action inconnue: %s", action) @@ -171,7 +170,6 @@ func userSvcAction(action string) error { fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName()) } fmt.Printf(" logs : %s\n", logFilePath()) - fmt.Printf(" config : %s\n", confPath()) return nil case "enable", "disable": fmt.Printf("%s '%s' réclame un service système : lance %s pour installer le LaunchDaemon.\n", From eac12112dd987ba7bb8a215abd80987182a780bf Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 20:34:43 +0200 Subject: [PATCH 244/279] Correctifs 0.8 : saisie de la cle de pilotage, identites appairees perdues a la migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux defauts trouves a l'usage : 1. Impossible de saisir la cle de pilotage dans l'UI : le chargement lance une dizaine d'appels /api/* en parallele, chaque 401 rouvrait la modale, et celle-ci vide son champ a l'ouverture. Les caracteres disparaissaient au fur et a mesure de la frappe. Une seule demande de cle est desormais partagee par tous les appels concurrents. Au passage, une modale ouverte n'est plus ecrasee en silence (sa promesse restait pendante pour toujours). 2. migrate_07 n'importait pas .authorized_users : les identites appairees pour le chat chiffre. Symptome trompeur — le tunnel s'etablit, la machine s'affiche en ligne, mais le portail refuse toute conversation. Importe et couvert par un test. --- internal/ajean/migrate_07.go | 22 +++++++++++++++++++++ internal/ajean/migrate_07_test.go | 10 ++++++++++ internal/ajean/ui/index.html | 28 +++++++++++++++++++++++++-- internal/ajean/ui/src/js/03-ui-kit.js | 4 ++++ internal/ajean/ui/src/js/04-api.js | 24 +++++++++++++++++++++-- 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/internal/ajean/migrate_07.go b/internal/ajean/migrate_07.go index 777fa9e..b4d28ab 100644 --- a/internal/ajean/migrate_07.go +++ b/internal/ajean/migrate_07.go @@ -26,6 +26,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" ) @@ -263,6 +264,27 @@ func importLegacyState(home string) error { } } } + // .authorized_users : les identités appairées pour le chat chiffré de bout en + // bout, une clé publique par ligne. L'oublier coûte cher et de façon peu + // lisible : le tunnel s'établit, la machine s'affiche en ligne, mais le + // portail refuse toute conversation chiffrée — il faut ré-appairer sans + // comprendre pourquoi. Vécu sur le serveur de test. + if v := read(".authorized_users"); v != "" { + var list []string + for _, line := range strings.Split(v, "\n") { + if h := strings.ToLower(strings.TrimSpace(line)); h != "" { + list = append(list, h) + } + } + if len(list) > 0 { + sort.Strings(list) + _ = putJSON(bkState, "authorized_users", list) + fmt.Printf(" %s %d identité(s) appairée(s)\n", green("✓"), len(list)) + } + } + // .pair_codes n'est PAS repris : ces codes expirent au bout de 10 minutes, + // les reprendre n'aurait aucun sens. + // mcp.json : on ne garde que la map interne, la clé « mcpServers » disparaît. if v := read("mcp.json"); v != "" { var f struct { diff --git a/internal/ajean/migrate_07_test.go b/internal/ajean/migrate_07_test.go index f36a115..c05d13f 100644 --- a/internal/ajean/migrate_07_test.go +++ b/internal/ajean/migrate_07_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" ) @@ -40,6 +41,7 @@ func fake07Home(t *testing.T) string { write("sysprompt.txt", "Tu es AJEAN.\n") write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`) write("SKILLS/vieux-skill/SKILL.md", "# un skill d'avant\n") + write(".authorized_users", "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF001122334455667788AA\n") return home } @@ -101,6 +103,14 @@ func TestMigration07(t *testing.T) { if conv.Seq != 42 { t.Errorf("conversation perdue (seq %d)", conv.Seq) } + // Les identités appairées : les perdre casse le chat chiffré du portail + // sans que rien ne le dise (le tunnel, lui, s'établit normalement). + // La liste est mise en cache une fois par process (authOnce) : en production + // c'est un process neuf qui sert après l'install, ici il faut le dire. + authOnce, authSet = sync.Once{}, map[string]bool{} + if !isAuthorizedUser("aabbccddeeff00112233445566778899aabbccddeeff001122334455667788aa") { + t.Error("identité appairée perdue : le chat E2E du portail serait refusé") + } // Les fichiers consommés sont rangés, pas détruits. for _, archive := range []string{"config.env", "SKILLS"} { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 634cd6f..79ef7aa 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1912,6 +1912,10 @@ function _openAsk(kind, message, opts){ // où cette fonction remplace le window.askConfirm du bootstrap boîte noire). if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; } opts=opts||{}; + // Une modale déjà ouverte ne doit pas être écrasée en silence : son appelant + // attend une réponse, et l'écraser laissait sa promesse pendante POUR + // TOUJOURS (requête suspendue, bouton figé). On l'annule proprement d'abord. + if(_askResolver){ const prev=_askResolver; _askResolver=null; prev(_askKind==='prompt' ? null : false); } _askKind=kind; document.getElementById('ask-title').textContent = opts.title || (kind==='alert'?'Info':kind==='prompt'?'Saisie':'Confirmation'); document.getElementById('ask-msg').textContent = message||''; @@ -1969,11 +1973,31 @@ async function jfetch(u, opts){ } finally{ if(timer) clearTimeout(timer); } if(r.status === 401){ - const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'}); - if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } + const k = await askKeyOnce(); + if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } } return r; } +// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels. +// +// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le +// rafraîchissement périodique en relance autant. Sans ce partage, CHAQUE 401 +// rouvrait la modale, laquelle vide son champ à l'ouverture : impossible de +// taper la clé, les caractères disparaissaient au fur et à mesure. Les appels +// concurrents attendent désormais la même promesse, et repartent tous avec la +// clé saisie une seule fois. +let _keyAsk = null; +function askKeyOnce(){ + if(!_keyAsk){ + _keyAsk = askPrompt('Clé de pilotage AJEAN requise :', {title:'Authentification', placeholder:'clé…'}) + .then(k => { + _keyAsk = null; + if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); } + return k; + }, e => { _keyAsk = null; throw e; }); + } + return _keyAsk; +} 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(){ diff --git a/internal/ajean/ui/src/js/03-ui-kit.js b/internal/ajean/ui/src/js/03-ui-kit.js index 6c3560a..d17cc73 100644 --- a/internal/ajean/ui/src/js/03-ui-kit.js +++ b/internal/ajean/ui/src/js/03-ui-kit.js @@ -54,6 +54,10 @@ function _openAsk(kind, message, opts){ // où cette fonction remplace le window.askConfirm du bootstrap boîte noire). if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; } opts=opts||{}; + // Une modale déjà ouverte ne doit pas être écrasée en silence : son appelant + // attend une réponse, et l'écraser laissait sa promesse pendante POUR + // TOUJOURS (requête suspendue, bouton figé). On l'annule proprement d'abord. + if(_askResolver){ const prev=_askResolver; _askResolver=null; prev(_askKind==='prompt' ? null : false); } _askKind=kind; document.getElementById('ask-title').textContent = opts.title || (kind==='alert'?'Info':kind==='prompt'?'Saisie':'Confirmation'); document.getElementById('ask-msg').textContent = message||''; diff --git a/internal/ajean/ui/src/js/04-api.js b/internal/ajean/ui/src/js/04-api.js index 6b3dcbb..5afc573 100644 --- a/internal/ajean/ui/src/js/04-api.js +++ b/internal/ajean/ui/src/js/04-api.js @@ -30,10 +30,30 @@ async function jfetch(u, opts){ } finally{ if(timer) clearTimeout(timer); } if(r.status === 401){ - const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'}); - if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } + const k = await askKeyOnce(); + if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } } return r; } +// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels. +// +// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le +// rafraîchissement périodique en relance autant. Sans ce partage, CHAQUE 401 +// rouvrait la modale, laquelle vide son champ à l'ouverture : impossible de +// taper la clé, les caractères disparaissaient au fur et à mesure. Les appels +// concurrents attendent désormais la même promesse, et repartent tous avec la +// clé saisie une seule fois. +let _keyAsk = null; +function askKeyOnce(){ + if(!_keyAsk){ + _keyAsk = askPrompt('Clé de pilotage AJEAN requise :', {title:'Authentification', placeholder:'clé…'}) + .then(k => { + _keyAsk = null; + if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); } + return k; + }, e => { _keyAsk = null; throw e; }); + } + return _keyAsk; +} 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(); } From 43ab55f083a202f02a0a680b029f6b63c548ff92 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 20:35:34 +0200 Subject: [PATCH 245/279] staticcheck : exception documentee pour brandTemplatePNG Elle n'est utilisee que par le tray macOS, que staticcheck ne compile pas hors CGO. Sans cette directive, la CI (qui lance staticcheck sur ubuntu) echouerait sur une fonction pourtant bien utilisee. --- internal/ajean/sys_brand_icon.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/ajean/sys_brand_icon.go b/internal/ajean/sys_brand_icon.go index 295310c..39dbc04 100644 --- a/internal/ajean/sys_brand_icon.go +++ b/internal/ajean/sys_brand_icon.go @@ -92,6 +92,7 @@ func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, // Utilisée par sys_tray_darwin.go — un fichier que seule une compilation avec // CGO voit. Les analyseurs lancés sans CGO la croient morte : elle avait été // supprimée à ce titre, ce qui a cassé la compilation macOS en CI. +//lint:ignore U1000 utilisée par sys_tray_darwin.go, invisible sans CGO/macOS func brandTemplatePNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandClear)) } From 479da7b8326231d27db26924689b139daa949dce Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 20:37:53 +0200 Subject: [PATCH 246/279] gofmt : ligne de separation avant la directive lint --- internal/ajean/sys_brand_icon.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/ajean/sys_brand_icon.go b/internal/ajean/sys_brand_icon.go index 39dbc04..f38e81d 100644 --- a/internal/ajean/sys_brand_icon.go +++ b/internal/ajean/sys_brand_icon.go @@ -92,6 +92,7 @@ func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, // Utilisée par sys_tray_darwin.go — un fichier que seule une compilation avec // CGO voit. Les analyseurs lancés sans CGO la croient morte : elle avait été // supprimée à ce titre, ce qui a cassé la compilation macOS en CI. +// //lint:ignore U1000 utilisée par sys_tray_darwin.go, invisible sans CGO/macOS func brandTemplatePNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandClear)) From 351ee25ad9005c7be6344a6724c1efa82db983cb Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 20:45:35 +0200 Subject: [PATCH 247/279] Zips macOS en minuscules : ajean-macos.zip, ajean-macos-arm.zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ils prennent simplement le nom du binaire correspondant. Effet de bord corrige au passage : tous les assets commencent desormais par « ajean- », donc le motif « ajean-* *.zip » de SHA256SUMS aurait liste les zips DEUX fois, et un fichier d'empreintes a doublons est precisement ce que verifie 'ajean update'. --- .github/workflows/mac-build.yml | 6 +++--- .github/workflows/release.yml | 9 ++++++--- README.md | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/mac-build.yml b/.github/workflows/mac-build.yml index 5386ae3..424bdf4 100644 --- a/.github/workflows/mac-build.yml +++ b/.github/workflows/mac-build.yml @@ -44,9 +44,9 @@ jobs: 'NSHighResolutionCapable' \ '' > "$app/Contents/Info.plist" codesign --force --deep -s - "$app" || true - (cd dist && zip -qry AJEAN-macos-arm.zip AJEAN.app && rm -rf AJEAN.app) + (cd dist && zip -qry ajean-macos-arm.zip AJEAN.app && rm -rf AJEAN.app) ls -l dist - uses: actions/upload-artifact@v4 with: - name: AJEAN-macos-arm - path: dist/AJEAN-macos-arm.zip + name: ajean-macos-arm + path: dist/ajean-macos-arm.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c3f3e6..2125e14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: # Signature ad-hoc : sans elle, macOS refuse purement et simplement # de lancer un binaire arm64 non signé (pas juste un avertissement). codesign --force --deep -s - "$app" || true - (cd "dist/bundle-$bin" && zip -qry "../AJEAN-${bin#ajean-}.zip" AJEAN.app) + (cd "dist/bundle-$bin" && zip -qry "../$bin.zip" AJEAN.app) rm -rf "dist/bundle-$bin" done ls -l dist @@ -119,8 +119,11 @@ jobs: path: dist - name: SHA256SUMS run: | - chmod +x dist/ajean-macos* - (cd dist && sha256sum ajean-* *.zip > SHA256SUMS) + chmod +x dist/ajean-macos dist/ajean-macos-arm + # Tous les assets commencent par « ajean- », zips compris : un seul + # motif suffit. Ajouter « *.zip » les compterait DEUX fois, et un + # SHA256SUMS à doublons est exactement ce que `ajean update` vérifie. + (cd dist && sha256sum ajean-* > SHA256SUMS) ls -l dist && cat dist/SHA256SUMS - name: publier la release uses: softprops/action-gh-release@v2 diff --git a/README.md b/README.md index 9513c87..680d00c 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ ajean status ## macOS -La page [Releases](../../releases) publie `AJEAN-macos-arm.zip` (Apple Silicon) et `AJEAN-macos.zip` (Intel), un bundle **`AJEAN.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock. +La page [Releases](../../releases) publie `ajean-macos-arm.zip` (Apple Silicon) et `ajean-macos.zip` (Intel), un bundle **`AJEAN.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock. L'application n'est signée qu'en ad-hoc : au premier lancement, faire **clic droit → Ouvrir**. From 672317d68c27a38afed963e6c4e214120ed2f8cf Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 21:08:20 +0200 Subject: [PATCH 248/279] =?UTF-8?q?Windows=20:=20=C2=AB=20Quitter=20=C2=BB?= =?UTF-8?q?=20arrete=20aussi=20le=20moteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le moteur tourne dans un process detache, qui survit volontairement a la fermeture de l'interface pour garder le modele charge entre deux ouvertures. Mais apres un « Quitter », plus rien ne le pilote et il conserve des dizaines de Go de RAM sans aucune fenetre pour l'expliquer. La fonction de sortie du systray appelle desormais svcStop, qui tue l'arbre (taskkill /T) donc llama-server avec. Windows uniquement : c'est la seule plateforme ou l'app est proprietaire du moteur. Sous Linux et macOS il appartient a systemd ou launchd, et fermer une interface n'a pas a arreter un service systeme. Le redemarrage apres mise a jour ne passe PAS par ce chemin (il a son propre os.Exit), donc une MAJ ne dechargera pas le modele. --- internal/ajean/sys_tray_darwin.go | 9 ++++++++- internal/ajean/sys_tray_windows.go | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/internal/ajean/sys_tray_darwin.go b/internal/ajean/sys_tray_darwin.go index 820d0ac..4ffe57f 100644 --- a/internal/ajean/sys_tray_darwin.go +++ b/internal/ajean/sys_tray_darwin.go @@ -43,6 +43,13 @@ func runTray(url string) { } }() }, func() { - os.Exit(0) // « Quitter » → arrête tout le process (serveur inclus) + // « Quitter » arrête l'application : l'interface et, avec elle, le + // tunnel d'accès distant qui tourne dans ce process. + // + // Le MOTEUR, lui, n'est pas arrêté : sous macOS c'est un daemon launchd, + // que seul root peut piloter. Fermer une interface n'a pas à arrêter un + // service système. Sous Windows, où l'app est propriétaire du moteur, + // « Quitter » l'arrête bel et bien (voir sys_tray_windows.go). + os.Exit(0) }) } diff --git a/internal/ajean/sys_tray_windows.go b/internal/ajean/sys_tray_windows.go index ddf2d4f..0456d26 100644 --- a/internal/ajean/sys_tray_windows.go +++ b/internal/ajean/sys_tray_windows.go @@ -26,7 +26,7 @@ func runTray(url string) { systray.SetTooltip("AJEAN — votre IA locale") mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface") systray.AddSeparator() - mQuit := systray.AddMenuItem("Quitter", "Arrêter AJEAN") + mQuit := systray.AddMenuItem("Quitter", "Arrêter AJEAN et décharger le modèle") go func() { for { @@ -40,7 +40,20 @@ func runTray(url string) { } }() }, func() { - os.Exit(0) // « Quitter » → arrête tout le process (serveur inclus) + // « Quitter » doit tout arrêter, moteur compris. + // + // Le moteur tourne dans un process DÉTACHÉ (voir svcStart) : il survit + // délibérément à la fermeture de l'app pour que le modèle reste chargé + // entre deux ouvertures de l'interface. Mais quand on quitte pour de + // bon, il n'a plus rien pour le piloter et garde des dizaines de Go de + // RAM, sans la moindre fenêtre pour l'expliquer. svcStop tue l'arbre + // (taskkill /T), donc llama-server avec lui. + // + // Windows seulement : ici l'app EST le propriétaire du moteur. Sous + // Linux et macOS c'est systemd ou launchd, et fermer une interface n'a + // pas à arrêter un service système. + _ = svcStop(false) + os.Exit(0) }) } From e0dcbe715ac92f886b713cb62e913825e5a7b677 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 21:12:35 +0200 Subject: [PATCH 249/279] Windows : plus de question a la premiere installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le double-clic demandait « Installer AJEAN ou juste le lancer ? ». La question n'avait qu'une reponse utile : rester a l'emplacement du fichier telecharge ne donne pas une installation utilisable (aucun raccourci, rien dans le PATH, et une application qui disparait le jour ou l'on vide ses telechargements). Faire porter a l'utilisateur un choix qui n'existe pas ne l'aide pas. L'installation se fait donc directement, et le message qui suit dit ce qui a ete fait : ou le programme s'est installe, et que le fichier telecharge peut etre supprime. Le repli en cas d'echec (droits sur %ProgramData%) est inchange. Les deux autres boites de dialogue sont conservees : elles portent de vraies decisions (fichier plus ancien que la version installee, fermer une application en cours pour appliquer une mise a jour). --- internal/ajean/sys_firstrun_windows.go | 32 ++++++++++---------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index d38e353..35c540d 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -100,28 +100,20 @@ func appFirstRun() bool { return runAsInstaller(target) } - // Première installation : là, une question. C'est le seul moment où - // l'utilisateur a intérêt à savoir ce qui va être posé sur sa machine, et - // c'est l'absence de cette question qui rendait le double-clic illisible. + // Première installation. On ne demande PAS l'autorisation : il n'y a rien à + // arbitrer. Rester à l'emplacement du fichier téléchargé ne donne pas une + // installation utilisable (pas de raccourci, rien dans le PATH, et une + // application qui disparaît le jour où l'on vide son dossier de + // téléchargements). Poser une question dont une seule réponse mène quelque + // part, c'est faire porter à l'utilisateur un choix qui n'existe pas. // - // Un refus n'est PAS mémorisé. Il l'a été en 0.6.11 pour éviter de reposer la - // question à chaque lancement, mais il devenait alors définitif : plus aucun - // moyen d'installer AJEAN, même en relançant le fichier téléchargé. Un « non » - // veut dire « pas maintenant », jamais « plus jamais » : c'est le comportement - // de n'importe quel installeur, qui repropose tant qu'on le relance. - if messageBox( - "Installer AJEAN sur cet ordinateur ?\n\n"+ - "• le programme sera copié dans :\n "+target+"\n"+ - "• un raccourci sera ajouté au menu Démarrer et au Bureau\n"+ - "• vos réglages et modèles seront rangés dans :\n "+AjeanHome()+"\n\n"+ - "Vous pourrez ensuite supprimer le fichier que vous venez de télécharger.\n\n"+ - "Répondre Non démarre AJEAN sans rien installer. La question reviendra\n"+ - "au prochain lancement de ce fichier.", - "Installation d'AJEAN", mbYesNo|mbIconQuestion) != idYes { - return false - } - + // On l'informe en revanche de ce qui vient d'être fait : il a double-cliqué + // un fichier, autant dire où le programme s'est installé et qu'il peut + // supprimer ce qu'il a téléchargé. if _, err := installSelf(filepath.Dir(target)); err != nil { + // Échec le plus courant : pas les droits sur %ProgramData%. AJEAN reste + // parfaitement utilisable depuis son emplacement actuel, le dossier de + // données étant ailleurs — on démarre donc au lieu d'abandonner. messageBox("Installation impossible :\n\n"+err.Error()+"\n\nAJEAN va démarrer depuis l'emplacement actuel.", "AJEAN", mbIconInfo) return false } From 7cea41c2a5e80d4298969c29ca2ee185116604cc Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 21:24:50 +0200 Subject: [PATCH 250/279] Notes de release : les deux changements Windows --- RELEASE_NOTES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fa8c618..5253d7e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -38,6 +38,12 @@ Désormais `ajean web` est cette porte unique : il sert l'interface et, si un je Séparer les deux services garde son intérêt : redémarrer l'interface est instantané, alors que redémarrer le moteur recharge des dizaines de gigaoctets. +## Sur Windows + +**« Quitter » arrête vraiment tout.** Le moteur tourne dans un processus détaché, qui survit volontairement à la fermeture de l'interface pour garder le modèle chargé entre deux ouvertures. Mais après un « Quitter » depuis la zone de notification, plus rien ne le pilotait et il conservait des dizaines de gigaoctets de mémoire, sans la moindre fenêtre pour l'expliquer. Quitter décharge désormais le modèle. Ailleurs, rien ne change : sous Linux et macOS le moteur appartient à systemd ou launchd, et fermer une interface n'a pas à arrêter un service système. + +**Plus de question au premier lancement.** Le double-clic demandait s'il fallait installer AJEAN ou seulement le lancer. La question n'avait qu'une réponse utile : rester à l'emplacement du fichier téléchargé ne donne pas une installation exploitable, sans raccourci, sans rien dans le PATH, et avec une application qui disparaît le jour où l'on vide ses téléchargements. L'installation se fait donc directement, et le message qui suit dit ce qui a été fait au lieu de demander une permission. + ## Ce qui a été retiré Tout le code écrit pour ménager les installations « jean » : la migration du dossier de données et ses reprises après échec, la migration de l'agencement système (unités, `/etc/default`, réécriture des chemins), l'élévation Windows qu'elle demandait, la résolution du nom d'unité réellement installée, la reprise des fichiers PID et des skills, les alias `jean` posés à l'installation, les variables `JEAN_*` lues en second. From 4dc7848301429de6e09dcfefa49d12f5cd4ac26b Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 21:38:57 +0200 Subject: [PATCH 251/279] Windows : la redirection et les tubes fonctionnent enfin 'ajean where > fichier.txt' n'ecrivait rien dans le fichier et 'ajean help | findstr x' ne transmettait rien : tout partait sur la console. Cause : le binaire etant en sous-systeme GUI, setupConsole reouvrait stdout, stderr et stdin sur CONOUT$/CONIN$ pour avoir ou ecrire. Il ecrasait ce faisant la redirection que le shell avait posee. Le test naif ne suffit pas : AttachConsole REMPLACE lui-meme les handles standard par ceux de la console, donc les interroger apres le rattachement ne montre jamais qu'une console. On releve donc leur type AVANT d'attacher, et on ne reouvre que ceux qui sont reellement inutilisables. Verifie sur un binaire compile comme celui de la release (sous-systeme GUI) : redirection vers un fichier, tube vers findstr, et affichage console inchange. --- internal/ajean/sys_console_windows.go | 61 ++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/internal/ajean/sys_console_windows.go b/internal/ajean/sys_console_windows.go index 5d0eacf..10a4c73 100644 --- a/internal/ajean/sys_console_windows.go +++ b/internal/ajean/sys_console_windows.go @@ -21,6 +21,14 @@ import ( // Retourne true si on dispose d'une console (→ usage CLI), false sinon (double- // clic sans console → expérience « application »). func setupConsole() bool { + // Relevé AVANT le rattachement, et c'est tout l'enjeu : AttachConsole + // REMPLACE lui-même les handles standard du processus par ceux de la + // console. Interrogé après, on ne voit donc plus jamais qu'une console, et + // la trace de la redirection posée par le shell est déjà perdue. + outRedirected := stdRedirected(stdOutputHandle) + errRedirected := stdRedirected(stdErrorHandle) + inRedirected := stdRedirected(stdInputHandle) + const attachParentProcess = ^uintptr(0) // (DWORD)-1 = ATTACH_PARENT_PROCESS k := syscall.NewLazyDLL("kernel32.dll") r, _, _ := k.NewProc("AttachConsole").Call(attachParentProcess) @@ -29,17 +37,60 @@ func setupConsole() bool { } // Réouvre les flux standard sur la console fraîchement rattachée : sans ça, // os.Stdout/Stderr pointent sur des handles invalides (sous-système GUI). - if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil { - os.Stdout = con - os.Stderr = con + // + // MAIS seulement ceux que le shell n'a pas déjà branchés ailleurs. Réouvrir + // systématiquement sur CONOUT$ écrasait la redirection : « ajean where > + // fichier.txt » n'écrivait rien dans le fichier, et « ajean where | findstr » + // ne transmettait rien au tube — tout partait sur la console. On ne touche + // donc qu'aux flux réellement inutilisables. + if !outRedirected { + if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil { + os.Stdout = con + } } - if cin, err := os.OpenFile("CONIN$", os.O_RDONLY, 0); err == nil { - os.Stdin = cin + if !errRedirected { + if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil { + os.Stderr = con + } + } + if !inRedirected { + if cin, err := os.OpenFile("CONIN$", os.O_RDONLY, 0); err == nil { + os.Stdin = cin + } } configureConsole() return true } +// Handles standard, tels que les nomme GetStdHandle. +const ( + stdInputHandle = ^uintptr(9) // -10 + stdOutputHandle = ^uintptr(10) // -11 + stdErrorHandle = ^uintptr(11) // -12 +) + +// stdRedirected dit si un flux standard est DÉJÀ branché sur un fichier ou un +// tube par le processus parent. Dans ce cas il ne faut surtout pas le remplacer +// par la console : c'est ce qui cassait « > fichier » et « | commande ». +// +// Un flux de type « caractère » (la console elle-même) ou inutilisable n'est pas +// considéré comme redirigé : on le réouvre alors sur CONOUT$/CONIN$, ce dont un +// binaire GUI a besoin pour écrire quelque part. +func stdRedirected(which uintptr) bool { + const ( + invalidHandle = ^uintptr(0) // INVALID_HANDLE_VALUE + fileTypeDisk = 1 + fileTypePipe = 3 + ) + k := syscall.NewLazyDLL("kernel32.dll") + h, _, _ := k.NewProc("GetStdHandle").Call(which) + if h == 0 || h == invalidHandle { + return false + } + t, _, _ := k.NewProc("GetFileType").Call(h) + return t == fileTypeDisk || t == fileTypePipe +} + // configureConsole passe la console en UTF-8 + traitement des séquences ANSI // (couleurs/curseur), comme le faisait l'init() au chargement — mais APRÈS le // rattachement, sinon ça ciblait une console encore inexistante. From 922b7a6862a2ecf493c019d306ecbe9ccd505602 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 21:40:15 +0200 Subject: [PATCH 252/279] Notes de release : redirection et tubes sous Windows --- RELEASE_NOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5253d7e..298e6ee 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -42,6 +42,10 @@ Séparer les deux services garde son intérêt : redémarrer l'interface est ins **« Quitter » arrête vraiment tout.** Le moteur tourne dans un processus détaché, qui survit volontairement à la fermeture de l'interface pour garder le modèle chargé entre deux ouvertures. Mais après un « Quitter » depuis la zone de notification, plus rien ne le pilotait et il conservait des dizaines de gigaoctets de mémoire, sans la moindre fenêtre pour l'expliquer. Quitter décharge désormais le modèle. Ailleurs, rien ne change : sous Linux et macOS le moteur appartient à systemd ou launchd, et fermer une interface n'a pas à arrêter un service système. +**La redirection et les tubes fonctionnent.** `ajean where > fichier.txt` n'écrivait rien dans le fichier, et `ajean help | findstr quelquechose` ne transmettait rien : tout partait sur la console. Le programme réouvrait sa sortie sur la console pour avoir où écrire, et écrasait ce faisant ce que le shell lui avait branché. + +À savoir : lancé depuis `cmd`, AJEAN rend la main immédiatement et son affichage arrive après la nouvelle invite. C'est le prix du sous-système graphique, celui-là même qui évite la fenêtre noire au double-clic. + **Plus de question au premier lancement.** Le double-clic demandait s'il fallait installer AJEAN ou seulement le lancer. La question n'avait qu'une réponse utile : rester à l'emplacement du fichier téléchargé ne donne pas une installation exploitable, sans raccourci, sans rien dans le PATH, et avec une application qui disparaît le jour où l'on vide ses téléchargements. L'installation se fait donc directement, et le message qui suit dit ce qui a été fait au lieu de demander une permission. ## Ce qui a été retiré From aa965227fc749aee19226d36ff1aa2e615c95f3b Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 21:59:42 +0200 Subject: [PATCH 253/279] Windows : sous-systeme console, pour qu'ajean se comporte en vraie commande Dans un terminal, cmd rendait la main avant qu'ajean n'ait rien ecrit : l'invite se reaffichait puis la sortie arrivait par-dessus. Windows n'attend jamais la fin d'un programme en sous-systeme graphique. La meme cause empechait les redirections et les tubes, et rendait 'ajean chat' inutilisable (le shell et ajean se disputaient l'entree). Le binaire passe donc en sous-systeme console. La fenetre noire au double-clic, que le sous-systeme graphique evitait, est traitee autrement : quand aucun autre processus ne partage notre console, c'est que Windows l'a creee pour nous, donc que personne ne nous a lances depuis un terminal. On la masque et on la libere. Les raccourcis demandent en plus un demarrage minimise. La relance interne (apres mise a jour) passe desormais 'app' explicitement : la detection ne repond qu'a la question du double-clic, et un processus sans console du tout ne doit pas avoir a y repondre. Verifie : ordre d'affichage correct en session cmd, redirection et tube fonctionnels, 'ajean' sans argument affiche l'aide, et aucune fenetre console visible au lancement facon double-clic. --- .github/workflows/release.yml | 7 +- RELEASE_NOTES.md | 6 +- internal/ajean/sys_console_windows.go | 119 ++++++++++--------------- internal/ajean/sys_firstrun_windows.go | 21 +++-- 4 files changed, 68 insertions(+), 85 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2125e14..ed84f34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,9 +107,10 @@ jobs: windows/amd64:ajean-windows.exe windows/arm64:ajean-windows-arm.exe; do target=${t%%:*}; name=${t#*:} os=${target%/*}; arch=${target#*/}; ldflags="-s -w" - # Windows : sous-système GUI → aucune console noire au double-clic - # (l'app se rattache à la console du terminal parent si lancée en CLI). - [ "$os" = windows ] && ldflags="$ldflags -H=windowsgui" + # Windows : sous-système CONSOLE, pour que `cmd` attende la fin du + # programme et que redirections, tubes et commandes interactives + # fonctionnent. La console créée au double-clic est refermée par le + # programme lui-même (voir setupConsole, sys_console_windows.go). GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ go build -trimpath -ldflags="$ldflags" -o "dist/$name" ./cmd/ajean done diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 298e6ee..d3209a1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -42,9 +42,11 @@ Séparer les deux services garde son intérêt : redémarrer l'interface est ins **« Quitter » arrête vraiment tout.** Le moteur tourne dans un processus détaché, qui survit volontairement à la fermeture de l'interface pour garder le modèle chargé entre deux ouvertures. Mais après un « Quitter » depuis la zone de notification, plus rien ne le pilotait et il conservait des dizaines de gigaoctets de mémoire, sans la moindre fenêtre pour l'expliquer. Quitter décharge désormais le modèle. Ailleurs, rien ne change : sous Linux et macOS le moteur appartient à systemd ou launchd, et fermer une interface n'a pas à arrêter un service système. -**La redirection et les tubes fonctionnent.** `ajean where > fichier.txt` n'écrivait rien dans le fichier, et `ajean help | findstr quelquechose` ne transmettait rien : tout partait sur la console. Le programme réouvrait sa sortie sur la console pour avoir où écrire, et écrasait ce faisant ce que le shell lui avait branché. +**AJEAN se comporte enfin comme une vraie commande.** Dans un terminal, `ajean` rendait la main avant d'avoir écrit quoi que ce soit : l'invite se réaffichait, et la sortie du programme arrivait par-dessus, comme si la commande avait été tapée deux fois. -À savoir : lancé depuis `cmd`, AJEAN rend la main immédiatement et son affichage arrive après la nouvelle invite. C'est le prix du sous-système graphique, celui-là même qui évite la fenêtre noire au double-clic. +La même cause empêchait `ajean where > fichier.txt` d'écrire dans le fichier, `ajean help | findstr ...` de transmettre quoi que ce soit, et rendait `ajean chat` inutilisable depuis un terminal, le shell et AJEAN se disputant le clavier. Le binaire était compilé en sous-système graphique, et Windows n'attend jamais la fin d'un programme de ce type. + +Il est désormais en sous-système console. La contrepartie, une fenêtre noire au double-clic, est traitée par le programme lui-même : constatant qu'aucun terminal ne l'a lancé, il referme aussitôt la console que Windows lui a allouée. Les raccourcis posés à l'installation demandent en plus un démarrage minimisé, si bien qu'elle n'est jamais peinte à l'écran. **Plus de question au premier lancement.** Le double-clic demandait s'il fallait installer AJEAN ou seulement le lancer. La question n'avait qu'une réponse utile : rester à l'emplacement du fichier téléchargé ne donne pas une installation exploitable, sans raccourci, sans rien dans le PATH, et avec une application qui disparaît le jour où l'on vide ses téléchargements. L'installation se fait donc directement, et le message qui suit dit ce qui a été fait au lieu de demander une permission. diff --git a/internal/ajean/sys_console_windows.go b/internal/ajean/sys_console_windows.go index 10a4c73..3222fee 100644 --- a/internal/ajean/sys_console_windows.go +++ b/internal/ajean/sys_console_windows.go @@ -4,96 +4,67 @@ package ajean // sys_console_windows.go — gestion de la console sous Windows. // -// Le binaire est compilé en sous-système GUI (`-H=windowsgui`) : au double-clic, -// Windows n'alloue AUCUNE console → plus jamais de « fenêtre noire de terminal » -// qui s'ouvre et se ferme. En contrepartie, un binaire GUI lancé depuis un -// terminal (cmd/PowerShell) n'écrit nulle part par défaut : on se rattache alors -// explicitement à la console du parent et on réouvre stdout/stderr/stdin dessus, -// pour que l'usage CLI (`ajean web`, `ajean status`, …) reste lisible. +// Le binaire est compilé en sous-système CONSOLE. C'est ce qui fait que `cmd` +// ATTEND la fin du programme : avec un binaire graphique, il rendait la main +// aussitôt et réaffichait son invite AVANT la sortie d'AJEAN, ce qui donnait +// +// C:\Users\Nathan>ajean +// +// C:\Users\Nathan>ajean 0.8.0 — manager llama.cpp + UI web +// +// La même cause cassait la redirection et les tubes, et rendait `ajean chat` +// inutilisable depuis un terminal (le shell et AJEAN se disputaient l'entrée). +// +// EN CONTREPARTIE, Windows alloue une console à tout lancement sans terminal — +// un double-clic. On la referme alors immédiatement : voir setupConsole. Les +// raccourcis créés par l'installation demandent en plus un démarrage minimisé, +// si bien que cette console n'est jamais peinte à l'écran. import ( - "os" "syscall" "unsafe" ) -// setupConsole rattache le process à la console de son parent si elle existe. -// Retourne true si on dispose d'une console (→ usage CLI), false sinon (double- -// clic sans console → expérience « application »). +// setupConsole décide dans quel monde on tourne et renvoie true en usage CLI. +// +// Le signal est le nombre de processus rattachés à notre console : si nous +// sommes le SEUL, c'est que Windows l'a créée pour nous, donc qu'aucun terminal +// ne nous a lancés. On la fait disparaître et on bascule en mode application. +// Un shell rattaché (cmd, PowerShell, Windows Terminal) en fait au moins deux. func setupConsole() bool { - // Relevé AVANT le rattachement, et c'est tout l'enjeu : AttachConsole - // REMPLACE lui-même les handles standard du processus par ceux de la - // console. Interrogé après, on ne voit donc plus jamais qu'une console, et - // la trace de la redirection posée par le shell est déjà perdue. - outRedirected := stdRedirected(stdOutputHandle) - errRedirected := stdRedirected(stdErrorHandle) - inRedirected := stdRedirected(stdInputHandle) - - const attachParentProcess = ^uintptr(0) // (DWORD)-1 = ATTACH_PARENT_PROCESS k := syscall.NewLazyDLL("kernel32.dll") - r, _, _ := k.NewProc("AttachConsole").Call(attachParentProcess) - if r == 0 { - return false // aucune console parente = lancé par double-clic (mode app) - } - // Réouvre les flux standard sur la console fraîchement rattachée : sans ça, - // os.Stdout/Stderr pointent sur des handles invalides (sous-système GUI). + + // Combien de processus partagent notre console ? Nous seul (1) = personne ne + // nous a lancés depuis un terminal, Windows a créé cette console pour nous : + // c'est un double-clic. Un shell rattaché en fait au moins deux. // - // MAIS seulement ceux que le shell n'a pas déjà branchés ailleurs. Réouvrir - // systématiquement sur CONOUT$ écrasait la redirection : « ajean where > - // fichier.txt » n'écrivait rien dans le fichier, et « ajean where | findstr » - // ne transmettait rien au tube — tout partait sur la console. On ne touche - // donc qu'aux flux réellement inutilisables. - if !outRedirected { - if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil { - os.Stdout = con - } - } - if !errRedirected { - if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil { - os.Stderr = con - } - } - if !inRedirected { - if cin, err := os.OpenFile("CONIN$", os.O_RDONLY, 0); err == nil { - os.Stdin = cin + // Le compte vaut 0 quand il n'y a aucune console (parent détaché). On répond + // alors « CLI », le cas contraire étant couvert autrement : les relances + // internes passent « app » explicitement (voir launch, sys_firstrun_windows.go). + // Mieux vaut afficher une aide inutile que démarrer un serveur en silence. + var pids [8]uint32 + n, _, _ := k.NewProc("GetConsoleProcessList").Call( + uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids))) + if n == 1 { + if hwnd, _, _ := k.NewProc("GetConsoleWindow").Call(); hwnd != 0 { + // Masquer AVANT de libérer : sinon la fenêtre reste peinte le temps + // que Windows la détruise. + const swHide = 0 + _, _, _ = k.NewProc("ShowWindow").Call(hwnd, swHide) } + _, _, _ = k.NewProc("FreeConsole").Call() + return false } + + // Lancé depuis un terminal : les flux standard sont déjà ceux du shell, + // redirections et tubes compris. Il n'y a rien à rebrancher, seulement à + // régler l'encodage et les couleurs. configureConsole() return true } -// Handles standard, tels que les nomme GetStdHandle. -const ( - stdInputHandle = ^uintptr(9) // -10 - stdOutputHandle = ^uintptr(10) // -11 - stdErrorHandle = ^uintptr(11) // -12 -) - -// stdRedirected dit si un flux standard est DÉJÀ branché sur un fichier ou un -// tube par le processus parent. Dans ce cas il ne faut surtout pas le remplacer -// par la console : c'est ce qui cassait « > fichier » et « | commande ». -// -// Un flux de type « caractère » (la console elle-même) ou inutilisable n'est pas -// considéré comme redirigé : on le réouvre alors sur CONOUT$/CONIN$, ce dont un -// binaire GUI a besoin pour écrire quelque part. -func stdRedirected(which uintptr) bool { - const ( - invalidHandle = ^uintptr(0) // INVALID_HANDLE_VALUE - fileTypeDisk = 1 - fileTypePipe = 3 - ) - k := syscall.NewLazyDLL("kernel32.dll") - h, _, _ := k.NewProc("GetStdHandle").Call(which) - if h == 0 || h == invalidHandle { - return false - } - t, _, _ := k.NewProc("GetFileType").Call(h) - return t == fileTypeDisk || t == fileTypePipe -} - // configureConsole passe la console en UTF-8 + traitement des séquences ANSI -// (couleurs/curseur), comme le faisait l'init() au chargement — mais APRÈS le -// rattachement, sinon ça ciblait une console encore inexistante. +// (couleurs/curseur). func configureConsole() { const ( cpUTF8 = 65001 diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index 35c540d..e47cfc8 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -29,11 +29,11 @@ import ( // // Maintenant, deux cas seulement : // -// 1. AJEAN n'est pas installé. On DEMANDE : c'est le seul moment où -// l'utilisateur gagne à savoir ce qu'on pose sur sa machine et où. Puis -// copie + PATH + raccourcis, et on démarre depuis la copie installée. Un -// refus vaut « pas maintenant » et la question revient au prochain lancement -// du fichier : mémorisé, il ne laissait AUCUN moyen d'installer AJEAN. +// 1. AJEAN n'est pas installé. On installe, sans demander : rester à +// l'emplacement du fichier téléchargé ne donne pas une installation +// utilisable, la question n'avait donc qu'une réponse. Copie + PATH + +// raccourcis, un message qui dit ce qui a été fait, et on démarre depuis la +// copie installée. // // 2. AJEAN est déjà installé. Le fichier téléchargé se comporte en installeur de // mise à jour, avec trois situations bien distinctes (voir runAsInstaller) : @@ -131,7 +131,12 @@ func appFirstRun() bool { // Renvoie false si le lancement échoue, auquel cas l'exécutable courant prend le // relais : mieux vaut démarrer depuis le mauvais dossier que pas du tout. func launch(target string) bool { - cmd := exec.Command(target) + // « app » est passé EXPLICITEMENT plutôt que de compter sur la détection du + // mode application. Celle-ci répond à une seule question — « ai-je été + // double-cliqué ? » — en regardant qui partage notre console. Un processus + // lancé sans console du tout (relance après mise à jour) ne peut pas y + // répondre, et se serait retrouvé à afficher l'aide au lieu de démarrer. + cmd := exec.Command(target, "app") cmd.Dir = filepath.Dir(target) return cmd.Start() == nil } @@ -404,6 +409,10 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) { $s.TargetPath=$t $s.WorkingDirectory=(Split-Path $t) $s.Description='AJEAN, votre IA locale' + # Minimisé (7) : le binaire étant en sous-système console, Windows lui alloue + # une console au lancement. AJEAN la referme aussitôt, mais demander un + # démarrage minimisé garantit qu'elle n'est jamais peinte à l'écran. + $s.WindowStyle=7 $s.Save() $done+=$d } catch {} From 6bf304573c094b941bcc0011ed0959c3ab387f32 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 22:09:43 +0200 Subject: [PATCH 254/279] Correctif : l'application ne demarrait plus du tout au double-clic ShowWindow etait cherchee dans kernel32 alors qu'elle vit dans user32. LazyProc.Call PANIQUE quand la procedure est introuvable, et setupConsole s'execute avant tout le reste : le programme mourait sur le champ, sans rien installer et sans rien afficher. Seul le chemin du double-clic etait touche (console creee pour nous), d'ou une CI verte et des tests CLI qui passaient. Tous les appels Win32 de ce fichier passent desormais par un helper qui verifie la presence de la procedure au lieu de paniquer : aucun reglage de confort ne justifie d'empecher le programme de demarrer. Verifie de bout en bout cette fois : lancement facon double-clic -> arborescence creee, bin/ajean.exe installe, aucune panique, processus vivant ; et les chemins CLI (ordre d'affichage, redirection, tube, aide sans argument) inchanges. --- internal/ajean/sys_console_windows.go | 52 +++++++++++++++++---------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/internal/ajean/sys_console_windows.go b/internal/ajean/sys_console_windows.go index 3222fee..8921f7f 100644 --- a/internal/ajean/sys_console_windows.go +++ b/internal/ajean/sys_console_windows.go @@ -25,6 +25,24 @@ import ( "unsafe" ) +// win appelle une procédure Win32 et renvoie (résultat, trouvée). +// +// LazyProc.Call PANIQUE quand la procédure est introuvable. Or tout ce fichier +// s'exécute AVANT le reste du programme : une panique ici et il ne reste rien à +// l'écran pour la comprendre, l'application ne démarre tout simplement plus. +// C'est exactement ce qui est arrivé en cherchant ShowWindow dans kernel32 +// alors qu'elle vit dans user32. Aucun réglage de confort ne mérite ça. +func win(dll *syscall.LazyDLL, name string, a ...uintptr) (uintptr, bool) { + p := dll.NewProc(name) + if err := p.Find(); err != nil { + return 0, false + } + r, _, _ := p.Call(a...) + return r, true +} + +var kernel32 = syscall.NewLazyDLL("kernel32.dll") + // setupConsole décide dans quel monde on tourne et renvoie true en usage CLI. // // Le signal est le nombre de processus rattachés à notre console : si nous @@ -32,27 +50,21 @@ import ( // ne nous a lancés. On la fait disparaître et on bascule en mode application. // Un shell rattaché (cmd, PowerShell, Windows Terminal) en fait au moins deux. func setupConsole() bool { - k := syscall.NewLazyDLL("kernel32.dll") - - // Combien de processus partagent notre console ? Nous seul (1) = personne ne - // nous a lancés depuis un terminal, Windows a créé cette console pour nous : - // c'est un double-clic. Un shell rattaché en fait au moins deux. - // // Le compte vaut 0 quand il n'y a aucune console (parent détaché). On répond // alors « CLI », le cas contraire étant couvert autrement : les relances // internes passent « app » explicitement (voir launch, sys_firstrun_windows.go). // Mieux vaut afficher une aide inutile que démarrer un serveur en silence. var pids [8]uint32 - n, _, _ := k.NewProc("GetConsoleProcessList").Call( + n, ok := win(kernel32, "GetConsoleProcessList", uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids))) - if n == 1 { - if hwnd, _, _ := k.NewProc("GetConsoleWindow").Call(); hwnd != 0 { - // Masquer AVANT de libérer : sinon la fenêtre reste peinte le temps - // que Windows la détruise. + if ok && n == 1 { + // Masquer AVANT de libérer : sinon la fenêtre reste peinte le temps que + // Windows la détruise. ShowWindow est dans user32, pas kernel32. + if hwnd, ok := win(kernel32, "GetConsoleWindow"); ok && hwnd != 0 { const swHide = 0 - _, _, _ = k.NewProc("ShowWindow").Call(hwnd, swHide) + win(u32s, "ShowWindow", hwnd, swHide) } - _, _, _ = k.NewProc("FreeConsole").Call() + win(kernel32, "FreeConsole") return false } @@ -71,13 +83,15 @@ func configureConsole() { enableVirtualTerminalProcessing = 0x0004 stdOutputHandle = ^uintptr(10) // -11 ) - k := syscall.NewLazyDLL("kernel32.dll") - _, _, _ = k.NewProc("SetConsoleOutputCP").Call(uintptr(cpUTF8)) - _, _, _ = k.NewProc("SetConsoleCP").Call(uintptr(cpUTF8)) - h, _, _ := k.NewProc("GetStdHandle").Call(stdOutputHandle) + win(kernel32, "SetConsoleOutputCP", uintptr(cpUTF8)) + win(kernel32, "SetConsoleCP", uintptr(cpUTF8)) + h, ok := win(kernel32, "GetStdHandle", stdOutputHandle) + if !ok { + return + } var mode uint32 - if r, _, _ := k.NewProc("GetConsoleMode").Call(h, uintptr(unsafe.Pointer(&mode))); r != 0 { - _, _, _ = k.NewProc("SetConsoleMode").Call(h, uintptr(mode|enableVirtualTerminalProcessing)) + if r, ok := win(kernel32, "GetConsoleMode", h, uintptr(unsafe.Pointer(&mode))); ok && r != 0 { + win(kernel32, "SetConsoleMode", h, uintptr(mode|enableVirtualTerminalProcessing)) } } From 68d6d2032a4cb712a18c5adaa9192ae28fe34b1d Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 22:30:01 +0200 Subject: [PATCH 255/279] Windows : version de fichier, fenetres de console et remplacement d'un meme build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois defauts signales a l'usage. 1. Le binaire 0.8.0 se DECLARAIT 0.7.13 a Windows : j'avais bump la constante Go sans toucher a cmd/ajean/versioninfo.json ni regenerer les .syso. D'ou le « version en cours 0.7.13 / ce fichier 0.8.0 » a chaque relance du fichier telecharge. Ressource remise a 0.8.0, .syso regeneres, et un test garde desormais les deux en phase. 2. Des fenetres de console apparaissaient : la relance apres installation et l'ouverture du navigateur creaient un enfant sans masquer sa fenetre, ce qui se voit maintenant que le binaire est en sous-systeme console. Les deux passent par hideCmd. Les raccourcis DEJA poses recoivent en plus le demarrage minimise, qui ne s'appliquait qu'aux nouveaux. 3. Deux builds de meme version n'etaient jamais remplaces : relancer un telechargement plus recent d'une preversion republiee demarrait simplement l'ancienne copie. On compare desormais les fichiers (taille puis SHA-256) et non les seuls numeros. Au passage, l'icone est epinglee sur le raccourci : Windows garde parfois en cache celle d'un binaire remplace et affiche un carre blanc. --- cmd/ajean/resource_windows_amd64.syso | Bin 7552 -> 7544 bytes cmd/ajean/resource_windows_arm64.syso | Bin 7552 -> 7544 bytes cmd/ajean/versioninfo.json | 10 ++-- internal/ajean/sys_firstrun_windows.go | 73 ++++++++++++++++++++++++- internal/ajean/sys_platform_windows.go | 2 +- internal/ajean/version_test.go | 41 ++++++++++++++ 6 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 internal/ajean/version_test.go diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 44a941c09d95e73441915836849f508dd6d34875..d1c8a073d670ba3f3741f00e91e75faa3b42b761 100644 GIT binary patch delta 142 zcmZp${$a(L(#FUD0!b4&r8y_aFfiBvIcGNNYBMu_*&NASEz0<1@>L0aT@J7+1_l^y z59PBkurNSOfC>PqkjaLU%8Uk^LnYTUG8-^(OlFr>L0aU3Rc4UIrL# z59Tv4umB;%1gHRzikWOEsmy4xIaG2zBeMwu=VW$iMP73TJqAMtV<2h3z_8g+S{Wq8 RJ~>=Ql@lh#Fu76YI{=k77xVxC diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index a507d7821e63dd930c0f12cda224c065fd89b1e6..cf6c1330ef663f26e185661fa5f346ccde574ae1 100644 GIT binary patch delta 142 zcmZp${$a(LvWk%b1d=9lN^?$-VPLQUa?Whj)n;b=vN@8uT9on2 Date: Sat, 8 Aug 2026 09:16:16 +0200 Subject: [PATCH 256/279] v0.8.1 : le moteur dit enfin pourquoi il ne demarre pas, et les presets repartent propres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start/restart : preflight (BIN, MODEL, .gguf) et fin du faux « [ok] activating » sur un service qui redemarre en boucle (SubState auto-restart). - MODEL en chemin absolu accepte hors des dossiers declares (lecture seule) ; l'allowlist reste sur la suppression de fichiers. - ajean edit : squelette commente des cles au lieu d'un fichier quasi vide. - install : les prochaines etapes n'oublient plus « ajean llamacpp install ». - Presets : un nouveau preset ne recopie plus la config active (issue #17), seulement BIN/HOST/PORT. - Presets : plus de guillemet interne mange a la relecture (Go et interface), et EXTRA_ARGS decoupe en respectant les guillemets. --- RELEASE_NOTES.md | 92 +++++++++----------- cmd/ajean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/ajean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/ajean/versioninfo.json | 6 +- internal/ajean/backend_config.go | 74 +++++++++++++++- internal/ajean/backend_config_quote_test.go | 77 ++++++++++++++++ internal/ajean/backend_model_dirs.go | 23 +++++ internal/ajean/backend_models.go | 6 +- internal/ajean/backend_presets.go | 26 +++++- internal/ajean/backend_serve.go | 7 +- internal/ajean/run.go | 10 ++- internal/ajean/sys_install_unix.go | 8 +- internal/ajean/sys_service.go | 91 ++++++++++++++++++- internal/ajean/sys_service_linux.go | 57 ++++++++++-- internal/ajean/ui/index.html | 30 ++++--- internal/ajean/ui/src/js/07-models.js | 30 ++++--- internal/ajean/web_api.go | 4 +- 17 files changed, 439 insertions(+), 102 deletions(-) create mode 100644 internal/ajean/backend_config_quote_test.go diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d3209a1..32f37f6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,63 +1,49 @@ -Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base. +Une version de corrections, née de vos retours sur la 0.8.0 : un moteur qui refusait de démarrer sans jamais le dire, et des presets qui se mélangeaient. -## Mise à jour : il faut réinstaller +## « ajean start » ne ment plus -**Ne passez pas par le bouton « mettre à jour » de la 0.7.** Il ne trouvera d'ailleurs rien : les binaires de cette version portent de nouveaux noms, que la 0.7 ne sait pas chercher. C'est délibéré. La laisser installer ce binaire aurait remplacé l'exécutable sans migrer ni les données ni les unités : le service de lien serait reparti en boucle d'échec sur une sous-commande disparue, et le moteur n'aurait plus trouvé sa configuration. Une machine à réparer en SSH après un clic dans un navigateur. +Le cas le plus pénible remonté cette semaine : `ajean start` répondait `[ok] ajean-engine: activating`, puis `ajean test` répondait « /health ne répond pas ». Rien dans les deux messages ne permettait de comprendre. -La marche à suivre, sur une machine déjà installée : +L'explication tenait à une nuance de systemd. Un service qui meurt au lancement est relancé toutes les trois secondes, et pendant ces trois secondes il est en `activating`, exactement comme un moteur en train de charger un modèle. AJEAN prenait donc un échec en boucle pour un démarrage en cours. Il distingue désormais les deux : un vrai chargement reste annoncé comme tel (un gros .gguf prend des minutes, c'est normal), une boucle d'échec affiche l'erreur, les vingt dernières lignes du journal et quoi corriger. -```bash -curl -L -o ajean https://github.com/nathaninline/ajean/releases/latest/download/ajean-linux -chmod +x ajean && sudo mv ajean /usr/local/bin/ajean -sudo ajean install +Mieux : `ajean start` et `ajean restart` vérifient d'abord ce sans quoi le moteur ne peut pas démarrer, et refusent de lancer un service condamné. Moteur absent, modèle non renseigné, fichier .gguf introuvable : c'est dit tout de suite, avec la commande qui répare. + +## Un modèle peut vivre où vous voulez + +`MODEL=/home/moi/modeles/mon-modele.gguf` était refusé si le dossier n'avait pas été déclaré au préalable dans l'interface web. Le moteur mourait alors en boucle sur un « dossier non autorisé » que personne ne voyait passer, et le seul remède connu consistait à ouvrir l'interface pour y ajouter le dossier. Un utilisateur l'a trouvé tout seul, à tâtons ; ce n'était pas une découverte à lui imposer. + +Ce garde-fou existe pour empêcher l'interface web de supprimer un fichier n'importe où sur la machine. Il n'avait aucune raison de s'appliquer à « ouvrir ce modèle en lecture ». Un chemin absolu vers un .gguf qui existe est maintenant accepté tel quel, quel que soit le disque. La protection reste entière là où elle sert. + +## La configuration s'explique enfin + +Depuis que la configuration vit en base, `ajean edit` déroulait dans votre éditeur les seules clés déjà définies. Sur une installation neuve, cela donnait un fichier quasiment vide : impossible de deviner quoi écrire. + +L'éditeur propose désormais un squelette commenté. Chaque réglage utile y figure avec son rôle et son défaut (BIN, MODEL, CTX, NGL, batch, threads, cache KV, raisonnement, mémoire, EXTRA_ARGS). Les valeurs que vous avez déjà réglées sont en clair, les autres restent commentées, donc inactives, et toute clé que le squelette ne connaît pas est conservée en fin de fichier. + +Les étapes affichées à la fin de `ajean install` ont été corrigées dans la foulée : elles oubliaient `ajean llamacpp install`, sans quoi la clé BIN reste vide et le moteur ne peut pas démarrer. + +## Presets : un nouveau preset repart propre (issue #17) + +Créer un preset avec le bouton « + » recopiait la configuration active en entier. Les réglages du modèle précédent (les experts déportés sur le CPU, la répartition entre cartes, le contexte, jusqu'au modèle lui-même) se mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à tout nettoyer à la main. + +Un nouveau preset ne reprend plus que ce qui relève de la machine : le moteur, l'adresse et le port. Tout le reste part des valeurs par défaut, que vous ajustez pour ce modèle-là. + +## Presets : les guillemets ne sont plus mangés + +Le rapport parlait de contenu tronqué et de guillemets non appariés, et c'était exact, avec une cause plus profonde qu'il n'y paraissait. En relisant une ligne de configuration, AJEAN retirait tout guillemet en début et en fin de valeur, sans vérifier qu'il s'agissait d'une paire. Une valeur comme : + +``` +EXTRA_ARGS=--jinja --chat-template-file "/etc/ajean/gabarit.jinja" ``` -`install` fait la reprise complète : il arrête et désactive les anciens services, déplace `configs/` vers `presets/`, `MEMORY/` vers `memory/` et les `.gguf` vers `models/`, reprend en base la configuration, les préférences, la conversation, les clés, le jeton de liaison, les interrupteurs, les serveurs MCP et les benchmarks, puis installe les deux nouvelles unités. +ressortait donc amputée de son guillemet final, déséquilibrée, et repartait ainsi dans le preset enregistré. Le défaut existait des deux côtés, dans le moteur comme dans l'interface. Seule une paire entourante est maintenant retirée, et l'écriture est faite pour être relue à l'identique. Un test verrouille l'aller-retour. -Rien n'est supprimé. Les presets, la mémoire et les modèles sont déplacés, jamais copiés ni effacés. Les anciens fichiers d'état sont rangés dans `avant-0.8/`, que vous pourrez supprimer quand tout ira bien. La clé du chiffrement de bout en bout n'est pas touchée, donc l'empreinte confirmée dans le portail reste valable. +Dans la même zone : EXTRA_ARGS était découpé sur les espaces sans tenir compte des guillemets, si bien qu'un chemin contenant une espace devenait deux arguments et que llama-server refusait de démarrer. Le découpage respecte désormais les guillemets, comme le ferait un shell. -## Une base à la place des fichiers d'état +## Mise à jour -`config.env`, `webprefs.json`, `conversation.json`, `model_dirs.json`, `mcp.json`, `.api_key`, `.web_key`, `.link_token`, `.agent_enabled`, `.internet_enabled` et le reste ont disparu. Tout cela vit maintenant dans **`ajean.db`**, un fichier unique en [bbolt](https://github.com/etcd-io/bbolt), pur Go, transactionnel, sans dépendance système. +```bash +ajean update +``` -Ce n'est pas qu'un rangement. Chaque fichier avait sa façon d'être écrit, et donc sa façon de rater une écriture concurrente : l'interface et un tour de chat qui touchaient au même réglage au même instant pouvaient en perdre un. Une transaction remplace tout ça. Une bascule de preset, en particulier, remplace la configuration d'un bloc : il n'existe plus d'instant où elle serait à moitié l'ancienne et à moitié la nouvelle. - -Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardés à la main : les presets, les pages de mémoire, les modèles. `ajean edit` déroule donc la configuration au format `clé=valeur` dans votre éditeur, puis la relit ; le fichier n'existe que le temps de l'édition. - -## Six dossiers - -`$AJEAN_HOME` contient `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. Ne restent à côté que ce qui ne peut pas aller ailleurs : la clé privée du chiffrement de bout en bout, le dossier des certificats TLS, les journaux et les fichiers PID des services. - -## Deux services qui portent enfin leur nom - -`ajean.service` et `ajean-link.service` deviennent **`ajean-engine`** et **`ajean-ui`**. Le premier exécute le modèle, le second sert l'interface web, le tunnel d'accès distant et l'endpoint OpenAI. Le nom « link » cachait l'essentiel : ce service est d'abord le serveur web. - -Surtout, il n'y a plus **qu'une seule façon** de servir l'interface. Avant, `ajean web` et `ajean link serve` savaient tous deux le faire, ce qui posait un piège permanent : lancer les deux, c'était un conflit sur le port 8090 et surtout deux fils de conversation qui divergeaient (la conversation vit en mémoire, deux process qui la servent finissent par s'écraser l'un l'autre). La consigne « ne jamais lancer `ajean web` » circulait comme une règle à retenir ; elle n'existait que parce que le code offrait deux portes pour la même pièce. - -Désormais `ajean web` est cette porte unique : il sert l'interface et, si un jeton de liaison est enregistré, ouvre le tunnel dans le même process. `ajean link` ne s'occupe plus que du compte (jeton, code d'appairage, état), et `ajean ui start|stop|restart|status` pilote le service. `ajean link serve`, `link start`, `link stop` et `link restart` disparaissent. - -Séparer les deux services garde son intérêt : redémarrer l'interface est instantané, alors que redémarrer le moteur recharge des dizaines de gigaoctets. - -## Sur Windows - -**« Quitter » arrête vraiment tout.** Le moteur tourne dans un processus détaché, qui survit volontairement à la fermeture de l'interface pour garder le modèle chargé entre deux ouvertures. Mais après un « Quitter » depuis la zone de notification, plus rien ne le pilotait et il conservait des dizaines de gigaoctets de mémoire, sans la moindre fenêtre pour l'expliquer. Quitter décharge désormais le modèle. Ailleurs, rien ne change : sous Linux et macOS le moteur appartient à systemd ou launchd, et fermer une interface n'a pas à arrêter un service système. - -**AJEAN se comporte enfin comme une vraie commande.** Dans un terminal, `ajean` rendait la main avant d'avoir écrit quoi que ce soit : l'invite se réaffichait, et la sortie du programme arrivait par-dessus, comme si la commande avait été tapée deux fois. - -La même cause empêchait `ajean where > fichier.txt` d'écrire dans le fichier, `ajean help | findstr ...` de transmettre quoi que ce soit, et rendait `ajean chat` inutilisable depuis un terminal, le shell et AJEAN se disputant le clavier. Le binaire était compilé en sous-système graphique, et Windows n'attend jamais la fin d'un programme de ce type. - -Il est désormais en sous-système console. La contrepartie, une fenêtre noire au double-clic, est traitée par le programme lui-même : constatant qu'aucun terminal ne l'a lancé, il referme aussitôt la console que Windows lui a allouée. Les raccourcis posés à l'installation demandent en plus un démarrage minimisé, si bien qu'elle n'est jamais peinte à l'écran. - -**Plus de question au premier lancement.** Le double-clic demandait s'il fallait installer AJEAN ou seulement le lancer. La question n'avait qu'une réponse utile : rester à l'emplacement du fichier téléchargé ne donne pas une installation exploitable, sans raccourci, sans rien dans le PATH, et avec une application qui disparaît le jour où l'on vide ses téléchargements. L'installation se fait donc directement, et le message qui suit dit ce qui a été fait au lieu de demander une permission. - -## Ce qui a été retiré - -Tout le code écrit pour ménager les installations « jean » : la migration du dossier de données et ses reprises après échec, la migration de l'agencement système (unités, `/etc/default`, réécriture des chemins), l'élévation Windows qu'elle demandait, la résolution du nom d'unité réellement installée, la reprise des fichiers PID et des skills, les alias `jean` posés à l'installation, les variables `JEAN_*` lues en second. - -La CLI perd ses alias hérités (`skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama`) et son aide est réorganisée autour des deux services. Chaque commande a désormais un seul nom. `app` quitte l'aide : c'est le comportement du double-clic, qu'on n'atteint pas en tapant son nom. - -Les binaires publiés prennent des noms lisibles : `ajean-linux`, `ajean-linux-arm`, `ajean-macos`, `ajean-macos-arm`, `ajean-windows.exe`, `ajean-windows-arm.exe`. Le suffixe `-arm` désigne l'arm64, son absence l'x86-64. - -## Ce qui n'a pas été testé - -La reprise 0.7 vers 0.8 a été vérifiée sur Linux (un serveur réel, avec 9 presets, 24 pages de mémoire et 8 modèles) et sur Windows (dossier de test complet), plus par trois tests automatisés. **Elle n'a pas été essayée sur macOS**, faute de machine : le support macOS reste globalement non validé sur du matériel Apple. Sauvegardez `$AJEAN_HOME` avant de vous lancer. +Ou, depuis l'interface, le bandeau de mise à jour. Rien à migrer : les presets, la configuration et la mémoire sont inchangés. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index d1c8a073d670ba3f3741f00e91e75faa3b42b761..2543f9382a393815aa63ce819ad00dac49e1b482 100644 GIT binary patch delta 35 ocmexi^}}idmn0`60|x^LFm4u>lw@Kw+^j1d%*bdsxl!gj0FsRe9{>OV delta 35 ncmexi^}}idmn0_x0|x^bY!;T3WMVYftScSN$Y?ORQRX`Ul1>O4 diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index cf6c1330ef663f26e185661fa5f346ccde574ae1..bb3017e5d32b53231e687bbcfd93f5c1aa7af001 100644 GIT binary patch delta 35 ocmexi^}}idmn0`60|x^LFm4u>lw@Kw+^j1d%*bdsxl!gj0FsRe9{>OV delta 35 ncmexi^}}idmn0_x0|x^bY!;T3WMVYftScSN$Y?ORQRX`Ul1>O4 diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index a2d3f51..5e356a1 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 8, - "Patch": 0, + "Patch": 1, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 8, - "Patch": 0, + "Patch": 1, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.8.0", + "ProductVersion": "0.8.1", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_config.go b/internal/ajean/backend_config.go index ec83097..5e6720c 100644 --- a/internal/ajean/backend_config.go +++ b/internal/ajean/backend_config.go @@ -99,8 +99,24 @@ func SetConfigKey(key, value string) error { return putStr(bkConfig, key, value) // qu'exige l'application d'un preset : jamais un état mi-ancien mi-nouveau. func WriteConfig(m map[string]string) error { return replaceKV(bkConfig, m) } +// unquoteValue retire les guillemets ENTOURANTS d'une valeur, et seulement +// eux : une paire ouvrante/fermante du même caractère. +// +// Un simple Trim(v, `"`) mangeait le guillemet d'un argument interne — par +// exemple EXTRA_ARGS=--chat-template-file "/etc/ajean/tpl.jinja" perdait son +// guillemet final et repartait déséquilibré dans le preset (issue #17 : contenu +// tronqué / guillemets non appariés à la création d'un preset). +func unquoteValue(v string) string { + if len(v) >= 2 { + if q := v[0]; (q == '"' || q == '\'') && v[len(v)-1] == q { + return v[1 : len(v)-1] + } + } + return v +} + // parseEnv lit un fichier au format clé=valeur (les presets). Les lignes vides -// et les commentaires sont ignorés, les guillemets retirés. +// et les commentaires sont ignorés, les guillemets entourants retirés. func parseEnv(text string) map[string]string { m := map[string]string{} for _, line := range strings.Split(text, "\n") { @@ -112,11 +128,25 @@ func parseEnv(text string) map[string]string { if !ok { continue } - m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"") + m[strings.TrimSpace(k)] = unquoteValue(strings.TrimSpace(v)) } return m } +// quoteValue rend une valeur telle que parseEnv la relise à l'identique. On +// n'entoure de guillemets que ce qui en a besoin (espaces), et jamais une valeur +// qui contient déjà un guillemet : elle est écrite telle quelle, unquoteValue ne +// touchant qu'à une paire entourante. +func quoteValue(v string) string { + if v == "" || strings.ContainsRune(v, '"') { + return v + } + if strings.ContainsAny(v, " \t") || unquoteValue(v) != v { + return `"` + v + `"` + } + return v +} + // formatEnv rend une configuration au format des presets, clés triées pour que // deux écritures du même contenu donnent le même fichier. func formatEnv(m map[string]string) string { @@ -127,11 +157,49 @@ func formatEnv(m map[string]string) string { sort.Strings(keys) var b strings.Builder for _, k := range keys { - fmt.Fprintf(&b, "%s=%s\n", k, m[k]) + fmt.Fprintf(&b, "%s=%s\n", k, quoteValue(m[k])) } return b.String() } +// splitArgs découpe EXTRA_ARGS comme le ferait un shell : sur les espaces, mais +// en respectant les guillemets, pour qu'un chemin qui en contient reste UN seul +// argument (--chat-template-file "/mes modèles/tpl.jinja"). trimSplit sur " " +// le coupait en deux et llama-server refusait de démarrer. +func splitArgs(s string) []string { + out := []string{} + var cur strings.Builder + var quote rune + flush := func() { + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + } + for _, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + // Guillemets vides ("" ) : on garde l'argument vide explicite. + if cur.Len() == 0 { + out = append(out, "") + } + } else { + cur.WriteRune(r) + } + case r == '"' || r == '\'': + quote = r + case r == ' ' || r == '\t' || r == '\n' || r == '\r': + flush() + default: + cur.WriteRune(r) + } + } + flush() + return out +} + // Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été // retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le // seul frein. diff --git a/internal/ajean/backend_config_quote_test.go b/internal/ajean/backend_config_quote_test.go new file mode 100644 index 0000000..6f23227 --- /dev/null +++ b/internal/ajean/backend_config_quote_test.go @@ -0,0 +1,77 @@ +package ajean + +import ( + "reflect" + "testing" +) + +// Un guillemet INTERNE (argument cité dans EXTRA_ARGS) ne doit pas être mangé : +// l'ancien Trim retirait le guillemet final et renvoyait une valeur déséquilibrée +// dans le preset (issue #17). +func TestParseEnvKeepsInnerQuotes(t *testing.T) { + cases := map[string]string{ + `EXTRA_ARGS=--jinja --chat-template-file "/etc/ajean/tpl.jinja"`: `--jinja --chat-template-file "/etc/ajean/tpl.jinja"`, + `EXTRA_ARGS="--jinja --flash-attn"`: `--jinja --flash-attn`, + `MODEL='/mnt/d/x.gguf'`: `/mnt/d/x.gguf`, + `MODEL=/mnt/d/x.gguf`: `/mnt/d/x.gguf`, + } + for line, want := range cases { + m := parseEnv(line) + var got string + for _, v := range m { + got = v + } + if got != want { + t.Errorf("parseEnv(%q) = %q, veut %q", line, got, want) + } + } +} + +// Écrire puis relire doit rendre exactement la même configuration. +func TestFormatEnvRoundTrip(t *testing.T) { + in := map[string]string{ + "MODEL": "/mnt/mes modèles/x.gguf", + "EXTRA_ARGS": `--jinja --chat-template-file "/etc/ajean/tpl.jinja"`, + "CTX": "32768", + } + out := parseEnv(formatEnv(in)) + if !reflect.DeepEqual(in, out) { + t.Fatalf("aller-retour cassé :\n%v\n%v", in, out) + } +} + +func TestSplitArgs(t *testing.T) { + got := splitArgs(`--jinja --chat-template-file "/etc/mes modèles/tpl.jinja" --flash-attn`) + want := []string{"--jinja", "--chat-template-file", "/etc/mes modèles/tpl.jinja", "--flash-attn"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("splitArgs = %q, veut %q", got, want) + } + if len(splitArgs("")) != 0 { + t.Fatal("EXTRA_ARGS vide doit donner zéro argument") + } +} + +// L'éditeur doit proposer les clés utiles même quand la configuration est vide, +// et ne jamais perdre une clé déjà définie. +func TestConfigEditorText(t *testing.T) { + txt := configEditorText(map[string]string{"MODEL": "x.gguf", "INCONNUE": "1"}) + round := parseEnv(txt) + if round["MODEL"] != "x.gguf" || round["INCONNUE"] != "1" { + t.Fatalf("clés perdues : %v", round) + } + if len(round) != 2 { + t.Fatalf("les clés non renseignées doivent rester commentées : %v", round) + } + if !contains(txt, "#BIN=") { + t.Fatal("le squelette doit documenter BIN") + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/ajean/backend_model_dirs.go b/internal/ajean/backend_model_dirs.go index e191d47..8f0534b 100644 --- a/internal/ajean/backend_model_dirs.go +++ b/internal/ajean/backend_model_dirs.go @@ -101,6 +101,29 @@ func baseName(p string) string { return p } +// resolveServeModelPath résout MODEL= pour LANCER le moteur. Différence avec +// resolveModelPath : un chemin absolu qui pointe sur un .gguf existant est +// accepté même hors des dossiers déclarés. L'allowlist protège des opérations +// destructrices pilotées depuis l'UI (suppression d'un fichier) ; l'appliquer à +// « ouvrir ce modèle en lecture » rendait la config éditée à la main +// inutilisable : MODEL=/home/moi/models/x.gguf renvoyait « dossier non +// autorisé », le moteur mourait en boucle, et le seul remède connu était +// d'aller déclarer le dossier dans l'interface web. +func resolveServeModelPath(name string) (string, error) { + p, err := resolveModelPath(name) + if err == nil { + return p, nil + } + s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`)) + if filepath.IsAbs(s) && strings.HasSuffix(strings.ToLower(s), ".gguf") { + abs := filepath.Clean(s) + if st, e := os.Stat(abs); e == nil && !st.IsDir() { + return abs, nil + } + } + return "", err +} + // resolveModelPath transforme une valeur MODEL= (nom de fichier OU chemin // absolu) en chemin absolu vers un .gguf. Un chemin absolu n'est accepté que // s'il est dans un des dossiers déclarés — sinon l'UI web deviendrait un moyen diff --git a/internal/ajean/backend_models.go b/internal/ajean/backend_models.go index ea68e91..3b1d56b 100644 --- a/internal/ajean/backend_models.go +++ b/internal/ajean/backend_models.go @@ -53,7 +53,7 @@ func presetReasoning(content string) string { continue } if strings.EqualFold(strings.TrimSpace(s[:i]), "REASONING") { - return strings.Trim(strings.TrimSpace(s[i+1:]), `"`) + return unquoteValue(strings.TrimSpace(s[i+1:])) } } return "" @@ -78,7 +78,7 @@ func detectQuant(content string) string { 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 != "" { + if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" { return strings.ToUpper(v) } } @@ -117,7 +117,7 @@ func modelFromPresetContent(content string) string { continue } if strings.TrimSpace(s[:i]) == "MODEL" { - return strings.Trim(strings.TrimSpace(s[i+1:]), "\"") + return unquoteValue(strings.TrimSpace(s[i+1:])) } } return "" diff --git a/internal/ajean/backend_presets.go b/internal/ajean/backend_presets.go index 8b88298..c4bc437 100644 --- a/internal/ajean/backend_presets.go +++ b/internal/ajean/backend_presets.go @@ -33,7 +33,7 @@ func presetDisplayName(content, fallback string) string { 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 != "" { + if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" { return v } } @@ -271,6 +271,30 @@ func cmdSwitch(args []string) error { return SwitchToPreset(list[n-1].Path) } +// newPresetSeedKeys : les SEULES clés reprises de la configuration active quand +// on crée un preset. Ce sont des réglages de MACHINE (quel moteur, où il écoute), +// pas des réglages de modèle. +// +// Issue #17 : le nouveau preset repartait d'une copie COMPLÈTE de la config +// active. Les réglages du modèle précédent (EXTRA_ARGS — --n-cpu-moe, +// --tensor-split, --flash-attn… — mais aussi CTX, NGL, KV_TYPE, MODEL) se +// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à +// tout nettoyer à la main. On repart d'une base vide : les valeurs non +// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…). +var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"} + +// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI. +func newPresetSeed() map[string]string { + cur := ReadConfig() + seed := map[string]string{} + for _, k := range newPresetSeedKeys { + if v := cur[k]; v != "" { + seed[k] = v + } + } + return seed +} + // 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 — diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index ad40de5..43079aa 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -25,7 +25,7 @@ func cmdServe(args []string) error { // juste ; lancé depuis une app de bureau, le répertoire courant est « / » et // llama-server ne trouvait rien. On résout donc explicitement, quel que soit // le contexte de lancement. - resolved, err := resolveModelPath(model) + resolved, err := resolveServeModelPath(model) if err != nil { return err } @@ -99,8 +99,9 @@ func cmdServe(args []string) error { } else if k := cfg["API_KEY"]; k != "" { llmArgs = append(llmArgs, "--api-key", k) } - // EXTRA_ARGS is appended verbatim, split on whitespace like the shell would. - llmArgs = append(llmArgs, trimSplit(cfg["EXTRA_ARGS"], " ")...) + // EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept + // together so a path with spaces stays one argument. + llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...) // Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj // mmproj-F16.gguf) still resolve. diff --git a/internal/ajean/run.go b/internal/ajean/run.go index deff654..f5237f4 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.8.0" +const Version = "0.8.1" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { @@ -42,7 +42,13 @@ func Main() { switch cmd { case "app": mustExit(cmdApp(args)) - case "start", "stop", "restart", "status", "enable", "disable": + case "start", "restart": + // Un moteur sans BIN ni MODEL démarre, meurt, et systemd le relance en + // boucle : l'utilisateur ne voyait qu'un « activating » rassurant et un + // /health muet. On le dit AVANT de lancer le service. + mustExit(preflightEngine()) + mustExit(serviceAction(cmd)) + case "stop", "status", "enable", "disable": mustExit(serviceAction(cmd)) case "logs": mustExit(serviceLogs()) diff --git a/internal/ajean/sys_install_unix.go b/internal/ajean/sys_install_unix.go index 39c4f64..c65ed50 100644 --- a/internal/ajean/sys_install_unix.go +++ b/internal/ajean/sys_install_unix.go @@ -87,9 +87,11 @@ func cmdInstall(args []string) error { fmt.Println() fmt.Printf("%s installation terminée.\n", green("[ok]")) fmt.Printf("\nProchaines étapes :\n") - fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("sudo -u "+targetUser+" ajean edit")) - fmt.Printf(" 2. démarre le moteur : %s\n", bold("sudo ajean start")) - fmt.Printf(" 3. démarre l'interface: %s\n", bold("sudo ajean ui start")) + fmt.Printf(" 1. installe le moteur : %s (compile llama.cpp, renseigne BIN)\n", bold("sudo -u "+targetUser+" ajean llamacpp install")) + fmt.Printf(" 2. choisis le modèle : %s (renseigne MODEL, un fichier .gguf)\n", bold("sudo -u "+targetUser+" ajean edit")) + fmt.Printf(" 3. démarre le moteur : %s\n", bold("sudo ajean start")) + fmt.Printf(" 4. vérifie : %s\n", bold("ajean test")) + fmt.Printf(" 5. démarre l'interface : %s (http://localhost:8090)\n", bold("sudo ajean ui start")) return nil } diff --git a/internal/ajean/sys_service.go b/internal/ajean/sys_service.go index 2378848..bbfb1ec 100644 --- a/internal/ajean/sys_service.go +++ b/internal/ajean/sys_service.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strconv" "strings" ) @@ -16,6 +17,94 @@ import ( // // editConfig and showVram live here because they work the same everywhere. +// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de +// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait +// toutes les 3 s, et `ajean start` affichait un « activating » rassurant pendant +// que `ajean test` répondait « /health ne répond pas ». Le diagnostic n'était +// visible que dans le journal. +func preflightEngine() error { + cfg := ReadConfig() + bin := strings.TrimSpace(cfg["BIN"]) + if bin == "" { + return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)", + bold("ajean llamacpp install"), bold("ajean edit")) + } + if !filepath.IsAbs(bin) { + bin = filepath.Join(AjeanHome(), bin) + } + if _, err := os.Stat(prebuiltResolveBin(bin)); err != nil { + return fmt.Errorf("moteur introuvable : %s — relance %s", bin, bold("ajean llamacpp install")) + } + model := strings.TrimSpace(cfg["MODEL"]) + if model == "" { + return fmt.Errorf("MODEL non défini — indique un .gguf avec %s (dossier des modèles : %s)", + bold("ajean edit"), modelsDir()) + } + p, err := resolveServeModelPath(model) + if err != nil { + return fmt.Errorf("MODEL=%s : %w", model, err) + } + if _, err := os.Stat(p); err != nil { + return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("ajean edit")) + } + return nil +} + +// configTemplate est le squelette commenté proposé par `ajean edit` : la +// configuration vit en base, donc sur une installation neuve le fichier +// temporaire était QUASI VIDE — impossible de deviner quoi écrire. On y déroule +// donc les clés utiles avec leur rôle, les valeurs déjà définies telles quelles, +// les autres commentées. +var configTemplate = []struct{ key, help string }{ + {"BIN", "chemin de llama-server (posé par « ajean llamacpp install »)"}, + {"MODEL", "nom de fichier .gguf ou chemin complet"}, + {"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"}, + {"PORT", "port du moteur (défaut 8080)"}, + {"CTX", "taille du contexte (défaut 32768)"}, + {"NGL", "couches déportées sur le GPU (défaut 999 = tout)"}, + {"BATCH", "batch (défaut 2048)"}, + {"UBATCH", "micro-batch (défaut 512)"}, + {"THREADS", "threads CPU, 0 = auto"}, + {"THREADS_BATCH", "threads CPU du prefill, 0 = auto"}, + {"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"}, + {"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"}, + {"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"}, + {"COMPACT", "compactage automatique du contexte (off pour couper)"}, + {"MEM_MODE", "mémoire de l'IA : off / ondemand / always"}, + {"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"}, +} + +// configEditorText rend la configuration au format présenté dans $EDITOR. +func configEditorText(cfg map[string]string) string { + var b strings.Builder + b.WriteString("# Configuration du moteur AJEAN (ajean-engine).\n") + b.WriteString("# Une clé par ligne : CLE=valeur. Les lignes commentées (#) sont ignorées :\n") + b.WriteString("# décommente celles dont tu as besoin. « ajean restart » applique.\n\n") + seen := map[string]bool{} + for _, f := range configTemplate { + seen[f.key] = true + fmt.Fprintf(&b, "# %s\n", f.help) + if v, ok := cfg[f.key]; ok && v != "" { + fmt.Fprintf(&b, "%s=%s\n\n", f.key, quoteValue(v)) + } else { + fmt.Fprintf(&b, "#%s=\n\n", f.key) + } + } + // Tout ce que le squelette ne connaît pas (clés d'une version plus récente, + // réglages posés par l'UI) : conservé tel quel, en fin de fichier. + rest := map[string]string{} + for k, v := range cfg { + if !seen[k] { + rest[k] = v + } + } + if len(rest) > 0 { + b.WriteString("# --- autres clés déjà définies ---\n") + b.WriteString(formatEnv(rest)) + } + return b.String() +} + // editConfig ouvre la configuration dans $EDITOR. La configuration vit en base // (voir store.go) : on la déroule dans un fichier temporaire au format clé=valeur, // on laisse l'éditeur faire son travail, puis on relit. Le contenu n'est réécrit @@ -31,7 +120,7 @@ func editConfig() error { } path := tmp.Name() defer os.Remove(path) - if _, err := tmp.WriteString(formatEnv(ReadConfig())); err != nil { + if _, err := tmp.WriteString(configEditorText(ReadConfig())); err != nil { tmp.Close() return err } diff --git a/internal/ajean/sys_service_linux.go b/internal/ajean/sys_service_linux.go index 00c16b4..dbc4c6a 100644 --- a/internal/ajean/sys_service_linux.go +++ b/internal/ajean/sys_service_linux.go @@ -42,20 +42,63 @@ func serviceAction(action string) error { return nil } +// unitState renvoie ActiveState et SubState de l'unité (« activating » / +// « auto-restart » par exemple). +func unitState(svc string) (active, sub string) { + out, _ := exec.Command("systemctl", "show", svc, "-p", "ActiveState", "-p", "SubState").Output() + for _, line := range strings.Split(string(out), "\n") { + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + switch k { + case "ActiveState": + active = v + case "SubState": + sub = v + } + } + return +} + +// checkStarted attend que l'unité se stabilise, puis dit la vérité. +// +// ⚠️ « activating » n'est PAS un succès : avec Restart=on-failure, un moteur qui +// meurt au démarrage repasse en boucle par activating (SubState auto-restart). +// L'ancien test l'acceptait, et `ajean start` répondait « [ok] activating » sur +// un service qui ne démarrerait jamais — d'où des rapports « ajean start dit ok +// mais ajean test ne répond pas ». On distingue donc le SubState, et on laisse +// au moteur le temps de charger un gros modèle (il reste en activating, mais +// PAS en auto-restart). func checkStarted(svc string) error { - time.Sleep(2 * time.Second) - out, _ := exec.Command("systemctl", "is-active", svc).Output() - state := strings.TrimSpace(string(out)) - if state == "active" || state == "activating" { - fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state) + var state, sub string + for i := 0; i < 5; i++ { + time.Sleep(2 * time.Second) + state, sub = unitState(svc) + if state == "active" { + fmt.Printf("%s %s: actif\n", green("[ok]"), svc) + return nil + } + if state == "failed" || sub == "auto-restart" { + break // inutile d'attendre : il boucle sur un échec + } + } + if state == "activating" && sub != "auto-restart" { + // Chargement en cours (un gros .gguf prend des minutes) : légitime. + fmt.Printf("%s %s: démarrage en cours (chargement du modèle) — %s pour suivre\n", + green("[ok]"), svc, bold("ajean logs")) return nil } - fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state) + what := state + if sub == "auto-restart" { + what = "redémarre en boucle (le moteur meurt au lancement)" + } + fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, what) fmt.Println("------------------------------------------------") logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output() fmt.Print(string(logs)) fmt.Println("------------------------------------------------") - fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n") + fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger la configuration (BIN, MODEL…)\n") return fmt.Errorf("service %s non démarré", svc) } diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 79ef7aa..cc7bdda 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -2663,11 +2663,24 @@ function fmtSize(b){ if(b > 1e3) return (b/1e3).toFixed(0)+' KB'; return b+' B'; } +// Retire les guillemets ENTOURANTS d'une valeur, et seulement eux. Les anciens +// motifs `"?([^"\n]*)"?` s'arrêtaient au premier guillemet INTERNE : une valeur +// comme `--chat-template-file "/etc/ajean/tpl.jinja"` était lue tronquée, puis +// réécrite avec des guillemets non appariés (issue #17). +function unquoteVal(v){ + v = String(v==null?'':v).trim(); + const q = v[0]; + if(v.length >= 2 && (q === '"' || q === "'") && v[v.length-1] === q) return v.slice(1,-1); + return v; +} +// Lit la valeur d'une clé KEY= dans un texte au format .env. +function readEnvKey(txt, key){ + const m = String(txt).match(new RegExp('^[ \\t]*'+key+'[ \\t]*=(.*)$','m')); + return m ? unquoteVal(m[1]) : ''; +} // Read the current MODEL= value out of the textarea, handling quotes / spaces. function currentModelInTextarea(){ - const txt = document.getElementById('m-content').value; - const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m); - return m ? m[1].trim() : ''; + return readEnvKey(document.getElementById('m-content').value, 'MODEL'); } // Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers : // on ne contrôle pas ce que l'utilisateur y met). @@ -2791,9 +2804,7 @@ function onPickModel(){ // qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel // backend fait tourner ce modèle — la barre latérale ne fait qu'installer. function currentBinInTextarea(){ - const txt = document.getElementById('m-content').value; - const m = txt.match(/^\s*BIN\s*=\s*"?([^"\n]*)"?\s*$/m); - return m ? m[1].trim() : ''; + return readEnvKey(document.getElementById('m-content').value, 'BIN'); } // Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows). function sameBinPath(a, b){ @@ -3079,8 +3090,8 @@ function onGpuPick(){ // 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() : ''; + const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=(.*)$/mi); + return m ? unquoteVal(m[1]) : ''; } function applyQuant(){ const val = document.getElementById('m-quant').value.trim(); @@ -3097,8 +3108,7 @@ function applyQuant(){ // Le fichier de config (textarea m-content) reste la source de vérité : chaque // champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT. function cfgReadKey(key){ - const m = document.getElementById('m-content').value.match(new RegExp('^[ \\t]*'+key+'[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$','m')); - return m ? m[1].trim() : ''; + return readEnvKey(document.getElementById('m-content').value, key); } function cfgWriteKey(key, val){ const ta = document.getElementById('m-content'); diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 7baaff9..ac864ce 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -171,11 +171,24 @@ function fmtSize(b){ if(b > 1e3) return (b/1e3).toFixed(0)+' KB'; return b+' B'; } +// Retire les guillemets ENTOURANTS d'une valeur, et seulement eux. Les anciens +// motifs `"?([^"\n]*)"?` s'arrêtaient au premier guillemet INTERNE : une valeur +// comme `--chat-template-file "/etc/ajean/tpl.jinja"` était lue tronquée, puis +// réécrite avec des guillemets non appariés (issue #17). +function unquoteVal(v){ + v = String(v==null?'':v).trim(); + const q = v[0]; + if(v.length >= 2 && (q === '"' || q === "'") && v[v.length-1] === q) return v.slice(1,-1); + return v; +} +// Lit la valeur d'une clé KEY= dans un texte au format .env. +function readEnvKey(txt, key){ + const m = String(txt).match(new RegExp('^[ \\t]*'+key+'[ \\t]*=(.*)$','m')); + return m ? unquoteVal(m[1]) : ''; +} // Read the current MODEL= value out of the textarea, handling quotes / spaces. function currentModelInTextarea(){ - const txt = document.getElementById('m-content').value; - const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m); - return m ? m[1].trim() : ''; + return readEnvKey(document.getElementById('m-content').value, 'MODEL'); } // Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers : // on ne contrôle pas ce que l'utilisateur y met). @@ -299,9 +312,7 @@ function onPickModel(){ // qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel // backend fait tourner ce modèle — la barre latérale ne fait qu'installer. function currentBinInTextarea(){ - const txt = document.getElementById('m-content').value; - const m = txt.match(/^\s*BIN\s*=\s*"?([^"\n]*)"?\s*$/m); - return m ? m[1].trim() : ''; + return readEnvKey(document.getElementById('m-content').value, 'BIN'); } // Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows). function sameBinPath(a, b){ @@ -587,8 +598,8 @@ function onGpuPick(){ // 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() : ''; + const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=(.*)$/mi); + return m ? unquoteVal(m[1]) : ''; } function applyQuant(){ const val = document.getElementById('m-quant').value.trim(); @@ -605,8 +616,7 @@ function applyQuant(){ // Le fichier de config (textarea m-content) reste la source de vérité : chaque // champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT. function cfgReadKey(key){ - const m = document.getElementById('m-content').value.match(new RegExp('^[ \\t]*'+key+'[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$','m')); - return m ? m[1].trim() : ''; + return readEnvKey(document.getElementById('m-content').value, key); } function cfgWriteKey(key, val){ const ta = document.getElementById('m-content'); diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 3d27069..859b160 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -337,9 +337,7 @@ func handlePresets(w http.ResponseWriter, r *http.Request) { func handlePreset(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { - // nouveau preset → pré-rempli avec la configuration active, pour ajuster - // plutôt que partir d'une page blanche - sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(ReadConfig())}) + sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(newPresetSeed())}) return } content, err := ReadPreset(id) From bc270ac15c8eaef1d91baf56205378e0e42fa20e Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 09:20:00 +0200 Subject: [PATCH 257/279] Suppression de trimSplit, devenu mort avec splitArgs (staticcheck U1000) --- internal/ajean/backend_config.go | 5 +++-- internal/ajean/run.go | 12 ------------ 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/internal/ajean/backend_config.go b/internal/ajean/backend_config.go index 5e6720c..d002714 100644 --- a/internal/ajean/backend_config.go +++ b/internal/ajean/backend_config.go @@ -164,8 +164,9 @@ func formatEnv(m map[string]string) string { // splitArgs découpe EXTRA_ARGS comme le ferait un shell : sur les espaces, mais // en respectant les guillemets, pour qu'un chemin qui en contient reste UN seul -// argument (--chat-template-file "/mes modèles/tpl.jinja"). trimSplit sur " " -// le coupait en deux et llama-server refusait de démarrer. +// argument (--chat-template-file "/mes modèles/tpl.jinja"). Le découpage +// précédent, sur le seul caractère espace, le coupait en deux et llama-server +// refusait de démarrer. func splitArgs(s string) []string { out := []string{} var cur strings.Builder diff --git a/internal/ajean/run.go b/internal/ajean/run.go index f5237f4..f21ba85 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -238,15 +238,3 @@ func red(s string) string { return col("31", s) } func dim(s string) string { return col("2", s) } func yellow(s string) string { return col("33", s) } func magenta(s string) string { return col("35", s) } - -// trimSplit splits and drops empty tokens. -func trimSplit(s, sep string) []string { - out := []string{} - for _, p := range strings.Split(s, sep) { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out -} From e6a948c3147d9c62bfb5b40d92f3f03cad0c4c05 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 10:56:41 +0200 Subject: [PATCH 258/279] v0.8.2 : audit du coeur, correctifs de securite et de robustesse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Le mode agent redevient une vraie fermeture : une surcharge de requete ne peut plus RALLUMER l'agent (donc bash/write/edit/MCP) quand la machine l'a coupe. - Une lecture ratee de la cle de pilotage ne se confond plus avec « aucune cle » : requireWebAuth ferme (503) au lieu d'ouvrir l'API. - healthCheck : timeout de 3 s. Sans lui, un moteur qui accepte la connexion sans repondre laissait /api/chat/send bloque indefiniment. - Acces distant : la session de tunnel ecoute enfin un contexte. Un redemarrage ne fait plus cohabiter deux sessions ; le backoff se remet a zero apres une session qui a tenu. - Flux SSE : selection des evenements neufs par dichotomie au lieu de relire tout le journal a chaque token et pour chaque appareil. - Cache de lecture de la configuration, invalide par ecriture locale, par mtime et par age : fin des ~100 ouvertures de base par tour agentique. - Erreurs moteur classees par type (errors.Is / net.Error) et non par sous-chaine anglaise ; les 3 compactions partagent un seul chemin ; champs morts de chatReq retires ; ReadHeaderTimeout sur les deux serveurs HTTP. --- RELEASE_NOTES.md | 50 ++++---- cmd/ajean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/ajean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/ajean/versioninfo.json | 6 +- internal/ajean/backend_config.go | 6 +- internal/ajean/chat_conversation.go | 146 ++++++++++------------- internal/ajean/chat_conversation_test.go | 47 ++++++++ internal/ajean/llm_client.go | 55 ++++++++- internal/ajean/relay_link.go | 68 ++++++++--- internal/ajean/run.go | 2 +- internal/ajean/store.go | 93 ++++++++++++++- internal/ajean/store_cache_test.go | 61 ++++++++++ internal/ajean/web_api.go | 33 +++-- internal/ajean/web_auth.go | 24 +++- internal/ajean/web_chat.go | 25 +++- internal/ajean/web_chat_caps_test.go | 44 +++++++ internal/ajean/web_server.go | 7 +- 17 files changed, 499 insertions(+), 168 deletions(-) create mode 100644 internal/ajean/store_cache_test.go create mode 100644 internal/ajean/web_chat_caps_test.go diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 32f37f6..aa3f4c9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,44 +1,48 @@ -Une version de corrections, née de vos retours sur la 0.8.0 : un moteur qui refusait de démarrer sans jamais le dire, et des presets qui se mélangeaient. +Une version d'audit. Aucune fonctionnalité nouvelle : une relecture du cœur du code, et la correction de ce qu'elle a mis au jour. Deux de ces défauts pouvaient vous mordre pour de bon. -## « ajean start » ne ment plus +## Le mode agent redevient un vrai interrupteur -Le cas le plus pénible remonté cette semaine : `ajean start` répondait `[ok] ajean-engine: activating`, puis `ajean test` répondait « /health ne répond pas ». Rien dans les deux messages ne permettait de comprendre. +L'API de chat acceptait, dans le corps de la requête, une surcharge qui **rallumait** le mode agent. Autrement dit : agent éteint sur la machine, mais un client qui envoyait le bon drapeau récupérait quand même le shell, l'écriture de fichiers et les outils MCP. Comme l'API de pilotage n'est pas protégée par défaut et écoute sur toutes les interfaces, l'interrupteur ne garantissait rien sur un réseau local partagé. -L'explication tenait à une nuance de systemd. Un service qui meurt au lancement est relancé toutes les trois secondes, et pendant ces trois secondes il est en `activating`, exactement comme un moteur en train de charger un modèle. AJEAN prenait donc un échec en boucle pour un démarrage en cours. Il distingue désormais les deux : un vrai chargement reste annoncé comme tel (un gros .gguf prend des minutes, c'est normal), une boucle d'échec affiche l'erreur, les vingt dernières lignes du journal et quoi corriger. +Désormais une surcharge ne peut que **restreindre**. Ce qui est éteint sur la machine ne peut plus être rallumé par une requête, et couper l'agent coupe aussi, du même geste, l'accès web qui en dépend. Deux tests verrouillent la règle dans les deux sens. -Mieux : `ajean start` et `ajean restart` vérifient d'abord ce sans quoi le moteur ne peut pas démarrer, et refusent de lancer un service condamné. Moteur absent, modèle non renseigné, fichier .gguf introuvable : c'est dit tout de suite, avec la commande qui répare. +Le champ hérité qui portait cette surcharge venait du temps où l'ancien portail gérait ses propres interrupteurs. Il reste accepté, mais borné. -## Un modèle peut vivre où vous voulez +## Une base illisible ne désarme plus l'authentification -`MODEL=/home/moi/modeles/mon-modele.gguf` était refusé si le dossier n'avait pas été déclaré au préalable dans l'interface web. Le moteur mourait alors en boucle sur un « dossier non autorisé » que personne ne voyait passer, et le seul remède connu consistait à ouvrir l'interface pour y ajouter le dossier. Un utilisateur l'a trouvé tout seul, à tâtons ; ce n'était pas une découverte à lui imposer. +Quand aucune clé de pilotage n'est enregistrée, l'API est ouverte : c'est le confort du local. Mais la lecture de cette clé traitait « je n'ai pas pu lire la base » exactement comme « il n'y a pas de clé ». Une base momentanément verrouillée par une commande lancée à côté suffisait donc, en théorie, à ouvrir l'API le temps de la contention. -Ce garde-fou existe pour empêcher l'interface web de supprimer un fichier n'importe où sur la machine. Il n'avait aucune raison de s'appliquer à « ouvrir ce modèle en lecture ». Un chemin absolu vers un .gguf qui existe est maintenant accepté tel quel, quel que soit le disque. La protection reste entière là où elle sert. +La lecture des secrets distingue maintenant les deux cas, et l'authentification **ferme** en cas de doute (503) au lieu d'ouvrir. -## La configuration s'explique enfin +## Le bouton d'envoi ne peut plus rester bloqué -Depuis que la configuration vit en base, `ajean edit` déroulait dans votre éditeur les seules clés déjà définies. Sur une installation neuve, cela donnait un fichier quasiment vide : impossible de deviner quoi écrire. +Avant chaque tour, AJEAN vérifie que le moteur répond. Cet appel n'avait aucun délai maximum. Un moteur qui accepte la connexion sans jamais répondre, ce qui est exactement ce que fait un très gros modèle pendant son chargement, laissait donc l'envoi du message suspendu indéfiniment, sans erreur et sans retour. Il abandonne maintenant au bout de trois secondes et vous dit que le modèle n'est pas prêt. -L'éditeur propose désormais un squelette commenté. Chaque réglage utile y figure avec son rôle et son défaut (BIN, MODEL, CTX, NGL, batch, threads, cache KV, raisonnement, mémoire, EXTRA_ARGS). Les valeurs que vous avez déjà réglées sont en clair, les autres restent commentées, donc inactives, et toute clé que le squelette ne connaît pas est conservée en fin de fichier. +## L'accès distant ne peut plus se dédoubler -Les étapes affichées à la fin de `ajean install` ont été corrigées dans la foulée : elles oubliaient `ajean llamacpp install`, sans quoi la clé BIN reste vide et le moteur ne peut pas démarrer. +Redémarrer le lien depuis l'interface arrêtait la boucle de connexion, mais la session en cours, elle, continuait de vivre : elle n'écoutait aucun signal d'arrêt et attendait la mort naturelle du WebSocket, qui n'arrive pas tant que le relais répond. Le nouveau tunnel s'ouvrait donc pendant que l'ancien tenait encore, et le relais voyait deux agents pour une seule machine. -## Presets : un nouveau preset repart propre (issue #17) +L'arrêt est maintenant immédiat et attendu : une session se ferme quand on le lui demande, et la suivante ne démarre qu'après. -Créer un preset avec le bouton « + » recopiait la configuration active en entier. Les réglages du modèle précédent (les experts déportés sur le CPU, la répartition entre cartes, le contexte, jusqu'au modèle lui-même) se mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à tout nettoyer à la main. +Dans la foulée, le délai entre deux tentatives de reconnexion se remet à zéro après une session qui a tenu. Il grimpait sans jamais redescendre et finissait collé à trente secondes, y compris pour rattraper un lien qui venait de fonctionner des heures. -Un nouveau preset ne reprend plus que ce qui relève de la machine : le moteur, l'adresse et le port. Tout le reste part des valeurs par défaut, que vous ajustez pour ce modèle-là. +## Le flux de chat ne relit plus tout le fil à chaque mot -## Presets : les guillemets ne sont plus mangés +Pour envoyer les nouveaux événements à votre navigateur, le serveur reparcourait la totalité du journal de conversation, à chaque token généré, et pour chaque appareil connecté, en tenant le verrou que la génération elle-même attend. Sur une longue conversation, c'est un coût qui grandit avec l'historique et qui ralentit ce qu'il est censé diffuser. -Le rapport parlait de contenu tronqué et de guillemets non appariés, et c'était exact, avec une cause plus profonde qu'il n'y paraissait. En relisant une ligne de configuration, AJEAN retirait tout guillemet en début et en fin de valeur, sans vérifier qu'il s'agissait d'une paire. Une valeur comme : +Le journal étant trié, la recherche du premier événement neuf se fait par dichotomie. Le coût ne dépend plus de la longueur de la conversation. -``` -EXTRA_ARGS=--jinja --chat-template-file "/etc/ajean/gabarit.jinja" -``` +## La configuration n'est plus relue depuis le disque cent fois par tour -ressortait donc amputée de son guillemet final, déséquilibrée, et repartait ainsi dans le preset enregistré. Le défaut existait des deux côtés, dans le moteur comme dans l'interface. Seule une paire entourante est maintenant retirée, et l'écriture est faite pour être relue à l'identique. Un test verrouille l'aller-retour. +La base de données est délibérément fermée entre deux opérations : c'est ce qui permet aux commandes du terminal de fonctionner pendant que le service tourne. Mais la boucle d'inférence relisait le port, la clé et le seuil de compactage à chaque itération, et le compactage se re-testait après chaque appel d'outil. Un tour agentique un peu fourni rouvrait le fichier une centaine de fois. -Dans la même zone : EXTRA_ARGS était découpé sur les espaces sans tenir compte des guillemets, si bien qu'un chemin contenant une espace devenait deux arguments et que llama-server refusait de démarrer. Le découpage respecte désormais les guillemets, comme le ferait un shell. +Un cache de lecture s'interpose, invalidé par toute écriture locale, par une écriture venue d'un autre processus (date et taille du fichier) et, en dernier filet, par l'âge. Le comportement ne change pas d'un iota, le travail inutile disparaît. + +## Le reste + +Les erreurs du moteur sont classées sur leur type plutôt que sur le texte du message : « connexion refusée » d'un Windows en français ne ressemblait à aucun des motifs anglais reconnus, et l'utilisateur recevait alors l'erreur brute au lieu de l'explication. + +Les trois compactions (début de tour, fin de tour, bouton manuel) déroulaient la même douzaine de lignes recopiées ; elles partagent maintenant un seul chemin, ce qui ferme la porte aux corrections qui n'atterrissaient que dans une des trois copies. La ligne de journal de la compaction de secours comparait le résultat à lui-même, elle compare enfin l'avant et l'après. Deux champs de l'API de chat que plus personne ne lisait depuis que la conversation vit côté serveur ont été retirés. Les serveurs HTTP posent un délai de lecture des en-têtes, pour qu'une connexion qui n'envoie jamais rien ne retienne pas de ressources. ## Mise à jour @@ -46,4 +50,4 @@ Dans la même zone : EXTRA_ARGS était découpé sur les espaces sans tenir comp ajean update ``` -Ou, depuis l'interface, le bandeau de mise à jour. Rien à migrer : les presets, la configuration et la mémoire sont inchangés. +Rien à migrer : presets, configuration et mémoire sont inchangés. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 2543f9382a393815aa63ce819ad00dac49e1b482..3cc656f437e0985c16b2d2659969960e745377ce 100644 GIT binary patch delta 35 ocmexi^}}idmn0_>0|x^LFl`o=lw@Kw+N>)b%*bdoxl!gj0FulIBLDyZ delta 35 ocmexi^}}idmn0`60|x^LFm4u>lw@Kw+^j1d%*bdsxl!gj0FsRe9{>OV diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index bb3017e5d32b53231e687bbcfd93f5c1aa7af001..3a1cb4c354f98503a55ec6c643429e8541322598 100644 GIT binary patch delta 35 ocmexi^}}idmn0_>0|x^LFl`o=lw@Kw+N>)b%*bdoxl!gj0FulIBLDyZ delta 35 ocmexi^}}idmn0`60|x^LFm4u>lw@Kw+^j1d%*bdsxl!gj0FsRe9{>OV diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 5e356a1..bf429e1 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 8, - "Patch": 1, + "Patch": 2, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 8, - "Patch": 1, + "Patch": 2, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.8.1", + "ProductVersion": "0.8.2", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_config.go b/internal/ajean/backend_config.go index d002714..a08f83f 100644 --- a/internal/ajean/backend_config.go +++ b/internal/ajean/backend_config.go @@ -89,8 +89,10 @@ func cmdSetAPIKey(args []string) error { return serviceAction("restart") } -// ReadConfig renvoie la configuration active de llama-server. -func ReadConfig() map[string]string { return allKV(bkConfig) } +// ReadConfig renvoie la configuration active de llama-server. Passe par le cache +// de lecture (store.go) : elle est relue à chaque itération de la boucle +// d'inférence et après chaque appel d'outil. +func ReadConfig() map[string]string { return cachedKV(bkConfig) } // SetConfigKey définit une clé de configuration. Une valeur vide la supprime. func SetConfigKey(key, value string) error { return putStr(bkConfig, key, value) } diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index 24e0a32..eb63d8d 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "strings" "sync" "time" @@ -198,6 +199,46 @@ func (c *Conversation) compactLogLocked() { c.Log = out } +// compactAndPublish exécute UNE compaction et en publie tout le cycle de vie : +// bannière de progression, journal, installation du résultat, jauge de contexte. +// Renvoie l'historique (compacté ou inchangé) et s'il a changé. +// +// Les trois compactions (début de tour, fin de tour, bouton manuel) déroulaient +// la même douzaine de lignes recopiées, y compris le calcul du surcoût fixe : +// une correction dans l'une ne suivait pas dans les deux autres. +// +// Le surcoût, justement : le contexte RÉEL mesuré moins l'estimation des +// messages donne ce que l'estimation ne voit pas (prompt système injecté, +// schémas d'outils, gabarit de chat). On le rajoute à l'estimation d'après +// compaction, sinon la jauge s'effondre puis resaute au tour suivant. +func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase string, msgs []Message, ctxUsed int, caps Caps) ([]Message, bool) { + c.appendDelta(epoch, map[string]any{"compacting": true}) + compacted, changed := compactMessages(ctx, msgs, caps) + c.appendDelta(epoch, map[string]any{"compacting": false}) + logCompact(phase, ctxUsed, msgs, compacted, changed) + if !changed { + // Seuil franchi mais compaction sans effet (torse vide, ou réduction sous + // le minimum exigé) : on le DIT. Retirer la bannière sans un mot donnait, + // vu de l'UI, « le compactage automatique ne fait rien ». + c.appendDelta(epoch, map[string]any{"compact_noop": true}) + return msgs, false + } + overhead := ctxUsed - estimateTokens(msgs) + if overhead < 0 { + overhead = 0 + } + est := estimateTokens(compacted) + overhead + c.mu.Lock() + if c.epoch == epoch { + c.Messages = compacted + c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour + } + c.mu.Unlock() + c.appendDelta(epoch, map[string]any{"compacted": true}) + c.appendDelta(epoch, map[string]any{"ctx_used": est}) // fait chuter la jauge tout de suite + return compacted, true +} + // convState renvoie un instantané léger (pour /api/chat/state). func (c *Conversation) state() map[string]any { c.mu.Lock() @@ -272,46 +313,13 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa c.mu.Unlock() // Compaction proactive (façon Hermes) sur la vue MODÈLE uniquement ; le journal - // d'affichage garde le fil complet. On signale juste un toast au client. - // Si le contexte dépasse le seuil, le résumé (un appel modèle non streamé) va - // bloquer plusieurs secondes AVANT que la vraie réponse commence. On affiche - // une bannière « compactage en cours » pendant ce temps (sinon l'UI se fige - // sans aucune info), puis on la retire — que le compactage ait changé quelque - // chose ou non. - willCompact := compactWouldTrigger(msgs, ctxUsed) - if willCompact { - c.appendDelta(epoch, map[string]any{"compacting": true}) - } - compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed) - if willCompact { - logCompact("début-tour", ctxUsed, msgs, compacted, changed) - } - if changed { - // Surcoût fixe non compactable (prompt système injecté + schémas d'outils + - // gabarit) = contexte réel mesuré − estimation des messages. On le rajoute à - // l'estimation post-compaction pour que la jauge affiche une valeur réaliste - // (sinon elle chute trop bas puis resaute au tour suivant). - overhead := ctxUsed - estimateTokens(msgs) - if overhead < 0 { - overhead = 0 + // d'affichage garde le fil complet. Le résumé est un appel modèle non streamé : + // il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la + // bannière de progression émise par compactAndPublish. + if compactWouldTrigger(msgs, ctxUsed) { + if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed { + msgs = out } - est := estimateTokens(compacted) + overhead - msgs = compacted - c.mu.Lock() - if c.epoch == epoch { - c.Messages = compacted - c.CtxUsed = est // le vrai compte reviendra avec les stats du tour - } - c.mu.Unlock() - c.appendDelta(epoch, map[string]any{"compacting": false}) - c.appendDelta(epoch, map[string]any{"compacted": true}) - c.appendDelta(epoch, map[string]any{"ctx_used": est}) // fait chuter la jauge tout de suite - } else if willCompact { - // Seuil franchi mais compaction sans effet (torse vide, ou réduction sous les - // 20% exigés) : on le DIT. Avant, on retirait juste la bannière sans un mot — - // vu de l'UI, « le compactage automatique ne fait rien » sans aucune trace. - c.appendDelta(epoch, map[string]any{"compacting": false}) - c.appendDelta(epoch, map[string]any{"compact_noop": true}) } // Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur). @@ -414,27 +422,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa if stale || ctx.Err() != nil || !compactWouldTrigger(msgs, ctxUsed) { return } - c.appendDelta(epoch, map[string]any{"compacting": true}) - compacted, changed = compactMessages(context.Background(), msgs, caps) - c.appendDelta(epoch, map[string]any{"compacting": false}) - logCompact("fin-tour", ctxUsed, msgs, compacted, changed) - if !changed { - c.appendDelta(epoch, map[string]any{"compact_noop": true}) - return - } - overhead := ctxUsed - estimateTokens(msgs) - if overhead < 0 { - overhead = 0 - } - est := estimateTokens(compacted) + overhead - c.mu.Lock() - if c.epoch == epoch { - c.Messages = compacted - c.CtxUsed = est - } - c.mu.Unlock() - c.appendDelta(epoch, map[string]any{"compacted": true}) - c.appendDelta(epoch, map[string]any{"ctx_used": est}) + // context.Background() et non ctx : le tour est terminé, son contexte peut + // être annulé alors que cette compaction-là doit aller au bout. + c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps) } // CompactNow force une compaction du contexte MAINTENANT, sans attendre le seuil @@ -466,28 +456,9 @@ func (c *Conversation) CompactNow() error { c.cancel = nil c.mu.Unlock() }() - c.appendDelta(epoch, map[string]any{"compacting": true}) - compacted, changed := compactMessages(ctx, msgs, Caps{}) - c.appendDelta(epoch, map[string]any{"compacting": false}) - logCompact("manuel", lastReal, msgs, compacted, changed) - if !changed { - c.appendDelta(epoch, map[string]any{"compact_noop": true}) - return + if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}); changed { + c.persist() } - overhead := lastReal - estimateTokens(msgs) - if overhead < 0 { - overhead = 0 - } - est := estimateTokens(compacted) + overhead // + surcoût fixe (système+outils) pour une jauge réaliste - c.mu.Lock() - if c.epoch == epoch { - c.Messages = compacted - c.CtxUsed = est // feedback immédiat ; le vrai compte revient au prochain tour - } - c.mu.Unlock() - c.appendDelta(epoch, map[string]any{"compacted": true}) - c.appendDelta(epoch, map[string]any{"ctx_used": est}) - c.persist() }() return nil } @@ -727,11 +698,16 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st // Copie des événements en attente SOUS verrou, émission HORS verrou : on // n'itère jamais sur c.Log pendant que la génération peut y écrire ou que // la troncature (maxLogEvents) peut le déplacer. + // + // c.Log est trié par Seq croissant : on trouve le premier événement neuf + // par dichotomie plutôt qu'en relisant tout. Le balayage complet coûtait + // la longueur du journal (jusqu'à 20 000) À CHAQUE TOKEN et pour CHAQUE + // appareil connecté, verrou tenu — donc au détriment de la génération + // elle-même. C'est quadratique sur un long fil. + i := sort.Search(len(c.Log), func(i int) bool { return c.Log[i].Seq > last }) var pending []LogEvent - for _, ev := range c.Log { - if ev.Seq > last { - pending = append(pending, ev) - } + if i < len(c.Log) { + pending = append(pending, c.Log[i:]...) } if len(pending) == 0 { c.cond.Wait() diff --git a/internal/ajean/chat_conversation_test.go b/internal/ajean/chat_conversation_test.go index a46969d..214f416 100644 --- a/internal/ajean/chat_conversation_test.go +++ b/internal/ajean/chat_conversation_test.go @@ -178,3 +178,50 @@ func TestReplayMarksReplaceWhenClientSawPartOfBlock(t *testing.T) { t.Fatalf("replace ne doit PAS être posé quand le client n'a rien vu du bloc") } } + +// Le direct sélectionne les événements neufs par dichotomie (c.Log est trié par +// Seq). Deux cas limites doivent rester justes : un journal TRONQUÉ, dont le +// premier Seq est très supérieur au `from` du client, et un client déjà à jour. +func TestLiveSelectionSurJournalTronque(t *testing.T) { + c := newTestConv() + // Journal amputé de son début, comme après la troncature à maxLogEvents. + c.Log = []LogEvent{ + {Seq: 500, Delta: map[string]any{"user": "a"}}, + {Seq: 501, Delta: map[string]any{"user": "b"}}, + {Seq: 502, Delta: map[string]any{"user": "c"}}, + } + c.Seq = 502 + + for _, tc := range []struct { + from int + first int // premier seq attendu + }{ + {from: 0, first: 500}, // client neuf : il reçoit tout ce qui reste + {from: 500, first: 501}, // au milieu du journal + {from: 501, first: 502}, + } { + ctx, cancel := context.WithCancel(context.Background()) + got := make(chan int, 8) + go c.Subscribe(ctx, tc.from, func(m map[string]any) bool { + if s, ok := m["seq"].(int); ok { + got <- s + } + return true + }) + waitSeq(t, got, tc.first) + cancel() + } + + // Client déjà à jour : rien à rejouer, mais le direct doit suivre. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + got := make(chan int, 8) + go c.Subscribe(ctx, 502, func(m map[string]any) bool { + if s, ok := m["seq"].(int); ok { + got <- s + } + return true + }) + c.appendDelta(c.epoch, map[string]any{"content": "suite"}) + waitSeq(t, got, 503) +} diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index f38ff92..8cadd67 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -8,10 +8,13 @@ import ( "errors" "fmt" "io" + "net" "net/http" "sort" "strconv" "strings" + "syscall" + "time" "unicode/utf8" ) @@ -472,24 +475,55 @@ const thinkClose = "" // « actively refused » (Windows) — arrive quand le moteur redémarre ou charge // encore le modèle ; l'erreur brute (« dial tcp 127.0.0.1:8080… ») ne dit rien à // l'utilisateur. On garde le silence sur une annulation volontaire (/stop). +// On classe d'abord sur les erreurs TYPÉES (errors.Is / net.Error) : la +// reconnaissance par sous-chaîne dépend de la langue et du format des messages +// de l'OS, et « connexion refusée » d'un Windows français ne ressemble à aucun +// des motifs anglais. Les sous-chaînes restent en second rideau, pour les +// erreurs enveloppées par une bibliothèque qui perd le type d'origine. func friendlyLLMError(err error) error { if err == nil { return nil } + if errors.Is(err, context.Canceled) { + return err // /stop : pas d'alarme + } + switch { + case errors.Is(err, syscall.ECONNREFUSED): + return errEngineDown() + case errors.Is(err, context.DeadlineExceeded), isNetTimeout(err): + return fmt.Errorf("⚠️ Le moteur (llama-server) met trop de temps à répondre (port %d) — il est peut-être surchargé ou en plein chargement. Réessaie dans un instant.", LLMPort()) + case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, syscall.ECONNRESET): + return errEngineReset() + } low := strings.ToLower(err.Error()) switch { case strings.Contains(low, "context canceled"): - return err // /stop : pas d'alarme + return err case strings.Contains(low, "connection refused"), strings.Contains(low, "actively refused"), strings.Contains(low, "connectex"), strings.Contains(low, "no connection could be made"): - return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort()) + return errEngineDown() case strings.Contains(low, "timeout"), strings.Contains(low, "deadline exceeded"): return fmt.Errorf("⚠️ Le moteur (llama-server) met trop de temps à répondre (port %d) — il est peut-être surchargé ou en plein chargement. Réessaie dans un instant.", LLMPort()) case strings.Contains(low, "eof"), strings.Contains(low, "connection reset"): - return fmt.Errorf("⚠️ Connexion au moteur (llama-server, port %d) interrompue — il a peut-être redémarré. Réessaie.", LLMPort()) + return errEngineReset() } return err } +func errEngineDown() error { + return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort()) +} + +func errEngineReset() error { + return fmt.Errorf("⚠️ Connexion au moteur (llama-server, port %d) interrompue — il a peut-être redémarré. Réessaie.", LLMPort()) +} + +// isNetTimeout : une erreur réseau qui se déclare elle-même comme un délai +// dépassé (net.Error.Timeout), quel que soit son libellé. +func isNetTimeout(err error) bool { + var ne net.Error + return errors.As(err, &ne) && ne.Timeout() +} + func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) { var extra []Message tools := EnabledTools(caps) @@ -575,8 +609,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if compactEnabled() && !compactedRetry { if c, changed := compactMessages(ctx, messages, caps); changed { compactedRetry = true - messages = c + // ⚠️ Journaliser AVANT d'installer le résultat : l'ancien ordre + // passait `messages` déjà remplacé comme état « avant », donc la + // ligne comparait le résultat à lui-même et n'apprenait rien. logCompact("réactif", 0, messages, c, changed) + messages = c // Même publication qu'en cours de tour : sans elle, la compaction de // secours ne survit pas à la fin du tour et le prompt re-déborde au // message suivant. @@ -1023,9 +1060,17 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps } } +// healthClient : /health doit répondre tout de suite ou pas du tout. Sans +// timeout (http.Get et son client par défaut n'en ont aucun), un moteur qui +// accepte la connexion sans jamais répondre — cas classique d'un très gros +// modèle en cours de chargement, ou d'un process figé — bloquait healthCheck +// indéfiniment. Et comme StartTurn commence par là, /api/chat/send restait +// pendu : l'utilisateur voyait un bouton d'envoi qui ne rendait jamais la main. +var healthClient = &http.Client{Timeout: 3 * time.Second} + // healthCheck pings llama.cpp's /health endpoint. func healthCheck() bool { - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort())) + resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort())) if err != nil { return false } diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 65e1bbe..4bde31a 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -214,7 +214,8 @@ func linkPrintIdentity() error { var appLink struct { mu sync.Mutex running bool - stop chan struct{} + stop context.CancelFunc + done chan struct{} // fermé quand la boucle a vraiment rendu la main } // appOwnsLink : vrai dans le process de l'app de bureau, qui pilote le tunnel @@ -238,21 +239,25 @@ func startAppLink(mux *http.ServeMux) { if token == "" { return } - stop := make(chan struct{}) - appLink.running, appLink.stop = true, stop + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + appLink.running, appLink.stop, appLink.done = true, cancel, done handler := newLinkHandler(mux) oaiTLS := oaiTLSConfig() go func() { + defer close(done) backoff := time.Second - for { - select { - case <-stop: - return - default: + for ctx.Err() == nil { + started := time.Now() + _ = runLinkSession(ctx, token, handler, oaiTLS) + // Une session qui a TENU repart de zéro. Sans cette remise, le délai + // grimpait de coupure en coupure et restait collé à 30 s pour toujours, + // y compris pour reconnecter un lien qui venait de vivre des heures. + if time.Since(started) > time.Minute { + backoff = time.Second } - _ = runLinkSession(token, handler, oaiTLS) select { - case <-stop: + case <-ctx.Done(): return case <-time.After(backoff): } @@ -266,15 +271,25 @@ func startAppLink(mux *http.ServeMux) { }() } -// stopAppLink arrête la boucle interne (la session en cours se termine d'elle-même). +// stopAppLink arrête la boucle interne ET attend qu'elle ait rendu la main. +// L'attente est ce qui garantit qu'un redémarrage ne fait pas cohabiter deux +// sessions : sans elle, le nouveau tunnel s'ouvrait pendant que l'ancien vivait +// encore, et le relais voyait deux agents pour la même machine. func stopAppLink() { appLink.mu.Lock() - defer appLink.mu.Unlock() if !appLink.running { + appLink.mu.Unlock() return } - close(appLink.stop) - appLink.running = false + cancel, done := appLink.stop, appLink.done + appLink.running, appLink.stop, appLink.done = false, nil, nil + appLink.mu.Unlock() + + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): // filet : on ne bloque pas l'UI indéfiniment + } } func appLinkRunning() bool { @@ -519,8 +534,14 @@ func withLocalAuth(next http.Handler) http.Handler { // runLinkSession opens one WebSocket→yamux session and serves it until it dies. // It blocks for the lifetime of the connection and returns the error that ended // it (so the caller can reconnect). -func runLinkSession(token string, handler http.Handler, oaiTLS *tls.Config) error { - ctx := context.Background() +// runLinkSession tient UNE session de tunnel, jusqu'à ce qu'elle meure ou que +// ctx soit annulé. Le contexte est indispensable : sans lui, un « arrêter le +// lien » ne prenait effet qu'à la mort naturelle du WebSocket, qui n'arrive +// jamais tant que le relais répond. Un redémarrage (stop puis start enchaînés) +// lançait donc une seconde session pendant que la première vivait encore : deux +// agents connectés au relais pour la même machine, une seule conversation +// derrière. +func runLinkSession(ctx context.Context, token string, handler http.Handler, oaiTLS *tls.Config) error { dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() @@ -569,7 +590,10 @@ func runLinkSession(token string, handler http.Handler, oaiTLS *tls.Config) erro // va au HTTP (comportement historique inchangé). httpLn := newChanListener(sess.Addr()) defer httpLn.Close() - srv := &http.Server{Handler: handler} + // Même garde-fou que le serveur local : un stream du tunnel qui n'envoie + // jamais d'en-têtes ne doit pas retenir une goroutine indéfiniment. Pas de + // WriteTimeout : le chat E2E est un flux SSE de longue durée. + srv := &http.Server{Handler: handler, ReadHeaderTimeout: 30 * time.Second} go srv.Serve(httpLn) var oaiLn *chanListener @@ -579,9 +603,19 @@ func runLinkSession(token string, handler http.Handler, oaiTLS *tls.Config) erro go runOAIFront(oaiLn, oaiTLS) } + // Annulation : fermer la session yamux débloque l'Accept ci-dessous, qui est + // le seul point où cette fonction attend. La goroutine meurt avec la session. + go func() { + <-ctx.Done() + _ = sess.Close() + }() + for { stream, err := sess.Accept() if err != nil { + if ctx.Err() != nil { + return ctx.Err() // arrêt demandé, pas une panne + } return fmt.Errorf("accept: %w", err) } go demuxTunnelStream(stream, httpLn, oaiLn) diff --git a/internal/ajean/run.go b/internal/ajean/run.go index f21ba85..6e124da 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.8.1" +const Version = "0.8.2" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/store.go b/internal/ajean/store.go index 65dcf68..9676291 100644 --- a/internal/ajean/store.go +++ b/internal/ajean/store.go @@ -86,6 +86,7 @@ func view(bucket string, fn func(b *bolt.Bucket) error) error { // update exécute une écriture, en créant le bucket au besoin. func update(bucket string, fn func(b *bolt.Bucket) error) error { + cacheBust(bucket) return withDB(func(d *bolt.DB) error { return d.Update(func(tx *bolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte(bucket)) @@ -97,17 +98,28 @@ func update(bucket string, fn func(b *bolt.Bucket) error) error { }) } -// getBytes lit une valeur. Une base inaccessible se comporte comme une base -// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile -// face à une erreur d'E/S, et tous ont déjà un défaut. -func getBytes(bucket, key string) []byte { +// getBytesErr lit une valeur en REMONTANT l'erreur d'accès. À réserver aux +// lecteurs pour qui « je n'ai pas pu lire » et « il n'y a rien » ne veulent pas +// dire la même chose — au premier chef les secrets : sans clé enregistrée, l'API +// de pilotage est ouverte, donc une lecture ratée traitée comme « pas de clé » +// ouvrirait l'API au lieu de la fermer. Voir readWebKeyErr. +func getBytesErr(bucket, key string) ([]byte, error) { var out []byte - _ = view(bucket, func(b *bolt.Bucket) error { + err := view(bucket, func(b *bolt.Bucket) error { if v := b.Get([]byte(key)); v != nil { out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction } return nil }) + return out, err +} + +// getBytes lit une valeur. Une base inaccessible se comporte comme une base +// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile +// face à une erreur d'E/S, et tous ont déjà un défaut. Les lecteurs pour qui +// l'erreur CHANGE la décision prennent getBytesErr. +func getBytes(bucket, key string) []byte { + out, _ := getBytesErr(bucket, key) return out } @@ -159,6 +171,76 @@ func putJSON(bucket, key string, v any) error { return putBytes(bucket, key, b) } +// --- Cache de lecture --------------------------------------------------------- +// +// La base est rouverte à CHAQUE opération (choix délibéré, voir plus haut), ce +// qui est parfait pour un réglage lu de temps en temps mais coûteux dans le +// chemin chaud : la boucle d'inférence relit le port, la clé, le mode +// raisonnement et le seuil de compactage à chaque itération, et le compactage +// se re-teste après chaque appel d'outil. Un tour agentique un peu fourni +// rouvrait la base une centaine de fois. +// +// cachedKV garde donc le contenu d'un bucket en mémoire, invalidé par : +// - une écriture de CE process (cacheBust, appelé par update/replaceKV) ; +// - un changement de taille ou de date du fichier, qui trahit l'écriture d'un +// AUTRE process (la CLI pendant que le service tourne) ; +// - l'âge, plafonné à une seconde, filet pour le cas limite où deux écritures +// rapprochées laisseraient date et taille inchangées. +// +// Il ne sert PAS aux secrets : eux se lisent directement (voir getBytesErr). +const cacheMaxAge = time.Second + +type kvCache struct { + kv map[string]string + when time.Time + mtime time.Time + size int64 +} + +var ( + cacheMu sync.Mutex + caches = map[string]kvCache{} +) + +// cacheBust vide le cache d'un bucket après une écriture locale. +func cacheBust(bucket string) { + cacheMu.Lock() + delete(caches, bucket) + cacheMu.Unlock() +} + +// dbStamp renvoie la date et la taille du fichier de base — de quoi repérer +// l'écriture d'un autre process pour le prix d'un stat. +func dbStamp() (time.Time, int64) { + fi, err := os.Stat(dbPath()) + if err != nil { + return time.Time{}, -1 + } + return fi.ModTime(), fi.Size() +} + +// cachedKV renvoie tout le contenu d'un bucket, depuis le cache quand il est +// encore valable. La carte renvoyée appartient à l'appelant (copie). +func cachedKV(bucket string) map[string]string { + mtime, size := dbStamp() + cacheMu.Lock() + c, ok := caches[bucket] + fresh := ok && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge + cacheMu.Unlock() + if !fresh { + kv := allKV(bucket) + c = kvCache{kv: kv, when: time.Now(), mtime: mtime, size: size} + cacheMu.Lock() + caches[bucket] = c + cacheMu.Unlock() + } + out := make(map[string]string, len(c.kv)) + for k, v := range c.kv { + out[k] = v + } + return out +} + // allKV renvoie tout le contenu d'un bucket. Utilisé par la configuration, dont // les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts). func allKV(bucket string) map[string]string { @@ -176,6 +258,7 @@ func allKV(bucket string) map[string]string { // C'est ce qu'exige l'application d'un preset : à aucun instant la config ne // doit être un mélange de l'ancienne et de la nouvelle. func replaceKV(bucket string, m map[string]string) error { + cacheBust(bucket) return withDB(func(d *bolt.DB) error { return d.Update(func(tx *bolt.Tx) error { if err := tx.DeleteBucket([]byte(bucket)); err != nil && !errors.Is(err, bolterrors.ErrBucketNotFound) { diff --git a/internal/ajean/store_cache_test.go b/internal/ajean/store_cache_test.go new file mode 100644 index 0000000..0d2ee52 --- /dev/null +++ b/internal/ajean/store_cache_test.go @@ -0,0 +1,61 @@ +package ajean + +import "testing" + +// Le cache de lecture ne doit jamais servir une valeur périmée après une +// écriture de ce process : un « ajean edit » suivi d'un « ajean restart » doit +// démarrer sur la NOUVELLE configuration. +func TestCacheConfigInvalideParEcriture(t *testing.T) { + testHome(t) + if err := WriteConfig(map[string]string{"CTX": "4096"}); err != nil { + t.Fatal(err) + } + if got := ReadConfig()["CTX"]; got != "4096" { // remplit le cache + t.Fatalf("CTX = %q, attendu 4096", got) + } + if err := SetConfigKey("CTX", "8192"); err != nil { + t.Fatal(err) + } + if got := ReadConfig()["CTX"]; got != "8192" { + t.Fatalf("valeur périmée servie par le cache : CTX = %q, attendu 8192", got) + } + if err := WriteConfig(map[string]string{"MODEL": "m.gguf"}); err != nil { + t.Fatal(err) + } + if _, ok := ReadConfig()["CTX"]; ok { + t.Error("WriteConfig remplace tout : CTX ne doit plus exister") + } +} + +// La carte renvoyée appartient à l'appelant : la modifier ne doit pas polluer +// le cache, sans quoi un appelant distrait corromprait la configuration vue par +// tous les autres. +func TestCacheConfigRenvoieUneCopie(t *testing.T) { + testHome(t) + if err := WriteConfig(map[string]string{"CTX": "4096"}); err != nil { + t.Fatal(err) + } + cfg := ReadConfig() + cfg["CTX"] = "999" + delete(cfg, "CTX") + if got := ReadConfig()["CTX"]; got != "4096" { + t.Fatalf("le cache a été altéré par l'appelant : CTX = %q", got) + } +} + +// Une lecture ratée ne doit pas se confondre avec « aucune clé » : sans clé, +// l'API de pilotage est OUVERTE, donc l'erreur doit remonter pour que +// requireWebAuth ferme au lieu d'ouvrir. +func TestWebKeyDistingueVideEtErreur(t *testing.T) { + testHome(t) + k, err := readWebKeyErr() + if err != nil || k != "" { + t.Fatalf("base neuve : attendu (\"\", nil), reçu (%q, %v)", k, err) + } + if err := putStr(bkState, "web_key", "secret"); err != nil { + t.Fatal(err) + } + if k, err := readWebKeyErr(); err != nil || k != "secret" { + t.Fatalf("attendu (\"secret\", nil), reçu (%q, %v)", k, err) + } +} diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 859b160..92919c0 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -899,29 +899,26 @@ func handleBenchLast(w http.ResponseWriter, r *http.Request) { } // chatReq est le corps d'une requête de chat (commun au chat clair et au chat E2E). +// +// `messages` et `ctx_used`, que les clients envoyaient du temps où l'historique +// vivait dans le navigateur, ont été RETIRÉS : depuis que la conversation est +// possédée par le serveur (chat_conversation.go), plus personne ne les lisait. +// Les garder laissait croire qu'un client pouvait encore imposer son historique +// ou son décompte de contexte. Un client qui les envoie encore n'est pas gêné : +// le décodeur JSON ignore les champs qu'il ne connaît pas. type chatReq struct { - Messages []Message `json:"messages"` - Temperature float64 `json:"temperature"` - // Optional per-request override of the agent mode (used by ajean.link - // agents, which carry their own toggle). nil = inherit the machine's - // global config. Tools/Skills sont conservés pour la rétro-compat des - // anciens clients relais : l'un OU l'autre à true active le mode agent. + Temperature float64 `json:"temperature"` + // Surcharges par requête, portées par les agents ajean.link qui ont leurs + // propres interrupteurs. nil = on prend la configuration de la machine. + // ⚠️ Elles ne peuvent que RESTREINDRE, jamais rallumer (voir capsFromBody). + // Tools/Skills : rétro-compat des anciens clients relais. Agent *bool `json:"agent"` Tools *bool `json:"tools"` Skills *bool `json:"skills"` - // Override par requête de l'accès internet (outils web). nil = config machine. + // Surcharge par requête de l'accès internet (outils web). Internet *bool `json:"internet"` - // Taille réelle du contexte au tour précédent (usage.prompt_tokens + tokens - // générés), rapportée par le client qui l'affiche déjà. Sert à décider du - // compactage sur le VRAI décompte plutôt qu'une estimation. 0 = inconnu. - CtxUsed int `json:"ctx_used"` - // Nouveau modèle « conversation serveur » : Message = texte du tour à lancer - // (via /api/chat/send) ; From = dernier Seq déjà vu par le client (le flux - // d'abonnement rejoue Log[From:] puis suit le direct). + // Message = texte du tour à lancer (/api/chat/send) ; From = dernier Seq déjà + // vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct). Message string `json:"message"` From int `json:"from"` } - -// capsFromBody dérive les capacités du tour à partir des overrides éventuels du -// corps de requête (agents ajean.link portant leurs propres toggles), sinon la -// config machine. diff --git a/internal/ajean/web_auth.go b/internal/ajean/web_auth.go index 4ee3874..6ff9ce9 100644 --- a/internal/ajean/web_auth.go +++ b/internal/ajean/web_auth.go @@ -19,15 +19,33 @@ import ( // et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un // changement de clé prenne effet sans redémarrer le serveur web. -// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie. -func readWebKey() string { return getStr(bkState, "web_key") } +// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une +// LECTURE RATÉE. La nuance est tout sauf cosmétique : sans clé, l'API est +// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était +// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme +// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir. +func readWebKeyErr() (string, error) { + b, err := getBytesErr(bkState, "web_key") + return string(b), err +} + +// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou +// illisible). Réservé à l'affichage ; toute décision d'accès passe par +// readWebKeyErr. +func readWebKey() string { k, _ := readWebKeyErr(); return k } // 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() + key, err := readWebKeyErr() + if err != nil { + // On ne sait pas si une clé protège cette API : on ferme. + sendJSON(w, http.StatusServiceUnavailable, + map[string]any{"error": "configuration illisible — réessaie dans un instant"}) + return + } if key == "" { next(w, r) return diff --git a/internal/ajean/web_chat.go b/internal/ajean/web_chat.go index aa211c0..40fa2cb 100644 --- a/internal/ajean/web_chat.go +++ b/internal/ajean/web_chat.go @@ -11,15 +11,30 @@ import ( "time" ) +// capsFromBody dérive les capacités d'un tour à partir de la configuration +// machine et des surcharges portées par la requête. +// +// ⚠️ Une surcharge ne peut que RESTREINDRE. Elle pouvait auparavant rallumer le +// mode agent : un simple {"agent":true} redonnait bash, write, edit et les +// outils MCP alors que l'interrupteur de la machine était sur OFF. Comme l'API +// n'est pas protégée par défaut et écoute sur 0.0.0.0, l'interrupteur ne +// garantissait donc rien. Il redevient une vraie fermeture : ce qui est éteint +// sur la machine ne peut pas être rallumé par un client. func capsFromBody(body chatReq) Caps { caps := globalCaps() - if body.Agent != nil { - caps.Agent = *body.Agent - } else if body.Tools != nil || body.Skills != nil { - caps.Agent = (body.Tools != nil && *body.Tools) || (body.Skills != nil && *body.Skills) + switch { + case body.Agent != nil: + caps.Agent = caps.Agent && *body.Agent + case body.Tools != nil || body.Skills != nil: + want := (body.Tools != nil && *body.Tools) || (body.Skills != nil && *body.Skills) + caps.Agent = caps.Agent && want } if body.Internet != nil { - caps.Internet = *body.Internet && crawlReachable() + caps.Internet = caps.Internet && *body.Internet + } + // Les outils dépendent du mode agent : agent coupé, tout est coupé. + if !caps.Agent { + caps.Internet = false } return caps } diff --git a/internal/ajean/web_chat_caps_test.go b/internal/ajean/web_chat_caps_test.go new file mode 100644 index 0000000..50e5d31 --- /dev/null +++ b/internal/ajean/web_chat_caps_test.go @@ -0,0 +1,44 @@ +package ajean + +import "testing" + +func ptrBool(b bool) *bool { return &b } + +// Une surcharge portée par la requête ne doit JAMAIS rallumer ce que la machine +// a éteint : sans ça, {"agent":true} redonnait bash/write/edit (et les outils +// MCP) alors que l'interrupteur agent était sur OFF, sur une API qui n'est pas +// protégée par défaut. +func TestCapsFromBodyNePeutPasRallumerLAgent(t *testing.T) { + testHome(t) + if err := setAgentEnabled(false); err != nil { + t.Fatal(err) + } + for _, body := range []chatReq{ + {Agent: ptrBool(true)}, + {Tools: ptrBool(true)}, + {Skills: ptrBool(true)}, + {Internet: ptrBool(true)}, + } { + if caps := capsFromBody(body); caps.Agent || caps.Internet { + t.Errorf("agent coupé sur la machine, mais %+v a donné %+v", body, caps) + } + } + // Et les outils ne doivent pas non plus être servis au modèle. + if tools := EnabledTools(capsFromBody(chatReq{Agent: ptrBool(true)})); len(tools) > 0 { + t.Errorf("agent coupé : aucun outil ne devrait être proposé, %d le sont", len(tools)) + } +} + +// L'inverse reste vrai : une surcharge peut RESTREINDRE un agent actif. +func TestCapsFromBodyPeutRestreindre(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if caps := capsFromBody(chatReq{Agent: ptrBool(false)}); caps.Agent { + t.Error("agent:false doit couper l'agent pour ce tour") + } + if caps := capsFromBody(chatReq{}); !caps.Agent { + t.Error("sans surcharge, on doit hériter de la configuration machine") + } +} diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index c94123f..ade34f5 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -69,7 +69,12 @@ func cmdWeb(args []string) error { fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled()) startAppLink(mux) } - return http.Serve(ln, mux) + // ReadHeaderTimeout : sans lui, une connexion qui n'envoie jamais sa requête + // immobilise une goroutine pour toujours — et ce port écoute sur 0.0.0.0. + // Surtout PAS de WriteTimeout ici : il couperait les flux SSE du chat, qui + // restent ouverts aussi longtemps que l'utilisateur regarde la page. + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + return srv.Serve(ln) } // newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être From acf11ff66ae913acba4743c70c15a22bfae9087d Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 11:20:22 +0200 Subject: [PATCH 259/279] v0.8.3 : le stop arrete vraiment, le chat ne se bloque plus, raisonnement off = off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (chat bloque, remonte par Nathan) : trois causes cumulees. - runShell naissait d'un contexte independant du tour : stop n'arretait rien. La commande herite desormais du contexte du tour, et la boucle d'outils s'interrompt des que l'arret est demande. - cmd.WaitDelay : une commande laissant un process detache gardait les tubes ouverts, donc Wait ne revenait JAMAIS (ni delai, ni stop). - Reset debloque toujours l'etat de generation : « nouvelle conversation » etait le geste tente quand le chat coince, il ne servait a rien. Un tour perime ne libere plus le tour courant. Trouve en testant : le workspace disparu cassait TOUTES les commandes suivantes (chdir: no such file or directory) jusqu'au redemarrage ; il est recree au besoin. Bug 2 (raisonnement, remonte par Nathan) : l'UI ecrivait « off » en EFFACANT la ligne REASONING, or sans drapeau le moteur suit le gabarit du modele, qui raisonne. L'UI ecrit maintenant on|off et « off » part en --reasoning off, apres avoir demande au binaire s'il connait le drapeau (ik_llama.cpp ne le connait pas et refuserait de demarrer). Presets existants inchanges ; l'editeur dit desormais « non precise : le modele decide » quand la cle est absente. --- RELEASE_NOTES.md | 48 ++++------- cmd/ajean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/ajean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/ajean/versioninfo.json | 6 +- internal/ajean/backend_serve.go | 65 +++++++++++++-- internal/ajean/chat_conversation.go | 30 ++++++- internal/ajean/chat_stuck_test.go | 112 ++++++++++++++++++++++++++ internal/ajean/chat_tools.go | 36 ++++++++- internal/ajean/llm_client.go | 8 +- internal/ajean/run.go | 2 +- internal/ajean/ui/index.html | 18 ++++- internal/ajean/ui/src/index.tmpl.html | 4 +- internal/ajean/ui/src/js/07-models.js | 14 +++- 13 files changed, 284 insertions(+), 59 deletions(-) create mode 100644 internal/ajean/chat_stuck_test.go diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index aa3f4c9..98f3fca 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,48 +1,32 @@ -Une version d'audit. Aucune fonctionnalité nouvelle : une relecture du cœur du code, et la correction de ce qu'elle a mis au jour. Deux de ces défauts pouvaient vous mordre pour de bon. +Deux bugs signalés par l'usage, tous les deux confirmés, tous les deux réparés. Le premier pouvait bloquer le chat jusqu'au redémarrage du service. Le second faisait mentir un interrupteur. -## Le mode agent redevient un vrai interrupteur +## Le bouton stop arrête vraiment, et le chat ne se bloque plus -L'API de chat acceptait, dans le corps de la requête, une surcharge qui **rallumait** le mode agent. Autrement dit : agent éteint sur la machine, mais un client qui envoyait le bon drapeau récupérait quand même le shell, l'écriture de fichiers et les outils MCP. Comme l'API de pilotage n'est pas protégée par défaut et écoute sur toutes les interfaces, l'interrupteur ne garantissait rien sur un réseau local partagé. +Le scénario, tel qu'il était vécu : le modèle lance une commande, elle dure, on clique sur stop, rien ne se passe. On redémarre alors le moteur, et là le chat reste figé avec son bouton stop, sans plus rien accepter. Vider la conversation ne suffit pas, rafraîchir la page non plus ; il faut redémarrer le service d'interface. -Désormais une surcharge ne peut que **restreindre**. Ce qui est éteint sur la machine ne peut plus être rallumé par une requête, et couper l'agent coupe aussi, du même geste, l'accès web qui en dépend. Deux tests verrouillent la règle dans les deux sens. +Trois défauts se cumulaient, et ils sont corrigés ensemble. -Le champ hérité qui portait cette surcharge venait du temps où l'ancien portail gérait ses propres interrupteurs. Il reste accepté, mais borné. +**La commande ignorait l'arrêt.** Elle démarrait avec un contexte à elle, indépendant du tour. Annuler la génération n'annulait donc rien du tout : le tour restait suspendu jusqu'au bout du délai, cinq minutes au maximum. La commande hérite désormais du contexte du tour, et stop la tue pour de bon. Dans la foulée, un arrêt demandé au milieu d'une série d'appels d'outils interrompt la série au lieu de la dérouler jusqu'au bout. -## Une base illisible ne désarme plus l'authentification +**Une commande qui laisse un processus en arrière-plan bloquait le tour pour toujours.** Quelque chose comme `./serveur &` rend la main tout de suite, mais le processus détaché garde les tubes de sortie ouverts, et l'attente de fin de commande attendait leur fermeture, c'est à dire jamais. Ni le délai ni le stop n'en venaient à bout. L'attente est maintenant bornée après la fin ou la mise à mort du processus. -Quand aucune clé de pilotage n'est enregistrée, l'API est ouverte : c'est le confort du local. Mais la lecture de cette clé traitait « je n'ai pas pu lire la base » exactement comme « il n'y a pas de clé ». Une base momentanément verrouillée par une commande lancée à côté suffisait donc, en théorie, à ouvrir l'API le temps de la contention. +**Vider la conversation ne débloquait pas.** C'est pourtant le geste qu'on tente quand le chat est coincé. « Nouvelle conversation » libère désormais toujours l'état de génération, quel que soit le sort du tour abandonné. Plus besoin de redémarrer quoi que ce soit. -La lecture des secrets distingue maintenant les deux cas, et l'authentification **ferme** en cas de doute (503) au lieu d'ouvrir. +Au passage, un tour abandonné qui se termine après le démarrage du suivant ne vient plus déclarer ce dernier terminé. -## Le bouton d'envoi ne peut plus rester bloqué +## Un troisième défaut, trouvé en écrivant les tests -Avant chaque tour, AJEAN vérifie que le moteur répond. Cet appel n'avait aucun délai maximum. Un moteur qui accepte la connexion sans jamais répondre, ce qui est exactement ce que fait un très gros modèle pendant son chargement, laissait donc l'envoi du message suspendu indéfiniment, sans erreur et sans retour. Il abandonne maintenant au bout de trois secondes et vous dit que le modèle n'est pas prêt. +Le dossier de travail de l'agent est résolu une fois pour toutes au démarrage. S'il disparaissait ensuite, parce que vous avez fait le ménage ou parce que le modèle l'a supprimé lui-même, **toutes** les commandes suivantes échouaient sur un « chdir : no such file or directory » incompréhensible, et ce jusqu'au redémarrage. Il est maintenant recréé au besoin. -## L'accès distant ne peut plus se dédoubler +## Raisonnement désactivé veut enfin dire désactivé -Redémarrer le lien depuis l'interface arrêtait la boucle de connexion, mais la session en cours, elle, continuait de vivre : elle n'écoutait aucun signal d'arrêt et attendait la mort naturelle du WebSocket, qui n'arrive pas tant que le relais répond. Le nouveau tunnel s'ouvrait donc pendant que l'ancien tenait encore, et le relais voyait deux agents pour une seule machine. +Couper le raisonnement dans l'éditeur de preset **effaçait** la ligne `REASONING` au lieu d'écrire `off`. Ce n'est pas la même chose : sans consigne, le moteur suit le gabarit du modèle, et un modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé » pendant que le modèle réfléchissait tranquillement. -L'arrêt est maintenant immédiat et attendu : une session se ferme quand on le lui demande, et la suivante ne démarre qu'après. +L'interface écrit maintenant `on` ou `off`, explicitement, et `off` est transmis au moteur comme une interdiction. -Dans la foulée, le délai entre deux tentatives de reconnexion se remet à zéro après une session qui a tenu. Il grimpait sans jamais redescendre et finissait collé à trente secondes, y compris pour rattraper un lien qui venait de fonctionner des heures. +Avec une précaution : le drapeau qui désactive le raisonnement est récent, et certains moteurs, notamment le fork ik_llama.cpp, ne le connaissent pas. Le leur passer les ferait refuser de démarrer, donc boucler. AJEAN demande au binaire ce qu'il sait faire avant de le lui passer, et le dit dans le journal quand le moteur choisi ne permet pas de couper la réflexion. -## Le flux de chat ne relit plus tout le fil à chaque mot - -Pour envoyer les nouveaux événements à votre navigateur, le serveur reparcourait la totalité du journal de conversation, à chaque token généré, et pour chaque appareil connecté, en tenant le verrou que la génération elle-même attend. Sur une longue conversation, c'est un coût qui grandit avec l'historique et qui ralentit ce qu'il est censé diffuser. - -Le journal étant trié, la recherche du premier événement neuf se fait par dichotomie. Le coût ne dépend plus de la longueur de la conversation. - -## La configuration n'est plus relue depuis le disque cent fois par tour - -La base de données est délibérément fermée entre deux opérations : c'est ce qui permet aux commandes du terminal de fonctionner pendant que le service tourne. Mais la boucle d'inférence relisait le port, la clé et le seuil de compactage à chaque itération, et le compactage se re-testait après chaque appel d'outil. Un tour agentique un peu fourni rouvrait le fichier une centaine de fois. - -Un cache de lecture s'interpose, invalidé par toute écriture locale, par une écriture venue d'un autre processus (date et taille du fichier) et, en dernier filet, par l'âge. Le comportement ne change pas d'un iota, le travail inutile disparaît. - -## Le reste - -Les erreurs du moteur sont classées sur leur type plutôt que sur le texte du message : « connexion refusée » d'un Windows en français ne ressemblait à aucun des motifs anglais reconnus, et l'utilisateur recevait alors l'erreur brute au lieu de l'explication. - -Les trois compactions (début de tour, fin de tour, bouton manuel) déroulaient la même douzaine de lignes recopiées ; elles partagent maintenant un seul chemin, ce qui ferme la porte aux corrections qui n'atterrissaient que dans une des trois copies. La ligne de journal de la compaction de secours comparait le résultat à lui-même, elle compare enfin l'avant et l'après. Deux champs de l'API de chat que plus personne ne lisait depuis que la conversation vit côté serveur ont été retirés. Les serveurs HTTP posent un délai de lecture des en-têtes, pour qu'une connexion qui n'envoie jamais rien ne retienne pas de ressources. +Vos presets existants ne sont pas modifiés. Ceux dont la ligne est absente gardent le comportement d'avant, et l'éditeur ne prétend plus que le raisonnement y est coupé : il indique que rien n'est précisé et que le modèle décide. Basculez l'interrupteur une fois pour trancher. ## Mise à jour @@ -50,4 +34,4 @@ Les trois compactions (début de tour, fin de tour, bouton manuel) déroulaient ajean update ``` -Rien à migrer : presets, configuration et mémoire sont inchangés. +Puis, si vous utilisez l'accès distant, rechargez la page du portail pour prendre la nouvelle interface. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 3cc656f437e0985c16b2d2659969960e745377ce..35b2d043e52daaa83602e09dbe04af26cd2356dd 100644 GIT binary patch delta 35 ocmexi^}}idmn0`M0|x^LFmD!?lw@Kw-mEJf%*bdwxl!gj0Fw&{CjbBd delta 35 ocmexi^}}idmn0_>0|x^LFl`o=lw@Kw+N>)b%*bdoxl!gj0FulIBLDyZ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index 3a1cb4c354f98503a55ec6c643429e8541322598..9f64fb7623271ee54591b1536a070cd1b12c55f7 100644 GIT binary patch delta 35 ocmexi^}}idmn0`M0|x^LFmD!?lw@Kw-mEJf%*bdwxl!gj0Fw&{CjbBd delta 35 ocmexi^}}idmn0_>0|x^LFl`o=lw@Kw+N>)b%*bdoxl!gj0FulIBLDyZ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index bf429e1..4520da5 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 8, - "Patch": 2, + "Patch": 3, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 8, - "Patch": 2, + "Patch": 3, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.8.2", + "ProductVersion": "0.8.3", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index 43079aa..6f1df9e 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -1,11 +1,39 @@ package ajean import ( + "bytes" + "context" "fmt" "os" + "os/exec" "path/filepath" + "strings" + "time" ) +// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ». +// +// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le +// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne +// l'ajoute que pour interdire le raisonnement, mieux vaut demander au binaire +// que parier : on lit son aide, une fois, au lancement du moteur. +// +// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement +// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide +// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à +// tort qu'il ne gère pas le drapeau. +func binSupportsReasoningFlag(bin string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "--help") + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Run(); err != nil && out.Len() == 0 { + return false // aide illisible : on ne prend pas le risque + } + return strings.Contains(out.String(), "--reasoning ") +} + // cmdServe replaces the historic start.sh: read config.env, build the // llama-server invocation, and exec it (replacing this process so systemd // supervises llama-server directly). @@ -81,14 +109,35 @@ func cmdServe(args []string) error { if vtv != "" { llmArgs = append(llmArgs, "-ctv", vtv) } - if r := cfg["REASONING"]; r != "" { - // budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au - // bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la - // réflexion atteignait le plafond et il ne restait plus de marge pour le - // contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 : - // sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement - // (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET. - llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1")) + // Raisonnement. Trois cas, et la nuance compte : + // + // REASONING=on|auto|deepseek → --reasoning + // REASONING=off → --reasoning off (interdiction EXPLICITE) + // clé absente → aucun drapeau, le moteur fait son défaut + // + // L'interface écrivait « off » en EFFAÇANT la ligne, ce qui n'est pas du tout + // la même chose : sans drapeau, llama-server suit le gabarit du modèle, et un + // modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé » + // pendant que le modèle réfléchissait quand même. Il faut le dire au moteur. + if r := strings.TrimSpace(cfg["REASONING"]); r != "" { + if reasoningActive(r) { + // budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au + // bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la + // réflexion atteignait le plafond et il ne restait plus de marge pour le + // contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 : + // sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement + // (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET. + llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1")) + } else if binSupportsReasoningFlag(bin) { + // Pas de budget ici : « off » suffit, et un budget sur un moteur qui + // n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer. + llmArgs = append(llmArgs, "--reasoning", "off") + } else { + // Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer + // le ferait sortir en erreur au démarrage, donc boucler. On le dit et on + // continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort. + fmt.Fprintf(os.Stderr, "[ajean serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n") + } } // API_KEY protège le serveur quand il est exposé sur internet : llama-server // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index eb63d8d..a42250e 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -292,9 +292,16 @@ func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) er func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) { defer func() { c.mu.Lock() - c.Generating = false - c.cancel = nil stale := c.epoch != epoch + // ⚠️ Un tour périmé ne touche PAS à l'état courant. Depuis que Reset + // débloque lui-même la conversation, un tour abandonné peut se terminer + // APRÈS le démarrage du suivant : remettre Generating à false ici + // déclarerait « libre » une génération toute neuve, et l'UI afficherait + // un fil qui se remplit avec un bouton « envoyer » actif. + if !stale { + c.Generating = false + c.cancel = nil + } c.mu.Unlock() if stale { return // Reset pendant le tour : Reset a déjà persisté l'état vide @@ -452,8 +459,12 @@ func (c *Conversation) CompactNow() error { go func() { defer func() { c.mu.Lock() - c.Generating = false - c.cancel = nil + // Même précaution que dans generate : une compaction abandonnée par un + // Reset ne doit pas déclarer « libre » le tour qui a démarré depuis. + if c.epoch == epoch { + c.Generating = false + c.cancel = nil + } c.mu.Unlock() }() if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}); changed { @@ -476,6 +487,15 @@ func (c *Conversation) Stop() { // Reset démarre une nouvelle conversation (vide) pour TOUS les appareils. On // interrompt une éventuelle génération, on vide tout et on bump epoch pour que // les abonnés reçoivent l'ordre de nettoyer leur affichage. +// +// Reset DÉBLOQUE toujours, et c'est sa deuxième raison d'être. Il se contentait +// avant de vider le fil : si un tour restait coincé (moteur redémarré sous ses +// pieds, commande shell accrochée à ses tubes), Generating restait vrai pour +// toujours et tout message suivant se voyait refusé « génération en cours ». +// Vider le fil ne changeait rien, rafraîchir non plus : il fallait redémarrer le +// service. « Nouvelle conversation » est le geste qu'on tente naturellement dans +// ce cas ; il doit donc rendre la main, quoi qu'il arrive au tour abandonné, que +// le bump d'epoch réduit de toute façon au silence. func (c *Conversation) Reset() { c.Stop() c.mu.Lock() @@ -484,6 +504,8 @@ func (c *Conversation) Reset() { c.Seq = 0 c.CtxUsed = 0 c.epoch++ + c.Generating = false + c.cancel = nil c.cond.Broadcast() c.mu.Unlock() c.persist() diff --git a/internal/ajean/chat_stuck_test.go b/internal/ajean/chat_stuck_test.go new file mode 100644 index 0000000..60fa5a0 --- /dev/null +++ b/internal/ajean/chat_stuck_test.go @@ -0,0 +1,112 @@ +package ajean + +import ( + "context" + "os" + "runtime" + "strings" + "testing" + "time" +) + +// Le bouton stop doit VRAIMENT arrêter une commande shell. Elle naissait d'un +// contexte indépendant du tour : arrêter la génération ne l'arrêtait pas, et le +// tour restait suspendu jusqu'à l'expiration du délai (5 min au maximum). +func TestRunShellObeitAuStop(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("commande de veille propre à Unix") + } + testHome(t) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(200 * time.Millisecond) + cancel() + }() + start := time.Now() + out := runShell(ctx, "sleep 30", 300) + if d := time.Since(start); d > 5*time.Second { + t.Fatalf("la commande a survécu à l'annulation (%s) : %q", d, out) + } + if !strings.Contains(out, "interrompue") { + t.Errorf("résultat attendu « interrompue », reçu %q", out) + } +} + +// Une commande qui laisse un process en arrière-plan garde les tubes de sortie +// ouverts. Sans WaitDelay, Wait attend leur fermeture — donc pour toujours — et +// le tour ne se terminait JAMAIS (seul un redémarrage du service débloquait). +func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("syntaxe de détachement propre à Unix") + } + testHome(t) + start := time.Now() + out := runShell(context.Background(), "sleep 30 & echo lance", 2) + if d := time.Since(start); d > 10*time.Second { + t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d) + } + if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") { + t.Errorf("sortie inattendue : %q", out) + } +} + +// Le dossier de travail est résolu une fois par process : s'il disparaît, plus +// AUCUNE commande ne passait (« chdir : no such file or directory ») jusqu'au +// redémarrage du service. Il doit être recréé au besoin. +func TestRunShellRecreeLeWorkspaceDisparu(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("echo se comporte différemment sous cmd.exe") + } + testHome(t) + ws := agentWorkspace() + if ws == "" { + t.Skip("pas de workspace résolu") + } + if err := os.RemoveAll(ws); err != nil { + t.Fatal(err) + } + out := runShell(context.Background(), "echo vivant", 10) + if !strings.Contains(out, "vivant") { + t.Fatalf("commande cassée par la disparition du workspace : %q", out) + } +} + +// Reset doit TOUJOURS rendre la main, même sur un tour resté coincé : c'est le +// geste que l'on tente quand le chat est bloqué. Avant, Generating restait vrai +// et tout envoi suivant était refusé « génération en cours » jusqu'au +// redémarrage du service. +func TestResetDebloqueUnTourCoince(t *testing.T) { + c := newTestConv() + // Simule un tour parti et jamais terminé (commande accrochée, moteur disparu). + c.mu.Lock() + c.Generating = true + c.mu.Unlock() + + c.Reset() + + if st := c.state(); st["generating"] != false { + t.Fatal("après Reset, la conversation doit être déclarée libre") + } +} + +// Le tour abandonné se termine parfois APRÈS le démarrage du suivant. Sa fin ne +// doit pas déclarer « libre » une génération toute neuve. +func TestTourPerimeNeLiberePasLeTourCourant(t *testing.T) { + c := newTestConv() + oldEpoch := c.epoch + c.Reset() // le tour d'epoch `oldEpoch` devient périmé + c.mu.Lock() + c.Generating = true // un nouveau tour démarre + c.mu.Unlock() + + // Fin (tardive) du tour périmé : même code que le defer de generate. + c.mu.Lock() + if c.epoch == oldEpoch { + c.Generating = false + } + c.mu.Unlock() + + if st := c.state(); st["generating"] != true { + t.Fatal("un tour périmé a libéré le tour courant") + } +} diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go index 8a43dea..ae89c85 100644 --- a/internal/ajean/chat_tools.go +++ b/internal/ajean/chat_tools.go @@ -2,6 +2,7 @@ package ajean import ( "context" + "errors" "fmt" "os" "os/exec" @@ -135,25 +136,52 @@ func machineSystemPrompt(caps Caps) string { // on Windows — see newShellCmd in sys_platform_*.go) with a clamped timeout, // returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..." // truncated to keep tool output bounded. -func runShell(command string, timeoutSec int) string { +// +// ⚠️ parent est le contexte DU TOUR : c'est lui qui rend le bouton stop utile. +// La commande naissait auparavant d'un context.Background(), donc arrêter la +// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du +// délai (5 minutes au maximum), bouton stop sans effet. +func runShell(parent context.Context, command string, timeoutSec int) string { if timeoutSec <= 0 { timeoutSec = toolDefaultTimeout } if timeoutSec > toolMaxTimeout { timeoutSec = toolMaxTimeout } - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second) defer cancel() cmd := newShellCmd(ctx, command) // Le shell démarre dans le workspace, pas dans le dossier d'où ajean a été // lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau. - cmd.Dir = agentWorkspace() + // + // Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il + // disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même le + // supprime — toutes les commandes suivantes échouaient sur un « chdir : no + // such file or directory » incompréhensible, et ce jusqu'au redémarrage. On + // le recrée au besoin, et à défaut on démarre là où on peut plutôt que de + // tout refuser. + if ws := agentWorkspace(); ws != "" { + if err := os.MkdirAll(ws, 0o755); err == nil { + cmd.Dir = ws + } + } var stdout, stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr + // ⚠️ WaitDelay borne l'attente APRÈS la fin (ou la mise à mort) du process. + // Sans elle, Wait attend que les tubes de sortie soient fermés — donc que + // TOUS ceux qui les tiennent aient disparu, petits-enfants compris. Une + // commande du genre « ./serveur & » rend la main tout de suite mais laisse + // un process en arrière-plan accroché aux tubes : runShell ne revenait alors + // JAMAIS, ni au délai, ni au stop. Le tour restait bloqué à vie, et la seule + // issue connue était de redémarrer ajean-ui. + cmd.WaitDelay = 2 * time.Second err := cmd.Run() - if ctx.Err() == context.DeadlineExceeded { + switch { + case errors.Is(ctx.Err(), context.DeadlineExceeded): return fmt.Sprintf("[timeout après %ds]", timeoutSec) + case errors.Is(parent.Err(), context.Canceled): + return "[commande interrompue]" } exit := 0 if err != nil { diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index 8cadd67..15de10e 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -874,6 +874,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps extra = append(extra, assistant) // 2. Execute each tool locally and append a "tool" reply. for _, tc := range tcs { + // Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce + // garde, un stop pendant une série d'appels laissait défiler toute + // la série avant de reprendre la main. + if ctx.Err() != nil { + return extra, nil + } var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) // Derive the human label (command / skill name) up front so we can @@ -989,7 +995,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps case int: to = v } - result = runShell(label, to) + result = runShell(ctx, label, to) case "web_search": result = capWebOutput(toolWebSearch(args)) case "web_open": diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 6e124da..0fe0719 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.8.2" +const Version = "0.8.3" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index cc7bdda..488c304 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1658,8 +1658,8 @@ button:hover{border-color:var(--dim);color:var(--text)}
    - Raisonnementréflexion étape par étape - + Raisonnementréflexion étape par étape +
    Flash attentionplus rapide, moins de VRAM @@ -3158,7 +3158,19 @@ function populateSettings(){ set('s-spec-n', eaGetValued('--spec-draft-n-max')); setSpecType(eaGetValued('--spec-type')); const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; }; - chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING'))); + // Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur. + // « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle, + // laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement + // réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le + // sous-titre la dit. (Les presets créés ou modifiés depuis cette version + // écrivent toujours on ou off, la question ne se pose plus pour eux.) + const rz = cfgReadKey('REASONING'); + chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz)); + const rzSub = document.getElementById('s-reasoning-sub'); + if(rzSub){ + rzSub.textContent = rz ? 'réflexion étape par étape' + : 'réflexion étape par étape — non précisé : le modèle décide'; + } chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn'))); chk('s-mlock', eaHasFlag('--mlock')); chk('s-nommap', eaHasFlag('--no-mmap')); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 7a6f4a5..d46c5c4 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -611,8 +611,8 @@
    - Raisonnementréflexion étape par étape - + Raisonnementréflexion étape par étape +
    Flash attentionplus rapide, moins de VRAM diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index ac864ce..7459e7a 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -666,7 +666,19 @@ function populateSettings(){ set('s-spec-n', eaGetValued('--spec-draft-n-max')); setSpecType(eaGetValued('--spec-type')); const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; }; - chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING'))); + // Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur. + // « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle, + // laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement + // réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le + // sous-titre la dit. (Les presets créés ou modifiés depuis cette version + // écrivent toujours on ou off, la question ne se pose plus pour eux.) + const rz = cfgReadKey('REASONING'); + chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz)); + const rzSub = document.getElementById('s-reasoning-sub'); + if(rzSub){ + rzSub.textContent = rz ? 'réflexion étape par étape' + : 'réflexion étape par étape — non précisé : le modèle décide'; + } chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn'))); chk('s-mlock', eaHasFlag('--mlock')); chk('s-nommap', eaHasFlag('--no-mmap')); From 52baab57860e552587797595adbe69b5b83dfa85 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 20:01:54 +0200 Subject: [PATCH 260/279] v0.8.4 : l'historique s'exporte, le moteur se laisse joindre, les modeles en plusieurs fichiers marchent Trois retours d'utilisateurs apres la 0.8.3. Export de la conversation (regression de la 0.8 : conversation.json a disparu dans ajean.db, verrouille et binaire). Bouton dans l'UI + commande 'ajean export [--json] [fichier]', endpoint /api/chat/export. Ecoute reseau du moteur : interrupteur 'joignable depuis le reseau local' dans le panneau Acces OpenAI (HOST + regle de pare-feu Windows), commande 'ajean network [on|off|status]'. HOST devient un reglage de machine preserve au changement de preset. Modeles GGUF decoupes en tranches -00001-of-000NN : une famille = un modele. Telechargement de toutes les tranches depuis n'importe quel lien, sonde d'espace disque sur le total, liste et suppression regroupees, demarrage refuse avec le nom du fichier manquant. --- RELEASE_NOTES.md | 49 +++-- cmd/ajean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/ajean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/ajean/versioninfo.json | 6 +- internal/ajean/backend_model_dirs.go | 5 +- internal/ajean/backend_models.go | 215 ++++++++++++------ internal/ajean/backend_models_dl_test.go | 6 +- internal/ajean/backend_models_shards.go | 127 +++++++++++ internal/ajean/backend_models_shards_test.go | 120 ++++++++++ internal/ajean/backend_presets.go | 10 +- internal/ajean/backend_serve.go | 7 + internal/ajean/chat_export.go | 218 +++++++++++++++++++ internal/ajean/chat_export_test.go | 128 +++++++++++ internal/ajean/run.go | 10 +- internal/ajean/store_test.go | 6 +- internal/ajean/sys_firewall_other.go | 20 ++ internal/ajean/sys_firewall_windows.go | 85 ++++++++ internal/ajean/sys_network.go | 166 ++++++++++++++ internal/ajean/sys_network_test.go | 106 +++++++++ internal/ajean/sys_service.go | 6 + internal/ajean/ui/index.html | 104 ++++++++- internal/ajean/ui/src/index.tmpl.html | 14 ++ internal/ajean/ui/src/js/06-settings.js | 71 +++++- internal/ajean/ui/src/js/07-models.js | 19 +- internal/ajean/web_api.go | 21 +- internal/ajean/web_server.go | 2 + 26 files changed, 1414 insertions(+), 107 deletions(-) create mode 100644 internal/ajean/backend_models_shards.go create mode 100644 internal/ajean/backend_models_shards_test.go create mode 100644 internal/ajean/chat_export.go create mode 100644 internal/ajean/chat_export_test.go create mode 100644 internal/ajean/sys_firewall_other.go create mode 100644 internal/ajean/sys_firewall_windows.go create mode 100644 internal/ajean/sys_network.go create mode 100644 internal/ajean/sys_network_test.go diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 98f3fca..a74dd68 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,32 +1,49 @@ -Deux bugs signalés par l'usage, tous les deux confirmés, tous les deux réparés. Le premier pouvait bloquer le chat jusqu'au redémarrage du service. Le second faisait mentir un interrupteur. +Trois retours d'utilisateurs après la 0.8.3, trois vrais trous dans le logiciel. Ils sont bouchés. -## Le bouton stop arrête vraiment, et le chat ne se bloque plus +## Récupérer sa conversation -Le scénario, tel qu'il était vécu : le modèle lance une commande, elle dure, on clique sur stop, rien ne se passe. On redémarre alors le moteur, et là le chat reste figé avec son bouton stop, sans plus rien accepter. Vider la conversation ne suffit pas, rafraîchir la page non plus ; il faut redémarrer le service d'interface. +Jusqu'à la 0.7, l'historique vivait dans un fichier `conversation.json` qu'on pouvait ouvrir, relire et copier pour archive. La 0.8 l'a rangé dans `ajean.db`, une base binaire, verrouillée tant que le service tourne : impossible ne serait ce que de la copier. Le fil et les raisonnements étaient toujours là, mais plus personne ne pouvait les sortir. C'était une régression, et elle n'aurait pas dû passer. -Trois défauts se cumulaient, et ils sont corrigés ensemble. +Deux façons de récupérer le fil, désormais. -**La commande ignorait l'arrêt.** Elle démarrait avec un contexte à elle, indépendant du tour. Annuler la génération n'annulait donc rien du tout : le tour restait suspendu jusqu'au bout du délai, cinq minutes au maximum. La commande hérite désormais du contexte du tour, et stop la tue pour de bon. Dans la foulée, un arrêt demandé au milieu d'une série d'appels d'outils interrompt la série au lieu de la dérouler jusqu'au bout. +Depuis l'interface, dans le panneau « Actions » : un bouton exporte la conversation en Markdown (les raisonnements sont repliés sous un « Raisonnement », les appels d'outils apparaissent avec leur résultat), un autre en JSON fidèle si vous voulez retraiter les données. -**Une commande qui laisse un processus en arrière-plan bloquait le tour pour toujours.** Quelque chose comme `./serveur &` rend la main tout de suite, mais le processus détaché garde les tubes de sortie ouverts, et l'attente de fin de commande attendait leur fermeture, c'est à dire jamais. Ni le délai ni le stop n'en venaient à bout. L'attente est maintenant bornée après la fin ou la mise à mort du processus. +Depuis le terminal : -**Vider la conversation ne débloquait pas.** C'est pourtant le geste qu'on tente quand le chat est coincé. « Nouvelle conversation » libère désormais toujours l'état de génération, quel que soit le sort du tour abandonné. Plus besoin de redémarrer quoi que ce soit. +```bash +ajean export # ajean-conversation-.md +ajean export --json # même chose en JSON +ajean export mon-fil.md # nom imposé, l'extension choisit le format +ajean export - # sur la sortie standard, pour enchaîner un tube +``` -Au passage, un tour abandonné qui se termine après le démarrage du suivant ne vient plus déclarer ce dernier terminé. +La commande fonctionne pendant que le service tourne. -## Un troisième défaut, trouvé en écrivant les tests +## Le moteur joignable depuis le réseau -Le dossier de travail de l'agent est résolu une fois pour toutes au démarrage. S'il disparaissait ensuite, parce que vous avez fait le ménage ou parce que le modèle l'a supprimé lui-même, **toutes** les commandes suivantes échouaient sur un « chdir : no such file or directory » incompréhensible, et ce jusqu'au redémarrage. Il est maintenant recréé au besoin. +Symptôme rapporté : « à part le chat dans le navigateur, impossible d'utiliser ton URL dans les logiciels en local ». L'adresse `http://:8080/v1` s'affichait bien dans l'interface, mais rien ne répondait depuis un autre ordinateur. -## Raisonnement désactivé veut enfin dire désactivé +Deux causes, cumulées, et aucune des deux n'était visible. -Couper le raisonnement dans l'éditeur de preset **effaçait** la ligne `REASONING` au lieu d'écrire `off`. Ce n'est pas la même chose : sans consigne, le moteur suit le gabarit du modèle, et un modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé » pendant que le modèle réfléchissait tranquillement. +Sous Windows, l'installation posait `HOST=127.0.0.1` : le moteur n'écoutait que sur la machine elle même. Le chat du navigateur marchait, puisqu'il passe par le serveur web d'AJEAN, sur place. Tout le reste était invisible. Et ce réglage n'était modifiable nulle part dans l'interface, il fallait connaître `ajean edit` et savoir quoi y écrire. -L'interface écrit maintenant `on` ou `off`, explicitement, et `off` est transmis au moteur comme une interdiction. +Deuxième cause : même ouvert sur toutes les interfaces, le pare feu Windows bloque les connexions entrantes tant qu'aucune règle n'autorise le port. AJEAN n'en posait aucune. -Avec une précaution : le drapeau qui désactive le raisonnement est récent, et certains moteurs, notamment le fork ik_llama.cpp, ne le connaissent pas. Le leur passer les ferait refuser de démarrer, donc boucler. AJEAN demande au binaire ce qu'il sait faire avant de le lui passer, et le dit dans le journal quand le moteur choisi ne permet pas de couper la réflexion. +Il y a maintenant un interrupteur « joignable depuis le réseau local », dans le panneau « Accès OpenAI », juste sous l'adresse qu'il conditionne. Il règle l'adresse d'écoute et pose la règle de pare feu dans le même geste. Poser une règle exige les droits administrateur, que l'installation d'AJEAN ne réclame pas : quand ça échoue, l'interface le dit et donne la commande exacte à coller dans un terminal administrateur, au lieu de laisser croire que c'est fait. -Vos presets existants ne sont pas modifiés. Ceux dont la ligne est absente gardent le comportement d'avant, et l'éditeur ne prétend plus que le raisonnement y est coupé : il indique que rien n'est précisé et que le modèle décide. Basculez l'interrupteur une fois pour trancher. +En ligne de commande : `ajean network`, `ajean network on`, `ajean network off`. Le moteur doit redémarrer pour appliquer, l'interface le propose. + +L'adresse d'écoute est aussi devenue un réglage de machine et non de modèle : basculer sur un preset écrit avant cette version ne la remet plus à zéro. Une machine volontairement fermée reste fermée. + +## Les modèles en plusieurs fichiers + +Au delà d'une certaine taille, un dépôt Hugging Face publie son GGUF en tranches : `...-00001-of-00003.gguf`, et ainsi de suite. llama.cpp n'a besoin que de la première, il ouvre les suivantes tout seul. AJEAN, lui, traitait chaque tranche comme un modèle indépendant, avec trois conséquences. + +Le lien collé ne rapatriait qu'un fichier sur trois, et le moteur mourait ensuite sur un tenseur introuvable. Le sélecteur affichait trois entrées pour un seul modèle, dont deux qui ne démarrent pas. Et supprimer « le modèle » n'effaçait que sa première tranche, laissant des dizaines de Go que plus rien ne référençait et que l'interface ne savait plus montrer. + +Une famille de tranches est maintenant un seul modèle. Coller le lien de n'importe laquelle télécharge la famille entière, avec une barre de progression unique et un compteur de fichiers ; la vérification d'espace disque porte sur le total, plus sur une tranche. La liste n'affiche que la première, avec la taille de l'ensemble et une mention « 3 fichiers ». S'il en manque une, elle est signalée dans la liste, et le démarrage du moteur s'arrête sur un message qui nomme le fichier absent au lieu de laisser le service boucler. La suppression emporte toutes les tranches. + +Un téléchargement interrompu à la deuxième tranche peut être relancé : les fichiers déjà complets sont conservés. ## Mise à jour @@ -35,3 +52,5 @@ ajean update ``` Puis, si vous utilisez l'accès distant, rechargez la page du portail pour prendre la nouvelle interface. + +Ce qui n'a pas été vérifié sur machine réelle : le téléchargement d'un vrai modèle découpé de bout en bout (la logique est couverte par des tests, mais pas un transfert complet depuis Hugging Face), et le comportement sur macOS, toujours pas testé sur un Mac. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 35b2d043e52daaa83602e09dbe04af26cd2356dd..7bbb949b5dcf32068ecf904a7374889aed3c0f79 100644 GIT binary patch delta 35 ocmexi^}}idmn0_(0|x^Luxu8Vlw@Kw*{mxa%*bdmxl!gj0Fz1xD*ylh delta 35 ocmexi^}}idmn0`M0|x^LFmD!?lw@Kw-mEJf%*bdwxl!gj0Fw&{CjbBd diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index 9f64fb7623271ee54591b1536a070cd1b12c55f7..0809f811469e38218208c1e138996974cb33e3dc 100644 GIT binary patch delta 35 ocmexi^}}idmn0_(0|x^Luxu8Vlw@Kw*{mxa%*bdmxl!gj0Fz1xD*ylh delta 35 ocmexi^}}idmn0`M0|x^LFmD!?lw@Kw-mEJf%*bdwxl!gj0Fw&{CjbBd diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 4520da5..12486b9 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 8, - "Patch": 3, + "Patch": 4, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 8, - "Patch": 3, + "Patch": 4, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.8.3", + "ProductVersion": "0.8.4", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_model_dirs.go b/internal/ajean/backend_model_dirs.go index 8f0534b..b77611d 100644 --- a/internal/ajean/backend_model_dirs.go +++ b/internal/ajean/backend_model_dirs.go @@ -167,7 +167,10 @@ func modelDirCount(dir string) int { } n := 0 for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { + // Les tranches 2..N d'un modèle découpé ne comptent pas pour un modèle : + // sinon « 3 modèles » s'affichait pour un seul, celui qui tient en trois + // fichiers. Même règle que la liste du sélecteur (handleModels). + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") && !isFollowerShard(e.Name()) { n++ } } diff --git a/internal/ajean/backend_models.go b/internal/ajean/backend_models.go index 3b1d56b..39fae47 100644 --- a/internal/ajean/backend_models.go +++ b/internal/ajean/backend_models.go @@ -124,7 +124,10 @@ func modelFromPresetContent(content string) string { } // deleteModelFile removes a .gguf file from one of the declared model folders -// after validating the name. +// after validating the name. Un modèle découpé emporte TOUTES ses tranches : +// n'effacer que la première laissait des dizaines de Go de fichiers que plus +// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les +// tranches suivantes n'y apparaissent pas). func deleteModelFile(name string) error { p, err := resolveModelPath(name) if err != nil { @@ -136,6 +139,10 @@ func deleteModelFile(name string) error { } return err } + dir := filepath.Dir(p) + for _, n := range shardFamily(filepath.Base(p)) { + _ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire + } return nil } @@ -166,6 +173,8 @@ type dlState struct { Done int64 `json:"done"` Speed int64 `json:"speed"` // bytes/s, smoothed over the last samples Conns int `json:"conns"` // parallel connections actually used + Parts int `json:"parts"` // nombre de tranches (1 pour un modèle en un seul fichier) + Part int `json:"part"` // tranche en cours (1-based) Finished bool `json:"finished"` Canceled bool `json:"canceled"` Err string `json:"error"` @@ -263,34 +272,56 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } - dest, err := downloadDestPath(name, req.Dir) - if err != nil { - sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) - return + // Modèle découpé : le lien ne désigne qu'une tranche, on rapatrie la famille. + // Le modèle porte le nom de sa PREMIÈRE tranche — c'est elle qu'on passe à + // llama-server, et c'est donc elle qui identifie le téléchargement. + urls, names := shardURLSet(dlURL, name) + name = names[0] + dests := make([]string, len(names)) + for i, n := range names { + if dests[i], err = downloadDestPath(n, req.Dir); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } } - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(dests[0]), 0o755); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": "dossier de destination inaccessible : " + err.Error()}) return } + // Tranches déjà là : on ne les retélécharge pas. C'est aussi ce qui permet de + // relancer un téléchargement en plusieurs fichiers interrompu à la deuxième + // tranche sans repayer les 15 Go de la première. + var todoURLs, todoDests []string + for i := range names { + if st, err := os.Stat(dests[i]); err == nil && !st.IsDir() { + continue + } + todoURLs = append(todoURLs, urls[i]) + todoDests = append(todoDests, dests[i]) + } + 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 { + if len(todoURLs) == 0 { dlMu.Unlock() sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name}) return } ctx, cancel := context.WithCancel(context.Background()) - st := &dlState{Filename: name, URL: dlURL, Dir: filepath.Dir(dest), StartedAt: time.Now().Unix(), cancel: cancel} + st := &dlState{ + Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]), + Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, + } dlDownloads[name] = st dlMu.Unlock() - go runDownload(ctx, st, dlURL, dest) - sendJSON(w, 200, map[string]any{"ok": true, "filename": name}) + go runDownloadSet(ctx, st, todoURLs, todoDests) + sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "parts": len(todoURLs)}) } // dlSpaceMargin est la marge laissée libre après le téléchargement : un disque @@ -346,14 +377,23 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } + // Un modèle découpé se sonde EN ENTIER : annoncer les 15 Go de la première + // tranche alors qu'il en faut 45 sur le disque, c'est promettre que ça tient + // puis échouer au deux tiers du transfert. + urls, names := shardURLSet(dlURL, name) + name = names[0] ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - total, _, err := dlProbe(ctx, dlURL) - if err != nil { - sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) - return + var total int64 + for _, u := range urls { + n, _, err := dlProbe(ctx, u) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + total += n } - out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true} + out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true, "parts": len(urls)} if err := checkDiskSpace(dir, total); err != nil { out["enough"] = false out["error"] = err.Error() @@ -420,15 +460,18 @@ func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err e } } -// runDownload fetches the URL into a .part file then renames it on success. -// When the source honours byte ranges (Hugging Face's CDN does), the file is -// split across several connections written in place with WriteAt, which is what -// makes a multi-GB .gguf saturate the link instead of a single TCP stream. -// Cancelling the context aborts every worker and the partial .part is removed, -// so a cancelled download leaves nothing behind. -func runDownload(ctx context.Context, st *dlState, dlURL, dest string) { - tmp := dest + ".part" - var done int64 // atomic: total bytes written across all workers +// runDownloadSet fetches one model, qui peut tenir en plusieurs fichiers (un +// GGUF découpé en tranches). Les tranches se suivent SÉQUENTIELLEMENT — chacune +// sature déjà le lien à elle seule grâce aux connexions parallèles, les mener de +// front ne ferait que multiplier les fichiers à jeter en cas d'annulation. +// La progression publiée (Done / Total) couvre l'ENSEMBLE : une barre unique du +// début à la fin, pas trois barres qui repartent de zéro. +// +// Annulation ou échec : toutes les tranches déjà écrites par CE téléchargement +// sont supprimées. Un modèle amputé d'une tranche ne démarre pas ; le laisser +// sur le disque n'offrirait qu'un modèle mort et des dizaines de Go occupés. +func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) { + var done int64 // atomique : octets écrits, toutes tranches confondues finish := func(e error) { dlMu.Lock() @@ -447,54 +490,33 @@ func runDownload(ctx context.Context, st *dlState, dlURL, dest string) { dlMu.Unlock() } - total, ranged, err := dlProbe(ctx, dlURL) - if err != nil { - finish(err) - return + // Sonde de TOUTES les tranches avant d'écrire quoi que ce soit : la place + // disque se vérifie sur le total, pas tranche par tranche. + totals := make([]int64, len(urls)) + rangeds := make([]bool, len(urls)) + var grand int64 + for i, u := range urls { + n, ranged, err := dlProbe(ctx, u) + if err != nil { + finish(err) + return + } + totals[i], rangeds[i] = n, ranged + grand += n } // Vérification serveur : l'UI a déjà prévenu, mais rien ne garantit qu'elle // l'ait fait (autre client, disque rempli entre-temps). - if err := checkDiskSpace(filepath.Dir(dest), total); err != nil { + if err := checkDiskSpace(filepath.Dir(dests[0]), grand); err != nil { finish(err) return } - conns := 1 - if ranged && total > 0 { - conns = dlConns() - if max := int((total + dlMinChunk - 1) / dlMinChunk); conns > max { - conns = max - } - if conns < 1 { - conns = 1 - } - } - dlMu.Lock() - st.Total = total - st.Conns = conns + st.Total = grand + st.Parts = len(urls) dlMu.Unlock() - f, err := os.Create(tmp) - if err != nil { - finish(err) - return - } - fail := func(e error) { - f.Close() - _ = os.Remove(tmp) - finish(e) - } - if conns > 1 { - // Preallocate so the filesystem can lay the file out contiguously and - // concurrent WriteAt calls never race to extend it. - if err := f.Truncate(total); err != nil { - fail(err) - return - } - } - - // Publish progress + a smoothed speed once a second. + // Publication de la progression + vitesse lissée, une fois par seconde. stop := make(chan struct{}) go func() { t := time.NewTicker(time.Second) @@ -514,7 +536,7 @@ func runDownload(ctx context.Context, st *dlState, dlURL, dest string) { if st.Speed == 0 { st.Speed = inst } else { - st.Speed = (st.Speed*2 + inst) / 3 // EMA, smooths CDN jitter + st.Speed = (st.Speed*2 + inst) / 3 // EMA, lisse les à-coups du CDN } } dlMu.Unlock() @@ -523,23 +545,74 @@ func runDownload(ctx context.Context, st *dlState, dlURL, dest string) { } }() - err = dlFetch(ctx, f, dlURL, total, conns, &done) + var written []string // tranches menées à bien par CE téléchargement + for i := range urls { + dlMu.Lock() + st.Part = i + 1 + dlMu.Unlock() + if err := dlOnePart(ctx, st, urls[i], dests[i], totals[i], rangeds[i], &done); err != nil { + close(stop) + for _, p := range written { + _ = os.Remove(p) + } + finish(err) // annulation comprise : le .part est supprimé dans tous les cas + return + } + written = append(written, dests[i]) + } close(stop) + finish(nil) +} + +// dlOnePart télécharge UN fichier dans un .part puis le renomme au succès. +// Quand la source honore les plages d'octets (le CDN de Hugging Face le fait), +// le fichier est réparti sur plusieurs connexions écrites en place via WriteAt : +// c'est ce qui fait qu'un .gguf de plusieurs Go sature le lien au lieu de se +// traîner sur un seul flux TCP. done est incrémenté globalement (il compte pour +// tout le modèle, pas seulement pour cette tranche). +func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64, ranged bool, done *int64) error { + tmp := dest + ".part" + conns := 1 + if ranged && total > 0 { + conns = dlConns() + if max := int((total + dlMinChunk - 1) / dlMinChunk); conns > max { + conns = max + } + if conns < 1 { + conns = 1 + } + } + dlMu.Lock() + st.Conns = conns + dlMu.Unlock() + + f, err := os.Create(tmp) if err != nil { - fail(err) // includes cancellation: the .part is deleted either way - return + return err + } + if conns > 1 { + // Préallocation : le système de fichiers pose le fichier d'un bloc et les + // WriteAt concurrents n'ont jamais à l'étendre en même temps. + if err := f.Truncate(total); err != nil { + f.Close() + _ = os.Remove(tmp) + return err + } + } + if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil { + f.Close() + _ = os.Remove(tmp) + return err } if err := f.Close(); err != nil { _ = os.Remove(tmp) - finish(err) - return + return err } if err := os.Rename(tmp, dest); err != nil { _ = os.Remove(tmp) - finish(err) - return + return err } - finish(nil) + return nil } // dlFetch writes the whole body into f, either as one stream or as `conns` diff --git a/internal/ajean/backend_models_dl_test.go b/internal/ajean/backend_models_dl_test.go index ad4a0d7..b92e808 100644 --- a/internal/ajean/backend_models_dl_test.go +++ b/internal/ajean/backend_models_dl_test.go @@ -52,14 +52,14 @@ func TestRunDownloadCancelLeavesNothing(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) st := &dlState{Filename: "m.gguf", cancel: cancel} done := make(chan struct{}) - go func() { runDownload(ctx, st, srv.URL+"/m.gguf", dest); close(done) }() + go func() { runDownloadSet(ctx, st, []string{srv.URL + "/m.gguf"}, []string{dest}); close(done) }() time.Sleep(300 * time.Millisecond) cancel() select { case <-done: case <-time.After(10 * time.Second): - t.Fatal("runDownload n'a pas rendu la main après annulation") + t.Fatal("runDownloadSet n a pas rendu la main apres annulation") } if !st.Canceled || !st.Finished { @@ -106,7 +106,7 @@ func TestRunDownloadParallelAndFallback(t *testing.T) { dir := t.TempDir() dest := filepath.Join(dir, "m.gguf") st := &dlState{Filename: "m.gguf"} - runDownload(context.Background(), st, srv.URL+"/m.gguf", dest) + runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest}) srv.Close() if st.Err != "" { diff --git a/internal/ajean/backend_models_shards.go b/internal/ajean/backend_models_shards.go new file mode 100644 index 0000000..b9fce69 --- /dev/null +++ b/internal/ajean/backend_models_shards.go @@ -0,0 +1,127 @@ +package ajean + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// Modèles découpés en plusieurs fichiers. Au-delà d'une cinquantaine de Go, un +// dépôt Hugging Face publie son GGUF en tranches nommées par convention +// llama.cpp : `-00001-of-00003.gguf`, `…-00002-of-00003.gguf`, etc. +// llama-server n'a besoin QUE de la première : il ouvre les suivantes tout seul, +// à condition qu'elles soient dans le même dossier. +// +// Tout le reste d'AJEAN traitait chaque tranche comme un modèle indépendant : +// le lien collé ne rapatriait qu'un fichier sur trois (le moteur mourait sur +// « tensor not found »), la liste du sélecteur montrait trois entrées dont deux +// ne démarrent pas, et supprimer « le modèle » laissait les deux autres tranches +// occuper le disque. Ce fichier ramène la tranche au rang de détail : une +// famille de tranches = UN modèle, désigné par sa première. + +// shardRe reconnaît le suffixe de tranche en fin de nom de fichier. Le nombre de +// chiffres n'est pas figé à 5 : le convertisseur de llama.cpp en pose 5, mais +// des dépôts en publient avec moins. +var shardRe = regexp.MustCompile(`(?i)-(\d{2,5})-of-(\d{2,5})\.gguf$`) + +// shardInfo décompose un nom de tranche : rang (1-based) et nombre total. ok +// vaut false pour un modèle d'un seul fichier. +func shardInfo(name string) (idx, total int, ok bool) { + m := shardRe.FindStringSubmatch(baseName(name)) + if m == nil { + return 0, 0, false + } + idx, _ = strconv.Atoi(m[1]) + total, _ = strconv.Atoi(m[2]) + if idx < 1 || total < 1 || idx > total { + return 0, 0, false // numérotation incohérente : on le traite en fichier simple + } + return idx, total, true +} + +// shardNameAt renvoie le nom de la tranche de rang i dans la même famille, en +// conservant la largeur de la numérotation d'origine (00002, pas 2). +func shardNameAt(name string, i int) string { + m := shardRe.FindStringSubmatchIndex(name) + if m == nil { + return name + } + width := m[3] - m[2] + totalPart := name[m[4]:m[5]] + return name[:m[2]] + fmt.Sprintf("%0*d", width, i) + "-of-" + totalPart + name[m[5]:] +} + +// isFollowerShard : tranche 2..N, celles qu'on ne montre jamais comme un modèle +// à part entière (elles ne démarrent pas seules). +func isFollowerShard(name string) bool { + idx, _, ok := shardInfo(name) + return ok && idx > 1 +} + +// shardFamily renvoie les noms de TOUTES les tranches de la famille de name (y +// compris name), dans l'ordre. Un fichier simple se renvoie lui-même. +func shardFamily(name string) []string { + base := baseName(name) + _, total, ok := shardInfo(base) + if !ok { + return []string{base} + } + out := make([]string, 0, total) + for i := 1; i <= total; i++ { + out = append(out, shardNameAt(base, i)) + } + return out +} + +// shardFamilySize additionne la taille des tranches PRÉSENTES dans dir. Sert à +// afficher « 42 Go » sur la première tranche au lieu des 15 Go qu'elle pèse +// seule, ce qui laissait croire à un modèle trois fois plus petit. +func shardFamilySize(dir, name string) int64 { + var sum int64 + for _, n := range shardFamily(name) { + if st, err := os.Stat(filepath.Join(dir, n)); err == nil && !st.IsDir() { + sum += st.Size() + } + } + return sum +} + +// shardFamilyMissing liste les tranches ABSENTES du dossier. Un modèle dont il +// manque une tranche démarre puis meurt sur une erreur de tenseur introuvable : +// autant le dire dans la liste plutôt que dans les logs du moteur. +func shardFamilyMissing(dir, name string) []string { + var missing []string + for _, n := range shardFamily(name) { + if st, err := os.Stat(filepath.Join(dir, n)); err != nil || st.IsDir() { + missing = append(missing, n) + } + } + return missing +} + +// shardURLSet transforme l'URL d'UNE tranche en la liste complète des URLs de la +// famille, dans l'ordre, avec les noms de fichiers correspondants. Le lien collé +// peut désigner n'importe quelle tranche (on tombe souvent sur la deuxième en +// parcourant l'arborescence du dépôt) : on remonte toujours à la première. +func shardURLSet(dlURL, name string) (urls, names []string) { + fam := shardFamily(name) + if len(fam) == 1 { + return []string{dlURL}, fam + } + base := baseName(name) + for _, n := range fam { + // Remplacement du seul nom de fichier, en fin d'URL : le nom peut réapparaître + // ailleurs dans le chemin (dossier de quantification portant le même libellé), + // et un ReplaceAll y toucherait aussi. + i := strings.LastIndex(dlURL, base) + if i < 0 { + return []string{dlURL}, []string{base} // lien inattendu : on ne devine rien + } + urls = append(urls, dlURL[:i]+n+dlURL[i+len(base):]) + names = append(names, n) + } + return urls, names +} diff --git a/internal/ajean/backend_models_shards_test.go b/internal/ajean/backend_models_shards_test.go new file mode 100644 index 0000000..c7e6477 --- /dev/null +++ b/internal/ajean/backend_models_shards_test.go @@ -0,0 +1,120 @@ +package ajean + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestShardInfo(t *testing.T) { + cases := []struct { + name string + idx, total int + ok bool + }{ + {"Laguna-S-2.1-UD-IQ4_XS-00001-of-00003.gguf", 1, 3, true}, + {"Laguna-S-2.1-UD-IQ4_XS-00002-of-00003.gguf", 2, 3, true}, + {"m-01-of-03.gguf", 1, 3, true}, + {"Qwen2.5-7B-Instruct-Q4_K_M.gguf", 0, 0, false}, + {"bidon-00004-of-00003.gguf", 0, 0, false}, // numérotation incohérente + {"pas-un-gguf-00001-of-00002.bin", 0, 0, false}, + } + for _, c := range cases { + idx, total, ok := shardInfo(c.name) + if idx != c.idx || total != c.total || ok != c.ok { + t.Errorf("shardInfo(%q) = %d,%d,%v ; attendu %d,%d,%v", c.name, idx, total, ok, c.idx, c.total, c.ok) + } + } +} + +func TestShardFamilyGardeLaLargeur(t *testing.T) { + got := shardFamily("Laguna-00002-of-00003.gguf") + want := []string{ + "Laguna-00001-of-00003.gguf", + "Laguna-00002-of-00003.gguf", + "Laguna-00003-of-00003.gguf", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("famille = %v ; attendu %v", got, want) + } + if solo := shardFamily("simple.gguf"); !reflect.DeepEqual(solo, []string{"simple.gguf"}) { + t.Fatalf("fichier simple = %v", solo) + } +} + +// Le lien collé peut désigner n'importe quelle tranche : on remonte toujours à la +// première, et on ne réécrit QUE le nom de fichier final (le même libellé peut +// apparaître plus haut dans le chemin du dépôt). +func TestShardURLSet(t *testing.T) { + base := "https://huggingface.co/unsloth/Laguna-S-2.1-GGUF/resolve/main/UD-IQ4_XS/" + in := base + "Laguna-UD-IQ4_XS-00002-of-00003.gguf" + urls, names := shardURLSet(in, "Laguna-UD-IQ4_XS-00002-of-00003.gguf") + if len(urls) != 3 || len(names) != 3 { + t.Fatalf("attendu 3 tranches, got %d/%d", len(urls), len(names)) + } + if names[0] != "Laguna-UD-IQ4_XS-00001-of-00003.gguf" { + t.Fatalf("première tranche = %q", names[0]) + } + if urls[0] != base+"Laguna-UD-IQ4_XS-00001-of-00003.gguf" { + t.Fatalf("URL de la première tranche = %q", urls[0]) + } + if urls[2] != base+"Laguna-UD-IQ4_XS-00003-of-00003.gguf" { + t.Fatalf("URL de la dernière tranche = %q", urls[2]) + } + // Fichier simple : une seule URL, inchangée. + u, n := shardURLSet(base+"m.gguf", "m.gguf") + if len(u) != 1 || u[0] != base+"m.gguf" || n[0] != "m.gguf" { + t.Fatalf("fichier simple mal traité : %v %v", u, n) + } +} + +func TestShardFamilyTailleEtManquantes(t *testing.T) { + dir := t.TempDir() + write := func(n string, size int) { + if err := os.WriteFile(filepath.Join(dir, n), make([]byte, size), 0o644); err != nil { + t.Fatal(err) + } + } + write("m-00001-of-00003.gguf", 100) + write("m-00003-of-00003.gguf", 300) + + if got := shardFamilySize(dir, "m-00001-of-00003.gguf"); got != 400 { + t.Fatalf("taille de la famille = %d ; attendu 400", got) + } + missing := shardFamilyMissing(dir, "m-00001-of-00003.gguf") + if !reflect.DeepEqual(missing, []string{"m-00002-of-00003.gguf"}) { + t.Fatalf("tranches manquantes = %v", missing) + } + write("m-00002-of-00003.gguf", 200) + if m := shardFamilyMissing(dir, "m-00001-of-00003.gguf"); len(m) != 0 { + t.Fatalf("famille complète signalée incomplète : %v", m) + } +} + +// Supprimer un modèle découpé doit emporter TOUTES ses tranches, sinon des +// dizaines de Go restent sur le disque sans que rien ne sache plus les effacer. +func TestDeleteModelFileEmporteLesTranches(t *testing.T) { + testHome(t) + dir := modelsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + names := []string{"m-00001-of-00002.gguf", "m-00002-of-00002.gguf", "autre.gguf"} + for _, n := range names { + if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil { + t.Fatal(err) + } + for _, n := range names[:2] { + if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) { + t.Fatalf("tranche %s non supprimée", n) + } + } + if _, err := os.Stat(filepath.Join(dir, "autre.gguf")); err != nil { + t.Fatal("un modèle sans rapport a été supprimé") + } +} diff --git a/internal/ajean/backend_presets.go b/internal/ajean/backend_presets.go index c4bc437..55d6f41 100644 --- a/internal/ajean/backend_presets.go +++ b/internal/ajean/backend_presets.go @@ -183,7 +183,13 @@ func safePresetPath(name string) (string, error) { // paramètres de modèle) qui doivent survivre à un changement de preset. Sans ça, // écraser config.env avec le preset ré-imposerait le mode mémoire et effacerait // l'URL du serveur internet à chaque bascule — ce qui obligeait à tout remettre. -var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES"} +// HOST y figure depuis la 0.8.4 : c'est un réglage de MACHINE (« le moteur +// est-il joignable depuis le réseau ? », voir sys_network.go), pas un réglage de +// modèle. Sans lui dans cette liste, basculer sur un preset écrit avant cette +// version effaçait la clé, et le moteur repartait sur le défaut « toutes les +// interfaces » : une machine volontairement fermée se rouvrait toute seule au +// premier changement de preset. +var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"} // softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit // pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un @@ -193,7 +199,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS // inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le // modèle se retrouvait entièrement sur une seule carte et mourait sur // « cudaMalloc failed: out of memory ». Le preset explicite gagne. -var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true} +var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true} // applyPresetFile installe le preset comme configuration active, en réinjectant // les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index 6f1df9e..5d2e767 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -61,6 +61,13 @@ func cmdServe(args []string) error { if _, err := os.Stat(model); err != nil { return fmt.Errorf("modèle introuvable : %s", model) } + // Modèle découpé en tranches : llama-server ouvre les suivantes tout seul, mais + // s'il en manque une il démarre puis meurt sur un tenseur introuvable — message + // incompréhensible, et systemd relance en boucle. On le dit ici, en clair. + if missing := shardFamilyMissing(filepath.Dir(model), filepath.Base(model)); len(missing) > 0 { + return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous", + strings.Join(missing, ", "), filepath.Dir(model), len(shardFamily(filepath.Base(model)))) + } if !filepath.IsAbs(bin) { bin = filepath.Join(AjeanHome(), bin) } diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go new file mode 100644 index 0000000..03e04c8 --- /dev/null +++ b/internal/ajean/chat_export.go @@ -0,0 +1,218 @@ +package ajean + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +// chat_export.go — sortir la conversation de la base. +// +// Jusqu'à la 0.7, l'historique vivait dans un `conversation.json` qu'on pouvait +// ouvrir, lire et copier pour archive. La 0.8 l'a rangé dans ajean.db (bbolt) : +// un fichier binaire, et qui porte de surcroît un verrou EXCLUSIF tant que le +// service tourne — donc impossible ne serait-ce que de le copier sans arrêter +// AJEAN. Le fil de discussion et les raisonnements sont toujours là, mais plus +// personne ne peut les récupérer. C'est une régression pour qui relisait ses +// échanges, et ce fichier la répare. +// +// Deux formats, un même contenu : le JSON est fidèle (vue modèle + journal +// d'affichage complet, rejouable), le Markdown est fait pour être lu. + +// exportPayload est la forme du fichier JSON exporté. `messages` est la vue +// envoyée au modèle, `log` le journal d'affichage (celui qui porte les +// raisonnements, les outils et les vitesses). +type exportPayload struct { + Version string `json:"ajean_version"` + ExportedAt string `json:"exported_at"` + CtxUsed int `json:"ctx_used"` + Messages []Message `json:"messages"` + Log []LogEvent `json:"log"` +} + +// ExportJSON rend la conversation entière en JSON indenté. +func (c *Conversation) ExportJSON() ([]byte, error) { + c.mu.Lock() + p := exportPayload{ + Version: Version, + ExportedAt: time.Now().Format(time.RFC3339), + CtxUsed: c.CtxUsed, + Messages: append([]Message(nil), c.Messages...), + Log: append([]LogEvent(nil), c.Log...), + } + c.mu.Unlock() + return json.MarshalIndent(p, "", " ") +} + +// ExportMarkdown rend la conversation en Markdown lisible : un bloc par tour, +// le raisonnement replié dans un
    (il est souvent plus long que la +// réponse), et les outils appelés en liste. +// +// On repart du JOURNAL D'AFFICHAGE et non de la vue modèle : c'est lui qui porte +// les raisonnements et la trace des outils, précisément ce qu'on vient chercher +// dans un export. coalesceReplay est réutilisé tel quel pour recoller les +// milliers de deltas d'un tour en un bloc de texte par bulle. +func (c *Conversation) ExportMarkdown() string { + c.mu.Lock() + snapshot := append([]LogEvent(nil), c.Log...) + c.mu.Unlock() + + var b strings.Builder + fmt.Fprintf(&b, "# Conversation AJEAN\n\nExportée le %s par AJEAN %s.\n", + time.Now().Format("02/01/2006 à 15:04"), Version) + + // openBubble : une bulle assistant est ouverte (au moins un bout de réponse a + // été écrit). Sert à ne poser l'en-tête « AJEAN » qu'une fois par tour, même + // quand la réponse est entrecoupée d'appels d'outils. + openBubble := false + for _, ev := range coalesceReplay(snapshot, 0) { + switch { + case ev["user"] != nil: + openBubble = false + fmt.Fprintf(&b, "\n---\n\n## Vous\n\n%s\n", mdText(ev["user"])) + case ev["reasoning_content"] != nil: + if !openBubble { + b.WriteString("\n## AJEAN\n") + openBubble = true + } + fmt.Fprintf(&b, "\n
    \nRaisonnement\n\n%s\n\n
    \n", + mdText(ev["reasoning_content"])) + case ev["content"] != nil: + if !openBubble { + b.WriteString("\n## AJEAN\n") + openBubble = true + } + fmt.Fprintf(&b, "\n%s\n", mdText(ev["content"])) + case ev["tool_used"] != nil: + tu, _ := ev["tool_used"].(map[string]any) + if tu == nil { + break + } + if !openBubble { + b.WriteString("\n## AJEAN\n") + openBubble = true + } + label, _ := tu["label"].(string) + name, _ := tu["name"].(string) + if label == "" { + label = name + } + fmt.Fprintf(&b, "\n> 🔧 **%s** — %s\n", mdInline(name), mdInline(label)) + if res, _ := tu["result"].(string); strings.TrimSpace(res) != "" { + fmt.Fprintf(&b, "\n```\n%s\n```\n", strings.TrimRight(res, "\n")) + } + case ev["compacted"] != nil: + fmt.Fprintf(&b, "\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n") + } + } + if !openBubble && len(snapshot) == 0 { + b.WriteString("\n_Conversation vide._\n") + } + return b.String() +} + +// mdText rend une valeur d'événement en texte de bloc Markdown. +func mdText(v any) string { + s, _ := v.(string) + return strings.TrimRight(s, "\n") +} + +// mdInline neutralise ce qui casserait une ligne Markdown (un label d'outil peut +// contenir un chemin, des astérisques, un retour à la ligne). +func mdInline(v any) string { + s, _ := v.(string) + s = strings.ReplaceAll(s, "\n", " ") + r := strings.NewReplacer("*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]") + return strings.TrimSpace(r.Replace(s)) +} + +// exportFilename : nom proposé au téléchargement, horodaté pour que deux exports +// ne s'écrasent pas dans le dossier de téléchargements. +func exportFilename(ext string) string { + return "ajean-conversation-" + time.Now().Format("2006-01-02-1504") + "." + ext +} + +// cmdExport écrit la conversation dans un fichier (ou sur la sortie standard). +// +// ajean export → ajean-conversation-.md dans le dossier courant +// ajean export --json → idem en JSON +// ajean export mon-fil.md → nom de fichier imposé (l'extension choisit le format) +// ajean export - → sur la sortie standard, pour enchaîner un tube +// +// La conversation est relue depuis la base à chaque appel : la commande marche +// pendant que le service tourne (bbolt n'est jamais gardé ouvert, voir store.go). +func cmdExport(args []string) error { + format, out := "md", "" + for _, a := range args { + switch a { + case "--json", "-j": + format = "json" + case "--md", "--markdown", "-m": + format = "md" + default: + if strings.HasPrefix(a, "-") && a != "-" { + return fmt.Errorf("option inconnue : %s (attendu --json ou --md)", a) + } + out = a + } + } + // Une extension explicite l'emporte sur le drapeau : `ajean export fil.json` + // qui produirait du Markdown serait un piège. + if strings.HasSuffix(strings.ToLower(out), ".json") { + format = "json" + } else if strings.HasSuffix(strings.ToLower(out), ".md") { + format = "md" + } + + LoadConversation() + var body []byte + if format == "json" { + b, err := conv.ExportJSON() + if err != nil { + return err + } + body = b + } else { + body = []byte(conv.ExportMarkdown()) + } + + if out == "-" { + _, err := os.Stdout.Write(body) + return err + } + if out == "" { + out = exportFilename(format) + } + if err := os.WriteFile(out, body, 0o644); err != nil { + return err + } + conv.mu.Lock() + n := len(conv.Messages) + conv.mu.Unlock() + fmt.Printf("%s %s (%d messages, %s)\n", green("[ok]"), out, n, humanBytes(int64(len(body)))) + return nil +} + +// handleChatExport sert la conversation en téléchargement. `format=md` (défaut) +// ou `format=json`. +func handleChatExport(w http.ResponseWriter, r *http.Request) { + var body []byte + var ctype, ext string + if r.URL.Query().Get("format") == "json" { + b, err := conv.ExportJSON() + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + body, ctype, ext = b, "application/json; charset=utf-8", "json" + } else { + body, ctype, ext = []byte(conv.ExportMarkdown()), "text/markdown; charset=utf-8", "md" + } + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Disposition", `attachment; filename="`+exportFilename(ext)+`"`) + w.Header().Set("Content-Length", fmt.Sprint(len(body))) + _, _ = w.Write(body) +} diff --git a/internal/ajean/chat_export_test.go b/internal/ajean/chat_export_test.go new file mode 100644 index 0000000..2160f3c --- /dev/null +++ b/internal/ajean/chat_export_test.go @@ -0,0 +1,128 @@ +package ajean + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// convDeTest pose un fil complet (question, raisonnement, outil, réponse) dans la +// conversation globale et la remet à zéro en fin de test. +func convDeTest(t *testing.T) { + t.Helper() + t.Cleanup(func() { conv.Reset() }) + conv.mu.Lock() + conv.Messages = []Message{ + {Role: "user", Content: "combien font 2+2 ?"}, + {Role: "assistant", Content: "4"}, + } + conv.Log = []LogEvent{ + {Seq: 1, TS: 1, Delta: map[string]any{"user": "combien font 2+2 ?"}}, + {Seq: 2, TS: 2, Delta: map[string]any{"reasoning_content": "addition simple"}}, + {Seq: 3, TS: 3, Delta: map[string]any{"tool_used": map[string]any{ + "name": "bash", "label": "echo 4", "result": "4", "done": true}}}, + {Seq: 4, TS: 4, Delta: map[string]any{"content": "4"}}, + {Seq: 5, TS: 5, Delta: map[string]any{"turn_done": true}}, + } + conv.Seq = 5 + conv.CtxUsed = 123 + conv.mu.Unlock() +} + +func TestExportMarkdownPorteToutLeFil(t *testing.T) { + convDeTest(t) + md := conv.ExportMarkdown() + for _, want := range []string{ + "## Vous", "combien font 2+2 ?", + "## AJEAN", + "Raisonnement", "addition simple", + "bash", "echo 4", + } { + if !strings.Contains(md, want) { + t.Errorf("Markdown exporté sans %q :\n%s", want, md) + } + } + // L'en-tête AJEAN ne doit apparaître qu'UNE fois pour un tour, même entrecoupé + // d'un appel d'outil. + if n := strings.Count(md, "\n## AJEAN\n"); n != 1 { + t.Errorf("en-tête assistant répété %d fois", n) + } +} + +func TestExportJSONRelisible(t *testing.T) { + convDeTest(t) + b, err := conv.ExportJSON() + if err != nil { + t.Fatal(err) + } + var p exportPayload + if err := json.Unmarshal(b, &p); err != nil { + t.Fatal(err) + } + if len(p.Messages) != 2 || len(p.Log) != 5 { + t.Fatalf("export tronqué : %d messages, %d événements", len(p.Messages), len(p.Log)) + } + if p.CtxUsed != 123 || p.Version != Version { + t.Fatalf("métadonnées absentes : ctx=%d version=%q", p.CtxUsed, p.Version) + } +} + +// L'endpoint doit se présenter en TÉLÉCHARGEMENT (et pas s'afficher dans +// l'onglet), avec un nom de fichier horodaté. +func TestHandleChatExportEnPieceJointe(t *testing.T) { + convDeTest(t) + for _, tc := range []struct{ format, ctype, ext string }{ + {"", "text/markdown", ".md"}, + {"json", "application/json", ".json"}, + } { + rr := httptest.NewRecorder() + handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?format="+tc.format, nil)) + if rr.Code != 200 { + t.Fatalf("format=%q : HTTP %d", tc.format, rr.Code) + } + if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) { + t.Errorf("format=%q : Content-Type %q", tc.format, ct) + } + cd := rr.Header().Get("Content-Disposition") + if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, tc.ext) { + t.Errorf("format=%q : Content-Disposition %q", tc.format, cd) + } + if rr.Body.Len() == 0 { + t.Errorf("format=%q : corps vide", tc.format) + } + } +} + +func TestCmdExportEcritLeFichier(t *testing.T) { + testHome(t) + convDeTest(t) + // LoadConversation (appelé par cmdExport) relit la base : on y persiste d'abord + // le fil, sinon la commande exporterait une conversation vide. + conv.persist() + + dir := t.TempDir() + for _, name := range []string{"fil.md", "fil.json"} { + p := filepath.Join(dir, name) + if err := cmdExport([]string{p}); err != nil { + t.Fatalf("%s : %v", name, err) + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "2+2") { + t.Fatalf("%s ne contient pas la conversation :\n%s", name, b) + } + } + // L'extension impose le format, même sans drapeau. + b, _ := os.ReadFile(filepath.Join(dir, "fil.json")) + if !json.Valid(b) { + t.Fatal("fil.json n'est pas du JSON valide") + } + if err := cmdExport([]string{"--zzz"}); err == nil { + t.Fatal("option inconnue acceptée") + } +} diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 0fe0719..623dbe4 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.8.3" +const Version = "0.8.4" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { @@ -62,10 +62,14 @@ func Main() { mustExit(showVram()) case "gpu": mustExit(cmdGPU(args)) + case "network": + mustExit(cmdNetwork(args)) case "switch": mustExit(cmdSwitch(args)) case "chat": mustExit(cmdChat(args)) + case "export": + mustExit(cmdExport(args)) case "web": mustExit(cmdWeb(args)) case "link": @@ -129,6 +133,8 @@ Moteur (ajean-engine) : vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer + network [on|off|status] rendre l'endpoint OpenAI joignable depuis le réseau local + (HOST + règle de pare-feu sous Windows) Interface (ajean-ui) : ui [start|stop|restart|status] pilote le service d'interface @@ -138,6 +144,8 @@ Interface (ajean-ui) : Interaction: chat [system-prompt] chat terminal streamé + export [--json] [fichier] exporte la conversation de l'interface web + (Markdown par défaut, « - » = sortie standard) agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire) memory [off|ondemand|always|status] mode mémoire de l'IA internet [on|off|status|engine |url |key ] diff --git a/internal/ajean/store_test.go b/internal/ajean/store_test.go index fcd9ec8..1ecd5d2 100644 --- a/internal/ajean/store_test.go +++ b/internal/ajean/store_test.go @@ -2,11 +2,15 @@ package ajean import "testing" -// testHome donne au test un $AJEAN_HOME à lui. +// testHome donne au test un $AJEAN_HOME à lui, et neutralise le pare-feu : un +// test ne doit jamais poser (ni retirer) une règle entrante sur la machine qui +// le fait tourner. func testHome(t *testing.T) string { t.Helper() home := t.TempDir() t.Setenv("AJEAN_HOME", home) + firewallInert = true + t.Cleanup(func() { firewallInert = false }) return home } diff --git a/internal/ajean/sys_firewall_other.go b/internal/ajean/sys_firewall_other.go new file mode 100644 index 0000000..c0e2904 --- /dev/null +++ b/internal/ajean/sys_firewall_other.go @@ -0,0 +1,20 @@ +//go:build !windows + +package ajean + +// Linux et macOS n'ont pas de pare-feu entrant activé par défaut, et quand il y +// en a un (ufw, firewalld, pf) c'est une affaire d'administration système qu'un +// gestionnaire de modèles n'a pas à trancher dans le dos de l'utilisateur. On +// n'en pilote donc aucun : « inconnu » dit exactement ça, et l'interface +// n'affiche pas d'avertissement de pare-feu là où il n'y a rien à avertir. + +// firewallInert n'a de rôle que sous Windows (voir sys_firewall_windows.go) ; +// il existe ici pour que le code de test reste commun aux trois plateformes. +var firewallInert bool + +func firewallOpen(int) error { return nil } +func firewallClose(int) error { return nil } + +func firewallState(int) string { return "inconnu" } + +func firewallManualHint(int) string { return "" } diff --git a/internal/ajean/sys_firewall_windows.go b/internal/ajean/sys_firewall_windows.go new file mode 100644 index 0000000..bf570b3 --- /dev/null +++ b/internal/ajean/sys_firewall_windows.go @@ -0,0 +1,85 @@ +//go:build windows + +package ajean + +import ( + "fmt" + "os/exec" + "strconv" + "strings" +) + +// sys_firewall_windows.go — règles de pare-feu entrantes pour le moteur. +// +// Sur une installation Windows fraîche, même avec HOST=0.0.0.0, le pare-feu +// bloque tout ce qui arrive du réseau : l'utilisateur voyait « ça écoute +// partout » et se prenait quand même un délai d'attente depuis son autre +// machine. AJEAN pose donc la règle lui-même, quand il en a le droit. +// +// `ajean install` ne réclame PAS les droits administrateur (voir +// sys_install_windows.go) : netsh échouera donc souvent. On ne fait pas semblant +// que ça a marché — firewallState relit la règle, et firewallManualHint donne la +// commande exacte à coller dans un terminal administrateur. + +// firewallRuleName identifie nos règles. Une par port : changer PORT dans la +// configuration ne doit pas laisser une règle ouverte sur l'ancien. +func firewallRuleName(port int) string { + return "AJEAN moteur (port " + strconv.Itoa(port) + ")" +} + +// netsh exécute netsh sans faire clignoter de console (hideCmd) et renvoie sa +// sortie combinée. +// +// firewallInert coupe tout appel à netsh. Positionné par les tests : une suite +// qui laisse une règle entrante derrière elle est un effet de bord inacceptable, +// et sur un runner élevé elle ouvrirait le port pour de bon. +var firewallInert bool + +func netsh(args ...string) (string, error) { + if firewallInert { + return "", fmt.Errorf("pare-feu non piloté") + } + out, err := hideCmd(exec.Command("netsh", args...)).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// firewallOpen autorise le port en entrée (TCP), pour les profils privé et +// domaine seulement : ouvrir un modèle non authentifié sur un réseau public +// (café, hôtel) n'est pas un défaut qu'on pose au nom de l'utilisateur. +func firewallOpen(port int) error { + _ = firewallClose(port) // idempotence : pas d'empilement de règles homonymes + out, err := netsh("advfirewall", "firewall", "add", "rule", + "name="+firewallRuleName(port), "dir=in", "action=allow", + "protocol=TCP", "localport="+strconv.Itoa(port), "profile=private,domain") + if err != nil { + return fmt.Errorf("règle de pare-feu refusée (droits administrateur requis) : %s", out) + } + return nil +} + +// firewallClose retire la règle. Absente = rien à faire, et surtout pas une +// erreur. +func firewallClose(port int) error { + _, _ = netsh("advfirewall", "firewall", "delete", "rule", "name="+firewallRuleName(port)) + return nil +} + +// firewallState relit l'état RÉEL de la règle plutôt que de croire au succès +// supposé d'une commande passée. +func firewallState(port int) string { + out, err := netsh("advfirewall", "firewall", "show", "rule", "name="+firewallRuleName(port)) + if err != nil || strings.TrimSpace(out) == "" { + return "ferme" + } + // netsh répond « Aucune règle ne correspond aux critères » (localisé) avec un + // code de sortie non nul : le err ci-dessus suffit. Ici la règle existe. + return "ouvert" +} + +// firewallManualHint : la commande à coller dans un terminal ADMINISTRATEUR +// quand AJEAN n'a pas pu poser la règle lui-même. +func firewallManualHint(port int) string { + return fmt.Sprintf("le pare-feu Windows bloque encore le port %d. Ouvre un terminal ADMINISTRATEUR et lance :\n"+ + ` netsh advfirewall firewall add rule name="%s" dir=in action=allow protocol=TCP localport=%d profile=private,domain`, + port, firewallRuleName(port), port) +} diff --git a/internal/ajean/sys_network.go b/internal/ajean/sys_network.go new file mode 100644 index 0000000..1c2271f --- /dev/null +++ b/internal/ajean/sys_network.go @@ -0,0 +1,166 @@ +package ajean + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// sys_network.go — « est-ce que ma machine est joignable depuis le réseau ? » +// +// Le moteur écoute à l'adresse HOST. Sous Windows, l'installation pose +// HOST=127.0.0.1 : le chat du navigateur marche (il passe par le serveur web +// d'AJEAN, sur la même machine), mais l'endpoint OpenAI :8080/v1 est INVISIBLE +// depuis le reste du réseau. Or c'est très exactement ce que les gens viennent +// chercher : brancher un logiciel tiers sur le modèle qui tourne dans le salon. +// +// Deux choses manquaient, et pas une seule : +// 1. HOST n'était réglable NULLE PART dans l'interface — il fallait connaître +// « ajean edit » et savoir quoi y écrire. +// 2. Même à 0.0.0.0, le pare-feu Windows bloque les connexions entrantes tant +// qu'aucune règle n'autorise le port. AJEAN n'en posait aucune. +// +// D'où un seul interrupteur qui fait les deux, et qui DIT ce qu'il n'a pas pu +// faire (poser une règle de pare-feu exige les droits administrateur, que +// l'installation d'AJEAN ne réclame pas). + +// hostLocalOnly est l'adresse d'écoute « cette machine seulement ». +const hostLocalOnly = "127.0.0.1" + +// hostAllInterfaces est l'adresse d'écoute « tout le réseau ». +const hostAllInterfaces = "0.0.0.0" + +// engineHost renvoie l'adresse d'écoute configurée pour le moteur. Vide = le +// défaut de backend_serve.go, c'est-à-dire toutes les interfaces. +func engineHost() string { + h := strings.TrimSpace(ReadConfig()["HOST"]) + if h == "" { + return hostAllInterfaces + } + return h +} + +// lanExposed : le moteur écoute-t-il ailleurs que sur la boucle locale ? +func lanExposed() bool { + h := engineHost() + return h != hostLocalOnly && h != "::1" && !strings.EqualFold(h, "localhost") +} + +// netStatus est ce que l'interface affiche, et ce que renvoie `ajean network`. +type netStatus struct { + Exposed bool `json:"exposed"` // le moteur écoute-t-il sur le réseau + Host string `json:"host"` // adresse d'écoute effective + Port int `json:"port"` // port du moteur + URL string `json:"url"` // endpoint OpenAI à coller dans un logiciel tiers + Firewall string `json:"firewall"` // "ouvert", "ferme", "inconnu" (aucun pare-feu piloté) + Hint string `json:"hint"` // ce qu'il reste à faire à la main, ou "" +} + +// networkStatus assemble l'état courant. +func networkStatus() netStatus { + st := netStatus{Exposed: lanExposed(), Host: engineHost(), Port: LLMPort()} + st.URL = fmt.Sprintf("http://%s:%d/v1", localIP(), st.Port) + st.Firewall = firewallState(st.Port) + if st.Exposed && st.Firewall == "ferme" { + st.Hint = firewallManualHint(st.Port) + } + return st +} + +// setLANExposure écrit HOST et, à l'ouverture, tente de poser la règle de +// pare-feu. Elle renvoie l'état obtenu ; une règle non posée n'est PAS une +// erreur bloquante (l'utilisateur peut la poser lui-même, Hint le lui dit). +// +// Le service n'est pas redémarré ici : llama-server ne lit --host qu'au +// lancement, et c'est à l'appelant de choisir le moment (l'interface propose le +// redémarrage, la CLI le rappelle). +func setLANExposure(on bool) (netStatus, error) { + host := hostLocalOnly + if on { + host = hostAllInterfaces + } + if err := SetConfigKey("HOST", host); err != nil { + return netStatus{}, err + } + port := LLMPort() + if on { + _ = firewallOpen(port) + } else { + _ = firewallClose(port) + } + return networkStatus(), nil +} + +// cmdNetwork : `ajean network [on|off|status]`. +func cmdNetwork(args []string) error { + sub := "status" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "on", "off": + st, err := setLANExposure(sub == "on") + if err != nil { + return err + } + if st.Exposed { + fmt.Printf("%s le moteur écoutera sur tout le réseau (%s)\n", green("[ok]"), st.URL) + } else { + fmt.Printf("%s le moteur n'écoutera plus que sur cette machine\n", green("[ok]")) + } + printNetHint(st) + fmt.Printf("%s redémarre le moteur pour appliquer : %s\n", dim("[info]"), bold("ajean restart")) + return nil + case "status", "": + st := networkStatus() + state := dim("cette machine seulement") + if st.Exposed { + state = green("tout le réseau") + } + fmt.Printf("%s écoute: %s (HOST=%s, PORT=%d)\n", cyan("Réseau"), state, st.Host, st.Port) + switch st.Firewall { + case "ouvert": + fmt.Printf(" pare-feu : %s\n", green("port autorisé")) + case "ferme": + fmt.Printf(" pare-feu : %s\n", yellow("aucune règle pour ce port")) + default: + fmt.Printf(" pare-feu : %s\n", dim("non géré par AJEAN sur cette plateforme")) + } + if st.Exposed { + fmt.Printf(" endpoint OpenAI : %s\n", bold(st.URL)) + } + printNetHint(st) + return nil + } + return fmt.Errorf("usage: ajean network [on|off|status]") +} + +func printNetHint(st netStatus) { + if st.Hint != "" { + fmt.Printf("%s %s\n", yellow("[attention]"), st.Hint) + } +} + +// handleNetwork : GET l'état, POST {exposed:bool} pour basculer. +func handleNetwork(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var req struct { + Exposed *bool `json:"exposed"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if req.Exposed != nil { + st, err := setLANExposure(*req.Exposed) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "status": st}) + return + } + } + sendJSON(w, 200, map[string]any{"ok": true, "status": networkStatus()}) +} diff --git a/internal/ajean/sys_network_test.go b/internal/ajean/sys_network_test.go new file mode 100644 index 0000000..8f1c1b6 --- /dev/null +++ b/internal/ajean/sys_network_test.go @@ -0,0 +1,106 @@ +package ajean + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEngineHostDefautEtBascule(t *testing.T) { + testHome(t) + // Clé absente : c'est le défaut de backend_serve.go qui s'applique, soit toutes + // les interfaces. Annoncer « fermé » ici serait un mensonge. + if h := engineHost(); h != hostAllInterfaces { + t.Fatalf("HOST absent → %q ; attendu %q", h, hostAllInterfaces) + } + if !lanExposed() { + t.Fatal("HOST absent devrait compter comme exposé") + } + if _, err := setLANExposure(false); err != nil { + t.Fatal(err) + } + if lanExposed() || ReadConfig()["HOST"] != hostLocalOnly { + t.Fatalf("fermeture sans effet : HOST=%q", ReadConfig()["HOST"]) + } + if _, err := setLANExposure(true); err != nil { + t.Fatal(err) + } + if !lanExposed() || ReadConfig()["HOST"] != hostAllInterfaces { + t.Fatalf("ouverture sans effet : HOST=%q", ReadConfig()["HOST"]) + } +} + +// « localhost » écrit à la main vaut boucle locale, au même titre que 127.0.0.1. +func TestLanExposedReconnaitLocalhost(t *testing.T) { + testHome(t) + for _, h := range []string{"127.0.0.1", "localhost", "::1", "LOCALHOST"} { + if err := SetConfigKey("HOST", h); err != nil { + t.Fatal(err) + } + if lanExposed() { + t.Errorf("HOST=%q compté comme exposé", h) + } + } + for _, h := range []string{"0.0.0.0", "192.168.1.25", "::"} { + if err := SetConfigKey("HOST", h); err != nil { + t.Fatal(err) + } + if !lanExposed() { + t.Errorf("HOST=%q compté comme fermé", h) + } + } +} + +// Une machine volontairement fermée ne doit pas se rouvrir toute seule parce +// qu'on bascule sur un preset écrit avant que HOST existe. +func TestHostSurvitAuChangementDePreset(t *testing.T) { + testHome(t) + if _, err := setLANExposure(false); err != nil { + t.Fatal(err) + } + p := filepath.Join(t.TempDir(), "vieux.env") + if err := os.WriteFile(p, []byte("MODEL=\"x.gguf\"\nCTX=4096\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := applyPresetFile(p); err != nil { + t.Fatal(err) + } + if ReadConfig()["HOST"] != hostLocalOnly { + t.Fatalf("HOST perdu à la bascule : %q", ReadConfig()["HOST"]) + } + // Mais un preset qui revendique HOST garde le dernier mot. + if err := os.WriteFile(p, []byte("MODEL=\"x.gguf\"\nHOST=\"0.0.0.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := applyPresetFile(p); err != nil { + t.Fatal(err) + } + if ReadConfig()["HOST"] != hostAllInterfaces { + t.Fatalf("le preset n'a pas pu imposer HOST : %q", ReadConfig()["HOST"]) + } +} + +func TestHandleNetwork(t *testing.T) { + testHome(t) + rr := httptest.NewRecorder() + handleNetwork(rr, httptest.NewRequest("POST", "/api/network", strings.NewReader(`{"exposed":false}`))) + if rr.Code != 200 { + t.Fatalf("HTTP %d : %s", rr.Code, rr.Body) + } + var out struct { + OK bool `json:"ok"` + Status netStatus `json:"status"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if !out.OK || out.Status.Exposed || out.Status.Host != hostLocalOnly { + t.Fatalf("état inattendu : %+v", out.Status) + } + if out.Status.Port == 0 || !strings.Contains(out.Status.URL, "/v1") { + t.Fatalf("endpoint mal formé : %+v", out.Status) + } +} diff --git a/internal/ajean/sys_service.go b/internal/ajean/sys_service.go index bbfb1ec..23e6511 100644 --- a/internal/ajean/sys_service.go +++ b/internal/ajean/sys_service.go @@ -47,6 +47,12 @@ func preflightEngine() error { if _, err := os.Stat(p); err != nil { return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("ajean edit")) } + // Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au moment + // où llama-server réclame un tenseur absent, dans le journal du service. + if missing := shardFamilyMissing(filepath.Dir(p), filepath.Base(p)); len(missing) > 0 { + return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous", + strings.Join(missing, ", "), filepath.Dir(p), len(shardFamily(filepath.Base(p)))) + } return nil } diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 488c304..f7a1efc 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1233,6 +1233,12 @@ button:hover{border-color:var(--dim);color:var(--text)}
    + + + + +
    + + +
    @@ -2420,6 +2434,75 @@ function renderApiKey(d){ document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none'; } async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } +// --- Export de la conversation --------------------------------------------- +// On passe par jfetch (et pas par un simple
    ) pour deux raisons : la clé +// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas, +// et le chemin de base change derrière le tunnel (/u/). +async function exportChat(format){ + toast('préparation de l\'export…'); + try{ + const r = await jfetch('/api/chat/export?format=' + encodeURIComponent(format||'md')); + if(!r.ok){ toast('erreur : HTTP ' + r.status); return; } + const blob = await r.blob(); + // Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom local. + let name = 'ajean-conversation.' + (format==='json' ? 'json' : 'md'); + const cd = r.headers.get('Content-Disposition') || ''; + const m = cd.match(/filename="([^"]+)"/); + if(m) name = m[1]; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = name; + document.body.appendChild(a); a.click(); a.remove(); + // Révocation différée : Safari annule le téléchargement si l'URL disparaît + // dans la foulée du clic. + setTimeout(()=>URL.revokeObjectURL(url), 10000); + toast('exporté : ' + name); + }catch(e){ toast('erreur : ' + e.message); } +} +// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------ +// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le +// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le +// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part. +function renderNetwork(st){ + if(!st) return; + const cb = document.getElementById('lan-toggle'); + if(cb) cb.checked = !!st.exposed; + const warn = document.getElementById('lan-warn'); + if(!warn) return; + if(!st.exposed){ + warn.style.display = ''; + warn.innerHTML = '
    Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.
    '; + return; + } + // Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute + // partout » et pourtant rien ne passe). On donne la commande à coller. + if(st.hint){ + warn.style.display = ''; + // --err et non --warn : la palette est volontairement monochrome et --warn y + // est un gris, illisible comme alerte. Ici il y a une vraie action à faire. + warn.innerHTML = '
    ⚠ ' + + escHtml(st.hint).replace(/\n/g,'
    ') + '
    '; + return; + } + warn.style.display = 'none'; +} +async function loadNetwork(){ + try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){} +} +async function toggleLAN(){ + const cb = document.getElementById('lan-toggle'); + const on = cb.checked; + const r = await jpost('/api/network', {exposed:on}); + if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; } + renderNetwork(r.status); + // llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur + // affiche un état que le moteur en cours ne respecte pas encore. + if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).', + {title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){ + await act('restart'); // même chemin que les boutons du panneau Moteur + } + loadApiKey(); +} function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; } async function apiKeyAction(action){ if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return; @@ -2486,7 +2569,7 @@ async function loadAll(){ // allSettled et pas all : un seul chargement en échec (accès distant coupé, // clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les // hauteurs réservées en place pour toujours. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } @@ -2715,7 +2798,14 @@ async function populateModelPicker(){ const on = samePath(cur, m.value) || samePath(cur, m.path) || (g.home && samePath(baseName(cur), m.name)) ? ' selected' : ''; if(on) matched = true; - opts += ''; + // Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche + // (les autres ne se lancent pas seules) et la taille de la famille entière. + // On le dit, et surtout on signale les tranches manquantes — sinon le + // modèle se sélectionne sans broncher puis le moteur meurt au démarrage. + let tag = ''; + if(m.shards > 1) tag = ' · ' + m.shards + ' fichiers'; + if(m.missing && m.missing.length) tag += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':''); + opts += ''; } html += groups.length > 1 ? ''+opts+'' @@ -3329,7 +3419,10 @@ async function startDownload(){ await populateDlDirs(); return; } - e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres'; + // p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas + // les 15 Go du fichier dont le lien a été collé. + const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : ''; + e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '+fmtSize(p.free)+' libres'; const r = await jpost('/api/models/download', {url, dir}); if(!r.ok){ e.prog.innerHTML = 'erreur : '+(r.error||'')+''; @@ -3398,7 +3491,10 @@ function watchDownload(fname){ extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s'); } } - e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+' — '+fname; + // Une seule barre pour tout le modèle ; le compteur de tranches dit où on en + // est, sans quoi un téléchargement en 3 fichiers semble se figer par paliers. + const part = st.parts > 1 ? ' — fichier '+(st.part||1)+'/'+st.parts : ''; + e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+part+' — '+fname; }; dlPoll = setInterval(tick, 800); tick(); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index d46c5c4..5c54470 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -186,6 +186,12 @@ + + + + +
    + + +
    diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index 09bd086..7e413ba 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -251,6 +251,75 @@ function renderApiKey(d){ document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none'; } async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } +// --- Export de la conversation --------------------------------------------- +// On passe par jfetch (et pas par un simple ) pour deux raisons : la clé +// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas, +// et le chemin de base change derrière le tunnel (/u/). +async function exportChat(format){ + toast('préparation de l\'export…'); + try{ + const r = await jfetch('/api/chat/export?format=' + encodeURIComponent(format||'md')); + if(!r.ok){ toast('erreur : HTTP ' + r.status); return; } + const blob = await r.blob(); + // Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom local. + let name = 'ajean-conversation.' + (format==='json' ? 'json' : 'md'); + const cd = r.headers.get('Content-Disposition') || ''; + const m = cd.match(/filename="([^"]+)"/); + if(m) name = m[1]; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = name; + document.body.appendChild(a); a.click(); a.remove(); + // Révocation différée : Safari annule le téléchargement si l'URL disparaît + // dans la foulée du clic. + setTimeout(()=>URL.revokeObjectURL(url), 10000); + toast('exporté : ' + name); + }catch(e){ toast('erreur : ' + e.message); } +} +// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------ +// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le +// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le +// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part. +function renderNetwork(st){ + if(!st) return; + const cb = document.getElementById('lan-toggle'); + if(cb) cb.checked = !!st.exposed; + const warn = document.getElementById('lan-warn'); + if(!warn) return; + if(!st.exposed){ + warn.style.display = ''; + warn.innerHTML = '
    Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.
    '; + return; + } + // Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute + // partout » et pourtant rien ne passe). On donne la commande à coller. + if(st.hint){ + warn.style.display = ''; + // --err et non --warn : la palette est volontairement monochrome et --warn y + // est un gris, illisible comme alerte. Ici il y a une vraie action à faire. + warn.innerHTML = '
    ⚠ ' + + escHtml(st.hint).replace(/\n/g,'
    ') + '
    '; + return; + } + warn.style.display = 'none'; +} +async function loadNetwork(){ + try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){} +} +async function toggleLAN(){ + const cb = document.getElementById('lan-toggle'); + const on = cb.checked; + const r = await jpost('/api/network', {exposed:on}); + if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; } + renderNetwork(r.status); + // llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur + // affiche un état que le moteur en cours ne respecte pas encore. + if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).', + {title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){ + await act('restart'); // même chemin que les boutons du panneau Moteur + } + loadApiKey(); +} function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; } async function apiKeyAction(action){ if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return; @@ -317,7 +386,7 @@ async function loadAll(){ // allSettled et pas all : un seul chargement en échec (accès distant coupé, // clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les // hauteurs réservées en place pour toujours. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 7459e7a..4bf5f6a 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -223,7 +223,14 @@ async function populateModelPicker(){ const on = samePath(cur, m.value) || samePath(cur, m.path) || (g.home && samePath(baseName(cur), m.name)) ? ' selected' : ''; if(on) matched = true; - opts += ''; + // Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche + // (les autres ne se lancent pas seules) et la taille de la famille entière. + // On le dit, et surtout on signale les tranches manquantes — sinon le + // modèle se sélectionne sans broncher puis le moteur meurt au démarrage. + let tag = ''; + if(m.shards > 1) tag = ' · ' + m.shards + ' fichiers'; + if(m.missing && m.missing.length) tag += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':''); + opts += ''; } html += groups.length > 1 ? ''+opts+'' @@ -837,7 +844,10 @@ async function startDownload(){ await populateDlDirs(); return; } - e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres'; + // p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas + // les 15 Go du fichier dont le lien a été collé. + const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : ''; + e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '+fmtSize(p.free)+' libres'; const r = await jpost('/api/models/download', {url, dir}); if(!r.ok){ e.prog.innerHTML = 'erreur : '+(r.error||'')+''; @@ -906,7 +916,10 @@ function watchDownload(fname){ extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s'); } } - e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+' — '+fname; + // Une seule barre pour tout le modèle ; le compteur de tranches dit où on en + // est, sans quoi un téléchargement en 3 fichiers semble se figer par paliers. + const part = st.parts > 1 ? ' — fichier '+(st.part||1)+'/'+st.parts : ''; + e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+part+' — '+fname; }; dlPoll = setInterval(tick, 800); tick(); diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 92919c0..63b9f33 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -281,6 +281,12 @@ func handleModels(w http.ResponseWriter, r *http.Request) { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { continue } + // Modèle découpé : seule la première tranche est un modèle lançable, les + // suivantes sont ouvertes par llama-server tout seul. Les lister toutes + // donnait trois entrées pour un modèle, dont deux qui échouent au démarrage. + if isFollowerShard(e.Name()) { + continue + } full := filepath.Join(dir, e.Name()) if seen[normDir(full)] { continue @@ -295,10 +301,21 @@ func handleModels(w http.ResponseWriter, r *http.Request) { if isHome { value = e.Name() } - out = append(out, map[string]any{ + m := map[string]any{ "name": e.Name(), "size": size, "value": value, "path": full, "dir": dir, "home": isHome, - }) + } + // Taille annoncée = la famille entière, et on signale les tranches + // manquantes : un modèle incomplet démarre puis meurt sur un tenseur + // introuvable, autant le voir avant de le sélectionner. + if _, total, ok := shardInfo(e.Name()); ok { + m["shards"] = total + m["size"] = shardFamilySize(dir, e.Name()) + if missing := shardFamilyMissing(dir, e.Name()); len(missing) > 0 { + m["missing"] = missing + } + } + out = append(out, m) } } sendJSON(w, 200, out) diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index ade34f5..be833a0 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -160,6 +160,7 @@ func newWebMux() *http.ServeMux { api("/api/mcp/tool", handleMCPTool) api("/api/mcp/test", handleMCPTest) api("/api/memory", handleMemoryMode) + api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows) api("/api/prefs", handleWebPrefs) api("/api/sysprompt", handleSysPrompt) // Alias rétro-compat : l'ancien portail ajean.link (dépôt ajean-relay) pilote @@ -184,6 +185,7 @@ func newWebMux() *http.ServeMux { api("/api/chat/reset", handleChatReset) // nouvelle conversation (pour tous les appareils) api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used} + api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json) api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais) return mux } From d8818953011f47094cf4277733b17f72a84fc44d Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 20:05:18 +0200 Subject: [PATCH 261/279] Le drapeau de neutralisation du pare-feu passe cote commun Declare dans sys_firewall_windows.go, il n'etait qu'ECRIT par les tests sur les autres plateformes : staticcheck le signalait mort (U1000) et la CI cassait. Il vit desormais dans sys_network.go, ou setLANExposure le lit sur les trois plateformes. --- internal/ajean/sys_firewall_other.go | 4 ---- internal/ajean/sys_firewall_windows.go | 6 ------ internal/ajean/sys_network.go | 15 +++++++++++++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/internal/ajean/sys_firewall_other.go b/internal/ajean/sys_firewall_other.go index c0e2904..59def08 100644 --- a/internal/ajean/sys_firewall_other.go +++ b/internal/ajean/sys_firewall_other.go @@ -8,10 +8,6 @@ package ajean // n'en pilote donc aucun : « inconnu » dit exactement ça, et l'interface // n'affiche pas d'avertissement de pare-feu là où il n'y a rien à avertir. -// firewallInert n'a de rôle que sous Windows (voir sys_firewall_windows.go) ; -// il existe ici pour que le code de test reste commun aux trois plateformes. -var firewallInert bool - func firewallOpen(int) error { return nil } func firewallClose(int) error { return nil } diff --git a/internal/ajean/sys_firewall_windows.go b/internal/ajean/sys_firewall_windows.go index bf570b3..8857a30 100644 --- a/internal/ajean/sys_firewall_windows.go +++ b/internal/ajean/sys_firewall_windows.go @@ -29,12 +29,6 @@ func firewallRuleName(port int) string { // netsh exécute netsh sans faire clignoter de console (hideCmd) et renvoie sa // sortie combinée. -// -// firewallInert coupe tout appel à netsh. Positionné par les tests : une suite -// qui laisse une règle entrante derrière elle est un effet de bord inacceptable, -// et sur un runner élevé elle ouvrirait le port pour de bon. -var firewallInert bool - func netsh(args ...string) (string, error) { if firewallInert { return "", fmt.Errorf("pare-feu non piloté") diff --git a/internal/ajean/sys_network.go b/internal/ajean/sys_network.go index 1c2271f..ac245ea 100644 --- a/internal/ajean/sys_network.go +++ b/internal/ajean/sys_network.go @@ -25,6 +25,14 @@ import ( // faire (poser une règle de pare-feu exige les droits administrateur, que // l'installation d'AJEAN ne réclame pas). +// firewallInert coupe tout pilotage du pare-feu. Positionné par les tests : une +// suite qui laisse une règle entrante derrière elle est un effet de bord +// inacceptable, et sur un runner élevé elle ouvrirait le port pour de bon. +// Déclaré ici, et non dans sys_firewall_windows.go, pour être LU sur les trois +// plateformes (sinon il n'est qu'écrit par les tests hors Windows, ce que +// staticcheck signale à juste titre comme une variable morte). +var firewallInert bool + // hostLocalOnly est l'adresse d'écoute « cette machine seulement ». const hostLocalOnly = "127.0.0.1" @@ -84,9 +92,12 @@ func setLANExposure(on bool) (netStatus, error) { return netStatus{}, err } port := LLMPort() - if on { + switch { + case firewallInert: + // rien : voir la note sur firewallInert + case on: _ = firewallOpen(port) - } else { + default: _ = firewallClose(port) } return networkStatus(), nil From 7a27dbf3be40e2becdb9f3f086e1560f0775a92c Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 20:25:18 +0200 Subject: [PATCH 262/279] Export : une fenetre d'options, et le vidage du chat passe en dernier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux boutons d'export cote a cote ne laissaient aucun choix : tout ou rien. Un bouton unique ouvre desormais une fenetre ou l'on choisit le format (Markdown a lire / JSON a retraiter), ce qu'on emporte (raisonnements, appels d'outils, sorties d'outils, ou journal d'affichage cote JSON) et la portee (tout le fil ou les N derniers echanges). Une ligne dit ce que l'export contiendra, et l'en-tete du fichier signale un export partiel : relu plus tard, il ne doit pas passer pour le fil complet. Memes options en ligne de commande : --last N, --no-reasoning, --no-tools, --no-results, --messages-only. Sans option, l'export reste complet, endpoint compris. « clear chat » descend en derniere position et seul sur sa ligne : c'est la seule action destructive du panneau, elle n'a rien a faire sous le doigt qui visait « refresh ». Au passage, issue #20 : le prompt systeme et la description de l'outil mem_search annoncaient encore la memoire sous MEMORY/, nom d'avant la 0.8. Le modele lisait donc un chemin qui n'existe plus. Corrige, avec les deux commentaires restes au meme nom. --- internal/ajean/chat_export.go | 254 +++++++++++++++++++----- internal/ajean/chat_export_test.go | 112 ++++++++++- internal/ajean/chat_memory.go | 2 +- internal/ajean/chat_tools.go | 2 +- internal/ajean/llm_client.go | 2 +- internal/ajean/run.go | 4 +- internal/ajean/ui/index.html | 143 +++++++++++-- internal/ajean/ui/src/index.tmpl.html | 61 +++++- internal/ajean/ui/src/js/06-settings.js | 82 +++++++- internal/ajean/web_api.go | 2 +- 10 files changed, 575 insertions(+), 89 deletions(-) diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go index 03e04c8..96c33ab 100644 --- a/internal/ajean/chat_export.go +++ b/internal/ajean/chat_export.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "os" + "strconv" "strings" "time" ) @@ -19,8 +20,62 @@ import ( // personne ne peut les récupérer. C'est une régression pour qui relisait ses // échanges, et ce fichier la répare. // -// Deux formats, un même contenu : le JSON est fidèle (vue modèle + journal -// d'affichage complet, rejouable), le Markdown est fait pour être lu. +// Deux formats : le Markdown est fait pour être LU (et se taille avec des +// options — un fil relu pour retrouver une réponse n'a que faire des mille +// lignes de raisonnement qui l'ont produite), le JSON est fait pour être +// RETRAITÉ et reste fidèle par défaut. + +// exportOpts décrit ce qu'on veut sortir. Le zéro de la structure n'est PAS un +// défaut utilisable (tout serait à false) : passer par defaultExportOpts. +type exportOpts struct { + Format string // "md" | "json" + Reasoning bool // Markdown : inclure les blocs de raisonnement + Tools bool // Markdown : inclure les appels d'outils + Results bool // Markdown : inclure la sortie des outils (implique Tools) + Turns int // 0 = tout le fil ; N = les N derniers échanges + JSONFull bool // JSON : joindre le journal d'affichage en plus des messages +} + +func defaultExportOpts() exportOpts { + return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true, JSONFull: true} +} + +// exportOptsFromQuery lit les options d'une requête. Toute clé absente garde sa +// valeur par défaut : `/api/chat/export` tout court reste l'export complet. +func exportOptsFromQuery(q map[string][]string) exportOpts { + o := defaultExportOpts() + get := func(k string) (string, bool) { + v, ok := q[k] + if !ok || len(v) == 0 { + return "", false + } + return v[0], true + } + flag := func(k string, cur bool) bool { + v, ok := get(k) + if !ok { + return cur + } + return v == "1" || v == "true" || v == "on" + } + if v, ok := get("format"); ok && v == "json" { + o.Format = "json" + } + o.Reasoning = flag("reasoning", o.Reasoning) + o.Tools = flag("tools", o.Tools) + o.Results = flag("results", o.Results) + o.JSONFull = flag("full", o.JSONFull) + if v, ok := get("turns"); ok { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + o.Turns = n + } + } + // Sans les bulles d'outils, leur sortie n'a nulle part où aller. + if !o.Tools { + o.Results = false + } + return o +} // exportPayload est la forme du fichier JSON exporté. `messages` est la vue // envoyée au modèle, `log` le journal d'affichage (celui qui porte les @@ -30,23 +85,65 @@ type exportPayload struct { ExportedAt string `json:"exported_at"` CtxUsed int `json:"ctx_used"` Messages []Message `json:"messages"` - Log []LogEvent `json:"log"` + Log []LogEvent `json:"log,omitempty"` } -// ExportJSON rend la conversation entière en JSON indenté. -func (c *Conversation) ExportJSON() ([]byte, error) { +// ExportJSON rend la conversation en JSON indenté. +func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) { c.mu.Lock() + msgs := append([]Message(nil), c.Messages...) + log := append([]LogEvent(nil), c.Log...) + ctxUsed := c.CtxUsed + c.mu.Unlock() + + if o.Turns > 0 { + msgs = lastTurnsMessages(msgs, o.Turns) + log = lastTurnsLog(log, o.Turns) + } p := exportPayload{ Version: Version, ExportedAt: time.Now().Format(time.RFC3339), - CtxUsed: c.CtxUsed, - Messages: append([]Message(nil), c.Messages...), - Log: append([]LogEvent(nil), c.Log...), + CtxUsed: ctxUsed, + Messages: msgs, + } + if o.JSONFull { + p.Log = log } - c.mu.Unlock() return json.MarshalIndent(p, "", " ") } +// lastTurnsMessages garde les n derniers échanges de la vue modèle, un échange +// commençant à un message `user`. +func lastTurnsMessages(msgs []Message, n int) []Message { + seen := 0 + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role != "user" { + continue + } + seen++ + if seen == n { + return msgs[i:] + } + } + return msgs +} + +// lastTurnsLog fait de même sur le journal d'affichage, où un échange commence à +// un événement `user`. +func lastTurnsLog(log []LogEvent, n int) []LogEvent { + seen := 0 + for i := len(log) - 1; i >= 0; i-- { + if _, ok := log[i].Delta["user"]; !ok { + continue + } + seen++ + if seen == n { + return log[i:] + } + } + return log +} + // ExportMarkdown rend la conversation en Markdown lisible : un bloc par tour, // le raisonnement replié dans un
    (il est souvent plus long que la // réponse), et les outils appelés en liste. @@ -55,53 +152,64 @@ func (c *Conversation) ExportJSON() ([]byte, error) { // les raisonnements et la trace des outils, précisément ce qu'on vient chercher // dans un export. coalesceReplay est réutilisé tel quel pour recoller les // milliers de deltas d'un tour en un bloc de texte par bulle. -func (c *Conversation) ExportMarkdown() string { +func (c *Conversation) ExportMarkdown(o exportOpts) string { c.mu.Lock() snapshot := append([]LogEvent(nil), c.Log...) c.mu.Unlock() + if o.Turns > 0 { + snapshot = lastTurnsLog(snapshot, o.Turns) + } var b strings.Builder fmt.Fprintf(&b, "# Conversation AJEAN\n\nExportée le %s par AJEAN %s.\n", time.Now().Format("02/01/2006 à 15:04"), Version) + // Un export tronqué ou allégé doit le DIRE : relu six mois plus tard, un fil + // sans ses raisonnements ne doit pas passer pour le fil complet. + if note := exportNote(o); note != "" { + fmt.Fprintf(&b, "\n%s\n", note) + } // openBubble : une bulle assistant est ouverte (au moins un bout de réponse a // été écrit). Sert à ne poser l'en-tête « AJEAN » qu'une fois par tour, même // quand la réponse est entrecoupée d'appels d'outils. openBubble := false + head := func() { + if !openBubble { + b.WriteString("\n## AJEAN\n") + openBubble = true + } + } for _, ev := range coalesceReplay(snapshot, 0) { switch { case ev["user"] != nil: openBubble = false fmt.Fprintf(&b, "\n---\n\n## Vous\n\n%s\n", mdText(ev["user"])) case ev["reasoning_content"] != nil: - if !openBubble { - b.WriteString("\n## AJEAN\n") - openBubble = true + if !o.Reasoning { + break } + head() fmt.Fprintf(&b, "\n
    \nRaisonnement\n\n%s\n\n
    \n", mdText(ev["reasoning_content"])) case ev["content"] != nil: - if !openBubble { - b.WriteString("\n## AJEAN\n") - openBubble = true - } + head() fmt.Fprintf(&b, "\n%s\n", mdText(ev["content"])) case ev["tool_used"] != nil: + if !o.Tools { + break + } tu, _ := ev["tool_used"].(map[string]any) if tu == nil { break } - if !openBubble { - b.WriteString("\n## AJEAN\n") - openBubble = true - } + head() label, _ := tu["label"].(string) name, _ := tu["name"].(string) if label == "" { label = name } fmt.Fprintf(&b, "\n> 🔧 **%s** — %s\n", mdInline(name), mdInline(label)) - if res, _ := tu["result"].(string); strings.TrimSpace(res) != "" { + if res, _ := tu["result"].(string); o.Results && strings.TrimSpace(res) != "" { fmt.Fprintf(&b, "\n```\n%s\n```\n", strings.TrimRight(res, "\n")) } case ev["compacted"] != nil: @@ -114,6 +222,26 @@ func (c *Conversation) ExportMarkdown() string { return b.String() } +// exportNote résume en une ligne ce que l'export ne contient PAS. +func exportNote(o exportOpts) string { + var parts []string + if o.Turns > 0 { + parts = append(parts, fmt.Sprintf("%d derniers échanges seulement", o.Turns)) + } + if !o.Reasoning { + parts = append(parts, "raisonnements retirés") + } + if !o.Tools { + parts = append(parts, "appels d'outils retirés") + } else if !o.Results { + parts = append(parts, "sorties d'outils retirées") + } + if len(parts) == 0 { + return "" + } + return "_Export partiel : " + strings.Join(parts, ", ") + "._" +} + // mdText rend une valeur d'événement en texte de bloc Markdown. func mdText(v any) string { s, _ := v.(string) @@ -135,6 +263,16 @@ func exportFilename(ext string) string { return "ajean-conversation-" + time.Now().Format("2006-01-02-1504") + "." + ext } +// exportBody rend la conversation selon les options. Renvoie le corps, son type +// MIME et l'extension de fichier. +func exportBody(o exportOpts) ([]byte, string, string, error) { + if o.Format == "json" { + b, err := conv.ExportJSON(o) + return b, "application/json; charset=utf-8", "json", err + } + return []byte(conv.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil +} + // cmdExport écrit la conversation dans un fichier (ou sur la sortie standard). // // ajean export → ajean-conversation-.md dans le dossier courant @@ -142,19 +280,43 @@ func exportFilename(ext string) string { // ajean export mon-fil.md → nom de fichier imposé (l'extension choisit le format) // ajean export - → sur la sortie standard, pour enchaîner un tube // +// Options de contenu, les mêmes que la fenêtre d'export de l'interface : +// +// --no-reasoning --no-tools --no-results --last N --messages-only +// // La conversation est relue depuis la base à chaque appel : la commande marche // pendant que le service tourne (bbolt n'est jamais gardé ouvert, voir store.go). func cmdExport(args []string) error { - format, out := "md", "" - for _, a := range args { + o := defaultExportOpts() + out := "" + for i := 0; i < len(args); i++ { + a := args[i] switch a { case "--json", "-j": - format = "json" + o.Format = "json" case "--md", "--markdown", "-m": - format = "md" + o.Format = "md" + case "--no-reasoning": + o.Reasoning = false + case "--no-tools": + o.Tools, o.Results = false, false + case "--no-results": + o.Results = false + case "--messages-only": + o.JSONFull = false + case "--last": + if i+1 >= len(args) { + return fmt.Errorf("--last attend un nombre d'échanges") + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n <= 0 { + return fmt.Errorf("--last : nombre d'échanges invalide (%s)", args[i]) + } + o.Turns = n default: if strings.HasPrefix(a, "-") && a != "-" { - return fmt.Errorf("option inconnue : %s (attendu --json ou --md)", a) + return fmt.Errorf("option inconnue : %s (voir « ajean help »)", a) } out = a } @@ -162,21 +324,15 @@ func cmdExport(args []string) error { // Une extension explicite l'emporte sur le drapeau : `ajean export fil.json` // qui produirait du Markdown serait un piège. if strings.HasSuffix(strings.ToLower(out), ".json") { - format = "json" + o.Format = "json" } else if strings.HasSuffix(strings.ToLower(out), ".md") { - format = "md" + o.Format = "md" } LoadConversation() - var body []byte - if format == "json" { - b, err := conv.ExportJSON() - if err != nil { - return err - } - body = b - } else { - body = []byte(conv.ExportMarkdown()) + body, _, ext, err := exportBody(o) + if err != nil { + return err } if out == "-" { @@ -184,7 +340,7 @@ func cmdExport(args []string) error { return err } if out == "" { - out = exportFilename(format) + out = exportFilename(ext) } if err := os.WriteFile(out, body, 0o644); err != nil { return err @@ -196,20 +352,14 @@ func cmdExport(args []string) error { return nil } -// handleChatExport sert la conversation en téléchargement. `format=md` (défaut) -// ou `format=json`. +// handleChatExport sert la conversation en téléchargement. Options en paramètres +// d'URL (voir exportOptsFromQuery) ; sans aucun paramètre, c'est l'export complet. func handleChatExport(w http.ResponseWriter, r *http.Request) { - var body []byte - var ctype, ext string - if r.URL.Query().Get("format") == "json" { - b, err := conv.ExportJSON() - if err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - body, ctype, ext = b, "application/json; charset=utf-8", "json" - } else { - body, ctype, ext = []byte(conv.ExportMarkdown()), "text/markdown; charset=utf-8", "md" + o := exportOptsFromQuery(r.URL.Query()) + body, ctype, ext, err := exportBody(o) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return } w.Header().Set("Content-Type", ctype) w.Header().Set("Content-Disposition", `attachment; filename="`+exportFilename(ext)+`"`) diff --git a/internal/ajean/chat_export_test.go b/internal/ajean/chat_export_test.go index 2160f3c..1d0c0ad 100644 --- a/internal/ajean/chat_export_test.go +++ b/internal/ajean/chat_export_test.go @@ -34,7 +34,7 @@ func convDeTest(t *testing.T) { func TestExportMarkdownPorteToutLeFil(t *testing.T) { convDeTest(t) - md := conv.ExportMarkdown() + md := conv.ExportMarkdown(defaultExportOpts()) for _, want := range []string{ "## Vous", "combien font 2+2 ?", "## AJEAN", @@ -54,7 +54,7 @@ func TestExportMarkdownPorteToutLeFil(t *testing.T) { func TestExportJSONRelisible(t *testing.T) { convDeTest(t) - b, err := conv.ExportJSON() + b, err := conv.ExportJSON(defaultExportOpts()) if err != nil { t.Fatal(err) } @@ -96,6 +96,114 @@ func TestHandleChatExportEnPieceJointe(t *testing.T) { } } +// Les options taillent l'export, et un export tronqué le DIT (sans quoi, relu +// plus tard, il passerait pour le fil complet). +func TestExportOptionsTaillentLeMarkdown(t *testing.T) { + convDeTest(t) + o := defaultExportOpts() + o.Reasoning = false + md := conv.ExportMarkdown(o) + if strings.Contains(md, "addition simple") { + t.Error("raisonnement présent malgré --no-reasoning") + } + if !strings.Contains(md, "Export partiel") || !strings.Contains(md, "raisonnements retirés") { + t.Errorf("export allégé non signalé :\n%s", md) + } + if !strings.Contains(md, "echo 4") { + t.Error("les outils ont disparu alors que seule la réflexion était exclue") + } + + o = defaultExportOpts() + o.Results = false + if md := conv.ExportMarkdown(o); strings.Contains(md, "```") { + t.Error("sortie d'outil présente malgré --no-results") + } + + // Couper les outils coupe forcément leurs sorties : la sortie n'a plus de + // bulle où s'accrocher. + o = exportOptsFromQuery(map[string][]string{"tools": {"0"}, "results": {"1"}}) + if o.Results { + t.Error("results resté actif alors que tools est coupé") + } + md = conv.ExportMarkdown(o) + if strings.Contains(md, "echo 4") { + t.Error("outil présent malgré tools=0") + } +} + +// Sans paramètre, l'endpoint reste l'export COMPLET : les options ne doivent pas +// silencieusement appauvrir ceux qui ne les connaissent pas. +func TestExportOptsFromQueryDefautComplet(t *testing.T) { + o := exportOptsFromQuery(map[string][]string{}) + if o != defaultExportOpts() { + t.Fatalf("défauts modifiés : %+v", o) + } + o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}, "full": {"0"}}) + if o.Format != "json" || o.Turns != 2 || o.JSONFull { + t.Fatalf("options mal lues : %+v", o) + } + // Une valeur de tours absurde ne doit pas vider l'export. + if o := exportOptsFromQuery(map[string][]string{"turns": {"-3"}}); o.Turns != 0 { + t.Fatalf("turns=-3 accepté : %d", o.Turns) + } +} + +func TestExportDerniersEchanges(t *testing.T) { + convDeTest(t) + // Un deuxième échange s'ajoute au fil de test. + conv.mu.Lock() + conv.Messages = append(conv.Messages, + Message{Role: "user", Content: "et 3+3 ?"}, Message{Role: "assistant", Content: "6"}) + conv.Log = append(conv.Log, + LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}}, + LogEvent{Seq: 7, TS: 7, Delta: map[string]any{"content": "6"}}) + conv.mu.Unlock() + + o := defaultExportOpts() + o.Turns = 1 + md := conv.ExportMarkdown(o) + if strings.Contains(md, "2+2") { + t.Errorf("le premier échange est encore là avec turns=1 :\n%s", md) + } + if !strings.Contains(md, "3+3") || !strings.Contains(md, "1 derniers échanges") { + t.Errorf("dernier échange absent ou non signalé :\n%s", md) + } + // Demander plus d'échanges qu'il n'en existe rend tout le fil, sans erreur. + o.Turns = 99 + if md := conv.ExportMarkdown(o); !strings.Contains(md, "2+2") { + t.Error("turns supérieur au nombre d'échanges a tronqué le fil") + } + // Même découpe côté JSON. + o = defaultExportOpts() + o.Turns = 1 + b, err := conv.ExportJSON(o) + if err != nil { + t.Fatal(err) + } + var p exportPayload + if err := json.Unmarshal(b, &p); err != nil { + t.Fatal(err) + } + if len(p.Messages) != 2 || p.Messages[0].Content != "et 3+3 ?" { + t.Fatalf("messages mal découpés : %+v", p.Messages) + } + // --messages-only laisse tomber le journal. ⚠️ Structure NEUVE : Unmarshal ne + // remet pas à zéro les champs absents du JSON, un `p` réutilisé garderait le + // journal de l'appel précédent et le test passerait pour de mauvaises raisons. + o.JSONFull = false + b, _ = conv.ExportJSON(o) + var sansLog exportPayload + if err := json.Unmarshal(b, &sansLog); err != nil { + t.Fatal(err) + } + if len(sansLog.Log) != 0 { + t.Fatalf("journal présent malgré --messages-only : %d événements", len(sansLog.Log)) + } + if len(sansLog.Messages) == 0 { + t.Fatal("messages perdus avec --messages-only") + } +} + func TestCmdExportEcritLeFichier(t *testing.T) { testHome(t) convDeTest(t) diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go index 57f8b21..c3e4cfe 100644 --- a/internal/ajean/chat_memory.go +++ b/internal/ajean/chat_memory.go @@ -10,7 +10,7 @@ import ( "strings" ) -// La mémoire de ajean = des fichiers Markdown plats sous MEMORY/.md. +// La mémoire de ajean = des fichiers Markdown plats sous memory/.md. // L'IA y range ce qu'elle veut retenir entre les sessions (préférences, // décisions, procédures, infos projet). Quatre outils : mem_search, mem_read, // mem_add, mem_edit. diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go index ae89c85..76a0743 100644 --- a/internal/ajean/chat_tools.go +++ b/internal/ajean/chat_tools.go @@ -54,7 +54,7 @@ func baseSystemPrompt(caps Caps) string { b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n") } if hasMem { - b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under MEMORY/.\n") + b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under memory/.\n") } // Politique d'usage de la mémoire selon le mode. switch caps.Mem { diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index 15de10e..e987bf7 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -56,7 +56,7 @@ func memSearchTool() Tool { Type: "function", Function: ToolFunction{ Name: "mem_search", - Description: "Search your memory (Markdown pages under MEMORY/). Returns a ranked list of {file, title, snippet}. Use it FIRST when the user mentions something you might already know (preferences, ongoing projects, past decisions). Follow up with mem_read on the most relevant page.", + Description: "Search your memory (Markdown pages under memory/). Returns a ranked list of {file, title, snippet}. Use it FIRST when the user mentions something you might already know (preferences, ongoing projects, past decisions). Follow up with mem_read on the most relevant page.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 623dbe4..15fd425 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -144,8 +144,10 @@ Interface (ajean-ui) : Interaction: chat [system-prompt] chat terminal streamé - export [--json] [fichier] exporte la conversation de l'interface web + export [options] [fichier] exporte la conversation de l'interface web (Markdown par défaut, « - » = sortie standard) + --json --last N --no-reasoning --no-tools + --no-results --messages-only agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire) memory [off|ondemand|always|status] mode mémoire de l'IA internet [on|off|status|engine |url |key ] diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index f7a1efc..5fafaf7 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1341,18 +1341,20 @@ button:hover{border-color:var(--dim);color:var(--text)}
    -
    - - - -
    - - + + + +
    + +
    +
    @@ -1404,6 +1406,51 @@ button:hover{border-color:var(--dim);color:var(--text)} + +
    @@ -357,6 +359,51 @@ + + @@ -2482,7 +2514,24 @@ function renderApiKey(d){ } async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } // --- Export de la conversation --------------------------------------------- -function openExportModal(){ onExportFormat(); showModal('export-modal'); } +// Taille d'un export, en français et avec une décimale. fmtSize convient aux +// modèles (des Go, où l'arrondi ne se voit pas) mais pas ici : un fil de 2585 +// octets s'y affichait « 3 KB », soit une taille fausse dans le seul endroit de +// l'interface qui prétend annoncer la taille réelle. +function fmtExportSize(b){ + b = Number(b) || 0; + if(b < 1024) return b + ' o'; + if(b < 1024*1024) return (b/1024).toFixed(1).replace('.', ',') + ' Ko'; + return (b/(1024*1024)).toFixed(1).replace('.', ',') + ' Mo'; +} +// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Connu +// seulement après la sonde ; d'ici là le curseur est désactivé plutôt que faux. +let xTurnsTotal = 0; +function openExportModal(){ + onExportFormat(); + showModal('export-modal'); + probeExport(); // remplit la borne du curseur et la taille réelle +} function closeExportModal(){ hideModal('export-modal'); } // Le format décide des options AFFICHÉES : les réglages Markdown n'ont aucun // effet sur un JSON, les montrer grisés ne ferait qu'encombrer. @@ -2511,11 +2560,20 @@ function exportQuery(){ if(!on('x-tools')) p.set('tools','0'); if(!on('x-results')) p.set('results','0'); } - const t = document.getElementById('x-turns').value; - if(t !== '0') p.set('turns', t); + const t = exportTurns(); + if(t > 0) p.set('turns', String(t)); const q = p.toString(); return '/api/chat/export' + (q ? '?'+q : ''); } +// Valeur de portée à envoyer : 0 = tout le fil. La butée DROITE du curseur vaut +// « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se +// périmerait à l'échange suivant. +function exportTurns(){ + const el = document.getElementById('x-turns'); + const v = parseInt(el.value, 10) || 0; + if(!xTurnsTotal || v >= xTurnsTotal) return 0; + return v; +} // Dit en une ligne ce que l'export va contenir. Sans ça on découvre en ouvrant // le fichier que le raisonnement qu'on venait chercher n'y est pas. function onExportPreview(){ @@ -2524,9 +2582,8 @@ function onExportPreview(){ const results = document.getElementById('x-results'); results.disabled = !tools.checked; if(!tools.checked) results.checked = false; + paintExportRange(); const parts = []; - const t = document.getElementById('x-turns'); - parts.push(t.value === '0' ? 'toute la conversation' : t.options[t.selectedIndex].text); if(exportFormat() === 'json'){ parts.push(document.getElementById('x-full').checked ? 'messages + journal complet' : 'messages seuls'); } else { @@ -2537,6 +2594,61 @@ function onExportPreview(){ parts.push(off.length ? 'sans ' + off.join(' ni ') : 'tout le contenu'); } document.getElementById('x-note').textContent = parts.join(' · '); + probeExportSoon(); +} +// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque +// pixel de glissement, il ne doit RIEN demander au serveur. +function paintExportRange(){ + const el = document.getElementById('x-turns'); + const n = parseInt(el.value, 10) || 0; + const max = Math.max(1, xTurnsTotal); + // La pastille ne parcourt pas toute la largeur : elle va de THUMB/2 à + // largeur - THUMB/2. On coupe le remplissage dans SON repère, sinon la piste + // colorée déborde à côté d'elle aux extrémités (même calcul que la barre GPU). + const frac = max > 1 ? (n - 1) / (max - 1) : 1; + const fill = document.getElementById('x-fill'); + if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)'; + const scope = document.getElementById('x-scope'); + if(!scope) return; + if(!xTurnsTotal) scope.textContent = 'conversation vide'; + else if(n >= xTurnsTotal) scope.textContent = 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'; + else scope.textContent = n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal; +} +// Sonde la taille RÉELLE du fichier pour les options courantes. Le serveur rend +// l'export puis le jette : c'est peu coûteux, mais pas au point de le refaire à +// chaque pixel du curseur, d'où le délai. Un `seq` protège du désordre : sur un +// glissement rapide, une réponse en retard ne doit pas écraser la plus récente. +let xProbeTimer = null, xProbeSeq = 0; +function probeExportSoon(){ + document.getElementById('x-size').textContent = '…'; + // ⚠️ On périme TOUT DE SUITE la sonde en vol, sans attendre que la suivante + // parte. Sans ça, une réponse partie avant le changement d'options revenait + // écraser le « … » par la taille de l'état PRÉCÉDENT : le poids affiché + // retardait d'un cran sur le curseur, ce qui est pire que pas de chiffre. + xProbeSeq++; + clearTimeout(xProbeTimer); + xProbeTimer = setTimeout(probeExport, 250); +} +async function probeExport(){ + const seq = ++xProbeSeq; + const sep = exportQuery().includes('?') ? '&' : '?'; + try{ + const r = await jget(exportQuery() + sep + 'probe=1'); + if(seq !== xProbeSeq) return; // une sonde plus récente a pris la main + if(!r || !r.ok) throw new Error('probe'); + // La borne du curseur n'est connue qu'ici : on la pose (et on la garde en + // butée droite) avant de repeindre. + const el = document.getElementById('x-turns'); + const atMax = parseInt(el.value,10) >= (xTurnsTotal || 1); + xTurnsTotal = r.turns || 0; + el.max = String(Math.max(1, xTurnsTotal)); + el.disabled = xTurnsTotal < 2; // 0 ou 1 échange : rien à trancher + if(atMax || parseInt(el.value,10) > xTurnsTotal) el.value = el.max; + document.getElementById('x-size').textContent = fmtExportSize(r.size); + paintExportRange(); + }catch(e){ + if(seq === xProbeSeq) document.getElementById('x-size').textContent = ''; + } } async function runExport(){ const btn = document.getElementById('x-go'); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index bd5e4c9..edf7ef6 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -373,8 +373,13 @@
    Format
    - - + +
    + + +
    Contenu
    @@ -388,13 +393,19 @@
    Portée
    - + +
    +
    + +
    +
    + toute la conversation + … +
    diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index 3eed29f..3dcf710 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -252,7 +252,24 @@ function renderApiKey(d){ } async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } // --- Export de la conversation --------------------------------------------- -function openExportModal(){ onExportFormat(); showModal('export-modal'); } +// Taille d'un export, en français et avec une décimale. fmtSize convient aux +// modèles (des Go, où l'arrondi ne se voit pas) mais pas ici : un fil de 2585 +// octets s'y affichait « 3 KB », soit une taille fausse dans le seul endroit de +// l'interface qui prétend annoncer la taille réelle. +function fmtExportSize(b){ + b = Number(b) || 0; + if(b < 1024) return b + ' o'; + if(b < 1024*1024) return (b/1024).toFixed(1).replace('.', ',') + ' Ko'; + return (b/(1024*1024)).toFixed(1).replace('.', ',') + ' Mo'; +} +// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Connu +// seulement après la sonde ; d'ici là le curseur est désactivé plutôt que faux. +let xTurnsTotal = 0; +function openExportModal(){ + onExportFormat(); + showModal('export-modal'); + probeExport(); // remplit la borne du curseur et la taille réelle +} function closeExportModal(){ hideModal('export-modal'); } // Le format décide des options AFFICHÉES : les réglages Markdown n'ont aucun // effet sur un JSON, les montrer grisés ne ferait qu'encombrer. @@ -281,11 +298,20 @@ function exportQuery(){ if(!on('x-tools')) p.set('tools','0'); if(!on('x-results')) p.set('results','0'); } - const t = document.getElementById('x-turns').value; - if(t !== '0') p.set('turns', t); + const t = exportTurns(); + if(t > 0) p.set('turns', String(t)); const q = p.toString(); return '/api/chat/export' + (q ? '?'+q : ''); } +// Valeur de portée à envoyer : 0 = tout le fil. La butée DROITE du curseur vaut +// « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se +// périmerait à l'échange suivant. +function exportTurns(){ + const el = document.getElementById('x-turns'); + const v = parseInt(el.value, 10) || 0; + if(!xTurnsTotal || v >= xTurnsTotal) return 0; + return v; +} // Dit en une ligne ce que l'export va contenir. Sans ça on découvre en ouvrant // le fichier que le raisonnement qu'on venait chercher n'y est pas. function onExportPreview(){ @@ -294,9 +320,8 @@ function onExportPreview(){ const results = document.getElementById('x-results'); results.disabled = !tools.checked; if(!tools.checked) results.checked = false; + paintExportRange(); const parts = []; - const t = document.getElementById('x-turns'); - parts.push(t.value === '0' ? 'toute la conversation' : t.options[t.selectedIndex].text); if(exportFormat() === 'json'){ parts.push(document.getElementById('x-full').checked ? 'messages + journal complet' : 'messages seuls'); } else { @@ -307,6 +332,61 @@ function onExportPreview(){ parts.push(off.length ? 'sans ' + off.join(' ni ') : 'tout le contenu'); } document.getElementById('x-note').textContent = parts.join(' · '); + probeExportSoon(); +} +// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque +// pixel de glissement, il ne doit RIEN demander au serveur. +function paintExportRange(){ + const el = document.getElementById('x-turns'); + const n = parseInt(el.value, 10) || 0; + const max = Math.max(1, xTurnsTotal); + // La pastille ne parcourt pas toute la largeur : elle va de THUMB/2 à + // largeur - THUMB/2. On coupe le remplissage dans SON repère, sinon la piste + // colorée déborde à côté d'elle aux extrémités (même calcul que la barre GPU). + const frac = max > 1 ? (n - 1) / (max - 1) : 1; + const fill = document.getElementById('x-fill'); + if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)'; + const scope = document.getElementById('x-scope'); + if(!scope) return; + if(!xTurnsTotal) scope.textContent = 'conversation vide'; + else if(n >= xTurnsTotal) scope.textContent = 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')'; + else scope.textContent = n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal; +} +// Sonde la taille RÉELLE du fichier pour les options courantes. Le serveur rend +// l'export puis le jette : c'est peu coûteux, mais pas au point de le refaire à +// chaque pixel du curseur, d'où le délai. Un `seq` protège du désordre : sur un +// glissement rapide, une réponse en retard ne doit pas écraser la plus récente. +let xProbeTimer = null, xProbeSeq = 0; +function probeExportSoon(){ + document.getElementById('x-size').textContent = '…'; + // ⚠️ On périme TOUT DE SUITE la sonde en vol, sans attendre que la suivante + // parte. Sans ça, une réponse partie avant le changement d'options revenait + // écraser le « … » par la taille de l'état PRÉCÉDENT : le poids affiché + // retardait d'un cran sur le curseur, ce qui est pire que pas de chiffre. + xProbeSeq++; + clearTimeout(xProbeTimer); + xProbeTimer = setTimeout(probeExport, 250); +} +async function probeExport(){ + const seq = ++xProbeSeq; + const sep = exportQuery().includes('?') ? '&' : '?'; + try{ + const r = await jget(exportQuery() + sep + 'probe=1'); + if(seq !== xProbeSeq) return; // une sonde plus récente a pris la main + if(!r || !r.ok) throw new Error('probe'); + // La borne du curseur n'est connue qu'ici : on la pose (et on la garde en + // butée droite) avant de repeindre. + const el = document.getElementById('x-turns'); + const atMax = parseInt(el.value,10) >= (xTurnsTotal || 1); + xTurnsTotal = r.turns || 0; + el.max = String(Math.max(1, xTurnsTotal)); + el.disabled = xTurnsTotal < 2; // 0 ou 1 échange : rien à trancher + if(atMax || parseInt(el.value,10) > xTurnsTotal) el.value = el.max; + document.getElementById('x-size').textContent = fmtExportSize(r.size); + paintExportRange(); + }catch(e){ + if(seq === xProbeSeq) document.getElementById('x-size').textContent = ''; + } } async function runExport(){ const btn = document.getElementById('x-go'); diff --git a/internal/ajean/ui/src/styles.css b/internal/ajean/ui/src/styles.css index 1f8f7a6..7a5d7a0 100644 --- a/internal/ajean/ui/src/styles.css +++ b/internal/ajean/ui/src/styles.css @@ -1046,3 +1046,24 @@ button:hover{border-color:var(--dim);color:var(--text)} /* Sélecteurs segmentés : noir plein quand c'est coché, pas de couleur. */ .pe-seg>label:has(input:checked),.seg>label:has(input:checked){background:var(--text);border-color:var(--text);color:var(--bg)} + +/* Curseur de portée de l'export. Même vocabulaire visuel que la répartition GPU + (piste arrondie + pastille cerclée), mais une seule valeur : la position tout + à droite vaut « toute la conversation ». Le libellé sous la piste donne le + compte RÉEL d'échanges et le poids du fichier, la question qu'on se pose + vraiment avant d'exporter. */ +.xr{position:relative;display:flex;align-items:center;height:22px} +.xr-track{width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)} +.xr-fill{height:100%;background:var(--accent);border-radius:999px;transition:width .12s} +.xr-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize} +.xr-range:focus{outline:none} +.xr-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none} +.xr-range::-moz-range-track{height:22px;background:transparent;border:none} +.xr-range::-moz-range-progress{background:transparent} +.xr-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.xr-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)} +.xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)} +.xr-range:disabled{cursor:default;opacity:.5} +.xr-legend{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-top:8px;font-size:12px} +.xr-legend .xr-size{color:var(--dim);font-size:11px;white-space:nowrap;font-variant-numeric:tabular-nums} From 4a66b2722752ff73489234ff7f3d60cbf1b74321 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 8 Aug 2026 20:56:11 +0200 Subject: [PATCH 264/279] Export : memes options quel que soit le format, curseur simple, etat vide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois choses ne tenaient pas debout dans la fenetre. Les options CHANGEAIENT selon le format : le JSON proposait un obscur « journal d'affichage » a la place des trois cases du Markdown. Personne ne pouvait deviner ce que ca recouvrait, et cocher une case n'avait pas le meme sens selon le format choisi juste au-dessus. Les trois memes options valent desormais pour les deux ; le format ne decide que du contenant. Cote JSON, le journal est filtre par les memes criteres. La taille du fichier annoncee sous le curseur ne servait a rien et justifiait a elle seule une sonde cote serveur. Supprimee, sonde comprise : le nombre d'echanges vient maintenant de /api/chat/state, qui est deja l'instantane leger de la conversation. La fenetre reprend le vocabulaire visuel de l'editeur de preset (modal-card, pe-group, pe-row, interrupteurs pe-switch) au lieu de son balisage bricole, et chaque option porte son sous-titre. Conversation vide : un simple « Rien a exporter » remplace les reglages, plutot que d'offrir de tailler un fichier qui serait vide de toute facon. --- internal/ajean/chat_conversation.go | 11 +- internal/ajean/chat_export.go | 100 +++++----- internal/ajean/chat_export_test.go | 76 +++----- internal/ajean/run.go | 4 +- internal/ajean/ui/index.html | 232 ++++++++++-------------- internal/ajean/ui/src/index.tmpl.html | 102 ++++++----- internal/ajean/ui/src/js/06-settings.js | 125 ++++--------- internal/ajean/ui/src/styles.css | 5 +- 8 files changed, 284 insertions(+), 371 deletions(-) diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index a42250e..cac150f 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -243,7 +243,16 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s func (c *Conversation) state() map[string]any { c.mu.Lock() defer c.mu.Unlock() - return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed} + // `turns` = nombre d'échanges, borne du curseur de portée de l'export (voir + // countTurns). Compté ici plutôt que par un appel dédié : c'est un balayage + // du journal déjà en main, et l'état est de toute façon relu à l'ouverture. + turns := 0 + for _, ev := range c.Log { + if _, ok := ev.Delta["user"]; ok { + turns++ + } + } + return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns} } // ErrBusy : une génération est déjà en cours (un seul tour à la fois). diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go index 98cdfc3..75de2c3 100644 --- a/internal/ajean/chat_export.go +++ b/internal/ajean/chat_export.go @@ -20,24 +20,24 @@ import ( // personne ne peut les récupérer. C'est une régression pour qui relisait ses // échanges, et ce fichier la répare. // -// Deux formats : le Markdown est fait pour être LU (et se taille avec des -// options — un fil relu pour retrouver une réponse n'a que faire des mille -// lignes de raisonnement qui l'ont produite), le JSON est fait pour être -// RETRAITÉ et reste fidèle par défaut. +// Deux formats pour un même contenu : le Markdown est fait pour être LU, le +// JSON pour être RETRAITÉ. Les options de contenu (raisonnements, outils, +// portée) sont les MÊMES pour les deux — le format ne choisit que le +// contenant. Un fil relu pour retrouver une réponse n'a que faire des mille +// lignes de raisonnement qui l'ont produite, et ça vaut dans les deux formats. // exportOpts décrit ce qu'on veut sortir. Le zéro de la structure n'est PAS un // défaut utilisable (tout serait à false) : passer par defaultExportOpts. type exportOpts struct { Format string // "md" | "json" - Reasoning bool // Markdown : inclure les blocs de raisonnement - Tools bool // Markdown : inclure les appels d'outils - Results bool // Markdown : inclure la sortie des outils (implique Tools) + Reasoning bool // inclure les blocs de raisonnement + Tools bool // inclure les appels d'outils + Results bool // inclure la sortie des outils (exige Tools) Turns int // 0 = tout le fil ; N = les N derniers échanges - JSONFull bool // JSON : joindre le journal d'affichage en plus des messages } func defaultExportOpts() exportOpts { - return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true, JSONFull: true} + return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true} } // exportOptsFromQuery lit les options d'une requête. Toute clé absente garde sa @@ -64,7 +64,6 @@ func exportOptsFromQuery(q map[string][]string) exportOpts { o.Reasoning = flag("reasoning", o.Reasoning) o.Tools = flag("tools", o.Tools) o.Results = flag("results", o.Results) - o.JSONFull = flag("full", o.JSONFull) if v, ok := get("turns"); ok { if n, err := strconv.Atoi(v); err == nil && n > 0 { o.Turns = n @@ -105,13 +104,52 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) { ExportedAt: time.Now().Format(time.RFC3339), CtxUsed: ctxUsed, Messages: msgs, - } - if o.JSONFull { - p.Log = log + Log: filterLog(log, o), } return json.MarshalIndent(p, "", " ") } +// filterLog applique au journal les MÊMES cases que le Markdown. Les deux +// formats avaient auparavant des options distinctes (le JSON proposait un +// obscur « journal d'affichage » à la place des trois autres) : personne ne +// pouvait deviner ce que ça changeait, et cocher une case n'avait pas le même +// sens selon le format choisi juste au-dessus. Le format ne décide plus que du +// contenant ; ce qu'on emporte se règle une fois pour toutes. +func filterLog(log []LogEvent, o exportOpts) []LogEvent { + if o.Reasoning && o.Tools && o.Results { + return log + } + out := make([]LogEvent, 0, len(log)) + for _, ev := range log { + if _, ok := ev.Delta["reasoning_content"]; ok && !o.Reasoning { + continue + } + if tu, ok := ev.Delta["tool_used"].(map[string]any); ok { + if !o.Tools { + continue + } + if !o.Results { + // Copie : l'événement appartient à la conversation vivante, le + // modifier en place amputerait le fil affiché dans les navigateurs. + cp := make(map[string]any, len(tu)) + for k, v := range tu { + if k != "result" { + cp[k] = v + } + } + d := make(map[string]any, len(ev.Delta)) + for k, v := range ev.Delta { + d[k] = v + } + d["tool_used"] = cp + ev.Delta = d + } + } + out = append(out, ev) + } + return out +} + // lastTurnsMessages garde les n derniers échanges de la vue modèle, un échange // commençant à un message `user`. func lastTurnsMessages(msgs []Message, n int) []Message { @@ -282,7 +320,7 @@ func exportBody(o exportOpts) ([]byte, string, string, error) { // // Options de contenu, les mêmes que la fenêtre d'export de l'interface : // -// --no-reasoning --no-tools --no-results --last N --messages-only +// --no-reasoning --no-tools --no-results --last N // // La conversation est relue depuis la base à chaque appel : la commande marche // pendant que le service tourne (bbolt n'est jamais gardé ouvert, voir store.go). @@ -302,8 +340,6 @@ func cmdExport(args []string) error { o.Tools, o.Results = false, false case "--no-results": o.Results = false - case "--messages-only": - o.JSONFull = false case "--last": if i+1 >= len(args) { return fmt.Errorf("--last attend un nombre d'échanges") @@ -352,40 +388,8 @@ func cmdExport(args []string) error { return nil } -// countTurns compte les échanges du journal d'affichage (un échange = un -// message utilisateur). C'est la borne du curseur de portée dans l'interface : -// proposer « 25 derniers échanges » sur un fil qui en compte trois n'avait -// aucun sens, et ne disait pas non plus combien il y en avait. -func (c *Conversation) countTurns() int { - c.mu.Lock() - defer c.mu.Unlock() - n := 0 - for _, ev := range c.Log { - if _, ok := ev.Delta["user"]; ok { - n++ - } - } - return n -} - -// handleChatExport sert la conversation en téléchargement. Options en paramètres -// d'URL (voir exportOptsFromQuery) ; sans aucun paramètre, c'est l'export complet. -// -// Avec `probe=1`, on ne renvoie PAS le fichier mais sa taille et le nombre -// d'échanges disponibles : l'interface annonce ainsi le poids réel avant de -// télécharger. Le rendu est fait puis jeté, ce qui coûte quelques millisecondes -// et évite d'entretenir un second calcul de taille qui finirait par mentir. func handleChatExport(w http.ResponseWriter, r *http.Request) { o := exportOptsFromQuery(r.URL.Query()) - if r.URL.Query().Get("probe") == "1" { - body, _, _, err := exportBody(o) - if err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - sendJSON(w, 200, map[string]any{"ok": true, "size": len(body), "turns": conv.countTurns()}) - return - } body, ctype, ext, err := exportBody(o) if err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) diff --git a/internal/ajean/chat_export_test.go b/internal/ajean/chat_export_test.go index cd56c49..5a446ad 100644 --- a/internal/ajean/chat_export_test.go +++ b/internal/ajean/chat_export_test.go @@ -138,8 +138,8 @@ func TestExportOptsFromQueryDefautComplet(t *testing.T) { if o != defaultExportOpts() { t.Fatalf("défauts modifiés : %+v", o) } - o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}, "full": {"0"}}) - if o.Format != "json" || o.Turns != 2 || o.JSONFull { + o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}}) + if o.Format != "json" || o.Turns != 2 { t.Fatalf("options mal lues : %+v", o) } // Une valeur de tours absurde ne doit pas vider l'export. @@ -187,60 +187,44 @@ func TestExportDerniersEchanges(t *testing.T) { if len(p.Messages) != 2 || p.Messages[0].Content != "et 3+3 ?" { t.Fatalf("messages mal découpés : %+v", p.Messages) } - // --messages-only laisse tomber le journal. ⚠️ Structure NEUVE : Unmarshal ne - // remet pas à zéro les champs absents du JSON, un `p` réutilisé garderait le - // journal de l'appel précédent et le test passerait pour de mauvaises raisons. - o.JSONFull = false + // Les cases de contenu valent AUSSI pour le JSON : le format ne choisit que le + // contenant. ⚠️ Structure NEUVE à chaque Unmarshal : les champs absents du JSON + // ne sont pas remis à zéro, un `p` réutilisé garderait le journal précédent et + // le test passerait pour de mauvaises raisons. + o = defaultExportOpts() + o.Reasoning = false b, _ = conv.ExportJSON(o) - var sansLog exportPayload - if err := json.Unmarshal(b, &sansLog); err != nil { + var sansRaison exportPayload + if err := json.Unmarshal(b, &sansRaison); err != nil { t.Fatal(err) } - if len(sansLog.Log) != 0 { - t.Fatalf("journal présent malgré --messages-only : %d événements", len(sansLog.Log)) + for _, ev := range sansRaison.Log { + if _, ok := ev.Delta["reasoning_content"]; ok { + t.Fatal("raisonnement présent dans le JSON malgré l'option coupée") + } } - if len(sansLog.Messages) == 0 { - t.Fatal("messages perdus avec --messages-only") + if len(sansRaison.Messages) == 0 || len(sansRaison.Log) == 0 { + t.Fatal("le JSON a été vidé alors que seule la réflexion était exclue") } } -// La sonde alimente le curseur de portée et la taille annoncée : elle doit -// rendre la taille EXACTE du fichier qui serait téléchargé avec les mêmes -// options, sinon l'interface annonce un poids qu'elle ne tient pas. -func TestHandleChatExportProbe(t *testing.T) { +// Le curseur de portée est borné par le nombre d'échanges publié dans l'état de +// la conversation : sans lui, l'interface ne saurait pas jusqu'où aller. +func TestChatStatePublieLeNombreDEchanges(t *testing.T) { convDeTest(t) - probe := func(q string) (size, turns int) { - rr := httptest.NewRecorder() - handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?probe=1&"+q, nil)) - if rr.Code != 200 { - t.Fatalf("%s : HTTP %d", q, rr.Code) - } - var out struct { - Size int `json:"size"` - Turns int `json:"turns"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { - t.Fatal(err) - } - return out.Size, out.Turns + if n, _ := conv.state()["turns"].(int); n != 1 { + t.Fatalf("échanges = %v ; le fil de test en a 1", conv.state()["turns"]) } - download := func(q string) int { - rr := httptest.NewRecorder() - handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?"+q, nil)) - return rr.Body.Len() + conv.mu.Lock() + conv.Log = append(conv.Log, LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}}) + conv.mu.Unlock() + if n, _ := conv.state()["turns"].(int); n != 2 { + t.Fatalf("échanges = %v après un second tour", conv.state()["turns"]) } - for _, q := range []string{"", "reasoning=0", "format=json", "turns=1"} { - if s, _ := probe(q); s != download(q) { - t.Errorf("q=%q : sonde %d o, téléchargement %d o", q, s, download(q)) - } - } - // Le compte d'échanges ne dépend PAS des options : c'est la borne du curseur, - // elle ne doit pas rétrécir quand on réduit la portée. - if _, n := probe("turns=1"); n != 1 { - t.Errorf("échanges comptés = %d ; le fil de test en a 1", n) - } - if got := conv.countTurns(); got != 1 { - t.Errorf("countTurns = %d", got) + // Conversation vide : zéro échange, c'est ce qui déclenche « rien à exporter ». + conv.Reset() + if n, _ := conv.state()["turns"].(int); n != 0 { + t.Fatalf("échanges = %v sur un fil vide", conv.state()["turns"]) } } diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 15fd425..c64b96e 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -146,8 +146,8 @@ Interaction: chat [system-prompt] chat terminal streamé export [options] [fichier] exporte la conversation de l'interface web (Markdown par défaut, « - » = sortie standard) - --json --last N --no-reasoning --no-tools - --no-results --messages-only + --json --last N --no-reasoning + --no-tools --no-results agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire) memory [off|ondemand|always|status] mode mémoire de l'IA internet [on|off|status|engine |url |key ] diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index d11f0e4..5f6fff8 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1091,8 +1091,9 @@ button:hover{border-color:var(--dim);color:var(--text)} .xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)} .xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)} .xr-range:disabled{cursor:default;opacity:.5} -.xr-legend{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-top:8px;font-size:12px} -.xr-legend .xr-size{color:var(--dim);font-size:11px;white-space:nowrap;font-variant-numeric:tabular-nums} +/* Conversation vide : un seul message centré à la place des réglages. */ +.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px} +.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7} -
    Actions
    +
    -
    -
    - -
    - -
    - +
    +
    +
    diff --git a/internal/ajean/ui/src/js/05-status.js b/internal/ajean/ui/src/js/05-status.js index 6fd3739..f8184b3 100644 --- a/internal/ajean/ui/src/js/05-status.js +++ b/internal/ajean/ui/src/js/05-status.js @@ -3,9 +3,12 @@ async function loadStatus(){ const el=document.getElementById('status-svc'); // Trois états : service coupé (err) · service actif mais modèle pas encore // chargé (loading, llama-server renvoie 503) · modèle prêt (ok). - let cls='err', txt=s.state; + // Pastille COURTE et honnête. Le détail (cause exacte) va dans #model-err + // dessous : la pastille ne doit pas affirmer « incompatible » quand l'échec + // peut être tout autre chose. + let cls='err', txt='arrêté'; if(s.active && s.health){ cls='ok'; txt='prêt'; } - else if(s.load_error){ cls='err'; txt='modèle incompatible'; } + else if(s.load_error){ cls='err'; txt='erreur'; } else if(s.active){ cls='loading'; txt='chargement…'; } el.className='statuspill '+cls; el.innerHTML=''+txt; diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 4bf5f6a..1f8ab55 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -74,7 +74,7 @@ const KINDS = { // skills keep name-as-identity (param 'name'). // newLabel / nameHint : « Nouveau Page » et « Nom du preset » dans l'éditeur de // mémoire venaient d'un libellé unique décliné mécaniquement. - preset: {label:'Preset', newLabel:'Nouveau preset', nameHint:'Nom du preset', + preset: {label:'', newLabel:'Nouveau preset', nameHint:'Nom du preset', param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, mem: {label:'Page', newLabel:'Nouvelle page', nameHint:'Nom de la page', param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()}, @@ -93,7 +93,7 @@ async function openItem(kind, key){ const K = KINDS[kind]; const seq = ++openSeq; editingKind = kind; editingKey = key || ''; - document.getElementById('modal-title').textContent = key ? (K.label + ' · ' + key) : K.newLabel; + document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + key : key) : K.newLabel; document.getElementById('m-name').value = key || ''; document.getElementById('m-name').placeholder = K.nameHint; document.getElementById('m-content').value = ''; @@ -145,7 +145,7 @@ async function openItem(kind, key){ const d = await r.json(); if(seq !== openSeq) return; // une autre ouverture a pris la main const display = d.name || key || ''; - document.getElementById('modal-title').textContent = key ? (K.label + ' · ' + display) : K.newLabel; + document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + display : display) : K.newLabel; document.getElementById('m-name').value = display; document.getElementById('m-content').value = d.content || ''; if(kind === 'preset'){ diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 3d7c4be..060911e 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -102,8 +102,19 @@ func modelLoadError() string { if loaded || reason == "" { return "" } - return "Le modèle n'a pas pu être chargé : " + reason + - ". Il est probablement incompatible avec le moteur sélectionné — choisis un autre backend pour ce modèle (édite le modèle → section Moteur)." + // Message PRÉCIS selon la cause : n'affirmer l'incompatibilité que quand le + // journal la montre vraiment (quant/architecture). Un échec générique de + // chargement peut avoir bien d'autres causes (mémoire, fichier, tenseur) — on + // renvoie alors vers le journal plutôt que d'accuser à tort le moteur. + switch reason { + case "format de quantification non reconnu par ce moteur", + "type/architecture de modèle inconnu de ce moteur": + return "Modèle incompatible avec le moteur : " + reason + + ". Choisis un autre backend (édite le modèle → Moteur)." + default: + return "Le modèle n'a pas pu être chargé (" + reason + + "). Ouvre le journal du moteur pour le détail." + } } // handleServiceLog (GET /api/service/log) : dernières lignes du journal du From aeca75c5479c31a9dbd30f6442a1872dddc36b20 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 14 Aug 2026 15:11:08 +0200 Subject: [PATCH 279/279] =?UTF-8?q?v0.9.4=20:=20la=20vision=20configurable?= =?UTF-8?q?=20par=20preset=20(projecteur=20mmproj)=20et=20les=20images=20e?= =?UTF-8?q?nvoy=C3=A9es=20au=20modele?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RELEASE_NOTES.md | 20 +++++--- cmd/ajean/resource_windows_amd64.syso | Bin 7544 -> 7544 bytes cmd/ajean/resource_windows_arm64.syso | Bin 7544 -> 7544 bytes cmd/ajean/versioninfo.json | 6 +-- internal/ajean/backend_serve.go | 17 +++++++ internal/ajean/chat_compact.go | 30 +++++++++-- internal/ajean/chat_conversation.go | 4 +- internal/ajean/run.go | 2 +- internal/ajean/ui/index.html | 70 +++++++++++++++++++++++--- internal/ajean/ui/src/index.tmpl.html | 6 ++- internal/ajean/ui/src/js/07-models.js | 64 ++++++++++++++++++++--- internal/ajean/web_upload.go | 66 ++++++++++++++++++++++++ 12 files changed, 254 insertions(+), 31 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ccad0c7..0faa042 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,13 +1,19 @@ -Quelques réglages d'interface pour une lecture plus claire, et un statut du moteur enfin honnête. +La grande nouveauté de cette version : AJEAN sait enfin voir les images. -## Un statut du moteur plus juste +## La vision, configurable par preset -La pastille d'état affichait « modèle incompatible » dès qu'un chargement échouait — même quand la vraie cause était ailleurs. Désormais elle reste courte et neutre (**prêt**, **chargement…**, **erreur**, **arrêté**), et le message détaillé en dessous n'accuse le moteur d'incompatibilité **que lorsque le journal le montre vraiment** (quantification ou architecture non reconnue). Pour tout autre échec, il renvoie simplement au journal du moteur au lieu de donner une fausse piste. +Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul. -## Panneau « Actions » réorganisé +Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision. -Le bouton « refresh », qui ne servait à rien, est retiré. Les actions sont regroupées deux par deux : **mise à jour** et **exporter** sur une ligne, **bench** et **effacer la conversation** sur l'autre. +## Les images arrivent vraiment au modèle -## Petits nettoyages +Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler. -Le titre de l'éditeur de preset n'affiche plus le mot « Preset » redondant (juste le nom), et le mode mémoire automatique s'appelle simplement « auto ». +## Mise à jour + +``` +ajean update +``` + +Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 732983b242ca471a350c2097df869be336786452..f07048d3014ce345ac7100683dc8e531214f6e54 100644 GIT binary patch delta 35 ocmexi^}}idmn0_(11AFruxu8Vlw@Kw*{mxa%*bdmxl!gj0Fz?~EC2ui delta 35 ocmexi^}}idmn0`M11AFrFmD!?lw@Kw-mEJf%*bdwxl!gj0FxvLC;$Ke diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index 8cd1a12f624222179b352b4ddb38d0ee78ce521c..c92084b559ae0eb500a450bc6fabbdecce8f957e 100644 GIT binary patch delta 35 ocmexi^}}idmn0_(11AFruxu8Vlw@Kw*{mxa%*bdmxl!gj0Fz?~EC2ui delta 35 ocmexi^}}idmn0`M11AFrFmD!?lw@Kw-mEJf%*bdwxl!gj0FxvLC;$Ke diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 6760f6b..63296b7 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 9, - "Patch": 3, + "Patch": 4, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 9, - "Patch": 3, + "Patch": 4, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.9.3", + "ProductVersion": "0.9.4", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index 5d2e767..ebab289 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -116,6 +116,23 @@ func cmdServe(args []string) error { if vtv != "" { llmArgs = append(llmArgs, "-ctv", vtv) } + // Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle. + // C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout + // comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin + // absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que + // le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier. + // Introuvable = on préfère le dire clairement plutôt que laisser llama-server + // mourir en boucle sur un « failed to load mmproj » cryptique. + if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" { + mmPath, err := resolveServeModelPath(mm) + if err != nil { + return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err) + } + if _, err := os.Stat(mmPath); err != nil { + return fmt.Errorf("projecteur vision introuvable : %s", mmPath) + } + llmArgs = append(llmArgs, "--mmproj", mmPath) + } // Raisonnement. Trois cas, et la nuance compte : // // REASONING=on|auto|deepseek → --reasoning diff --git a/internal/ajean/chat_compact.go b/internal/ajean/chat_compact.go index 21bbf18..f5903a8 100644 --- a/internal/ajean/chat_compact.go +++ b/internal/ajean/chat_compact.go @@ -78,10 +78,34 @@ func ctxWindow() int { } // msgText extrait le texte d'un message (Content est `any`, en pratique string -// ou nil quand l'assistant n'a que des tool_calls). +// ou nil quand l'assistant n'a que des tool_calls). Un message multimodal +// (userMessageContent : parties texte + image quand la vision est active) porte +// un tableau de parties ; on en recolle les segments `text` pour que l'estimation +// de contexte et le transcript de compaction ne partent pas d'un texte vide. func msgText(m Message) string { - if s, ok := m.Content.(string); ok { - return s + switch v := m.Content.(type) { + case string: + return v + case []map[string]any: + var b strings.Builder + for _, part := range v { + if part["type"] == "text" { + if s, ok := part["text"].(string); ok { + b.WriteString(s) + } + } + } + return b.String() + case []any: // même contenu relu depuis le JSON persisté (map générique) + var b strings.Builder + for _, p := range v { + if part, ok := p.(map[string]any); ok && part["type"] == "text" { + if s, ok := part["text"].(string); ok { + b.WriteString(s) + } + } + } + return b.String() } return "" } diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index 4b745c4..e5edd14 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -287,7 +287,9 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem if strings.TrimSpace(prompt) == "" { prompt = "Prends-en connaissance." } - c.Messages = append(c.Messages, Message{Role: "user", Content: attachNote(files) + prompt}) + // Content = simple texte d'ordinaire ; format multimodal (texte + images) quand + // la vision est active et qu'une pièce jointe est une image (userMessageContent). + c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)}) epoch := c.epoch c.mu.Unlock() diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 536669e..95058ff 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.9.3" +const Version = "0.9.4" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index d7f6ef6..364c8dc 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1794,8 +1794,12 @@ button:hover{border-color:var(--dim);color:var(--text)} Quantizationauto si vide +
    + Visionprojecteur mmproj + +
    - Télécharger un modèle lien direct vers un .gguf + Télécharger un modèle ou un projecteur mmproj — lien direct vers un .gguf
    @@ -3096,7 +3100,7 @@ async function openItem(kind, key){ document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); attachDownload(); // téléchargement encore en cours côté serveur ? - await Promise.all([populateBackend(), populateModelPicker(), populateDlDirs()]); + await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]); if(seq !== openSeq) return; } // On lève le voile SANS transition (voir la note dans styles.css), puis on rend @@ -3258,6 +3262,50 @@ function onPickModel(){ toast('MODEL='+val); } +// --- Vision (projecteur multimodal --mmproj) -------------------------------- +// Le projecteur est un .gguf séparé du modèle : il apparaît donc dans la même +// liste que les modèles (/api/models). On le range dans sa PROPRE clé MMPROJ, +// que backend_serve.go traduit en --mmproj — plutôt que dans EXTRA_ARGS, pour +// que le chemin soit résolu comme celui du modèle (nom simple = cherché dans +// les dossiers déclarés). +function currentMmprojInTextarea(){ + // Clé dédiée d'abord ; à défaut, un --mmproj posé à la main dans EXTRA_ARGS + // (anciens presets), pour que le champ montre la vision déjà configurée. + return readEnvKey(document.getElementById('m-content').value, 'MMPROJ') + || eaGetValued('--mmproj'); +} +function onPickMmproj(){ + // On retire un éventuel --mmproj d'EXTRA_ARGS pour ne pas le charger deux fois, + // et on centralise le choix dans la clé MMPROJ. + eaSetValued('--mmproj', ''); + cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value); +} +// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la +// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là — +// mêler les modèles de plusieurs Go n'aiderait pas à choisir des yeux. +function isMmprojName(name){ return /mmproj/i.test(String(name||'')); } +async function populateMmproj(){ + const sel = document.getElementById('m-mmproj'); + if(!sel) return; + const list = await jget('/api/models'); + const cur = currentMmprojInTextarea(); + const items = (list||[]).filter(m => isMmprojName(m.name)); + let html = ''; + let matched = false; + for(const m of items){ + const on = samePath(cur, m.value) || samePath(cur, m.path) || + samePath(baseName(cur), m.name) ? ' selected' : ''; + if(on) matched = true; + html += ''; + } + // MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors + // convention) : on le garde affiché plutôt que de l'effacer silencieusement. + if(cur && !matched){ + html += ''; + } + sel.innerHTML = html; +} + // Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé) // qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel // backend fait tourner ce modèle — la barre latérale ne fait qu'installer. @@ -3834,12 +3882,18 @@ function watchDownload(fname){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); - await Promise.all([populateModelPicker(), populateDlDirs()]); - // Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le - // chemin complet, pas le simple nom de fichier. - const sel = document.getElementById('m-model'); - const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); - if(opt){ sel.value = opt.value; onPickModel(); } + await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]); + // Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans + // le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier + // a pu atterrir hors du dossier ajean : l'option porte alors le chemin + // complet, pas le simple nom de fichier. + const pick = (id, cb)=>{ + const s = document.getElementById(id); + const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname)); + if(o){ s.value = o.value; cb(); } + }; + if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj); + else pick('m-model', onPickModel); return; } const pct = st.total>0 ? st.done*100/st.total : 0; diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 88651c7..57970e4 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -624,8 +624,12 @@ Quantizationauto si vide
    +
    + Visionprojecteur mmproj + +
    - Télécharger un modèle lien direct vers un .gguf + Télécharger un modèle ou un projecteur mmproj — lien direct vers un .gguf
    diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 1f8ab55..da38866 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -153,7 +153,7 @@ async function openItem(kind, key){ document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); attachDownload(); // téléchargement encore en cours côté serveur ? - await Promise.all([populateBackend(), populateModelPicker(), populateDlDirs()]); + await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]); if(seq !== openSeq) return; } // On lève le voile SANS transition (voir la note dans styles.css), puis on rend @@ -315,6 +315,50 @@ function onPickModel(){ toast('MODEL='+val); } +// --- Vision (projecteur multimodal --mmproj) -------------------------------- +// Le projecteur est un .gguf séparé du modèle : il apparaît donc dans la même +// liste que les modèles (/api/models). On le range dans sa PROPRE clé MMPROJ, +// que backend_serve.go traduit en --mmproj — plutôt que dans EXTRA_ARGS, pour +// que le chemin soit résolu comme celui du modèle (nom simple = cherché dans +// les dossiers déclarés). +function currentMmprojInTextarea(){ + // Clé dédiée d'abord ; à défaut, un --mmproj posé à la main dans EXTRA_ARGS + // (anciens presets), pour que le champ montre la vision déjà configurée. + return readEnvKey(document.getElementById('m-content').value, 'MMPROJ') + || eaGetValued('--mmproj'); +} +function onPickMmproj(){ + // On retire un éventuel --mmproj d'EXTRA_ARGS pour ne pas le charger deux fois, + // et on centralise le choix dans la clé MMPROJ. + eaSetValued('--mmproj', ''); + cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value); +} +// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la +// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là — +// mêler les modèles de plusieurs Go n'aiderait pas à choisir des yeux. +function isMmprojName(name){ return /mmproj/i.test(String(name||'')); } +async function populateMmproj(){ + const sel = document.getElementById('m-mmproj'); + if(!sel) return; + const list = await jget('/api/models'); + const cur = currentMmprojInTextarea(); + const items = (list||[]).filter(m => isMmprojName(m.name)); + let html = ''; + let matched = false; + for(const m of items){ + const on = samePath(cur, m.value) || samePath(cur, m.path) || + samePath(baseName(cur), m.name) ? ' selected' : ''; + if(on) matched = true; + html += ''; + } + // MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors + // convention) : on le garde affiché plutôt que de l'effacer silencieusement. + if(cur && !matched){ + html += ''; + } + sel.innerHTML = html; +} + // Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé) // qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel // backend fait tourner ce modèle — la barre latérale ne fait qu'installer. @@ -891,12 +935,18 @@ function watchDownload(fname){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); - await Promise.all([populateModelPicker(), populateDlDirs()]); - // Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le - // chemin complet, pas le simple nom de fichier. - const sel = document.getElementById('m-model'); - const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); - if(opt){ sel.value = opt.value; onPickModel(); } + await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]); + // Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans + // le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier + // a pu atterrir hors du dossier ajean : l'option porte alors le chemin + // complet, pas le simple nom de fichier. + const pick = (id, cb)=>{ + const s = document.getElementById(id); + const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname)); + if(o){ s.value = o.value; cb(); } + }; + if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj); + else pick('m-model', onPickModel); return; } const pct = st.total>0 ? st.done*100/st.total : 0; diff --git a/internal/ajean/web_upload.go b/internal/ajean/web_upload.go index a0e2910..f95141b 100644 --- a/internal/ajean/web_upload.go +++ b/internal/ajean/web_upload.go @@ -155,6 +155,72 @@ func attachNote(files []attachInfo) string { return head + "\n" + strings.Join(lines, "\n") + "\n\n" } +// imageMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme image (contenu +// multimodal), quand la vision est active. Un format hors de cette liste reste +// un simple fichier du workspace, que le modèle ouvre avec ses outils. +var imageMimes = map[string]string{ + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", +} + +// imageMime renvoie le type MIME image d'un nom de fichier, ou "" si ce n'est +// pas une image qu'on sait montrer au modèle. +func imageMime(name string) string { + return imageMimes[strings.ToLower(filepath.Ext(name))] +} + +// visionEnabled dit si un projecteur multimodal est configuré (clé MMPROJ) — +// c'est la condition pour que backend_serve.go passe --mmproj au moteur, donc la +// seule où envoyer une image AU MODÈLE a un sens. Sans projecteur, llama-server +// rejetterait un contenu image ; on s'en tient alors au dépôt-fichier. +func visionEnabled() bool { + return strings.TrimSpace(ReadConfig()["MMPROJ"]) != "" +} + +// userMessageContent construit le champ Content du message utilisateur. Cas +// courant : une simple chaîne (note des fichiers joints + texte). Quand la vision +// est active ET qu'au moins une pièce jointe est une image, on renvoie le format +// multimodal d'OpenAI — une partie `text` suivie d'une partie `image_url` par +// image (data URI base64) — que llama-server comprend une fois --mmproj chargé : +// le modèle VOIT alors l'image au lieu de devoir l'ouvrir comme un fichier binaire. +// Les images ainsi intégrées sortent de la note textuelle (inutile de dire au +// modèle d'aller ouvrir un fichier qu'il a déjà sous les yeux) ; les autres +// fichiers, eux, restent annoncés comme avant. +func userMessageContent(files []attachInfo, prompt string) any { + if !visionEnabled() { + return attachNote(files) + prompt + } + dir, err := uploadsDir() + if err != nil { + return attachNote(files) + prompt + } + var imgParts []map[string]any + var textFiles []attachInfo + for _, f := range files { + mime := imageMime(f.Name) + if mime == "" { + textFiles = append(textFiles, f) + continue + } + b, err := os.ReadFile(filepath.Join(dir, f.Name)) + if err != nil { + textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier + continue + } + imgParts = append(imgParts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), + }, + }) + } + if len(imgParts) == 0 { + return attachNote(files) + prompt + } + parts := []map[string]any{{"type": "text", "text": attachNote(textFiles) + prompt}} + return append(parts, imgParts...) +} + // workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et, // si oui, renvoie son chemin relatif en séparateurs '/'. //