v0.5.10 : le produit s'appelle AJEAN + diff des ecritures dans le chat + variante AJEAN sombre

- renommage : « Jean » devient « AJEAN » dans tous les textes visibles (titre de
  l'UI, proprietes du binaire Windows, barre de menus macOS / zone de
  notification, messages du terminal, README). La commande, les services, les
  chemins et le depot ne changent pas.
- nouveau thème « AJEAN sombre » (même habillage pixel, palette nuit) ; les
  regles de mise en page sont partagees via [data-theme^="ajean"].
- l'IA montre ce qu'elle ecrit : les outils edit / mem_add / mem_edit renvoient
  un diff ligne a ligne, affiche en vert (+) et rouge (-) dans la bulle d'outil
  et persiste avec la conversation (chat_diff.go).
- ecritures idempotentes : si la modification est deja en place, edit et
  mem_edit repondent « deja a jour » au lieu d'une erreur trompeuse ; un appel
  d'outil rigoureusement identique n'est plus rejoue dans le meme tour.
- saisie du chat auto-agrandissante (une ligne au repos en thème ChatGPT, deux
  ailleurs), bouton d'envoi enfin aligne, astuce clavier detachee de la carte.
- editeur de preset relu pour le thème AJEAN (plus de double encadrement), champ
  + bouton de telechargement recolles dans tous les thèmes, interrupteurs carres.
- emojis retires de l'interface (les glyphes monochromes restent).
This commit is contained in:
nathaninline committed 2026-07-28 10:20:19 +02:00
1 parent 6195d77759
commit e2475c6688
30 files changed
+641 -209

No files matched your search

