mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 12:46:56 +02:00
Go: layout standard cmd/ + internal/ - tout le code dans internal/jean (package jean), cmd/jean = main() + ressources Windows (.syso/icone/versioninfo), ui/ demenage dans internal/jean/ui (go:embed inchange) ; racine GitHub epuree
This commit is contained in:
1 parent
79e8d322a9
commit
e3fe63ca24
68 files changed
+75
-62
No files matched your search
@@ -0,0 +1,51 @@
|
||||
package jean
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompiledFile(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
` Compiling CUDA source file ..\..\..\..\ggml\src\ggml-cuda\acc.cu...`: "acc.cu",
|
||||
` ggml-threading.cpp`: "ggml-threading.cpp",
|
||||
` ggml-quants.c`: "ggml-quants.c",
|
||||
// Make (Linux) et Ninja.
|
||||
`[ 45%] Building CXX object src/CMakeFiles/llama.dir/llama.cpp.o`: "llama.cpp",
|
||||
`[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/jean/...`: "",
|
||||
`-- UI: running npm install`: "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := compiledFile(in); got != want {
|
||||
t.Errorf("compiledFile(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildError(t *testing.T) {
|
||||
keep := []string{
|
||||
`acc.cu(12): error C2065: 'foo': undeclared identifier`,
|
||||
`LINK : fatal error LNK1104: cannot open file`,
|
||||
// gcc/clang (Linux).
|
||||
`/x/ggml.cpp:42:9: error: 'foo' was not declared in this scope`,
|
||||
}
|
||||
drop := []string{
|
||||
// Les warnings ne doivent PAS remonter (bruit tiers).
|
||||
`ggml.cpp(10): warning C4244: conversion`,
|
||||
`LINK : warning LNK4098: conflit entre la bibliothèque ...`,
|
||||
// Contient -D_CRT_SECURE_NO_WARNINGS mais n'est pas un diagnostic.
|
||||
`nvcc.exe ... -D_CRT_SECURE_NO_WARNINGS -DGGML_SHARED ... -o acc.obj`,
|
||||
`Compiling CUDA source file acc.cu...`,
|
||||
`C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.3\bin\nvcc.exe`,
|
||||
}
|
||||
for _, l := range keep {
|
||||
if !reBuildError.MatchString(l) {
|
||||
t.Errorf("erreur attendue mais ratée: %q", l)
|
||||
}
|
||||
}
|
||||
for _, l := range drop {
|
||||
if reBuildError.MatchString(l) {
|
||||
t.Errorf("fausse erreur: %q", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package jean
|
||||
|
||||
// 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 (à
|
||||
// venir) propose « en un clic » un modèle adapté à la machine.
|
||||
//
|
||||
// La liste vit sur https://ajean.link/models.json (éditée par l'opérateur, voir
|
||||
// jean-relay/models_catalog.go). Si le réseau échoue, on retombe sur un
|
||||
// catalogue minimal embarqué (fallbackCatalogJSON) pour ne jamais bloquer.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
const catalogURL = "https://ajean.link/models.json"
|
||||
|
||||
type catalogModel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Params string `json:"params"`
|
||||
Quant string `json:"quant"`
|
||||
SizeGB float64 `json:"size_gb"`
|
||||
MinRAMGB float64 `json:"min_ram_gb"`
|
||||
URL string `json:"url"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type catalog struct {
|
||||
Version int `json:"version"`
|
||||
Models []catalogModel `json:"models"`
|
||||
}
|
||||
|
||||
type hardwareInfo struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
RAMGB float64 `json:"ram_gb"`
|
||||
}
|
||||
|
||||
// fetchCatalog récupère le catalogue distant, avec repli embarqué.
|
||||
func fetchCatalog() catalog {
|
||||
var c catalog
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
if resp, err := client.Get(catalogURL); err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 && json.NewDecoder(resp.Body).Decode(&c) == nil && len(c.Models) > 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal([]byte(fallbackCatalogJSON), &c)
|
||||
return c
|
||||
}
|
||||
|
||||
func detectHardware() hardwareInfo {
|
||||
return hardwareInfo{OS: runtime.GOOS, Arch: runtime.GOARCH, RAMGB: totalRAMGB()}
|
||||
}
|
||||
|
||||
// handleCatalog : renvoie le catalogue + le matériel local. L'UI s'en sert pour
|
||||
// marquer chaque modèle « tient / trop lourd » et proposer le bon par défaut.
|
||||
func handleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
resp := struct {
|
||||
Hardware hardwareInfo `json:"hardware"`
|
||||
Models []catalogModel `json:"models"`
|
||||
}{Hardware: detectHardware(), Models: fetchCatalog().Models}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// fallbackCatalogJSON — repli minimal si ajean.link/models.json est injoignable.
|
||||
const fallbackCatalogJSON = `{
|
||||
"version": 1,
|
||||
"models": [
|
||||
{"id":"qwen2.5-3b-instruct-q4","name":"Qwen2.5 3B Instruct","params":"3B","quant":"Q4_K_M","size_gb":2.1,"min_ram_gb":6,"url":"https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf","note":"Léger et rapide — idéal petites machines."},
|
||||
{"id":"qwen2.5-7b-instruct-q4","name":"Qwen2.5 7B Instruct","params":"7B","quant":"Q4_K_M","size_gb":4.7,"min_ram_gb":10,"url":"https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf","note":"Plus capable — recommandé avec 16 Go de RAM ou un GPU."}
|
||||
]
|
||||
}`
|
||||
@@ -0,0 +1,196 @@
|
||||
package jean
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// genAPIKey returns a fresh random OpenAI-style completion key.
|
||||
func genAPIKey() string {
|
||||
buf := make([]byte, 24)
|
||||
_, _ = rand.Read(buf)
|
||||
return "sk-jean-" + hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// writeAPIKey persists (key != "") or clears (key == "") the completion API key
|
||||
// in $JEAN_HOME/.api_key. It also wipes any residual API_KEY in config.env to
|
||||
// avoid ambiguity. It does NOT restart the service — callers decide when to
|
||||
// apply (llama-server only reads --api-key at launch).
|
||||
func writeAPIKey(key string) error {
|
||||
_ = SetConfigKey("API_KEY", "")
|
||||
if key == "" {
|
||||
if err := os.Remove(apiKeyPath()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(apiKeyPath(), []byte(key+"\n"), 0o600)
|
||||
}
|
||||
|
||||
// maskAPIKey renders a key for display: keep the "sk-jean-" prefix and last 4
|
||||
// chars, elide the middle. Empty in → empty out.
|
||||
func maskAPIKey(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 12 {
|
||||
return "…" + k[len(k)-2:]
|
||||
}
|
||||
return k[:8] + "…" + k[len(k)-4:]
|
||||
}
|
||||
|
||||
// cmdSetAPIKey sets (or clears) the API key stored in $JEAN_HOME/.api_key. When
|
||||
// exposed on the internet, llama-server requires "Authorization: Bearer <clé>"
|
||||
// for every call.
|
||||
//
|
||||
// jean set-api-key <clé> 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:
|
||||
key = genAPIKey()
|
||||
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.
|
||||
if err := writeAPIKey(key); 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)
|
||||
}
|
||||
|
||||
// toolLimitEnabled reports whether the per-turn tool-call cap is active.
|
||||
// Default: true (limité). Only an explicit "off"/"false"/"0"/"no" in config.env
|
||||
// (TOOL_LIMIT) disables it — anything else keeps the safety cap on.
|
||||
func toolLimitEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["TOOL_LIMIT"])) {
|
||||
case "off", "false", "0", "no", "non":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// toolCallLimit returns the max number of agentic tool-call iterations per turn.
|
||||
// 8 when the limit is on (default), a very high ceiling when the user disabled it
|
||||
// from the web UI — the repeated-call anti-loop still guards against runaways.
|
||||
func toolCallLimit() int {
|
||||
if toolLimitEnabled() {
|
||||
return 8
|
||||
}
|
||||
return 1000
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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)
|
||||
//
|
||||
// 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
|
||||
// à 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 <index…> 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 := hideCmd(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
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// backend_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
|
||||
gen string // générateur CMake (-G), vide => défaut de la plateforme
|
||||
genArch string // architecture du générateur (-A), ex. "x64" (VS uniquement)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err := ensureCompiler(); err != nil {
|
||||
return err
|
||||
}
|
||||
ensureAccelerator() // best-effort : installe le toolkit GPU si une carte est détectée
|
||||
|
||||
// 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 := llamaServerBin(repo)
|
||||
if bin == "" {
|
||||
return fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build"))
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := ensureCompiler(); err != nil {
|
||||
return err
|
||||
}
|
||||
ensureAccelerator() // best-effort : installe le toolkit GPU si une carte est détectée
|
||||
|
||||
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)
|
||||
if localRev != "" && localRev == remoteRev && !clean && !force && llamaServerBin(repo) != "" {
|
||||
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
|
||||
}
|
||||
bin := llamaServerBin(repo)
|
||||
if bin == "" {
|
||||
return fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build"))
|
||||
}
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
return fmt.Errorf("build ok mais échec écriture BIN dans config.env: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if bin := llamaServerBin(repo); 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",
|
||||
// L'UI web embarquée de llama-server exige npm (ou un téléchargement
|
||||
// d'assets pré-compilés depuis HuggingFace) pour générer un service-worker
|
||||
// PWA — une dépendance lourde qui casse le build sur une machine sans node.
|
||||
// jean fournit sa propre UI, donc on la désactive : build plus rapide et
|
||||
// sans dépendance réseau/npm. BUILD_UI=OFF coupe npm ; USE_PREBUILT_UI=OFF
|
||||
// coupe le téléchargement d'assets pré-compilés depuis HuggingFace (qui
|
||||
// échoue sur un réseau restreint et fait planter l'embed). Sur un checkout
|
||||
// neuf le dist est vide → llama-server embarque une UI vide sans erreur.
|
||||
// Voir scripts/ui-assets.cmake côté llama.cpp.
|
||||
"-DLLAMA_BUILD_UI=OFF",
|
||||
"-DLLAMA_USE_PREBUILT_UI=OFF",
|
||||
}
|
||||
|
||||
// Sur Windows, le générateur CMake par défaut est « NMake Makefiles », qui
|
||||
// suppose un Developer Command Prompt MSVC. On force le générateur Visual
|
||||
// Studio : il localise le toolchain MSVC tout seul via le registre, sans
|
||||
// vcvars, depuis un shell ordinaire.
|
||||
if runtime.GOOS == "windows" {
|
||||
p.gen = msvcGenerator()
|
||||
p.genArch = "x64"
|
||||
if runtime.GOARCH == "arm64" {
|
||||
p.genArch = "ARM64"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// NB : on n'active PAS GGML_CUDA_FA_ALL_QUANTS — il compile les kernels
|
||||
// Flash-Attention pour toutes les combinaisons de quant (des centaines de
|
||||
// .cu), ce qui explose le temps de build pour un gain d'inférence marginal.
|
||||
p.flags = append(p.flags, "-DGGML_CUDA=ON", "-DGGML_CUDA_F16=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)
|
||||
parts := []string{
|
||||
"CUDACXX=" + p.cudaCXX,
|
||||
"PATH=" + cudaBin + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
}
|
||||
// L'intégration MSBuild CUDA (générateur Visual Studio) résout
|
||||
// CudaToolkitDir depuis CUDA_PATH / CUDA_PATH_Vx_y. L'installeur les pose
|
||||
// dans l'environnement persistant, mais pas dans ce process déjà lancé —
|
||||
// on les réinjecte sinon le configure échoue sur « CUDA Toolkit directory '' ».
|
||||
parts = append(parts, cudaPathEnv(filepath.Dir(cudaBin))...)
|
||||
env = strings.Join(parts, "\x00")
|
||||
}
|
||||
|
||||
cfgArgs := []string{"-B", "build", "-S", "."}
|
||||
if p.gen != "" {
|
||||
cfgArgs = append(cfgArgs, "-G", p.gen)
|
||||
if p.genArch != "" {
|
||||
cfgArgs = append(cfgArgs, "-A", p.genArch)
|
||||
}
|
||||
}
|
||||
cfgArgs = append(cfgArgs, p.flags...)
|
||||
cfgLog := filepath.Join(repo, "configure.log")
|
||||
if err := runBuildStep("cmake configure", repo, env, "cmake", cfgLog, cfgArgs...); err != nil {
|
||||
hintMissingBuildDep(p, cfgLog)
|
||||
return fmt.Errorf("configuration CMake échouée: %w", err)
|
||||
}
|
||||
|
||||
buildArgs := []string{"--build", "build", "--config", "Release",
|
||||
"-j", fmt.Sprintf("%d", p.jobs), "--target", "llama-server"}
|
||||
// Générateur Visual Studio : MSBuild réaffiche par défaut la ligne de commande
|
||||
// nvcc complète de chaque kernel (des pavés illisibles). On le passe en
|
||||
// verbosité minimale via les args natifs après « -- ».
|
||||
if strings.HasPrefix(p.gen, "Visual Studio") {
|
||||
buildArgs = append(buildArgs, "--", "/nologo", "/verbosity:minimal")
|
||||
}
|
||||
if err := runBuildStep("cmake build", repo, env, "cmake", filepath.Join(repo, "build.log"), buildArgs...); err != nil {
|
||||
return fmt.Errorf("compilation échouée: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hintMissingBuildDep scanne le log de configuration CMake à la recherche de
|
||||
// dépendances manquantes CONNUES et affiche un indice d'installation adapté à la
|
||||
// distribution, plutôt que de laisser l'utilisateur face à l'erreur CMake brute.
|
||||
// Best-effort : silencieux si rien de reconnu. (Issue #6 : backend Vulkan qui
|
||||
// échoue sur « Could not find ... SPIRV-Headers ».)
|
||||
func hintMissingBuildDep(p buildPlan, cfgLog string) {
|
||||
data, err := os.ReadFile(cfgLog)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log := string(data)
|
||||
// Backend Vulkan : les en-têtes SPIR-V (paquet SPIRV-Headers) sont requis par
|
||||
// la config CMake de ggml-vulkan, mais absents par défaut sur beaucoup de
|
||||
// distros même quand glslc/libvulkan sont là.
|
||||
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))
|
||||
} else {
|
||||
fmt.Printf(" installe le paquet de développement « SPIRV-Headers » de ta distribution, puis relance %s.\n", bold("jean llamacpp install"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pkgInstallHint renvoie la commande d'installation d'un paquet adaptée au
|
||||
// gestionnaire de paquets présent sur la machine (best-effort ; "" si aucun
|
||||
// gestionnaire connu n'est trouvé). Sert uniquement à afficher un indice — on
|
||||
// n'exécute rien automatiquement.
|
||||
func pkgInstallHint(pkg string) string {
|
||||
for _, m := range []struct{ bin, cmd string }{
|
||||
{"pacman", "sudo pacman -S " + pkg},
|
||||
{"apt-get", "sudo apt-get install -y " + pkg},
|
||||
{"dnf", "sudo dnf install -y " + pkg},
|
||||
{"zypper", "sudo zypper install -y " + pkg},
|
||||
{"brew", "brew install " + pkg},
|
||||
} {
|
||||
if _, err := exec.LookPath(m.bin); err == nil {
|
||||
return m.cmd
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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 runtime.GOOS == "windows" {
|
||||
// CUDA_PATH est posé par l'installeur officiel.
|
||||
if cp := os.Getenv("CUDA_PATH"); cp != "" {
|
||||
if p := filepath.Join(cp, "bin", "nvcc.exe"); isFile(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
// Layout standard : …\NVIDIA GPU Computing Toolkit\CUDA\v12.x\bin\nvcc.exe
|
||||
for _, base := range []string{os.Getenv("ProgramFiles"), `C:\Program Files`} {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(base, "NVIDIA GPU Computing Toolkit", "CUDA", "v*", "bin", "nvcc.exe"))
|
||||
if len(matches) > 0 {
|
||||
sort.Strings(matches) // v12.2 < v12.8 → on prend le plus récent
|
||||
return matches[len(matches)-1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
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 := hideCmd(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 := hideCmd(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()
|
||||
}
|
||||
|
||||
// llamaServerBin returns the path to the built llama-server binary under repo,
|
||||
// probing the layouts the different CMake generators emit: the Visual Studio
|
||||
// multi-config generator nests it under build/bin/Release/ and Windows adds a
|
||||
// .exe suffix, whereas the Unix Makefiles generator drops it in build/bin/.
|
||||
// Returns "" when no binary is found.
|
||||
func llamaServerBin(repo string) string {
|
||||
ext := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
ext = ".exe"
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("build", "bin", "Release", "llama-server"+ext),
|
||||
filepath.Join("build", "bin", "llama-server"+ext),
|
||||
filepath.Join("build", "Release", "llama-server"+ext),
|
||||
filepath.Join("build", "llama-server"+ext),
|
||||
} {
|
||||
if p := filepath.Join(repo, rel); isFile(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasTool(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func requireTools(tools ...string) error {
|
||||
missing := missingTools(tools)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tentative d'installation automatique (winget sur Windows, apt/brew/dnf sur
|
||||
// Unix). On rafraîchit ensuite le PATH du process car un installeur système
|
||||
// écrit le PATH machine sans toucher l'environnement déjà chargé.
|
||||
fmt.Printf("%s outils manquants: %s — installation automatique…\n", yellow("[info]"), strings.Join(missing, ", "))
|
||||
for _, t := range missing {
|
||||
if err := autoInstallTool(t); err != nil {
|
||||
fmt.Printf(" %s %s: %v\n", dim("•"), t, err)
|
||||
}
|
||||
}
|
||||
refreshToolPath()
|
||||
|
||||
if still := missingTools(tools); len(still) > 0 {
|
||||
return fmt.Errorf("outils toujours manquants après tentative d'install: %s — installe-les à la main puis réessaie", strings.Join(still, ", "))
|
||||
}
|
||||
fmt.Printf("%s outils installés.\n", green("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func missingTools(tools []string) []string {
|
||||
var missing []string
|
||||
for _, t := range tools {
|
||||
if !hasTool(t) {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// runBuildStep runs a compile step while keeping the terminal clean: the full
|
||||
// output goes to logPath, and the screen shows only a single self-rewriting
|
||||
// progress line (spinner + compiled-file count) plus any real compiler
|
||||
// diagnostics. The hundreds of per-file nvcc/cl command echoes are hidden. On
|
||||
// failure the tail of the log is printed so the actual error is never lost.
|
||||
func runBuildStep(name, dir, extraEnv, bin, logPath string, args ...string) error {
|
||||
fmt.Printf("\n%s %s\n", cyan("▶"), name)
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Dir = dir
|
||||
if extraEnv != "" {
|
||||
env := os.Environ()
|
||||
for _, kv := range strings.Split(extraEnv, "\x00") {
|
||||
if kv != "" {
|
||||
env = upsertEnv(env, kv)
|
||||
}
|
||||
}
|
||||
cmd.Env = env
|
||||
}
|
||||
|
||||
var logf *os.File
|
||||
if logPath != "" {
|
||||
if f, err := os.Create(logPath); err == nil {
|
||||
logf = f
|
||||
defer logf.Close()
|
||||
}
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
count int
|
||||
label = "préparation…"
|
||||
fi int
|
||||
)
|
||||
clearLine := func() {
|
||||
if colorOn {
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
}
|
||||
// draw redessine la ligne d'état ; appelé par une horloge pour rester animé
|
||||
// même quand un seul gros fichier compile pendant plusieurs minutes.
|
||||
draw := func() {
|
||||
if !colorOn {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
fi = (fi + 1) % len(frames)
|
||||
fmt.Printf("\r\033[K %c %s", frames[fi], label)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
sc := bufio.NewScanner(pr)
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<20) // les échos de commande sont énormes
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if logf != nil {
|
||||
fmt.Fprintln(logf, line)
|
||||
}
|
||||
mu.Lock()
|
||||
if f := compiledFile(line); f != "" {
|
||||
count++
|
||||
label = fmt.Sprintf("compilation… %d fichiers %s", count, dim("("+f+")"))
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if p := phaseLabel(line); p != "" {
|
||||
label = p
|
||||
}
|
||||
mu.Unlock()
|
||||
// On ne fait remonter que les vraies ERREURS (les warnings MSVC/linker
|
||||
// d'un projet tiers sont du bruit ; ils restent dans le log). Les CMake
|
||||
// Error de la phase configure sont aussi affichés.
|
||||
if reBuildError.MatchString(line) || strings.HasPrefix(strings.TrimSpace(line), "CMake Error") {
|
||||
mu.Lock()
|
||||
clearLine()
|
||||
fmt.Println(" " + strings.TrimSpace(line))
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Horloge d'animation, indépendante du flux de sortie.
|
||||
stop := make(chan struct{})
|
||||
tickerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(tickerDone)
|
||||
t := time.NewTicker(120 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
draw()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := cmd.Wait()
|
||||
_ = pw.Close()
|
||||
<-done
|
||||
close(stop)
|
||||
<-tickerDone
|
||||
clearLine()
|
||||
if err == nil && count > 0 {
|
||||
fmt.Printf(" %s %d fichiers compilés\n", green("✓"), count)
|
||||
}
|
||||
if err != nil && logPath != "" {
|
||||
fmt.Printf("%s étape échouée — log complet : %s\n", yellow("[err]"), logPath)
|
||||
printLogTail(logPath, 30)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// phaseLabel maps a non-compile output line to a short status label, or "" to
|
||||
// leave the current label unchanged. Keeps the spinner informative during the
|
||||
// CMake configure phase and the final link.
|
||||
func phaseLabel(line string) string {
|
||||
t := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(t, "-- "):
|
||||
return "configuration… " + truncLabel(strings.TrimPrefix(t, "-- "), 50)
|
||||
case strings.Contains(t, "Linking") || strings.Contains(t, "Build files have been written"):
|
||||
return "édition de liens…"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncLabel(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return s[:n-1] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var (
|
||||
// MSBuild (Windows) : « Compiling CUDA source file …\foo.cu… » ou nom de
|
||||
// source seul « foo.cpp » imprimé par cl.
|
||||
reCompilingCUDA = regexp.MustCompile(`Compiling .*?([\w.\-]+\.cu)\b`)
|
||||
reBareSource = regexp.MustCompile(`^[\w.\-]+\.(c|cc|cpp|cxx|cu|cuh)$`)
|
||||
// Make / Ninja (Linux, macOS) : « [ 45%] Building CXX object …/foo.cpp.o » ou
|
||||
// « [12/345] Building CUDA object …/foo.cu.o ».
|
||||
reBuildingObj = regexp.MustCompile(`Building (?:C|CXX|CUDA|ASM)\w* object .*?/([^/]+?)\.o(?:bj)?\b`)
|
||||
// Vraies erreurs : « foo.cpp(12): error C2065 » (MSVC), « foo.cpp:12:5: error: »
|
||||
// (gcc/clang), « LINK : fatal error LNK1104 ». On exige le « : » devant le
|
||||
// mot-clé pour ne PAS matcher les flags type -D_CRT_SECURE_NO_WARNINGS dans les
|
||||
// lignes de commande. Les warnings (bruit d'un projet tiers) sont exclus.
|
||||
reBuildError = regexp.MustCompile(`(?i):\s*(fatal error|error)\b`)
|
||||
)
|
||||
|
||||
// compiledFile returns the source filename a build line announces compiling, or
|
||||
// "" if the line isn't a compile-progress marker. Handles both the MSBuild
|
||||
// (Windows) and Make/Ninja (Unix) output formats.
|
||||
func compiledFile(line string) string {
|
||||
t := strings.TrimSpace(line)
|
||||
if m := reCompilingCUDA.FindStringSubmatch(t); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
if m := reBuildingObj.FindStringSubmatch(t); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
if reBareSource.MatchString(t) {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// printLogTail prints the last n lines of the log file (best-effort).
|
||||
func printLogTail(path string, n int) {
|
||||
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:]
|
||||
}
|
||||
for _, l := range lines {
|
||||
fmt.Println(" " + dim(l))
|
||||
}
|
||||
}
|
||||
|
||||
// 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, " ")))
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package jean
|
||||
|
||||
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. backend_serve.go
|
||||
// passes the flag whenever the value is non-empty, but an explicit off/none is
|
||||
// treated here as disabled so the UI badge isn't misleading.
|
||||
func reasoningActive(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "", "off", "none", "false", "0", "no", "disable", "disabled":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
|
||||
// (manual override, with or without a leading '#') wins; otherwise it is
|
||||
// auto-detected from the MODEL= filename. Returns "" when unknown.
|
||||
func detectQuant(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
|
||||
i := strings.IndexByte(s, '=')
|
||||
if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "QUANT") {
|
||||
if v := strings.Trim(strings.TrimSpace(s[i+1:]), `"`); v != "" {
|
||||
return strings.ToUpper(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return quantFromName(modelFromPresetContent(content))
|
||||
}
|
||||
|
||||
// modelFilePath resolves a model file name to a path inside JEAN_HOME, refusing
|
||||
// anything that would escape it (path traversal). config.env conventionally
|
||||
// stores only the basename, so we deliberately strip any directory component.
|
||||
func modelFilePath(name string) (string, error) {
|
||||
base := filepath.Base(strings.TrimSpace(name))
|
||||
if base == "" || base == "." || base == string(filepath.Separator) {
|
||||
return "", fmt.Errorf("nom de modèle invalide")
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(base), ".gguf") {
|
||||
return "", fmt.Errorf("seuls les fichiers .gguf peuvent être supprimés")
|
||||
}
|
||||
return filepath.Join(JeanHome(), base), nil
|
||||
}
|
||||
|
||||
// modelFromPresetContent extracts the MODEL= value (basename) from a preset's
|
||||
// config.env body, or "" if absent.
|
||||
func modelFromPresetContent(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "" || strings.HasPrefix(s, "#") {
|
||||
continue
|
||||
}
|
||||
i := strings.IndexByte(s, '=')
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(s[:i]) == "MODEL" {
|
||||
v := strings.Trim(strings.TrimSpace(s[i+1:]), "\"")
|
||||
return filepath.Base(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// deleteModelFile removes a .gguf file from JEAN_HOME after validating the name.
|
||||
func deleteModelFile(name string) error {
|
||||
p, err := modelFilePath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleModelDelete deletes a single .gguf from JEAN_HOME.
|
||||
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := deleteModelFile(req.Name); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Hugging Face downloads -------------------------------------------------
|
||||
|
||||
// dlState tracks a single in-flight (or finished) model download.
|
||||
type dlState struct {
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
Total int64 `json:"total"`
|
||||
Done int64 `json:"done"`
|
||||
Finished bool `json:"finished"`
|
||||
Err string `json:"error"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
dlMu sync.Mutex
|
||||
dlDownloads = map[string]*dlState{} // keyed by filename
|
||||
)
|
||||
|
||||
// normalizeHFURL turns a Hugging Face "blob" page URL into a direct "resolve"
|
||||
// download URL, and leaves already-direct URLs untouched. Returns the URL to
|
||||
// fetch and the target filename.
|
||||
func normalizeHFURL(raw string) (string, string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", fmt.Errorf("lien vide")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("lien invalide: %v", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", "", fmt.Errorf("lien invalide (http/https attendu)")
|
||||
}
|
||||
// huggingface.co/<repo>/blob/<rev>/<file> → /resolve/<rev>/<file>
|
||||
if strings.Contains(u.Host, "huggingface.co") {
|
||||
u.Path = strings.Replace(u.Path, "/blob/", "/resolve/", 1)
|
||||
}
|
||||
name := path.Base(u.Path)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
return "", "", fmt.Errorf("impossible de déduire le nom du fichier depuis le lien")
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".gguf") {
|
||||
return "", "", fmt.Errorf("le lien doit pointer vers un fichier .gguf")
|
||||
}
|
||||
return u.String(), name, nil
|
||||
}
|
||||
|
||||
// handleModelDownload kicks off a background download of a .gguf from a URL
|
||||
// (typically Hugging Face) into JEAN_HOME. Progress is polled via
|
||||
// /api/models/download/status.
|
||||
func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
dlURL, name, err := normalizeHFURL(req.URL)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
dest, err := modelFilePath(name)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dlMu.Lock()
|
||||
if st, ok := dlDownloads[name]; ok && !st.Finished {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "téléchargement déjà en cours pour " + name})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
|
||||
return
|
||||
}
|
||||
st := &dlState{Filename: name, URL: dlURL, StartedAt: time.Now().Unix()}
|
||||
dlDownloads[name] = st
|
||||
dlMu.Unlock()
|
||||
|
||||
go runDownload(st, dlURL, dest)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name})
|
||||
}
|
||||
|
||||
// runDownload streams the URL to a .part file then renames it on success.
|
||||
func runDownload(st *dlState, dlURL, dest string) {
|
||||
finish := func(e error) {
|
||||
dlMu.Lock()
|
||||
if e != nil {
|
||||
st.Err = e.Error()
|
||||
}
|
||||
st.Finished = true
|
||||
dlMu.Unlock()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", dlURL, nil)
|
||||
if err != nil {
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
// HF gated/private repos may need a token; reuse the same key store if set.
|
||||
if k := os.Getenv("HF_TOKEN"); k != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+k)
|
||||
}
|
||||
client := &http.Client{Timeout: 0} // large files: no overall timeout
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
finish(fmt.Errorf("HTTP %d depuis la source", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
dlMu.Lock()
|
||||
st.Total = resp.ContentLength
|
||||
dlMu.Unlock()
|
||||
|
||||
tmp := dest + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 1<<20) // 1 MiB
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
finish(werr)
|
||||
return
|
||||
}
|
||||
dlMu.Lock()
|
||||
st.Done += int64(n)
|
||||
dlMu.Unlock()
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
finish(rerr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
finish(nil)
|
||||
}
|
||||
|
||||
// handleModelDownloadStatus returns the state of all known downloads this run.
|
||||
func handleModelDownloadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
dlMu.Lock()
|
||||
out := make([]dlState, 0, len(dlDownloads))
|
||||
for _, st := range dlDownloads {
|
||||
out = append(out, *st)
|
||||
}
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 200, out)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
ID string // filename without .env — stable, unique identity
|
||||
Name string // display name (# NAME= line, falls back to ID)
|
||||
Path string
|
||||
Active bool
|
||||
}
|
||||
|
||||
var nameLineRe = regexp.MustCompile(`(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$`)
|
||||
|
||||
// presetDisplayName extracts the `# NAME=` value from a preset body, falling
|
||||
// back to `fallback` (the filename id) when absent — keeps old presets working.
|
||||
func presetDisplayName(content, fallback string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
|
||||
i := strings.IndexByte(s, '=')
|
||||
if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "NAME") {
|
||||
if v := strings.Trim(strings.TrimSpace(s[i+1:]), `"`); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// withDisplayName ensures the body carries a `# NAME=<name>` line (replacing an
|
||||
// existing one, or prepended otherwise).
|
||||
func withDisplayName(content, name string) string {
|
||||
line := "# NAME=" + name
|
||||
if nameLineRe.MatchString(content) {
|
||||
return nameLineRe.ReplaceAllString(content, line)
|
||||
}
|
||||
return line + "\n" + content
|
||||
}
|
||||
|
||||
// presetFingerprint hashes config content while IGNORING the device-level keys
|
||||
// (preservedKeys) that SwitchToPreset re-applies on top of a freshly copied
|
||||
// preset. Without this, those injected MEM_MODE/CRAWL4AI_URL lines make
|
||||
// config.env differ from every preset file, so no preset is ever detected as
|
||||
// active (regression introduced when preservedKeys were added). We strip those
|
||||
// assignment lines symmetrically from both sides before hashing so a plain
|
||||
// content match still identifies the active preset.
|
||||
func presetFingerprint(content []byte) string {
|
||||
var b strings.Builder
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
if isPreservedAssignment(line) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
h := sha1.Sum([]byte(b.String()))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// isPreservedAssignment reports whether a line assigns one of the preservedKeys
|
||||
// (commented or not), e.g. `MEM_MODE=off` or `# CRAWL4AI_URL=...`.
|
||||
func isPreservedAssignment(line string) bool {
|
||||
t := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
|
||||
eq := strings.IndexByte(t, '=')
|
||||
if eq < 0 {
|
||||
return false
|
||||
}
|
||||
key := strings.TrimSpace(t[:eq])
|
||||
for _, k := range preservedKeys {
|
||||
if key == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ListPresets returns all configs/*.env, marking the one whose contents match
|
||||
// the current config.env (ignoring device-level preservedKeys), sorted by name.
|
||||
func ListPresets() ([]Preset, error) {
|
||||
dir := presetsDir()
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
cur := ""
|
||||
if b, err := os.ReadFile(confPath()); err == nil {
|
||||
cur = presetFingerprint(b)
|
||||
}
|
||||
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
|
||||
}
|
||||
id := strings.TrimSuffix(e.Name(), ".env")
|
||||
out = append(out, Preset{
|
||||
ID: id,
|
||||
Name: presetDisplayName(string(b), id),
|
||||
Path: p,
|
||||
Active: presetFingerprint(b) == cur,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
name = strings.TrimSpace(name)
|
||||
if err := validPresetName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// preservedKeys sont des réglages « appareil » (préférences utilisateur, pas des
|
||||
// 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"}
|
||||
|
||||
// SwitchToPreset backs up the current config and copies the target into place,
|
||||
// then restarts the service. Les réglages « appareil » (preservedKeys) sont
|
||||
// conservés à travers la bascule.
|
||||
func SwitchToPreset(target string) error {
|
||||
src, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capture les réglages appareil AVANT d'écraser config.env.
|
||||
cur := ReadConfig()
|
||||
if err := os.WriteFile(confPath(), src, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
// Ré-applique les réglages appareil par-dessus le preset fraîchement copié.
|
||||
for _, k := range preservedKeys {
|
||||
if v, ok := cur[k]; ok {
|
||||
_ = SetConfigKey(k, v)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target))
|
||||
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 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
return id, os.WriteFile(p, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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 id (filename).
|
||||
func ReadPreset(id string) (string, error) {
|
||||
p, err := safePresetPath(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
// 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"))
|
||||
}
|
||||
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
// $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"))
|
||||
|
||||
// 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 sys_platform_*.go.
|
||||
return execServer(bin, llmArgs)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Le « mode agent » est l'unique interrupteur qui donne à l'IA l'accès à ses
|
||||
// outils. Quand il est actif, l'IA dispose du shell (run_shell) et de sa
|
||||
// mémoire (mem_search/mem_read/mem_add/mem_edit) — les anciens « skills » ont
|
||||
// été fondus dans la mémoire. Plus de drapeaux machine/skills séparés : un seul
|
||||
// fichier .agent_enabled.
|
||||
|
||||
func agentEnabled() bool {
|
||||
if _, err := os.Stat(agentFlag()); err == nil {
|
||||
return true
|
||||
}
|
||||
// Migration : si l'un des anciens drapeaux séparés traîne encore, on
|
||||
// considère le mode agent comme actif (et on le matérialise au prochain set).
|
||||
if _, err := os.Stat(legacyToolsFlag()); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(legacySkillsFlag()); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setAgentEnabled(on bool) error {
|
||||
_ = os.MkdirAll(JeanHome(), 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())
|
||||
_ = os.Remove(legacySkillsFlag())
|
||||
if on {
|
||||
f, err := os.Create(agentFlag())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
if err := os.Remove(agentFlag()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdAgent(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
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")
|
||||
case "off":
|
||||
if err := setAgentEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " mode agent désactivé")
|
||||
case "", "status", "list":
|
||||
state := dim("off")
|
||||
if agentEnabled() {
|
||||
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)
|
||||
mem := MemList()
|
||||
if len(mem) == 0 {
|
||||
fmt.Printf(" mémoire : aucune page — crée %s/<nom>.md\n", memoryDir())
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" mémoire (%s) :\n", memoryDir())
|
||||
for _, p := range mem {
|
||||
fmt.Printf(" %s %s\n", bold(p.Name), p.Title)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("usage: jean agent [on|off|status]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"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 <prompt> 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") + " > ")
|
||||
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.
|
||||
if compacted, changed := MaybeCompact(context.Background(), msgs, caps, 0); changed {
|
||||
msgs = compacted
|
||||
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
|
||||
}
|
||||
extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, 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.DropReasoning:
|
||||
// Le tour a « pensé sans agir » : on relance. Impossible d'effacer le
|
||||
// texte déjà imprimé en terminal — on referme juste la ligne reasoning.
|
||||
if inReason {
|
||||
fmt.Print("\n")
|
||||
inReason = false
|
||||
}
|
||||
case ev.ToolUsed != nil:
|
||||
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 := "mémoire"
|
||||
switch ev.ToolUsed.Name {
|
||||
case "bash":
|
||||
icon = "⚙️"
|
||||
verb = "exécution"
|
||||
case "edit":
|
||||
icon = "✏️"
|
||||
verb = "édition"
|
||||
case "web_search", "web_open", "web_read", "web_grep":
|
||||
icon = "🌐"
|
||||
verb = "web"
|
||||
}
|
||||
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 {
|
||||
// Persist the tool turns (skill reads, shell runs) BEFORE the final
|
||||
// answer, so next turn the model remembers it already did them instead
|
||||
// of re-invoking the same skill/command from scratch.
|
||||
msgs = append(msgs, extra...)
|
||||
msgs = append(msgs, Message{Role: "assistant", Content: full.String()})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compactage du contexte, façon Hermes Agent : au lieu de vider la conversation
|
||||
// quand la fenêtre de contexte se remplit, on la scinde en trois zones —
|
||||
//
|
||||
// Head (tête) : messages système + tout premier message utilisateur. Protégé.
|
||||
// Tail (queue) : les tours récents (dans un budget de tokens). Protégé.
|
||||
// Torso (torse) : tout le milieu. C'est LA SEULE zone compactée.
|
||||
//
|
||||
// Le torse est d'abord dégraissé sans IA (les vieux résultats d'outils longs
|
||||
// sont remplacés par un marqueur), puis résumé par le modèle local en UN seul
|
||||
// appel, et le tout est remplacé par un court résumé. Résultat : des
|
||||
// conversations quasi illimitées sans jamais « clear », comme Hermes.
|
||||
//
|
||||
// La logique vit côté serveur (dans le flux de chat) donc elle profite à TOUS
|
||||
// les clients — UI web, terminal, accès distant ajean.link — sans duplication.
|
||||
|
||||
const (
|
||||
// Seuil de déclenchement proactif : on compacte quand l'historique estimé
|
||||
// dépasse cette fraction de la fenêtre de contexte.
|
||||
compactTriggerFrac = 0.75
|
||||
// Budget de la queue : fraction de la fenêtre gardée intacte (tours récents).
|
||||
compactTailFrac = 0.35
|
||||
// Un résultat d'outil du torse plus long que ça est remplacé par un marqueur
|
||||
// avant le résumé (dégraissage sans IA, gratuit).
|
||||
compactToolPruneLen = 200
|
||||
)
|
||||
|
||||
const compactPrunedMarker = "[Ancien résultat d'outil effacé pour économiser le contexte]"
|
||||
|
||||
// compactEnabled indique si le compactage automatique du contexte est actif.
|
||||
// Défaut : true. Seule une valeur off/false/0/no/non explicite (config.env
|
||||
// COMPACT) le désactive — cohérent avec toolLimitEnabled().
|
||||
func compactEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["COMPACT"])) {
|
||||
case "off", "false", "0", "no", "non":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ctxWindow renvoie la fenêtre de contexte configurée (config.env CTX), 32768
|
||||
// par défaut — la même valeur que celle passée à llama-server au lancement.
|
||||
func ctxWindow() int {
|
||||
if v := ReadConfig()["CTX"]; v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 32768
|
||||
}
|
||||
|
||||
// msgText extrait le texte d'un message (Content est `any`, en pratique string
|
||||
// ou nil quand l'assistant n'a que des tool_calls).
|
||||
func msgText(m Message) string {
|
||||
if s, ok := m.Content.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// msgTokens estime grossièrement le coût en tokens d'un message (~4 caractères
|
||||
// par token, plus un forfait par message pour le rôle et les délimiteurs). C'est
|
||||
// volontairement approximatif : le comptage EXACT vient de llama.cpp
|
||||
// (PromptTokensTotal) ; ici on veut juste décider quand compacter.
|
||||
func msgTokens(m Message) int {
|
||||
n := 4
|
||||
n += len(msgText(m)) / 4
|
||||
for _, tc := range m.ToolCalls {
|
||||
n += (len(tc.Function.Name) + len(tc.Function.Arguments)) / 4
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// estimateTokens estime la taille de l'historique en tokens.
|
||||
func estimateTokens(msgs []Message) int {
|
||||
total := 0
|
||||
for _, m := range msgs {
|
||||
total += msgTokens(m)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// MaybeCompact compacte l'historique si (et seulement si) il dépasse le seuil
|
||||
// proactif. Renvoie l'historique (compacté ou inchangé) et un booléen indiquant
|
||||
// s'il a changé. À appeler sur l'historique BRUT (avant InjectSkills) pour que
|
||||
// le résultat puisse être renvoyé au client sans le préfixe système injecté.
|
||||
//
|
||||
// knownTokens = taille RÉELLE du contexte au tour précédent (usage.prompt_tokens
|
||||
// + tokens générés), telle que rapportée par llama.cpp et affichée par l'UI. On
|
||||
// la préfère à estimateTokens() car cette dernière n'est qu'une heuristique et,
|
||||
// surtout, ne « voit » pas le prompt système injecté (machine briefing) ni le
|
||||
// gabarit de chat — donc elle sous-estime largement le vrai contexte. 0 = inconnu
|
||||
// (clients sans compteur, ex. terminal) → repli sur l'estimation.
|
||||
func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens int) ([]Message, bool) {
|
||||
if !compactEnabled() {
|
||||
return msgs, false
|
||||
}
|
||||
used := knownTokens
|
||||
if used <= 0 {
|
||||
used = estimateTokens(msgs)
|
||||
}
|
||||
if used < int(float64(ctxWindow())*compactTriggerFrac) {
|
||||
return msgs, false
|
||||
}
|
||||
return compactMessages(ctx, msgs, caps)
|
||||
}
|
||||
|
||||
// compactMessages exécute la compaction sans tenir compte du seuil (utilisé en
|
||||
// secours réactif quand llama-server refuse un prompt trop long). Renvoie
|
||||
// l'historique compacté et true s'il a effectivement changé.
|
||||
// compactBounds calcule les frontières head/tail pour un historique donné et un
|
||||
// budget de queue (en tokens). Fonction pure (pas d'IO) → testable :
|
||||
// - head : nb de messages protégés en tête = messages système + 1er message
|
||||
// utilisateur (il ancre l'objectif). Un message user est une frontière sûre.
|
||||
// - tailStart : index de début de la queue protégée. On remonte depuis la fin
|
||||
// jusqu'à remplir le budget, puis on recule jusqu'au message `user` précédent
|
||||
// pour que la queue démarre sur un début de tour — on ne sépare JAMAIS un
|
||||
// assistant+tool_calls de ses résultats `tool`.
|
||||
//
|
||||
// Le torse à compacter est [head, tailStart). Il est vide (tailStart <= head)
|
||||
// quand il n'y a rien à résumer.
|
||||
func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
|
||||
for head < len(msgs) && msgs[head].Role == "system" {
|
||||
head++
|
||||
}
|
||||
if head < len(msgs) && msgs[head].Role == "user" {
|
||||
head++
|
||||
}
|
||||
tailStart = len(msgs)
|
||||
acc := 0
|
||||
for i := len(msgs) - 1; i >= head; i-- {
|
||||
acc += msgTokens(msgs[i])
|
||||
tailStart = i
|
||||
if acc >= tailBudget {
|
||||
break
|
||||
}
|
||||
}
|
||||
for tailStart > head && msgs[tailStart].Role != "user" {
|
||||
tailStart--
|
||||
}
|
||||
return head, tailStart
|
||||
}
|
||||
|
||||
func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, bool) {
|
||||
tailBudget := int(float64(ctxWindow()) * compactTailFrac)
|
||||
head, tailStart := compactBounds(msgs, tailBudget)
|
||||
|
||||
// Rien à compacter : le torse [head, tailStart) est vide.
|
||||
if tailStart <= head {
|
||||
return msgs, false
|
||||
}
|
||||
|
||||
torso := msgs[head:tailStart]
|
||||
|
||||
// 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
|
||||
// marqueur. On travaille sur une copie pour ne pas muter l'historique amont.
|
||||
pruned := make([]Message, len(torso))
|
||||
for i, m := range torso {
|
||||
pruned[i] = m
|
||||
if m.Role == "tool" {
|
||||
if t := msgText(m); len(t) > compactToolPruneLen {
|
||||
pruned[i].Content = compactPrunedMarker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Résumé du torse par le modèle local (un seul appel). En cas d'échec on
|
||||
// garde le torse dégraissé plutôt que de perdre du contenu.
|
||||
summary, err := summarizeTranscript(ctx, renderTranscript(pruned))
|
||||
var mid []Message
|
||||
if err != nil || strings.TrimSpace(summary) == "" {
|
||||
mid = pruned
|
||||
} else {
|
||||
// Le résumé est injecté comme un tour utilisateur→assistant (jamais un
|
||||
// message `system` au milieu : certains gabarits, ex. Qwen, exigent que le
|
||||
// system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
|
||||
mid = []Message{
|
||||
{Role: "user", Content: "[COMPACTION DU CONTEXTE] Les tours précédents de cette conversation ont été résumés pour économiser le contexte. Voici le résumé :\n\n" + summary},
|
||||
{Role: "assistant", Content: "Compris, je continue avec ce contexte."},
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Message, 0, head+len(mid)+len(msgs)-tailStart)
|
||||
out = append(out, msgs[:head]...)
|
||||
out = append(out, mid...)
|
||||
out = append(out, msgs[tailStart:]...)
|
||||
|
||||
// Si on n'a rien gagné (résumé raté ET rien à dégraisser), signaler inchangé.
|
||||
if len(out) == len(msgs) && estimateTokens(out) >= estimateTokens(msgs) {
|
||||
return msgs, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// renderTranscript sérialise le torse en texte lisible pour le résumeur.
|
||||
func renderTranscript(msgs []Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "user":
|
||||
fmt.Fprintf(&b, "Utilisateur: %s\n", msgText(m))
|
||||
case "assistant":
|
||||
if t := msgText(m); t != "" {
|
||||
fmt.Fprintf(&b, "Assistant: %s\n", t)
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
fmt.Fprintf(&b, "Assistant → outil %s(%s)\n", tc.Function.Name, tc.Function.Arguments)
|
||||
}
|
||||
case "tool":
|
||||
fmt.Fprintf(&b, "Résultat outil: %s\n", msgText(m))
|
||||
case "system":
|
||||
fmt.Fprintf(&b, "Système: %s\n", msgText(m))
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
// Garde-fou pour les petites fenêtres : le résumeur ne doit pas lui-même
|
||||
// déborder. On plafonne la transcription (~0,7×contexte en tokens ≈ 2,8
|
||||
// caractères/token) en gardant la FIN (la plus récente) et en marquant la
|
||||
// troncature de tête.
|
||||
maxChars := int(float64(ctxWindow()) * 2.8)
|
||||
if maxChars > 0 && len(s) > maxChars {
|
||||
s = "[…début tronqué…]\n" + s[len(s)-maxChars:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// summarizeResp modélise le sous-ensemble utile d'une réponse non-streamée de
|
||||
// /v1/chat/completions.
|
||||
type summarizeResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// summarizeTranscript demande au modèle local un résumé dense et fidèle du torse.
|
||||
// Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle
|
||||
// principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte).
|
||||
func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
|
||||
sys := `Tu es un compacteur de contexte. On te donne la transcription d'une conversation entre un utilisateur et un assistant IA (avec ses outils). Résume-la de façon dense et FIDÈLE en français, en préservant tout ce qui est nécessaire pour continuer la conversation sans perte :
|
||||
- Objectif(s) et contraintes de l'utilisateur
|
||||
- Décisions prises et faits établis
|
||||
- Actions/outils exécutés et leurs résultats importants (chemins, valeurs, configuration)
|
||||
- Tâches terminées, en cours, bloquées
|
||||
- Prochaines étapes prévues
|
||||
N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement le résumé structuré. Complet mais concis.`
|
||||
|
||||
payload := map[string]any{
|
||||
"model": "jean",
|
||||
"messages": []Message{
|
||||
{Role: "system", Content: sys},
|
||||
{Role: "user", Content: transcript},
|
||||
},
|
||||
"stream": false,
|
||||
"temperature": 0.3,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
|
||||
req, err := http.NewRequestWithContext(ctx, "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 {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
|
||||
return "", fmt.Errorf("résumé: llama-server %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out summarizeResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("résumé: réponse vide")
|
||||
}
|
||||
c := out.Choices[0].Message.Content
|
||||
// Certains modèles à raisonnement préfixent un bloc <think>…</think> : on ne
|
||||
// garde que la réponse finale.
|
||||
if i := strings.LastIndex(c, thinkClose); i >= 0 {
|
||||
c = c[i+len(thinkClose):]
|
||||
}
|
||||
return strings.TrimSpace(c), nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package jean
|
||||
|
||||
import "testing"
|
||||
|
||||
// helpers pour construire des historiques de test lisibles.
|
||||
func um(s string) Message { return Message{Role: "user", Content: s} }
|
||||
func am(s string) Message { return Message{Role: "assistant", Content: s} }
|
||||
func atc(name string) Message {
|
||||
return Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Function: ToolCallFunc{Name: name, Arguments: "{}"}}}}
|
||||
}
|
||||
func tm(s string) Message { return Message{Role: "tool", ToolCallID: "c1", Content: s} }
|
||||
|
||||
func TestCompactBoundsProtectsHead(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
um("premier"), am("r1"),
|
||||
um("q2"), am("r2"),
|
||||
um("q3"), am("r3"),
|
||||
}
|
||||
// budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
|
||||
head, tail := compactBounds(msgs, 1)
|
||||
if head != 2 { // system + premier user
|
||||
t.Fatalf("head = %d, attendu 2", head)
|
||||
}
|
||||
if msgs[tail].Role != "user" {
|
||||
t.Fatalf("la queue doit démarrer sur un message user, obtenu %q", msgs[tail].Role)
|
||||
}
|
||||
if tail <= head {
|
||||
t.Fatalf("torse vide (tail=%d head=%d) alors qu'il y a du milieu à compacter", tail, head)
|
||||
}
|
||||
}
|
||||
|
||||
// La queue ne doit jamais démarrer entre un assistant+tool_calls et ses
|
||||
// résultats `tool` : elle recule jusqu'au message user du tour.
|
||||
func TestCompactBoundsKeepsToolPairs(t *testing.T) {
|
||||
msgs := []Message{
|
||||
um("q1"), am("r1"),
|
||||
um("q2"), atc("bash"), tm("sortie longue"), am("r2"),
|
||||
um("q3"), am("r3"),
|
||||
}
|
||||
// budget moyen qui, sans le recul, couperait au milieu du tour outillé.
|
||||
_, tail := compactBounds(msgs, msgTokens(msgs[6])+msgTokens(msgs[7])+msgTokens(msgs[5])+1)
|
||||
if msgs[tail].Role != "user" {
|
||||
t.Fatalf("la queue démarre sur %q, doit être un user (pas d'orphelin tool/assistant)", msgs[tail].Role)
|
||||
}
|
||||
// Vérifie qu'aucun message `tool` de la queue n'a perdu son assistant parent.
|
||||
for i := tail; i < len(msgs); i++ {
|
||||
if msgs[i].Role == "tool" {
|
||||
if i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool") {
|
||||
t.Fatalf("message tool orphelin à l'index %d de la queue", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTokensGrows(t *testing.T) {
|
||||
small := estimateTokens([]Message{um("court")})
|
||||
big := estimateTokens([]Message{um("un message nettement plus long que le précédent pour dépasser")})
|
||||
if big <= small {
|
||||
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// État de conversation CÔTÉ SERVEUR — une seule conversation partagée par tous
|
||||
// les appareils. Avant, l'historique vivait dans le localStorage de chaque
|
||||
// navigateur : refresh = perte des détails (outils/vitesses/raisonnement),
|
||||
// contexte différent par appareil, et fermer l'onglet coupait la génération
|
||||
// (liée à r.Context()). Ici l'état est possédé par le serveur, persisté sur
|
||||
// disque, et la génération tourne dans une goroutine détachée : fermer le
|
||||
// navigateur ne l'arrête plus, et se reconnecter rejoue tout le fil.
|
||||
//
|
||||
// Idée clé : l'UI reconstruit déjà tout l'affichage à partir d'une suite
|
||||
// d'événements SSE `delta` (content, reasoning_content, tool_used, stats…). On
|
||||
// JOURNALISE ces événements (Log) horodatés par Seq. Se reconnecter = rejouer
|
||||
// Log[from:] puis suivre les événements en direct — aucun code de rendu nouveau.
|
||||
|
||||
// maxLogEvents plafonne le journal d'AFFICHAGE (pas la vue modèle). Une très
|
||||
// longue conversation ne fait pas exploser la mémoire/le disque ; la troncature
|
||||
// ne retire que de vieilles bulles à l'écran, jamais du contexte du modèle
|
||||
// (celui-ci vit dans Messages, réduit séparément par le compactage).
|
||||
const maxLogEvents = 5000
|
||||
|
||||
// LogEvent = un événement d'affichage rejouable (un delta SSE + son numéro de
|
||||
// séquence monotone + un horodatage serveur en ms). Le TS permet au client de
|
||||
// calculer la vitesse (tok/s) à partir du temps RÉEL de génération — correct
|
||||
// aussi bien en direct qu'au replay (où tout arrive d'un bloc côté client).
|
||||
type LogEvent struct {
|
||||
Seq int `json:"seq"`
|
||||
TS int64 `json:"ts"`
|
||||
Delta map[string]any `json:"delta"`
|
||||
}
|
||||
|
||||
// Conversation est le fil unique partagé. Protégé par mu ; cond réveille les
|
||||
// abonnés (aucun canal par abonné : les abonnés lisent Log au-delà de leur
|
||||
// dernier Seq puis attendent cond — replay et direct sont le même chemin).
|
||||
type Conversation struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
|
||||
Messages []Message `json:"messages"` // vue « modèle » (nourrit runChat)
|
||||
Log []LogEvent `json:"log"` // vue « UI » rejouable
|
||||
Seq int `json:"seq"`
|
||||
CtxUsed int `json:"ctx_used"` // taille réelle du contexte au dernier tour
|
||||
|
||||
Generating bool `json:"-"`
|
||||
cancel context.CancelFunc // annule la génération en cours (/stop)
|
||||
epoch int // incrémenté à chaque reset → invalide les abonnés
|
||||
}
|
||||
|
||||
var conv = func() *Conversation {
|
||||
c := &Conversation{}
|
||||
c.cond = sync.NewCond(&c.mu)
|
||||
return c
|
||||
}()
|
||||
|
||||
// convPath = fichier de persistance sur la box jean (JeanHome = /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") }
|
||||
|
||||
// LoadConversation recharge l'état persisté au démarrage du process. Sans fichier
|
||||
// (première fois) on part d'une conversation vide.
|
||||
func LoadConversation() {
|
||||
b, err := os.ReadFile(convPath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conv.mu.Lock()
|
||||
defer conv.mu.Unlock()
|
||||
_ = json.Unmarshal(b, conv)
|
||||
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
|
||||
conv.Generating = false
|
||||
conv.cancel = nil
|
||||
}
|
||||
|
||||
// persist écrit l'état sur disque (appelé en fin de tour et sur reset, pas à
|
||||
// chaque delta). L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) persist() {
|
||||
c.mu.Lock()
|
||||
b, err := json.Marshal(c)
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(JeanHome(), 0o755)
|
||||
tmp := convPath() + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, convPath())
|
||||
}
|
||||
|
||||
// appendDelta journalise un événement d'affichage et réveille les abonnés.
|
||||
func (c *Conversation) appendDelta(delta map[string]any) {
|
||||
c.mu.Lock()
|
||||
c.Seq++
|
||||
c.Log = append(c.Log, LogEvent{Seq: c.Seq, TS: time.Now().UnixMilli(), Delta: delta})
|
||||
if len(c.Log) > maxLogEvents {
|
||||
c.Log = c.Log[len(c.Log)-maxLogEvents:]
|
||||
}
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// convState renvoie un instantané léger (pour /api/chat/state).
|
||||
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}
|
||||
}
|
||||
|
||||
// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
|
||||
var ErrBusy = fmt.Errorf("génération en cours")
|
||||
|
||||
// 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.")
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.Generating {
|
||||
c.mu.Unlock()
|
||||
return ErrBusy
|
||||
}
|
||||
c.Generating = true
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.cancel = cancel
|
||||
c.Messages = append(c.Messages, Message{Role: "user", Content: text})
|
||||
c.mu.Unlock()
|
||||
|
||||
// Borne de tour + bulle utilisateur (rejouables).
|
||||
c.appendDelta(map[string]any{"user": text})
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
}
|
||||
go c.generate(ctx, caps, temperature)
|
||||
return nil
|
||||
}
|
||||
|
||||
// generate exécute un tour complet et journalise chaque événement. Détaché : la
|
||||
// fermeture du navigateur n'a aucun effet ici, seul /stop (cancel) l'interrompt.
|
||||
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64) {
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(map[string]any{"turn_done": true})
|
||||
c.persist()
|
||||
}()
|
||||
|
||||
// Snapshot de la vue modèle.
|
||||
c.mu.Lock()
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
ctxUsed := c.CtxUsed
|
||||
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.
|
||||
if compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed); changed {
|
||||
msgs = compacted
|
||||
c.mu.Lock()
|
||||
c.Messages = compacted
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(map[string]any{"compacted": true})
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
extra, _ := runChat(ctx, InjectSkills(msgs, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(map[string]any{"error": ev.Err.Error()})
|
||||
case ev.ToolUsed != nil:
|
||||
c.appendDelta(map[string]any{"tool_used": map[string]any{
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
}})
|
||||
case ev.Stats != nil:
|
||||
// Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
|
||||
// et la décision de compactage au tour suivant.
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
c.mu.Lock()
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.appendDelta(map[string]any{"stats": ev.Stats})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(map[string]any{"drop_reasoning": true})
|
||||
case ev.Reasoning != "":
|
||||
c.appendDelta(map[string]any{"reasoning_content": ev.Reasoning})
|
||||
case ev.Content != "":
|
||||
content.WriteString(ev.Content)
|
||||
c.appendDelta(map[string]any{"content": ev.Content})
|
||||
}
|
||||
return true // génération détachée : on ne s'interrompt jamais sur un abonné
|
||||
})
|
||||
|
||||
// Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats)
|
||||
// PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle
|
||||
// garde la trace de ce qu'il a fait.
|
||||
c.mu.Lock()
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stop interrompt la génération en cours (le cas échéant).
|
||||
func (c *Conversation) Stop() {
|
||||
c.mu.Lock()
|
||||
cancel := c.cancel
|
||||
c.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Conversation) Reset() {
|
||||
c.Stop()
|
||||
c.mu.Lock()
|
||||
c.Messages = nil
|
||||
c.Log = nil
|
||||
c.Seq = 0
|
||||
c.CtxUsed = 0
|
||||
c.epoch++
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
}
|
||||
|
||||
// coalesceReplay fusionne les deltas texte consécutifs (content / reasoning_content)
|
||||
// d'un même bloc en UN seul événement, pour que le replay au chargement soit léger
|
||||
// (quelques événements par tour au lieu de milliers de tokens). On conserve le
|
||||
// nombre de tokens fusionnés (toks) et les bornes d'horodatage (ts0→ts) pour que le
|
||||
// client reconstitue le compteur ET la vitesse. Les événements non-texte (user,
|
||||
// tool_used, stats, turn_done…) passent tels quels.
|
||||
func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
var out []map[string]any
|
||||
var buf strings.Builder
|
||||
bufKey := ""
|
||||
var bufSeq, bufToks int
|
||||
var bufTs0, bufTs int64
|
||||
flush := func() {
|
||||
if bufKey == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, map[string]any{bufKey: buf.String(), "seq": bufSeq, "ts": bufTs, "ts0": bufTs0, "toks": bufToks})
|
||||
buf.Reset()
|
||||
bufKey, bufSeq, bufToks, bufTs0, bufTs = "", 0, 0, 0, 0
|
||||
}
|
||||
// Coalescence des outils : un appel d'outil génère plein d'événements tool_used
|
||||
// intermédiaires (streaming des arguments) jusqu'à un dernier avec done=true. Au
|
||||
// replay, seul l'état FINAL de chaque bulle compte (les intermédiaires ne servent
|
||||
// qu'à l'affichage progressif en direct). Sans ça, un long fil rejoue des milliers
|
||||
// de tool_used → 2-3 s de rendu inutile sur mobile. On ne garde donc que les
|
||||
// done=true, en mémorisant le dernier non-done pour le cas d'un outil interrompu.
|
||||
var pendingTool map[string]any
|
||||
flushTool := func() {
|
||||
if pendingTool != nil {
|
||||
out = append(out, pendingTool)
|
||||
pendingTool = nil
|
||||
}
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ev.Seq <= from {
|
||||
continue
|
||||
}
|
||||
_, isTool := ev.Delta["tool_used"].(map[string]any)
|
||||
// Tout événement NON-outil clôt une éventuelle bulle d'outil en attente, pour
|
||||
// préserver l'ordre (l'outil non terminé s'affiche avant ce qui le suit).
|
||||
if !isTool {
|
||||
flushTool()
|
||||
}
|
||||
// Delta texte ? (une seule clé content ou reasoning_content, valeur string)
|
||||
key := ""
|
||||
if s, ok := ev.Delta["content"].(string); ok {
|
||||
key, _ = "content", s
|
||||
} else if s, ok := ev.Delta["reasoning_content"].(string); ok {
|
||||
key, _ = "reasoning_content", s
|
||||
}
|
||||
if key != "" {
|
||||
if bufKey != "" && bufKey != key {
|
||||
flush()
|
||||
}
|
||||
if bufKey == "" {
|
||||
bufKey, bufTs0 = key, ev.TS
|
||||
}
|
||||
buf.WriteString(ev.Delta[key].(string))
|
||||
bufSeq, bufTs = ev.Seq, ev.TS
|
||||
bufToks++
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
m := map[string]any{"seq": ev.Seq, "ts": ev.TS}
|
||||
for k, v := range ev.Delta {
|
||||
m[k] = v
|
||||
}
|
||||
if isTool {
|
||||
tu, _ := ev.Delta["tool_used"].(map[string]any)
|
||||
done, _ := tu["done"].(bool)
|
||||
if done {
|
||||
pendingTool = nil // les intermédiaires de cet outil sont superflus
|
||||
out = append(out, m)
|
||||
} else {
|
||||
pendingTool = m // on retient le dernier état non terminé, sans l'émettre
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
flush()
|
||||
flushTool()
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe diffuse les événements au client via emit : d'abord un REPLAY coalescé
|
||||
// de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement.
|
||||
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
|
||||
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
|
||||
// Réveille les attentes de cond quand la connexion se ferme.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c.mu.Lock()
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
// 0. Amorçage anti-buffering. Le chat E2E d'app.ajean.link traverse Cloudflare
|
||||
// (ajean.link, proxy orange) : tant qu'un proxy intermédiaire n'a pas reçu assez
|
||||
// d'octets, il bufferise la réponse et ne la relaie qu'en retard (symptôme :
|
||||
// derniers messages qui arrivent 20-30 s après le reste, de façon intermittente
|
||||
// sur Safari). Envoyer d'emblée un gros événement de padding force le proxy à
|
||||
// basculer en mode streaming tout de suite. Le client ignore la clé `pad`.
|
||||
if !emit(map[string]any{"pad": strings.Repeat("·", 2048)}) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Replay coalescé (snapshot hors verrou pour ne pas bloquer la génération).
|
||||
c.mu.Lock()
|
||||
snapshot := append([]LogEvent(nil), c.Log...)
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
last := from
|
||||
for _, ev := range coalesceReplay(snapshot, from) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !emit(ev) {
|
||||
return
|
||||
}
|
||||
if s, ok := ev["seq"].(int); ok {
|
||||
last = s
|
||||
}
|
||||
}
|
||||
if !emit(map[string]any{"caught_up": true}) {
|
||||
return
|
||||
}
|
||||
// Le padding doit venir APRÈS caught_up, pas dessus. Un proxy (Cloudflare) garde
|
||||
// toujours le DERNIER bout de flux en tampon jusqu'au prochain flush (~2-3 s). En
|
||||
// envoyant un gros pad juste après, ce sont ses octets — et non le dernier vrai
|
||||
// message — qui deviennent la « queue » qui attend : le dernier message et
|
||||
// caught_up, eux, sont poussés dehors immédiatement. Le client ignore `pad`.
|
||||
// 16 Ko : largement au-dessus du tampon de coalescence d'un proxy courant.
|
||||
if !emit(map[string]any{"pad": strings.Repeat("·", 16384)}) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Direct : événements granulaires au-delà de `last`.
|
||||
c.mu.Lock()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if c.epoch != epoch { // reset → on ordonne au client de nettoyer et on repart
|
||||
epoch = c.epoch
|
||||
last = 0
|
||||
c.mu.Unlock()
|
||||
if !emit(map[string]any{"reset": true}) {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
continue
|
||||
}
|
||||
sent := false
|
||||
for _, ev := range c.Log {
|
||||
if ev.Seq <= last {
|
||||
continue
|
||||
}
|
||||
last = ev.Seq
|
||||
delta := ev.Delta
|
||||
ts := ev.TS
|
||||
c.mu.Unlock()
|
||||
out := map[string]any{"seq": ev.Seq, "ts": ts}
|
||||
for k, v := range delta {
|
||||
out[k] = v
|
||||
}
|
||||
if !emit(out) {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
sent = true
|
||||
if ctx.Err() != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
if sent {
|
||||
continue
|
||||
}
|
||||
c.cond.Wait()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestConv crée une conversation isolée (le global `conv` sert au process réel).
|
||||
func newTestConv() *Conversation {
|
||||
c := &Conversation{}
|
||||
c.cond = sync.NewCond(&c.mu)
|
||||
return c
|
||||
}
|
||||
|
||||
// Subscribe doit rejouer les événements déjà journalisés PUIS suivre le direct,
|
||||
// et se terminer quand le contexte (la connexion) est annulé.
|
||||
func TestSubscribeReplayAndLive(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(map[string]any{"user": "salut"})
|
||||
c.appendDelta(map[string]any{"content": "bon"})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 32)
|
||||
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Les 2 événements déjà présents (replay).
|
||||
waitSeq(t, got, 1)
|
||||
waitSeq(t, got, 2)
|
||||
// Un événement en direct après abonnement.
|
||||
c.appendDelta(map[string]any{"content": "jour"})
|
||||
waitSeq(t, got, 3)
|
||||
}
|
||||
|
||||
// Un abonné qui démarre à from=N ne reçoit que ce qui est plus récent que N.
|
||||
func TestSubscribeFromOffset(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(map[string]any{"user": "a"})
|
||||
c.appendDelta(map[string]any{"user": "b"})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, 1, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
return true
|
||||
})
|
||||
// from=1 → on saute le seq 1, on reçoit 2 en premier.
|
||||
waitSeq(t, got, 2)
|
||||
}
|
||||
|
||||
// Reset bump l'epoch et pousse un {reset:true} aux abonnés, qui repartent de 0.
|
||||
func TestResetNotifiesSubscribers(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(map[string]any{"user": "x"})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
resetSeen := make(chan bool, 4)
|
||||
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
||||
if _, ok := m["reset"]; ok {
|
||||
resetSeen <- true
|
||||
}
|
||||
return true
|
||||
})
|
||||
// laisse l'abonné consommer le replay initial
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
c.Reset()
|
||||
select {
|
||||
case <-resetSeen:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("l'abonné n'a pas reçu l'événement reset")
|
||||
}
|
||||
if c.Seq != 0 || len(c.Log) != 0 {
|
||||
t.Fatalf("après Reset: Seq=%d len(Log)=%d, attendu 0/0", c.Seq, len(c.Log))
|
||||
}
|
||||
}
|
||||
|
||||
// Les handlers de contrôle répondent en JSON sans dépendre du modèle.
|
||||
func TestChatControlHandlers(t *testing.T) {
|
||||
// reset → conversation vide
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatReset(rr, httptest.NewRequest("POST", "/api/chat/reset", nil))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("reset code %d", rr.Code)
|
||||
}
|
||||
// state → seq 0, pas de génération
|
||||
rr = httptest.NewRecorder()
|
||||
handleChatState(rr, httptest.NewRequest("GET", "/api/chat/state", nil))
|
||||
if rr.Code != 200 || !strings.Contains(rr.Body.String(), "\"seq\":0") {
|
||||
t.Fatalf("state inattendu: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// send message vide → 400
|
||||
rr = httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{"message":""}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
handleChatSend(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("send vide devrait être 400, obtenu %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func waitSeq(t *testing.T, ch <-chan int, want int) {
|
||||
t.Helper()
|
||||
select {
|
||||
case s := <-ch:
|
||||
if s != want {
|
||||
t.Fatalf("seq reçu %d, attendu %d", s, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timeout en attendant seq %d", want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Accès internet de l'IA — port Go de l'extension pi ~/.pi/agent/extensions/web.ts.
|
||||
//
|
||||
// jean parle à un serveur Crawl4AI (Chrome headless, endpoint /crawl) dont l'URL
|
||||
// est configurée dans config.env (CRAWL4AI_URL). Quand le mode agent ET l'accès
|
||||
// internet sont actifs ET que le serveur répond, l'IA dispose de 4 outils :
|
||||
//
|
||||
// - web_search : recherche DuckDuckGo (via crawl), liste {title, url, snippet}.
|
||||
// - web_open : récupère une URL (cache 10 min), renvoie SEULEMENT les métadonnées
|
||||
// (nb de lignes, taille, plan des titres) — pas le contenu.
|
||||
// - web_read : lit une plage de lignes d'une URL déjà ouverte (offset + limit).
|
||||
// - web_grep : recherche regex dans une URL déjà ouverte, lignes + contexte.
|
||||
//
|
||||
// Workflow attendu : web_open(url) → web_read/web_grep. Même logique que pi.
|
||||
|
||||
// ─── configuration & état ───────────────────────────────────────────────────
|
||||
|
||||
// crawl4aiURL renvoie l'URL du serveur Crawl4AI (config.env CRAWL4AI_URL), sans
|
||||
// slash final. Vide si non configuré.
|
||||
func crawl4aiURL() string {
|
||||
u := strings.TrimSpace(ReadConfig()["CRAWL4AI_URL"])
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
|
||||
// internetEnabled : accès internet actif = drapeau .internet_enabled présent ET
|
||||
// une URL Crawl4AI configurée. Même modèle que agentEnabled() (chat_agent.go).
|
||||
func internetEnabled() bool {
|
||||
if crawl4aiURL() == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(internetFlag())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func setInternetEnabled(on bool) error {
|
||||
_ = os.MkdirAll(JeanHome(), 0o755)
|
||||
if on {
|
||||
f, err := os.Create(internetFlag())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
if err := os.Remove(internetFlag()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crawlReachable teste que le serveur Crawl4AI répond, avec un cache court (~30 s)
|
||||
// pour ne pas ralentir chaque tour de chat. Un serveur configuré mais injoignable
|
||||
// => les outils web ne sont pas proposés (« actif ET fonctionnel »).
|
||||
var (
|
||||
reachMu sync.Mutex
|
||||
reachOK bool
|
||||
reachURL string
|
||||
reachWhen time.Time
|
||||
)
|
||||
|
||||
const reachTTL = 30 * time.Second
|
||||
|
||||
func crawlReachable() bool {
|
||||
base := crawl4aiURL()
|
||||
if base == "" {
|
||||
return false
|
||||
}
|
||||
reachMu.Lock()
|
||||
defer reachMu.Unlock()
|
||||
if base == reachURL && time.Since(reachWhen) < reachTTL {
|
||||
return reachOK
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
ok := false
|
||||
// Crawl4AI expose /health ; on tolère aussi une simple réponse HTTP sur la racine.
|
||||
for _, path := range []string{"/health", "/"} {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 500 {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
reachOK, reachURL, reachWhen = ok, base, time.Now()
|
||||
return ok
|
||||
}
|
||||
|
||||
// ─── invocation crwl ────────────────────────────────────────────────────────
|
||||
|
||||
// autoDismissJS : dismisser conservateur de bandeaux cookies/consentement, exécuté
|
||||
// dans la page. N'agit que sur des éléments qui RESSEMBLENT à une UI cookie
|
||||
// (position fixed/sticky ou id/class cookie/consent/gdpr). Port direct de web.ts.
|
||||
const autoDismissJS = `(() => { try {
|
||||
const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;
|
||||
const isOverlayish = (el) => {
|
||||
try {
|
||||
let cur = el;
|
||||
for (let i = 0; i < 6 && cur; i++) {
|
||||
const s = getComputedStyle(cur);
|
||||
if (s.position === 'fixed' || s.position === 'sticky') return true;
|
||||
const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();
|
||||
if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
};
|
||||
const candidates = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]');
|
||||
let clicked = 0;
|
||||
for (const b of candidates) {
|
||||
if (clicked >= 2) break;
|
||||
const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();
|
||||
if (!t || !TEXT.test(t)) continue;
|
||||
if (b.offsetParent === null) continue;
|
||||
if (!isOverlayish(b)) continue;
|
||||
try { b.click(); clicked++; } catch {}
|
||||
}
|
||||
document.querySelectorAll('[id*="cookie" i],[id*="consent" i],[class*="cookie" i],[class*="consent" i],[id*="gdpr" i],[class*="gdpr" i],[id*="cmp" i],[class*="cmp" i]')
|
||||
.forEach(el => { try {
|
||||
const s = getComputedStyle(el);
|
||||
if (s.position === 'fixed' || s.position === 'sticky') el.remove();
|
||||
} catch {} });
|
||||
document.documentElement.style.overflow = 'auto';
|
||||
if (document.body) document.body.style.overflow = 'auto';
|
||||
} catch {} })();`
|
||||
|
||||
type crwlOptions struct {
|
||||
jsCode []string
|
||||
waitFor string
|
||||
pageTimeoutMs int
|
||||
rawMarkdown bool // true => préfère raw_markdown (recherche) ; false => fit_markdown
|
||||
}
|
||||
|
||||
// runCrwl appelle POST {base}/crawl et renvoie le markdown extrait. Port de web.ts.
|
||||
func runCrwl(target string, opts crwlOptions) (string, error) {
|
||||
base := crawl4aiURL()
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("aucun serveur Crawl4AI configuré (CRAWL4AI_URL)")
|
||||
}
|
||||
params := map[string]any{}
|
||||
if len(opts.jsCode) > 0 {
|
||||
params["js_code"] = opts.jsCode
|
||||
}
|
||||
if opts.waitFor != "" {
|
||||
params["wait_for"] = opts.waitFor
|
||||
}
|
||||
if opts.pageTimeoutMs > 0 {
|
||||
params["page_timeout"] = opts.pageTimeoutMs
|
||||
}
|
||||
body := map[string]any{
|
||||
"urls": []string{target},
|
||||
"browser_config": map[string]any{
|
||||
"type": "BrowserConfig",
|
||||
"params": map[string]any{"headless": true},
|
||||
},
|
||||
"crawler_config": map[string]any{
|
||||
"type": "CrawlerRunConfig",
|
||||
"params": params,
|
||||
},
|
||||
}
|
||||
buf, _ := json.Marshal(body)
|
||||
|
||||
timeout := 60 * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/crawl", bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Crawl4AI injoignable (%s): %v", base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("Crawl4AI HTTP %d: %s", resp.StatusCode, tailRunes(string(b), 300))
|
||||
}
|
||||
var data struct {
|
||||
Results []crwlResult `json:"results"`
|
||||
}
|
||||
// La réponse peut être soit {results:[...]}, soit un objet unique. On décode
|
||||
// d'abord la forme {results}, sinon on retombe sur un résultat unique.
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if jerr := json.Unmarshal(raw, &data); jerr != nil || len(data.Results) == 0 {
|
||||
var single crwlResult
|
||||
if json.Unmarshal(raw, &single) == nil {
|
||||
data.Results = []crwlResult{single}
|
||||
}
|
||||
}
|
||||
if len(data.Results) == 0 {
|
||||
return "", fmt.Errorf("Crawl4AI : réponse vide")
|
||||
}
|
||||
r := data.Results[0]
|
||||
if !r.Success {
|
||||
msg := r.ErrorMessage
|
||||
if msg == "" {
|
||||
msg = "échec du crawl"
|
||||
}
|
||||
return "", fmt.Errorf("Crawl4AI : %s", tailRunes(msg, 300))
|
||||
}
|
||||
return r.markdown(opts.rawMarkdown), nil
|
||||
}
|
||||
|
||||
// crwlResult modélise un résultat Crawl4AI. markdown peut être une string ou un
|
||||
// objet {raw_markdown, fit_markdown} — on gère les deux via json.RawMessage.
|
||||
type crwlResult struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Markdown json.RawMessage `json:"markdown"`
|
||||
}
|
||||
|
||||
func (r crwlResult) markdown(preferRaw bool) string {
|
||||
if len(r.Markdown) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(r.Markdown, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var obj struct {
|
||||
Raw string `json:"raw_markdown"`
|
||||
Fit string `json:"fit_markdown"`
|
||||
}
|
||||
if json.Unmarshal(r.Markdown, &obj) == nil {
|
||||
if preferRaw {
|
||||
if obj.Raw != "" {
|
||||
return obj.Raw
|
||||
}
|
||||
return obj.Fit
|
||||
}
|
||||
if obj.Fit != "" {
|
||||
return obj.Fit
|
||||
}
|
||||
return obj.Raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── URL & normalisation ────────────────────────────────────────────────────
|
||||
|
||||
// normalizeCrawlURL : github.com/owner/repo → README brut. Port de web.ts.
|
||||
func normalizeCrawlURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if u.Host == "github.com" {
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(parts) == 2 {
|
||||
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/README.md", parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// normalizeLines : \r\n → \n, trim trailing, réduit les runs de lignes vides et
|
||||
// les doublons consécutifs, retire les vides en tête/queue. Port de web.ts.
|
||||
func normalizeLines(raw string) []string {
|
||||
src := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
|
||||
out := []string{}
|
||||
prev := ""
|
||||
blankRun := 0
|
||||
trailSpace := regexp.MustCompile(`[ \t]+$`)
|
||||
for _, l := range src {
|
||||
l = trailSpace.ReplaceAllString(l, "")
|
||||
if strings.TrimSpace(l) == "" {
|
||||
blankRun++
|
||||
if blankRun > 1 {
|
||||
continue
|
||||
}
|
||||
out = append(out, "")
|
||||
prev = ""
|
||||
continue
|
||||
}
|
||||
blankRun = 0
|
||||
if l == prev {
|
||||
continue
|
||||
}
|
||||
out = append(out, l)
|
||||
prev = l
|
||||
}
|
||||
for len(out) > 0 && out[0] == "" {
|
||||
out = out[1:]
|
||||
}
|
||||
for len(out) > 0 && out[len(out)-1] == "" {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── cache mémoire (TTL 10 min) ─────────────────────────────────────────────
|
||||
|
||||
type cacheEntry struct {
|
||||
url string
|
||||
lines []string
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
pageCacheMu sync.Mutex
|
||||
pageCache = map[string]*cacheEntry{}
|
||||
)
|
||||
|
||||
const pageCacheTTL = 10 * time.Minute
|
||||
|
||||
type fetchOptions struct {
|
||||
force bool
|
||||
actions []string
|
||||
dismissPopups bool
|
||||
waitFor string
|
||||
}
|
||||
|
||||
func cacheKeyFor(u string, opts fetchOptions) string {
|
||||
fp, _ := json.Marshal(map[string]any{"a": opts.actions, "d": opts.dismissPopups, "w": opts.waitFor})
|
||||
if string(fp) == `{"a":null,"d":true,"w":""}` || string(fp) == `{"a":[],"d":true,"w":""}` {
|
||||
return u
|
||||
}
|
||||
h := md5.Sum(fp)
|
||||
return u + "#" + hex.EncodeToString(h[:])[:8]
|
||||
}
|
||||
|
||||
// findCached : entrée de cache la plus récente pour une URL (toutes options).
|
||||
func findCached(rawURL string) *cacheEntry {
|
||||
u := normalizeCrawlURL(rawURL)
|
||||
pageCacheMu.Lock()
|
||||
defer pageCacheMu.Unlock()
|
||||
var best *cacheEntry
|
||||
for k, v := range pageCache {
|
||||
if k == u || strings.HasPrefix(k, u+"#") {
|
||||
if time.Since(v.fetchedAt) > pageCacheTTL {
|
||||
continue
|
||||
}
|
||||
if best == nil || v.fetchedAt.After(best.fetchedAt) {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
||||
u := normalizeCrawlURL(rawURL)
|
||||
key := cacheKeyFor(u, opts)
|
||||
pageCacheMu.Lock()
|
||||
cached := pageCache[key]
|
||||
pageCacheMu.Unlock()
|
||||
if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
jsCode := []string{}
|
||||
if opts.dismissPopups {
|
||||
jsCode = append(jsCode, autoDismissJS)
|
||||
}
|
||||
jsCode = append(jsCode, opts.actions...)
|
||||
pageTimeout := 0
|
||||
if opts.waitFor != "" || len(opts.actions) > 0 {
|
||||
pageTimeout = 45000
|
||||
}
|
||||
md, err := runCrwl(u, crwlOptions{jsCode: jsCode, waitFor: opts.waitFor, pageTimeoutMs: pageTimeout})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(md) == "" {
|
||||
return nil, fmt.Errorf("page vide")
|
||||
}
|
||||
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()}
|
||||
pageCacheMu.Lock()
|
||||
pageCache[key] = entry
|
||||
pageCacheMu.Unlock()
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ─── helpers de formatage ───────────────────────────────────────────────────
|
||||
|
||||
var headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+?)\s*$`)
|
||||
|
||||
func extractOutline(lines []string) string {
|
||||
var out []string
|
||||
for i, l := range lines {
|
||||
if m := headingRe.FindStringSubmatch(l); m != nil {
|
||||
indent := strings.Repeat(" ", len(m[1])-1)
|
||||
out = append(out, fmt.Sprintf("%5d | %s%s", i+1, indent, m[2]))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "(aucun titre markdown trouvé)"
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func formatLines(lines []string, startLine int) string {
|
||||
var b strings.Builder
|
||||
for i, l := range lines {
|
||||
fmt.Fprintf(&b, "%5d | %s\n", startLine+i, l)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func formatBytes(n int) string {
|
||||
switch {
|
||||
case n < 1024:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
case n < 1024*1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||||
default:
|
||||
return fmt.Sprintf("%.2f MB", float64(n)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── recherche DuckDuckGo ───────────────────────────────────────────────────
|
||||
|
||||
type searchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
var (
|
||||
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
wsRe = regexp.MustCompile(`\s+`)
|
||||
numEntityRe = regexp.MustCompile(`&#(\d+);`)
|
||||
ddgHeadRe = regexp.MustCompile(`^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$`)
|
||||
ddgLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
|
||||
urlishRe = regexp.MustCompile(`^[\w.-]+\.[a-z]{2,}`)
|
||||
)
|
||||
|
||||
func decodeHTMLEntities(s string) string {
|
||||
s = strings.NewReplacer(
|
||||
"&", "&", "<", "<", ">", ">", """, `"`,
|
||||
"'", "'", "/", "/", " ", " ",
|
||||
).Replace(s)
|
||||
return numEntityRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||
var n int
|
||||
fmt.Sscanf(m, "&#%d;", &n)
|
||||
if n > 0 {
|
||||
return string(rune(n))
|
||||
}
|
||||
return m
|
||||
})
|
||||
}
|
||||
|
||||
// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
|
||||
func decodeUddg(raw string) string {
|
||||
s := raw
|
||||
if strings.HasPrefix(s, "//") {
|
||||
s = "https:" + s
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
if q := u.Query().Get("uddg"); q != "" {
|
||||
if dec, e := url.QueryUnescape(q); e == nil {
|
||||
return dec
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func duckduckgoSearch(query string, limit int) ([]searchResult, error) {
|
||||
searchURL := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query)
|
||||
md, err := runCrwl(searchURL, crwlOptions{rawMarkdown: true, pageTimeoutMs: 30000})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.Contains(md, "anomaly-modal") || strings.Contains(md, "anomaly.js") {
|
||||
return nil, fmt.Errorf("DuckDuckGo a renvoyé un défi anti-bot")
|
||||
}
|
||||
var results []searchResult
|
||||
lines := strings.Split(md, "\n")
|
||||
for i := 0; i < len(lines) && len(results) < limit; i++ {
|
||||
h := ddgHeadRe.FindStringSubmatch(lines[i])
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(strings.ReplaceAll(decodeHTMLEntities(h[1]), "**", ""))
|
||||
u := decodeUddg(h[2])
|
||||
if title == "" || u == "" || strings.Contains(u, "duckduckgo.com") {
|
||||
continue
|
||||
}
|
||||
snippet := ""
|
||||
for j := i + 1; j < i+6 && j < len(lines); j++ {
|
||||
ln := strings.TrimSpace(lines[j])
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
for _, lm := range ddgLinkRe.FindAllStringSubmatch(ln, -1) {
|
||||
text := strings.TrimSpace(lm[1])
|
||||
if text == "" || strings.HasPrefix(text, "!") || urlishRe.MatchString(text) {
|
||||
continue
|
||||
}
|
||||
snippet = strings.TrimSpace(strings.ReplaceAll(decodeHTMLEntities(text), "**", ""))
|
||||
break
|
||||
}
|
||||
if snippet != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
dup := false
|
||||
for _, r := range results {
|
||||
if r.URL == u {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dup {
|
||||
continue
|
||||
}
|
||||
results = append(results, searchResult{Title: title, URL: u, Snippet: snippet})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ─── définitions d'outils (schémas OpenAI, comme llm_client.go) ────────────────────
|
||||
|
||||
func webSearchTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_search",
|
||||
Description: "Recherche sur le web via DuckDuckGo. Renvoie une liste classée de {title, url, snippet}. " +
|
||||
"À utiliser quand l'utilisateur pose une question sans URL, cherche un outil/une bibliothèque, " +
|
||||
"ou demande une information récente. À enchaîner avec web_open + web_read sur le meilleur résultat.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Requête (langage naturel ou mots-clés)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "Nb max de résultats (défaut 8, max 20)"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func webOpenTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_open",
|
||||
Description: "Récupère une URL et renvoie SEULEMENT les métadonnées (taille, nb de lignes, plan des titres). " +
|
||||
"Ne renvoie PAS le contenu. Toujours appeler ceci d'abord avant de lire. Résultat en cache 10 min " +
|
||||
"— les web_read / web_grep suivants le réutilisent.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "URL complète à récupérer"},
|
||||
"refresh": map[string]any{"type": "boolean", "description": "Ignore le cache et re-fetch. Défaut false."},
|
||||
"actions": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "Snippets JS à exécuter sur la page AVANT extraction (déplier des sections, cliquer 'voir plus', etc.)."},
|
||||
"dismiss_popups": map[string]any{"type": "boolean", "description": "Ferme auto les bandeaux cookies/overlays. Défaut true."},
|
||||
"wait_for": map[string]any{"type": "string", "description": "Sélecteur CSS ou expr JS à attendre après les actions."},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func webReadTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_read",
|
||||
Description: "Lit une plage de lignes d'une URL déjà ouverte avec web_open. Coût en tokens prévisible. " +
|
||||
"Lignes 1-indexées, préfixées par leur numéro.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "URL précédemment ouverte avec web_open"},
|
||||
"offset": map[string]any{"type": "integer", "description": "Ligne de départ (1-indexée, défaut 1)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "Nb de lignes (défaut 80, max 500)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func webGrepTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_grep",
|
||||
Description: "Recherche regex dans une URL déjà ouverte avec web_open. Renvoie les lignes correspondantes " +
|
||||
"avec contexte et numéros. Idéal quand la page est longue et qu'on connaît un mot-clé.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "URL précédemment ouverte avec web_open"},
|
||||
"pattern": map[string]any{"type": "string", "description": "Motif regex (insensible à la casse)"},
|
||||
"context": map[string]any{"type": "integer", "description": "Lignes de contexte autour de chaque match. Défaut 2."},
|
||||
"max_matches": map[string]any{"type": "integer", "description": "Plafond de matches renvoyés. Défaut 30."},
|
||||
},
|
||||
"required": []string{"url", "pattern"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
|
||||
|
||||
func toolWebSearch(args map[string]any) string {
|
||||
query, _ := args["query"].(string)
|
||||
limit := 8
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
results, err := duckduckgoSearch(query, limit)
|
||||
if err != nil {
|
||||
return "❌ Recherche échouée : " + err.Error()
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("Aucun résultat pour « %s »", query)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Recherche : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
|
||||
for i, r := range results {
|
||||
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func toolWebOpen(args map[string]any) string {
|
||||
u, _ := args["url"].(string)
|
||||
opts := fetchOptions{dismissPopups: true}
|
||||
if v, ok := args["refresh"].(bool); ok {
|
||||
opts.force = v
|
||||
}
|
||||
if v, ok := args["dismiss_popups"].(bool); ok {
|
||||
opts.dismissPopups = v
|
||||
}
|
||||
if v, ok := args["wait_for"].(string); ok {
|
||||
opts.waitFor = v
|
||||
}
|
||||
if arr, ok := args["actions"].([]any); ok {
|
||||
for _, a := range arr {
|
||||
if s, ok := a.(string); ok {
|
||||
opts.actions = append(opts.actions, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
entry, err := getPage(u, opts)
|
||||
if err != nil {
|
||||
return "❌ " + err.Error()
|
||||
}
|
||||
total := len(entry.lines)
|
||||
chars := total
|
||||
for _, l := range entry.lines {
|
||||
chars += len(l)
|
||||
}
|
||||
return fmt.Sprintf("# Ouvert : %s\nTotal : %d lignes, %s (%d caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n%s\n```",
|
||||
entry.url, total, formatBytes(chars), chars, extractOutline(entry.lines))
|
||||
}
|
||||
|
||||
func toolWebRead(args map[string]any) string {
|
||||
u, _ := args["url"].(string)
|
||||
entry := findCached(u)
|
||||
if entry == nil {
|
||||
return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
|
||||
}
|
||||
total := len(entry.lines)
|
||||
offset := 1
|
||||
if v, ok := args["offset"].(float64); ok {
|
||||
offset = int(v)
|
||||
}
|
||||
if offset < 1 {
|
||||
offset = 1
|
||||
}
|
||||
limit := 80
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
start := offset - 1
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
slice := entry.lines[start:end]
|
||||
remaining := total - end
|
||||
tail := " (fin de page)"
|
||||
if remaining > 0 {
|
||||
tail = fmt.Sprintf(" (%d de plus en dessous)", remaining)
|
||||
}
|
||||
return fmt.Sprintf("# %s\nLignes %d–%d sur %d%s\n\n```\n%s\n```",
|
||||
entry.url, offset, end, total, tail, formatLines(slice, offset))
|
||||
}
|
||||
|
||||
func toolWebGrep(args map[string]any) string {
|
||||
u, _ := args["url"].(string)
|
||||
pattern, _ := args["pattern"].(string)
|
||||
entry := findCached(u)
|
||||
if entry == nil {
|
||||
return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
|
||||
}
|
||||
re, err := regexp.Compile("(?i)" + pattern)
|
||||
if err != nil {
|
||||
return "❌ Regex invalide : " + err.Error()
|
||||
}
|
||||
ctx := 2
|
||||
if v, ok := args["context"].(float64); ok {
|
||||
ctx = int(v)
|
||||
}
|
||||
if ctx < 0 {
|
||||
ctx = 0
|
||||
}
|
||||
maxMatches := 30
|
||||
if v, ok := args["max_matches"].(float64); ok {
|
||||
maxMatches = int(v)
|
||||
}
|
||||
if maxMatches < 1 {
|
||||
maxMatches = 1
|
||||
}
|
||||
lines := entry.lines
|
||||
var matchIdx []int
|
||||
for i := 0; i < len(lines) && len(matchIdx) < maxMatches; i++ {
|
||||
if re.MatchString(lines[i]) {
|
||||
matchIdx = append(matchIdx, i)
|
||||
}
|
||||
}
|
||||
if len(matchIdx) == 0 {
|
||||
return fmt.Sprintf("# %s\nAucun match pour /%s/i", entry.url, pattern)
|
||||
}
|
||||
// Fusionne les fenêtres de contexte qui se chevauchent.
|
||||
type rng struct{ s, e int }
|
||||
var ranges []rng
|
||||
for _, i := range matchIdx {
|
||||
s := i - ctx
|
||||
if s < 0 {
|
||||
s = 0
|
||||
}
|
||||
e := i + ctx
|
||||
if e > len(lines)-1 {
|
||||
e = len(lines) - 1
|
||||
}
|
||||
if n := len(ranges); n > 0 && s <= ranges[n-1].e+1 {
|
||||
if e > ranges[n-1].e {
|
||||
ranges[n-1].e = e
|
||||
}
|
||||
} else {
|
||||
ranges = append(ranges, rng{s, e})
|
||||
}
|
||||
}
|
||||
var blocks []string
|
||||
for _, r := range ranges {
|
||||
blocks = append(blocks, "```\n"+formatLines(lines[r.s:r.e+1], r.s+1)+"\n```")
|
||||
}
|
||||
capped := ""
|
||||
if len(matchIdx) == maxMatches {
|
||||
capped = fmt.Sprintf(" (plafonné à %d)", maxMatches)
|
||||
}
|
||||
return fmt.Sprintf("# %s\n%d match(es) pour /%s/i%s\n\n%s",
|
||||
entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n"))
|
||||
}
|
||||
|
||||
// ─── CLI : jean internet [on|off|status|url <url>] ──────────────────────────
|
||||
|
||||
func cmdInternet(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if crawl4aiURL() == "" {
|
||||
return fmt.Errorf("configure d'abord l'URL : jean internet url <url>")
|
||||
}
|
||||
if err := setInternetEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_open/web_read/web_grep (si le mode agent est actif)")
|
||||
case "off":
|
||||
if err := setInternetEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " accès internet désactivé")
|
||||
case "url":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: jean internet url <url> (ex: http://localhost:11235)")
|
||||
}
|
||||
u := strings.TrimRight(strings.TrimSpace(args[1]), "/")
|
||||
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
|
||||
return err
|
||||
}
|
||||
reachMu.Lock()
|
||||
reachURL = "" // invalide le cache de reachability
|
||||
reachMu.Unlock()
|
||||
fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u))
|
||||
case "", "status", "list":
|
||||
state := dim("off")
|
||||
if internetEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
||||
u := crawl4aiURL()
|
||||
if u == "" {
|
||||
fmt.Printf(" serveur : %s — configure : jean internet url <url>\n", dim("(non configuré)"))
|
||||
return nil
|
||||
}
|
||||
reach := red("injoignable")
|
||||
if crawlReachable() {
|
||||
reach = green("joignable")
|
||||
}
|
||||
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 <url>]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// La mémoire de jean = des fichiers Markdown plats sous MEMORY/<nom>.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.
|
||||
|
||||
type MemPage struct {
|
||||
Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
|
||||
Title string // 1re ligne non vide, sans les #
|
||||
}
|
||||
|
||||
type MemHit struct {
|
||||
File string
|
||||
Title string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
var memNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
// memFileName normalise un nom de page : ajoute .md si absent et valide.
|
||||
func memFileName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("nom vide")
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".md") {
|
||||
name += ".md"
|
||||
}
|
||||
if !memNameRe.MatchString(name) {
|
||||
return "", fmt.Errorf("nom invalide (alphanum, ._-)")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// safeMemPath valide et renvoie le chemin disque d'une page mémoire.
|
||||
func safeMemPath(name string) (string, error) {
|
||||
fn, err := memFileName(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
root, err := filepath.Abs(memoryDir())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
abs, err := filepath.Abs(filepath.Join(root, fn))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(abs, root+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path invalide")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// titleOf renvoie la 1re ligne non vide d'un contenu, sans les # de tête.
|
||||
func titleOf(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#"))
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MemList liste les pages mémoire (nom + titre), triées par nom.
|
||||
func MemList() []MemPage {
|
||||
entries, err := os.ReadDir(memoryDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := []MemPage{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
continue
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
|
||||
out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque
|
||||
// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de
|
||||
// recherche : à compléter par mem_read sur la page la plus pertinente.
|
||||
func MemSearch(query string, limit int) []MemHit {
|
||||
if limit <= 0 || limit > 30 {
|
||||
limit = 8
|
||||
}
|
||||
terms := strings.Fields(strings.ToLower(query))
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
score int
|
||||
}
|
||||
var ranked []scored
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
hay := strings.ToLower(p.Name + "\n" + content)
|
||||
score := 0
|
||||
for _, t := range terms {
|
||||
score += strings.Count(hay, t)
|
||||
if strings.Contains(strings.ToLower(p.Name), t) {
|
||||
score += 3 // bonus si le terme est dans le nom de la page
|
||||
}
|
||||
}
|
||||
if score == 0 && query != "" {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, scored{
|
||||
hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
|
||||
out := []MemHit{}
|
||||
for i, r := range ranked {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
out = append(out, r.hit)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme.
|
||||
func snippetAround(content string, terms []string) string {
|
||||
flat := strings.Join(strings.Fields(content), " ")
|
||||
low := strings.ToLower(flat)
|
||||
idx := -1
|
||||
for _, t := range terms {
|
||||
if i := strings.Index(low, t); i >= 0 && (idx < 0 || i < idx) {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
if len(flat) > 160 {
|
||||
return flat[:160] + "…"
|
||||
}
|
||||
return flat
|
||||
}
|
||||
start := idx - 60
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + 100
|
||||
if end > len(flat) {
|
||||
end = len(flat)
|
||||
}
|
||||
s := flat[start:end]
|
||||
if start > 0 {
|
||||
s = "…" + s
|
||||
}
|
||||
if end < len(flat) {
|
||||
s += "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du
|
||||
// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500).
|
||||
func MemRead(name string, offset, limit int) (string, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
if offset <= 0 {
|
||||
offset = 1
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
var out strings.Builder
|
||||
for i := offset - 1; i < len(lines) && i < offset-1+limit; i++ {
|
||||
fmt.Fprintf(&out, "%d\t%s\n", i+1, lines[i])
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n"), nil
|
||||
}
|
||||
|
||||
// MemAdd crée une nouvelle page mémoire. Refuse d'écraser une page existante
|
||||
// (utiliser mem_edit pour modifier).
|
||||
func MemAdd(name, content string) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier")
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
return os.WriteFile(p, []byte(body), 0o644)
|
||||
}
|
||||
|
||||
// MemEdit remplace oldText par newText dans une page. oldText doit apparaître
|
||||
// EXACTEMENT une fois (sinon erreur), pour une édition sans ambiguïté.
|
||||
func MemEdit(name, oldText, newText string) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
content := string(b)
|
||||
n := strings.Count(content, oldText)
|
||||
if oldText == "" {
|
||||
return fmt.Errorf("old vide")
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("old introuvable dans la page")
|
||||
}
|
||||
if n > 1 {
|
||||
return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
return os.WriteFile(p, []byte(updated), 0o644)
|
||||
}
|
||||
|
||||
// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
|
||||
// l'éditeur web.
|
||||
func MemContent(name string) string {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// MemSave écrit (crée ou écrase) une page mémoire. Si old est fourni et diffère
|
||||
// du nouveau nom, l'ancienne page est renommée (supprimée). Utilisé par l'éditeur
|
||||
// web.
|
||||
func MemSave(name, old, content string) error {
|
||||
if old != "" {
|
||||
if oldFn, e1 := memFileName(old); e1 == nil {
|
||||
if newFn, e2 := memFileName(name); e2 == nil && oldFn != newFn {
|
||||
if od, e3 := safeMemPath(old); e3 == nil {
|
||||
_ = os.Remove(od)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
return os.WriteFile(p, []byte(body), 0o644)
|
||||
}
|
||||
|
||||
// MemDelete supprime une page mémoire.
|
||||
func MemDelete(name string) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return fmt.Errorf("introuvable")
|
||||
}
|
||||
return os.Remove(p)
|
||||
}
|
||||
|
||||
// migrateSkillsToMemory copie une seule fois les anciens skills
|
||||
// (SKILLS/<nom>/SKILL.md) vers la mémoire (MEMORY/<nom>.md). Idempotente : ne
|
||||
// touche pas une page mémoire déjà existante, et pose un drapeau .migrated pour
|
||||
// ne pas reparcourir à chaque lancement. Silencieuse en cas d'absence de SKILLS.
|
||||
func migrateSkillsToMemory() {
|
||||
flag := filepath.Join(memoryDir(), ".skills_migrated")
|
||||
if _, err := os.Stat(flag); err == nil {
|
||||
return // déjà fait
|
||||
}
|
||||
entries, err := os.ReadDir(skillsDir())
|
||||
if err != nil {
|
||||
return // pas d'anciens skills
|
||||
}
|
||||
migrated := 0
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(skillsDir(), e.Name(), "SKILL.md")
|
||||
b, rerr := os.ReadFile(src)
|
||||
if rerr != nil {
|
||||
continue
|
||||
}
|
||||
dst, derr := safeMemPath(e.Name())
|
||||
if derr != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue // page mémoire homonyme déjà présente : on ne l'écrase pas
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
if os.WriteFile(dst, b, 0o644) == nil {
|
||||
migrated++
|
||||
}
|
||||
}
|
||||
_ = os.MkdirAll(memoryDir(), 0o755)
|
||||
_ = os.WriteFile(flag, []byte(fmt.Sprintf("%d skills migrés\n", migrated)), 0o644)
|
||||
}
|
||||
|
||||
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
|
||||
// mode agent (shell). Trois modes :
|
||||
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
|
||||
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
|
||||
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
|
||||
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
|
||||
type MemMode string
|
||||
|
||||
const (
|
||||
MemOff MemMode = "off"
|
||||
MemOnDemand MemMode = "ondemand"
|
||||
MemAlways MemMode = "always"
|
||||
)
|
||||
|
||||
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
|
||||
// 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":
|
||||
return MemOff
|
||||
case "ondemand", "on-demand", "demand", "manual", "manuel":
|
||||
return MemOnDemand
|
||||
default: // "always", "auto", "" et inconnus
|
||||
return MemAlways
|
||||
}
|
||||
}
|
||||
|
||||
// setMemMode persiste le mode mémoire dans config.env.
|
||||
func setMemMode(m MemMode) error {
|
||||
return SetConfigKey("MEM_MODE", string(m))
|
||||
}
|
||||
|
||||
// cmdMemory : jean memory [off|ondemand|always|status]
|
||||
func cmdMemory(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
}
|
||||
label := map[MemMode]string{
|
||||
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
|
||||
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
|
||||
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
|
||||
}
|
||||
switch sub {
|
||||
case "off", "none", "0", "false":
|
||||
if err := setMemMode(MemOff); err != nil {
|
||||
return err
|
||||
}
|
||||
case "ondemand", "on-demand", "demand", "manual", "manuel":
|
||||
if err := setMemMode(MemOnDemand); err != nil {
|
||||
return err
|
||||
}
|
||||
case "always", "auto", "on":
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
return err
|
||||
}
|
||||
case "", "status", "list":
|
||||
m := memMode()
|
||||
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
|
||||
pages := MemList()
|
||||
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("usage: jean memory [off|ondemand|always|status]")
|
||||
}
|
||||
m := memMode()
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
toolDefaultTimeout = 30
|
||||
toolMaxTimeout = 300
|
||||
toolMaxOutput = 8000 // characters of stdout/stderr returned to the model
|
||||
)
|
||||
|
||||
// baseSystemPrompt is the always-on system preamble. Structured like pi's
|
||||
// proven prompt (identity → method → guidelines → concision → date): a concrete,
|
||||
// procedural prompt gives the model rails so it stops deliberating forever
|
||||
// ("Wait, let me check… Wait, I'll just run it…") and commits to an action.
|
||||
// The per-tool "Outil disponible" sections live in machine/skills prompts so
|
||||
// they only appear when the matching feature is on.
|
||||
func baseSystemPrompt(caps Caps) string {
|
||||
hasMem := caps.Mem != MemOff
|
||||
// No tool access at all → no agentic preamble. A plain chat model told to
|
||||
// "call tools immediately" hallucinates textual tool calls (e.g.
|
||||
// default_api:bash) that leak into the answer. Let the user's own system
|
||||
// prompt stand alone. (Internet requiert l'agent, donc pas testé ici.)
|
||||
if !caps.Agent && !hasMem {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
// Prompt VOLONTAIREMENT court. Un préambule verbeux (longue liste de
|
||||
// « guidelines », surtout des méta-instructions sur la réflexion) fait
|
||||
// sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think>
|
||||
// puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts »
|
||||
// mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler.
|
||||
b.WriteString("You are jean, an expert assistant operating directly on this machine with real tools.")
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
|
||||
}
|
||||
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("- 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")
|
||||
}
|
||||
// Politique d'usage de la mémoire selon le mode.
|
||||
switch caps.Mem {
|
||||
case MemAlways:
|
||||
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
||||
b.WriteString("- When the user tells you to remember something, or shares a preference, fact, decision, or how-to worth keeping, save it with mem_add (or mem_edit to update an existing page) — do it on your own, without being asked.\n")
|
||||
b.WriteString("- Before doing any task or answering, first call mem_search to see whether your memory already holds the answer or how to do it, then mem_read the best page. Do this even when the request has new specifics like a name, a place or a value — your saved method still applies, only the parameter changes.\n")
|
||||
case MemOnDemand:
|
||||
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")
|
||||
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")
|
||||
}
|
||||
}
|
||||
if caps.Internet {
|
||||
b.WriteString("\nWeb access (Crawl4AI): web_search (DuckDuckGo), web_open (fetch a URL → metadata + outline), web_read (read a line range of an opened URL), web_grep (regex in an opened URL). Workflow: web_open first, then web_read/web_grep.\n")
|
||||
year := time.Now().Format("2006")
|
||||
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, fixtures, 'since when') call web_search BEFORE writing any date or version. Your answer must match the dates/facts you actually read.\n")
|
||||
b.WriteString("SEARCH QUERY YEAR RULE: today is in " + year + ". If your query includes a year, use ONLY " + year + " — NEVER write a past year like " + prevYear(year) + " that you remember from training; it silently biases results toward stale pages. Default: put no year at all and let the freshest result win. Don't hedge ('probably', 'I think') about a fact a tool can verify — search instead.\n")
|
||||
}
|
||||
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// prevYear returns the year before the given "2006"-formatted year string, used
|
||||
// to name explicitly the stale year the model must NOT put in search queries.
|
||||
func prevYear(year string) string {
|
||||
n, err := strconv.Atoi(year)
|
||||
if err != nil {
|
||||
return year
|
||||
}
|
||||
return strconv.Itoa(n - 1)
|
||||
}
|
||||
|
||||
// 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(caps Caps) string {
|
||||
if !caps.Agent {
|
||||
return ""
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
if host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
who := ""
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Machine: host=%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 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 {
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = toolDefaultTimeout
|
||||
}
|
||||
if timeoutSec > toolMaxTimeout {
|
||||
timeoutSec = toolMaxTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
cmd := newShellCmd(ctx, 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")
|
||||
}
|
||||
|
||||
// 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.
|
||||
func fileEdit(path, oldText, newText string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
if oldText == "" {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
content := string(b)
|
||||
n := strings.Count(content, oldText)
|
||||
if n == 0 {
|
||||
return "[erreur] old introuvable dans le fichier"
|
||||
}
|
||||
if n > 1 {
|
||||
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
|
||||
}
|
||||
|
||||
// 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:])
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package jean
|
||||
|
||||
// cli_app.go — expérience « application » de Jean.
|
||||
//
|
||||
// Quand on double-clique sur le binaire (aucun argument, console fraîche), au
|
||||
// lieu d'afficher l'aide dans une console qui se ferme, Jean démarre son UI web,
|
||||
// l'ouvre dans le navigateur ET pose une icône dans la zone de notification
|
||||
// Windows (voir sys_tray_windows.go) : on voit que Jean tourne et on le pilote
|
||||
// (« Ouvrir Jean » / « Quitter »).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const appPort = 8090
|
||||
|
||||
// cmdApp lance l'UI web, l'ouvre dans le navigateur et fait tourner l'icône de
|
||||
// la zone de notification (Windows). Sur une machine vierge, il crée d'abord le
|
||||
// dossier de données + config.env pour que l'interface s'ouvre quand même
|
||||
// (l'écran d'accueil prendra le relais pour le choix du modèle).
|
||||
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)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", appPort)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
// Jean tourne déjà sur ce port : on ouvre juste l'UI sur l'instance
|
||||
// existante plutôt que d'échouer.
|
||||
fmt.Printf("Jean est déjà lancé — ouverture de %s\n", url)
|
||||
return openBrowser(url)
|
||||
}
|
||||
|
||||
// Sert l'UI en tâche de fond ; l'icône tray tient le premier plan.
|
||||
go func() { _ = http.Serve(ln, newWebMux()) }()
|
||||
|
||||
sp := showSplash("Lancement de Jean en cours…")
|
||||
waitServerReady(url)
|
||||
_ = openBrowser(url)
|
||||
time.Sleep(900 * time.Millisecond) // laisse le navigateur s'afficher par-dessus le splash
|
||||
sp.close()
|
||||
|
||||
runTray(url) // icône zone de notification ; bloque jusqu'à « Quitter »
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitServerReady attend que l'UI réponde (jusqu'à ~4 s) pour n'ouvrir le
|
||||
// navigateur qu'une fois le serveur prêt.
|
||||
func waitServerReady(url string) {
|
||||
c := &http.Client{Timeout: 400 * time.Millisecond}
|
||||
for i := 0; i < 20; i++ {
|
||||
if resp, err := c.Get(url); err == nil {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Jean — cœur du binaire (package jean, appelé par cmd/jean). 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…)
|
||||
// cli_* expérience « application » (double-clic : UI + tray + splash)
|
||||
// web_* serveur HTTP local :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)
|
||||
// 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
|
||||
//
|
||||
// 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
|
||||
@@ -0,0 +1,238 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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
|
||||
res := &benchResult{
|
||||
PromptN: t.PromptN, PromptMs: t.PromptMs, PromptPerSecond: t.PromptPerSecond,
|
||||
PredictedN: t.PredictedN, PredictedMs: t.PredictedMs, PredictedPerSec: t.PredictedPerSec,
|
||||
Elapsed: elapsed,
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// Tool definitions: OpenAI-shaped function schemas advertised to the model when
|
||||
// the agent mode is on. The memory tools (mem_*) let the model keep persistent
|
||||
// Markdown notes across sessions; bash is its real access to the machine.
|
||||
|
||||
func memSearchTool() Tool {
|
||||
return 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.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Keywords or a short phrase"},
|
||||
"limit": map[string]any{"type": "integer", "description": "Max results (default 8, max 30)"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func memReadTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "mem_read",
|
||||
Description: "Read a memory page (Markdown file). 1-indexed output, lines prefixed with their number. offset/limit for long pages.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Page name (e.g. docker-notes.md)"},
|
||||
"offset": map[string]any{"type": "integer", "description": "Start line (1-indexed, default 1)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "Number of lines (default 500, max 500)"},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func memAddTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "mem_add",
|
||||
Description: "Create a new memory page (Markdown). One topic per page, descriptive kebab-case name. First line = short title (#). Refuses to overwrite an existing page (use mem_edit).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Page name (e.g. docker-notes.md)"},
|
||||
"content": map[string]any{"type": "string", "description": "Markdown content (first line = title #)"},
|
||||
},
|
||||
"required": []string{"file", "content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func memEditTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "mem_edit",
|
||||
Description: "Edit a memory page by exact replacement: old → new. old must appear EXACTLY once in the page (add context to make it unique). To append, put the current end of the page in old and the extended version in new.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Page name"},
|
||||
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique in the page)"},
|
||||
"new": map[string]any{"type": "string", "description": "Replacement text"},
|
||||
},
|
||||
"required": []string{"file", "old", "new"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func editTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "edit",
|
||||
Description: "Modify a file on disk by exact replacement: old → new. old must appear EXACTLY once in the file (add surrounding context to make it unique). Prefer this over rewriting the whole file — it avoids retyping everything.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Path of the file to modify"},
|
||||
"old": map[string]any{"type": "string", "description": "Exact text to replace (unique in the file)"},
|
||||
"new": map[string]any{"type": "string", "description": "Replacement text"},
|
||||
},
|
||||
"required": []string{"file", "old", "new"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{"type": "string", "description": "The bash command"},
|
||||
"timeout": map[string]any{"type": "integer", "description": fmt.Sprintf("Timeout s (default %d, max %d)", toolDefaultTimeout, toolMaxTimeout)},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Caps are the per-request capabilities (tool access) for a chat turn. They let
|
||||
// a caller (e.g. an ajean.link agent with its own tools/skills toggles) scope
|
||||
// what the model can do for this conversation, instead of always inheriting the
|
||||
// machine's global config. Use globalCaps() to fall back to the global config.
|
||||
type Caps struct {
|
||||
// Agent = mode agent actif : un seul interrupteur qui débloque TOUS les
|
||||
// outils de l'IA (shell + skills). Un skill est un outil comme un autre.
|
||||
Agent bool
|
||||
// Internet = accès web actif (serveur Crawl4AI configuré + joignable) : ajoute
|
||||
// les outils web_search/web_open/web_read/web_grep. Requiert aussi Agent.
|
||||
Internet bool
|
||||
// Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
|
||||
// indépendant du mode agent. Voir MemMode.
|
||||
Mem MemMode
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
// specify its own capabilities.
|
||||
func globalCaps() Caps {
|
||||
// Internet inclut la joignabilité du serveur Crawl4AI : « actif ET fonctionnel ».
|
||||
// Ainsi le prompt système (chat_tools.go) et les outils fournis (EnabledTools) sont
|
||||
// gouvernés par la MÊME condition — sinon le prompt promet web_search alors que
|
||||
// l'outil n'existe pas, et le modèle le tape en bash (command not found).
|
||||
// Mémoire et accès internet sont des sous-réglages du mode agent (cf. UI web) :
|
||||
// sans agent, on ne fournit NI les outils mem_*, NI les outils web. Ça garde le
|
||||
// prompt système et les outils cohérents avec l'interface (blocs grisés quand
|
||||
// l'agent est off) — l'IA en chat pur répond sans mémoire ni web.
|
||||
agent := agentEnabled()
|
||||
if !agent {
|
||||
return Caps{Agent: false, Internet: false, Mem: MemOff}
|
||||
}
|
||||
return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode()}
|
||||
}
|
||||
|
||||
// InjectSkills prepends context system messages to msgs: the decisive-agent
|
||||
// preamble + machine briefing (when tools are enabled) and the lightweight
|
||||
// skills directory (when skills are enabled). Merges with an existing system
|
||||
// message if present.
|
||||
func InjectSkills(msgs []Message, caps Caps) []Message {
|
||||
var parts []string
|
||||
// Decisive-agent preamble (anti-loop) — only when the model actually has
|
||||
// tools, otherwise it nudges a plain chat model to "call tools" it doesn't
|
||||
// have, which leaks malformed tool-call text into the answer.
|
||||
if bp := baseSystemPrompt(caps); bp != "" {
|
||||
parts = append(parts, bp)
|
||||
}
|
||||
if mp := machineSystemPrompt(caps); mp != "" {
|
||||
parts = append(parts, mp)
|
||||
}
|
||||
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: prefix + "\n\n" + existing}}, msgs[1:]...)
|
||||
return merged
|
||||
}
|
||||
return append([]Message{{Role: "system", Content: prefix}}, msgs...)
|
||||
}
|
||||
|
||||
// EnabledTools returns the tools to advertise on the next inference call.
|
||||
func EnabledTools(caps Caps) []Tool {
|
||||
tools := []Tool{}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), 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).
|
||||
if caps.Mem != MemOff {
|
||||
tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool())
|
||||
}
|
||||
// Outils web : seulement si le mode agent ET l'accès internet sont actifs.
|
||||
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
|
||||
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
||||
if caps.Agent && caps.Internet {
|
||||
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// StreamEvent is what a ChatCallback receives for each piece of streamed output.
|
||||
// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning} is set
|
||||
// per call.
|
||||
type StreamEvent struct {
|
||||
Content string
|
||||
Reasoning string
|
||||
ToolUsed *ToolUsedEvent
|
||||
Stats *StatsEvent
|
||||
Err error
|
||||
// DropReasoning demande à l'UI de retirer la dernière bulle de raisonnement :
|
||||
// le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là
|
||||
// est mort-né et ne doit pas rester à l'écran (sinon double raisonnement).
|
||||
DropReasoning bool
|
||||
}
|
||||
type ToolUsedEvent struct {
|
||||
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).
|
||||
type StatsEvent struct {
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
PromptPerSecond float64 `json:"prompt_per_second,omitempty"`
|
||||
PromptMs float64 `json:"prompt_ms,omitempty"`
|
||||
GenTokens int `json:"gen_tokens,omitempty"`
|
||||
GenPerSecond float64 `json:"gen_per_second,omitempty"`
|
||||
GenMs float64 `json:"gen_ms,omitempty"`
|
||||
// Taille TOTALE du prompt traité ce tour (préfixe caché compris), issue de
|
||||
// `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage.
|
||||
PromptTokensTotal int `json:"prompt_tokens_total,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// Chunk final (include_usage) : taille totale du prompt, hors choices.
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// runChat drives the full inference loop including tool calling.
|
||||
// On finish_reason="tool_calls" we execute locally, append a "tool" message
|
||||
// and call /v1/chat/completions again — up to 8 iterations as a safety cap.
|
||||
const thinkClose = "</think>"
|
||||
|
||||
// runChat returns `extra`: the tool-turn messages (assistant-with-tool_calls and
|
||||
// their tool results) it appended during the agentic loop. The caller persists
|
||||
// these into the durable history BEFORE the final assistant text, so the model
|
||||
// keeps the trace of what it already read/ran across user turns — otherwise it
|
||||
// re-invokes the same skill/command every turn (it has no memory of having done
|
||||
// it) and can confabulate paths/results it can no longer see.
|
||||
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) {
|
||||
var extra []Message
|
||||
tools := EnabledTools(caps)
|
||||
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
|
||||
// in streaming mode: the model's <think> block (opened by the chat template)
|
||||
// arrives inline in `content`, terminated by a literal </think>. When
|
||||
// reasoning is enabled we split that out ourselves so the UI's reasoning
|
||||
// bubble works regardless of backend. The ik_llama.cpp fork already sends
|
||||
// reasoning_content, in which case we leave content untouched.
|
||||
reasoningOn := reasoningActive(ReadConfig()["REASONING"])
|
||||
// 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
|
||||
// Filet réactif (façon Hermes) : si llama-server refuse le prompt (souvent un
|
||||
// dépassement de la fenêtre de contexte après de gros résultats d'outils), on
|
||||
// compacte l'historique en vol et on rejoue le tour — une seule fois.
|
||||
compactedRetry := false
|
||||
// Anti-loop net: if the model re-emits the exact same tool call (name+args)
|
||||
// several times, it's stuck — break instead of spinning to the iteration cap.
|
||||
lastSig := ""
|
||||
repeatSig := 0
|
||||
// Garde-fou « pensé sans agir » : certains modèles à reasoning planifient un
|
||||
// appel d'outil dans leur <think> puis émettent le token de fin SANS l'émettre
|
||||
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
|
||||
// explicite au lieu d'afficher « pas de réponse ».
|
||||
nudged := false
|
||||
// Plafond d'itérations d'appels d'outils. Activé par défaut (8) pour éviter
|
||||
// qu'un modèle parti en vrille n'enchaîne les appels indéfiniment ; l'anti-
|
||||
// boucle (lastSig) protège toujours même quand la limite est désactivée via
|
||||
// l'UI (TOOL_LIMIT=off → plafond très haut, quasi illimité).
|
||||
maxIter := toolCallLimit()
|
||||
for iter := 0; iter < maxIter; iter++ {
|
||||
payload := map[string]any{
|
||||
"model": "jean",
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
||||
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
|
||||
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
|
||||
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
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())
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return extra, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
authHeader(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
cb(StreamEvent{Err: err})
|
||||
return extra, 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
|
||||
}
|
||||
// Le prompt a peut-être dépassé la fenêtre de contexte : on tente une
|
||||
// compaction en vol et on rejoue le tour (une seule fois) avant tout le
|
||||
// reste. C'est le filet de secours à la Hermes.
|
||||
if compactEnabled() && !compactedRetry {
|
||||
if c, changed := compactMessages(ctx, messages, caps); changed {
|
||||
compactedRetry = true
|
||||
messages = c
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 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 extra, err
|
||||
}
|
||||
toolCalls := map[int]*ToolCall{}
|
||||
assistantContent := strings.Builder{}
|
||||
finishReason := ""
|
||||
// Accumulateur de stats : timings (prefill/decode) puis usage (total prompt)
|
||||
// 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)
|
||||
// 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)
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
// Timings ET usage (include_usage) arrivent sur le CHUNK FINAL qui, sur ce
|
||||
// build llama.cpp (MTP/spéculatif), a `choices:[]` — on les traite AVANT le
|
||||
// garde de choices, sinon `gen_tokens`/`gen_per_second` (decode) sont jetés
|
||||
// et l'UI retombe à « 0 tok/s » à la fin de la génération.
|
||||
if chunk.Timings != nil {
|
||||
stats.PromptTokens = chunk.Timings.PromptN
|
||||
stats.PromptPerSecond = chunk.Timings.PromptPerSecond
|
||||
stats.PromptMs = chunk.Timings.PromptMs
|
||||
stats.GenTokens = chunk.Timings.PredictedN
|
||||
stats.GenPerSecond = chunk.Timings.PredictedPerSec
|
||||
stats.GenMs = chunk.Timings.PredictedMs
|
||||
s := stats
|
||||
cb(StreamEvent{Stats: &s})
|
||||
}
|
||||
if chunk.Usage != nil && chunk.Usage.PromptTokens > 0 {
|
||||
stats.PromptTokensTotal = chunk.Usage.PromptTokens
|
||||
s := stats
|
||||
cb(StreamEvent{Stats: &s})
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
ch := chunk.Choices[0]
|
||||
if ch.FinishReason != "" {
|
||||
finishReason = ch.FinishReason
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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"
|
||||
switch cur.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
key = "query"
|
||||
case "mem_read", "mem_add", "mem_edit", "edit":
|
||||
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}}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if ch.Delta.Content != "" {
|
||||
assistantContent.WriteString(ch.Delta.Content)
|
||||
if !thinkOpen || sawReasoningField {
|
||||
if !cb(StreamEvent{Content: ch.Delta.Content}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// Inside the prompt-opened <think> block: stream to the
|
||||
// reasoning bubble until the closing </think>, then switch
|
||||
// the remainder to normal content.
|
||||
thinkTail.WriteString(ch.Delta.Content)
|
||||
s := thinkTail.String()
|
||||
if i := strings.Index(s, thinkClose); i >= 0 {
|
||||
reason := s[:i]
|
||||
after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n")
|
||||
thinkOpen = false
|
||||
thinkTail.Reset()
|
||||
if reason != "" && !cb(StreamEvent{Reasoning: reason}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
if after != "" && !cb(StreamEvent{Content: after}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// Hold back a tail that could be a partial "</think>".
|
||||
keep := len(thinkClose) - 1
|
||||
if len(s) > keep {
|
||||
emit := s[:len(s)-keep]
|
||||
thinkTail.Reset()
|
||||
thinkTail.WriteString(s[len(s)-keep:])
|
||||
if !cb(StreamEvent{Reasoning: emit}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flush any buffered reasoning if the stream ended mid-think.
|
||||
if !aborted && thinkOpen && thinkTail.Len() > 0 {
|
||||
cb(StreamEvent{Reasoning: thinkTail.String()})
|
||||
}
|
||||
resp.Body.Close()
|
||||
if aborted {
|
||||
return extra, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
extra = append(extra, assistant)
|
||||
// Loop guard: same exact call(s) as last turn? Count it; on the 3rd
|
||||
// identical turn, stop so we don't churn the same command forever.
|
||||
sig := strings.Builder{}
|
||||
for _, tc := range tcs {
|
||||
sig.WriteString(tc.Function.Name)
|
||||
sig.WriteByte('\x00')
|
||||
sig.WriteString(tc.Function.Arguments)
|
||||
sig.WriteByte('\n')
|
||||
}
|
||||
if s := sig.String(); s == lastSig {
|
||||
repeatSig++
|
||||
if repeatSig >= 2 {
|
||||
cb(StreamEvent{Content: "\n\n[stop: appel d'outil répété en boucle — " + tcs[0].Function.Name + "]"})
|
||||
return extra, nil
|
||||
}
|
||||
} else {
|
||||
lastSig = s
|
||||
repeatSig = 0
|
||||
}
|
||||
// 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)
|
||||
// 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 "mem_search", "web_search":
|
||||
label, _ = args["query"].(string)
|
||||
case "mem_read", "mem_add", "mem_edit", "edit":
|
||||
label, _ = args["file"].(string)
|
||||
case "bash":
|
||||
label, _ = args["command"].(string)
|
||||
case "web_open", "web_read":
|
||||
label, _ = args["url"].(string)
|
||||
case "web_grep":
|
||||
u, _ := args["url"].(string)
|
||||
p, _ := args["pattern"].(string)
|
||||
label = p + " @ " + u
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
|
||||
|
||||
result := ""
|
||||
switch tc.Function.Name {
|
||||
case "mem_search":
|
||||
lim := 0
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
lim = int(v)
|
||||
}
|
||||
hits := MemSearch(label, lim)
|
||||
if len(hits) == 0 {
|
||||
result = "[aucun résultat]"
|
||||
} else {
|
||||
var b strings.Builder
|
||||
for _, h := range hits {
|
||||
fmt.Fprintf(&b, "- %s — %s\n %s\n", h.File, h.Title, h.Snippet)
|
||||
}
|
||||
result = strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
case "mem_read":
|
||||
off, lim := 0, 0
|
||||
if v, ok := args["offset"].(float64); ok {
|
||||
off = int(v)
|
||||
}
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
lim = int(v)
|
||||
}
|
||||
if c, rerr := MemRead(label, off, lim); rerr != nil {
|
||||
result = "[erreur] " + rerr.Error()
|
||||
} else {
|
||||
result = c
|
||||
}
|
||||
case "mem_add":
|
||||
content, _ := args["content"].(string)
|
||||
if werr := MemAdd(label, content); werr != nil {
|
||||
result = "[erreur] " + werr.Error()
|
||||
} else {
|
||||
result = fmt.Sprintf("[ok] page '%s' créée", label)
|
||||
}
|
||||
case "mem_edit":
|
||||
oldText, _ := args["old"].(string)
|
||||
newText, _ := args["new"].(string)
|
||||
if werr := MemEdit(label, oldText, newText); werr != nil {
|
||||
result = "[erreur] " + werr.Error()
|
||||
} else {
|
||||
result = fmt.Sprintf("[ok] page '%s' modifiée", label)
|
||||
}
|
||||
case "edit":
|
||||
oldText, _ := args["old"].(string)
|
||||
newText, _ := args["new"].(string)
|
||||
result = fileEdit(label, oldText, newText)
|
||||
case "bash":
|
||||
to := 0
|
||||
switch v := args["timeout"].(type) {
|
||||
case float64:
|
||||
to = int(v)
|
||||
case int:
|
||||
to = v
|
||||
}
|
||||
result = runShell(label, to)
|
||||
case "web_search":
|
||||
result = toolWebSearch(args)
|
||||
case "web_open":
|
||||
result = toolWebOpen(args)
|
||||
case "web_read":
|
||||
result = toolWebRead(args)
|
||||
case "web_grep":
|
||||
result = toolWebGrep(args)
|
||||
default:
|
||||
result = "[erreur] outil inconnu: " + tc.Function.Name
|
||||
}
|
||||
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}})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
}
|
||||
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()) == "" {
|
||||
// Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) :
|
||||
// si un modèle « pense sans agir » malgré tout, on le relance UNE fois avec
|
||||
// une consigne impérative au lieu d'afficher « pas de réponse ».
|
||||
if len(tools) > 0 && !disableTools && !nudged {
|
||||
nudged = true
|
||||
// Le raisonnement de ce tour avorté ne mène à rien : on demande à
|
||||
// l'UI de l'effacer avant de relancer, pour ne pas afficher deux
|
||||
// blocs de réflexion successifs.
|
||||
cb(StreamEvent{DropReasoning: true})
|
||||
messages = append(messages, Message{
|
||||
Role: "user",
|
||||
Content: "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.",
|
||||
})
|
||||
continue
|
||||
}
|
||||
cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})
|
||||
}
|
||||
return extra, nil
|
||||
}
|
||||
cb(StreamEvent{Content: "\n\n[stop: trop d'appels d'outils]"})
|
||||
return extra, 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
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
)
|
||||
|
||||
// llm_oai.go — front TLS de l'accès OpenAI public "VPS aveugle" (SNI passthrough).
|
||||
//
|
||||
// Principe : le SaaS parle HTTPS OpenAI standard vers <machine>.oai.ajean.link.
|
||||
// Le VPS relais ne fait que recopier les octets TLS bruts (routage par SNI, sans
|
||||
// déchiffrer). C'est ICI, sur l'agent, que le TLS est terminé — avec un cert dont
|
||||
// la clé privée ne quitte JAMAIS cette machine — puis proxifié vers llama-server
|
||||
// local (/v1). Un attaquant qui possède le VPS ne voit donc que du chiffré.
|
||||
//
|
||||
// Certificat : Let's Encrypt via challenge TLS-ALPN-01 servi À TRAVERS LE TUNNEL
|
||||
// (l'agent est derrière CGNAT, mais le VPS forwarde la validation jusqu'à lui).
|
||||
// Aucun secret DNS nulle part : le seul DNS est un wildcard *.oai.ajean.link
|
||||
// statique posé une fois par l'opérateur.
|
||||
|
||||
// oaiSuffix est le domaine sous lequel on autorise l'émission de certificats.
|
||||
const oaiSuffix = ".oai.ajean.link"
|
||||
|
||||
// oaiHandler construit le reverse-proxy vers llama-server, restreint à la surface
|
||||
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le SaaS envoie
|
||||
// la vraie clé (.api_key), que llama-server valide lui-même (--api-key).
|
||||
func oaiHandler() http.Handler {
|
||||
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
|
||||
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
|
||||
}
|
||||
http.Error(w, "not found (endpoint OpenAI: /v1/*)", http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
// runOAIFront termine le TLS sur rawLn (avec tlsCfg) puis sert oaiHandler dessus.
|
||||
// rawLn peut être un vrai listener TCP (test local) ou un listener alimenté par
|
||||
// les streams "raw" du tunnel (prod). Bloquant.
|
||||
func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
|
||||
srv := &http.Server{
|
||||
Handler: oaiHandler(),
|
||||
IdleTimeout: 120 * time.Second,
|
||||
// completions longues : pas de Read/Write timeout.
|
||||
}
|
||||
return srv.Serve(tls.NewListener(rawLn, tlsCfg))
|
||||
}
|
||||
|
||||
// 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") }
|
||||
|
||||
// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette machine.
|
||||
func oaiPublicEnabled() bool {
|
||||
if _, err := os.Stat(oaiPublicPath()); err == nil {
|
||||
return true
|
||||
}
|
||||
return os.Getenv("JEAN_LINK_ALLOW_OAI") == "1" // rétro-compat (ancien drapeau env)
|
||||
}
|
||||
|
||||
// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public.
|
||||
func setOAIPublic(on bool) error {
|
||||
if on {
|
||||
return os.WriteFile(oaiPublicPath(), []byte("1\n"), 0o600)
|
||||
}
|
||||
if err := os.Remove(oaiPublicPath()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via
|
||||
// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et
|
||||
// répond elle-même aux challenges ACME. La clé privée est stockée dans
|
||||
// $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.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
|
||||
magic := certmagic.NewDefault()
|
||||
magic.OnDemand = &certmagic.OnDemandConfig{
|
||||
DecisionFunc: func(_ context.Context, name string) error {
|
||||
if strings.HasSuffix(name, oaiSuffix) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("nom non autorisé pour l'accès OpenAI: %s", name)
|
||||
},
|
||||
}
|
||||
cfg := magic.TLSConfig() // GetCertificate (on-demand) + gère l'ALPN acme-tls/1
|
||||
// certmagic ne met QUE "acme-tls/1" dans NextProtos ; sans "http/1.1" les
|
||||
// clients normaux sont rejetés (« unsupported application protocols »). On
|
||||
// préfixe http/1.1 tout en gardant acme-tls/1 pour les challenges.
|
||||
cfg.NextProtos = append([]string{"http/1.1"}, cfg.NextProtos...)
|
||||
cfg.MinVersion = tls.VersionTLS12
|
||||
return cfg
|
||||
}
|
||||
|
||||
// --- démultiplexeur du tunnel ------------------------------------------------
|
||||
// Le relais ouvre soit un stream HTTP normal (UI / E2E), soit un stream "brut"
|
||||
// qui porte une session TLS de bout en bout (accès OpenAI). On les distingue au
|
||||
// 1er octet : un enregistrement TLS commence par 0x16 (handshake), une requête
|
||||
// HTTP par une lettre ASCII (GET/POST/…). Voir demuxTunnelStream dans relay_link.go.
|
||||
|
||||
// peekedConn rend un net.Conn dont on a déjà consulté le début, sans perdre ces
|
||||
// octets (ils restent dans le bufio.Reader).
|
||||
type peekedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (p *peekedConn) Read(b []byte) (int, error) { return p.r.Read(b) }
|
||||
|
||||
// chanListener est un net.Listener alimenté à la main (push), pour injecter dans
|
||||
// http.Server / tls.NewListener des conns déjà acceptées ailleurs (les streams
|
||||
// démultiplexés du tunnel).
|
||||
type chanListener struct {
|
||||
ch chan net.Conn
|
||||
done chan struct{}
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
func newChanListener(addr net.Addr) *chanListener {
|
||||
return &chanListener{ch: make(chan net.Conn), done: make(chan struct{}), addr: addr}
|
||||
}
|
||||
|
||||
func (l *chanListener) push(c net.Conn) {
|
||||
select {
|
||||
case l.ch <- c:
|
||||
case <-l.done:
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *chanListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-l.ch:
|
||||
return c, nil
|
||||
case <-l.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (l *chanListener) Close() error {
|
||||
select {
|
||||
case <-l.done:
|
||||
default:
|
||||
close(l.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *chanListener) Addr() net.Addr {
|
||||
if l.addr != nil {
|
||||
return l.addr
|
||||
}
|
||||
return dummyAddr{}
|
||||
}
|
||||
|
||||
type dummyAddr struct{}
|
||||
|
||||
func (dummyAddr) Network() string { return "tunnel" }
|
||||
func (dummyAddr) String() string { return "tunnel" }
|
||||
|
||||
// selfSignedTLSConfig fabrique un *tls.Config auto-signé pour host. Tests locaux
|
||||
// uniquement (curl -k) avant de brancher Let's Encrypt.
|
||||
func selfSignedTLSConfig(host string) (*tls.Config, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: host},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
tmpl.IPAddresses = []net.IP{ip}
|
||||
} else {
|
||||
tmpl.DNSNames = []string{host}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil
|
||||
}
|
||||
|
||||
// cmdOAI pilote l'accès OpenAI public côté agent.
|
||||
//
|
||||
// jean 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 := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
args = args[1:]
|
||||
}
|
||||
switch sub {
|
||||
case "serve":
|
||||
port := 8443
|
||||
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
|
||||
}
|
||||
host := "localhost"
|
||||
if len(args) > 1 && args[1] != "" {
|
||||
host = args[1]
|
||||
}
|
||||
tlsCfg, err := selfSignedTLSConfig(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[jean 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(" quand JEAN_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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(context.Background(), msgs, 0, Caps{}, 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
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package jean
|
||||
|
||||
// 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 ».
|
||||
//
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// Aucune dépendance externe : crypto/ecdh (X25519) + AES-GCM de la stdlib.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func e2eKeyPath() string { return filepath.Join(JeanHome(), ".e2e_key") }
|
||||
|
||||
var (
|
||||
e2eOnce sync.Once
|
||||
e2eKey *ecdh.PrivateKey
|
||||
e2eErr error
|
||||
)
|
||||
|
||||
// e2ePrivateKey charge (ou crée puis persiste) la clé privée X25519 de l'agent.
|
||||
func e2ePrivateKey() (*ecdh.PrivateKey, error) {
|
||||
e2eOnce.Do(func() {
|
||||
if b, err := os.ReadFile(e2eKeyPath()); err == nil {
|
||||
raw, derr := hex.DecodeString(strings.TrimSpace(string(b)))
|
||||
if derr == nil {
|
||||
e2eKey, e2eErr = ecdh.X25519().NewPrivateKey(raw)
|
||||
return
|
||||
}
|
||||
}
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
e2eErr = err
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(JeanHome(), 0o755)
|
||||
if err := os.WriteFile(e2eKeyPath(), []byte(hex.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil {
|
||||
e2eErr = err
|
||||
return
|
||||
}
|
||||
e2eKey = k
|
||||
})
|
||||
return e2eKey, e2eErr
|
||||
}
|
||||
|
||||
// e2ePubHex retourne la clé publique X25519 de l'agent (hex), pour publication au relais.
|
||||
func e2ePubHex() string {
|
||||
k, err := e2ePrivateKey()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// e2eFingerprint retourne une empreinte lisible de la clé publique (à comparer
|
||||
// dans le portail). Format : 8 groupes hex de 4 = SHA-256(pub) tronqué.
|
||||
func e2eFingerprint() string {
|
||||
k, err := e2ePrivateKey()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256(k.PublicKey().Bytes())
|
||||
s := hex.EncodeToString(h[:8]) // 16 hex
|
||||
var parts []string
|
||||
for i := 0; i < len(s); i += 4 {
|
||||
parts = append(parts, strings.ToUpper(s[i:i+4]))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
// e2eOpenSeal ouvre une boîte scellée (ephPub32 || nonce12 || ct) chiffrée vers
|
||||
// la clé publique de l'agent, et retourne le secret en clair (la racine R).
|
||||
func e2eOpenSeal(blob []byte) ([]byte, error) {
|
||||
if len(blob) < 32+12+16 {
|
||||
return nil, fmt.Errorf("sceau trop court")
|
||||
}
|
||||
priv, err := e2ePrivateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ephPubBytes := blob[:32]
|
||||
nonce := blob[32:44]
|
||||
ct := blob[44:]
|
||||
ephPub, err := ecdh.X25519().NewPublicKey(ephPubBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := priv.ECDH(ephPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := sealKey(shared, ephPubBytes, priv.PublicKey().Bytes())
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Open(nil, nonce, ct, nil)
|
||||
}
|
||||
|
||||
// sealKey dérive la clé AES de la boîte scellée (liée aux deux clés publiques).
|
||||
func sealKey(shared, ephPub, agentPub []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(shared)
|
||||
h.Write(ephPub)
|
||||
h.Write(agentPub)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
blk, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(blk)
|
||||
}
|
||||
|
||||
// handleE2EChat : chat chiffré de bout en bout, AUTHENTIFIÉ. L'enveloppe est
|
||||
// déchiffrée avec la clé du canal authentifié (liée à l'identité appairée de
|
||||
// l'utilisateur) — un relais ne peut ni la lire ni la forger. Chaque événement SSE
|
||||
// est chiffré. Le relais ne voit que de l'opaque.
|
||||
func handleE2EChat(w http.ResponseWriter, r *http.Request) {
|
||||
plain, key, err := e2eAuthOpenReq(r)
|
||||
if err != nil {
|
||||
http.Error(w, "e2e: "+err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
http.Error(w, "e2e: clé", 500)
|
||||
return
|
||||
}
|
||||
var body chatReq
|
||||
if err := json.Unmarshal(plain, &body); err != nil {
|
||||
http.Error(w, "e2e: requête", 400)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
// no-transform : interdit à Cloudflare (proxy orange sur ajean.link) de
|
||||
// bufferiser/compresser le flux — sinon les événements arrivent en retard.
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
mu, stop := sseHeartbeat(w, flusher)
|
||||
defer stop()
|
||||
emit := func(obj map[string]any) bool {
|
||||
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return false
|
||||
}
|
||||
sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if _, err := w.Write([]byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n")); err != nil {
|
||||
return false
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
runChatStream(r.Context(), body, emit)
|
||||
}
|
||||
|
||||
// handleE2EReq : proxy de CONTRÔLE chiffré de bout en bout. Même enveloppe que le
|
||||
// chat, mais le clair décrit un appel d'API interne {method, path, body}. On le
|
||||
// redispatche dans le handler web LOCAL (inner, déjà authentifié), puis on chiffre
|
||||
// la réponse {status, body}. Résultat : toute la gestion du serveur (presets, VRAM,
|
||||
// skills, service…) transite par le relais SANS qu'il en voie le contenu. « Zéro
|
||||
// exception » : le tunnel refuse tout /api/* en clair, seuls /api/e2e/* passent.
|
||||
func handleE2EReq(w http.ResponseWriter, r *http.Request, inner http.Handler) {
|
||||
plain, key, err := e2eAuthOpenReq(r)
|
||||
if err != nil {
|
||||
http.Error(w, "e2e: "+err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
http.Error(w, "e2e: clé", 500)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &req); err != nil {
|
||||
writeE2EResp(w, gcm, 400, []byte(`{"error":"requête invalide"}`))
|
||||
return
|
||||
}
|
||||
// Sécurité : on ne dispatche QUE des chemins d'API internes, jamais /api/e2e/*
|
||||
// (pas de récursion) ni autre chose (pas d'accès à l'UI/aux assets par ce biais).
|
||||
if !strings.HasPrefix(req.Path, "/api/") || strings.HasPrefix(req.Path, "/api/e2e") {
|
||||
writeE2EResp(w, gcm, 403, []byte(`{"error":"chemin interdit"}`))
|
||||
return
|
||||
}
|
||||
method := req.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
// Un chemin/méthode mal formé ferait paniquer httptest.NewRequest : on récupère.
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
writeE2EResp(w, gcm, 400, []byte(`{"error":"requête malformée"}`))
|
||||
}
|
||||
}()
|
||||
var bodyReader io.Reader
|
||||
if len(req.Body) > 0 && string(req.Body) != "null" {
|
||||
bodyReader = bytes.NewReader(req.Body)
|
||||
}
|
||||
ir := httptest.NewRequest(method, req.Path, bodyReader)
|
||||
ir.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
inner.ServeHTTP(rec, ir)
|
||||
respBody := rec.Body.Bytes()
|
||||
if len(respBody) == 0 {
|
||||
respBody = []byte("null")
|
||||
} else if !json.Valid(respBody) {
|
||||
// Réponse non-JSON (ex : http.Error en texte) : on l'enveloppe en chaîne.
|
||||
respBody, _ = json.Marshal(string(respBody))
|
||||
}
|
||||
writeE2EResp(w, gcm, rec.Code, respBody)
|
||||
}
|
||||
|
||||
// writeE2EResp chiffre {status, body} avec la clé du chat et l'écrit en base64
|
||||
// (nonce||ct). Le relais ne voit que de l'opaque.
|
||||
func writeE2EResp(w http.ResponseWriter, gcm cipher.AEAD, status int, body []byte) {
|
||||
out, _ := json.Marshal(map[string]any{"status": status, "body": json.RawMessage(body)})
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
http.Error(w, "e2e: nonce", 500)
|
||||
return
|
||||
}
|
||||
sealed := append(nonce, gcm.Seal(nil, nonce, out, nil)...)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package jean
|
||||
|
||||
// relay_e2eauth.go — authentification MUTUELLE + anti-rejeu du canal chiffré.
|
||||
//
|
||||
// Le scellé anonyme (boîte scellée) garantit la confidentialité mais PAS
|
||||
// l'authenticité : n'importe qui connaissant la clé publique de l'agent (dont un
|
||||
// relais compromis) peut fabriquer une enveloppe. Ici on ferme ce trou.
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// peut produire une requête valide ; le relais est totalement verrouillé.
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fenêtre d'acceptation de l'horodatage (anti-rejeu) : ±90 s pour tolérer une
|
||||
// dérive d'horloge raisonnable entre l'appareil et le serveur.
|
||||
const e2eAuthWindowMs = 90_000
|
||||
|
||||
// ---- Identités utilisateur appairées ---------------------------------------
|
||||
|
||||
var (
|
||||
authOnce sync.Once
|
||||
authMu sync.Mutex
|
||||
authSet = map[string]bool{}
|
||||
)
|
||||
|
||||
func authUsersPath() string { return filepath.Join(JeanHome(), ".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) {
|
||||
authSet[h] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isHexPub(h string) bool {
|
||||
if len(h) != 64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(h)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func isAuthorizedUser(uPubHex string) bool {
|
||||
loadAuthUsers()
|
||||
authMu.Lock()
|
||||
defer authMu.Unlock()
|
||||
return authSet[strings.ToLower(uPubHex)]
|
||||
}
|
||||
|
||||
func authorizeUser(uPubHex string) error {
|
||||
uPubHex = strings.ToLower(strings.TrimSpace(uPubHex))
|
||||
if !isHexPub(uPubHex) {
|
||||
return fmt.Errorf("clé publique invalide")
|
||||
}
|
||||
loadAuthUsers()
|
||||
authMu.Lock()
|
||||
defer authMu.Unlock()
|
||||
if authSet[uPubHex] {
|
||||
return nil
|
||||
}
|
||||
authSet[uPubHex] = true
|
||||
_ = os.MkdirAll(JeanHome(), 0o755)
|
||||
f, err := os.OpenFile(authUsersPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(uPubHex + "\n")
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
// un fichier JeanHome/.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.
|
||||
|
||||
const pairCodeTTL = 10 * time.Minute
|
||||
|
||||
type pairEntry struct {
|
||||
Hash string `json:"h"` // hex SHA-256 du code
|
||||
Exp int64 `json:"e"` // expiration (unix ms)
|
||||
}
|
||||
|
||||
func pairCodesPath() string { return filepath.Join(JeanHome(), ".pair_codes") }
|
||||
|
||||
func hashPairCode(code string) string {
|
||||
h := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code))))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
kept := es[:0]
|
||||
for _, e := range es {
|
||||
if e.Exp > now {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func savePairEntries(es []pairEntry) error {
|
||||
if err := os.MkdirAll(JeanHome(), 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
|
||||
}
|
||||
|
||||
// newPairCode génère un code frais (usage unique, 10 min), le persiste haché et
|
||||
// le retourne en clair.
|
||||
func newPairCode() (string, error) {
|
||||
b := make([]byte, 5) // 40 bits → 8 caractères base32
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
||||
es := append(loadPairEntries(), pairEntry{
|
||||
Hash: hashPairCode(code),
|
||||
Exp: time.Now().Add(pairCodeTTL).UnixMilli(),
|
||||
})
|
||||
if err := savePairEntries(es); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// consumePairCode renvoie true si le code est valide (non expiré, non déjà
|
||||
// utilisé) et le CONSOMME (usage unique).
|
||||
func consumePairCode(code string) bool {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return false
|
||||
}
|
||||
want := hashPairCode(code)
|
||||
es := loadPairEntries()
|
||||
out := make([]pairEntry, 0, len(es))
|
||||
found := false
|
||||
for _, e := range es {
|
||||
if !found && e.Hash == want {
|
||||
found = true // retiré du store = consommé
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if found {
|
||||
_ = savePairEntries(out)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// Limitation des tentatives d'appairage (anti-brute-force du code via le relais).
|
||||
var (
|
||||
pairFailMu sync.Mutex
|
||||
pairFails int
|
||||
pairLockAt time.Time
|
||||
)
|
||||
|
||||
func pairLocked() bool {
|
||||
pairFailMu.Lock()
|
||||
defer pairFailMu.Unlock()
|
||||
if pairFails >= 10 {
|
||||
if time.Since(pairLockAt) < 5*time.Minute {
|
||||
return true
|
||||
}
|
||||
pairFails = 0 // fenêtre expirée
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pairRecordFail() {
|
||||
pairFailMu.Lock()
|
||||
defer pairFailMu.Unlock()
|
||||
pairFails++
|
||||
pairLockAt = time.Now()
|
||||
}
|
||||
|
||||
func pairReset() {
|
||||
pairFailMu.Lock()
|
||||
defer pairFailMu.Unlock()
|
||||
pairFails = 0
|
||||
}
|
||||
|
||||
// ---- Anti-rejeu -------------------------------------------------------------
|
||||
|
||||
var (
|
||||
replayMu sync.Mutex
|
||||
replaySeen = map[string]time.Time{}
|
||||
)
|
||||
|
||||
// replayCheck renvoie true si la requête (upub|ts|iv) a déjà été vue (= rejeu).
|
||||
// Sinon elle l'enregistre. Purge opportuniste des entrées hors fenêtre.
|
||||
func replayCheck(uPub string, ts int64, iv string) bool {
|
||||
key := uPub + "|" + fmt.Sprint(ts) + "|" + iv
|
||||
now := time.Now()
|
||||
replayMu.Lock()
|
||||
defer replayMu.Unlock()
|
||||
if len(replaySeen) > 4096 {
|
||||
for k, t := range replaySeen {
|
||||
if now.Sub(t) > 2*e2eAuthWindowMs*time.Millisecond {
|
||||
delete(replaySeen, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := replaySeen[key]; ok {
|
||||
return true
|
||||
}
|
||||
replaySeen[key] = now
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- Dérivation de clé + ouverture authentifiée ----------------------------
|
||||
|
||||
// e2eAuthKey dérive la clé du canal authentifié pour l'utilisateur uPub :
|
||||
// SHA256( ECDH(agentPriv, uPub) || "ajean-authchan-v1" ). Symétrique de la version
|
||||
// navigateur (WASM) qui calcule ECDH(uPriv, agentPub) = même secret partagé.
|
||||
func e2eAuthKey(uPubHex string) ([]byte, error) {
|
||||
priv, err := e2ePrivateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uPubBytes, err := hex.DecodeString(uPubHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uPub, err := ecdh.X25519().NewPublicKey(uPubBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ss, err := priv.ECDH(uPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := sha256.New()
|
||||
h.Write(ss)
|
||||
h.Write([]byte("ajean-authchan-v1"))
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
func e2eAuthAAD(uPub string, ts int64) []byte {
|
||||
return []byte(uPub + "|" + fmt.Sprint(ts))
|
||||
}
|
||||
|
||||
// e2eAuthOpenReq lit l'enveloppe authentifiée {upub, ts, iv, ct} d'une requête,
|
||||
// vérifie l'appairage / l'horodatage / le rejeu, puis déchiffre. Renvoie le clair
|
||||
// et la clé de canal (réutilisée pour chiffrer la réponse).
|
||||
func e2eAuthOpenReq(r *http.Request) (plain []byte, key []byte, err error) {
|
||||
var env struct {
|
||||
UPub string `json:"upub"`
|
||||
Ts int64 `json:"ts"`
|
||||
Iv string `json:"iv"`
|
||||
Ct string `json:"ct"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&env); err != nil {
|
||||
return nil, nil, fmt.Errorf("enveloppe invalide")
|
||||
}
|
||||
if !isAuthorizedUser(env.UPub) {
|
||||
return nil, nil, fmt.Errorf("appareil non appairé (confirme le code d'appairage)")
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if d := now - env.Ts; d > e2eAuthWindowMs || d < -e2eAuthWindowMs {
|
||||
return nil, nil, fmt.Errorf("horodatage hors fenêtre")
|
||||
}
|
||||
if replayCheck(env.UPub, env.Ts, env.Iv) {
|
||||
return nil, nil, fmt.Errorf("rejeu détecté")
|
||||
}
|
||||
key, err = e2eAuthKey(env.UPub)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
iv, _ := base64.StdEncoding.DecodeString(env.Iv)
|
||||
ct, _ := base64.StdEncoding.DecodeString(env.Ct)
|
||||
plain, err = gcm.Open(nil, iv, ct, e2eAuthAAD(env.UPub, env.Ts))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("authentification échouée")
|
||||
}
|
||||
return plain, key, nil
|
||||
}
|
||||
|
||||
// handleE2EPair : appairage d'une identité utilisateur. Le navigateur scelle
|
||||
// {upub, code} vers la clé publique de l'agent (le relais ne peut ni l'ouvrir ni
|
||||
// connaître le code, affiché uniquement dans le log du serveur). Si le code matche,
|
||||
// uPub est enregistré comme autorisé.
|
||||
func handleE2EPair(w http.ResponseWriter, r *http.Request) {
|
||||
if pairLocked() {
|
||||
http.Error(w, "trop de tentatives d'appairage, réessaie plus tard", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Sealed string `json:"sealed"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "appairage: requête invalide", 400)
|
||||
return
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(body.Sealed)
|
||||
if err != nil {
|
||||
http.Error(w, "appairage: format", 400)
|
||||
return
|
||||
}
|
||||
plain, err := e2eOpenSeal(blob)
|
||||
if err != nil {
|
||||
pairRecordFail()
|
||||
http.Error(w, "appairage: sceau invalide", 400)
|
||||
return
|
||||
}
|
||||
var pm struct {
|
||||
UPub string `json:"upub"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &pm); err != nil {
|
||||
http.Error(w, "appairage: contenu", 400)
|
||||
return
|
||||
}
|
||||
if !consumePairCode(pm.Code) {
|
||||
pairRecordFail()
|
||||
http.Error(w, "appairage: code incorrect ou expiré", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := authorizeUser(pm.UPub); err != nil {
|
||||
http.Error(w, "appairage: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
pairReset()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package jean
|
||||
|
||||
// relay_link.go — `jean link <token>` : 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 (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"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
|
||||
}
|
||||
|
||||
// linkServiceName est l'unité systemd qui exécute le worker « jean link --foreground ».
|
||||
const linkServiceName = "jean-link"
|
||||
|
||||
func cmdLink(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "":
|
||||
// `jean 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`.
|
||||
// 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: jean link <token>")
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
case "logout":
|
||||
_ = os.Remove(linkTokenPath())
|
||||
fmt.Println(green("[ok]") + " token supprimé")
|
||||
return nil
|
||||
case "stop":
|
||||
return linkServiceCtl("stop")
|
||||
case "restart":
|
||||
if err := linkServiceCtl("restart"); err != nil {
|
||||
return err
|
||||
}
|
||||
return linkPrintIdentity()
|
||||
case "code":
|
||||
code, err := newPairCode()
|
||||
if err != nil {
|
||||
return fmt.Errorf("génération du code (droits sur %s ?): %w", JeanHome(), err)
|
||||
}
|
||||
fmt.Printf("%s code d'appairage (valable 10 min, à usage unique) :\n %s\n", green("[link]"), bold(code))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Un argument restant n'est traité comme TOKEN 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).
|
||||
if !strings.HasPrefix(sub, "jl_") {
|
||||
fmt.Fprintf(os.Stderr, "%s sous-commande inconnue : %q\n\n", yellow("[link]"), sub)
|
||||
printLinkHelp()
|
||||
return fmt.Errorf("sous-commande link inconnue: %s", sub)
|
||||
}
|
||||
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: jean link <token> (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 — « jean link restart » pour le relancer\n", yellow("[info]"), linkServiceName)
|
||||
default:
|
||||
if err := linkServiceCtl("start"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return linkPrintIdentity()
|
||||
}
|
||||
|
||||
// printLinkHelp liste les sous-commandes de `jean link`.
|
||||
func printLinkHelp() {
|
||||
fmt.Print(`jean link — accès distant via le relais ajean.link
|
||||
|
||||
Usage :
|
||||
jean link <token> 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é
|
||||
|
||||
Le token est fourni sur ajean.link. « jean link » seul affiche cette aide.
|
||||
`)
|
||||
}
|
||||
|
||||
// linkPrintIdentity affiche l'empreinte E2E (à confirmer une fois) et un code
|
||||
// d'appairage frais (à saisir une fois) pour le portail.
|
||||
func linkPrintIdentity() error {
|
||||
if fp := e2eFingerprint(); fp != "" {
|
||||
fmt.Printf("\n%s empreinte E2E de cette machine :\n %s\n", green("[e2e]"), bold(fp))
|
||||
fmt.Printf(" Confirme-la dans le portail (Mon compte → serveur) pour activer la boîte noire.\n")
|
||||
}
|
||||
code, err := newPairCode()
|
||||
if err != nil {
|
||||
fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), JeanHome(), err)
|
||||
fmt.Printf(" Réessaie : sudo jean link code\n")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" Code d'appairage (valable 10 min, usage unique) : %s\n\n", bold(code))
|
||||
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: jean link <token>")
|
||||
}
|
||||
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")
|
||||
if fp := e2eFingerprint(); fp != "" {
|
||||
fmt.Printf("%s empreinte E2E : %s (code d'appairage : jean 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
|
||||
// historique → confusion. Désormais le process jean-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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
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)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s UI locale sur http://%s (même conversation que le tunnel)\n", green("[web]"), addr)
|
||||
_ = http.Serve(ln, mux)
|
||||
}
|
||||
|
||||
// linkServiceCtl pilote l'unité systemd jean-link (start/stop/restart), avec sudo
|
||||
// non interactif si on n'est pas root. Linux uniquement (le relais cible est Linux).
|
||||
func linkServiceCtl(action string) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("gestion du service %s : Linux/systemd uniquement (lance « jean link --foreground » directement)", linkServiceName)
|
||||
}
|
||||
bin, pre := "systemctl", []string{}
|
||||
if os.Geteuid() != 0 {
|
||||
bin, pre = "sudo", []string{"-n", "systemctl"}
|
||||
}
|
||||
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)
|
||||
}
|
||||
switch action {
|
||||
case "start", "restart":
|
||||
fmt.Printf("%s service %s %s\n", green("[ok]"), linkServiceName, action+"é")
|
||||
case "stop":
|
||||
fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// linkServiceActive indique si l'unité systemd jean-link tourne.
|
||||
func linkServiceActive() bool {
|
||||
if runtime.GOOS != "linux" {
|
||||
return false
|
||||
}
|
||||
out, _ := exec.Command("systemctl", "is-active", linkServiceName).Output()
|
||||
return strings.TrimSpace(string(out)) == "active"
|
||||
}
|
||||
|
||||
// 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/<machine>/v1
|
||||
// - tout le reste → l'UI web de Jean (avec injection de la clé de pilotage)
|
||||
func newLinkHandler(mux *http.ServeMux) http.Handler {
|
||||
web := withLocalAuth(mux)
|
||||
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)
|
||||
}
|
||||
// Boîte noire « zéro exception » : TOUTE l'API (chat ET contrôle) ne transite
|
||||
// que chiffrée de bout en bout. Le relais ne voit jamais de clair.
|
||||
// - /api/e2e/chat : chat chiffré (streaming SSE chiffré).
|
||||
// - /api/e2e/req : proxy de contrôle chiffré (presets, VRAM, skills, service…).
|
||||
// Tout autre /api/* en clair est REFUSÉ via le tunnel.
|
||||
oaiAllowed := os.Getenv("JEAN_LINK_ALLOW_OAI") == "1"
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
if strings.HasPrefix(p, "/api/e2e/") {
|
||||
if p == "/api/e2e/req" {
|
||||
handleE2EReq(w, r, web) // dispatche dans le handler local authentifié
|
||||
return
|
||||
}
|
||||
if p == "/api/e2e/pair" {
|
||||
handleE2EPair(w, r) // appairage d'une identité utilisateur (code hors-bande)
|
||||
return
|
||||
}
|
||||
web.ServeHTTP(w, r) // /api/e2e/chat est routé par le mux
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(p, "/api/") {
|
||||
http.Error(w, "via le relais : seuls les endpoints chiffrés /api/e2e/* sont autorisés (boîte noire)", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {
|
||||
// L'endpoint OpenAI (OpenCode/Hermes) ne peut pas être chiffré navigateur :
|
||||
// il transiterait en clair par le relais. Désactivé par défaut.
|
||||
if !oaiAllowed {
|
||||
http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; JEAN_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
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, oaiTLS *tls.Config) 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 pk := e2ePubHex(); pk != "" {
|
||||
q.Set("pk", pk) // clé publique E2E → publiée au relais pour le scellement navigateur
|
||||
}
|
||||
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]"))
|
||||
|
||||
// Chaque Accept() = un stream. Deux natures possibles :
|
||||
// - requête HTTP normale (UI Jean / E2E) → servie par `handler` ;
|
||||
// - session TLS brute (accès OpenAI public) → terminée par le front TLS.
|
||||
// On les distingue au 1er octet (0x16 = handshake TLS). Sans OAI activé, tout
|
||||
// va au HTTP (comportement historique inchangé).
|
||||
httpLn := newChanListener(sess.Addr())
|
||||
defer httpLn.Close()
|
||||
srv := &http.Server{Handler: handler}
|
||||
go srv.Serve(httpLn)
|
||||
|
||||
var oaiLn *chanListener
|
||||
if oaiTLS != nil {
|
||||
oaiLn = newChanListener(sess.Addr())
|
||||
defer oaiLn.Close()
|
||||
go runOAIFront(oaiLn, oaiTLS)
|
||||
}
|
||||
|
||||
for {
|
||||
stream, err := sess.Accept()
|
||||
if err != nil {
|
||||
return fmt.Errorf("accept: %w", err)
|
||||
}
|
||||
go demuxTunnelStream(stream, httpLn, oaiLn)
|
||||
}
|
||||
}
|
||||
|
||||
// demuxTunnelStream aiguille un stream du tunnel selon son 1er octet : 0x16 (TLS)
|
||||
// → front OAI ; sinon → HTTP. On consulte l'octet sans le consommer (peekedConn).
|
||||
func demuxTunnelStream(stream net.Conn, httpLn, oaiLn *chanListener) {
|
||||
br := bufio.NewReader(stream)
|
||||
b, err := br.Peek(1)
|
||||
if err != nil {
|
||||
stream.Close()
|
||||
return
|
||||
}
|
||||
pc := &peekedConn{Conn: stream, r: br}
|
||||
if b[0] == 0x16 { // handshake TLS = accès OpenAI public
|
||||
if oaiLn != nil && oaiPublicEnabled() {
|
||||
oaiLn.push(pc)
|
||||
} else {
|
||||
stream.Close() // public désactivé → on refuse (fail-closed)
|
||||
}
|
||||
return
|
||||
}
|
||||
httpLn.push(pc)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.4.4"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
|
||||
func Main() {
|
||||
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
|
||||
// nouvelle mémoire (MEMORY/<nom>.md). Idempotente, silencieuse si rien à faire.
|
||||
migrateSkillsToMemory()
|
||||
// Nettoie un éventuel binaire .old laissé par une mise à jour Windows.
|
||||
cleanupOldBinary()
|
||||
|
||||
args := os.Args[1:]
|
||||
noArgs := len(args) == 0
|
||||
cmd := "help"
|
||||
if len(args) > 0 {
|
||||
cmd = args[0]
|
||||
args = args[1:]
|
||||
}
|
||||
// Double-clic sur le binaire (aucun argument, console fraîche) → on lance
|
||||
// l'expérience « application » (UI web + navigateur) plutôt que d'afficher
|
||||
// l'aide dans une console qui se referme aussitôt. Lancé depuis un shell,
|
||||
// `jean` sans argument garde son comportement d'aide.
|
||||
if noArgs && launchedByDoubleClick() {
|
||||
relaunchDetachedApp() // relance sans console (ne revient pas), puis quitte l'original
|
||||
return
|
||||
}
|
||||
switch cmd {
|
||||
case "app":
|
||||
mustExit(cmdApp(args))
|
||||
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 "set-web-key":
|
||||
mustExit(cmdSetWebKey(args))
|
||||
case "vram":
|
||||
mustExit(showVram())
|
||||
case "gpu":
|
||||
mustExit(cmdGPU(args))
|
||||
case "switch":
|
||||
mustExit(cmdSwitch(args))
|
||||
case "chat":
|
||||
mustExit(cmdChat(args))
|
||||
case "web":
|
||||
mustExit(cmdWeb(args))
|
||||
case "link":
|
||||
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.
|
||||
mustExit(cmdAgent(args))
|
||||
case "internet", "web-access":
|
||||
mustExit(cmdInternet(args))
|
||||
case "memory", "mem":
|
||||
mustExit(cmdMemory(args))
|
||||
case "serve":
|
||||
mustExit(cmdServe(args))
|
||||
case "test":
|
||||
mustExit(cmdTest(args))
|
||||
case "bench":
|
||||
mustExit(cmdBench(args))
|
||||
case "llamacpp", "llama":
|
||||
mustExit(cmdLlamacpp(args))
|
||||
case "update", "upgrade", "self-update":
|
||||
mustExit(cmdUpdate(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 <commande> [args]
|
||||
|
||||
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)
|
||||
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 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)
|
||||
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 + mode agent
|
||||
internet [on|off|status|url <url>] accès web de l'IA via un serveur Crawl4AI (web_search/open/read/grep)
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto)
|
||||
|
||||
Accès distant (ajean.link) :
|
||||
link <token> 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 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')
|
||||
|
||||
Mode agent:
|
||||
agent [on|off|status] active TOUS les outils de l'IA (shell complet + mémoire) — un seul interrupteur
|
||||
|
||||
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 (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config)
|
||||
uninstall désinstaller
|
||||
update [--check] mettre à jour jean 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)
|
||||
|
||||
Config: $JEAN_HOME/config.env
|
||||
`, Version)
|
||||
}
|
||||
|
||||
func mustExit(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[err]", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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=<path>. 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 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") }
|
||||
|
||||
// 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 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
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//go:build darwin
|
||||
|
||||
package jean
|
||||
|
||||
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/jean », cf. issue #4).
|
||||
// Implémentation prudente ; à valider sur une machine Apple.
|
||||
|
||||
const configTemplate = `# Configuration JEAN — édite-moi puis: jean 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, UserName, WorkingDirectory,
|
||||
// JEAN_HOME, StandardOutPath, StandardErrorPath. KeepAlive/SuccessfulExit=false
|
||||
// ≈ Restart=on-failure ; RunAtLoad relance au boot une fois chargé avec `-w`.
|
||||
const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>%s</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/jean</string>
|
||||
<string>serve</string>
|
||||
</array>
|
||||
<key>UserName</key><string>%s</string>
|
||||
<key>WorkingDirectory</key><string>%s</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict><key>JEAN_HOME</key><string>%s</string></dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key>
|
||||
<dict><key>SuccessfulExit</key><false/></dict>
|
||||
<key>StandardOutPath</key><string>%s</string>
|
||||
<key>StandardErrorPath</key><string>%s</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
|
||||
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 (launchd)\n", svc)
|
||||
|
||||
// 1. Répertoires
|
||||
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. config.env si absent
|
||||
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 idempotent vers /usr/local/bin/jean — 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 := "/usr/local/bin/jean"
|
||||
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)
|
||||
}
|
||||
|
||||
// 4. /etc/default/jean pour que les invocations CLI résolvent 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 {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" %s /etc/default/jean\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)
|
||||
plistPath := launchdPlistPath(svc)
|
||||
if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" %s %s\n", green("✓"), plistPath)
|
||||
|
||||
// 6. chown JEAN_HOME au user cible
|
||||
chown(jeanHome, 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+" jean 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"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdUninstall(args []string) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("jean 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/jean",
|
||||
"/usr/local/bin/jean",
|
||||
} {
|
||||
if err := os.Remove(p); err == nil {
|
||||
fmt.Printf(" %s %s\n", green("✓"), p)
|
||||
}
|
||||
}
|
||||
// On ne supprime jamais JEAN_HOME automatiquement (modèles, configs).
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//go:build linux
|
||||
|
||||
package jean
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
if rp, e := filepath.EvalSymlinks(self); e == nil {
|
||||
self = rp
|
||||
}
|
||||
target := "/usr/local/bin/jean"
|
||||
// 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
|
||||
// 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).
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//go:build windows
|
||||
|
||||
package jean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 sys_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("•"))
|
||||
}
|
||||
|
||||
// 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
|
||||
// /usr/local/bin symlink the Unix installer creates).
|
||||
binDir := filepath.Join(jeanHome, "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
onPath := false
|
||||
if dst, err := installSelf(binDir); 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)
|
||||
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)
|
||||
onPath = true
|
||||
default:
|
||||
fmt.Printf(" %s %s déjà dans le PATH\n", dim("•"), binDir)
|
||||
onPath = true
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
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 {
|
||||
fmt.Printf("\n%s pour exécuter 'jean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func installSelf(binDir string) (string, error) {
|
||||
src, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
src, _ = filepath.EvalSymlinks(src)
|
||||
dst := filepath.Join(binDir, "jean.exe")
|
||||
if strings.EqualFold(src, dst) {
|
||||
return dst, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// addToUserPath appends dir to the per-user PATH (HKCU\Environment) persistently,
|
||||
// without admin rights and without setx's 1024-char truncation. Returns false if
|
||||
// dir was already present. The change applies to newly launched shells.
|
||||
func addToUserPath(dir string) (bool, error) {
|
||||
// Read the *user* PATH (not the process PATH, which is User+Machine merged),
|
||||
// edit it, and write it back, all via PowerShell's environment API which
|
||||
// handles the registry REG_EXPAND_SZ type and the WM_SETTINGCHANGE broadcast.
|
||||
ps := fmt.Sprintf(`$d=%s
|
||||
$p=[Environment]::GetEnvironmentVariable('Path','User')
|
||||
if (-not $p) { $p='' }
|
||||
$parts=$p.Split(';') | Where-Object { $_ -ne '' }
|
||||
if ($parts -contains $d) { Write-Output 'present'; exit 0 }
|
||||
$new=(@($parts) + $d) -join ';'
|
||||
[Environment]::SetEnvironmentVariable('Path',$new,'User')
|
||||
Write-Output 'added'`, psQuote(dir))
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
|
||||
outBytes, err := cmd.CombinedOutput()
|
||||
out := strings.TrimSpace(string(outBytes))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%v: %s", err, out)
|
||||
}
|
||||
return strings.Contains(out, "added"), nil
|
||||
}
|
||||
|
||||
// removeFromUserPath drops dir from the per-user PATH if present. Returns false
|
||||
// if it wasn't there.
|
||||
func removeFromUserPath(dir string) (bool, error) {
|
||||
ps := fmt.Sprintf(`$d=%s
|
||||
$p=[Environment]::GetEnvironmentVariable('Path','User')
|
||||
if (-not $p) { Write-Output 'absent'; exit 0 }
|
||||
$parts=$p.Split(';') | Where-Object { $_ -ne '' -and $_ -ne $d }
|
||||
if (($p.Split(';') | Where-Object { $_ -eq $d }).Count -eq 0) { Write-Output 'absent'; exit 0 }
|
||||
[Environment]::SetEnvironmentVariable('Path',($parts -join ';'),'User')
|
||||
Write-Output 'removed'`, psQuote(dir))
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
|
||||
outBytes, err := cmd.CombinedOutput()
|
||||
out := strings.TrimSpace(string(outBytes))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%v: %s", err, out)
|
||||
}
|
||||
return strings.Contains(out, "removed"), nil
|
||||
}
|
||||
|
||||
// psQuote wraps s in a PowerShell single-quoted string literal (doubling any
|
||||
// embedded single quotes), safe against spaces and metacharacters in the path.
|
||||
func psQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Pull JEAN_HOME\bin off the user PATH (best-effort).
|
||||
binDir := filepath.Join(JeanHome(), "bin")
|
||||
if removed, err := removeFromUserPath(binDir); err == nil && removed {
|
||||
fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//go:build unix
|
||||
|
||||
package jean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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" }
|
||||
|
||||
// hideCmd : no-op sur Unix (pas de fenêtre de console à masquer).
|
||||
func hideCmd(cmd *exec.Cmd) *exec.Cmd { return cmd }
|
||||
|
||||
// openBrowser ouvre l'URL dans le navigateur par défaut (macOS: open, Linux:
|
||||
// xdg-open). Best-effort.
|
||||
func openBrowser(url string) error {
|
||||
bin := "xdg-open"
|
||||
if runtime.GOOS == "darwin" {
|
||||
bin = "open"
|
||||
}
|
||||
return exec.Command(bin, url).Start()
|
||||
}
|
||||
|
||||
// totalRAMGB renvoie la RAM physique totale en Go (Linux: /proc/meminfo,
|
||||
// macOS: sysctl hw.memsize).
|
||||
func totalRAMGB() float64 {
|
||||
if runtime.GOOS == "darwin" {
|
||||
out, err := exec.Command("sysctl", "-n", "hw.memsize").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64); err == nil {
|
||||
return v / (1024 * 1024 * 1024)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
b, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 2 {
|
||||
if kb, err := strconv.ParseFloat(f[1], 64); err == nil {
|
||||
return kb / (1024 * 1024) // kB → Go
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// launchedByDoubleClick : sur Unix, jean tourne en service/CLI, on ne déclenche
|
||||
// jamais le mode app sur un simple no-arg (éviterait de surprendre un serveur).
|
||||
// L'expérience app double-clic est propre à Windows/macOS (coque native, phase 3).
|
||||
func launchedByDoubleClick() bool { return false }
|
||||
|
||||
// setLibraryPath ensures llama-server can load shared libs bundled next to the
|
||||
// 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) {
|
||||
parts := []string{dir}
|
||||
parts = append(parts, cudaLibDirs()...)
|
||||
if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" {
|
||||
parts = append(parts, existing)
|
||||
}
|
||||
_ = 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
|
||||
}
|
||||
|
||||
// autoInstallTool installs a missing build tool with the system package manager
|
||||
// (apt/dnf/pacman on Linux, brew on macOS). Best-effort: returns an error when no
|
||||
// known manager is available or the install fails. Uses sudo on Linux when not
|
||||
// already root (brew refuses to run as root).
|
||||
func autoInstallTool(name string) error {
|
||||
managers := []struct {
|
||||
bin string
|
||||
install []string // args before the package name
|
||||
}{
|
||||
{"apt-get", []string{"install", "-y"}},
|
||||
{"dnf", []string{"install", "-y"}},
|
||||
{"pacman", []string{"-S", "--noconfirm"}},
|
||||
{"brew", []string{"install"}},
|
||||
}
|
||||
for _, m := range managers {
|
||||
if _, err := exec.LookPath(m.bin); err != nil {
|
||||
continue
|
||||
}
|
||||
argv := append(append([]string{m.bin}, m.install...), name)
|
||||
if m.bin != "brew" && os.Geteuid() != 0 {
|
||||
if _, err := exec.LookPath("sudo"); err != nil {
|
||||
return fmt.Errorf("%s requiert root (ni root ni sudo disponibles)", m.bin)
|
||||
}
|
||||
argv = append([]string{"sudo"}, argv...)
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
return fmt.Errorf("aucun gestionnaire de paquets connu — installe %s manuellement", name)
|
||||
}
|
||||
|
||||
// msvcGenerator is Windows-only; on Unix the default CMake generator (Unix
|
||||
// Makefiles) is correct, so detectBuildPlan never calls this for real. Present
|
||||
// only so the shared code compiles.
|
||||
func msvcGenerator() string { return "" }
|
||||
|
||||
// ensureCompiler makes sure a C/C++ toolchain (cc + c++ + make) is present,
|
||||
// installing it via the system package manager when missing. build-essential on
|
||||
// Debian/Ubuntu pulls the lot; elsewhere we fall back to individual packages.
|
||||
func ensureCompiler() error {
|
||||
haveCC := hasTool("cc") || hasTool("gcc") || hasTool("clang")
|
||||
haveCXX := hasTool("c++") || hasTool("g++") || hasTool("clang++")
|
||||
if haveCC && haveCXX && hasTool("make") {
|
||||
return nil
|
||||
}
|
||||
candidates := []string{"build-essential", "gcc", "g++", "make"}
|
||||
if _, err := exec.LookPath("apt-get"); err != nil {
|
||||
// Non-Debian: build-essential n'existe pas, on vise les paquets directs.
|
||||
candidates = []string{"gcc", "gcc-c++", "make"}
|
||||
}
|
||||
fmt.Println(yellow("[info]") + " compilateur C/C++ absent — installation via le gestionnaire de paquets…")
|
||||
for _, pkg := range candidates {
|
||||
_ = autoInstallTool(pkg) // best-effort, paquets variables selon la distro
|
||||
}
|
||||
if (hasTool("cc") || hasTool("gcc")) && (hasTool("c++") || hasTool("g++")) && hasTool("make") {
|
||||
fmt.Println(green("✓") + " compilateur C/C++ prêt.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement")
|
||||
}
|
||||
|
||||
// cudaPathEnv is Windows-specific (the MSBuild CUDA integration needs CUDA_PATH);
|
||||
// on Unix the Makefiles/Ninja generators find nvcc via PATH/CUDACXX, so there's
|
||||
// nothing extra to inject.
|
||||
func cudaPathEnv(toolkitDir string) []string { return nil }
|
||||
|
||||
// ensureAccelerator is a no-op on Unix: CUDA/ROCm toolkits are installed through
|
||||
// the distro (their layout is already probed by findNvcc / detectBuildPlan), and
|
||||
// auto-installing multi-GB GPU toolkits across distros is too varied to do safely.
|
||||
func ensureAccelerator() {}
|
||||
|
||||
// refreshToolPath is a no-op on Unix: package managers install into directories
|
||||
// already on PATH (/usr/bin, /usr/local/bin), unlike Windows.
|
||||
func refreshToolPath() {}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//go:build windows
|
||||
|
||||
package jean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// init makes the Windows console behave like a modern terminal: UTF-8 so the
|
||||
// Unicode glyphs jean prints (✓ ▶ …) and child-process output don't turn into
|
||||
// mojibake (the default OEM codepage, e.g. cp850, renders "✓" as "├ö"), and VT
|
||||
// processing so the ANSI colour/cursor escapes (including the build progress
|
||||
// line) are interpreted instead of printed literally. Best-effort: a redirected
|
||||
// or legacy console just keeps its defaults.
|
||||
func init() {
|
||||
const (
|
||||
cpUTF8 = 65001
|
||||
enableVirtualTerminalProcessing = 0x0004
|
||||
stdOutputHandle = ^uintptr(10) // -11 as DWORD
|
||||
)
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
_, _, _ = kernel32.NewProc("SetConsoleOutputCP").Call(uintptr(cpUTF8))
|
||||
_, _, _ = kernel32.NewProc("SetConsoleCP").Call(uintptr(cpUTF8))
|
||||
|
||||
getStdHandle := kernel32.NewProc("GetStdHandle")
|
||||
getConsoleMode := kernel32.NewProc("GetConsoleMode")
|
||||
setConsoleMode := kernel32.NewProc("SetConsoleMode")
|
||||
h, _, _ := getStdHandle.Call(stdOutputHandle)
|
||||
var mode uint32
|
||||
if r, _, _ := getConsoleMode.Call(h, uintptr(unsafe.Pointer(&mode))); r != 0 {
|
||||
_, _, _ = setConsoleMode.Call(h, uintptr(mode|enableVirtualTerminalProcessing))
|
||||
}
|
||||
}
|
||||
|
||||
// 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" }
|
||||
|
||||
// openBrowser ouvre l'URL dans le navigateur par défaut. rundll32 évite les
|
||||
// pièges de quoting de `cmd /c start`.
|
||||
func openBrowser(url string) error {
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
}
|
||||
|
||||
// hideCmd empêche une commande externe d'ouvrir une fenêtre de console
|
||||
// (CREATE_NO_WINDOW). Indispensable quand Jean tourne sans console (mode app) :
|
||||
// sinon chaque `nvidia-smi`/`git`/… ferait clignoter une fenêtre noire. Fusionne
|
||||
// avec les flags déjà présents pour ne pas écraser un éventuel détachement.
|
||||
func hideCmd(cmd *exec.Cmd) *exec.Cmd {
|
||||
const createNoWindow = 0x08000000
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.HideWindow = true
|
||||
cmd.SysProcAttr.CreationFlags |= createNoWindow
|
||||
return cmd
|
||||
}
|
||||
|
||||
// totalRAMGB renvoie la RAM physique totale en Go (GlobalMemoryStatusEx).
|
||||
func totalRAMGB() float64 {
|
||||
var m struct {
|
||||
dwLength uint32
|
||||
dwMemoryLoad uint32
|
||||
ullTotalPhys uint64
|
||||
ullAvailPhys uint64
|
||||
ullTotalPageFile uint64
|
||||
ullAvailPageFile uint64
|
||||
ullTotalVirtual uint64
|
||||
ullAvailVirtual uint64
|
||||
ullAvailExtendedVirtual uint64
|
||||
}
|
||||
m.dwLength = uint32(unsafe.Sizeof(m))
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
r, _, _ := kernel32.NewProc("GlobalMemoryStatusEx").Call(uintptr(unsafe.Pointer(&m)))
|
||||
if r == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(m.ullTotalPhys) / (1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
// launchedByDoubleClick indique qu'on a été lancé par un double-clic (Explorer)
|
||||
// et non depuis un shell existant. GetConsoleProcessList renvoie le nombre de
|
||||
// process attachés à la console : 1 = on possède une console fraîche (double-
|
||||
// clic), >1 = on a hérité de la console d'un shell (cmd/PowerShell), auquel cas
|
||||
// l'utilisateur veut la CLI, pas l'app.
|
||||
func launchedByDoubleClick() bool {
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
proc := kernel32.NewProc("GetConsoleProcessList")
|
||||
var pids [4]uint32
|
||||
r, _, _ := proc.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
|
||||
return r == 1
|
||||
}
|
||||
|
||||
// setLibraryPath ensures llama-server can load its dependent DLLs. Windows
|
||||
// resolves them via PATH (and the binary's own directory), so we prepend dir.
|
||||
// For a CUDA build we must also add the CUDA Toolkit's bin: ggml-cuda.dll links
|
||||
// against cublas64_*/cublasLt64_*.dll which live there, not next to the binary —
|
||||
// without it the server dies with 0xC0000135 (DLL not found) unless the launching
|
||||
// shell happened to have CUDA on PATH.
|
||||
func setLibraryPath(dir string) {
|
||||
parts := []string{dir}
|
||||
if nvcc := findNvcc(); nvcc != "" {
|
||||
binDir := filepath.Dir(nvcc) // …\CUDA\vX.Y\bin
|
||||
parts = append(parts, binDir)
|
||||
// CUDA 13+ a déplacé les DLL runtime (cublas64_*, cublasLt64_*, cudart64_*)
|
||||
// dans bin\x64\ ; sur CUDA 12 elles sont directement dans bin\. On ajoute
|
||||
// les deux pour couvrir les deux layouts.
|
||||
if x64 := filepath.Join(binDir, "x64"); isDir(x64) {
|
||||
parts = append(parts, x64)
|
||||
}
|
||||
}
|
||||
if existing := os.Getenv("PATH"); existing != "" {
|
||||
parts = append(parts, existing)
|
||||
}
|
||||
_ = os.Setenv("PATH", strings.Join(parts, string(os.PathListSeparator)))
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// wingetIDs maps a tool's command name to its winget package ID.
|
||||
var wingetIDs = map[string]string{
|
||||
"git": "Git.Git",
|
||||
"cmake": "Kitware.CMake",
|
||||
"ninja": "Ninja-build.Ninja",
|
||||
}
|
||||
|
||||
// autoInstallTool installs a missing build tool via winget (bundled with Windows
|
||||
// 10/11 and Server 2025). Returns an error if winget is absent or the install
|
||||
// fails; the caller re-checks availability afterwards.
|
||||
func autoInstallTool(name string) error {
|
||||
if _, err := exec.LookPath("winget"); err != nil {
|
||||
return fmt.Errorf("winget introuvable — installe %s manuellement", name)
|
||||
}
|
||||
id, ok := wingetIDs[name]
|
||||
if !ok {
|
||||
id = name
|
||||
}
|
||||
cmd := exec.Command("winget", "install", "--id", id, "-e",
|
||||
"--accept-source-agreements", "--accept-package-agreements",
|
||||
"--disable-interactivity", "--silent")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// refreshToolPath reloads the process PATH from the Windows registry (Machine +
|
||||
// User), so tools just installed by winget become resolvable without restarting
|
||||
// the shell.
|
||||
func refreshToolPath() {
|
||||
ps := `$m=[Environment]::GetEnvironmentVariable('Path','Machine')
|
||||
$u=[Environment]::GetEnvironmentVariable('Path','User')
|
||||
Write-Output ((@($m,$u) | Where-Object { $_ }) -join ';')`
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps).Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if merged := strings.TrimSpace(string(out)); merged != "" {
|
||||
_ = os.Setenv("PATH", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// vswherePath returns the location of vswhere.exe, the official tool for
|
||||
// locating Visual Studio / Build Tools installs. It ships in a fixed spot.
|
||||
func vswherePath() string {
|
||||
base := os.Getenv("ProgramFiles(x86)")
|
||||
if base == "" {
|
||||
base = os.Getenv("ProgramFiles")
|
||||
}
|
||||
return filepath.Join(base, "Microsoft Visual Studio", "Installer", "vswhere.exe")
|
||||
}
|
||||
|
||||
// msvcInstallVersion returns the major version of the newest MSVC install that
|
||||
// has the C++ toolchain (e.g. "17"), or "" if none is found.
|
||||
func msvcInstallVersion() string {
|
||||
vs := vswherePath()
|
||||
if _, err := os.Stat(vs); err != nil {
|
||||
return ""
|
||||
}
|
||||
out, err := exec.Command(vs, "-latest", "-products", "*",
|
||||
"-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
|
||||
"-property", "installationVersion").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
if ver == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexByte(ver, '.'); i > 0 {
|
||||
return ver[:i]
|
||||
}
|
||||
return ver
|
||||
}
|
||||
|
||||
// msvcGenerator returns the CMake generator name for the installed MSVC, falling
|
||||
// back to VS 2022 (the version `ensureCompiler` installs).
|
||||
func msvcGenerator() string {
|
||||
switch msvcInstallVersion() {
|
||||
case "16":
|
||||
return "Visual Studio 16 2019"
|
||||
case "15":
|
||||
return "Visual Studio 15 2017"
|
||||
default:
|
||||
return "Visual Studio 17 2022"
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Windows box.
|
||||
func ensureCompiler() error {
|
||||
if msvcInstallVersion() != "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := exec.LookPath("winget"); err != nil {
|
||||
return fmt.Errorf("compilateur C++ absent et winget introuvable — installe « Visual Studio Build Tools » (charge de travail C++) manuellement")
|
||||
}
|
||||
fmt.Printf("%s compilateur C++ absent — installation des Build Tools MSVC (gros téléchargement, une seule fois)…\n", yellow("[info]"))
|
||||
cmd := exec.Command("winget", "install", "--id", "Microsoft.VisualStudio.2022.BuildTools", "-e",
|
||||
"--accept-source-agreements", "--accept-package-agreements",
|
||||
"--disable-interactivity",
|
||||
"--override", "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("installation des Build Tools MSVC échouée: %w", err)
|
||||
}
|
||||
if msvcInstallVersion() == "" {
|
||||
return fmt.Errorf("Build Tools installés mais toolchain C++ introuvable — relance la commande ou vérifie l'installation Visual Studio")
|
||||
}
|
||||
fmt.Printf("%s compilateur C++ prêt.\n", green("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAccelerator installs the CUDA Toolkit when an NVIDIA GPU is present but
|
||||
// nvcc isn't, so the build can target the GPU instead of falling back to CPU.
|
||||
// Best-effort: any failure just leaves the machine on the CPU path (the caller
|
||||
// ignores the return value). The CUDA download is large; we only trigger it when
|
||||
// a GPU is actually detected.
|
||||
func ensureAccelerator() {
|
||||
if !hasNvidiaGPU() {
|
||||
return // pas de GPU NVIDIA visible → rien à installer, build CPU
|
||||
}
|
||||
if findNvcc() != "" {
|
||||
return // toolkit déjà présent
|
||||
}
|
||||
if _, err := exec.LookPath("winget"); err != nil {
|
||||
fmt.Printf("%s GPU NVIDIA détecté mais CUDA Toolkit absent et winget introuvable — build CPU (installe le CUDA Toolkit pour l'accélération GPU)\n", yellow("[info]"))
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s GPU NVIDIA détecté — installation du CUDA Toolkit pour l'accélération GPU (gros téléchargement, une seule fois)…\n", yellow("[info]"))
|
||||
cmd := exec.Command("winget", "install", "--id", "Nvidia.CUDA", "-e",
|
||||
"--accept-source-agreements", "--accept-package-agreements",
|
||||
"--disable-interactivity")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Printf("%s installation du CUDA Toolkit échouée (%v) — on continue en CPU\n", yellow("[warn]"), err)
|
||||
return
|
||||
}
|
||||
refreshToolPath()
|
||||
if findNvcc() != "" {
|
||||
fmt.Printf("%s CUDA Toolkit prêt — build GPU activé.\n", green("✓"))
|
||||
} else {
|
||||
fmt.Printf("%s CUDA Toolkit installé mais nvcc introuvable dans cette session — relance la commande pour activer le GPU\n", yellow("[info]"))
|
||||
}
|
||||
}
|
||||
|
||||
// cudaPathEnv returns the CUDA toolkit env vars the MSBuild CUDA integration
|
||||
// needs (CUDA_PATH and the version-specific CUDA_PATH_Vx_y), derived from the
|
||||
// toolkit root, e.g. "...\CUDA\v13.3" → CUDA_PATH_V13_3. Returns NUL-free
|
||||
// "KEY=VAL" strings.
|
||||
func cudaPathEnv(toolkitDir string) []string {
|
||||
out := []string{"CUDA_PATH=" + toolkitDir}
|
||||
// Le dossier se nomme "v13.3" → variable CUDA_PATH_V13_3.
|
||||
ver := strings.TrimPrefix(filepath.Base(toolkitDir), "v")
|
||||
if ver != "" {
|
||||
out = append(out, "CUDA_PATH_V"+strings.ReplaceAll(ver, ".", "_")+"="+toolkitDir)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newShellCmd builds the command used by the run_shell tool. hideCmd évite un
|
||||
// flash de console à chaque commande d'agent quand Jean tourne en mode app.
|
||||
func newShellCmd(ctx context.Context, command string) *exec.Cmd {
|
||||
return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package jean
|
||||
|
||||
// sys_proc_other.go — hors Windows, pas de détachement (le mode app double-clic est
|
||||
// propre à Windows). Défini pour la compilation ; non appelé en pratique car
|
||||
// launchedByDoubleClick renvoie false sur Unix.
|
||||
|
||||
func relaunchDetachedApp() { _ = cmdApp(nil) }
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build windows
|
||||
|
||||
package jean
|
||||
|
||||
// sys_proc_windows.go — au double-clic, on relance Jean en processus DÉTACHÉ, sans
|
||||
// console. L'original (qui, lui, possède la console fraîche créée par Explorer)
|
||||
// se termine aussitôt, fermant sa console. Le nouveau process n'a aucune fenêtre
|
||||
// de console : il n'y a donc plus de « terminal noir » et plus rien à fermer qui
|
||||
// couperait le serveur. Seuls restent le splash puis l'icône de la zone de
|
||||
// notification.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func relaunchDetachedApp() {
|
||||
const (
|
||||
detachedProcess = 0x00000008
|
||||
createNoWindow = 0x08000000
|
||||
createNewProcGrp = 0x00000200
|
||||
)
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
_ = cmdApp(nil) // repli : on lance quand même en place
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(exe, "app")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: detachedProcess | createNoWindow | createNewProcGrp,
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = cmdApp(nil)
|
||||
return
|
||||
}
|
||||
os.Exit(0) // ferme l'original → sa console disparaît ; le détaché continue
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sys_service.go holds the platform-neutral pieces of service management. The
|
||||
// actual start/stop/restart/status/logs implementation is platform-specific:
|
||||
// - sys_service_linux.go → systemd (systemctl/journalctl)
|
||||
// - sys_service_darwin.go → launchd (launchctl)
|
||||
// - sys_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 = defaultEditor()
|
||||
}
|
||||
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.
|
||||
// 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 := hideCmd(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
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//go:build darwin
|
||||
|
||||
package jean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sys_service_darwin.go — gestion du service via launchd (LaunchDaemon), équivalent
|
||||
// macOS de sys_service_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : jusqu'ici
|
||||
// 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 }
|
||||
|
||||
// launchdPlistPath : chemin du LaunchDaemon (domaine système, exécuté par root
|
||||
// puis abaissé à l'utilisateur cible via la clé UserName du plist).
|
||||
func launchdPlistPath(svc string) string {
|
||||
return "/Library/LaunchDaemons/" + launchdLabel(svc) + ".plist"
|
||||
}
|
||||
|
||||
// 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") }
|
||||
|
||||
// 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.
|
||||
func serviceAction(action string) error {
|
||||
svc := serviceName()
|
||||
plist := launchdPlistPath(svc)
|
||||
run := func(args ...string) error {
|
||||
bin, a := "launchctl", args
|
||||
if os.Geteuid() != 0 {
|
||||
bin, a = "sudo", append([]string{"-n", "launchctl"}, args...)
|
||||
}
|
||||
cmd := exec.Command(bin, a...)
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
switch action {
|
||||
case "start", "enable":
|
||||
if err := run("load", "-w", plist); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkStarted(svc)
|
||||
case "stop", "disable":
|
||||
if err := run("unload", "-w", plist); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " arrêté")
|
||||
return nil
|
||||
case "restart":
|
||||
_ = run("unload", plist) // best-effort : peut ne pas être chargé
|
||||
if err := run("load", "-w", plist); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkStarted(svc)
|
||||
}
|
||||
return fmt.Errorf("action inconnue: %s", action)
|
||||
}
|
||||
|
||||
func checkStarted(svc string) error {
|
||||
time.Sleep(2 * time.Second)
|
||||
if serviceIsActive() {
|
||||
fmt.Printf("%s %s: actif\n", green("[ok]"), svc)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s %s: non démarré — derniers logs :\n", red("[ERREUR]"), svc)
|
||||
fmt.Println("------------------------------------------------")
|
||||
if b, err := os.ReadFile(launchdLogPath()); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||
if len(lines) > 20 {
|
||||
lines = lines[len(lines)-20:]
|
||||
}
|
||||
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")
|
||||
return fmt.Errorf("service %s non démarré", svc)
|
||||
}
|
||||
|
||||
func serviceLogs() error {
|
||||
// tail -f du fichier de log défini dans le plist (StandardOut/ErrorPath).
|
||||
cmd := exec.Command("tail", "-n", "80", "-f", launchdLogPath())
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// serviceIsActive : le service est chargé ET possède un PID courant. `launchctl
|
||||
// list <label>` renvoie un dict incluant la clé "PID" uniquement quand il tourne.
|
||||
func serviceIsActive() bool {
|
||||
out, err := exec.Command("launchctl", "list", launchdLabel(serviceName())).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), "\"PID\"")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build linux
|
||||
|
||||
package jean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// serviceAction wraps `systemctl <action> <svc>` 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"
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//go:build windows
|
||||
|
||||
package jean
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// 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 := hideCmd(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. On utilise l'API
|
||||
// Win32 (OpenProcess + GetExitCodeProcess) plutôt que `tasklist` : l'ancien
|
||||
// appel externe faisait CLIGNOTER une fenêtre de console à CHAQUE vérification,
|
||||
// or l'UI web poll le statut en boucle → rafale de fenêtres noires. L'API ne
|
||||
// lance aucun process.
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
const (
|
||||
queryLimitedInfo = 0x1000
|
||||
stillActive = 259
|
||||
)
|
||||
k := syscall.NewLazyDLL("kernel32.dll")
|
||||
h, _, _ := k.NewProc("OpenProcess").Call(queryLimitedInfo, 0, uintptr(pid))
|
||||
if h == 0 {
|
||||
return false
|
||||
}
|
||||
defer k.NewProc("CloseHandle").Call(h)
|
||||
var code uint32
|
||||
r, _, _ := k.NewProc("GetExitCodeProcess").Call(h, uintptr(unsafe.Pointer(&code)))
|
||||
if r == 0 {
|
||||
return false
|
||||
}
|
||||
return code == stillActive
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package jean
|
||||
|
||||
// sys_splash_other.go — pas de fenêtre de démarrage hors Windows (no-op).
|
||||
|
||||
type splash struct{}
|
||||
|
||||
func showSplash(text string) *splash { return &splash{} }
|
||||
func (s *splash) close() {}
|
||||
@@ -0,0 +1,271 @@
|
||||
//go:build windows
|
||||
|
||||
package jean
|
||||
|
||||
// sys_splash_windows.go — écran de démarrage « Lancement de Jean… ».
|
||||
//
|
||||
// Petite fenêtre sans bordure, à coins arrondis, aux couleurs de la marque
|
||||
// (fond bleu #1f6feb, logo « j » blanc, texte blanc), affichée le temps que le
|
||||
// serveur monte et que le navigateur s'ouvre. Rendu GDI (pur syscall, aucun
|
||||
// CGO). Elle tourne sur son propre thread avec sa boucle de messages ; close()
|
||||
// lui envoie WM_CLOSE.
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
splashW = 440
|
||||
splashH = 150
|
||||
)
|
||||
|
||||
var (
|
||||
u32s = syscall.NewLazyDLL("user32.dll")
|
||||
g32s = syscall.NewLazyDLL("gdi32.dll")
|
||||
k32s = syscall.NewLazyDLL("kernel32.dll")
|
||||
|
||||
pRegisterClassExW = u32s.NewProc("RegisterClassExW")
|
||||
pCreateWindowExW = u32s.NewProc("CreateWindowExW")
|
||||
pDefWindowProcW = u32s.NewProc("DefWindowProcW")
|
||||
pShowWindow = u32s.NewProc("ShowWindow")
|
||||
pUpdateWindow = u32s.NewProc("UpdateWindow")
|
||||
pGetMessageW = u32s.NewProc("GetMessageW")
|
||||
pTranslateMessage = u32s.NewProc("TranslateMessage")
|
||||
pDispatchMessageW = u32s.NewProc("DispatchMessageW")
|
||||
pPostQuitMessage = u32s.NewProc("PostQuitMessage")
|
||||
pDestroyWindow = u32s.NewProc("DestroyWindow")
|
||||
pPostMessageW = u32s.NewProc("PostMessageW")
|
||||
pBeginPaint = u32s.NewProc("BeginPaint")
|
||||
pEndPaint = u32s.NewProc("EndPaint")
|
||||
pFillRect = u32s.NewProc("FillRect")
|
||||
pDrawTextW = u32s.NewProc("DrawTextW")
|
||||
pGetSystemMetrics = u32s.NewProc("GetSystemMetrics")
|
||||
pLoadCursorW = u32s.NewProc("LoadCursorW")
|
||||
pSetWindowRgn = u32s.NewProc("SetWindowRgn")
|
||||
|
||||
pCreateSolidBrush = g32s.NewProc("CreateSolidBrush")
|
||||
pDeleteObject = g32s.NewProc("DeleteObject")
|
||||
pCreateFontW = g32s.NewProc("CreateFontW")
|
||||
pSelectObject = g32s.NewProc("SelectObject")
|
||||
pSetTextColor = g32s.NewProc("SetTextColor")
|
||||
pSetBkMode = g32s.NewProc("SetBkMode")
|
||||
pCreateRoundRectRgn = g32s.NewProc("CreateRoundRectRgn")
|
||||
|
||||
pGetModuleHandleW = k32s.NewProc("GetModuleHandleW")
|
||||
|
||||
splashClassOnce sync.Once
|
||||
splashProc = syscall.NewCallback(splashWndProc)
|
||||
)
|
||||
|
||||
type rect struct{ left, top, right, bottom int32 }
|
||||
type point struct{ x, y int32 }
|
||||
type msg struct {
|
||||
hwnd uintptr
|
||||
message uint32
|
||||
wParam uintptr
|
||||
lParam uintptr
|
||||
time uint32
|
||||
pt point
|
||||
}
|
||||
type paintStruct struct {
|
||||
hdc uintptr
|
||||
fErase int32
|
||||
rcPaint rect
|
||||
fRestore int32
|
||||
fIncUpdate int32
|
||||
rgbReserved [32]byte
|
||||
}
|
||||
type wndClassExW struct {
|
||||
cbSize uint32
|
||||
style uint32
|
||||
lpfnWndProc uintptr
|
||||
cbClsExtra int32
|
||||
cbWndExtra int32
|
||||
hInstance uintptr
|
||||
hIcon uintptr
|
||||
hCursor uintptr
|
||||
hbrBackground uintptr
|
||||
lpszMenuName *uint16
|
||||
lpszClassName *uint16
|
||||
hIconSm uintptr
|
||||
}
|
||||
|
||||
const (
|
||||
colBlue = 0x00EB6F1F // COLORREF = 0x00BBGGRR pour #1f6feb
|
||||
colWhite = 0x00FFFFFF
|
||||
)
|
||||
|
||||
type splash struct{ hwnd uintptr }
|
||||
|
||||
func showSplash(text string) *splash {
|
||||
s := &splash{}
|
||||
ready := make(chan uintptr, 1)
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
hwnd := createSplashWindow()
|
||||
ready <- hwnd
|
||||
if hwnd != 0 {
|
||||
pumpMessages()
|
||||
}
|
||||
}()
|
||||
s.hwnd = <-ready
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *splash) close() {
|
||||
if s == nil || s.hwnd == 0 {
|
||||
return
|
||||
}
|
||||
const wmClose = 0x0010
|
||||
pPostMessageW.Call(s.hwnd, wmClose, 0, 0)
|
||||
}
|
||||
|
||||
func createSplashWindow() uintptr {
|
||||
className, _ := syscall.UTF16PtrFromString("JeanSplash")
|
||||
hInst, _, _ := pGetModuleHandleW.Call(0)
|
||||
|
||||
splashClassOnce.Do(func() {
|
||||
idcArrow := uintptr(32512)
|
||||
cursor, _, _ := pLoadCursorW.Call(0, idcArrow)
|
||||
wc := wndClassExW{
|
||||
lpfnWndProc: splashProc,
|
||||
hInstance: hInst,
|
||||
hCursor: cursor,
|
||||
lpszClassName: className,
|
||||
}
|
||||
wc.cbSize = uint32(unsafe.Sizeof(wc))
|
||||
pRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc)))
|
||||
})
|
||||
|
||||
const (
|
||||
wsPopup = 0x80000000
|
||||
exTopmost = 0x00000008
|
||||
exToolwindow = 0x00000080
|
||||
smCX = 0
|
||||
smCY = 1
|
||||
)
|
||||
cx, _, _ := pGetSystemMetrics.Call(smCX)
|
||||
cy, _, _ := pGetSystemMetrics.Call(smCY)
|
||||
x := (int(cx) - splashW) / 2
|
||||
y := (int(cy) - splashH) / 2
|
||||
title, _ := syscall.UTF16PtrFromString("Jean")
|
||||
|
||||
hwnd, _, _ := pCreateWindowExW.Call(
|
||||
uintptr(exTopmost|exToolwindow),
|
||||
uintptr(unsafe.Pointer(className)),
|
||||
uintptr(unsafe.Pointer(title)),
|
||||
uintptr(uint32(wsPopup)),
|
||||
uintptr(x), uintptr(y), uintptr(splashW), uintptr(splashH),
|
||||
0, 0, hInst, 0,
|
||||
)
|
||||
if hwnd == 0 {
|
||||
return 0
|
||||
}
|
||||
// Coins arrondis.
|
||||
rgn, _, _ := pCreateRoundRectRgn.Call(0, 0, splashW+1, splashH+1, 22, 22)
|
||||
pSetWindowRgn.Call(hwnd, rgn, 1)
|
||||
|
||||
const swShowNoActivate = 4
|
||||
pShowWindow.Call(hwnd, swShowNoActivate)
|
||||
pUpdateWindow.Call(hwnd)
|
||||
return hwnd
|
||||
}
|
||||
|
||||
func pumpMessages() {
|
||||
var m msg
|
||||
for {
|
||||
r, _, _ := pGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
|
||||
if int32(r) <= 0 { // 0 = WM_QUIT, -1 = erreur
|
||||
return
|
||||
}
|
||||
pTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||
pDispatchMessageW.Call(uintptr(unsafe.Pointer(&m)))
|
||||
}
|
||||
}
|
||||
|
||||
func splashWndProc(hwnd, message, wParam, lParam uintptr) uintptr {
|
||||
const (
|
||||
wmDestroy = 0x0002
|
||||
wmClose = 0x0010
|
||||
wmPaint = 0x000F
|
||||
)
|
||||
switch message {
|
||||
case wmPaint:
|
||||
var ps paintStruct
|
||||
hdc, _, _ := pBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
|
||||
paintSplash(hdc)
|
||||
pEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
|
||||
return 0
|
||||
case wmClose:
|
||||
pDestroyWindow.Call(hwnd)
|
||||
return 0
|
||||
case wmDestroy:
|
||||
pPostQuitMessage.Call(0)
|
||||
return 0
|
||||
}
|
||||
r, _, _ := pDefWindowProcW.Call(hwnd, message, wParam, lParam)
|
||||
return r
|
||||
}
|
||||
|
||||
func paintSplash(hdc uintptr) {
|
||||
// Fond bleu.
|
||||
blue, _, _ := pCreateSolidBrush.Call(colBlue)
|
||||
full := rect{0, 0, splashW, splashH}
|
||||
pFillRect.Call(hdc, uintptr(unsafe.Pointer(&full)), blue)
|
||||
pDeleteObject.Call(blue)
|
||||
|
||||
// Logo « j » blanc (rects du favicon sur grille 12, 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}} {
|
||||
r := rect{
|
||||
left: ox + int32(rc[0]*scale),
|
||||
top: oy + int32(rc[1]*scale),
|
||||
right: ox + int32((rc[0]+rc[2])*scale),
|
||||
bottom: oy + int32((rc[1]+rc[3])*scale),
|
||||
}
|
||||
pFillRect.Call(hdc, uintptr(unsafe.Pointer(&r)), white)
|
||||
}
|
||||
pDeleteObject.Call(white)
|
||||
|
||||
// Textes (blanc, fond transparent).
|
||||
const transparent = 1
|
||||
pSetBkMode.Call(hdc, transparent)
|
||||
pSetTextColor.Call(hdc, colWhite)
|
||||
|
||||
textX := ox + logo + 26
|
||||
drawText(hdc, "Jean", -30, 700, rect{textX, 40, splashW - 20, 82})
|
||||
drawText(hdc, "Lancement en cours…", -17, 400, rect{textX, 84, splashW - 20, 118})
|
||||
}
|
||||
|
||||
func drawText(hdc uintptr, s string, height, weight int32, r rect) {
|
||||
face, _ := syscall.UTF16PtrFromString("Segoe UI")
|
||||
const (
|
||||
defaultCharset = 1
|
||||
cleartypeQuality = 5
|
||||
)
|
||||
font, _, _ := pCreateFontW.Call(
|
||||
uintptr(height), 0, 0, 0, uintptr(weight),
|
||||
0, 0, 0, defaultCharset, 0, 0, cleartypeQuality, 0,
|
||||
uintptr(unsafe.Pointer(face)),
|
||||
)
|
||||
old, _, _ := pSelectObject.Call(hdc, font)
|
||||
txt, _ := syscall.UTF16PtrFromString(s)
|
||||
const (
|
||||
dtLeft = 0x0000
|
||||
dtSingleline = 0x0020
|
||||
dtVcenter = 0x0004
|
||||
dtNoclip = 0x0100
|
||||
)
|
||||
rc := r
|
||||
pDrawTextW.Call(hdc, uintptr(unsafe.Pointer(txt)), ^uintptr(0),
|
||||
uintptr(unsafe.Pointer(&rc)), dtLeft|dtSingleline|dtVcenter|dtNoclip)
|
||||
pSelectObject.Call(hdc, old)
|
||||
pDeleteObject.Call(font)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package jean
|
||||
|
||||
// sys_tray_other.go — pas d'icône de zone de notification hors Windows (la demande
|
||||
// vise le tray Windows). On se contente d'ouvrir l'UI dans le navigateur et de
|
||||
// garder le serveur en vie. Aucun import systray ici → les builds Linux/macOS
|
||||
// restent purs (CGO_ENABLED=0).
|
||||
|
||||
func runTray(url string) {
|
||||
select {} // garde le serveur en vie (Ctrl-C pour quitter) ; le navigateur est déjà ouvert par cmdApp
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//go:build windows
|
||||
|
||||
package jean
|
||||
|
||||
// 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
|
||||
// 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 ».
|
||||
//
|
||||
// getlantern/systray est pur Go sur Windows (Win32 via x/sys, aucun CGO) — le
|
||||
// binaire reste statique. L'import est confiné à ce fichier windows pour que les
|
||||
// builds Linux/macOS (CGO_ENABLED=0) ne le touchent jamais.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
|
||||
"github.com/getlantern/systray"
|
||||
)
|
||||
|
||||
// runTray ouvre l'UI dans le navigateur puis fait tourner l'icône de la zone de
|
||||
// notification. Bloque jusqu'à ce que l'utilisateur choisisse « Quitter ».
|
||||
func runTray(url string) {
|
||||
systray.Run(func() {
|
||||
systray.SetIcon(trayIcon())
|
||||
systray.SetTitle("Jean")
|
||||
systray.SetTooltip("Jean — votre IA locale")
|
||||
mOpen := systray.AddMenuItem("Ouvrir Jean", "Ouvrir l'interface")
|
||||
systray.AddSeparator()
|
||||
mQuit := systray.AddMenuItem("Quitter", "Arrêter Jean")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-mOpen.ClickedCh:
|
||||
_ = openBrowser(url)
|
||||
case <-mQuit.ClickedCh:
|
||||
systray.Quit()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}, func() {
|
||||
os.Exit(0) // « Quitter » → arrête tout le process (serveur inclus)
|
||||
})
|
||||
}
|
||||
|
||||
// trayIcon génère l'icône (.ico) de Jean, 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.
|
||||
// Windows accepte un PNG (avec alpha pour les coins arrondis) dans le .ico.
|
||||
func trayIcon() []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)
|
||||
p := pngBuf.Bytes()
|
||||
|
||||
// Conteneur ICO : ICONDIR (6) + 1 ICONDIRENTRY (16) + PNG.
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package jean
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// jean 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).
|
||||
|
||||
const updateRepo = "nathaninline/jean"
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// updateAssetName reconstruit le nom d'asset attendu pour la plateforme courante,
|
||||
// suivant la convention des releases : jean-<os>-<arch>[.exe].
|
||||
func updateAssetName() string {
|
||||
n := "jean-" + runtime.GOOS + "-" + runtime.GOARCH
|
||||
if runtime.GOOS == "windows" {
|
||||
n += ".exe"
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ensureV préfixe "v" si absent, pour comparer via golang.org/x/mod/semver.
|
||||
func ensureV(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" && !strings.HasPrefix(s, "v") {
|
||||
s = "v" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
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")
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("GitHub a répondu %s", resp.Status)
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rel, nil
|
||||
}
|
||||
|
||||
// updateInfo décrit l'état de mise à jour (partagé CLI + UI web).
|
||||
type updateInfo struct {
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
Available bool `json:"available"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// checkForUpdate interroge GitHub et compare à la version courante. Réutilisé
|
||||
// par `jean update` (CLI) et par l'endpoint web /api/update.
|
||||
func checkForUpdate() (updateInfo, error) {
|
||||
info := updateInfo{Current: Version}
|
||||
rel, err := fetchLatestRelease()
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
latest := ensureV(rel.TagName)
|
||||
if !semver.IsValid(latest) {
|
||||
return info, fmt.Errorf("tag de release inattendu : %q", rel.TagName)
|
||||
}
|
||||
info.Latest = strings.TrimPrefix(latest, "v")
|
||||
info.URL = rel.HTMLURL
|
||||
info.Available = semver.Compare(latest, ensureV(Version)) > 0
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// applyUpdate télécharge et installe le binaire de la dernière release pour
|
||||
// l'OS/arch courant. Renvoie la nouvelle version. Ne redémarre AUCUN service
|
||||
// (voir printRestartHint / le message renvoyé à l'UI).
|
||||
func applyUpdate() (string, error) {
|
||||
rel, err := fetchLatestRelease()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("impossible de contacter GitHub : %w", err)
|
||||
}
|
||||
latest := ensureV(rel.TagName)
|
||||
if !semver.IsValid(latest) {
|
||||
return "", fmt.Errorf("tag de release inattendu : %q", rel.TagName)
|
||||
}
|
||||
if semver.Compare(latest, ensureV(Version)) <= 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
if url == "" {
|
||||
return "", fmt.Errorf("aucun binaire %q dans la release %s (os/arch %s/%s)", want, latest, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(exe), ".jean-update.tmp")
|
||||
|
||||
if err := downloadTo(url, tmp); err != nil {
|
||||
return "", fmt.Errorf("téléchargement : %w", err)
|
||||
}
|
||||
mode := os.FileMode(0o755)
|
||||
if fi, err := os.Stat(exe); err == nil {
|
||||
mode = fi.Mode()
|
||||
}
|
||||
if err := os.Chmod(tmp, mode); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
if got := fileSize(tmp); size > 0 && got != size {
|
||||
os.Remove(tmp)
|
||||
return "", fmt.Errorf("taille inattendue (%d o reçus, %d attendus) — mise à jour annulée", got, size)
|
||||
}
|
||||
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 "", err
|
||||
}
|
||||
return strings.TrimPrefix(latest, "v"), nil
|
||||
}
|
||||
|
||||
func cmdUpdate(args []string) error {
|
||||
checkOnly := false
|
||||
for _, a := range args {
|
||||
if a == "--check" || a == "-check" || a == "check" {
|
||||
checkOnly = true
|
||||
}
|
||||
}
|
||||
fmt.Println("recherche de la dernière version…")
|
||||
info, err := checkForUpdate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("impossible de contacter GitHub : %w", err)
|
||||
}
|
||||
if !info.Available {
|
||||
fmt.Printf("jean est déjà à jour (%s).\n", Version)
|
||||
return nil
|
||||
}
|
||||
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.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("téléchargement de %s…\n", updateAssetName())
|
||||
newVer, err := applyUpdate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ jean mis à jour en %s\n", newVer)
|
||||
printRestartHint()
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadTo(url, dst string) error {
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("User-Agent", "jean-update")
|
||||
req.Header.Set("Accept", "application/octet-stream")
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("GitHub a répondu %s", resp.Status)
|
||||
}
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, cErr := io.Copy(f, resp.Body)
|
||||
if closeErr := f.Close(); cErr == nil {
|
||||
cErr = closeErr
|
||||
}
|
||||
return cErr
|
||||
}
|
||||
|
||||
func fileSize(p string) int64 {
|
||||
if fi, err := os.Stat(p); err == nil {
|
||||
return fi.Size()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// replaceBinary échange le binaire en place. Sous Unix, rename() dans le même
|
||||
// dossier est atomique et fonctionne même si l'ancien binaire tourne encore
|
||||
// (l'inode ouvert reste valide). Sous Windows on ne peut pas écraser un .exe en
|
||||
// cours : on renomme l'ancien en .old (supprimé au prochain lancement).
|
||||
func replaceBinary(exe, tmp string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
old := exe + ".old"
|
||||
_ = os.Remove(old) // nettoyage d'une éventuelle MAJ précédente
|
||||
if err := os.Rename(exe, old); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, exe); err != nil {
|
||||
_ = os.Rename(old, exe) // rollback
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(old) // échoue si l'exe tourne encore → nettoyé au prochain run
|
||||
return nil
|
||||
}
|
||||
return os.Rename(tmp, exe)
|
||||
}
|
||||
|
||||
// cleanupOldBinary supprime silencieusement le .old laissé par une MAJ Windows
|
||||
// précédente (le fichier n'était pas supprimable tant que l'exe tournait).
|
||||
func cleanupOldBinary() {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
_ = os.Remove(exe + ".old")
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpdateCheck (GET /api/update) : renvoie l'état de mise à jour pour l'UI.
|
||||
func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := checkForUpdate()
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"current": Version, "available": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, info)
|
||||
}
|
||||
|
||||
// handleUpdateApply (POST /api/update/apply) : télécharge et installe la dernière
|
||||
// version. Sur un serveur Linux/systemd, relance ensuite AUTOMATIQUEMENT le service
|
||||
// jean-link (celui qui sert l'UI, le chat et le tunnel) pour appliquer la MAJ sans
|
||||
// avoir à SSH — voir restartAfterUpdate. Renvoie la version + le message de statut.
|
||||
func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
|
||||
newVer, err := applyUpdate()
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
restarting, msg := restartAfterUpdate()
|
||||
if !restarting {
|
||||
msg = restartHintText()
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "version": newVer, "restart": msg, "restarting": restarting})
|
||||
}
|
||||
|
||||
// restartAfterUpdate relance le service jean-link juste après une MAJ déclenchée
|
||||
// depuis l'UI, pour éviter le SSH manuel. Conditions : Linux/systemd + jean-link
|
||||
// actif (le service tourne en root → systemctl passe sans sudo). On NE touche PAS à
|
||||
// jean.service (llama-server) pour ne pas recharger le modèle (long et inattendu
|
||||
// depuis un bouton « mettre à jour »). Le redémarrage est DIFFÉRÉ (la réponse HTTP
|
||||
// doit partir d'abord) et lancé en --no-block : systemd enregistre le job puis nous
|
||||
// arrête/relance ; la clé E2E (.e2e_key) survit au restart, donc pas de ré-appairage
|
||||
// et l'UI se reconnecte toute seule (boucle de reconnexion du flux). Renvoie
|
||||
// (déclenché, message). Non-serveur (poste client, Windows) → (false, "").
|
||||
func restartAfterUpdate() (bool, string) {
|
||||
if runtime.GOOS != "linux" || !linkServiceActive() {
|
||||
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()
|
||||
}()
|
||||
return true, "Service " + linkServiceName + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)."
|
||||
}
|
||||
|
||||
func restartHintText() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "Redémarre Jean (quitte puis relance) pour appliquer la mise à jour."
|
||||
}
|
||||
return "Redémarre les services pour appliquer : sudo systemctl restart jean jean-link"
|
||||
}
|
||||
|
||||
func printRestartHint() { fmt.Println(restartHintText()) }
|
||||
@@ -0,0 +1,30 @@
|
||||
# assemble.ps1 — reconstruit ui/index.html à partir des sources ui/src/.
|
||||
#
|
||||
# ui/index.html est un fichier GÉNÉRÉ (mais committé : go:embed et
|
||||
# ajean-app/build-server-ui.ps1 le lisent tel quel). Pour modifier l'UI :
|
||||
# éditer ui/src/ (styles.css, js/NN-*.js, index.tmpl.html) puis relancer ce
|
||||
# script. Les js/ sont concaténés dans l'ordre alphabétique (préfixes NN-),
|
||||
# tout reste en scope global comme avant le découpage.
|
||||
#
|
||||
# Tout est manipulé en octets bruts (fins de ligne/encodage préservés).
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$src = Join-Path $here 'src'
|
||||
$dst = Join-Path $here 'index.html'
|
||||
|
||||
$tmpl = [IO.File]::ReadAllText((Join-Path $src 'index.tmpl.html'))
|
||||
$css = [IO.File]::ReadAllText((Join-Path $src 'styles.css'))
|
||||
$js = (Get-ChildItem (Join-Path $src 'js') -Filter '*.js' | Sort-Object Name |
|
||||
ForEach-Object { [IO.File]::ReadAllText($_.FullName) }) -join ''
|
||||
|
||||
# le marqueur et SA fin de ligne sont remplacés (LF ou CRLF selon le checkout) ;
|
||||
# chaque source apporte ses propres fins de ligne → sortie byte-stable.
|
||||
foreach ($m in '@@CSS@@', '@@JS@@') {
|
||||
if ($tmpl -notmatch "$m`r?`n") { throw "marqueur $m introuvable dans index.tmpl.html" }
|
||||
}
|
||||
$html = [regex]::Replace($tmpl, "@@CSS@@`r?`n", { $css })
|
||||
$html = [regex]::Replace($html, "@@JS@@`r?`n", { $js })
|
||||
|
||||
[IO.File]::WriteAllText($dst, $html, [Text.UTF8Encoding]::new($false))
|
||||
Write-Host "OK -> $dst ($([IO.File]::ReadAllBytes($dst).Length) octets)"
|
||||
File diff suppressed because it is too large.
Load diff
Vendored
+6
@@ -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.length<t;)n.push("");for(;s<n.length;s++)n[s]=n[s].trim().replace(/\\\|/g,"|");return n}function x(e,t,n){const s=e.length;if(0===s)return"";let r=0;for(;r<s;){const i=e.charAt(s-r-1);if(i!==t||n){if(i===t||!n)break;r++}else r++}return e.slice(0,s-r)}function b(e,t,n,s){const r=t.href,i=t.title?c(t.title):null,l=e[1].replace(/\\([\[\]])/g,"$1");if("!"!==e[0].charAt(0)){s.state.inLink=!0;const e={type:"link",raw:n,href:r,title:i,text:l,tokens:s.inlineTokens(l)};return s.state.inLink=!1,e}return{type:"image",raw:n,href:r,title:i,text:c(l)}}class w{options;rules;lexer;constructor(t){this.options=t||e.defaults}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)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))Line truncated
|
||||
@@ -0,0 +1,222 @@
|
||||
<!doctype html>
|
||||
<!-- GÉNÉRÉ par ui/assemble.ps1 depuis ui/src/ — NE PAS ÉDITER index.html DIRECTEMENT -->
|
||||
<html lang="fr"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<!-- PWA standalone : "Ajouter à l'écran d'accueil" sur iPhone → app plein écran
|
||||
SANS la barre d'onglets Safari (indispensable pour enregistrer des démos). -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Jean">
|
||||
<title>Jean</title>
|
||||
<!-- Favicon (même marque « ajean » que la landing/portail) + icône PWA (écran d'accueil
|
||||
iOS). SVG inline pour le favicon, PNG 180×180 en data-URI pour apple-touch-icon
|
||||
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%231f6feb'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
|
||||
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABa0lEQVR42u3dsQ2AIBBAUSCOYA8b4iiMSI9D2FpYGTEheW8AisvPUV7MdQR4kowAcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+IAcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+JAHCAOxMEHNiO4622f9HI5TpsD3wriQBwgDsSBOBAH4kAciANxIA7EAeJAHIgDcSAOxIE4EAfiQBwgDsSBOBAH4kAciANxIA7EgThAHIgDcSAOxIE4EAfiQByIA8SBOBAH4kAciANxIA7EgThAHIgDcSAO/rLkLXsX520OxIE4EAfiQByIA8SBOBAH4kAciANxIA7EgThAHIgDcSAOxIE4EAfiQByIA8SBOHgl5jpMAZsDcSAOxIE4EAfiQByIA3GAOBAH4kAciANxIA7EgTgQB4gDcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+IAcSAOxME0F8YvCl24O02KAAAAAElFTkSuQmCC">
|
||||
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
|
||||
<script>try{document.documentElement.setAttribute('data-theme',localStorage.getItem('jean-theme')||'dark');document.documentElement.setAttribute('data-display',localStorage.getItem('jean-display')||'full')}catch(e){document.documentElement.setAttribute('data-theme','dark');document.documentElement.setAttribute('data-display','full')}</script>
|
||||
<style>
|
||||
@@CSS@@
|
||||
</style></head><body>
|
||||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||||
<div id="backdrop" onclick="toggleSide()"></div>
|
||||
<div class="side" id="side">
|
||||
<div class="sidehead">
|
||||
<h1 id="brand"><span id="brand-a">a</span>jean<span id="ver"></span></h1>
|
||||
<span id="status-svc" class="statuspill">…</span>
|
||||
</div>
|
||||
<details open><summary>GPU</summary>
|
||||
<div id="vram" class="muted">…</div>
|
||||
</details>
|
||||
<details open id="cfg-details"><summary>Config active</summary>
|
||||
<div id="cfg" class="muted">…</div>
|
||||
</details>
|
||||
<details><summary>Presets<button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter">+</button></summary>
|
||||
<div id="presets"></div>
|
||||
</details>
|
||||
<details><summary>Mode agent <span id="agent-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠️ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label>
|
||||
<!-- Mémoire / Accès internet / Paramètres : sous-réglages du mode agent, en cartes. Grisés/inertes quand l'agent est off (voir setAgentGate). -->
|
||||
<div id="mem-block" class="gated-block">
|
||||
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
|
||||
<label class="switchrow" style="justify-content:flex-start;gap:12px"><span>mode</span>
|
||||
<select id="mem-mode" onchange="setMemMode()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px">
|
||||
<option value="always">auto (proactive)</option>
|
||||
<option value="ondemand">sur demande</option>
|
||||
<option value="off">désactivée</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="mem-pages-bar" class="pages-bar" onclick="toggleMemPages()">
|
||||
<span class="pages-caret">▸</span>
|
||||
<span class="pages-title">Pages <span id="mem-count" class="mem-count"></span></span>
|
||||
<button class="addbtn" onclick="event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button>
|
||||
</div>
|
||||
<div id="mem-pages-body" hidden>
|
||||
<input id="mem-search" class="mem-search" type="search" placeholder="filtrer la mémoire…" oninput="renderMemList()" style="display:none">
|
||||
<div id="mem-list" class="mem-list muted" style="font-size:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="net-block" class="gated-block">
|
||||
<div class="subhead">Accès internet<span id="internet-badge" style="margin-left:6px;font-size:10px"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b> via un serveur <b>Crawl4AI</b>. Actifs seulement si le serveur répond.</span></span></div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-url" type="text" placeholder="http://localhost:11235" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit" onchange="saveCrawlUrl()">
|
||||
<button onclick="saveCrawlUrl()">enregistrer</button>
|
||||
</div>
|
||||
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||||
</div>
|
||||
<div id="param-block" class="gated-block">
|
||||
<div class="subhead">Paramètres</div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="toollimit-toggle" onchange="toggleToolLimit()"><span class="slider"></span></span> limiter les appels d'outils par tour<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : coupe l'IA après ~8 appels d'outils (message <code>[stop: trop d'appels d'outils]</code>). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).</span></span></label>
|
||||
<label class="switchrow" style="margin-top:8px"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
||||
</div>
|
||||
</details>
|
||||
<details><summary>Apparence</summary>
|
||||
<label class="switchrow" style="justify-content:space-between;gap:12px"><span>thème</span>
|
||||
<select id="theme-select" onchange="applyTheme(this.value);savePrefs()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px"></select>
|
||||
</label>
|
||||
<label class="switchrow" style="justify-content:space-between;gap:12px;margin-top:8px"><span>affichage<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip"><b>Complet</b> : tout est visible (raisonnement, appels d'outils, vitesse, config active). <b>Simple</b> : masque le raisonnement, les appels d'outils, les détails de vitesse sur les bulles et la config active à gauche.</span></span></span>
|
||||
<select id="display-select" onchange="applyDisplay(this.value);savePrefs()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px">
|
||||
<option value="full">complet</option>
|
||||
<option value="simple">simple</option>
|
||||
</select>
|
||||
</label>
|
||||
</details>
|
||||
<details><summary>System prompt</summary>
|
||||
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:70px;resize:vertical"></textarea>
|
||||
</details>
|
||||
<details><summary>Accès OpenAI</summary>
|
||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branche n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur cette URL. Modèle : <code>jean</code>. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||
<label class="switchrow" style="margin-top:6px"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend ton IA joignable depuis <b>n'importe où</b> via <code>https://<machine>.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite un abonnement ajean.link actif.</span></span></label>
|
||||
<div id="oai-public-wrap" style="display:none;margin-top:4px">
|
||||
<div class="muted" style="font-size:11px;margin-bottom:2px">🌍 public (ajean.link) — accessible partout, TLS de bout en bout jusqu'à ce serveur</div>
|
||||
<div class="row">
|
||||
<input id="oai-public-url" readonly style="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">
|
||||
<button onclick="copyText(document.getElementById('oai-public-url').value,'URL publique copiée')" title="copier">⧉</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="oai-local-wrap" style="margin-top:4px">
|
||||
<div class="muted" style="font-size:11px;margin-bottom:2px">🏠 local (réseau)</div>
|
||||
<div class="row">
|
||||
<input id="oai-url" readonly style="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">
|
||||
<button onclick="copyText(document.getElementById('oai-url').value,'URL copiée')" title="copier">⧉</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:3px">modèle : <code>jean</code></div>
|
||||
<div class="subhead" style="margin-top:12px">Clé API<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé <b>redémarre</b> le service.</span></span></div>
|
||||
<div class="row" style="margin-top:4px">
|
||||
<input id="oai-key" readonly style="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">
|
||||
<button id="oai-key-eye" onclick="toggleKeyReveal()" title="afficher / masquer">👁</button>
|
||||
<button onclick="copyApiKey()" title="copier la clé">⧉</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:6px">
|
||||
<button onclick="apiKeyAction('generate')">générer</button>
|
||||
<button onclick="apiKeySet()">définir…</button>
|
||||
<button onclick="apiKeyAction('clear')">retirer</button>
|
||||
</div>
|
||||
</details>
|
||||
<details><summary>Service</summary>
|
||||
<div class="row">
|
||||
<button onclick="act('start')">start</button>
|
||||
<button onclick="act('restart')">restart</button>
|
||||
<button onclick="act('stop')">stop</button>
|
||||
</div>
|
||||
</details>
|
||||
<details id="upd-details"><summary>Actions</summary>
|
||||
<div class="row"><button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button></div>
|
||||
<div id="upd-msg" class="muted" style="font-size:12px;margin-top:6px;line-height:1.5"></div>
|
||||
<div class="row" style="margin-top:6px">
|
||||
<button onclick="loadAll()">↻ refresh</button>
|
||||
<button onclick="resetChat()">⌫ clear chat</button>
|
||||
<button id="btn-bench" onclick="runBenchUI()">⚡ bench</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="main" style="position:relative">
|
||||
<div id="chat"></div>
|
||||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
|
||||
<div id="composer">
|
||||
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
||||
<div id="ctxmeta">
|
||||
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
||||
<span style="display:flex;align-items:center;gap:10px;margin-left:auto">
|
||||
<span id="ctx-text" class="muted">contexte 0 / 0</span>
|
||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="inputbar">
|
||||
<textarea id="input" placeholder="message…" onkeydown="onKey(event)"></textarea>
|
||||
<button id="send" onclick="send()">send</button>
|
||||
<button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
|
||||
</div>
|
||||
<div id="sendhint" class="muted" style="padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>⚡ Benchmark</strong>
|
||||
<button onclick="closeBenchModal()" style="margin:0">×</button>
|
||||
</div>
|
||||
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||
<button onclick="closeBenchModal()">fermer</button>
|
||||
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ask-modal" class="ask-overlay" style="display:none" onclick="if(event.target===this)askResolve(false)">
|
||||
<div class="ask-box">
|
||||
<div id="ask-title" class="ask-title"></div>
|
||||
<div id="ask-msg" class="ask-msg"></div>
|
||||
<input id="ask-input" class="ask-input" style="display:none">
|
||||
<div class="ask-actions">
|
||||
<button id="ask-cancel" onclick="askResolve(false)">Annuler</button>
|
||||
<button id="ask-ok" class="ask-ok" onclick="askResolve(true)">Confirmer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:30;align-items:center;justify-content:center;padding:20px">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:700px;max-height:90vh;display:flex;flex-direction:column">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong id="modal-title">Preset</strong>
|
||||
<button onclick="closeModal()" style="margin:0">×</button>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:10px;overflow:auto;flex:1">
|
||||
<label class="muted">Nom <span style="opacity:.6">(espaces et accents OK, sauf / et \)</span></label>
|
||||
<input id="m-name" style="background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit">
|
||||
<div id="m-model-row" style="display:none">
|
||||
<label class="muted">Backend <span style="opacity:.6">(remplace la ligne BIN=)</span></label>
|
||||
<select id="m-backend" onchange="onPickBackend()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin:6px 0 10px"></select>
|
||||
<label class="muted">Modèle <span style="opacity:.6">(remplace la ligne MODEL=)</span></label>
|
||||
<select id="m-model" onchange="onPickModel()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin-top:6px"></select>
|
||||
<label class="muted" style="margin-top:10px;display:block">Télécharger depuis Hugging Face <span style="opacity:.6">(lien vers un .gguf)</span></label>
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<input id="m-hf-url" placeholder="https://huggingface.co/…/resolve/main/model.gguf" style="flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit">
|
||||
<button id="m-hf-btn" onclick="startDownload()" style="flex-shrink:0">⬇ télécharger</button>
|
||||
</div>
|
||||
<div id="m-hf-progress" class="muted" style="display:none;font-size:11px;margin-top:6px"></div>
|
||||
<label class="muted" style="margin-top:10px;display:block">Quantization (tag) <span style="opacity:.6">(vide = détection auto depuis le nom du modèle)</span></label>
|
||||
<input id="m-quant" oninput="applyQuant()" placeholder="ex: Q4_K_M, IQ3_XXS, BF16…" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;margin-top:6px">
|
||||
</div>
|
||||
<label class="muted">Contenu</label>
|
||||
<textarea id="m-content" spellcheck="false" style="background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:280px;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div style="padding:14px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:6px;flex-wrap:wrap">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<button id="m-del" onclick="delItem()" style="background:var(--danger-bg);border-color:var(--err);color:var(--err);margin:0">supprimer</button>
|
||||
<label id="m-del-model-wrap" style="display:none;align-items:center;gap:5px;font-size:11px;color:var(--dim)"><input type="checkbox" id="m-del-model"> + le modèle .gguf</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button onclick="closeModal()">annuler</button>
|
||||
<button onclick="saveItem()" style="border-color:var(--accent);color:var(--accent)">enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="marked.min.js"></script>
|
||||
<script>
|
||||
@@JS@@
|
||||
</script></body></html>
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,43 @@
|
||||
// ===== Thèmes ================================================================
|
||||
// Registre des thèmes disponibles. Pour en AJOUTER un : ajouter un bloc
|
||||
// [data-theme="…"] dans le <style> puis une entrée {id,label} ici — le reste
|
||||
// (menu déroulant, persistance localStorage, application) est automatique.
|
||||
const THEMES=[{id:'dark',label:'sombre'},{id:'light',label:'clair'},{id:'soft',label:'doux'}];
|
||||
// Lexend (thème "doux") n'est chargé QUE si ce thème est choisi : aucune requête
|
||||
// externe (Google Fonts) tant qu'on reste en sombre/clair — jean reste local par
|
||||
// défaut. Fallback système sans-serif si hors ligne.
|
||||
function ensureSoftFont(){
|
||||
if(document.getElementById('lexend-font')) return;
|
||||
const l=document.createElement('link');
|
||||
l.id='lexend-font'; l.rel='stylesheet';
|
||||
l.href='https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap';
|
||||
document.head.appendChild(l);
|
||||
}
|
||||
function applyTheme(id){
|
||||
if(!THEMES.some(t=>t.id===id)) id='dark';
|
||||
if(id==='soft') ensureSoftFont();
|
||||
document.documentElement.setAttribute('data-theme', id);
|
||||
try{ localStorage.setItem('jean-theme', id); }catch(e){}
|
||||
const sel=document.getElementById('theme-select'); if(sel) sel.value=id;
|
||||
}
|
||||
function initTheme(){
|
||||
const sel=document.getElementById('theme-select');
|
||||
if(sel && !sel.options.length){
|
||||
THEMES.forEach(t=>{ const o=document.createElement('option'); o.value=t.id; o.textContent=t.label; sel.appendChild(o); });
|
||||
}
|
||||
let id='dark'; try{ id=localStorage.getItem('jean-theme')||'dark'; }catch(e){}
|
||||
applyTheme(id);
|
||||
}
|
||||
// ===== Mode d'affichage (complet / simple) ==================================
|
||||
// "simple" masque raisonnement, appels d'outils, détails de vitesse et config
|
||||
// active (via [data-display] + CSS). Persisté dans 'jean-display'.
|
||||
function applyDisplay(mode){
|
||||
if(mode!=='simple') mode='full';
|
||||
document.documentElement.setAttribute('data-display', mode);
|
||||
try{ localStorage.setItem('jean-display', mode); }catch(e){}
|
||||
const sel=document.getElementById('display-select'); if(sel) sel.value=mode;
|
||||
}
|
||||
function initDisplay(){
|
||||
let m='full'; try{ m=localStorage.getItem('jean-display')||'full'; }catch(e){}
|
||||
applyDisplay(m);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// ===== Persistance côté serveur (partagée entre appareils) ==================
|
||||
// L'apparence est aussi enregistrée sur la machine jean (/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='dark', display='full';
|
||||
try{ theme=localStorage.getItem('jean-theme')||'dark'; display=localStorage.getItem('jean-display')||'full'; }catch(e){}
|
||||
jpost('/api/prefs', {theme, display}).catch(()=>{});
|
||||
}
|
||||
async function loadPrefs(){
|
||||
try{
|
||||
const p=await jget('/api/prefs');
|
||||
if(p && p.ok && p.prefs){
|
||||
if(p.prefs.theme) applyTheme(p.prefs.theme);
|
||||
if(p.prefs.display) applyDisplay(p.prefs.display);
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initDisplay(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; restoreChat(); });
|
||||
@@ -0,0 +1,43 @@
|
||||
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';
|
||||
function askResolve(ok){
|
||||
if(!_askResolver) return;
|
||||
const r=_askResolver; _askResolver=null;
|
||||
document.getElementById('ask-modal').style.display='none';
|
||||
document.removeEventListener('keydown', _askKey, true);
|
||||
if(_askKind==='prompt') r(ok ? document.getElementById('ask-input').value : null);
|
||||
else r(ok);
|
||||
}
|
||||
function _askKey(e){
|
||||
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); askResolve(false); }
|
||||
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
|
||||
// {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||''; }
|
||||
opts=opts||{};
|
||||
_askKind=kind;
|
||||
document.getElementById('ask-title').textContent = opts.title || (kind==='alert'?'Info':kind==='prompt'?'Saisie':'Confirmation');
|
||||
document.getElementById('ask-msg').textContent = message||'';
|
||||
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';
|
||||
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';
|
||||
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; });
|
||||
}
|
||||
function askConfirm(message,opts){ return _openAsk('confirm',message,opts); }
|
||||
function askPrompt(message,opts){ return _openAsk('prompt',message,opts); }
|
||||
function askAlert(message,opts){ return _openAsk('alert',message,opts); }
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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; }
|
||||
// Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link.
|
||||
// Garde les chemins absolus (/api/…) corrects derrière le tunnel.
|
||||
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
|
||||
async function jfetch(u, opts){
|
||||
opts = opts || {};
|
||||
opts.headers = authHeaders(opts.headers);
|
||||
if(u.charAt(0) === '/') u = API_BASE + u;
|
||||
let r = await fetch(u, opts);
|
||||
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); }
|
||||
}
|
||||
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(); }
|
||||
@@ -0,0 +1,74 @@
|
||||
async function loadStatus(){
|
||||
const s=await jget('/api/status');
|
||||
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;
|
||||
if(s.active && s.health){ cls='ok'; txt='prêt'; }
|
||||
else if(s.active){ cls='loading'; txt='chargement…'; }
|
||||
el.className='statuspill '+cls;
|
||||
el.innerHTML='<span class="dot"></span>'+txt+' <span class="port">:'+s.port+'</span>';
|
||||
MODEL_READY = !!(s.active && s.health);
|
||||
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
|
||||
if(s.version){
|
||||
document.getElementById('ver').textContent='v'+s.version;
|
||||
}
|
||||
}
|
||||
async function checkUpdate(){
|
||||
const b=document.getElementById('upd-check'), msg=document.getElementById('upd-msg');
|
||||
b.disabled=true; msg.textContent='Vérification…';
|
||||
try{
|
||||
const r=await jget('/api/update');
|
||||
if(r.error){ msg.textContent='Erreur : '+r.error; }
|
||||
else if(r.available){
|
||||
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
|
||||
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
|
||||
msg.appendChild(btn);
|
||||
} else { msg.textContent='Jean est à jour ✓'; }
|
||||
}catch(e){ msg.textContent='Erreur réseau'; }
|
||||
b.disabled=false;
|
||||
}
|
||||
async function applyUpdate(){
|
||||
const msg=document.getElementById('upd-msg');
|
||||
msg.textContent='Téléchargement et installation…';
|
||||
try{
|
||||
const r=await jpost('/api/update/apply',{});
|
||||
if(r.ok){
|
||||
msg.innerHTML='✓ Installé en <b>v'+r.version+'</b>.<br>'+(r.restart||'');
|
||||
// Redémarrage auto du service côté serveur : le flux va se couper puis
|
||||
// reconnecter tout seul (connectStream boucle). On rafraîchit l'état après.
|
||||
if(r.restarting){ toast('mise à jour appliquée — reconnexion…'); setTimeout(loadAll, 6000); }
|
||||
}
|
||||
else { msg.textContent='Échec : '+(r.error||'inconnu'); }
|
||||
}catch(e){ msg.textContent='Erreur pendant la mise à jour (réessaie).'; }
|
||||
}
|
||||
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
|
||||
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
|
||||
let CTX_MAX=0, CTX_USED=0, MODEL_READY=false;
|
||||
function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
|
||||
function updateCtxMeter(){
|
||||
if(!CTX_MAX) return;
|
||||
const pct=Math.min(100, Math.round(CTX_USED*100/CTX_MAX));
|
||||
const fill=document.getElementById('ctx-fill');
|
||||
fill.style.width=pct+'%';
|
||||
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
|
||||
document.getElementById('ctx-text').textContent='contexte '+CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
|
||||
document.getElementById('ctx-compact').style.display = pct>=90 ? 'inline-block' : 'none';
|
||||
}
|
||||
async function loadVram(){
|
||||
const gpus=await jget('/api/vram');
|
||||
document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{
|
||||
const pct=Math.round(g.used*100/g.total);
|
||||
return '<div style="margin:6px 0"><div style="font-size:12px">'+g.name+'</div>'+
|
||||
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
|
||||
'<div class="muted">'+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB · GPU '+g.util+'% · '+g.temp+'°C</div></div>';
|
||||
}).join('') || '<span class="muted">(pas de GPU)</span>';
|
||||
}
|
||||
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=>{
|
||||
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
|
||||
return '<div class="kv"><span>'+k+'</span><span title="'+v.replace(/"/g,'"')+'">'+v+'</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
async function loadPresets(){
|
||||
const p=await jget('/api/presets');
|
||||
const act = p.find(x=>x.active);
|
||||
// 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.textContent = act ? act.name : '';
|
||||
sp.title = act ? 'preset actif' : '';
|
||||
const cont=document.getElementById('presets');
|
||||
cont.innerHTML='';
|
||||
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
|
||||
p.forEach((x,i)=>{
|
||||
const row=document.createElement('div');
|
||||
row.className='preset'+(x.active?' active':'');
|
||||
row.onclick=()=>switchTo(i+1, x.name);
|
||||
const info=document.createElement('div'); info.className='preset-info';
|
||||
const nm=document.createElement('div'); nm.className='preset-name';
|
||||
nm.textContent=(x.active?'● ':'')+x.name; nm.title=x.name;
|
||||
// Second row: quant tag + bench perf, so the title row stays full-width.
|
||||
const meta=document.createElement('div'); meta.className='preset-meta';
|
||||
if(x.quant){
|
||||
const q=document.createElement('span'); q.className='qtag';
|
||||
q.textContent=x.quant; q.title='quantization';
|
||||
meta.appendChild(q);
|
||||
}
|
||||
if(x.reasoning){
|
||||
const rt=document.createElement('span'); rt.className='rtag';
|
||||
rt.title='raisonnement actif ('+x.reasoning+')';
|
||||
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
|
||||
rt.appendChild(document.createTextNode(x.reasoning));
|
||||
meta.appendChild(rt);
|
||||
}
|
||||
if(x.bench){
|
||||
const bt=document.createElement('span'); bt.className='btag';
|
||||
bt.title='prefill / decode — dernier bench de ce preset';
|
||||
bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s';
|
||||
meta.appendChild(bt);
|
||||
}
|
||||
info.appendChild(nm);
|
||||
if(meta.children.length) info.appendChild(meta);
|
||||
const edit=document.createElement('button');
|
||||
edit.className='preset-edit'; edit.title='éditer'; edit.textContent='✎';
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); };
|
||||
row.appendChild(info); row.appendChild(edit);
|
||||
cont.appendChild(row);
|
||||
});
|
||||
}
|
||||
// « 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 ».
|
||||
async function loadAgent(){
|
||||
const s=await jget('/api/agent');
|
||||
const on = s.enabled;
|
||||
document.getElementById('agent-toggle').checked = on;
|
||||
document.getElementById('toollimit-toggle').checked = (s.tool_limit !== false);
|
||||
document.getElementById('compact-toggle').checked = (s.compact !== false);
|
||||
document.getElementById('agent-badge').innerHTML = on
|
||||
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
|
||||
document.getElementById('brand').classList.toggle('agent', on);
|
||||
setAgentGate(on);
|
||||
if(s.mem_mode){ document.getElementById('mem-mode').value = s.mem_mode; renderMemModeDesc(s.mem_mode); }
|
||||
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';
|
||||
renderMemList();
|
||||
}
|
||||
// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni
|
||||
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
|
||||
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
|
||||
function setAgentGate(on){
|
||||
['mem-block','net-block','param-block'].forEach(id=>{
|
||||
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
|
||||
});
|
||||
}
|
||||
// Repli/dépli de la liste des pages mémoire (fermée par défaut → gagne de la place).
|
||||
function toggleMemPages(){
|
||||
const body=document.getElementById('mem-pages-body');
|
||||
const bar=document.getElementById('mem-pages-bar');
|
||||
const open=body.hasAttribute('hidden');
|
||||
if(open){ body.removeAttribute('hidden'); bar.classList.add('open'); }
|
||||
else { body.setAttribute('hidden',''); bar.classList.remove('open'); }
|
||||
}
|
||||
// Liste mémoire scalable : recherche + rendu plafonné (les milliers de pages ne
|
||||
// déroulent plus une barre géante). memShown grimpe par paliers via « voir plus ».
|
||||
let memPages=[], memShown=0; const MEM_PAGE=50;
|
||||
function renderMemList(){
|
||||
const q=(document.getElementById('mem-search').value||'').trim().toLowerCase();
|
||||
const list=document.getElementById('mem-list');
|
||||
list.textContent='';
|
||||
if(!memPages.length){ list.innerHTML='<div class="muted">(aucune page mémoire)</div>'; return; }
|
||||
const matches = q ? memPages.filter(x=>(x.name+' '+(x.desc||'')).toLowerCase().includes(q)) : memPages;
|
||||
if(!matches.length){ list.innerHTML='<div class="muted">(aucun résultat pour « '+q.replace(/[<>&]/g,'')+' »)</div>'; return; }
|
||||
const shown = matches.slice(0, memShown);
|
||||
shown.forEach(x=>{
|
||||
const row=document.createElement('div'); row.className='preset'; row.style.fontSize='12px';
|
||||
row.onclick=()=>openMem(x.name);
|
||||
const span=document.createElement('span');
|
||||
const b=document.createElement('b'); b.style.color='var(--text)'; b.textContent=x.name; span.appendChild(b);
|
||||
if(x.desc){ const d=document.createElement('span'); d.className='muted'; d.textContent=' — '+x.desc; span.appendChild(d); }
|
||||
const btn=document.createElement('button'); btn.textContent='edit'; btn.style.cssText='margin:0;padding:2px 8px;font-size:11px';
|
||||
btn.onclick=e=>{ e.stopPropagation(); openMem(x.name); };
|
||||
row.appendChild(span); row.appendChild(btn); list.appendChild(row);
|
||||
});
|
||||
if(matches.length > shown.length){
|
||||
const more=document.createElement('div'); more.className='mem-more';
|
||||
more.textContent='+ voir plus ('+(matches.length-shown.length)+' de plus)';
|
||||
more.style.cursor='pointer';
|
||||
more.onclick=()=>{ memShown+=MEM_PAGE; renderMemList(); };
|
||||
list.appendChild(more);
|
||||
}
|
||||
}
|
||||
// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire
|
||||
const loadMem = loadAgent;
|
||||
async function toggleAgent(){
|
||||
const on=document.getElementById('agent-toggle').checked;
|
||||
if(on && !await askConfirm("L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.", {title:'Activer le mode agent ?', okText:'Activer', danger:true})){ document.getElementById('agent-toggle').checked=false; return; }
|
||||
await jpost('/api/agent/toggle',{on});
|
||||
loadAgent();
|
||||
}
|
||||
async function toggleToolLimit(){
|
||||
const on=document.getElementById('toollimit-toggle').checked;
|
||||
await jpost('/api/agent/tool-limit',{on});
|
||||
loadAgent();
|
||||
}
|
||||
async function toggleCompact(){
|
||||
const on=document.getElementById('compact-toggle').checked;
|
||||
await jpost('/api/agent/compact',{on});
|
||||
}
|
||||
// Mode mémoire (3 états) — indépendant du mode agent.
|
||||
const MEM_DESC={
|
||||
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
|
||||
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
|
||||
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
|
||||
};
|
||||
function renderMemModeDesc(m){ const d=document.getElementById('mem-mode-desc'); if(d) d.textContent=MEM_DESC[m]||''; }
|
||||
async function setMemMode(){
|
||||
const mode=document.getElementById('mem-mode').value;
|
||||
const r=await jpost('/api/memory',{mode});
|
||||
renderMemModeDesc(r.mode||mode);
|
||||
}
|
||||
// Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte.
|
||||
let internetOn=false;
|
||||
function renderInternet(s){
|
||||
internetOn = !!s.enabled;
|
||||
document.getElementById('internet-toggle').checked = internetOn;
|
||||
if(document.activeElement !== document.getElementById('crawl-url'))
|
||||
document.getElementById('crawl-url').value = s.url || '';
|
||||
document.getElementById('internet-badge').innerHTML = internetOn
|
||||
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
|
||||
const st=document.getElementById('internet-status');
|
||||
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
|
||||
else if(s.reachable){ st.innerHTML='<span style="color:var(--accent)">✓</span> serveur joignable — outils web actifs'; }
|
||||
else { st.innerHTML='⚠️ serveur injoignable — les outils web ne seront pas proposés'; }
|
||||
}
|
||||
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
|
||||
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
|
||||
let OAI_KEY='', OAI_REVEAL=false;
|
||||
async function copyText(txt, msg){
|
||||
if(!txt){ toast('rien à copier'); return; }
|
||||
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(); }
|
||||
toast(msg||'copié');
|
||||
}
|
||||
function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); }
|
||||
function renderApiKey(d){
|
||||
OAI_KEY = d.key || '';
|
||||
// URL de l'endpoint : llama-server tourne sur d.port (≠ port de l'UI web).
|
||||
// On prend l'hôte annoncé par le serveur (IP LAN détectée côté Go) : dans le
|
||||
// tunnel ajean.link, location.hostname serait le domaine du relais (faux) —
|
||||
// l'accès OpenAI reste TOUJOURS l'adresse locale de la machine.
|
||||
const host = d.host || location.hostname;
|
||||
document.getElementById('oai-url').value = 'http://'+host+':'+d.port+'/v1';
|
||||
// Endpoint PUBLIC (ajean.link) : affiché seulement si l'accès public est activé.
|
||||
const tg = document.getElementById('oai-public-toggle');
|
||||
if(tg) tg.checked = !!d.oai_public;
|
||||
const pubWrap = document.getElementById('oai-public-wrap');
|
||||
if(d.oai_public && d.machine){
|
||||
document.getElementById('oai-public-url').value = 'https://'+d.machine+'.oai.ajean.link/v1';
|
||||
pubWrap.style.display = '';
|
||||
} else {
|
||||
pubWrap.style.display = 'none';
|
||||
}
|
||||
const inp=document.getElementById('oai-key');
|
||||
if(!d.set){ inp.value='(aucune clé — serveur ouvert)'; inp.style.opacity=.6; }
|
||||
else { inp.style.opacity=1; inp.value = OAI_REVEAL ? OAI_KEY : d.masked; }
|
||||
document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none';
|
||||
}
|
||||
async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); }
|
||||
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)); }
|
||||
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;
|
||||
if(action==='generate' && OAI_KEY && !await askConfirm('Générer une nouvelle clé invalide l\'ancienne (les clients devront être mis à jour). Le service va redémarrer.', {title:'Régénérer la clé ?', okText:'Générer'})) return;
|
||||
OAI_REVEAL=(action==='generate'); // révèle la clé fraîche pour qu'on puisse la copier
|
||||
toast('application…');
|
||||
renderApiKey(await jpost('/api/apikey', {action}));
|
||||
}
|
||||
async function toggleOAIPublic(){
|
||||
const on = document.getElementById('oai-public-toggle').checked;
|
||||
await jpost('/api/oai/public', {enabled:on});
|
||||
toast(on ? 'accès public activé' : 'accès public coupé');
|
||||
loadApiKey();
|
||||
}
|
||||
async function apiKeySet(){
|
||||
const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'});
|
||||
if(!k || !k.trim()) return;
|
||||
OAI_REVEAL=true; toast('application…');
|
||||
renderApiKey(await jpost('/api/apikey', {action:'set', key:k.trim()}));
|
||||
}
|
||||
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; }
|
||||
renderInternet(await jpost('/api/internet',{enabled:on, url}));
|
||||
}
|
||||
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 act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -0,0 +1,255 @@
|
||||
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
|
||||
function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; }
|
||||
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
|
||||
function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; }
|
||||
async function runBenchUI(){
|
||||
const btn = document.getElementById('btn-bench');
|
||||
const rerun = document.getElementById('bench-rerun');
|
||||
const body = document.getElementById('bench-body');
|
||||
openBenchModal();
|
||||
btn.disabled = true; btn.textContent = '⏳ bench…';
|
||||
rerun.disabled = true;
|
||||
body.innerHTML =
|
||||
'<div style="text-align:center;padding:20px 0">' +
|
||||
'<div style="font-size:24px;animation:spin 1s linear infinite;display:inline-block">⏳</div>' +
|
||||
'<div class="muted" style="margin-top:8px">prompt 2000 tok + 300 decode<br>~10 secondes…</div>' +
|
||||
'</div>';
|
||||
try{
|
||||
const r = await jget('/api/bench');
|
||||
if(!r.ok){
|
||||
body.innerHTML = '<div style="color:var(--err);text-align:center">erreur: '+r.error+'</div>';
|
||||
return;
|
||||
}
|
||||
const x = r.result;
|
||||
body.innerHTML =
|
||||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
|
||||
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
|
||||
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Prefill</div>' +
|
||||
'<div style="font-size:26px;color:var(--accent);font-weight:600;margin:6px 0">'+x.prompt_per_second.toFixed(0)+'</div>' +
|
||||
'<div class="muted">tok/s</div>' +
|
||||
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s</div>' +
|
||||
'</div>' +
|
||||
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
|
||||
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Decode</div>' +
|
||||
'<div style="font-size:26px;color:var(--ok);font-weight:600;margin:6px 0">'+x.predicted_per_second.toFixed(1)+'</div>' +
|
||||
'<div class="muted">tok/s</div>' +
|
||||
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>';
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '⚡ bench';
|
||||
rerun.disabled = false;
|
||||
loadPresets();
|
||||
}
|
||||
}
|
||||
async function switchTo(n,name){
|
||||
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
|
||||
toast('switching…');
|
||||
const r=await jpost('/api/switch',{n:n});
|
||||
toast(r.ok?'switched':'erreur'); setTimeout(loadAll,2000);
|
||||
}
|
||||
// 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 = {
|
||||
// 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()},
|
||||
mem: {label:'Page', param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()},
|
||||
};
|
||||
async function openItem(kind, key){
|
||||
const K = KINDS[kind];
|
||||
const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||''));
|
||||
const d = await r.json();
|
||||
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';
|
||||
// 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';
|
||||
}
|
||||
|
||||
// Pretty-print bytes — handy for the dropdown options.
|
||||
function fmtSize(b){
|
||||
if(b > 1e9) return (b/1e9).toFixed(1)+' GB';
|
||||
if(b > 1e6) return (b/1e6).toFixed(0)+' MB';
|
||||
if(b > 1e3) return (b/1e3).toFixed(0)+' KB';
|
||||
return b+' B';
|
||||
}
|
||||
// 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() : '';
|
||||
}
|
||||
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();
|
||||
let html = '<option value="">— choisir un modèle —</option>';
|
||||
let matched = false;
|
||||
for(const m of list){
|
||||
const sel = (m.name === curBase) ? ' selected' : '';
|
||||
if(sel) matched = true;
|
||||
html += '<option value="'+m.name+'"'+sel+'>'+m.name+' ('+fmtSize(m.size)+')</option>';
|
||||
}
|
||||
// If MODEL points to something not in JEAN_HOME, show it as a disabled hint.
|
||||
if(cur && !matched){
|
||||
html += '<option value="" disabled selected>('+(curBase||cur)+' — introuvable dans JEAN_HOME)</option>';
|
||||
}
|
||||
sel.innerHTML = html;
|
||||
}
|
||||
function onPickModel(){
|
||||
const val = document.getElementById('m-model').value;
|
||||
if(!val) return;
|
||||
const ta = document.getElementById('m-content');
|
||||
if(/^\s*MODEL\s*=.*$/m.test(ta.value)){
|
||||
ta.value = ta.value.replace(/^\s*MODEL\s*=.*$/m, 'MODEL="'+val+'"');
|
||||
} else {
|
||||
// No MODEL= line yet — prepend so it's visible at the top.
|
||||
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
||||
}
|
||||
toast('MODEL='+val);
|
||||
}
|
||||
|
||||
// Backend picker — same pattern as model: list /etc/jean/backends/*, rewrite BIN=.
|
||||
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() : '';
|
||||
}
|
||||
async function populateBackendPicker(){
|
||||
const sel = document.getElementById('m-backend');
|
||||
const list = await jget('/api/backends');
|
||||
const cur = currentBinInTextarea();
|
||||
let html = '<option value="">— choisir un backend —</option>';
|
||||
let matched = false;
|
||||
for(const b of list){
|
||||
const isSel = (b.path === cur);
|
||||
if(isSel) matched = true;
|
||||
html += '<option value="'+b.path+'"'+(isSel?' selected':'')+'>'+b.name+'</option>';
|
||||
}
|
||||
if(cur && !matched){
|
||||
html += '<option value="" disabled selected>('+(cur.split('/').slice(-3,-2)[0]||cur)+' — hors /etc/jean/backends)</option>';
|
||||
}
|
||||
sel.innerHTML = html;
|
||||
}
|
||||
function onPickBackend(){
|
||||
const val = document.getElementById('m-backend').value;
|
||||
if(!val) return;
|
||||
const ta = document.getElementById('m-content');
|
||||
if(/^\s*BIN\s*=.*$/m.test(ta.value)){
|
||||
ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"');
|
||||
} else {
|
||||
ta.value = 'BIN="'+val+'"\n' + ta.value;
|
||||
}
|
||||
toast('BIN='+val);
|
||||
}
|
||||
// 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 openMem = (n)=>openItem('mem', n);
|
||||
function closeModal(){ document.getElementById('modal').style.display = 'none'; }
|
||||
async function saveItem(){
|
||||
const K = KINDS[editingKind];
|
||||
const name = document.getElementById('m-name').value.trim();
|
||||
const content = document.getElementById('m-content').value;
|
||||
if(!name){ toast('nom requis'); return; }
|
||||
// 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(!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;
|
||||
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; }
|
||||
if(delModel){
|
||||
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
|
||||
else if(r.modelDeleted) toast('preset + modèle supprimés');
|
||||
else toast('supprimé (aucun modèle référencé)');
|
||||
} else { toast('supprimé'); }
|
||||
closeModal(); K.reload();
|
||||
}
|
||||
// Download a .gguf from Hugging Face, polling progress until done.
|
||||
let dlPoll = null;
|
||||
async function startDownload(){
|
||||
const url = document.getElementById('m-hf-url').value.trim();
|
||||
if(!url){ toast('colle un lien .gguf'); return; }
|
||||
const btn = document.getElementById('m-hf-btn');
|
||||
const prog = document.getElementById('m-hf-progress');
|
||||
btn.disabled = true;
|
||||
prog.style.display = 'block';
|
||||
prog.textContent = 'démarrage…';
|
||||
const r = await jpost('/api/models/download', {url});
|
||||
if(!r.ok){ prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>'; btn.disabled=false; return; }
|
||||
const fname = r.filename;
|
||||
if(dlPoll) clearInterval(dlPoll);
|
||||
dlPoll = setInterval(async ()=>{
|
||||
const list = await jget('/api/models/download/status');
|
||||
const st = (list||[]).find(d=>d.filename===fname);
|
||||
if(!st){ return; }
|
||||
if(st.error){
|
||||
prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
|
||||
clearInterval(dlPoll); dlPoll=null; btn.disabled=false; return;
|
||||
}
|
||||
if(st.finished){
|
||||
prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
||||
clearInterval(dlPoll); dlPoll=null; btn.disabled=false;
|
||||
await populateModelPicker();
|
||||
document.getElementById('m-model').value = fname; onPickModel();
|
||||
return;
|
||||
}
|
||||
const pct = st.total>0 ? Math.round(st.done*100/st.total) : 0;
|
||||
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : '';
|
||||
prog.textContent = '⬇ '+fmtSize(st.done)+tot+' — '+fname;
|
||||
}, 800);
|
||||
}
|
||||
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
|
||||
// up. Re-stick when they scroll back near bottom themselves.
|
||||
@@ -0,0 +1,225 @@
|
||||
let stickyBottom = true;
|
||||
const chatEl = () => document.getElementById('chat');
|
||||
function isNearBottom(){
|
||||
const c = chatEl();
|
||||
return c.scrollHeight - c.scrollTop - c.clientHeight < 60;
|
||||
}
|
||||
function scrollMaybe(){
|
||||
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
|
||||
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
|
||||
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
|
||||
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
|
||||
if(REPLAYING) return;
|
||||
if(stickyBottom){
|
||||
const c = chatEl();
|
||||
c.scrollTop = c.scrollHeight;
|
||||
}
|
||||
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
|
||||
}
|
||||
function jumpBottom(){ stickyBottom = true; scrollMaybe(); }
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const c = chatEl();
|
||||
c.addEventListener('scroll', ()=>{
|
||||
stickyBottom = isNearBottom();
|
||||
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
|
||||
});
|
||||
});
|
||||
|
||||
function addMsg(role, text){
|
||||
const el=document.createElement('div');
|
||||
el.className='msg '+role;
|
||||
const collapsible = (role==='reasoning' || role==='tool');
|
||||
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
|
||||
if(collapsible){
|
||||
el.classList.add('collapsible');
|
||||
el.innerHTML='<span class="label">'+role+'</span><div class="bodywrap"><div class="body"></div></div>';
|
||||
el.querySelector('.label').onclick=()=>toggleCollapse(el);
|
||||
} else {
|
||||
el.innerHTML='<span class="label">'+role+'</span><div class="body"></div>';
|
||||
}
|
||||
el.querySelector('.body').textContent=text;
|
||||
chatEl().appendChild(el);
|
||||
scrollMaybe();
|
||||
return el;
|
||||
}
|
||||
// Bulle « … » animée affichée dès l'envoi, retirée au 1er token/outil/erreur.
|
||||
function addTyping(){
|
||||
const el=document.createElement('div');
|
||||
el.className='msg assistant typing';
|
||||
el.innerHTML='<span></span><span></span><span></span>';
|
||||
chatEl().appendChild(el); scrollMaybe();
|
||||
return el;
|
||||
}
|
||||
// Replie/déplie en douceur les bulles reasoning/tool. Hauteur animée en JS :
|
||||
// on fige scrollHeight puis on va à 0 (fermeture) ou de 0 vers scrollHeight
|
||||
// (ouverture), sans jamais dépasser. overflow:hidden clippe pendant l'animation.
|
||||
function collapseBody(el){
|
||||
const bw=el.querySelector('.bodywrap'); if(!bw || el.classList.contains('collapsed')) return;
|
||||
bw.style.height = bw.scrollHeight+'px'; // fige les dimensions courantes
|
||||
bw.style.width = bw.scrollWidth+'px';
|
||||
void bw.offsetHeight; // reflow pour que la transition parte de là
|
||||
el.classList.add('collapsed');
|
||||
bw.style.height = '0px'; // → anime height ET width vers 0
|
||||
bw.style.width = '0px';
|
||||
}
|
||||
function expandBody(el){
|
||||
const bw=el.querySelector('.bodywrap'); if(!bw) return;
|
||||
el.classList.remove('collapsed');
|
||||
bw.style.height=''; bw.style.width=''; // mesure les dimensions naturelles…
|
||||
const h=bw.scrollHeight, w=bw.scrollWidth;
|
||||
bw.style.height='0px'; bw.style.width='0px';// …repart de 0 (pas de flash, même frame)
|
||||
void bw.offsetHeight;
|
||||
bw.style.height=h+'px'; bw.style.width=w+'px';
|
||||
const done=e=>{ if(e.propertyName!=='height') return; bw.style.height=''; bw.style.width=''; bw.removeEventListener('transitionend',done); };
|
||||
bw.addEventListener('transitionend',done);
|
||||
}
|
||||
function toggleCollapse(el){ el.classList.contains('collapsed') ? expandBody(el) : collapseBody(el); }
|
||||
// Replie toutes les bulles d'un tour une fois la réponse finale entamée.
|
||||
function collapseAll(list){ for(const el of list){ if(el) collapseBody(el); } list.length=0; }
|
||||
// Replie une bulle INSTANTANÉMENT (sans animation) — utilisé pendant le replay au
|
||||
// chargement pour que les vieilles bulles apparaissent déjà fermées. La classe
|
||||
// 'collapsed' seule ne gère que l'opacité ; la hauteur est en style inline, donc
|
||||
// on la met à 0 transition désactivée.
|
||||
function collapseInstant(el){
|
||||
const bw=el.querySelector('.bodywrap'); if(!bw) return;
|
||||
el.classList.add('collapsed');
|
||||
// Pas de `void bw.offsetHeight` ici : la bulle vient d'être créée et n'a jamais
|
||||
// été peinte dépliée, donc poser height:0 n'anime pas — inutile de forcer un
|
||||
// reflow par bulle (ce qui, multiplié par le replay, coûtait très cher).
|
||||
bw.style.transition='none';
|
||||
bw.style.height='0px'; bw.style.width='0px';
|
||||
requestAnimationFrame(()=>{ bw.style.transition=''; });
|
||||
}
|
||||
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){ const b=bodyOf(el); b.innerHTML = md(text); addCopyButtons(b); scrollMaybe(); }
|
||||
// Render a tool call as its own conversation message: the command the model
|
||||
// wrote, then the response it got back. textContent keeps it injection-safe.
|
||||
function renderToolMsg(el, tu){
|
||||
// Métadonnées d'affichage par outil : nom court + en-tête avec icône. Les outils
|
||||
// web (web_search/open/read/grep) doivent afficher 🌐, pas le fallback mémoire.
|
||||
const META = {
|
||||
bash: {lbl:'terminal', head:'⚙️ commande'},
|
||||
edit: {lbl:'édition', head:'✏️ édition'},
|
||||
web_search: {lbl:'recherche', head:'🌐 recherche web'},
|
||||
web_open: {lbl:'page web', head:'🌐 ouverture'},
|
||||
web_read: {lbl:'page web', head:'🌐 lecture'},
|
||||
web_grep: {lbl:'page web', head:'🌐 recherche'},
|
||||
};
|
||||
const meta = META[tu.name] || {lbl:'mémoire', head:'🧠 mémoire'};
|
||||
let lbl = meta.lbl;
|
||||
// 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);
|
||||
const body=bodyOf(el); body.innerHTML='';
|
||||
const head=document.createElement('div'); head.className='tool-head';
|
||||
head.textContent = meta.head;
|
||||
body.appendChild(head);
|
||||
if(tu.label){
|
||||
const pre=document.createElement('pre'); pre.className='tool-cmd';
|
||||
const code=document.createElement('code'); code.textContent=tu.label;
|
||||
if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
}
|
||||
const hasResult = tu.result!==undefined && tu.result!=='';
|
||||
if(hasResult){
|
||||
const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre');
|
||||
const code=document.createElement('code'); code.textContent=tu.result;
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
} else if(!tu.done && !tu.typing){
|
||||
const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='⏳ exécution en cours…';
|
||||
body.appendChild(wait);
|
||||
}
|
||||
addCopyButtons(body); scrollMaybe();
|
||||
}
|
||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
root.querySelectorAll('pre').forEach(pre=>{
|
||||
if(pre.querySelector('.copybtn')) return;
|
||||
const btn=document.createElement('button');
|
||||
btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
|
||||
btn.onclick=async(e)=>{
|
||||
e.stopPropagation();
|
||||
const code=pre.querySelector('code'), txt=(code||pre).innerText;
|
||||
try{ await navigator.clipboard.writeText(txt); }
|
||||
catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
|
||||
btn.textContent='copié ✓'; btn.classList.add('done');
|
||||
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
|
||||
};
|
||||
pre.appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Nouvelle conversation POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||||
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
|
||||
function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); toast('nouvelle conversation'); }
|
||||
// Compaction : on demande à l'IA un résumé de la conversation destiné à la
|
||||
// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
|
||||
// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
|
||||
async function compactContext(){
|
||||
// Le compactage est désormais AUTOMATIQUE côté serveur (façon Hermes) : ce
|
||||
// bouton manuel n'a plus d'objet et est neutralisé.
|
||||
toast('le compactage est automatique (façon Hermes)'); return;
|
||||
if(busy) return;
|
||||
if(!msgs.length){ toast('rien à compacter'); return; }
|
||||
if(!await askConfirm('L\'IA va résumer la conversation puis repartir d\'une session propre basée sur ce résumé.', {title:'Compacter le contexte ?', okText:'Compacter'})) return;
|
||||
busy=true;
|
||||
const btn=document.getElementById('ctx-compact'); btn.textContent='compaction…'; btn.disabled=true;
|
||||
const instruction='Résume de façon structurée et complète TOUTE notre conversation jusqu\'ici, dans le but de poursuivre le travail dans une nouvelle session sans rien perdre d\'important : objectifs, décisions prises, état actuel, fichiers/commandes/éléments clés, et ce qu\'il reste à faire. Écris uniquement le résumé, sans préambule.';
|
||||
let summary='';
|
||||
try{
|
||||
const sys=(document.getElementById('sysprompt').value||'').trim();
|
||||
const base=[...msgs,{role:'user',content:instruction}];
|
||||
const out = sys ? [{role:'system',content:sys},...base] : base;
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out})});
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
const {done,value}=await reader.read(); if(done) break;
|
||||
buf+=dec.decode(value,{stream:true}); let i;
|
||||
while((i=buf.indexOf('\n\n'))>=0){
|
||||
const chunk=buf.slice(0,i); buf=buf.slice(i+2);
|
||||
for(const line of chunk.split('\n')){
|
||||
if(!line.startsWith('data:')) continue;
|
||||
const data=line.slice(5).trim(); if(data==='[DONE]') continue;
|
||||
try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; if(d.content) summary+=d.content; }catch(e){}
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch(e){ toast('échec compaction: '+e.message); busy=false; btn.textContent='compacter'; btn.disabled=false; return; }
|
||||
summary=summary.trim();
|
||||
if(!summary){ toast('résumé vide'); busy=false; btn.textContent='compacter'; btn.disabled=false; return; }
|
||||
// Session propre seedée avec le résumé.
|
||||
msgs=[{role:'user',content:'Résumé de notre session précédente (reprends le travail à partir de là) :\n\n'+summary},
|
||||
{role:'assistant',content:'Compris, je reprends à partir de ce résumé.'}];
|
||||
document.getElementById('chat').innerHTML='';
|
||||
const note=addMsg('tool',''); note.querySelector('.label').textContent='contexte compacté';
|
||||
renderBody(note, '🗜️ **Contexte compacté.** Résumé de reprise :\n\n'+summary);
|
||||
saveChat();
|
||||
setCtxUsed(0); try{localStorage.removeItem('jean.ctxused');}catch(e){}
|
||||
busy=false; btn.textContent='compacter'; btn.disabled=false;
|
||||
toast('contexte compacté');
|
||||
}
|
||||
// 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){} }
|
||||
// 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
|
||||
// localStorage : le même contexte est partagé par tous les appareils.
|
||||
// Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue
|
||||
// tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement)
|
||||
// puis suit le direct. Partagé par tous les appareils.
|
||||
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
|
||||
// signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s.
|
||||
const c=chatEl(); c.style.opacity='0';
|
||||
// Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand
|
||||
// même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que
|
||||
// REPLAYING, donc on force ici le positionnement).
|
||||
setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000);
|
||||
connectStream();
|
||||
}
|
||||
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
|
||||
@@ -0,0 +1,146 @@
|
||||
// ===== 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
|
||||
// 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.
|
||||
let lastSeq=0, streamAbort=null;
|
||||
// REPLAYING = on est dans le replay initial (rejeu du journal au chargement).
|
||||
// Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées →
|
||||
// pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up}
|
||||
// quand le replay est fini, on repasse alors en direct.
|
||||
let REPLAYING=true;
|
||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||
let T=null;
|
||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; }
|
||||
newTurn();
|
||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
||||
function killTyping(){ if(!T.typingEl) return; if(simpleMode()) return; removeTyping(); }
|
||||
function showTyping(){ if(!simpleMode()) 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).
|
||||
function renderStats(el, s){
|
||||
if(!el||!s) return;
|
||||
const role=(el===T.contentEl)?'assistant':'reasoning';
|
||||
const parts=[role];
|
||||
const pt=s.prompt_tokens||s.prompt_tokens_total;
|
||||
if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
|
||||
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
|
||||
setLabel(el, parts.join(' · '));
|
||||
}
|
||||
// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
|
||||
// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
|
||||
// donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un
|
||||
// bloc côté client, mais leurs ts serveur restent espacés du vrai temps écoulé).
|
||||
function labelTokens(el, role, n, firstTs, lastTs){
|
||||
if(!el) return;
|
||||
const secs=(lastTs-firstTs)/1000;
|
||||
if(secs>0.05 && n>1){ setLabel(el, role+' · '+n+' tok · '+(n/secs).toFixed(1)+' tok/s'); }
|
||||
else { setLabel(el, role+' · '+n+' tok'); }
|
||||
}
|
||||
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
|
||||
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
|
||||
// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
|
||||
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
|
||||
function syncSendBtn(){
|
||||
document.getElementById('send').style.display=busy?'none':'inline-block';
|
||||
document.getElementById('stop').style.display=busy?'inline-block':'none';
|
||||
}
|
||||
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
|
||||
// piloté par le serveur et rejouable à l'identique.
|
||||
function handleDelta(d){
|
||||
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
|
||||
if(d.caught_up){
|
||||
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
|
||||
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
|
||||
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
|
||||
return; }
|
||||
if(d.reset!==undefined){ document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
|
||||
if(d.user!==undefined){ newTurn(); addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
|
||||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
|
||||
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.compacted){ toast('contexte compacté — les vieux tours ont été résumés'); return; }
|
||||
if(d.stats){ T.serverStats=d.stats;
|
||||
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;
|
||||
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
|
||||
renderToolMsg(T.pendingToolEl, tu);
|
||||
if(!tu.done) showTyping();
|
||||
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();
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
|
||||
showTyping(); 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);
|
||||
labelTokens(T.reasonEl, 'reasoning', T.reasonTok, T.reasonFirstTs, T.reasonLastTs);
|
||||
return; }
|
||||
if(d.content){
|
||||
removeTyping();
|
||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; }
|
||||
T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent);
|
||||
if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1);
|
||||
labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
|
||||
return; }
|
||||
}
|
||||
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
|
||||
// re-téléchargement complet après une coupure / bascule d'appareil).
|
||||
async function connectStream(){
|
||||
while(true){
|
||||
streamAbort=new AbortController();
|
||||
try{
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
const {done,value}=await reader.read(); if(done) break;
|
||||
buf+=dec.decode(value,{stream:true}); let i;
|
||||
while((i=buf.indexOf('\n\n'))>=0){
|
||||
const chunk=buf.slice(0,i); buf=buf.slice(i+2);
|
||||
for(const line of chunk.split('\n')){
|
||||
if(!line.startsWith('data:')) continue;
|
||||
const data=line.slice(5).trim(); if(data===''||data==='[DONE]') continue;
|
||||
try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; handleDelta(d); }catch(e){}
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch(e){ /* coupure : on reconnecte silencieusement */ }
|
||||
await new Promise(res=>setTimeout(res, 600));
|
||||
}
|
||||
}
|
||||
// Interrompt la génération en cours côté serveur (la goroutine détachée est
|
||||
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
|
||||
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
|
||||
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
|
||||
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
|
||||
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
|
||||
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
|
||||
// « network error » alarmiste alors que l'IA répond quand même.
|
||||
async function send(){
|
||||
if(busy) return;
|
||||
const ta=document.getElementById('input'); const text=ta.value.trim();
|
||||
if(!text) return;
|
||||
ta.value='';
|
||||
for(let attempt=0; attempt<3; attempt++){
|
||||
try{
|
||||
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})});
|
||||
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
|
||||
if(r.ok) return; // la bulle + les tokens arrivent par le flux
|
||||
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} toast(m); ta.value=text; return; }
|
||||
}catch(e){ /* réseau : on retente */ }
|
||||
await new Promise(res=>setTimeout(res, 600));
|
||||
}
|
||||
// Après plusieurs échecs : le serveur a peut-être quand même reçu le message.
|
||||
try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){}
|
||||
toast('échec de l\'envoi — réessaie'); ta.value=text;
|
||||
}
|
||||
loadAll();
|
||||
setInterval(loadStatus, 5000);
|
||||
setInterval(loadVram, 3000);
|
||||
@@ -0,0 +1,271 @@
|
||||
/* ===== Thèmes =====================================================
|
||||
Toutes les couleurs de l'UI passent par ces variables. Pour AJOUTER un
|
||||
thème plus tard : 1) copier un bloc [data-theme="…"] ci-dessous en changeant
|
||||
les valeurs, 2) ajouter son {id,label} au registre THEMES (voir le JS). Le
|
||||
reste (sélecteur, persistance, application) est automatique. */
|
||||
/* --font : police par défaut (mono). Un thème peut la surcharger (voir "soft").
|
||||
Les blocs plus spécifiques qui ne redéfinissent pas --font héritent de :root. */
|
||||
:root{color-scheme:dark;--font:ui-monospace,SFMono-Regular,Menlo,monospace;--bubble:#1f6feb;--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#e6edf3;--dim:#7d8590;--accent:#58a6ff;--accent-bg:rgba(88,166,255,.12);--ok:#58a6ff;--warn:#d29922;--err:#f85149;--mag:#bc8cff;--code-bg:#0d1117;--danger-bg:#3d1f23;--warn-bg:#3d2f1f}
|
||||
[data-theme="dark"]{color-scheme:dark;--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#e6edf3;--dim:#7d8590;--accent:#58a6ff;--accent-bg:rgba(88,166,255,.12);--ok:#58a6ff;--warn:#d29922;--err:#f85149;--mag:#bc8cff;--code-bg:#0d1117;--danger-bg:#3d1f23;--warn-bg:#3d2f1f}
|
||||
[data-theme="light"]{color-scheme:light;--bg:#f0f1f4;--panel:#f8f9fb;--border:#d7dbe1;--text:#24292f;--dim:#636b74;--accent:#0969da;--accent-bg:rgba(9,105,218,.10);--ok:#0969da;--warn:#9a6700;--err:#cf222e;--mag:#8250df;--code-bg:#e6e9ee;--danger-bg:#fbe6e6;--warn-bg:#fbefda}
|
||||
/* "doux" — thème clair chaud + police Lexend (lecture agréable). */
|
||||
[data-theme="soft"]{color-scheme:light;--font:'Lexend',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--bubble:#c2611f;--bg:#faf8f4;--panel:#ffffff;--border:#e8e3d9;--text:#2c2a26;--dim:#8c8577;--accent:#c2611f;--accent-bg:rgba(194,97,31,.10);--ok:#c2611f;--warn:#b45309;--err:#dc2626;--mag:#9a5bd6;--code-bg:#f3f0ea;--danger-bg:#fbe9e7;--warn-bg:#fbf1e0}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;display:flex;font-size:14px}
|
||||
/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne
|
||||
pas passer sous l'encoche / la barre système / l'indicateur d'accueil. Les
|
||||
env(safe-area-inset-*) valent 0 hors PWA, donc aucun impact ailleurs. */
|
||||
body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
|
||||
.side{width:320px;border-right:1px solid var(--border);padding:16px;overflow-y:auto;flex-shrink:0}
|
||||
.main{flex:1;display:flex;flex-direction:column;min-width:0}
|
||||
#menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));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}
|
||||
@media (max-width:720px){
|
||||
.side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left))}
|
||||
.side.open{transform:translateX(0)}
|
||||
#menubtn,#backdrop.open{display:block}
|
||||
body.drawer-open #menubtn{display:none}
|
||||
.main{width:100%}
|
||||
#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}
|
||||
/* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et
|
||||
ça prend de la place. Elle reste visible sur desktop. */
|
||||
#sendhint{display:none}
|
||||
}
|
||||
/* 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;font-size:18px;color:var(--accent)}
|
||||
#brand{display:inline-flex;align-items:baseline}
|
||||
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:5px;letter-spacing:0}
|
||||
.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:18px}
|
||||
/* pastille d'état service, en haut à droite de la barre latérale */
|
||||
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap}
|
||||
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||||
.statuspill.err{color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)}
|
||||
.statuspill.loading{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)}
|
||||
.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:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;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)}
|
||||
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;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. */
|
||||
.subhead{position:relative;display:flex;align-items:center;font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:var(--accent);font-weight:600;margin:0 0 6px}
|
||||
/* 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{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:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);font-weight:600}
|
||||
.pages-bar .addbtn{margin-left:auto}
|
||||
.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,.6);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,.5);animation:askpop .12s ease-out}
|
||||
@keyframes askpop{from{opacity:0;transform:translateY(6px) scale(.98)}to{opacity:1;transform:none}}
|
||||
.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}
|
||||
.ask-input:focus{outline:none;border-color:var(--accent)}
|
||||
.ask-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
|
||||
.ask-actions button{margin:0;padding:7px 16px}
|
||||
.ask-ok{border-color:var(--accent);color:var(--accent)}
|
||||
.ask-ok.danger{border-color:var(--err);color:var(--err)}
|
||||
#brand-a{display:inline-block;line-height:1;max-width:0;opacity:0;overflow:hidden;color:var(--text);transition:max-width .35s ease,opacity .35s ease}
|
||||
#brand.agent #brand-a{max-width:1ch;opacity:1;margin-right:.04em}
|
||||
/* interrupteur (switch) */
|
||||
.switchrow{position:relative;display:flex;align-items:center;gap:9px;font-size:12px;padding:6px 0;cursor:pointer;user-select:none}
|
||||
/* 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. */
|
||||
.gated-block{margin-top:10px;border:1px solid var(--border);border-radius:8px;padding:9px 11px;background:var(--panel);transition:opacity .15s}
|
||||
.gated-block>.switchrow:first-of-type{padding-top:0}
|
||||
.gated-block.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}
|
||||
.switch .slider{position:absolute;inset:0;background:var(--border);border-radius:999px;transition:background .25s ease}
|
||||
.switch .slider::before{content:"";position:absolute;width:14px;height:14px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .25s ease;box-shadow:0 1px 2px rgba(0,0,0,.4)}
|
||||
.switch input:checked + .slider{background:var(--accent)}
|
||||
.switch input:checked + .slider::before{transform:translateX(16px)}
|
||||
.switch input:focus-visible + .slider{box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--accent)}
|
||||
h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600}
|
||||
details{margin:7px 0;border-top:1px solid var(--border);padding-top:4px}
|
||||
details>summary{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600;cursor:pointer;padding:3px 0;list-style:none;display:flex;justify-content:space-between;align-items:center;user-select:none}
|
||||
details>summary::-webkit-details-marker{display:none}
|
||||
details>summary::after{content:"▸";transition:transform .15s;color:var(--dim);font-size:10px}
|
||||
details[open]>summary::after{transform:rotate(90deg)}
|
||||
details>summary:hover{color:var(--text)}
|
||||
details>div,details>textarea{margin-top:6px}
|
||||
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer}
|
||||
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
|
||||
details[open]>summary>.preset-add{display:inline-block}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
|
||||
.tag.ok{color:var(--ok);border-color:var(--ok)}
|
||||
.tag.err{color:var(--err);border-color:var(--err)}
|
||||
button{background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font:inherit;cursor:pointer;margin:2px}
|
||||
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;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(--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}
|
||||
.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:.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}
|
||||
/* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */
|
||||
.help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle}
|
||||
.help:hover{color:var(--accent);border-color:var(--accent)}
|
||||
/* La bulle est ancrée à la ligne (.switchrow position:relative), pas au « ? »,
|
||||
pour rester alignée au bord gauche du panneau et ne pas déborder. */
|
||||
.help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.4);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;cursor:auto}
|
||||
.help:hover>.tip,.help:focus>.tip,.help.open>.tip{display:block}
|
||||
.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}
|
||||
.bar{height:6px;background:var(--panel);border-radius:3px;overflow:hidden;margin:2px 0}
|
||||
.bar>div{height:100%;background:var(--accent)}
|
||||
#chat{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:14px}
|
||||
.msg{max-width:80%;padding:10px 14px;border-radius:10px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
|
||||
.msg.assistant,.msg.reasoning{white-space:normal}
|
||||
.msg .body{overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.user{align-self:flex-end;background:var(--bubble);color:#fff}
|
||||
.msg.user .label{color:rgba(255,255,255,.8)}
|
||||
.msg.assistant{align-self:flex-start;background:var(--panel);border:1px solid var(--border)}
|
||||
.msg.assistant.errmsg{border-color:rgba(248,81,73,.55);background:rgba(248,81,73,.08);color:var(--err)}
|
||||
.msg .body>*{margin:1em 0}
|
||||
.msg .body>*:first-child{margin-top:0}
|
||||
.msg .body>*:last-child{margin-bottom:0}
|
||||
.msg .body h1,.msg .body h2,.msg .body h3,.msg .body h4{font-weight:600;line-height:1.25;margin:1.2em 0 .5em}
|
||||
.msg .body h1{font-size:1.3em} .msg .body h2{font-size:1.15em} .msg .body h3{font-size:1em} .msg .body h4{font-size:.95em}
|
||||
.msg .body ul,.msg .body ol{padding-left:1.4em}
|
||||
.msg .body li{margin:.15em 0}
|
||||
.msg .body li>p{margin:0}
|
||||
.msg.assistant .body code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;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,.msg.reasoning .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,.msg.reasoning .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}
|
||||
.msg.assistant .body a:hover{text-decoration:underline}
|
||||
.msg.assistant .body table{border-collapse:collapse;margin:.5em 0}
|
||||
.msg.assistant .body th,.msg.assistant .body td{border:1px solid var(--border);padding:4px 8px}
|
||||
.msg.assistant .body hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
|
||||
#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%;overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.tool{align-self:flex-start;background:transparent;border:1px dashed var(--border);max-width:90%;white-space:normal;padding:8px 12px;font-size:12px;overflow-wrap:anywhere;word-break:break-word}
|
||||
.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:var(--code-bg);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}}
|
||||
/* Indicateur « le modèle réfléchit » : 3 points animés avant le 1er token. */
|
||||
.msg.typing{display:inline-flex;gap:5px;align-items:center;padding:14px 16px}
|
||||
.msg.typing span{width:7px;height:7px;border-radius:50%;background:var(--dim);animation:typingdot 1.2s infinite ease-in-out}
|
||||
.msg.typing span:nth-child(2){animation-delay:.16s}
|
||||
.msg.typing span:nth-child(3){animation-delay:.32s}
|
||||
@keyframes typingdot{0%,70%,100%{opacity:.25;transform:translateY(0)}35%{opacity:1;transform:translateY(-4px)}}
|
||||
.msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block}
|
||||
/* Bulles repliables (reasoning / tool) : une fois la réponse arrivée, on les
|
||||
replie en douceur pour désencombrer le fil, tout en gardant le label (et son
|
||||
compteur de tokens) cliquable pour redéplier. */
|
||||
.msg.collapsible .label{cursor:pointer;user-select:none;margin-bottom:0}
|
||||
/* ===== Affichage simple ==========================================
|
||||
Mode "simple" (Apparence) : masque le raisonnement, les appels d'outils,
|
||||
les détails de vitesse sur les bulles et la config active à gauche. Piloté
|
||||
par [data-display="simple"] sur <html> (persisté dans 'jean-display'). */
|
||||
/* En simple, raisonnement et outils sont masqués. L'activité est signalée par le
|
||||
VRAI indicateur de frappe (.msg.typing, mêmes 3 points animés qu'avant la
|
||||
réponse), maintenu en bas pendant tout le tour côté JS — animation identique. */
|
||||
[data-display="simple"] #chat .msg.reasoning,
|
||||
[data-display="simple"] #chat .msg.tool{display:none}
|
||||
[data-display="simple"] #chat .msg .label{display:none}
|
||||
[data-display="simple"] #cfg-details{display:none}
|
||||
/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui
|
||||
peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli.
|
||||
width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0
|
||||
APRÈS le fold (width 0s .3s) à la fermeture pour rétrécir la bulle au label. */
|
||||
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
|
||||
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
|
||||
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
|
||||
.msg.collapsible .bodywrap>.body{min-width:0}
|
||||
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
|
||||
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
|
||||
ligne de progression pleine largeur affleurant le bord) + zone de saisie. */
|
||||
#composer{border-top:1px solid var(--border);background:var(--panel-2,rgba(255,255,255,.02))}
|
||||
#ctxtrack{height:3px;width:100%;background:var(--panel);overflow:hidden}
|
||||
#ctx-fill{height:100%;width:0%;background:var(--ok,#3a7);transition:width .3s,background .3s}
|
||||
#ctxmeta{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:5px 12px 0}
|
||||
#ctx-text{font-size:10px;letter-spacing:.03em;white-space:nowrap}
|
||||
#ctx-compact{display:none;margin:0;padding:2px 10px;font-size:10px;background:var(--warn-bg);border-color:var(--warn,#c93)}
|
||||
#inputbar{padding:8px 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}
|
||||
#input:focus{outline:none;border-color:var(--accent)}
|
||||
#send{padding:0 20px}
|
||||
.muted{color:var(--dim);font-size:12px}
|
||||
#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}
|
||||
}
|
||||
/* Appareil TACTILE en PAYSAGE : on réduit nettement tout pour gagner de la place
|
||||
(interface « un peu grosse » en horizontal, utile pour enregistrer des démos).
|
||||
Détection par (hover:none)+(pointer:coarse)+landscape : infaillible sur tout
|
||||
téléphone couché, sans dépendre d'un seuil de largeur/hauteur en pixels (les
|
||||
iPhone Pro Max font 932px de large et ~430px de haut, ce qui piégeait les
|
||||
seuils précédents). Le desktop (souris) n'est jamais touché.
|
||||
NB : la règle iOS 16px anti-zoom plus haut est surchargée ici volontairement
|
||||
pour #input — on garde une saisie compacte (le pinch-zoom manuel reste dispo). */
|
||||
@media (hover:none) and (pointer:coarse) and (orientation:landscape){
|
||||
body{font-size:11.5px}
|
||||
/* Tiroir latéral : on décale le TIROIR ENTIER après l'encoche (left = inset)
|
||||
au lieu de padder à l'intérieur — sinon grosse bordure vide à gauche qui
|
||||
rétrécit le contenu. Padding interne normal → largeur utile pleine. */
|
||||
.side{width:300px;max-width:300px;left:env(safe-area-inset-left);padding:calc(14px + env(safe-area-inset-top)) 14px 14px 14px}
|
||||
#chat{padding:48px 10px 6px;gap:7px}
|
||||
.msg{padding:6px 10px;border-radius:8px;max-width:78%}
|
||||
#chat .msg.assistant,#chat .msg.reasoning{max-width:88%}
|
||||
#inputbar{padding:5px 8px;gap:6px}
|
||||
/* PAS de font-size < 16px ici : iOS Safari zoome à la sélection du champ. On
|
||||
garde 16px (hérité de la règle anti-zoom) et on compacte via hauteur/padding. */
|
||||
#input{min-height:34px;max-height:120px;padding:6px 9px}
|
||||
#send,#stop{padding:0 14px}
|
||||
#composer .muted{display:none}
|
||||
#ctxmeta{padding:3px 10px 0}
|
||||
#ctx-text{font-size:9px}
|
||||
h1{font-size:15px}
|
||||
h2,details>summary{font-size:10px}
|
||||
#menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// web_auth.go protège l'API de pilotage (jean web) quand elle est exposée sur
|
||||
// internet — c.-à-d. l'API que tout client (navigateur, app mobile, script…)
|
||||
// utilise pour switcher de preset, redémarrer le service, lire le status, etc.
|
||||
//
|
||||
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
|
||||
// protège llama-server / les complétions). On veut pouvoir donner à un client
|
||||
// un accès aux complétions sans lui donner le droit de redémarrer la machine,
|
||||
// et inversement. Elle est stockée dans $JEAN_HOME/.web_key et lue à chaque
|
||||
// requête (pas de cache) pour qu'un changement de clé prenne effet sans
|
||||
// redémarrer le serveur web.
|
||||
|
||||
func webKeyPath() string { return filepath.Join(JeanHome(), ".web_key") }
|
||||
|
||||
// readWebKey returns the trimmed contents of $JEAN_HOME/.web_key, or "" if the
|
||||
// file is absent/empty.
|
||||
func readWebKey() string {
|
||||
b, err := os.ReadFile(webKeyPath())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// requireWebAuth wraps an HTTP handler, rejecting requests that don't present
|
||||
// the configured Bearer token. When no key is configured the handler is left
|
||||
// open (pratique en local) — cmdWeb avertit alors bruyamment au démarrage.
|
||||
func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
key := readWebKey()
|
||||
if key == "" {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
if !checkBearer(r, key) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="jean"`)
|
||||
sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// checkBearer reports whether the request carries the expected key, either as an
|
||||
// "Authorization: Bearer <clé>" header (cas normal) or as a ?key=<clé>
|
||||
// query param (repli pour les clients qui ne peuvent pas poser d'en-tête, p.ex.
|
||||
// une URL ouverte directement). La comparaison est à temps constant.
|
||||
func checkBearer(r *http.Request, key string) bool {
|
||||
want := []byte(key)
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
got := []byte(strings.TrimSpace(h[len("Bearer "):]))
|
||||
if subtle.ConstantTimeCompare(got, want) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if q := r.URL.Query().Get("key"); q != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(q), want) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cmdSetWebKey sets (or clears) the control-API key in $JEAN_HOME/.web_key.
|
||||
//
|
||||
// jean set-web-key <clé> définit la clé
|
||||
// jean set-web-key génère une clé aléatoire
|
||||
// jean set-web-key "" supprime la protection (API ouverte)
|
||||
//
|
||||
// Contrairement à set-api-key, aucun redémarrage n'est nécessaire : le serveur
|
||||
// web relit la clé à chaque requête.
|
||||
func cmdSetWebKey(args []string) error {
|
||||
var key string
|
||||
switch {
|
||||
case len(args) == 0:
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
key = "jean-web-" + hex.EncodeToString(buf)
|
||||
fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key))
|
||||
case args[0] == "" || args[0] == "off" || args[0] == "none":
|
||||
key = ""
|
||||
default:
|
||||
key = strings.TrimSpace(args[0])
|
||||
}
|
||||
if key == "" {
|
||||
if err := os.Remove(webKeyPath()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]"))
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(webKeyPath(), []byte(key+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s clé de pilotage enregistrée dans %s\n", green("[ok]"), webKeyPath())
|
||||
fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
|
||||
fmt.Printf(" (relance 'jean web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package jean
|
||||
|
||||
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
|
||||
// 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(JeanHome(), "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{
|
||||
"theme": {"dark": true, "light": true, "soft": true},
|
||||
"display": {"full": true, "simple": true},
|
||||
}
|
||||
|
||||
// loadWebPrefs lit les préférences enregistrées (map vide si absent/illisible).
|
||||
func loadWebPrefs() map[string]string {
|
||||
out := map[string]string{}
|
||||
b, err := os.ReadFile(webPrefsPath())
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
_ = json.Unmarshal(b, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// saveWebPrefs fusionne les valeurs valides de `in` dans le fichier et l'écrit.
|
||||
func saveWebPrefs(in map[string]string) (map[string]string, error) {
|
||||
webPrefsMu.Lock()
|
||||
defer webPrefsMu.Unlock()
|
||||
prefs := loadWebPrefs()
|
||||
for k, v := range in {
|
||||
allowed, ok := webPrefsAllowed[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
if !allowed[v] {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// handleWebPrefs expose les préférences d'apparence partagées entre appareils.
|
||||
//
|
||||
// GET → {ok, prefs:{theme,display}}
|
||||
// POST {theme?, display?} → fusionne puis renvoie {ok, prefs}
|
||||
func handleWebPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
prefs, err := saveWebPrefs(req)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "prefs": prefs})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "prefs": loadWebPrefs()})
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//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 := newWebMux()
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
// Port occupé : on identifie le process qui le tient et on propose de
|
||||
// le terminer pour relancer à sa place.
|
||||
if !resolvePortConflict(port) {
|
||||
return err
|
||||
}
|
||||
if ln, err = net.Listen("tcp", addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("[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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
convLoadOnce.Do(LoadConversation)
|
||||
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")
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write(b)
|
||||
})
|
||||
// api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go).
|
||||
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
||||
api("/api/ping", handlePing)
|
||||
api("/api/status", handleStatus)
|
||||
api("/api/vram", handleVram)
|
||||
api("/api/config", handleConfigEnv)
|
||||
api("/api/catalog", handleCatalog)
|
||||
api("/api/update", handleUpdateCheck)
|
||||
api("/api/update/apply", handleUpdateApply)
|
||||
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/agent", handleAgent)
|
||||
api("/api/agent/toggle", handleAgentToggle)
|
||||
api("/api/agent/tool-limit", handleToolLimitToggle)
|
||||
api("/api/agent/compact", handleCompactToggle)
|
||||
api("/api/apikey", handleAPIKey)
|
||||
api("/api/oai/public", handleOAIPublic)
|
||||
api("/api/internet", handleInternet)
|
||||
api("/api/memory", handleMemoryMode)
|
||||
api("/api/prefs", handleWebPrefs)
|
||||
// Alias rétro-compat : l'ancien portail ajean.link (dépôt jean-relay) pilote
|
||||
// encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E.
|
||||
// On les mappe sur le mode agent unifié le temps que le portail soit mis à jour.
|
||||
api("/api/tools", handleAgent)
|
||||
api("/api/tools/toggle", handleAgentToggle)
|
||||
api("/api/skills", handleAgent)
|
||||
api("/api/skills/toggle", handleAgentToggle)
|
||||
api("/api/mem", handleMem)
|
||||
api("/api/mem/save", handleMemSave)
|
||||
api("/api/mem/delete", handleMemDelete)
|
||||
api("/api/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) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
||||
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||
api("/api/chat/reset", handleChatReset) // nouvelle conversation (pour tous les appareils)
|
||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
||||
return mux
|
||||
}
|
||||
|
||||
// resolvePortConflict identifies the process listening on `port`, asks the user
|
||||
// whether to terminate it, and (on yes) kills it and waits for the port to free.
|
||||
// Returns true if the caller should retry binding.
|
||||
func resolvePortConflict(port int) bool {
|
||||
pid, name := pidOnPort(port)
|
||||
if pid == 0 {
|
||||
fmt.Printf("%s port %d déjà utilisé, mais le process n'a pas pu être identifié (essaie en root ?)\n", red("[err]"), port)
|
||||
return false
|
||||
}
|
||||
fmt.Printf("%s le port %d est déjà utilisé par %s (PID %d).\n", yellow("[!]"), port, bold(name), pid)
|
||||
fmt.Print(dim(" terminer ce process et relancer ? [Y/n] "))
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
|
||||
fmt.Println(dim(" annulé."))
|
||||
return false
|
||||
}
|
||||
// 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) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, err := uiFS.ReadFile("ui/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func sendJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// handlePing is a lightweight authenticated endpoint a client hits to verify
|
||||
// connectivity AND that its key is valid (200 = bonne clé, 401 = mauvaise clé).
|
||||
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 sys_service_*.go).
|
||||
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
active := serviceIsActive()
|
||||
state := "inactive"
|
||||
if active {
|
||||
state = "active"
|
||||
}
|
||||
health := false
|
||||
if active {
|
||||
health = healthCheck()
|
||||
}
|
||||
ctx := 32768
|
||||
if v := ReadConfig()["CTX"]; v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
ctx = n
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"state": state,
|
||||
"active": active,
|
||||
"health": health,
|
||||
"port": LLMPort(),
|
||||
"ctx": ctx,
|
||||
"version": Version,
|
||||
})
|
||||
}
|
||||
|
||||
func handleVram(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := hideCmd(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/<name>/ 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",
|
||||
// Layout du générateur Visual Studio (multi-config) + suffixe .exe Windows.
|
||||
"build/bin/Release/llama-server.exe", "build/bin/llama-server.exe",
|
||||
"build/bin/Release/llama-server", "llama-server.exe",
|
||||
}
|
||||
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
|
||||
}
|
||||
store := loadBenchStore()
|
||||
out := []map[string]any{}
|
||||
for _, p := range list {
|
||||
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) {
|
||||
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)})
|
||||
return
|
||||
}
|
||||
content, err := ReadPreset(id)
|
||||
if err != nil {
|
||||
sendJSON(w, 404, map[string]any{"error": "not found"})
|
||||
return
|
||||
}
|
||||
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"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func handlePresetSave(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
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, "id": newID, "name": req.Name})
|
||||
}
|
||||
|
||||
func handlePresetDelete(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que
|
||||
// l'IA gère via les outils mem_*) — un seul aller-retour pour l'UI. La clé
|
||||
// "skills" est conservée en miroir de "pages" pour l'ancien portail ajean.link.
|
||||
func handleAgent(w http.ResponseWriter, r *http.Request) {
|
||||
pages := MemList()
|
||||
out := []map[string]any{}
|
||||
for _, p := range pages {
|
||||
out = append(out, map[string]any{"name": p.Name, "desc": p.Title})
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "tool_limit": toolLimitEnabled(), "compact": compactEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out})
|
||||
}
|
||||
|
||||
// handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always).
|
||||
//
|
||||
// GET → {mode}
|
||||
// POST {mode} → persiste MEM_MODE
|
||||
func handleMemoryMode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
// On normalise via memMode() en réinjectant la valeur : toute entrée
|
||||
// inconnue retombe sur "always", donc on valide en passant par le parseur.
|
||||
m := MemAlways
|
||||
switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) {
|
||||
case MemOff:
|
||||
m = MemOff
|
||||
case MemOnDemand:
|
||||
m = MemOnDemand
|
||||
case MemAlways:
|
||||
m = MemAlways
|
||||
}
|
||||
if err := setMemMode(m); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "mode": string(memMode())})
|
||||
}
|
||||
|
||||
// handleToolLimitToggle active/désactive le plafond d'appels d'outils par tour
|
||||
// (config.env TOOL_LIMIT). On=limité (défaut), off=quasi illimité.
|
||||
func handleToolLimitToggle(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
val := ""
|
||||
if !req.On {
|
||||
val = "off"
|
||||
}
|
||||
if err := SetConfigKey("TOOL_LIMIT", val); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "tool_limit": toolLimitEnabled()})
|
||||
}
|
||||
|
||||
// handleCompactToggle active/désactive le compactage automatique du contexte
|
||||
// (config.env COMPACT). On=compacte (défaut), off=jamais.
|
||||
func handleCompactToggle(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
val := ""
|
||||
if !req.On {
|
||||
val = "off"
|
||||
}
|
||||
if err := SetConfigKey("COMPACT", val); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "compact": compactEnabled()})
|
||||
}
|
||||
|
||||
func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := setAgentEnabled(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": agentEnabled()})
|
||||
}
|
||||
|
||||
// handleInternet pilote l'accès web de l'IA (serveur Crawl4AI).
|
||||
//
|
||||
// GET → {enabled, url, reachable}
|
||||
// POST {enabled, url} → enregistre CRAWL4AI_URL + le drapeau .internet_enabled
|
||||
// handleAPIKey expose et pilote la clé d'accès à l'endpoint compatible OpenAI
|
||||
// (llama-server /v1). GET renvoie l'état ; POST {action:"generate"|"set"|"clear",
|
||||
// key?} l'écrit puis redémarre le service (llama-server lit --api-key au lancement).
|
||||
func handleAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
var key string
|
||||
switch req.Action {
|
||||
case "generate":
|
||||
key = genAPIKey()
|
||||
case "set":
|
||||
key = strings.TrimSpace(req.Key)
|
||||
case "clear":
|
||||
key = ""
|
||||
default:
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "action inconnue"})
|
||||
return
|
||||
}
|
||||
if err := writeAPIKey(key); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// La clé n'est appliquée qu'au (re)démarrage de llama-server.
|
||||
if serviceIsActive() {
|
||||
_ = serviceAction("restart")
|
||||
}
|
||||
}
|
||||
k := readAPIKey()
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"set": k != "",
|
||||
"key": k,
|
||||
"masked": maskAPIKey(k),
|
||||
"port": LLMPort(),
|
||||
"host": localIP(),
|
||||
// Accès OpenAI PUBLIC via ajean.link (passthrough SNI, VPS aveugle) : si
|
||||
// activé, l'URL publique est https://<machine>.oai.ajean.link/v1.
|
||||
"oai_public": oaiPublicEnabled(),
|
||||
"machine": machineID(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleOAIPublic pilote le drapeau d'accès OpenAI public (exposition via
|
||||
// ajean.link). GET renvoie l'état ; POST {enabled} l'active/coupe en direct
|
||||
// (aucun redémarrage : le démux du tunnel relit le drapeau à chaque connexion).
|
||||
func handleOAIPublic(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
if err := setOAIPublic(*req.Enabled); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"enabled": oaiPublicEnabled(),
|
||||
"machine": machineID(),
|
||||
})
|
||||
}
|
||||
|
||||
// localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du
|
||||
// trafic sortant), ou "localhost" à défaut. Sert à annoncer l'endpoint OpenAI
|
||||
// avec une adresse correcte sur le réseau local MÊME quand l'UI est atteinte via
|
||||
// le tunnel ajean.link (où location.hostname serait le domaine du relais, faux).
|
||||
func localIP() string {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return "localhost"
|
||||
}
|
||||
defer conn.Close()
|
||||
if a, ok := conn.LocalAddr().(*net.UDPAddr); ok && a.IP != nil {
|
||||
return a.IP.String()
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
func handleInternet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
URL *string `json:"url"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.URL != nil {
|
||||
u := strings.TrimRight(strings.TrimSpace(*req.URL), "/")
|
||||
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
reachMu.Lock()
|
||||
reachURL = "" // invalide le cache de reachability
|
||||
reachMu.Unlock()
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
if err := setInternetEnabled(*req.Enabled); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"enabled": internetEnabled(),
|
||||
"url": crawl4aiURL(),
|
||||
"reachable": crawlReachable(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleMem / handleMemSave / handleMemDelete : éditeur web des pages mémoire
|
||||
// (MEMORY/<nom>.md). Payload partagé saveReq (name/old/content) ; "name" = nom
|
||||
// de fichier de la page.
|
||||
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"})
|
||||
return
|
||||
}
|
||||
c := MemContent(name)
|
||||
if c == "" {
|
||||
sendJSON(w, 404, map[string]any{"error": "not found"})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"name": name, "content": c})
|
||||
}
|
||||
|
||||
func handleMemSave(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 := MemSave(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 handleMemDelete(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 := MemDelete(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 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 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.
|
||||
func svcHandler(action string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
err := serviceAction(action)
|
||||
msg := "ok"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": err == nil, "out": msg})
|
||||
}
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// chatReq est le corps d'une requête de chat (commun au chat clair et au chat E2E).
|
||||
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.
|
||||
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.
|
||||
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 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.
|
||||
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)
|
||||
}
|
||||
if body.Internet != nil {
|
||||
caps.Internet = *body.Internet && crawlReachable()
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
// sseHeartbeat garde la réponse SSE active en écrivant un commentaire (`: ping`,
|
||||
// ignoré par le parseur côté navigateur, aucun contenu donc rien à chiffrer)
|
||||
// toutes les ~15 s. Sans ça, un long silence (exécution d'outil en mode agent,
|
||||
// gros prefill) laisse la réponse inactive et un proxy intermédiaire (Cloudflare,
|
||||
// ~100 s) la coupe → le fetch navigateur échoue (« Load failed »). Retourne un
|
||||
// mutex à partager avec l'émetteur (writes concurrents sur le même w) et une
|
||||
// fonction d'arrêt à différer.
|
||||
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
|
||||
mu := &sync.Mutex{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester
|
||||
// coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser.
|
||||
t := time.NewTicker(4 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-t.C:
|
||||
mu.Lock()
|
||||
_, err := w.Write([]byte(": ping\n\n"))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return mu, func() { close(done) }
|
||||
}
|
||||
|
||||
// runChatStream est désormais un pur ABONNÉ au journal de la conversation serveur :
|
||||
// il rejoue Log[body.From:] puis suit le direct, jusqu'à ce que la connexion (ctx)
|
||||
// se ferme. La GÉNÉRATION est lancée séparément par /api/chat/send dans une
|
||||
// goroutine détachée — fermer le navigateur n'arrête donc plus rien. Partagé par
|
||||
// handleChat (clair) et handleE2EChat (chiffré).
|
||||
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
|
||||
conv.Subscribe(ctx, body.From, emit)
|
||||
}
|
||||
|
||||
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse
|
||||
// req/resp (les événements arrivent par le flux d'abonnement). Passe par le proxy
|
||||
// tunnel /api/e2e/req pour app.ajean.link — aucun code E2E spécifique requis.
|
||||
func handleChatSend(w http.ResponseWriter, r *http.Request) {
|
||||
var body chatReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Message) == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"})
|
||||
return
|
||||
}
|
||||
if err := conv.StartTurn(body.Message, capsFromBody(body), body.Temperature); err != nil {
|
||||
// 409 = occupé (génération en cours) ; 503 = modèle pas prêt.
|
||||
code := 503
|
||||
if err == ErrBusy {
|
||||
code = 409
|
||||
}
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleChatStop(w http.ResponseWriter, r *http.Request) {
|
||||
conv.Stop()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleChatReset(w http.ResponseWriter, r *http.Request) {
|
||||
conv.Reset()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleChatState(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, conv.state())
|
||||
}
|
||||
|
||||
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
var body chatReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
mu, stop := sseHeartbeat(w, flusher)
|
||||
defer stop()
|
||||
emit := func(obj map[string]any) bool {
|
||||
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
|
||||
return false
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
runChatStream(r.Context(), body, emit)
|
||||
}
|
||||
Reference in new issue
Block a user