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.** **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 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 ## 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 ## Fonctionnalités
@@ -59,7 +59,7 @@ sudo jean install
jean llamacpp 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 ### 4. Pointe-le sur un modèle et démarre
@@ -77,7 +77,7 @@ jean web # http://<hôte>:8090
## Windows ## 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. - **Pas de systemd.** `jean start` lance `jean serve` en arrière-plan (processus détaché), suivi via un fichier PID. `stop` / `restart` / `status` / `logs` agissent dessus — aucun droit administrateur requis. `enable` / `disable` (démarrage au boot) ne sont pas gérés : utilise une tâche planifiée ou `sc.exe` si tu en as besoin.
- **`JEAN_HOME`** vaut par défaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). Surcharge avec la variable d'environnement `JEAN_HOME`. - **`JEAN_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 ## 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*). 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 ### 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 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é 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) ### 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 ; - la gestion de **plusieurs serveurs** et d'**agents** depuis un tableau de bord ;
- en option, un **endpoint compatible OpenAI** (voir ci-dessous). - 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 ### 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. `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. - **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. - **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. - **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 #### 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.)* 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 ## 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 ```bash
git clone https://github.com/nathaninline/jean.git 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 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 ## Comment ça marche
Binary file not shown.
Binary file not shown.
+7 -7
View File
@@ -3,13 +3,13 @@
"FileVersion": { "FileVersion": {
"Major": 0, "Major": 0,
"Minor": 5, "Minor": 5,
"Patch": 9, "Patch": 10,
"Build": 0 "Build": 0
}, },
"ProductVersion": { "ProductVersion": {
"Major": 0, "Major": 0,
"Minor": 5, "Minor": 5,
"Patch": 9, "Patch": 10,
"Build": 0 "Build": 0
}, },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
@@ -19,13 +19,13 @@
"FileSubType": "00" "FileSubType": "00"
}, },
"StringFileInfo": { "StringFileInfo": {
"CompanyName": "Jean contributors", "CompanyName": "AJEAN contributors",
"FileDescription": "Jean — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés", "FileDescription": "AJEAN — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés",
"InternalName": "jean", "InternalName": "jean",
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "jean.exe", "OriginalFilename": "jean.exe",
"ProductName": "Jean", "ProductName": "AJEAN",
"ProductVersion": "0.5.9", "ProductVersion": "0.5.10",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
}, },
"VarFileInfo": { "VarFileInfo": {
+8 -2
View File
@@ -324,10 +324,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
case ev.Err != nil: case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()}) c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
case ev.ToolUsed != nil: 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, "name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing, "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: case ev.Stats != nil:
// Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur // Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
// et la décision de compactage au tour suivant. // 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 package jean
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -215,6 +216,10 @@ func MemAdd(name, content string) error {
// MemEdit remplace oldText par newText dans une page. oldText doit apparaître // MemEdit remplace oldText par newText dans une page. oldText doit apparaître
// EXACTEMENT une fois (sinon erreur), pour une édition sans ambiguïté. // 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 { func MemEdit(name, oldText, newText string) error {
p, err := safeMemPath(name) p, err := safeMemPath(name)
if err != nil { if err != nil {
@@ -230,6 +235,11 @@ func MemEdit(name, oldText, newText string) error {
return fmt.Errorf("old vide") return fmt.Errorf("old vide")
} }
if n == 0 { 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") return fmt.Errorf("old introuvable dans la page")
} }
if n > 1 { if n > 1 {
+5
View File
@@ -179,6 +179,11 @@ func fileEdit(path, oldText, newText string) string {
content := string(b) content := string(b)
n := strings.Count(content, oldText) n := strings.Count(content, oldText)
if n == 0 { 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" return "[erreur] old introuvable dans le fichier"
} }
if n > 1 { if n > 1 {
+2 -2
View File
@@ -35,7 +35,7 @@ func cmdApp(args []string) error {
if err != nil { if err != nil {
// Jean tourne déjà sur ce port : on ouvre juste l'UI sur l'instance // Jean tourne déjà sur ce port : on ouvre juste l'UI sur l'instance
// existante plutôt que d'échouer. // 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) return openBrowser(url)
} }
@@ -59,7 +59,7 @@ func cmdApp(args []string) error {
startAppLink(appWebMux) startAppLink(appWebMux)
}() }()
sp := showSplash("Lancement de Jean en cours…") sp := showSplash("Lancement d'AJEAN en cours…")
waitServerReady(url) waitServerReady(url)
_ = openBrowser(url) _ = openBrowser(url)
time.Sleep(900 * time.Millisecond) // laisse le navigateur s'afficher par-dessus le splash time.Sleep(900 * time.Millisecond) // laisse le navigateur s'afficher par-dessus le splash
+36 -2
View File
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -264,6 +265,9 @@ type ToolUsedEvent struct {
Result string // tool output (stdout/stderr/exit for run_shell, skill body for read_skill) 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 Done bool // false = call announced (command only); true = result is ready
Typing bool // true = command still being written (partial), no spinner yet 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 // 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. // several times, it's stuck — break instead of spinning to the iteration cap.
lastSig := "" lastSig := ""
repeatSig := 0 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 // 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 // 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 // (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}}) cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
result := "" 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 { switch tc.Function.Name {
case "mem_search": case "mem_search":
lim := 0 lim := 0
@@ -782,19 +805,27 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
result = "[erreur] " + werr.Error() result = "[erreur] " + werr.Error()
} else { } else {
result = fmt.Sprintf("[ok] page '%s' créée", label) result = fmt.Sprintf("[ok] page '%s' créée", label)
diff = addedDiff(content)
} }
case "mem_edit": case "mem_edit":
oldText, _ := args["old"].(string) oldText, _ := args["old"].(string)
newText, _ := args["new"].(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() result = "[erreur] " + werr.Error()
} else { } else {
result = fmt.Sprintf("[ok] page '%s' modifiée", label) result = fmt.Sprintf("[ok] page '%s' modifiée", label)
diff = lineDiff(oldText, newText)
} }
case "edit": case "edit":
oldText, _ := args["old"].(string) oldText, _ := args["old"].(string)
newText, _ := args["new"].(string) newText, _ := args["new"].(string)
result = fileEdit(label, oldText, newText) 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": case "bash":
to := 0 to := 0
switch v := args["timeout"].(type) { 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 result = "[erreur] outil inconnu: " + tc.Function.Name
} }
} }
if !strings.HasPrefix(result, "[erreur]") {
doneCalls[callKey] = result
}
shown := result shown := result
if r := []rune(shown); len(r) > 4000 { if r := []rune(shown); len(r) > 4000 {
shown = string(r[:4000]) + "\n…[tronqué]" 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} toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg) messages = append(messages, toolMsg)
extra = append(extra, 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>") return fmt.Errorf("aucun token. Usage: jean link <token>")
} }
fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL()) 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 != "" { if fp := e2eFingerprint(); fp != "" {
fmt.Printf("%s empreinte E2E : %s (code d'appairage : jean link code)\n", green("[e2e]"), bold(fp)) fmt.Printf("%s empreinte E2E : %s (code d'appairage : jean link code)\n", green("[e2e]"), bold(fp))
} }
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings" "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). // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() { func Main() {
+1 -1
View File
@@ -53,7 +53,7 @@ func appWarning() string {
if !strings.Contains(exe, "/AppTranslocation/") { if !strings.Contains(exe, "/AppTranslocation/") {
return "" 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 — " + "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." "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) cy, _, _ := pGetSystemMetrics.Call(smCY)
x := (int(cx) - splashW) / 2 x := (int(cx) - splashW) / 2
y := (int(cy) - splashH) / 2 y := (int(cy) - splashH) / 2
title, _ := syscall.UTF16PtrFromString("Jean") title, _ := syscall.UTF16PtrFromString("AJEAN")
hwnd, _, _ := pCreateWindowExW.Call( hwnd, _, _ := pCreateWindowExW.Call(
uintptr(exTopmost|exToolwindow), uintptr(exTopmost|exToolwindow),
@@ -240,7 +240,7 @@ func paintSplash(hdc uintptr) {
pSetTextColor.Call(hdc, colWhite) pSetTextColor.Call(hdc, colWhite)
textX := ox + logo + 26 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}) 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) { func runTray(url string) {
systray.Run(func() { systray.Run(func() {
systray.SetIcon(trayIconPNG()) systray.SetIcon(trayIconPNG())
systray.SetTooltip("Jean — votre IA locale") systray.SetTooltip("AJEAN — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir Jean", "Ouvrir l'interface") mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface")
systray.AddSeparator() systray.AddSeparator()
mQuit := systray.AddMenuItem("Quitter", "Arrêter Jean") mQuit := systray.AddMenuItem("Quitter", "Arrêter AJEAN")
go func() { go func() {
for { for {
+4 -4
View File
@@ -24,11 +24,11 @@ import (
func runTray(url string) { func runTray(url string) {
systray.Run(func() { systray.Run(func() {
systray.SetIcon(trayIcon()) systray.SetIcon(trayIcon())
systray.SetTitle("Jean") systray.SetTitle("AJEAN")
systray.SetTooltip("Jean — votre IA locale") systray.SetTooltip("AJEAN — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir Jean", "Ouvrir l'interface") mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface")
systray.AddSeparator() systray.AddSeparator()
mQuit := systray.AddMenuItem("Quitter", "Arrêter Jean") mQuit := systray.AddMenuItem("Quitter", "Arrêter AJEAN")
go func() { go func() {
for { for {
+1 -1
View File
@@ -368,7 +368,7 @@ func restartAfterUpdate() (bool, string) {
func restartHintText() string { func restartHintText() string {
if runtime.GOOS == "windows" { 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" 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="apple-mobile-web-app-capable" content="yes">
<meta name="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-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Jean"> <meta name="apple-mobile-web-app-title" content="AJEAN">
<title>Jean</title> <title>AJEAN</title>
<!-- Favicon (même marque « ajean » que la landing/portail) + icône PWA (écran d'accueil <!-- 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). 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. --> (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. */ reste (sélecteur, persistance, application) est automatique. */
/* --font : police par défaut (mono). Un thème peut la surcharger (voir "soft"). /* --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. */ 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="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} [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). */ /* "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, /* "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 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"). */ 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} *{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} 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 /* 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.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{align-self:flex-start;background:transparent;border:1px dashed var(--border);max-width:90%;white-space:normal;padding:8px 12px;font-size:12px;overflow-wrap:anywhere;word-break:break-word}
.msg.tool .tool-head{font-size:12px;color:var(--accent);font-weight:600;margin-bottom:6px} .msg.tool .tool-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 .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{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre} .msg.tool 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-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)} #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} #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)} #input:focus{outline:none;border-color:var(--accent)}
#send{padding:0 20px} #send{padding:0 20px}
.muted{color:var(--dim);font-size:12px} .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} .pe-row.stack.plain .pe-inp:focus{box-shadow:none}
/* Champ + bouton accolé (téléchargement) — même trait que les autres champs, /* Champ + bouton accolé (téléchargement) — même trait que les autres champs,
sans pastille colorée qui ressort. */ sans pastille colorée qui ressort. */
.pe-inpbtn{display:flex} .pe-inpbtn{display:flex;align-items:stretch}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0} .pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0;border-right: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} /* Le bouton reprend EXACTEMENT le cadre du champ (mêmes coins, même bordure) :
.pe-inpbtn button:hover{color:var(--accent);border-color:var(--accent)} 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} .pe-inpbtn button:disabled{opacity:.5;cursor:default}
/* Contrôle segmenté (moteur) façon iOS. */ /* 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%} .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"] #scrollbtn{bottom:180px;border-radius:999px}
[data-theme="gpt"] #compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px} [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"] #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 /* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
bordure, seule la saisie change (curseur). */ bordure, seule la saisie change (curseur). */
[data-theme="gpt"] #input:focus{border-color:transparent} [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. */ l'arrondi du pied de carte. */
[data-theme="gpt"] #ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)} [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"] #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. */ /* 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} [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,[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{background:transparent;border-color:transparent}
[data-theme="gpt"] .preset:hover{background:var(--accent-bg);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 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é, blocs pleins (pas de flou), fond quadrillé comme du papier millimétré,
boutons en capitales qui « s'enfoncent » au survol. Tout est préfixé 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, /* 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. */ 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. */ /* 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"] 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"] .side{background:var(--panel);border-right:2px solid var(--border)}
[data-theme="ajean"] #composer,[data-theme="ajean"] #chat{background:transparent} [data-theme^="ajean"] #composer,[data-theme^="ajean"] #chat{background:transparent}
/* Boutons : capitales, bordure épaisse, ombre en bloc qui disparaît au survol /* Boutons : capitales, bordure épaisse, ombre en bloc qui disparaît au survol
pendant que le bouton se décale — impression de touche enfoncée. */ 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,[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"] 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"] .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"] .copybtn{border:2px solid var(--border)}
[data-theme="ajean"] #send{background:var(--accent);border-color:var(--accent);color:#fff} [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"] #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 /* 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. */ 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.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.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.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 .label{color:var(--dim);letter-spacing:.14em}
[data-theme="ajean"] #chat .msg.user .label{color:rgba(255,255,255,.85)} [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.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"] .msg.typing span{background:var(--accent)}
/* Panneaux, encadrés et champs : mêmes bordures épaisses, mêmes ombres. */ /* 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,[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"] .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"] #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,[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"] #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"] #composer{border-top:2px solid var(--border);background:var(--panel)}
/* Jauge de contexte : un vrai bandeau plein, pas un filet dégradé. */ /* Jauge de contexte : un vrai bandeau plein, pas un filet dégradé. */
[data-theme="ajean"] #ctxtrack{height:5px;background:var(--grid)} [data-theme^="ajean"] #ctxtrack{height:5px;background:var(--grid)}
[data-theme="ajean"] #ctx-fill{background:var(--accent)} [data-theme^="ajean"] #ctx-fill{background:var(--accent)}
/* Fenêtres modales / info-bulles / toast : cadre net + ombre en bloc bleutée. */ /* 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"] .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)} [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. */ /* Interrupteurs : carrés, bleus quand ils sont actifs. */
[data-theme="ajean"] .switch .slider{background:var(--line2)} [data-theme^="ajean"] .switch .slider{background:var(--line2)}
[data-theme="ajean"] .switch input:checked + .slider{background:var(--accent)} [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 /* 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. */ 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 .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:#fff;background:var(--panel)} [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. */ /* É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> </style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button> <button id="menubtn" onclick="toggleSide()">☰</button>
<div id="backdrop" onclick="toggleSide()"></div> <div id="backdrop" onclick="toggleSide()"></div>
@@ -655,7 +750,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div id="presets"></div> <div id="presets"></div>
</details> </details>
<details><summary>Mode agent <span id="agent-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary> <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). --> <!-- 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 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> <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 id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div> </div>
<div id="mcp-block" class="gated-block"> <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> <div id="mcp-list" class="mcp-list"></div>
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button> <button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
</div> </div>
@@ -770,17 +865,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div id="lc-status" class="lc-head muted">…</div> <div id="lc-status" class="lc-head muted">…</div>
<div class="lc-modes"> <div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')"> <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-d">prête à l'emploi, installée en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div> <div class="lc-mode-state" id="lc-fast-state"></div>
</div> </div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')"> <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-d">compilée pour votre machine, plus longue</div>
<div class="lc-mode-state" id="lc-opt-state"></div> <div class="lc-mode-state" id="lc-opt-state"></div>
</div> </div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()"> <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-d">forks llama.cpp pour modèles spéciaux (ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div> <div class="lc-mode-state" id="lc-custom-state"></div>
</div> </div>
@@ -804,7 +899,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div class="row" style="margin-top:6px"> <div class="row" style="margin-top:6px">
<button onclick="loadAll()">↻ refresh</button> <button onclick="loadAll()">↻ refresh</button>
<button onclick="resetChat()">⌫ clear chat</button> <button onclick="resetChat()">⌫ clear chat</button>
<button id="btn-bench" onclick="runBenchUI()">⚡ bench</button> <button id="btn-bench" onclick="runBenchUI()">bench</button>
</div> </div>
</details> </details>
</div> </div>
@@ -822,18 +917,18 @@ button:disabled{opacity:.5;cursor:not-allowed}
</span> </span>
</div> </div>
<div id="inputbar"> <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="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> <button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
</div> </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> </div>
<div id="toast"></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 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="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"> <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> <button onclick="closeBenchModal()" style="margin:0">×</button>
</div> </div>
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></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 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-card" style="max-width:560px">
<div class="modal-head"> <div class="modal-head">
<strong>🧩 Backends personnalisés</strong> <strong>Backends personnalisés</strong>
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button> <button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button>
</div> </div>
<div class="modal-body mform"> <div class="modal-body mform">
@@ -898,8 +993,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div class="fld"> <div class="fld">
<label class="fld-l">Transport</label> <label class="fld-l">Transport</label>
<div class="seg"> <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="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="http" onchange="mcpTransportUI('http')"><span>http (distant)</span></label>
</div> </div>
</div> </div>
<div id="mcp-stdio-fields"> <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> <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"> <div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf"> <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>
<div id="m-hf-progress" class="pe-note" style="display:none"></div> <div id="m-hf-progress" class="pe-note" style="display:none"></div>
</div> </div>
@@ -1120,7 +1215,7 @@ async function loadSys(){
// Registre des thèmes disponibles. Pour en AJOUTER un : ajouter un bloc // 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 // [data-theme="…"] dans le <style> puis une entrée {id,label} ici — le reste
// (menu déroulant, persistance localStorage, application) est automatique. // (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. // Anciens identifiants (v0.5.7, thème livré en deux variantes) → thème unique.
const THEME_ALIASES={'gpt-light':'gpt','gpt-dark':'gpt'}; 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 // 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; let r;
try{ r = await fetch(u, opts); } try{ r = await fetch(u, opts); }
catch(e){ 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; throw e;
} }
finally{ if(timer) clearTimeout(timer); } finally{ if(timer) clearTimeout(timer); }
@@ -1323,7 +1418,7 @@ async function checkUpdate(){
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. '; msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate; const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
msg.appendChild(btn); msg.appendChild(btn);
} else { msg.textContent='Jean est à jour ✓'; } } else { msg.textContent='AJEAN est à jour ✓'; }
}catch(e){ msg.textContent='Erreur réseau'; } }catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false; b.disabled=false;
} }
@@ -1385,8 +1480,8 @@ async function loadCfg(){
// Moteur : rapide / optimisé / custom (avec le chemin). Le title garde // Moteur : rapide / optimisé / custom (avec le chemin). Le title garde
// toujours le chemin complet, quel que soit le libellé. // toujours le chemin complet, quel que soit le libellé.
let v; let v;
if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide'; if(lc && lc.prebuilt && lc.prebuilt.in_use) v='rapide';
else if(lc && lc.in_use) v='🔧 optimisé'; else if(lc && lc.in_use) v='optimisé';
else v='custom : '+c.BIN; else v='custom : '+c.BIN;
rows.push(row('MOTEUR', v, c.BIN)); rows.push(row('MOTEUR', v, c.BIN));
} }
@@ -1551,7 +1646,7 @@ function renderInternet(s){
const st=document.getElementById('internet-status'); const st=document.getElementById('internet-status');
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } 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 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')); } async function loadInternet(){ renderInternet(await jget('/api/internet')); }
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) ----------------- // --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
@@ -1669,7 +1764,7 @@ async function runBenchUI(){
'</div>' + '</div>' +
'<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>'; '<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>';
} finally { } finally {
btn.disabled = false; btn.textContent = '⚡ bench'; btn.disabled = false; btn.textContent = 'bench';
rerun.disabled = false; rerun.disabled = false;
loadPresets(); loadPresets();
} }
@@ -1952,7 +2047,7 @@ async function delItem(){
const name = document.getElementById('m-name').value.trim() || editingKey; const name = document.getElementById('m-name').value.trim() || editingKey;
const delModel = editingKind==='preset' && document.getElementById('m-del-model').checked; const delModel = editingKind==='preset' && document.getElementById('m-del-model').checked;
let msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?'; 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; if(!await askConfirm(msg, {title:'Suppression', okText:'Supprimer', danger:true})) return;
const payload = editingKind==='preset' const payload = editingKind==='preset'
? {id: editingKey, deleteModel: delModel} ? {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 pct = st.total>0 ? Math.round(st.done*100/st.total) : 0;
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : ''; const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : '';
prog.textContent = '⬇ '+fmtSize(st.done)+tot+' — '+fname; prog.textContent = '↓ '+fmtSize(st.done)+tot+' — '+fname;
}, 800); }, 800);
} }
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled // 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 // 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. // wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){ function renderToolMsg(el, tu){
// Métadonnées d'affichage par outil : nom court + en-tête avec icône. Les outils // Métadonnées d'affichage par outil : nom court + en-tête. Les outils web
// web (web_search/open/read/grep) doivent afficher 🌐, pas le fallback mémoire. // (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire.
const META = { const META = {
bash: {lbl:'terminal', head:'⚙️ commande'}, bash: {lbl:'terminal', head:'commande'},
edit: {lbl:'édition', head:'✏️ édition'}, edit: {lbl:'édition', head:'édition'},
web_search: {lbl:'recherche', head:'🌐 recherche web'}, web_search: {lbl:'recherche', head:'recherche web'},
web_open: {lbl:'page web', head:'🌐 ouverture'}, web_open: {lbl:'page web', head:'ouverture'},
web_read: {lbl:'page web', head:'🌐 lecture'}, web_read: {lbl:'page web', head:'lecture'},
web_grep: {lbl:'page web', head:'🌐 recherche'}, 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é. // pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
let meta = META[tu.name]; let meta = META[tu.name];
if(!meta && tu.name && tu.name.indexOf('mcp__')===0){ if(!meta && tu.name && tu.name.indexOf('mcp__')===0){
const parts = tu.name.slice(5).split('__'); const parts = tu.name.slice(5).split('__');
const server = parts.shift() || 'mcp'; const server = parts.shift() || 'mcp';
const tool = parts.join('__') || tu.name; 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; let lbl = meta.lbl;
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). // 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'; } 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); } if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
pre.appendChild(code); body.appendChild(pre); 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!==''; const hasResult = tu.result!==undefined && tu.result!=='';
if(hasResult){ if(hasResult){
const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse'; const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
@@ -2152,6 +2267,8 @@ function renderToolMsg(el, tu){
function addCopyButtons(root){ function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{ root.querySelectorAll('pre').forEach(pre=>{
if(pre.querySelector('.copybtn')) return; 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'); const btn=document.createElement('button');
btn.className='copybtn'; btn.type='button'; btn.textContent='copier'; btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
btn.onclick=async(e)=>{ btn.onclick=async(e)=>{
@@ -2208,6 +2325,17 @@ function restoreChat(){
connectStream(); connectStream();
} }
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } } 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) ===== // ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
// L'historique et la génération vivent sur le serveur jean. Le client ouvre un // L'historique et la génération vivent sur le serveur jean. Le client ouvre un
// flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis // flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis
@@ -2362,13 +2490,13 @@ async function send(){
if(busy) return; if(busy) return;
const ta=document.getElementById('input'); const text=ta.value.trim(); const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return; if(!text) return;
ta.value=''; ta.value=''; autoGrow(ta);
for(let attempt=0; attempt<3; attempt++){ for(let attempt=0; attempt<3; attempt++){
try{ try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})}); 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.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.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 */ } }catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600)); await new Promise(res=>setTimeout(res, 600));
} }
@@ -2381,8 +2509,8 @@ setInterval(loadStatus, 5000);
setInterval(loadVram, 3000); setInterval(loadVram, 3000);
setInterval(loadRam, 3000); setInterval(loadRam, 3000);
// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs. // Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
// ⚡ Version rapide = binaires officiels précompilés (aucune compilation) // « Version rapide » = binaires officiels précompilés (aucune compilation)
// 🔧 Version optimisée = compilée pour la machine // « Version optimisée » = compilée pour la machine
// Le CHOIX du moteur utilisé se fait par modèle (preset), pas ici — voir la // 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 // section « Moteur » dans l'éditeur de modèle. Ça évite que la barre latérale
// et les presets se battent pour la ligne BIN. // et les presets se battent pour la ligne BIN.
@@ -2419,7 +2547,7 @@ function lcRenderMode(mode, installed){
card.classList.toggle('installed', installed); card.classList.toggle('installed', installed);
// Lien de vérification : interroge la dernière version SANS rien installer. // 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). // 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){ if(installed){
state.innerHTML = '<span class="lc-mode-active-tag">✓ installée</span>' state.innerHTML = '<span class="lc-mode-active-tag">✓ installée</span>'
+ '<span class="lc-update-link" onclick="event.stopPropagation();lcUpdate(\''+mode+'\')">↻ mettre à jour</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') : ''; 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'); const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = ''; 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)'; } 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'; document.getElementById('mcp-del').style.display = s ? '' : 'none';
renderMcpTools(s); renderMcpTools(s);
@@ -2777,11 +2905,11 @@ async function saveMcp(){
const st = document.getElementById('mcp-modal-status'); const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = ''; st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body); 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); renderMCP(r);
const saved = (r.servers||[]).find(x=>x.name===name); 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(); } 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(); } else { toast('enregistré'); closeMcp(); }
} }
+15 -15
View File
@@ -7,8 +7,8 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="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-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Jean"> <meta name="apple-mobile-web-app-title" content="AJEAN">
<title>Jean</title> <title>AJEAN</title>
<!-- Favicon (même marque « ajean » que la landing/portail) + icône PWA (écran d'accueil <!-- 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). 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. --> (iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
@@ -51,7 +51,7 @@
<div id="presets"></div> <div id="presets"></div>
</details> </details>
<details><summary>Mode agent <span id="agent-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary> <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). --> <!-- 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 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> <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 id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div> </div>
<div id="mcp-block" class="gated-block"> <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> <div id="mcp-list" class="mcp-list"></div>
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button> <button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
</div> </div>
@@ -166,17 +166,17 @@
<div id="lc-status" class="lc-head muted">…</div> <div id="lc-status" class="lc-head muted">…</div>
<div class="lc-modes"> <div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')"> <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-d">prête à l'emploi, installée en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div> <div class="lc-mode-state" id="lc-fast-state"></div>
</div> </div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')"> <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-d">compilée pour votre machine, plus longue</div>
<div class="lc-mode-state" id="lc-opt-state"></div> <div class="lc-mode-state" id="lc-opt-state"></div>
</div> </div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()"> <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-d">forks llama.cpp pour modèles spéciaux (ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div> <div class="lc-mode-state" id="lc-custom-state"></div>
</div> </div>
@@ -200,7 +200,7 @@
<div class="row" style="margin-top:6px"> <div class="row" style="margin-top:6px">
<button onclick="loadAll()">↻ refresh</button> <button onclick="loadAll()">↻ refresh</button>
<button onclick="resetChat()">⌫ clear chat</button> <button onclick="resetChat()">⌫ clear chat</button>
<button id="btn-bench" onclick="runBenchUI()">⚡ bench</button> <button id="btn-bench" onclick="runBenchUI()">bench</button>
</div> </div>
</details> </details>
</div> </div>
@@ -218,18 +218,18 @@
</span> </span>
</div> </div>
<div id="inputbar"> <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="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> <button id="stop" onclick="stopGen()" style="display:none;background:var(--danger-bg);border-color:var(--err);color:var(--err)">stop</button>
</div> </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> </div>
<div id="toast"></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 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="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"> <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> <button onclick="closeBenchModal()" style="margin:0">×</button>
</div> </div>
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></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 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-card" style="max-width:560px">
<div class="modal-head"> <div class="modal-head">
<strong>🧩 Backends personnalisés</strong> <strong>Backends personnalisés</strong>
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button> <button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button>
</div> </div>
<div class="modal-body mform"> <div class="modal-body mform">
@@ -294,8 +294,8 @@
<div class="fld"> <div class="fld">
<label class="fld-l">Transport</label> <label class="fld-l">Transport</label>
<div class="seg"> <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="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="http" onchange="mcpTransportUI('http')"><span>http (distant)</span></label>
</div> </div>
</div> </div>
<div id="mcp-stdio-fields"> <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> <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"> <div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf"> <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>
<div id="m-hf-progress" class="pe-note" style="display:none"></div> <div id="m-hf-progress" class="pe-note" style="display:none"></div>
</div> </div>
+1 -1
View File
@@ -2,7 +2,7 @@
// Registre des thèmes disponibles. Pour en AJOUTER un : ajouter un bloc // 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 // [data-theme="…"] dans le <style> puis une entrée {id,label} ici — le reste
// (menu déroulant, persistance localStorage, application) est automatique. // (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. // Anciens identifiants (v0.5.7, thème livré en deux variantes) → thème unique.
const THEME_ALIASES={'gpt-light':'gpt','gpt-dark':'gpt'}; 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 // 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; let r;
try{ r = await fetch(u, opts); } try{ r = await fetch(u, opts); }
catch(e){ 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; throw e;
} }
finally{ if(timer) clearTimeout(timer); } 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. '; msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate; const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
msg.appendChild(btn); msg.appendChild(btn);
} else { msg.textContent='Jean est à jour ✓'; } } else { msg.textContent='AJEAN est à jour ✓'; }
}catch(e){ msg.textContent='Erreur réseau'; } }catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false; b.disabled=false;
} }
@@ -119,8 +119,8 @@ async function loadCfg(){
// Moteur : rapide / optimisé / custom (avec le chemin). Le title garde // Moteur : rapide / optimisé / custom (avec le chemin). Le title garde
// toujours le chemin complet, quel que soit le libellé. // toujours le chemin complet, quel que soit le libellé.
let v; let v;
if(lc && lc.prebuilt && lc.prebuilt.in_use) v='⚡ rapide'; if(lc && lc.prebuilt && lc.prebuilt.in_use) v='rapide';
else if(lc && lc.in_use) v='🔧 optimisé'; else if(lc && lc.in_use) v='optimisé';
else v='custom : '+c.BIN; else v='custom : '+c.BIN;
rows.push(row('MOTEUR', v, 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'); const st=document.getElementById('internet-status');
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } 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 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')); } async function loadInternet(){ renderInternet(await jget('/api/internet')); }
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) ----------------- // --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
+3 -3
View File
@@ -36,7 +36,7 @@ async function runBenchUI(){
'</div>' + '</div>' +
'<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>'; '<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>';
} finally { } finally {
btn.disabled = false; btn.textContent = '⚡ bench'; btn.disabled = false; btn.textContent = 'bench';
rerun.disabled = false; rerun.disabled = false;
loadPresets(); loadPresets();
} }
@@ -319,7 +319,7 @@ async function delItem(){
const name = document.getElementById('m-name').value.trim() || editingKey; const name = document.getElementById('m-name').value.trim() || editingKey;
const delModel = editingKind==='preset' && document.getElementById('m-del-model').checked; const delModel = editingKind==='preset' && document.getElementById('m-del-model').checked;
let msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?'; 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; if(!await askConfirm(msg, {title:'Suppression', okText:'Supprimer', danger:true})) return;
const payload = editingKind==='preset' const payload = editingKind==='preset'
? {id: editingKey, deleteModel: delModel} ? {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 pct = st.total>0 ? Math.round(st.done*100/st.total) : 0;
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : ''; const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : '';
prog.textContent = '⬇ '+fmtSize(st.done)+tot+' — '+fname; prog.textContent = '↓ '+fmtSize(st.done)+tot+' — '+fname;
}, 800); }, 800);
} }
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled // 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 // 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. // wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){ function renderToolMsg(el, tu){
// Métadonnées d'affichage par outil : nom court + en-tête avec icône. Les outils // Métadonnées d'affichage par outil : nom court + en-tête. Les outils web
// web (web_search/open/read/grep) doivent afficher 🌐, pas le fallback mémoire. // (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire.
const META = { const META = {
bash: {lbl:'terminal', head:'⚙️ commande'}, bash: {lbl:'terminal', head:'commande'},
edit: {lbl:'édition', head:'✏️ édition'}, edit: {lbl:'édition', head:'édition'},
web_search: {lbl:'recherche', head:'🌐 recherche web'}, web_search: {lbl:'recherche', head:'recherche web'},
web_open: {lbl:'page web', head:'🌐 ouverture'}, web_open: {lbl:'page web', head:'ouverture'},
web_read: {lbl:'page web', head:'🌐 lecture'}, web_read: {lbl:'page web', head:'lecture'},
web_grep: {lbl:'page web', head:'🌐 recherche'}, 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é. // pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
let meta = META[tu.name]; let meta = META[tu.name];
if(!meta && tu.name && tu.name.indexOf('mcp__')===0){ if(!meta && tu.name && tu.name.indexOf('mcp__')===0){
const parts = tu.name.slice(5).split('__'); const parts = tu.name.slice(5).split('__');
const server = parts.shift() || 'mcp'; const server = parts.shift() || 'mcp';
const tool = parts.join('__') || tu.name; 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; let lbl = meta.lbl;
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). // 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'; } 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); } if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
pre.appendChild(code); body.appendChild(pre); 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!==''; const hasResult = tu.result!==undefined && tu.result!=='';
if(hasResult){ if(hasResult){
const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse'; const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
@@ -148,6 +168,8 @@ function renderToolMsg(el, tu){
function addCopyButtons(root){ function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{ root.querySelectorAll('pre').forEach(pre=>{
if(pre.querySelector('.copybtn')) return; 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'); const btn=document.createElement('button');
btn.className='copybtn'; btn.type='button'; btn.textContent='copier'; btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
btn.onclick=async(e)=>{ btn.onclick=async(e)=>{
@@ -204,3 +226,14 @@ function restoreChat(){
connectStream(); connectStream();
} }
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } } 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; if(busy) return;
const ta=document.getElementById('input'); const text=ta.value.trim(); const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return; if(!text) return;
ta.value=''; ta.value=''; autoGrow(ta);
for(let attempt=0; attempt<3; attempt++){ for(let attempt=0; attempt<3; attempt++){
try{ try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})}); 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.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.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 */ } }catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600)); 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. // Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
// ⚡ Version rapide = binaires officiels précompilés (aucune compilation) // « Version rapide » = binaires officiels précompilés (aucune compilation)
// 🔧 Version optimisée = compilée pour la machine // « Version optimisée » = compilée pour la machine
// Le CHOIX du moteur utilisé se fait par modèle (preset), pas ici — voir la // 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 // section « Moteur » dans l'éditeur de modèle. Ça évite que la barre latérale
// et les presets se battent pour la ligne BIN. // et les presets se battent pour la ligne BIN.
@@ -37,7 +37,7 @@ function lcRenderMode(mode, installed){
card.classList.toggle('installed', installed); card.classList.toggle('installed', installed);
// Lien de vérification : interroge la dernière version SANS rien installer. // 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). // 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){ if(installed){
state.innerHTML = '<span class="lc-mode-active-tag">✓ installée</span>' state.innerHTML = '<span class="lc-mode-active-tag">✓ installée</span>'
+ '<span class="lc-update-link" onclick="event.stopPropagation();lcUpdate(\''+mode+'\')">↻ mettre à jour</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') : ''; 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'); const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = ''; 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)'; } 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'; document.getElementById('mcp-del').style.display = s ? '' : 'none';
renderMcpTools(s); renderMcpTools(s);
@@ -174,11 +174,11 @@ async function saveMcp(){
const st = document.getElementById('mcp-modal-status'); const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = ''; st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body); 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); renderMCP(r);
const saved = (r.servers||[]).find(x=>x.name===name); 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(); } 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(); } else { toast('enregistré'); closeMcp(); }
} }
+136 -41
View File
@@ -5,7 +5,7 @@
reste (sélecteur, persistance, application) est automatique. */ reste (sélecteur, persistance, application) est automatique. */
/* --font : police par défaut (mono). Un thème peut la surcharger (voir "soft"). /* --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. */ 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="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} [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). */ /* "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, /* "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 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"). */ 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} *{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} 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 /* 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.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{align-self:flex-start;background:transparent;border:1px dashed var(--border);max-width:90%;white-space:normal;padding:8px 12px;font-size:12px;overflow-wrap:anywhere;word-break:break-word}
.msg.tool .tool-head{font-size:12px;color:var(--accent);font-weight:600;margin-bottom:6px} .msg.tool .tool-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 .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{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre} .msg.tool 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-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)} #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} #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)} #input:focus{outline:none;border-color:var(--accent)}
#send{padding:0 20px} #send{padding:0 20px}
.muted{color:var(--dim);font-size:12px} .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} .pe-row.stack.plain .pe-inp:focus{box-shadow:none}
/* Champ + bouton accolé (téléchargement) — même trait que les autres champs, /* Champ + bouton accolé (téléchargement) — même trait que les autres champs,
sans pastille colorée qui ressort. */ sans pastille colorée qui ressort. */
.pe-inpbtn{display:flex} .pe-inpbtn{display:flex;align-items:stretch}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0} .pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0;border-right: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} /* Le bouton reprend EXACTEMENT le cadre du champ (mêmes coins, même bordure) :
.pe-inpbtn button:hover{color:var(--accent);border-color:var(--accent)} 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} .pe-inpbtn button:disabled{opacity:.5;cursor:default}
/* Contrôle segmenté (moteur) façon iOS. */ /* 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%} .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"] #scrollbtn{bottom:180px;border-radius:999px}
[data-theme="gpt"] #compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px} [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"] #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 /* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
bordure, seule la saisie change (curseur). */ bordure, seule la saisie change (curseur). */
[data-theme="gpt"] #input:focus{border-color:transparent} [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. */ l'arrondi du pied de carte. */
[data-theme="gpt"] #ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)} [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"] #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. */ /* 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} [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,[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{background:transparent;border-color:transparent}
[data-theme="gpt"] .preset:hover{background:var(--accent-bg);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 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é, blocs pleins (pas de flou), fond quadrillé comme du papier millimétré,
boutons en capitales qui « s'enfoncent » au survol. Tout est préfixé 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, /* 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. */ 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. */ /* 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"] 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"] .side{background:var(--panel);border-right:2px solid var(--border)}
[data-theme="ajean"] #composer,[data-theme="ajean"] #chat{background:transparent} [data-theme^="ajean"] #composer,[data-theme^="ajean"] #chat{background:transparent}
/* Boutons : capitales, bordure épaisse, ombre en bloc qui disparaît au survol /* Boutons : capitales, bordure épaisse, ombre en bloc qui disparaît au survol
pendant que le bouton se décale — impression de touche enfoncée. */ 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,[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"] 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"] .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"] .copybtn{border:2px solid var(--border)}
[data-theme="ajean"] #send{background:var(--accent);border-color:var(--accent);color:#fff} [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"] #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 /* 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. */ 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.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.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.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 .label{color:var(--dim);letter-spacing:.14em}
[data-theme="ajean"] #chat .msg.user .label{color:rgba(255,255,255,.85)} [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.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"] .msg.typing span{background:var(--accent)}
/* Panneaux, encadrés et champs : mêmes bordures épaisses, mêmes ombres. */ /* 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,[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"] .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"] #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,[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"] #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"] #composer{border-top:2px solid var(--border);background:var(--panel)}
/* Jauge de contexte : un vrai bandeau plein, pas un filet dégradé. */ /* Jauge de contexte : un vrai bandeau plein, pas un filet dégradé. */
[data-theme="ajean"] #ctxtrack{height:5px;background:var(--grid)} [data-theme^="ajean"] #ctxtrack{height:5px;background:var(--grid)}
[data-theme="ajean"] #ctx-fill{background:var(--accent)} [data-theme^="ajean"] #ctx-fill{background:var(--accent)}
/* Fenêtres modales / info-bulles / toast : cadre net + ombre en bloc bleutée. */ /* 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"] .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)} [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. */ /* Interrupteurs : carrés, bleus quand ils sont actifs. */
[data-theme="ajean"] .switch .slider{background:var(--line2)} [data-theme^="ajean"] .switch .slider{background:var(--line2)}
[data-theme="ajean"] .switch input:checked + .slider{background:var(--accent)} [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 /* 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. */ 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 .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:#fff;background:var(--panel)} [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. */ /* É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 // listé ICI : sinon POST /api/prefs le rejette silencieusement, le serveur
// conserve l'ancienne valeur et loadPrefs() la réapplique à chaque // conserve l'ancienne valeur et loadPrefs() la réapplique à chaque
// rafraîchissement — le thème « saute » alors qu'il est bien en localStorage. // 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}, "display": {"full": true, "simple": true},
} }