+14 -14
View File
@@ -1,10 +1,10 @@
# Jean
# AJEAN
![Interface web de Jean](docs/ui.png)
![Interface web d'AJEAN](docs/ui.png)
**Un gestionnaire mono-binaire pour serveurs [llama.cpp](https://github.com/ggml-org/llama.cpp) auto-hébergés — avec une interface web intégrée, un chat terminal, et un compilateur de backend qui détecte automatiquement le matériel.**
Dépose un seul binaire sur une machine, lance `jean llamacpp install`, et Jean clone, configure et compile llama.cpp pour le matériel de *cette* machine (CUDA / ROCm / Metal / Vulkan / CPU) — aucun flag à retenir. Ensuite `jean start` et tu as un endpoint compatible OpenAI avec un chat web par-dessus.
Dépose un seul binaire sur une machine, lance `jean llamacpp install`, et AJEAN clone, configure et compile llama.cpp pour le matériel de *cette* machine (CUDA / ROCm / Metal / Vulkan / CPU) — aucun flag à retenir. Ensuite `jean start` et tu as un endpoint compatible OpenAI avec un chat web par-dessus.
```
télécharge le binaire → jean llamacpp install → jean edit → jean start → c'est parti
@@ -14,7 +14,7 @@ télécharge le binaire → jean llamacpp install → jean edit → jean s
## Pourquoi
Faire tourner llama.cpp comme un vrai service, ça veut dire d'habitude : trouver les bons flags CMake pour ton GPU, écrire une unité systemd, gérer une clé API, changer de modèle, garder le build à jour… Jean transforme tout ça en quelques sous-commandes derrière un binaire statique unique, **sans dépendance à l'exécution** (à part llama.cpp lui-même, que Jean peut compiler pour toi).
Faire tourner llama.cpp comme un vrai service, ça veut dire d'habitude : trouver les bons flags CMake pour ton GPU, écrire une unité systemd, gérer une clé API, changer de modèle, garder le build à jour… AJEAN transforme tout ça en quelques sous-commandes derrière un binaire statique unique, **sans dépendance à l'exécution** (à part llama.cpp lui-même, que AJEAN peut compiler pour toi).
## Fonctionnalités
@@ -59,7 +59,7 @@ sudo jean install
jean llamacpp install
```
Jean détecte ton accélérateur, compile `llama-server`, et pointe la config sur le nouveau binaire. Nécessite `git` et `cmake` (plus le toolkit correspondant, ex. CUDA, si tu veux l'accélération GPU).
AJEAN détecte ton accélérateur, compile `llama-server`, et pointe la config sur le nouveau binaire. Nécessite `git` et `cmake` (plus le toolkit correspondant, ex. CUDA, si tu veux l'accélération GPU).
### 4. Pointe-le sur un modèle et démarre
@@ -77,7 +77,7 @@ jean web # http://<hôte>:8090
## Windows
Jean fonctionne aussi sur Windows. Les différences avec Linux :
AJEAN fonctionne aussi sur Windows. Les différences avec Linux :
- **Pas de systemd.** `jean start` lance `jean serve` en arrière-plan (processus détaché), suivi via un fichier PID. `stop` / `restart` / `status` / `logs` agissent dessus — aucun droit administrateur requis. `enable` / `disable` (démarrage au boot) ne sont pas gérés : utilise une tâche planifiée ou `sc.exe` si tu en as besoin.
- **`JEAN_HOME`** vaut par défaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). Surcharge avec la variable d'environnement `JEAN_HOME`.
@@ -98,7 +98,7 @@ jean logs # suit %ProgramData%\jean\jean.log
## macOS
Comme sous Windows, tu peux lancer Jean **d'un simple clic** : la page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézippe, glisse-le dans *Applications*, ouvre-le : Jean démarre son UI web sur `http://localhost:8090`, l'ouvre dans le navigateur et pose son **icône dans la barre de menus** (en haut à droite) — pendant exact du system tray Windows, avec « Ouvrir Jean » / « Quitter ». Pas de fenêtre de Terminal, pas d'icône dans le Dock.
Comme sous Windows, tu peux lancer AJEAN **d'un simple clic** : la page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézippe, glisse-le dans *Applications*, ouvre-le : AJEAN démarre son UI web sur `http://localhost:8090`, l'ouvre dans le navigateur et pose son **icône dans la barre de menus** (en haut à droite) — pendant exact du system tray Windows, avec « Ouvrir AJEAN » / « Quitter ». Pas de fenêtre de Terminal, pas d'icône dans le Dock.
L'app n'est **pas signée** par un certificat Apple (seulement ad-hoc) : au premier lancement, fais **clic droit → Ouvrir** (ou *Réglages Système → Confidentialité et sécurité → Ouvrir quand même*).
@@ -188,7 +188,7 @@ La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans
### API de pilotage à distance
`jean web` expose une API HTTP pour piloter Jean à distance : status, VRAM, liste/sélection de presets (switch de modèle), démarrage/arrêt/redémarrage du service, chat. Pour l'exposer sur internet en sécurité, protège-la par une clé :
`jean web` expose une API HTTP pour piloter AJEAN à distance : status, VRAM, liste/sélection de presets (switch de modèle), démarrage/arrêt/redémarrage du service, chat. Pour l'exposer sur internet en sécurité, protège-la par une clé :
```
jean set-web-key # génère une clé aléatoire
@@ -215,7 +215,7 @@ Endpoints utiles pour un client :
La clé est stockée dans `$JEAN_HOME/.web_key`, distincte de `.api_key` (pilotage ≠ accès complétions), et relue à chaud à chaque requête. Le pilotage du service est cross-platform (systemd sous Linux, supervision par PID-file sous Windows).
> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, place Jean derrière un reverse-proxy HTTPS (Caddy, nginx) ou un tunnel (Tailscale, Cloudflare Tunnel).
> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, place AJEAN derrière un reverse-proxy HTTPS (Caddy, nginx) ou un tunnel (Tailscale, Cloudflare Tunnel).
### Accès internet de l'IA (Crawl4AI)
@@ -256,13 +256,13 @@ Une fois lié, tu retrouves depuis le portail :
- la gestion de **plusieurs serveurs** et d'**agents** depuis un tableau de bord ;
- en option, un **endpoint compatible OpenAI** (voir ci-dessous).
C'est un service optionnel et payant ; tout le reste de Jean est et restera open source et gratuit.
C'est un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit.
### Sécurité — boîte noire
`jean link` est conçu pour que le relais ajean.link soit un **pur tube aveugle** : il transporte tes données mais ne peut pas les lire.
- **Chat chiffré de bout en bout.** Le chat entre ton navigateur et ton serveur Jean est chiffré (X25519 + AES-GCM) : le relais ne voit ni tes prompts ni les réponses, seulement de l'opaque. La clé est dérivée de ton mot de passe (protocole **OPAQUE**) et ne quitte jamais ton navigateur.
- **Chat chiffré de bout en bout.** Le chat entre ton navigateur et ton serveur AJEAN est chiffré (X25519 + AES-GCM) : le relais ne voit ni tes prompts ni les réponses, seulement de l'opaque. La clé est dérivée de ton mot de passe (protocole **OPAQUE**) et ne quitte jamais ton navigateur.
- **Empreinte vérifiée.** `jean link` affiche une empreinte de la clé de la machine, à confirmer une fois dans le portail — ça défait toute tentative d'interception par le relais.
- **Appairage authentifié.** En plus de l'empreinte, tu autorises ton navigateur avec un **code d'appairage** (`jean link code`) : à usage unique et valable 10 min, il garantit que seul *ton* navigateur peut piloter le serveur — même un relais compromis ne peut pas forger de commande.
- **Aucun chat en clair par le relais.** L'ancien chemin en clair est refusé à travers le tunnel ; seul le chemin chiffré transporte du contenu.
@@ -272,7 +272,7 @@ C'est un service optionnel et payant ; tout le reste de Jean est et restera open
#### Endpoint OpenAI (`<machine>.oai.ajean.link`) — opt-in
Pour brancher des outils tiers (OpenCode, etc.), Jean peut exposer un endpoint compatible OpenAI **par machine** : `https://<machine>.oai.ajean.link/v1`. L'authentification est la clé API de ton `llama-server` (`jean set-api-key`), présentée en `Authorization: Bearer <clé>`. C'est **désactivé par défaut**.
Pour brancher des outils tiers (OpenCode, etc.), AJEAN peut exposer un endpoint compatible OpenAI **par machine** : `https://<machine>.oai.ajean.link/v1`. L'authentification est la clé API de ton `llama-server` (`jean set-api-key`), présentée en `Authorization: Bearer <clé>`. C'est **désactivé par défaut**.
Tu l'actives par machine depuis l'**interface web** (panneau « Accès OpenAI ») : le drapeau est relu **en direct** par le tunnel, sans redémarrage. *(Ancien mécanisme : la variable d'environnement `JEAN_LINK_ALLOW_OAI=1` sur le service — encore acceptée pour rétro-compatibilité, mais le toggle de l'UI est désormais la voie recommandée.)*
@@ -288,7 +288,7 @@ Contrairement aux premières versions, ce flux **n'est pas en clair chez le rela
## Compiler depuis les sources
Nécessite Go 1.25+. Jean est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) :
Nécessite Go 1.25+. AJEAN est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) :
```bash
git clone https://github.com/nathaninline/jean.git
@@ -299,7 +299,7 @@ CGO_ENABLED=0 go build -o jean ./cmd/jean
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jean-linux-amd64 ./cmd/jean
```
> Compiler **Jean** ne nécessite que Go. Compiler le **backend llama.cpp** (`jean llamacpp install`) nécessite `git`, `cmake`, et le toolkit de ton accélérateur (CUDA, ROCm, etc.).
> Compiler **AJEAN** ne nécessite que Go. Compiler le **backend llama.cpp** (`jean llamacpp install`) nécessite `git`, `cmake`, et le toolkit de ton accélérateur (CUDA, ROCm, etc.).
## Comment ça marche
Binary file not shown.
Binary file not shown.
+7 -7
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 5,
"Patch": 9,
"Patch": 10,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 5,
"Patch": 9,
"Patch": 10,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -19,13 +19,13 @@
"FileSubType": "00"
},
"StringFileInfo": {
"CompanyName": "Jean contributors",
"FileDescription": "Jean — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés",
"CompanyName": "AJEAN contributors",
"FileDescription": "AJEAN — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés",
"InternalName": "jean",
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "Jean",
"ProductVersion": "0.5.9",
"ProductName": "AJEAN",
"ProductVersion": "0.5.10",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+8 -2
View File
@@ -324,10 +324,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
case ev.ToolUsed != nil:
c.appendDelta(epoch, map[string]any{"tool_used": map[string]any{
tu := map[string]any{
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
}})
}
// Lignes +/- d'une écriture (edit / mémoire) : persistées avec le reste
// pour que le diff soit encore là après un rafraîchissement.
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
}
c.appendDelta(epoch, map[string]any{"tool_used": tu})
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.
+121
View File
@@ -0,0 +1,121 @@
package jean
// chat_diff.go — petit diff ligne à ligne pour l'interface : quand l'IA modifie
// un fichier (outil `edit`) ou une page de mémoire (`mem_edit`, `mem_add`), on
// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
import "strings"
const (
diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent)
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
)
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
type DiffLine struct {
Op string `json:"op"`
Text string `json:"text"`
}
// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les
// lignes communes comme contexte : c'est ce qui rend le changement lisible
// quand l'IA ne touche qu'un mot au milieu d'un paragraphe.
func lineDiff(oldText, newText string) []DiffLine {
a := splitLines(oldText)
b := splitLines(newText)
// Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre
// simplement l'ancien en retrait et le nouveau en ajout.
if len(a) > diffMaxLines || len(b) > diffMaxLines {
out := make([]DiffLine, 0, len(a)+len(b))
for _, l := range a {
out = append(out, DiffLine{Op: "-", Text: l})
}
for _, l := range b {
out = append(out, DiffLine{Op: "+", Text: l})
}
return capLines(out)
}
// LCS : table des longueurs, puis remontée.
n, m := len(a), len(b)
lcs := make([][]int, n+1)
for i := range lcs {
lcs[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
var out []DiffLine
i, j := 0, 0
for i < n && j < m {
switch {
case a[i] == b[j]:
out = append(out, DiffLine{Op: " ", Text: a[i]})
i++
j++
case lcs[i+1][j] >= lcs[i][j+1]:
out = append(out, DiffLine{Op: "-", Text: a[i]})
i++
default:
out = append(out, DiffLine{Op: "+", Text: b[j]})
j++
}
}
for ; i < n; i++ {
out = append(out, DiffLine{Op: "-", Text: a[i]})
}
for ; j < m; j++ {
out = append(out, DiffLine{Op: "+", Text: b[j]})
}
return capLines(out)
}
// addedDiff présente un contenu entièrement nouveau (création d'une page).
func addedDiff(text string) []DiffLine {
lines := splitLines(text)
out := make([]DiffLine, 0, len(lines))
for _, l := range lines {
out = append(out, DiffLine{Op: "+", Text: l})
}
return capLines(out)
}
func splitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
func capLines(d []DiffLine) []DiffLine {
if len(d) <= diffMaxShown {
return d
}
cut := len(d) - diffMaxShown
out := append([]DiffLine{}, d[:diffMaxShown]...)
return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
+10
View File
@@ -1,6 +1,7 @@
package jean
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -215,6 +216,10 @@ func MemAdd(name, content string) error {
// MemEdit remplace oldText par newText dans une page. oldText doit apparaître
// EXACTEMENT une fois (sinon erreur), pour une édition sans ambiguïté.
// errAlreadyApplied signale une édition dont le résultat est DÉJÀ en place :
// ce n'est pas un échec, la page est dans l'état demandé.
var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification")
func MemEdit(name, oldText, newText string) error {
p, err := safeMemPath(name)
if err != nil {
@@ -230,6 +235,11 @@ func MemEdit(name, oldText, newText string) error {
return fmt.Errorf("old vide")
}
if n == 0 {
// Déjà remplacé (le modèle rejoue souvent la même édition) : ce n'est pas
// une erreur, la page est dans l'état demandé.
if newText != "" && strings.Contains(content, newText) {
return errAlreadyApplied
}
return fmt.Errorf("old introuvable dans la page")
}
if n > 1 {
+5
View File
@@ -179,6 +179,11 @@ func fileEdit(path, oldText, newText string) string {
content := string(b)
n := strings.Count(content, oldText)
if n == 0 {
// Modification déjà en place : on le dit clairement plutôt que de renvoyer
// une erreur, sinon le modèle croit avoir échoué et recommence.
if newText != "" && strings.Contains(content, newText) {
return "[ok] déjà à jour — le fichier contient déjà cette modification"
}
return "[erreur] old introuvable dans le fichier"
}
if n > 1 {
+2 -2
View File
@@ -35,7 +35,7 @@ func cmdApp(args []string) error {
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)
fmt.Printf("AJEAN est déjà lancé — ouverture de %s\n", url)
return openBrowser(url)
}
@@ -59,7 +59,7 @@ func cmdApp(args []string) error {
startAppLink(appWebMux)
}()
sp := showSplash("Lancement de Jean en cours…")
sp := showSplash("Lancement d'AJEAN en cours…")
waitServerReady(url)
_ = openBrowser(url)
time.Sleep(900 * time.Millisecond) // laisse le navigateur s'afficher par-dessus le splash
+36 -2
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -264,6 +265,9 @@ type ToolUsedEvent struct {
Result string // tool output (stdout/stderr/exit for run_shell, skill body for read_skill)
Done bool // false = call announced (command only); true = result is ready
Typing bool // true = command still being written (partial), no spinner yet
// Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
// (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
Diff []DiffLine
}
// previewArg pulls the (possibly incomplete) string value of key out of a
@@ -414,6 +418,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// several times, it's stuck — break instead of spinning to the iteration cap.
lastSig := ""
repeatSig := 0
// Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas
// rejouer deux fois exactement la même écriture dans un même échange.
doneCalls := map[string]string{}
// 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
@@ -747,6 +754,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
result := ""
// diff : rempli par les outils d'écriture (edit / mémoire) pour que
// l'UI montre les lignes ajoutées et retirées.
var diff []DiffLine
// Appel rigoureusement identique déjà exécuté dans ce tour : on ne le
// rejoue pas. Les petits modèles réémettent volontiers deux fois la
// même écriture ; la rejouer produisait une fausse erreur (« old
// introuvable », puisque le remplacement est déjà fait).
callKey := tc.Function.Name + "\x00" + tc.Function.Arguments
if prev, seen := doneCalls[callKey]; seen {
result = prev + "\n[note] appel identique déjà exécuté dans ce tour — non rejoué"
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: result, Done: true}})
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg)
extra = append(extra, toolMsg)
continue
}
switch tc.Function.Name {
case "mem_search":
lim := 0
@@ -782,19 +805,27 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
result = "[erreur] " + werr.Error()
} else {
result = fmt.Sprintf("[ok] page '%s' créée", label)
diff = addedDiff(content)
}
case "mem_edit":
oldText, _ := args["old"].(string)
newText, _ := args["new"].(string)
if werr := MemEdit(label, oldText, newText); werr != nil {
if werr := MemEdit(label, oldText, newText); errors.Is(werr, errAlreadyApplied) {
result = fmt.Sprintf("[ok] page '%s' %s", label, werr.Error())
} else if werr != nil {
result = "[erreur] " + werr.Error()
} else {
result = fmt.Sprintf("[ok] page '%s' modifiée", label)
diff = lineDiff(oldText, newText)
}
case "edit":
oldText, _ := args["old"].(string)
newText, _ := args["new"].(string)
result = fileEdit(label, oldText, newText)
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
if !strings.HasPrefix(result, "[erreur]") {
diff = lineDiff(oldText, newText)
}
case "bash":
to := 0
switch v := args["timeout"].(type) {
@@ -819,11 +850,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
result = "[erreur] outil inconnu: " + tc.Function.Name
}
}
if !strings.HasPrefix(result, "[erreur]") {
doneCalls[callKey] = result
}
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}})
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shown, Done: true, Diff: diff}})
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg)
extra = append(extra, toolMsg)
+1 -1
View File
@@ -225,7 +225,7 @@ func runLinkForeground() error {
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")
fmt.Printf(" (UI AJEAN + 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))
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.5.9"
const Version = "0.5.10"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() {
+1 -1
View File
@@ -53,7 +53,7 @@ func appWarning() string {
if !strings.Contains(exe, "/AppTranslocation/") {
return ""
}
return "Jean tourne depuis une copie temporaire en lecture seule (App Translocation de macOS). " +
return "AJEAN tourne depuis une copie temporaire en lecture seule (App Translocation de macOS). " +
"Fermez l'app, déplacez Jean.app dans le dossier Applications, puis rouvrez-la — " +
"sinon chaque lancement crée une instance séparée et les mises à jour du moteur échouent."
}
+2 -2
View File
@@ -152,7 +152,7 @@ func createSplashWindow() uintptr {
cy, _, _ := pGetSystemMetrics.Call(smCY)
x := (int(cx) - splashW) / 2
y := (int(cy) - splashH) / 2
title, _ := syscall.UTF16PtrFromString("Jean")
title, _ := syscall.UTF16PtrFromString("AJEAN")
hwnd, _, _ := pCreateWindowExW.Call(
uintptr(exTopmost|exToolwindow),
@@ -240,7 +240,7 @@ func paintSplash(hdc uintptr) {
pSetTextColor.Call(hdc, colWhite)
textX := ox + logo + 26
drawText(hdc, "Jean", -30, 700, rect{textX, 40, splashW - 20, 82})
drawText(hdc, "AJEAN", -30, 700, rect{textX, 40, splashW - 20, 82})
drawText(hdc, "Lancement en cours…", -17, 400, rect{textX, 84, splashW - 20, 118})
}
+3 -3
View File
@@ -23,10 +23,10 @@ import (
func runTray(url string) {
systray.Run(func() {
systray.SetIcon(trayIconPNG())
systray.SetTooltip("Jean — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir Jean", "Ouvrir l'interface")
systray.SetTooltip("AJEAN — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quitter", "Arrêter Jean")
mQuit := systray.AddMenuItem("Quitter", "Arrêter AJEAN")
go func() {
for {
+4 -4
View File
@@ -24,11 +24,11 @@ import (
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.SetTitle("AJEAN")
systray.SetTooltip("AJEAN — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quitter", "Arrêter Jean")
mQuit := systray.AddMenuItem("Quitter", "Arrêter AJEAN")
go func() {
for {
+1 -1
View File
@@ -368,7 +368,7 @@ func restartAfterUpdate() (bool, string) {
func restartHintText() string {
if runtime.GOOS == "windows" {
return "Redémarre Jean (quitte puis relance) pour appliquer la mise à jour."
return "Redémarre AJEAN (quitte puis relance) pour appliquer la mise à jour."
}
return "Redémarre les services pour appliquer : sudo systemctl restart jean jean-link"
}
+212 -84
View File
@@ -7,8 +7,8 @@
<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>
<meta name="apple-mobile-web-app-title" content="AJEAN">
<title>AJEAN</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. -->
@@ -24,7 +24,7 @@
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}
: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;--add:#3fb950;--add-bg:rgba(63,185,80,.12);--del:#f85149;--del-bg:rgba(248,81,73,.12)}
[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). */
@@ -39,7 +39,10 @@
/* "AJEAN" — clair, bleu, esprit rétro-machine : bordures nettes de 2 px,
aucun arrondi, ombres en blocs décalés, fond quadrillé (même charte que
ajean.app). Mise en page complète plus bas (section "Thème pixel"). */
[data-theme="ajean"]{color-scheme:light;--font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#1f6feb;--bg:#f5f8ff;--panel:#ffffff;--border:#cfdcf5;--text:#0f1f3d;--dim:#5b6f96;--accent:#1f6feb;--accent-bg:rgba(31,111,235,.10);--ok:#1f6feb;--warn:#9a6700;--err:#cf222e;--mag:#6f52d8;--code-bg:#eef3fe;--danger-bg:#fbe6e6;--warn-bg:#fbefda;--grid:#e4ecfa;--line2:#b6cbf0}
[data-theme="ajean"]{color-scheme:light;--font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#1f6feb;--bg:#f5f8ff;--panel:#ffffff;--border:#cfdcf5;--text:#0f1f3d;--dim:#5b6f96;--accent:#1f6feb;--accent-bg:rgba(31,111,235,.10);--ok:#1f6feb;--warn:#9a6700;--err:#cf222e;--mag:#6f52d8;--code-bg:#eef3fe;--danger-bg:#fbe6e6;--warn-bg:#fbefda;--grid:#e4ecfa;--line2:#b6cbf0;--soft:#e8f0ff;--sh:#cddffb;--knob:#ffffff}
/* « AJEAN sombre » — même habillage pixel, palette nuit : bleu vif sur fond
presque noir, quadrillage discret, ombres bleutées. */
[data-theme="ajean-dark"]{color-scheme:dark;--font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#3b82f6;--bg:#050810;--panel:#0a1020;--border:#16233f;--text:#dce6ff;--dim:#63739c;--accent:#3b82f6;--accent-bg:rgba(59,130,246,.16);--ok:#3b82f6;--warn:#d29922;--err:#f85149;--mag:#9b8cff;--code-bg:#03060d;--danger-bg:#3d1f23;--warn-bg:#3d2f1f;--grid:#0a1120;--line2:#1e3054;--soft:#101b33;--sh:#132342;--knob:#dce6ff}
*{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
@@ -215,6 +218,16 @@ button:disabled{opacity:.5;cursor:not-allowed}
.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}
/* ===== Diff des écritures (outil edit / mémoire) ==================
Lignes ajoutées en vert, retirées en rouge, contexte neutre. Les couleurs
viennent de variables : chaque thème peut les ajuster (clair vs sombre). */
.msg.tool pre.diff{display:block;padding:0;overflow-x:auto;background:var(--code-bg)}
.msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)}
.msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)}
.msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)}
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
.diff-count .a{color:var(--add)}
.diff-count .d{color:var(--del)}
.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}
@@ -265,7 +278,10 @@ button:disabled{opacity:.5;cursor:not-allowed}
#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}
/* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir
changer ses marges, ce qu'un style en ligne empêchait). */
#sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7}
#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:56px;max-height:200px;overflow-y:hidden}
#input:focus{outline:none;border-color:var(--accent)}
#send{padding:0 20px}
.muted{color:var(--dim);font-size:12px}
@@ -363,10 +379,14 @@ button:disabled{opacity:.5;cursor:not-allowed}
.pe-row.stack.plain .pe-inp:focus{box-shadow:none}
/* Champ + bouton accolé (téléchargement) — même trait que les autres champs,
sans pastille colorée qui ressort. */
.pe-inpbtn{display:flex}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0}
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-left:0;border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s}
.pe-inpbtn button:hover{color:var(--accent);border-color:var(--accent)}
.pe-inpbtn{display:flex;align-items:stretch}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0;border-right:0}
/* Le bouton reprend EXACTEMENT le cadre du champ (mêmes coins, même bordure) :
sans ça, un thème qui arrondit tous les boutons (ChatGPT) le détachait du
champ, avec un coin rond au milieu de la paire. */
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s,background .12s}
.pe-inpbtn button:hover:not(:disabled){color:#fff;background:var(--accent);border-color:var(--accent)}
.pe-inpbtn:focus-within .pe-inp,.pe-inpbtn:focus-within button{border-color:var(--accent)}
.pe-inpbtn button:disabled{opacity:.5;cursor:default}
/* Contrôle segmenté (moteur) façon iOS. */
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:9px;padding:3px;width:100%}
@@ -539,7 +559,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
[data-theme="gpt"] #scrollbtn{bottom:180px;border-radius:999px}
[data-theme="gpt"] #compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px}
[data-theme="gpt"] #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0}
[data-theme="gpt"] #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:26px}
[data-theme="gpt"] #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
bordure, seule la saisie change (curseur). */
[data-theme="gpt"] #input:focus{border-color:transparent}
@@ -559,9 +579,14 @@ button:disabled{opacity:.5;cursor:not-allowed}
l'arrondi du pied de carte. */
[data-theme="gpt"] #ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
[data-theme="gpt"] #ctx-fill{background:var(--text);opacity:.75}
[data-theme="gpt"] #sendhint{order:4;padding:8px var(--col) 0}
[data-theme="gpt"] #sendhint{order:4;padding:12px var(--col) 2px}
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
[data-theme="gpt"] button,[data-theme="gpt"] .addbtn,[data-theme="gpt"] .mem-search,[data-theme="gpt"] #theme-select{border-radius:10px}
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
[data-theme="gpt"] .pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
[data-theme="gpt"] .pe-inpbtn button{border-radius:0 10px 10px 0}
[data-theme="gpt"] .input-btn .ctl{border-radius:10px 0 0 10px}
[data-theme="gpt"] .input-btn .btn-in{border-radius:0 10px 10px 0}
[data-theme="gpt"] .preset,[data-theme="gpt"] .gated-block,[data-theme="gpt"] .lc-mode{border-radius:12px}
[data-theme="gpt"] .preset{background:transparent;border-color:transparent}
[data-theme="gpt"] .preset:hover{background:var(--accent-bg);border-color:transparent}
@@ -576,52 +601,122 @@ button:disabled{opacity:.5;cursor:not-allowed}
Rétro-machine : plus aucun arrondi, bordures de 2 px, ombres portées en
blocs pleins (pas de flou), fond quadrillé comme du papier millimétré,
boutons en capitales qui « s'enfoncent » au survol. Tout est préfixé
[data-theme="ajean"] : aucun autre thème n'est touché. */
[data-theme^="ajean"] : aucun autre thème n'est touché. */
/* Un seul balayage pour supprimer TOUS les arrondis (bulles, boutons, pastilles,
interrupteurs, points animés…) — c'est ce qui fait l'identité du thème. */
[data-theme="ajean"] *,[data-theme="ajean"] *::before,[data-theme="ajean"] *::after{border-radius:0 !important}
[data-theme^="ajean"] *,[data-theme^="ajean"] *::before,[data-theme^="ajean"] *::after{border-radius:0 !important}
/* Fond quadrillé : le papier millimétré derrière toute l'interface. */
[data-theme="ajean"] body{background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:32px 32px}
[data-theme="ajean"] .side{background:var(--panel);border-right:2px solid var(--border)}
[data-theme="ajean"] #composer,[data-theme="ajean"] #chat{background:transparent}
[data-theme^="ajean"] body{background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:32px 32px}
[data-theme^="ajean"] .side{background:var(--panel);border-right:2px solid var(--border)}
[data-theme^="ajean"] #composer,[data-theme^="ajean"] #chat{background:transparent}
/* Boutons : capitales, bordure épaisse, ombre en bloc qui disparaît au survol
pendant que le bouton se décale — impression de touche enfoncée. */
[data-theme="ajean"] button,[data-theme="ajean"] .addbtn,[data-theme="ajean"] .preset-add{border:2px solid var(--line2);background:var(--panel);color:var(--text);text-transform:uppercase;letter-spacing:.06em;font-weight:700;box-shadow:3px 3px 0 0 var(--grid);transition:.1s}
[data-theme="ajean"] button:hover:not(:disabled),[data-theme="ajean"] .addbtn:hover,[data-theme="ajean"] .preset-add:hover{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:none;transform:translate(3px,3px)}
[data-theme="ajean"] .preset-edit,[data-theme="ajean"] .copybtn{border:none;box-shadow:none;text-transform:none}
[data-theme="ajean"] .copybtn{border:2px solid var(--border)}
[data-theme="ajean"] #send{background:var(--accent);border-color:var(--accent);color:#fff}
[data-theme="ajean"] #menubtn{border:2px solid var(--line2);box-shadow:3px 3px 0 0 var(--grid)}
[data-theme^="ajean"] button,[data-theme^="ajean"] .addbtn,[data-theme^="ajean"] .preset-add{border:2px solid var(--line2);background:var(--panel);color:var(--text);text-transform:uppercase;letter-spacing:.06em;font-weight:700;box-shadow:3px 3px 0 0 var(--grid);transition:.1s}
[data-theme^="ajean"] button:hover:not(:disabled),[data-theme^="ajean"] .addbtn:hover,[data-theme^="ajean"] .preset-add:hover{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:none;transform:translate(3px,3px)}
[data-theme^="ajean"] .preset-edit,[data-theme^="ajean"] .copybtn{border:none;box-shadow:none;text-transform:none}
[data-theme^="ajean"] .copybtn{border:2px solid var(--border)}
[data-theme^="ajean"] #send{background:var(--accent);border-color:var(--accent);color:#fff}
[data-theme^="ajean"] #menubtn{border:2px solid var(--line2);box-shadow:3px 3px 0 0 var(--grid)}
/* Messages : la question en carré bleu plein, la réponse en fiche blanche
posée sur son ombre. Raisonnement et outils restent en pointillés. */
[data-theme="ajean"] #chat .msg.user{background:var(--accent);color:#fff;box-shadow:4px 4px 0 0 #cddffb}
[data-theme="ajean"] #chat .msg.assistant{background:var(--panel);border:2px solid var(--border);box-shadow:4px 4px 0 0 var(--grid)}
[data-theme="ajean"] #chat .msg.reasoning,[data-theme="ajean"] #chat .msg.tool{border:2px dashed var(--line2);background:var(--panel)}
[data-theme="ajean"] #chat .msg .label{color:var(--dim);letter-spacing:.14em}
[data-theme="ajean"] #chat .msg.user .label{color:rgba(255,255,255,.85)}
[data-theme="ajean"] .msg.assistant .body pre,[data-theme="ajean"] .msg.reasoning .body pre,[data-theme="ajean"] .msg.tool pre{border:2px solid var(--border);background:var(--code-bg)}
[data-theme="ajean"] .msg.typing span{background:var(--accent)}
[data-theme^="ajean"] #chat .msg.user{background:var(--accent);color:#fff;box-shadow:4px 4px 0 0 var(--sh)}
[data-theme^="ajean"] #chat .msg.assistant{background:var(--panel);border:2px solid var(--border);box-shadow:4px 4px 0 0 var(--grid)}
[data-theme^="ajean"] #chat .msg.reasoning,[data-theme^="ajean"] #chat .msg.tool{border:2px dashed var(--line2);background:var(--panel)}
[data-theme^="ajean"] #chat .msg .label{color:var(--dim);letter-spacing:.14em}
[data-theme^="ajean"] #chat .msg.user .label{color:rgba(255,255,255,.85)}
[data-theme^="ajean"] .msg.assistant .body pre,[data-theme^="ajean"] .msg.reasoning .body pre,[data-theme^="ajean"] .msg.tool pre{border:2px solid var(--border);background:var(--code-bg)}
[data-theme^="ajean"] .msg.typing span{background:var(--accent)}
/* Panneaux, encadrés et champs : mêmes bordures épaisses, mêmes ombres. */
[data-theme="ajean"] .preset,[data-theme="ajean"] .gated-block,[data-theme="ajean"] .tag,[data-theme="ajean"] .qtag,[data-theme="ajean"] .rtag,[data-theme="ajean"] .btag,[data-theme="ajean"] .statuspill,[data-theme="ajean"] .lc-mode{border-width:2px}
[data-theme="ajean"] .preset.active{border-color:var(--accent);color:var(--accent);box-shadow:3px 3px 0 0 #cddffb}
[data-theme="ajean"] #input,[data-theme="ajean"] .mem-search,[data-theme="ajean"] .ask-input,[data-theme="ajean"] #modal input,[data-theme="ajean"] #modal textarea,[data-theme="ajean"] #modal select,[data-theme="ajean"] #theme-select{border:2px solid var(--line2);background:var(--panel)}
[data-theme="ajean"] #input:focus,[data-theme="ajean"] .mem-search:focus,[data-theme="ajean"] .ask-input:focus{border-color:var(--accent)}
[data-theme="ajean"] #composer{border-top:2px solid var(--border);background:var(--panel)}
[data-theme^="ajean"] .preset,[data-theme^="ajean"] .gated-block,[data-theme^="ajean"] .tag,[data-theme^="ajean"] .qtag,[data-theme^="ajean"] .rtag,[data-theme^="ajean"] .btag,[data-theme^="ajean"] .statuspill,[data-theme^="ajean"] .lc-mode{border-width:2px}
[data-theme^="ajean"] .preset.active{border-color:var(--accent);color:var(--accent);box-shadow:3px 3px 0 0 var(--sh)}
[data-theme^="ajean"] #input,[data-theme^="ajean"] .mem-search,[data-theme^="ajean"] .ask-input,[data-theme^="ajean"] #theme-select,[data-theme^="ajean"] .modal-card .ctl{border:2px solid var(--line2);background:var(--panel)}
[data-theme^="ajean"] #input:focus,[data-theme^="ajean"] .mem-search:focus,[data-theme^="ajean"] .ask-input:focus{border-color:var(--accent)}
[data-theme^="ajean"] #composer{border-top:2px solid var(--border);background:var(--panel)}
/* Jauge de contexte : un vrai bandeau plein, pas un filet dégradé. */
[data-theme="ajean"] #ctxtrack{height:5px;background:var(--grid)}
[data-theme="ajean"] #ctx-fill{background:var(--accent)}
[data-theme^="ajean"] #ctxtrack{height:5px;background:var(--grid)}
[data-theme^="ajean"] #ctx-fill{background:var(--accent)}
/* Fenêtres modales / info-bulles / toast : cadre net + ombre en bloc bleutée. */
[data-theme="ajean"] .ask-box,[data-theme="ajean"] .help>.tip,[data-theme="ajean"] #toast,[data-theme="ajean"] .modal-box,[data-theme="ajean"] #modal>div{border:2px solid var(--line2);box-shadow:7px 7px 0 0 #cddffb}
[data-theme="ajean"] #scrollbtn{border:2px solid var(--line2);box-shadow:3px 3px 0 0 var(--grid)}
[data-theme^="ajean"] .ask-box,[data-theme^="ajean"] .help>.tip,[data-theme^="ajean"] #toast,[data-theme^="ajean"] .modal-box,[data-theme^="ajean"] #modal>div{border:2px solid var(--line2);box-shadow:7px 7px 0 0 var(--sh)}
[data-theme^="ajean"] #scrollbtn{border:2px solid var(--line2);box-shadow:3px 3px 0 0 var(--grid)}
/* Interrupteurs : carrés, bleus quand ils sont actifs. */
[data-theme="ajean"] .switch .slider{background:var(--line2)}
[data-theme="ajean"] .switch input:checked + .slider{background:var(--accent)}
[data-theme^="ajean"] .switch .slider{background:var(--line2)}
[data-theme^="ajean"] .switch input:checked + .slider{background:var(--accent)}
/* Pastille carrée : box-sizing explicite (un ::before n'hérite pas de la règle
globale *{box-sizing:border-box}) — sans ça la bordure débordait du rail. */
[data-theme="ajean"] .switch .slider::before{box-sizing:border-box;width:14px;height:14px;background:#fff;border:2px solid var(--line2);box-shadow:none}
[data-theme="ajean"] .switch input:checked + .slider::before{border-color:#fff;background:var(--panel)}
[data-theme^="ajean"] .switch .slider::before{box-sizing:border-box;width:14px;height:14px;background:var(--knob);border:2px solid var(--line2);box-shadow:none}
[data-theme^="ajean"] .switch input:checked + .slider::before{border-color:var(--knob);background:var(--knob)}
/* Étiquettes de section : capitales espacées, comme des en-têtes de terminal. */
[data-theme="ajean"] .subhead,[data-theme="ajean"] .skillshead>span{letter-spacing:.16em}
[data-theme^="ajean"] .subhead,[data-theme^="ajean"] .skillshead>span{letter-spacing:.16em}
/* ----- Thème AJEAN : modales (éditeur de preset, mémoire, MCP) -------------
Les règles génériques du thème (bouton en capitales + ombre en bloc + décalage
au survol) sont faites pour la barre latérale ; dans un formulaire dense elles
rendaient l'éditeur de preset illisible : boutons énormes, ombres qui se
chevauchent, champs collés au fond de la carte. On rejoue donc la modale à
plat — hiérarchie nette, un seul niveau de relief (la carte). */
[data-theme^="ajean"] .modal-ov{background:rgba(15,31,61,.45)}
[data-theme^="ajean"] .modal-card{background:var(--panel);border:2px solid var(--line2);box-shadow:8px 8px 0 0 var(--sh)}
[data-theme^="ajean"] .modal-head{border-bottom:2px solid var(--border);background:var(--soft,#e8f0ff)}
[data-theme^="ajean"] .modal-head strong{text-transform:uppercase;letter-spacing:.14em;font-size:12px;color:var(--accent)}
[data-theme^="ajean"] .modal-foot{border-top:2px solid var(--border)}
/* Champs simples (mémoire, MCP…) : fond teinté pour se détacher de la carte. */
[data-theme^="ajean"] .ctl{border:2px solid var(--line2);background:var(--bg);background-image:none}
[data-theme^="ajean"] .ctl:focus{border-color:var(--accent)}
[data-theme^="ajean"] .fld-l{color:var(--text);letter-spacing:.1em}
[data-theme^="ajean"] .fld-hint,[data-theme^="ajean"] .seg .be-note{color:var(--dim)}
[data-theme^="ajean"] .modal-body .card{border:2px solid var(--border);box-shadow:none;background:var(--panel)}
[data-theme^="ajean"] .card-h{letter-spacing:.14em}
/* --- Éditeur de preset (listes façon Réglages) --- */
[data-theme^="ajean"] .pe-body{background:var(--bg)}
/* En-tête de groupe : petite étiquette bleue en capitales. */
[data-theme^="ajean"] .pe-gh{color:var(--accent);text-transform:uppercase;letter-spacing:.14em;font-size:11px;font-weight:700}
/* La LISTE porte le cadre ; les champs à l'intérieur n'en ont pas (sinon double
encadrement). Séparateurs de rangées à la même épaisseur. */
[data-theme^="ajean"] .pe-list,[data-theme^="ajean"] .pe-adv{border:2px solid var(--border);background:var(--panel)}
[data-theme^="ajean"] .pe-row:not(:last-child){border-bottom:2px solid var(--border)}
[data-theme^="ajean"] .pe-adv[open]>summary{border-bottom:2px solid var(--border)}
[data-theme^="ajean"] .pe-val,[data-theme^="ajean"] .pe-selc select{border:none;background:transparent}
/* Champs pleine largeur (URL, chemin…) : eux gardent un cadre, sur fond teinté. */
[data-theme^="ajean"] .pe-inp{border:2px solid var(--line2);background:var(--bg)}
[data-theme^="ajean"] .pe-inp:focus{border-color:var(--accent);box-shadow:none}
[data-theme^="ajean"] .pe-row.stack.plain .pe-inp{border:none;background:transparent}
[data-theme^="ajean"] .pe-inpbtn button{border:2px solid var(--line2);box-shadow:none;background:var(--panel);text-transform:none;letter-spacing:0}
[data-theme^="ajean"] .pe-inpbtn .pe-inp{border-right:0}
[data-theme^="ajean"] .pe-inpbtn button:hover:not(:disabled){transform:none;background:var(--accent);border-color:var(--accent);color:#fff}
/* Sélecteur segmenté : cases carrées côte à côte, bleu plein pour l'active. */
[data-theme^="ajean"] .pe-seg{background:transparent;padding:0;gap:8px}
[data-theme^="ajean"] .pe-seg>label{border:2px solid var(--border);background:var(--panel);font-weight:700;text-transform:uppercase;letter-spacing:.06em;font-size:11px;padding:9px 4px}
[data-theme^="ajean"] .pe-seg>label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:none}
/* Interrupteurs de la modale : carrés, comme ceux de la barre latérale. */
[data-theme^="ajean"] .pe-sl{background:var(--line2)}
[data-theme^="ajean"] .pe-sl::before{box-sizing:border-box;background:var(--knob);border:2px solid var(--line2);box-shadow:none}
[data-theme^="ajean"] .pe-switch input:checked+.pe-sl{background:var(--accent)}
[data-theme^="ajean"] .pe-switch input:checked+.pe-sl::before{border-color:var(--knob);background:var(--knob)}
/* Boutons DANS la modale : mêmes capitales, mais posés à plat — pas d'ombre ni
de décalage au survol, sinon les rangées de boutons dansent et se recouvrent. */
[data-theme^="ajean"] .modal-card button{box-shadow:none;font-size:11.5px}
[data-theme^="ajean"] .modal-card button:hover:not(:disabled){transform:none}
[data-theme^="ajean"] .modal-card .btn-save{background:var(--accent);border-color:var(--accent);color:#fff}
[data-theme^="ajean"] .modal-card .btn-danger{background:var(--danger-bg);border-color:var(--err);color:var(--err)}
[data-theme^="ajean"] .modal-card .btn-danger:hover{background:var(--err);border-color:var(--err);color:#fff}
[data-theme^="ajean"] .modal-x{border:2px solid transparent;background:transparent;color:var(--dim)}
[data-theme^="ajean"] .modal-x:hover{background:transparent;border-color:var(--line2);color:var(--text)}
/* Champ + bouton accolés : les bordures se chevauchent au lieu de doubler. */
[data-theme^="ajean"] .input-btn .btn-in{margin-left:-2px}
/* Sélecteur segmenté : bleu plein quand c'est coché (le liseré seul se voyait mal). */
[data-theme^="ajean"] .seg>label{border:2px solid var(--border);background:var(--bg)}
[data-theme^="ajean"] .seg>label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff}
[data-theme^="ajean"] .seg>label:has(input:checked) .be-note{color:rgba(255,255,255,.85)}
/* Listes (MCP, outils) : mêmes bordures épaisses, survol teinté. */
[data-theme^="ajean"] .mcp-row,[data-theme^="ajean"] .mcp-tools-list,[data-theme^="ajean"] #lc-log{border:2px solid var(--border)}
[data-theme^="ajean"] .mcp-tool-row:hover{background:var(--soft,#e8f0ff)}
/* Diff sur fond clair : mêmes rôles, teintes plus profondes pour rester lisibles. */
[data-theme="light"],[data-theme="soft"],[data-theme="gpt"],[data-theme="ajean"]{
--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)}
[data-theme="soft-dark"],[data-theme="ajean-dark"]{
--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<div id="backdrop" onclick="toggleSide()"></div>
@@ -655,7 +750,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<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>
<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>
@@ -686,7 +781,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
<div id="mcp-block" class="gated-block">
<div class="subhead">Serveurs MCP<span id="mcp-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">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠️ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div class="subhead">Serveurs MCP<span id="mcp-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">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div id="mcp-list" class="mcp-list"></div>
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
</div>
@@ -770,17 +865,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div id="lc-status" class="lc-head muted">…</div>
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">⚡ Version rapide <span class="lc-reco">conseillé</span></div>
<div class="lc-mode-t">Version rapide <span class="lc-reco">conseillé</span></div>
<div class="lc-mode-d">prête à l'emploi, installée en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">🔧 Version optimisée</div>
<div class="lc-mode-t">Version optimisée</div>
<div class="lc-mode-d">compilée pour votre machine, plus longue</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">🧩 Backend personnalisé</div>
<div class="lc-mode-t">Backend personnalisé</div>
<div class="lc-mode-d">forks llama.cpp pour modèles spéciaux (ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
@@ -804,7 +899,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<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>
<button id="btn-bench" onclick="runBenchUI()">bench</button>
</div>
</details>
</div>
@@ -822,18 +917,18 @@ button:disabled{opacity:.5;cursor:not-allowed}
</span>
</div>
<div id="inputbar">
<textarea id="input" placeholder="message…" onkeydown="onKey(event)"></textarea>
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)"></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 id="sendhint" class="muted">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>
<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>
@@ -857,7 +952,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
<strong>🧩 Backends personnalisés</strong>
<strong>Backends personnalisés</strong>
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
@@ -898,8 +993,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div class="fld">
<label class="fld-l">Transport</label>
<div class="seg">
<label><input type="radio" name="mcp-transport" value="stdio" onchange="mcpTransportUI('stdio')"><span>⚙️ stdio (local)</span></label>
<label><input type="radio" name="mcp-transport" value="http" onchange="mcpTransportUI('http')"><span>🌐 http (distant)</span></label>
<label><input type="radio" name="mcp-transport" value="stdio" onchange="mcpTransportUI('stdio')"><span>stdio (local)</span></label>
<label><input type="radio" name="mcp-transport" value="http" onchange="mcpTransportUI('http')"><span>http (distant)</span></label>
</div>
</div>
<div id="mcp-stdio-fields">
@@ -973,7 +1068,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">⬇</button>
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
</div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
</div>
@@ -1120,7 +1215,7 @@ async function loadSys(){
// 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:'ajean',label:'AJEAN'},{id:'dark',label:'sombre'},{id:'light',label:'clair'},{id:'soft',label:'doux'},{id:'soft-dark',label:'doux sombre'},{id:'gpt',label:'ChatGPT'}];
const THEMES=[{id:'ajean',label:'AJEAN'},{id:'ajean-dark',label:'AJEAN sombre'},{id:'dark',label:'sombre'},{id:'light',label:'clair'},{id:'soft',label:'doux'},{id:'soft-dark',label:'doux sombre'},{id:'gpt',label:'ChatGPT'}];
// Anciens identifiants (v0.5.7, thème livré en deux variantes) → thème unique.
const THEME_ALIASES={'gpt-light':'gpt','gpt-dark':'gpt'};
// Lexend (thème "doux") n'est chargé QUE si ce thème est choisi : aucune requête
@@ -1252,7 +1347,7 @@ async function jfetch(u, opts){
let r;
try{ r = await fetch(u, opts); }
catch(e){
if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Jean ouverts ?)'); }
if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets AJEAN ouverts ?)'); }
throw e;
}
finally{ if(timer) clearTimeout(timer); }
@@ -1323,7 +1418,7 @@ async function checkUpdate(){
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 ✓'; }
} else { msg.textContent='AJEAN est à jour ✓'; }
}catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false;
}
@@ -1385,8 +1480,8 @@ async function loadCfg(){
// Moteur : rapide / optimisé / custom (avec le chemin). Le title garde
// toujours le chemin complet, quel que soit le libellé.
let v;
if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide';
else if(lc && lc.in_use) v='🔧 optimisé';
if(lc && lc.prebuilt && lc.prebuilt.in_use) v='rapide';
else if(lc && lc.in_use) v='optimisé';
else v='custom : '+c.BIN;
rows.push(row('MOTEUR', v, c.BIN));
}
@@ -1551,7 +1646,7 @@ function renderInternet(s){
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'; }
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) -----------------
@@ -1669,7 +1764,7 @@ async function runBenchUI(){
'</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';
btn.disabled = false; btn.textContent = 'bench';
rerun.disabled = false;
loadPresets();
}
@@ -1952,7 +2047,7 @@ async function delItem(){
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(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}
@@ -1997,7 +2092,7 @@ async function startDownload(){
}
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;
prog.textContent = '↓ '+fmtSize(st.done)+tot+' — '+fname;
}, 800);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
@@ -2101,26 +2196,26 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCo
// 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.
// Métadonnées d'affichage par outil : nom court + en-tête. Les outils web
// (web_search/open/read/grep) ont leur propre libellé, 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'},
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'},
};
// Outils MCP (nom mcp__<serveur>__<outil>) : icône dédiée + libellé lisible,
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
let meta = META[tu.name];
if(!meta && tu.name && tu.name.indexOf('mcp__')===0){
const parts = tu.name.slice(5).split('__');
const server = parts.shift() || 'mcp';
const tool = parts.join('__') || tu.name;
meta = {lbl: tool, head: '🔌 '+server};
meta = {lbl: tool, head: server};
}
meta = meta || {lbl:'mémoire', head:'🧠 mémoire'};
meta = meta || {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'; }
@@ -2135,6 +2230,26 @@ function renderToolMsg(el, tu){
if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
pre.appendChild(code); body.appendChild(pre);
}
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
// retirées en rouge, contexte en gris — comme un diff de terminal.
if(tu.diff && tu.diff.length){
const sub=document.createElement('div'); sub.className='tool-sub';
let add=0, del=0;
tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; });
sub.textContent='modifications';
const cnt=document.createElement('span'); cnt.className='diff-count';
cnt.innerHTML='<span class="a">+'+add+'</span> <span class="d">-'+del+'</span>';
sub.appendChild(cnt);
body.appendChild(sub);
const pre=document.createElement('pre'); pre.className='diff';
tu.diff.forEach(l=>{
const ln=document.createElement('span');
ln.className='dl'+(l.op==='+'?' add':l.op==='-'?' del':'');
ln.textContent=(l.op==='+'?'+':l.op==='-'?'-':' ')+' '+l.text;
pre.appendChild(ln);
});
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';
@@ -2152,6 +2267,8 @@ function renderToolMsg(el, tu){
function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{
if(pre.querySelector('.copybtn')) return;
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
if(pre.classList.contains('diff')) return;
const btn=document.createElement('button');
btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
btn.onclick=async(e)=>{
@@ -2208,6 +2325,17 @@ function restoreChat(){
connectStream();
}
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
// La zone de saisie s'ajuste à son contenu : une ligne au repos, puis elle
// grandit jusqu'à sa max-height (au-delà, elle défile). Appelée à la frappe, à
// l'envoi et au chargement.
function autoGrow(ta){
ta = ta || document.getElementById('input');
if(!ta) return;
ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden';
}
// ===== 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
@@ -2362,13 +2490,13 @@ async function send(){
if(busy) return;
const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return;
ta.value='';
ta.value=''; autoGrow(ta);
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; }
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} toast(m); ta.value=text; autoGrow(ta); return; }
}catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600));
}
@@ -2381,8 +2509,8 @@ setInterval(loadStatus, 5000);
setInterval(loadVram, 3000);
setInterval(loadRam, 3000);
// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
// ⚡ Version rapide = binaires officiels précompilés (aucune compilation)
// 🔧 Version optimisée = compilée pour la machine
// « Version rapide » = binaires officiels précompilés (aucune compilation)
// « Version optimisée » = compilée pour la machine
// Le CHOIX du moteur utilisé se fait par modèle (preset), pas ici — voir la
// section « Moteur » dans l'éditeur de modèle. Ça évite que la barre latérale
// et les presets se battent pour la ligne BIN.
@@ -2419,7 +2547,7 @@ function lcRenderMode(mode, installed){
card.classList.toggle('installed', installed);
// Lien de vérification : interroge la dernière version SANS rien installer.
// event.stopPropagation empêche le clic de la carte (qui lance l'install).
const check = '<span class="lc-update-link" onclick="event.stopPropagation();lcCheck(\''+mode+'\')">🔍 vérifier la version</span>';
const check = '<span class="lc-update-link" onclick="event.stopPropagation();lcCheck(\''+mode+'\')">vérifier la version</span>';
if(installed){
state.innerHTML = '<span class="lc-mode-active-tag">✓ installée</span>'
+ '<span class="lc-update-link" onclick="event.stopPropagation();lcUpdate(\''+mode+'\')">↻ mettre à jour</span>'
@@ -2698,7 +2826,7 @@ function openMcp(name){
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠️ '+s.error; st.style.color = 'var(--err)'; }
if(s && s.enabled && s.error){ st.textContent = '⚠ '+s.error; st.style.color = 'var(--err)'; }
else if(s && s.connected){ st.innerHTML = '<span style="color:var(--accent)">✓</span> connecté — '+(s.tools?s.tools.length:0)+' outil(s)'; }
document.getElementById('mcp-del').style.display = s ? '' : 'none';
renderMcpTools(s);
@@ -2777,11 +2905,11 @@ async function saveMcp(){
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
if(!r.ok){ st.textContent = '⚠️ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; }
if(!r.ok){ st.textContent = '⚠ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; }
renderMCP(r);
const saved = (r.servers||[]).find(x=>x.name===name);
if(saved && saved.connected){ toast('serveur connecté — '+(saved.tools?saved.tools.length:0)+' outil(s)'); closeMcp(); }
else if(saved && saved.error){ st.textContent = '⚠️ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; }
else if(saved && saved.error){ st.textContent = '⚠ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; }
else { toast('enregistré'); closeMcp(); }
}
+15 -15
View File
@@ -7,8 +7,8 @@
<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>
<meta name="apple-mobile-web-app-title" content="AJEAN">
<title>AJEAN</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. -->
@@ -51,7 +51,7 @@
<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>
<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>
@@ -82,7 +82,7 @@
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
<div id="mcp-block" class="gated-block">
<div class="subhead">Serveurs MCP<span id="mcp-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">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠️ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div class="subhead">Serveurs MCP<span id="mcp-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">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div id="mcp-list" class="mcp-list"></div>
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
</div>
@@ -166,17 +166,17 @@
<div id="lc-status" class="lc-head muted">…</div>
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">⚡ Version rapide <span class="lc-reco">conseillé</span></div>
<div class="lc-mode-t">Version rapide <span class="lc-reco">conseillé</span></div>
<div class="lc-mode-d">prête à l'emploi, installée en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">🔧 Version optimisée</div>
<div class="lc-mode-t">Version optimisée</div>
<div class="lc-mode-d">compilée pour votre machine, plus longue</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">🧩 Backend personnalisé</div>
<div class="lc-mode-t">Backend personnalisé</div>
<div class="lc-mode-d">forks llama.cpp pour modèles spéciaux (ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
@@ -200,7 +200,7 @@
<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>
<button id="btn-bench" onclick="runBenchUI()">bench</button>
</div>
</details>
</div>
@@ -218,18 +218,18 @@
</span>
</div>
<div id="inputbar">
<textarea id="input" placeholder="message…" onkeydown="onKey(event)"></textarea>
<textarea id="input" rows="1" placeholder="message…" onkeydown="onKey(event)" oninput="autoGrow(this)"></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 id="sendhint" class="muted">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>
<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>
@@ -253,7 +253,7 @@
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
<strong>🧩 Backends personnalisés</strong>
<strong>Backends personnalisés</strong>
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
@@ -294,8 +294,8 @@
<div class="fld">
<label class="fld-l">Transport</label>
<div class="seg">
<label><input type="radio" name="mcp-transport" value="stdio" onchange="mcpTransportUI('stdio')"><span>⚙️ stdio (local)</span></label>
<label><input type="radio" name="mcp-transport" value="http" onchange="mcpTransportUI('http')"><span>🌐 http (distant)</span></label>
<label><input type="radio" name="mcp-transport" value="stdio" onchange="mcpTransportUI('stdio')"><span>stdio (local)</span></label>
<label><input type="radio" name="mcp-transport" value="http" onchange="mcpTransportUI('http')"><span>http (distant)</span></label>
</div>
</div>
<div id="mcp-stdio-fields">
@@ -369,7 +369,7 @@
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">⬇</button>
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
</div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
// 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:'ajean',label:'AJEAN'},{id:'dark',label:'sombre'},{id:'light',label:'clair'},{id:'soft',label:'doux'},{id:'soft-dark',label:'doux sombre'},{id:'gpt',label:'ChatGPT'}];
const THEMES=[{id:'ajean',label:'AJEAN'},{id:'ajean-dark',label:'AJEAN sombre'},{id:'dark',label:'sombre'},{id:'light',label:'clair'},{id:'soft',label:'doux'},{id:'soft-dark',label:'doux sombre'},{id:'gpt',label:'ChatGPT'}];
// Anciens identifiants (v0.5.7, thème livré en deux variantes) → thème unique.
const THEME_ALIASES={'gpt-light':'gpt','gpt-dark':'gpt'};
// Lexend (thème "doux") n'est chargé QUE si ce thème est choisi : aucune requête
+1 -1
View File
@@ -25,7 +25,7 @@ async function jfetch(u, opts){
let r;
try{ r = await fetch(u, opts); }
catch(e){
if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Jean ouverts ?)'); }
if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets AJEAN ouverts ?)'); }
throw e;
}
finally{ if(timer) clearTimeout(timer); }
+3 -3
View File
@@ -57,7 +57,7 @@ async function checkUpdate(){
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 ✓'; }
} else { msg.textContent='AJEAN est à jour ✓'; }
}catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false;
}
@@ -119,8 +119,8 @@ async function loadCfg(){
// Moteur : rapide / optimisé / custom (avec le chemin). Le title garde
// toujours le chemin complet, quel que soit le libellé.
let v;
if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide';
else if(lc && lc.in_use) v='🔧 optimisé';
if(lc && lc.prebuilt && lc.prebuilt.in_use) v='rapide';
else if(lc && lc.in_use) v='optimisé';
else v='custom : '+c.BIN;
rows.push(row('MOTEUR', v, c.BIN));
}
+1 -1
View File
@@ -150,7 +150,7 @@ function renderInternet(s){
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'; }
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) -----------------
+3 -3
View File
@@ -36,7 +36,7 @@ async function runBenchUI(){
'</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';
btn.disabled = false; btn.textContent = 'bench';
rerun.disabled = false;
loadPresets();
}
@@ -319,7 +319,7 @@ async function delItem(){
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(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}
@@ -364,7 +364,7 @@ async function startDownload(){
}
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;
prog.textContent = '↓ '+fmtSize(st.done)+tot+' — '+fname;
}, 800);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
+44 -11
View File
@@ -97,26 +97,26 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCo
// 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.
// Métadonnées d'affichage par outil : nom court + en-tête. Les outils web
// (web_search/open/read/grep) ont leur propre libellé, 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'},
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'},
};
// Outils MCP (nom mcp__<serveur>__<outil>) : icône dédiée + libellé lisible,
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
let meta = META[tu.name];
if(!meta && tu.name && tu.name.indexOf('mcp__')===0){
const parts = tu.name.slice(5).split('__');
const server = parts.shift() || 'mcp';
const tool = parts.join('__') || tu.name;
meta = {lbl: tool, head: '🔌 '+server};
meta = {lbl: tool, head: server};
}
meta = meta || {lbl:'mémoire', head:'🧠 mémoire'};
meta = meta || {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'; }
@@ -131,6 +131,26 @@ function renderToolMsg(el, tu){
if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
pre.appendChild(code); body.appendChild(pre);
}
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
// retirées en rouge, contexte en gris — comme un diff de terminal.
if(tu.diff && tu.diff.length){
const sub=document.createElement('div'); sub.className='tool-sub';
let add=0, del=0;
tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; });
sub.textContent='modifications';
const cnt=document.createElement('span'); cnt.className='diff-count';
cnt.innerHTML='<span class="a">+'+add+'</span> <span class="d">-'+del+'</span>';
sub.appendChild(cnt);
body.appendChild(sub);
const pre=document.createElement('pre'); pre.className='diff';
tu.diff.forEach(l=>{
const ln=document.createElement('span');
ln.className='dl'+(l.op==='+'?' add':l.op==='-'?' del':'');
ln.textContent=(l.op==='+'?'+':l.op==='-'?'-':' ')+' '+l.text;
pre.appendChild(ln);
});
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';
@@ -148,6 +168,8 @@ function renderToolMsg(el, tu){
function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{
if(pre.querySelector('.copybtn')) return;
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
if(pre.classList.contains('diff')) return;
const btn=document.createElement('button');
btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
btn.onclick=async(e)=>{
@@ -204,3 +226,14 @@ function restoreChat(){
connectStream();
}
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
// La zone de saisie s'ajuste à son contenu : une ligne au repos, puis elle
// grandit jusqu'à sa max-height (au-delà, elle défile). Appelée à la frappe, à
// l'envoi et au chargement.
function autoGrow(ta){
ta = ta || document.getElementById('input');
if(!ta) return;
ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden';
}
+2 -2
View File
@@ -152,13 +152,13 @@ async function send(){
if(busy) return;
const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return;
ta.value='';
ta.value=''; autoGrow(ta);
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; }
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} toast(m); ta.value=text; autoGrow(ta); return; }
}catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600));
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
// ⚡ Version rapide = binaires officiels précompilés (aucune compilation)
// 🔧 Version optimisée = compilée pour la machine
// « Version rapide » = binaires officiels précompilés (aucune compilation)
// « Version optimisée » = compilée pour la machine
// Le CHOIX du moteur utilisé se fait par modèle (preset), pas ici — voir la
// section « Moteur » dans l'éditeur de modèle. Ça évite que la barre latérale
// et les presets se battent pour la ligne BIN.
@@ -37,7 +37,7 @@ function lcRenderMode(mode, installed){
card.classList.toggle('installed', installed);
// Lien de vérification : interroge la dernière version SANS rien installer.
// event.stopPropagation empêche le clic de la carte (qui lance l'install).
const check = '<span class="lc-update-link" onclick="event.stopPropagation();lcCheck(\''+mode+'\')">🔍 vérifier la version</span>';
const check = '<span class="lc-update-link" onclick="event.stopPropagation();lcCheck(\''+mode+'\')">vérifier la version</span>';
if(installed){
state.innerHTML = '<span class="lc-mode-active-tag">✓ installée</span>'
+ '<span class="lc-update-link" onclick="event.stopPropagation();lcUpdate(\''+mode+'\')">↻ mettre à jour</span>'
+3 -3
View File
@@ -95,7 +95,7 @@ function openMcp(name){
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠️ '+s.error; st.style.color = 'var(--err)'; }
if(s && s.enabled && s.error){ st.textContent = '⚠ '+s.error; st.style.color = 'var(--err)'; }
else if(s && s.connected){ st.innerHTML = '<span style="color:var(--accent)">✓</span> connecté — '+(s.tools?s.tools.length:0)+' outil(s)'; }
document.getElementById('mcp-del').style.display = s ? '' : 'none';
renderMcpTools(s);
@@ -174,11 +174,11 @@ async function saveMcp(){
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
if(!r.ok){ st.textContent = '⚠️ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; }
if(!r.ok){ st.textContent = '⚠ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; }
renderMCP(r);
const saved = (r.servers||[]).find(x=>x.name===name);
if(saved && saved.connected){ toast('serveur connecté — '+(saved.tools?saved.tools.length:0)+' outil(s)'); closeMcp(); }
else if(saved && saved.error){ st.textContent = '⚠️ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; }
else if(saved && saved.error){ st.textContent = '⚠ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; }
else { toast('enregistré'); closeMcp(); }
}
+136 -41
View File
@@ -5,7 +5,7 @@
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}
: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;--add:#3fb950;--add-bg:rgba(63,185,80,.12);--del:#f85149;--del-bg:rgba(248,81,73,.12)}
[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). */
@@ -20,7 +20,10 @@
/* "AJEAN" — clair, bleu, esprit rétro-machine : bordures nettes de 2 px,
aucun arrondi, ombres en blocs décalés, fond quadrillé (même charte que
ajean.app). Mise en page complète plus bas (section "Thème pixel"). */
[data-theme="ajean"]{color-scheme:light;--font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#1f6feb;--bg:#f5f8ff;--panel:#ffffff;--border:#cfdcf5;--text:#0f1f3d;--dim:#5b6f96;--accent:#1f6feb;--accent-bg:rgba(31,111,235,.10);--ok:#1f6feb;--warn:#9a6700;--err:#cf222e;--mag:#6f52d8;--code-bg:#eef3fe;--danger-bg:#fbe6e6;--warn-bg:#fbefda;--grid:#e4ecfa;--line2:#b6cbf0}
[data-theme="ajean"]{color-scheme:light;--font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#1f6feb;--bg:#f5f8ff;--panel:#ffffff;--border:#cfdcf5;--text:#0f1f3d;--dim:#5b6f96;--accent:#1f6feb;--accent-bg:rgba(31,111,235,.10);--ok:#1f6feb;--warn:#9a6700;--err:#cf222e;--mag:#6f52d8;--code-bg:#eef3fe;--danger-bg:#fbe6e6;--warn-bg:#fbefda;--grid:#e4ecfa;--line2:#b6cbf0;--soft:#e8f0ff;--sh:#cddffb;--knob:#ffffff}
/* « AJEAN sombre » — même habillage pixel, palette nuit : bleu vif sur fond
presque noir, quadrillage discret, ombres bleutées. */
[data-theme="ajean-dark"]{color-scheme:dark;--font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#3b82f6;--bg:#050810;--panel:#0a1020;--border:#16233f;--text:#dce6ff;--dim:#63739c;--accent:#3b82f6;--accent-bg:rgba(59,130,246,.16);--ok:#3b82f6;--warn:#d29922;--err:#f85149;--mag:#9b8cff;--code-bg:#03060d;--danger-bg:#3d1f23;--warn-bg:#3d2f1f;--grid:#0a1120;--line2:#1e3054;--soft:#101b33;--sh:#132342;--knob:#dce6ff}
*{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
@@ -196,6 +199,16 @@ button:disabled{opacity:.5;cursor:not-allowed}
.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}
/* ===== Diff des écritures (outil edit / mémoire) ==================
Lignes ajoutées en vert, retirées en rouge, contexte neutre. Les couleurs
viennent de variables : chaque thème peut les ajuster (clair vs sombre). */
.msg.tool pre.diff{display:block;padding:0;overflow-x:auto;background:var(--code-bg)}
.msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)}
.msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)}
.msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)}
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
.diff-count .a{color:var(--add)}
.diff-count .d{color:var(--del)}
.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}
@@ -246,7 +259,10 @@ button:disabled{opacity:.5;cursor:not-allowed}
#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}
/* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir
changer ses marges, ce qu'un style en ligne empêchait). */
#sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7}
#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:56px;max-height:200px;overflow-y:hidden}
#input:focus{outline:none;border-color:var(--accent)}
#send{padding:0 20px}
.muted{color:var(--dim);font-size:12px}
@@ -344,10 +360,14 @@ button:disabled{opacity:.5;cursor:not-allowed}
.pe-row.stack.plain .pe-inp:focus{box-shadow:none}
/* Champ + bouton accolé (téléchargement) — même trait que les autres champs,
sans pastille colorée qui ressort. */
.pe-inpbtn{display:flex}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0}
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-left:0;border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s}
.pe-inpbtn button:hover{color:var(--accent);border-color:var(--accent)}
.pe-inpbtn{display:flex;align-items:stretch}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0;border-right:0}
/* Le bouton reprend EXACTEMENT le cadre du champ (mêmes coins, même bordure) :
sans ça, un thème qui arrondit tous les boutons (ChatGPT) le détachait du
champ, avec un coin rond au milieu de la paire. */
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s,background .12s}
.pe-inpbtn button:hover:not(:disabled){color:#fff;background:var(--accent);border-color:var(--accent)}
.pe-inpbtn:focus-within .pe-inp,.pe-inpbtn:focus-within button{border-color:var(--accent)}
.pe-inpbtn button:disabled{opacity:.5;cursor:default}
/* Contrôle segmenté (moteur) façon iOS. */
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:9px;padding:3px;width:100%}
@@ -520,7 +540,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
[data-theme="gpt"] #scrollbtn{bottom:180px;border-radius:999px}
[data-theme="gpt"] #compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px}
[data-theme="gpt"] #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0}
[data-theme="gpt"] #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:26px}
[data-theme="gpt"] #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
bordure, seule la saisie change (curseur). */
[data-theme="gpt"] #input:focus{border-color:transparent}
@@ -540,9 +560,14 @@ button:disabled{opacity:.5;cursor:not-allowed}
l'arrondi du pied de carte. */
[data-theme="gpt"] #ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
[data-theme="gpt"] #ctx-fill{background:var(--text);opacity:.75}
[data-theme="gpt"] #sendhint{order:4;padding:8px var(--col) 0}
[data-theme="gpt"] #sendhint{order:4;padding:12px var(--col) 2px}
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
[data-theme="gpt"] button,[data-theme="gpt"] .addbtn,[data-theme="gpt"] .mem-search,[data-theme="gpt"] #theme-select{border-radius:10px}
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
[data-theme="gpt"] .pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
[data-theme="gpt"] .pe-inpbtn button{border-radius:0 10px 10px 0}
[data-theme="gpt"] .input-btn .ctl{border-radius:10px 0 0 10px}
[data-theme="gpt"] .input-btn .btn-in{border-radius:0 10px 10px 0}
[data-theme="gpt"] .preset,[data-theme="gpt"] .gated-block,[data-theme="gpt"] .lc-mode{border-radius:12px}
[data-theme="gpt"] .preset{background:transparent;border-color:transparent}
[data-theme="gpt"] .preset:hover{background:var(--accent-bg);border-color:transparent}
@@ -557,49 +582,119 @@ button:disabled{opacity:.5;cursor:not-allowed}
Rétro-machine : plus aucun arrondi, bordures de 2 px, ombres portées en
blocs pleins (pas de flou), fond quadrillé comme du papier millimétré,
boutons en capitales qui « s'enfoncent » au survol. Tout est préfixé
[data-theme="ajean"] : aucun autre thème n'est touché. */
[data-theme^="ajean"] : aucun autre thème n'est touché. */
/* Un seul balayage pour supprimer TOUS les arrondis (bulles, boutons, pastilles,
interrupteurs, points animés…) — c'est ce qui fait l'identité du thème. */
[data-theme="ajean"] *,[data-theme="ajean"] *::before,[data-theme="ajean"] *::after{border-radius:0 !important}
[data-theme^="ajean"] *,[data-theme^="ajean"] *::before,[data-theme^="ajean"] *::after{border-radius:0 !important}
/* Fond quadrillé : le papier millimétré derrière toute l'interface. */
[data-theme="ajean"] body{background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:32px 32px}
[data-theme="ajean"] .side{background:var(--panel);border-right:2px solid var(--border)}
[data-theme="ajean"] #composer,[data-theme="ajean"] #chat{background:transparent}
[data-theme^="ajean"] body{background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:32px 32px}
[data-theme^="ajean"] .side{background:var(--panel);border-right:2px solid var(--border)}
[data-theme^="ajean"] #composer,[data-theme^="ajean"] #chat{background:transparent}
/* Boutons : capitales, bordure épaisse, ombre en bloc qui disparaît au survol
pendant que le bouton se décale — impression de touche enfoncée. */
[data-theme="ajean"] button,[data-theme="ajean"] .addbtn,[data-theme="ajean"] .preset-add{border:2px solid var(--line2);background:var(--panel);color:var(--text);text-transform:uppercase;letter-spacing:.06em;font-weight:700;box-shadow:3px 3px 0 0 var(--grid);transition:.1s}
[data-theme="ajean"] button:hover:not(:disabled),[data-theme="ajean"] .addbtn:hover,[data-theme="ajean"] .preset-add:hover{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:none;transform:translate(3px,3px)}
[data-theme="ajean"] .preset-edit,[data-theme="ajean"] .copybtn{border:none;box-shadow:none;text-transform:none}
[data-theme="ajean"] .copybtn{border:2px solid var(--border)}
[data-theme="ajean"] #send{background:var(--accent);border-color:var(--accent);color:#fff}
[data-theme="ajean"] #menubtn{border:2px solid var(--line2);box-shadow:3px 3px 0 0 var(--grid)}
[data-theme^="ajean"] button,[data-theme^="ajean"] .addbtn,[data-theme^="ajean"] .preset-add{border:2px solid var(--line2);background:var(--panel);color:var(--text);text-transform:uppercase;letter-spacing:.06em;font-weight:700;box-shadow:3px 3px 0 0 var(--grid);transition:.1s}
[data-theme^="ajean"] button:hover:not(:disabled),[data-theme^="ajean"] .addbtn:hover,[data-theme^="ajean"] .preset-add:hover{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:none;transform:translate(3px,3px)}
[data-theme^="ajean"] .preset-edit,[data-theme^="ajean"] .copybtn{border:none;box-shadow:none;text-transform:none}
[data-theme^="ajean"] .copybtn{border:2px solid var(--border)}
[data-theme^="ajean"] #send{background:var(--accent);border-color:var(--accent);color:#fff}
[data-theme^="ajean"] #menubtn{border:2px solid var(--line2);box-shadow:3px 3px 0 0 var(--grid)}
/* Messages : la question en carré bleu plein, la réponse en fiche blanche
posée sur son ombre. Raisonnement et outils restent en pointillés. */
[data-theme="ajean"] #chat .msg.user{background:var(--accent);color:#fff;box-shadow:4px 4px 0 0 #cddffb}
[data-theme="ajean"] #chat .msg.assistant{background:var(--panel);border:2px solid var(--border);box-shadow:4px 4px 0 0 var(--grid)}
[data-theme="ajean"] #chat .msg.reasoning,[data-theme="ajean"] #chat .msg.tool{border:2px dashed var(--line2);background:var(--panel)}
[data-theme="ajean"] #chat .msg .label{color:var(--dim);letter-spacing:.14em}
[data-theme="ajean"] #chat .msg.user .label{color:rgba(255,255,255,.85)}
[data-theme="ajean"] .msg.assistant .body pre,[data-theme="ajean"] .msg.reasoning .body pre,[data-theme="ajean"] .msg.tool pre{border:2px solid var(--border);background:var(--code-bg)}
[data-theme="ajean"] .msg.typing span{background:var(--accent)}
[data-theme^="ajean"] #chat .msg.user{background:var(--accent);color:#fff;box-shadow:4px 4px 0 0 var(--sh)}
[data-theme^="ajean"] #chat .msg.assistant{background:var(--panel);border:2px solid var(--border);box-shadow:4px 4px 0 0 var(--grid)}
[data-theme^="ajean"] #chat .msg.reasoning,[data-theme^="ajean"] #chat .msg.tool{border:2px dashed var(--line2);background:var(--panel)}
[data-theme^="ajean"] #chat .msg .label{color:var(--dim);letter-spacing:.14em}
[data-theme^="ajean"] #chat .msg.user .label{color:rgba(255,255,255,.85)}
[data-theme^="ajean"] .msg.assistant .body pre,[data-theme^="ajean"] .msg.reasoning .body pre,[data-theme^="ajean"] .msg.tool pre{border:2px solid var(--border);background:var(--code-bg)}
[data-theme^="ajean"] .msg.typing span{background:var(--accent)}
/* Panneaux, encadrés et champs : mêmes bordures épaisses, mêmes ombres. */
[data-theme="ajean"] .preset,[data-theme="ajean"] .gated-block,[data-theme="ajean"] .tag,[data-theme="ajean"] .qtag,[data-theme="ajean"] .rtag,[data-theme="ajean"] .btag,[data-theme="ajean"] .statuspill,[data-theme="ajean"] .lc-mode{border-width:2px}
[data-theme="ajean"] .preset.active{border-color:var(--accent);color:var(--accent);box-shadow:3px 3px 0 0 #cddffb}
[data-theme="ajean"] #input,[data-theme="ajean"] .mem-search,[data-theme="ajean"] .ask-input,[data-theme="ajean"] #modal input,[data-theme="ajean"] #modal textarea,[data-theme="ajean"] #modal select,[data-theme="ajean"] #theme-select{border:2px solid var(--line2);background:var(--panel)}
[data-theme="ajean"] #input:focus,[data-theme="ajean"] .mem-search:focus,[data-theme="ajean"] .ask-input:focus{border-color:var(--accent)}
[data-theme="ajean"] #composer{border-top:2px solid var(--border);background:var(--panel)}
[data-theme^="ajean"] .preset,[data-theme^="ajean"] .gated-block,[data-theme^="ajean"] .tag,[data-theme^="ajean"] .qtag,[data-theme^="ajean"] .rtag,[data-theme^="ajean"] .btag,[data-theme^="ajean"] .statuspill,[data-theme^="ajean"] .lc-mode{border-width:2px}
[data-theme^="ajean"] .preset.active{border-color:var(--accent);color:var(--accent);box-shadow:3px 3px 0 0 var(--sh)}
[data-theme^="ajean"] #input,[data-theme^="ajean"] .mem-search,[data-theme^="ajean"] .ask-input,[data-theme^="ajean"] #theme-select,[data-theme^="ajean"] .modal-card .ctl{border:2px solid var(--line2);background:var(--panel)}
[data-theme^="ajean"] #input:focus,[data-theme^="ajean"] .mem-search:focus,[data-theme^="ajean"] .ask-input:focus{border-color:var(--accent)}
[data-theme^="ajean"] #composer{border-top:2px solid var(--border);background:var(--panel)}
/* Jauge de contexte : un vrai bandeau plein, pas un filet dégradé. */
[data-theme="ajean"] #ctxtrack{height:5px;background:var(--grid)}
[data-theme="ajean"] #ctx-fill{background:var(--accent)}
[data-theme^="ajean"] #ctxtrack{height:5px;background:var(--grid)}
[data-theme^="ajean"] #ctx-fill{background:var(--accent)}
/* Fenêtres modales / info-bulles / toast : cadre net + ombre en bloc bleutée. */
[data-theme="ajean"] .ask-box,[data-theme="ajean"] .help>.tip,[data-theme="ajean"] #toast,[data-theme="ajean"] .modal-box,[data-theme="ajean"] #modal>div{border:2px solid var(--line2);box-shadow:7px 7px 0 0 #cddffb}
[data-theme="ajean"] #scrollbtn{border:2px solid var(--line2);box-shadow:3px 3px 0 0 var(--grid)}
[data-theme^="ajean"] .ask-box,[data-theme^="ajean"] .help>.tip,[data-theme^="ajean"] #toast,[data-theme^="ajean"] .modal-box,[data-theme^="ajean"] #modal>div{border:2px solid var(--line2);box-shadow:7px 7px 0 0 var(--sh)}
[data-theme^="ajean"] #scrollbtn{border:2px solid var(--line2);box-shadow:3px 3px 0 0 var(--grid)}
/* Interrupteurs : carrés, bleus quand ils sont actifs. */
[data-theme="ajean"] .switch .slider{background:var(--line2)}
[data-theme="ajean"] .switch input:checked + .slider{background:var(--accent)}
[data-theme^="ajean"] .switch .slider{background:var(--line2)}
[data-theme^="ajean"] .switch input:checked + .slider{background:var(--accent)}
/* Pastille carrée : box-sizing explicite (un ::before n'hérite pas de la règle
globale *{box-sizing:border-box}) — sans ça la bordure débordait du rail. */
[data-theme="ajean"] .switch .slider::before{box-sizing:border-box;width:14px;height:14px;background:#fff;border:2px solid var(--line2);box-shadow:none}
[data-theme="ajean"] .switch input:checked + .slider::before{border-color:#fff;background:var(--panel)}
[data-theme^="ajean"] .switch .slider::before{box-sizing:border-box;width:14px;height:14px;background:var(--knob);border:2px solid var(--line2);box-shadow:none}
[data-theme^="ajean"] .switch input:checked + .slider::before{border-color:var(--knob);background:var(--knob)}
/* Étiquettes de section : capitales espacées, comme des en-têtes de terminal. */
[data-theme="ajean"] .subhead,[data-theme="ajean"] .skillshead>span{letter-spacing:.16em}
[data-theme^="ajean"] .subhead,[data-theme^="ajean"] .skillshead>span{letter-spacing:.16em}
/* ----- Thème AJEAN : modales (éditeur de preset, mémoire, MCP) -------------
Les règles génériques du thème (bouton en capitales + ombre en bloc + décalage
au survol) sont faites pour la barre latérale ; dans un formulaire dense elles
rendaient l'éditeur de preset illisible : boutons énormes, ombres qui se
chevauchent, champs collés au fond de la carte. On rejoue donc la modale à
plat — hiérarchie nette, un seul niveau de relief (la carte). */
[data-theme^="ajean"] .modal-ov{background:rgba(15,31,61,.45)}
[data-theme^="ajean"] .modal-card{background:var(--panel);border:2px solid var(--line2);box-shadow:8px 8px 0 0 var(--sh)}
[data-theme^="ajean"] .modal-head{border-bottom:2px solid var(--border);background:var(--soft,#e8f0ff)}
[data-theme^="ajean"] .modal-head strong{text-transform:uppercase;letter-spacing:.14em;font-size:12px;color:var(--accent)}
[data-theme^="ajean"] .modal-foot{border-top:2px solid var(--border)}
/* Champs simples (mémoire, MCP…) : fond teinté pour se détacher de la carte. */
[data-theme^="ajean"] .ctl{border:2px solid var(--line2);background:var(--bg);background-image:none}
[data-theme^="ajean"] .ctl:focus{border-color:var(--accent)}
[data-theme^="ajean"] .fld-l{color:var(--text);letter-spacing:.1em}
[data-theme^="ajean"] .fld-hint,[data-theme^="ajean"] .seg .be-note{color:var(--dim)}
[data-theme^="ajean"] .modal-body .card{border:2px solid var(--border);box-shadow:none;background:var(--panel)}
[data-theme^="ajean"] .card-h{letter-spacing:.14em}
/* --- Éditeur de preset (listes façon Réglages) --- */
[data-theme^="ajean"] .pe-body{background:var(--bg)}
/* En-tête de groupe : petite étiquette bleue en capitales. */
[data-theme^="ajean"] .pe-gh{color:var(--accent);text-transform:uppercase;letter-spacing:.14em;font-size:11px;font-weight:700}
/* La LISTE porte le cadre ; les champs à l'intérieur n'en ont pas (sinon double
encadrement). Séparateurs de rangées à la même épaisseur. */
[data-theme^="ajean"] .pe-list,[data-theme^="ajean"] .pe-adv{border:2px solid var(--border);background:var(--panel)}
[data-theme^="ajean"] .pe-row:not(:last-child){border-bottom:2px solid var(--border)}
[data-theme^="ajean"] .pe-adv[open]>summary{border-bottom:2px solid var(--border)}
[data-theme^="ajean"] .pe-val,[data-theme^="ajean"] .pe-selc select{border:none;background:transparent}
/* Champs pleine largeur (URL, chemin…) : eux gardent un cadre, sur fond teinté. */
[data-theme^="ajean"] .pe-inp{border:2px solid var(--line2);background:var(--bg)}
[data-theme^="ajean"] .pe-inp:focus{border-color:var(--accent);box-shadow:none}
[data-theme^="ajean"] .pe-row.stack.plain .pe-inp{border:none;background:transparent}
[data-theme^="ajean"] .pe-inpbtn button{border:2px solid var(--line2);box-shadow:none;background:var(--panel);text-transform:none;letter-spacing:0}
[data-theme^="ajean"] .pe-inpbtn .pe-inp{border-right:0}
[data-theme^="ajean"] .pe-inpbtn button:hover:not(:disabled){transform:none;background:var(--accent);border-color:var(--accent);color:#fff}
/* Sélecteur segmenté : cases carrées côte à côte, bleu plein pour l'active. */
[data-theme^="ajean"] .pe-seg{background:transparent;padding:0;gap:8px}
[data-theme^="ajean"] .pe-seg>label{border:2px solid var(--border);background:var(--panel);font-weight:700;text-transform:uppercase;letter-spacing:.06em;font-size:11px;padding:9px 4px}
[data-theme^="ajean"] .pe-seg>label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:none}
/* Interrupteurs de la modale : carrés, comme ceux de la barre latérale. */
[data-theme^="ajean"] .pe-sl{background:var(--line2)}
[data-theme^="ajean"] .pe-sl::before{box-sizing:border-box;background:var(--knob);border:2px solid var(--line2);box-shadow:none}
[data-theme^="ajean"] .pe-switch input:checked+.pe-sl{background:var(--accent)}
[data-theme^="ajean"] .pe-switch input:checked+.pe-sl::before{border-color:var(--knob);background:var(--knob)}
/* Boutons DANS la modale : mêmes capitales, mais posés à plat — pas d'ombre ni
de décalage au survol, sinon les rangées de boutons dansent et se recouvrent. */
[data-theme^="ajean"] .modal-card button{box-shadow:none;font-size:11.5px}
[data-theme^="ajean"] .modal-card button:hover:not(:disabled){transform:none}
[data-theme^="ajean"] .modal-card .btn-save{background:var(--accent);border-color:var(--accent);color:#fff}
[data-theme^="ajean"] .modal-card .btn-danger{background:var(--danger-bg);border-color:var(--err);color:var(--err)}
[data-theme^="ajean"] .modal-card .btn-danger:hover{background:var(--err);border-color:var(--err);color:#fff}
[data-theme^="ajean"] .modal-x{border:2px solid transparent;background:transparent;color:var(--dim)}
[data-theme^="ajean"] .modal-x:hover{background:transparent;border-color:var(--line2);color:var(--text)}
/* Champ + bouton accolés : les bordures se chevauchent au lieu de doubler. */
[data-theme^="ajean"] .input-btn .btn-in{margin-left:-2px}
/* Sélecteur segmenté : bleu plein quand c'est coché (le liseré seul se voyait mal). */
[data-theme^="ajean"] .seg>label{border:2px solid var(--border);background:var(--bg)}
[data-theme^="ajean"] .seg>label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff}
[data-theme^="ajean"] .seg>label:has(input:checked) .be-note{color:rgba(255,255,255,.85)}
/* Listes (MCP, outils) : mêmes bordures épaisses, survol teinté. */
[data-theme^="ajean"] .mcp-row,[data-theme^="ajean"] .mcp-tools-list,[data-theme^="ajean"] #lc-log{border:2px solid var(--border)}
[data-theme^="ajean"] .mcp-tool-row:hover{background:var(--soft,#e8f0ff)}
/* Diff sur fond clair : mêmes rôles, teintes plus profondes pour rester lisibles. */
[data-theme="light"],[data-theme="soft"],[data-theme="gpt"],[data-theme="ajean"]{
--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)}
[data-theme="soft-dark"],[data-theme="ajean-dark"]{
--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)}
+1 -1
View File
@@ -26,7 +26,7 @@ var webPrefsAllowed = map[string]map[string]bool{
// listé ICI : sinon POST /api/prefs le rejette silencieusement, le serveur
// conserve l'ancienne valeur et loadPrefs() la réapplique à chaque
// rafraîchissement — le thème « saute » alors qu'il est bien en localStorage.
"theme": {"ajean": true, "dark": true, "light": true, "soft": true, "soft-dark": true, "gpt": true},
"theme": {"ajean": true, "ajean-dark": true, "dark": true, "light": true, "soft": true, "soft-dark": true, "gpt": true},
"display": {"full": true, "simple": true},
}