v0.5.11 : themes clair/sombre par famille + police reglable + affichage a la carte + cle API Crawl4AI

- Apparence repensee : la liste ne montre plus que les FAMILLES de theme
  (Classique, AJEAN, Doux, ChatGPT) avec un interrupteur clair/sombre, au lieu
  d'une entree par variante. Nouveau theme « ChatGPT sombre » (fond quasi noir).
- Le theme par defaut redevient « clair ».
- Nouveau reglage « police », independant du theme : du theme / Mono / Systeme /
  Lexend / Serif (Lexend n'est telechargee que si elle est choisie).
- Le mode « complet / simple » laisse place a quatre interrupteurs : masquer le
  raisonnement, masquer les appels d'outils, garder les bulles repliees, barre
  laterale escamotable (tiroir meme sur ordinateur). L'ancien reglage est repris.
- Cle API du serveur Crawl4AI reglable depuis l'UI (et `jean internet key`) :
  envoyee en Authorization: Bearer sur toutes les requetes, jamais relue par
  l'API (indice sur 4 caracteres), preservee au changement de preset.
- Mise en page : colonne de lecture bornee a 900 px en mode escamotable, marges
  d'origine sinon ; tiroir de meme largeur que la barre fixe ; bouton ☰ qui ne
  recouvre plus le fil ; logo et numero de version recales.
This commit is contained in:
nathaninline committed 2026-07-28 11:21:44 +02:00
1 parent e2475c6688
commit dbd4a6a570
16 files changed
+563 -197

No files matched your search

Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 5,
"Patch": 10,
"Patch": 11,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 5,
"Patch": 10,
"Patch": 11,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.5.10",
"ProductVersion": "0.5.11",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+1 -1
View File
@@ -201,7 +201,7 @@ func safePresetPath(name string) (string, error) {
// paramètres de modèle) qui doivent survivre à un changement de preset. Sans ça,
// écraser config.env avec le preset ré-imposerait le mode mémoire et effacerait
// l'URL du serveur internet à chaque bascule — ce qui obligeait à tout remettre.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL"}
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "CRAWL4AI_KEY"}
// SwitchToPreset backs up the current config and copies the target into place,
// then restarts the service. Les réglages « appareil » (preservedKeys) sont
+15
View File
@@ -40,6 +40,19 @@ func crawl4aiURL() string {
return strings.TrimRight(u, "/")
}
// crawl4aiKey renvoie la clé d'accès au serveur Crawl4AI (config.env
// CRAWL4AI_KEY), vide si le serveur est ouvert. Envoyée en « Authorization:
// Bearer … », ce qu'attend Crawl4AI quand l'authentification est activée.
func crawl4aiKey() string { return strings.TrimSpace(ReadConfig()["CRAWL4AI_KEY"]) }
// crawlAuth pose l'en-tête d'authentification sur une requête vers Crawl4AI.
// Sans clé configurée, la requête part telle quelle.
func crawlAuth(req *http.Request) {
if k := crawl4aiKey(); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
}
// internetEnabled : accès internet actif = drapeau .internet_enabled présent ET
// une URL Crawl4AI configurée. Même modèle que agentEnabled() (chat_agent.go).
func internetEnabled() bool {
@@ -96,6 +109,7 @@ func crawlReachable() bool {
if err != nil {
continue
}
crawlAuth(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
continue
@@ -193,6 +207,7 @@ func runCrwl(target string, opts crwlOptions) (string, error) {
return "", err
}
req.Header.Set("Content-Type", "application/json")
crawlAuth(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("Crawl4AI injoignable (%s): %v", base, err)
+16
View File
@@ -284,6 +284,22 @@ func cmdInternet(args []string) error {
reachURL = "" // invalide le cache de reachability
reachMu.Unlock()
fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u))
case "key":
if len(args) < 2 {
return fmt.Errorf("usage: jean internet key <clé> (vide pour l'enlever : jean internet key \"\")")
}
k := strings.TrimSpace(args[1])
if err := SetConfigKey("CRAWL4AI_KEY", k); err != nil {
return err
}
reachMu.Lock()
reachURL = ""
reachMu.Unlock()
if k == "" {
fmt.Println(green("[ok]") + " clé Crawl4AI retirée")
} else {
fmt.Println(green("[ok]") + " clé Crawl4AI enregistrée")
}
case "", "status", "list":
state := dim("off")
if internetEnabled() {
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.5.10"
const Version = "0.5.11"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() {
@@ -130,7 +130,7 @@ Presets:
Interaction:
chat [system-prompt] chat terminal streamé
web [PORT] UI web (défaut :8090) — chat + presets + mode agent
internet [on|off|status|url <url>] accès web de l'IA via un serveur Crawl4AI (web_search/open/read/grep)
internet [on|off|status|url <url>|key <clé>] accès web de l'IA via un serveur Crawl4AI (web_search/open/read/grep)
memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto)
Accès distant (ajean.link) :
+247 -94
View File
@@ -15,7 +15,7 @@
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%231f6feb'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABa0lEQVR42u3dsQ2AIBBAUSCOYA8b4iiMSI9D2FpYGTEheW8AisvPUV7MdQR4kowAcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+IAcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+JAHCAOxMEHNiO4622f9HI5TpsD3wriQBwgDsSBOBAH4kAciANxIA7EAeJAHIgDcSAOxIE4EAfiQBwgDsSBOBAH4kAciANxIA7EgThAHIgDcSAOxIE4EAfiQByIA8SBOBAH4kAciANxIA7EgThAHIgDcSAO/rLkLXsX520OxIE4EAfiQByIA8SBOBAH4kAciANxIA7EgThAHIgDcSAOxIE4EAfiQByIA8SBOHgl5jpMAZsDcSAOxIE4EAfiQByIA3GAOBAH4kAciANxIA7EgTgQB4gDcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+IAcSAOxME0F8YvCl24O02KAAAAAElFTkSuQmCC">
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
<script>try{document.documentElement.setAttribute('data-theme',localStorage.getItem('jean-theme')||'ajean');document.documentElement.setAttribute('data-display',localStorage.getItem('jean-display')||'full')}catch(e){document.documentElement.setAttribute('data-theme','ajean');document.documentElement.setAttribute('data-display','full')}</script>
<script>try{document.documentElement.setAttribute('data-theme',localStorage.getItem('jean-theme')||'light');['hide-reasoning','hide-tools','fold-tools','hide-side'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')});document.documentElement.setAttribute('data-font',localStorage.getItem('jean-font')||'auto')}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style>
/* ===== Thèmes =====================================================
Toutes les couleurs de l'UI passent par ces variables. Pour AJOUTER un
@@ -34,8 +34,11 @@
/* "ChatGPT" — monochrome (noir & blanc), police système, aucun accent coloré.
Seules les erreurs gardent un rouge discret pour rester lisibles. */
[data-theme="gpt"]{color-scheme:light;--font:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;--bubble:#e8e8e8;--bg:#ffffff;--panel:#f9f9f9;--border:#e3e3e3;--text:#0d0d0d;--dim:#8f8f8f;--accent:#0d0d0d;--accent-bg:rgba(0,0,0,.06);--ok:#0d0d0d;--warn:#5d5d5d;--err:#c0392b;--mag:#5d5d5d;--code-bg:#f4f4f4;--danger-bg:#fbeae8;--warn-bg:#f0f0f0}
/* "ChatGPT sombre" — même disposition, palette nuit de ChatGPT : gris très
sombre, bulle utilisateur gris clair, aucun accent coloré. */
[data-theme="gpt-dark"]{color-scheme:dark;--font:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;--bubble:#1f1f1f;--bg:#0a0a0a;--panel:#141414;--border:#262626;--text:#ececec;--dim:#8e8e8e;--accent:#ececec;--accent-bg:rgba(255,255,255,.07);--ok:#ececec;--warn:#c8b273;--err:#f2777a;--mag:#b0b0b0;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#26241a}
/* Bulle utilisateur claire : le texte blanc par défaut (.msg.user) serait illisible. */
[data-theme="gpt"] .msg.user{color:var(--text)}
[data-theme^="gpt"] .msg.user{color:var(--text)}
/* "AJEAN" — clair, bleu, esprit rétro-machine : bordures nettes de 2 px,
aucun arrondi, ombres en blocs décalés, fond quadrillé (même charte que
ajean.app). Mise en page complète plus bas (section "Thème pixel"). */
@@ -43,6 +46,15 @@
/* « 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}
/* ===== Police (réglage séparé du thème) ===========================
Chaque thème propose une police par défaut via --font ; ce réglage la
remplace. Même spécificité que les blocs de thème, mais écrit APRÈS : c'est
donc lui qui gagne. "auto" = on laisse la police du thème.
Lexend n'est chargée (Google Fonts) que si elle est effectivement choisie. */
[data-font="mono"]{--font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
[data-font="system"]{--font:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
[data-font="serif"]{--font:Georgia,'Iowan Old Style','Times New Roman',serif}
[data-font="lexend"]{--font:'Lexend',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}
*{box-sizing:border-box}
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;display:flex;font-size:14px}
/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne
@@ -64,7 +76,6 @@ body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-i
#chat .msg.assistant,#chat .msg.reasoning{max-width:100%}
#inputbar{padding:8px;gap:4px}
#send,#stop{padding:0 12px}
h1{margin-left:48px}
/* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et
ça prend de la place. Elle reste visible sur desktop. */
#sendhint{display:none}
@@ -81,7 +92,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
#brand{display:inline-flex;align-items:center}
#brand svg{height:10px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
#brand-word{display:inline-flex;color:var(--accent)}
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:7px;letter-spacing:0}
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:7px;letter-spacing:0;align-self:flex-end;line-height:1;position:relative;top:3px}
.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:18px}
/* pastille d'état service, en haut à droite de la barre latérale */
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
@@ -185,6 +196,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
.bar{height:6px;background:var(--panel);border-radius:3px;overflow:hidden;margin:2px 0}
.bar>div{height:100%;background:var(--accent)}
#chat{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:14px}
:root{--col:max(20px,calc(50% - 450px))}
@media (max-width:720px){#chat{padding:60px 8px 12px}}
.msg{max-width:80%;padding:10px 14px;border-radius:10px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
.msg.assistant,.msg.reasoning{white-space:normal}
.msg .body{overflow-wrap:anywhere;word-break:break-word}
@@ -245,17 +258,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
replie en douceur pour désencombrer le fil, tout en gardant le label (et son
compteur de tokens) cliquable pour redéplier. */
.msg.collapsible .label{cursor:pointer;user-select:none;margin-bottom:0}
/* ===== Affichage simple ==========================================
Mode "simple" (Apparence) : masque le raisonnement, les appels d'outils,
les détails de vitesse sur les bulles et la config active à gauche. Piloté
par [data-display="simple"] sur <html> (persisté dans 'jean-display'). */
/* En simple, raisonnement et outils sont masqués. L'activité est signalée par le
VRAI indicateur de frappe (.msg.typing, mêmes 3 points animés qu'avant la
réponse), maintenu en bas pendant tout le tour côté JS — animation identique. */
[data-display="simple"] #chat .msg.reasoning,
[data-display="simple"] #chat .msg.tool{display:none}
[data-display="simple"] #chat .msg .label{display:none}
[data-display="simple"] #cfg-details{display:none}
/* ===== Réglages d'affichage ======================================
Chaque option est un interrupteur indépendant (Apparence), porté par un
attribut sur <html> et persisté dans localStorage + /api/prefs :
data-hide-reasoning : masque les bulles de raisonnement
data-hide-tools : masque les bulles d'appel d'outil
data-fold-tools : les garde repliées (déplia­bles au clic)
data-hide-side : barre latérale en tiroir, même sur ordinateur
Quand raisonnement et outils sont masqués, l'activité reste signalée par
l'indicateur de frappe (.msg.typing) maintenu pendant tout le tour. */
[data-hide-reasoning="1"] #chat .msg.reasoning{display:none}
[data-hide-tools="1"] #chat .msg.tool{display:none}
/* Barre latérale en tiroir sur TOUTES les tailles d'écran : mêmes règles que
la version mobile, activées par l'attribut au lieu de la largeur. */
[data-hide-side="1"] .side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left));border-right:1px solid var(--border)}
@media (min-width:721px){[data-hide-side="1"] .side{width:380px;max-width:380px}}
/* Le bouton ☰ est posé au-dessus du fil : on dégage le haut de la
conversation pour qu'il ne recouvre aucun message. */
@media (min-width:721px){html[data-hide-side="1"] #chat{padding-top:60px}}
@media (min-width:721px){html[data-hide-side="1"]:not([data-theme^="gpt"]) #chat{padding-left:var(--col);padding-right:var(--col)}}
[data-hide-side="1"] .side.open{transform:translateX(0)}
[data-hide-side="1"] #menubtn{display:block}
[data-hide-side="1"] #backdrop.open{display:block}
[data-hide-side="1"] body.drawer-open #menubtn{display:none}
[data-hide-side="1"] .main{width:100%}
/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui
peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli.
width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0
@@ -522,30 +548,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
disposition de ChatGPT — colonne de lecture centrée, réponses SANS bulle
(texte nu sur le fond), question de l'utilisateur en pastille arrondie à
droite, composer en « pilule », pas d'étiquettes VOUS/JEAN, typo système
plus grande et plus aérée. Tout est préfixé [data-theme="gpt"] : aucun
plus grande et plus aérée. Tout est préfixé [data-theme^="gpt"] : aucun
autre thème n'est affecté. */
[data-theme="gpt"] body{font-size:15px}
[data-theme="gpt"] h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
[data-theme^="gpt"] body{font-size:15px}
[data-theme^="gpt"] h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
/* Barre latérale : plus sombre/atténuée que la zone de conversation. */
[data-theme="gpt"] .side{background:var(--panel);border-right-color:transparent}
@media (max-width:720px){[data-theme="gpt"] .side{background:var(--panel)}}
[data-theme^="gpt"] .side{background:var(--panel);border-right-color:transparent}
@media (max-width:720px){[data-theme^="gpt"] .side{background:var(--panel)}}
/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile. */
[data-theme="gpt"] #chat{padding:28px max(16px,calc(50% - 380px)) 8px;gap:22px}
[data-theme^="gpt"] #chat{padding:28px max(16px,calc(50% - 380px)) 8px;gap:22px;scrollbar-gutter:stable both-edges}
/* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */
[data-theme="gpt"] #chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7}
[data-theme^="gpt"] #chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7}
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
[data-theme="gpt"] #chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
[data-theme="gpt"] #chat .msg.user .label{color:var(--dim)}
[data-theme^="gpt"] #chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
[data-theme^="gpt"] #chat .msg.user .label{color:var(--dim)}
/* Étiquettes VOUS / JEAN : inutiles ici (l'alignement suffit), sauf pour les
bulles repliables (raisonnement / outils) où le label est le bouton de repli. */
[data-theme="gpt"] #chat .msg .label{display:none}
[data-theme="gpt"] #chat .msg.collapsible .label{display:block}
[data-theme^="gpt"] #chat .msg .label{display:none}
[data-theme^="gpt"] #chat .msg.collapsible .label{display:block}
/* Raisonnement / outils : encart gris discret plutôt qu'un cadre pointillé. */
[data-theme="gpt"] #chat .msg.reasoning,[data-theme="gpt"] #chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:14px;color:var(--dim);padding:12px 16px}
[data-theme="gpt"] #chat .msg.reasoning{color:var(--dim)}
[data-theme="gpt"] #chat .msg.tool .tool-head{color:var(--text)}
[data-theme="gpt"] #chat .msg.assistant .body pre,[data-theme="gpt"] #chat .msg.reasoning .body pre,[data-theme="gpt"] #chat .msg.tool pre{border-radius:12px;padding:12px 14px}
[data-theme="gpt"] #chat .msg.assistant .body a{text-decoration:underline;text-underline-offset:2px}
[data-theme^="gpt"] #chat .msg.reasoning,[data-theme^="gpt"] #chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:14px;color:var(--dim);padding:12px 16px}
[data-theme^="gpt"] #chat .msg.reasoning{color:var(--dim)}
[data-theme^="gpt"] #chat .msg.tool .tool-head{color:var(--text)}
[data-theme^="gpt"] #chat .msg.assistant .body pre,[data-theme^="gpt"] #chat .msg.reasoning .body pre,[data-theme^="gpt"] #chat .msg.tool pre{border-radius:12px;padding:12px 14px}
[data-theme^="gpt"] #chat .msg.assistant .body a{text-decoration:underline;text-underline-offset:2px}
/* Composer = UNE seule carte posée PAR-DESSUS le fil : la conversation
continue de défiler DERRIÈRE elle et s'efface en fondu (dégradé vers le fond)
au lieu de s'arrêter net au-dessus. Dans la carte, de haut en bas : la saisie
@@ -554,48 +580,48 @@ button:disabled{opacity:.5;cursor:not-allowed}
(overflow:hidden sur le rayon). Les trois blocs sont des frères dans le HTML :
on les recolle en dessinant les bordures par morceaux et l'ordre visuel vient
de l'ordre flex — aucun changement de markup. */
[data-theme="gpt"] #composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 8px;--col:max(16px,calc(50% - 380px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)}
[data-theme="gpt"] #chat{padding-bottom:170px}
[data-theme="gpt"] #scrollbtn{bottom:180px;border-radius:999px}
[data-theme="gpt"] #compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px}
[data-theme="gpt"] #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0}
[data-theme="gpt"] #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
[data-theme^="gpt"] #composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 8px;--col:max(16px,calc(50% - 380px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)}
[data-theme^="gpt"] #chat{padding-bottom:170px}
[data-theme^="gpt"] #scrollbtn{bottom:180px;border-radius:999px}
[data-theme^="gpt"] #compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px}
[data-theme^="gpt"] #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0}
[data-theme^="gpt"] #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
bordure, seule la saisie change (curseur). */
[data-theme="gpt"] #input:focus{border-color:transparent}
[data-theme="gpt"] #send,[data-theme="gpt"] #stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0}
[data-theme="gpt"] #send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600}
[data-theme="gpt"] #send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)}
[data-theme^="gpt"] #input:focus{border-color:transparent}
[data-theme^="gpt"] #send,[data-theme^="gpt"] #stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0}
[data-theme^="gpt"] #send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600}
[data-theme^="gpt"] #send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)}
/* Pied de carte : c'est LUI qui porte l'arrondi bas (26 px, comme en haut) et
qui clippe la jauge. Un bandeau de 7 px ne peut pas dessiner un rayon de 26 px
(le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte
découper la jauge dans sa propre forme — la barre est bien épaisse ET rognée
exactement sur la courbe, symétrique du haut. */
[data-theme="gpt"] #ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 26px 26px;overflow:hidden}
[data-theme="gpt"] #ctx-text,[data-theme="gpt"] #status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em}
[data-theme="gpt"] #ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)}
[data-theme="gpt"] #ctx-compact:hover{border-color:var(--dim);color:var(--text)}
[data-theme^="gpt"] #ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 26px 26px;overflow:hidden}
[data-theme^="gpt"] #ctx-text,[data-theme^="gpt"] #status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em}
[data-theme^="gpt"] #ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)}
[data-theme^="gpt"] #ctx-compact:hover{border-color:var(--dim);color:var(--text)}
/* Jauge = bord bas de la carte : épaisse, plaquée au ras du bord, rognée par
l'arrondi du pied de carte. */
[data-theme="gpt"] #ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
[data-theme="gpt"] #ctx-fill{background:var(--text);opacity:.75}
[data-theme="gpt"] #sendhint{order:4;padding:12px var(--col) 2px}
[data-theme^="gpt"] #ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
[data-theme^="gpt"] #ctx-fill{background:var(--text);opacity:.75}
[data-theme^="gpt"] #sendhint{order:4;padding:12px var(--col) 2px}
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
[data-theme="gpt"] button,[data-theme="gpt"] .addbtn,[data-theme="gpt"] .mem-search,[data-theme="gpt"] #theme-select{border-radius:10px}
[data-theme^="gpt"] button,[data-theme^="gpt"] .addbtn,[data-theme^="gpt"] .mem-search,[data-theme^="gpt"] #theme-select{border-radius:10px}
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
[data-theme="gpt"] .pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
[data-theme="gpt"] .pe-inpbtn button{border-radius:0 10px 10px 0}
[data-theme="gpt"] .input-btn .ctl{border-radius:10px 0 0 10px}
[data-theme="gpt"] .input-btn .btn-in{border-radius:0 10px 10px 0}
[data-theme="gpt"] .preset,[data-theme="gpt"] .gated-block,[data-theme="gpt"] .lc-mode{border-radius:12px}
[data-theme="gpt"] .preset{background:transparent;border-color:transparent}
[data-theme="gpt"] .preset:hover{background:var(--accent-bg);border-color:transparent}
[data-theme="gpt"] .preset.active{background:var(--accent-bg);border-color:var(--border);color:var(--text)}
[data-theme="gpt"] .subhead,[data-theme="gpt"] .lc-reco,[data-theme="gpt"] .lc-mode-go,[data-theme="gpt"] .lc-mode-active-tag{color:var(--dim)}
[data-theme="gpt"] button:hover{border-color:var(--dim);color:var(--text)}
[data-theme="gpt"] .switch input:checked + .slider{background:var(--text)}
[data-theme="gpt"] .switch .slider::before{background:var(--bg)}
[data-theme="gpt"] .switch input:not(:checked) + .slider::before{background:#fff}
[data-theme^="gpt"] .pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
[data-theme^="gpt"] .pe-inpbtn button{border-radius:0 10px 10px 0}
[data-theme^="gpt"] .input-btn .ctl{border-radius:10px 0 0 10px}
[data-theme^="gpt"] .input-btn .btn-in{border-radius:0 10px 10px 0}
[data-theme^="gpt"] .preset,[data-theme^="gpt"] .gated-block,[data-theme^="gpt"] .lc-mode{border-radius:12px}
[data-theme^="gpt"] .preset{background:transparent;border-color:transparent}
[data-theme^="gpt"] .preset:hover{background:var(--accent-bg);border-color:transparent}
[data-theme^="gpt"] .preset.active{background:var(--accent-bg);border-color:var(--border);color:var(--text)}
[data-theme^="gpt"] .subhead,[data-theme^="gpt"] .lc-reco,[data-theme^="gpt"] .lc-mode-go,[data-theme^="gpt"] .lc-mode-active-tag{color:var(--dim)}
[data-theme^="gpt"] button:hover{border-color:var(--dim);color:var(--text)}
[data-theme^="gpt"] .switch input:checked + .slider{background:var(--text)}
[data-theme^="gpt"] .switch .slider::before{background:var(--bg)}
[data-theme^="gpt"] .switch input:not(:checked) + .slider::before{background:#fff}
/* ===== Thème "AJEAN" — mise en page ==========================
Rétro-machine : plus aucun arrondi, bordures de 2 px, ombres portées en
@@ -713,7 +739,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
[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"]{
[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)}
@@ -778,6 +804,15 @@ button:disabled{opacity:.5;cursor:not-allowed}
<input id="crawl-url" type="text" placeholder="http://localhost:11235" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit" onchange="saveCrawlUrl()">
<button onclick="saveCrawlUrl()">enregistrer</button>
</div>
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
qu'un indice sur les 4 derniers caractères. -->
<div class="row" style="margin-top:6px">
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit" onchange="saveCrawlKey()">
<button onclick="saveCrawlKey()">enregistrer</button>
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
</div>
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
<div id="mcp-block" class="gated-block">
@@ -793,14 +828,19 @@ button:disabled{opacity:.5;cursor:not-allowed}
</details>
<details><summary>Apparence</summary>
<label class="switchrow" style="justify-content:space-between;gap:12px"><span>thème</span>
<select id="theme-select" onchange="applyTheme(this.value);savePrefs()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px"></select>
<select id="theme-select" onchange="pickThemeFamily(this.value)" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px"></select>
</label>
<label class="switchrow" style="justify-content:space-between;gap:12px;margin-top:8px"><span>affichage<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip"><b>Complet</b> : tout est visible (raisonnement, appels d'outils, vitesse, config active). <b>Simple</b> : masque le raisonnement, les appels d'outils, les détails de vitesse sur les bulles et la config active à gauche.</span></span></span>
<select id="display-select" onchange="applyDisplay(this.value);savePrefs()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px">
<option value="full">complet</option>
<option value="simple">simple</option>
</select>
<label class="switchrow" style="justify-content:space-between;gap:12px;margin-top:8px"><span>mode sombre</span>
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
</label>
<label class="switchrow" style="justify-content:space-between;gap:12px;margin-top:8px"><span>police<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Indépendante du thème. <b>Du thème</b> garde celle prévue par le thème choisi (mono pour AJEAN, système pour ChatGPT, Lexend pour Doux). <b>Lexend</b> est téléchargée depuis Google Fonts, les autres sont déjà sur votre appareil.</span></span></span>
<select id="font-select" onchange="applyFont(this.value);savePrefs()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px"></select>
</label>
<div class="subhead" style="margin-top:14px">Affichage</div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-reasoning" onchange="toggleView('hide-reasoning')"><span class="slider"></span></span> masquer le raisonnement</label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-tools" onchange="toggleView('hide-tools')"><span class="slider"></span></span> masquer les appels d'outils</label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-side" onchange="toggleView('hide-side')"><span class="slider"></span></span> barre latérale escamotable<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">La barre latérale devient un tiroir <b>même sur ordinateur</b> : elle reste fermée et s'ouvre avec le bouton ☰. Toute la largeur pour la conversation.</span></span></label>
</details>
<details><summary>System prompt</summary>
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:70px;resize:vertical"></textarea>
@@ -1215,9 +1255,28 @@ async function loadSys(){
// Registre des thèmes disponibles. Pour en AJOUTER un : ajouter un bloc
// [data-theme="…"] dans le <style> puis une entrée {id,label} ici — le reste
// (menu déroulant, persistance localStorage, application) est automatique.
const THEMES=[{id:'ajean',label:'AJEAN'},{id:'ajean-dark',label:'AJEAN sombre'},{id:'dark',label:'sombre'},{id:'light',label:'clair'},{id:'soft',label:'doux'},{id:'soft-dark',label:'doux sombre'},{id:'gpt',label:'ChatGPT'}];
// Anciens identifiants (v0.5.7, thème livré en deux variantes) → thème unique.
const THEME_ALIASES={'gpt-light':'gpt','gpt-dark':'gpt'};
// Chaque FAMILLE de thème existe en clair et en sombre : le menu ne liste donc
// que les familles, et un interrupteur choisit la variante. Bien plus court que
// l'ancienne liste où chaque variante était une entrée séparée.
const THEME_FAMILIES=[
{id:'classic', label:'Classique', light:'light', dark:'dark'},
{id:'ajean', label:'AJEAN', light:'ajean', dark:'ajean-dark'},
{id:'soft', label:'Doux', light:'soft', dark:'soft-dark'},
{id:'gpt', label:'ChatGPT', light:'gpt', dark:'gpt-dark'},
];
// Tous les identifiants valides, à plat (persistance inchangée : on enregistre
// toujours un id de thème, pas une famille).
const THEMES=THEME_FAMILIES.flatMap(f=>[{id:f.light,label:f.label},{id:f.dark,label:f.label+' sombre'}]);
// Ancien identifiant (v0.5.7) → thème actuel.
const THEME_ALIASES={'gpt-light':'gpt'};
// familyOf renvoie {famille, sombre?} pour un id de thème.
function familyOf(id){
for(const f of THEME_FAMILIES){
if(f.light===id) return {fam:f, dark:false};
if(f.dark===id) return {fam:f, dark:true};
}
return {fam:THEME_FAMILIES[0], dark:false};
}
// Lexend (thème "doux") n'est chargé QUE si ce thème est choisi : aucune requête
// externe (Google Fonts) tant qu'on reste en sombre/clair — jean reste local par
// défaut. Fallback système sans-serif si hors ligne.
@@ -1230,32 +1289,95 @@ function ensureSoftFont(){
}
function applyTheme(id){
id=THEME_ALIASES[id]||id;
if(!THEMES.some(t=>t.id===id)) id='ajean';
if(!THEMES.some(t=>t.id===id)) id='light';
if(id==='soft'||id==='soft-dark') ensureSoftFont();
document.documentElement.setAttribute('data-theme', id);
try{ localStorage.setItem('jean-theme', id); }catch(e){}
const sel=document.getElementById('theme-select'); if(sel) sel.value=id;
const cur=familyOf(id);
const sel=document.getElementById('theme-select'); if(sel) sel.value=cur.fam.id;
const sw=document.getElementById('theme-dark'); if(sw) sw.checked=cur.dark;
}
// Bascule clair/sombre en gardant la famille courante.
function toggleThemeDark(){
const id=document.documentElement.getAttribute('data-theme')||'light';
const cur=familyOf(id);
const dark=document.getElementById('theme-dark').checked;
applyTheme(dark?cur.fam.dark:cur.fam.light);
savePrefs();
}
// Changement de famille : on reste dans la même variante (clair ou sombre).
function pickThemeFamily(famID){
const id=document.documentElement.getAttribute('data-theme')||'light';
const dark=familyOf(id).dark;
const f=THEME_FAMILIES.find(x=>x.id===famID)||THEME_FAMILIES[0];
applyTheme(dark?f.dark:f.light);
savePrefs();
}
function initTheme(){
const sel=document.getElementById('theme-select');
if(sel && !sel.options.length){
THEMES.forEach(t=>{ const o=document.createElement('option'); o.value=t.id; o.textContent=t.label; sel.appendChild(o); });
THEME_FAMILIES.forEach(f=>{ const o=document.createElement('option'); o.value=f.id; o.textContent=f.label; sel.appendChild(o); });
}
let id='ajean'; try{ id=localStorage.getItem('jean-theme')||'ajean'; }catch(e){}
let id='light'; try{ id=localStorage.getItem('jean-theme')||'light'; }catch(e){}
applyTheme(id);
}
// ===== Mode d'affichage (complet / simple) ==================================
// "simple" masque raisonnement, appels d'outils, détails de vitesse et config
// active (via [data-display] + CSS). Persisté dans 'jean-display'.
function applyDisplay(mode){
if(mode!=='simple') mode='full';
document.documentElement.setAttribute('data-display', mode);
try{ localStorage.setItem('jean-display', mode); }catch(e){}
const sel=document.getElementById('display-select'); if(sel) sel.value=mode;
// ===== Police ===============================================================
// Réglage indépendant du thème : "auto" garde celle du thème, les autres la
// remplacent (voir les blocs [data-font=…] dans le <style>). Persisté dans
// 'jean-font' et partagé entre appareils via /api/prefs.
const FONTS=[
{id:'auto', label:'du thème'},
{id:'mono', label:'Mono'},
{id:'system', label:'Système'},
{id:'lexend', label:'Lexend'},
{id:'serif', label:'Serif'},
];
function applyFont(id){
if(!FONTS.some(f=>f.id===id)) id='auto';
if(id==='lexend') ensureSoftFont();
document.documentElement.setAttribute('data-font', id);
try{ localStorage.setItem('jean-font', id); }catch(e){}
const sel=document.getElementById('font-select'); if(sel) sel.value=id;
}
function initDisplay(){
let m='full'; try{ m=localStorage.getItem('jean-display')||'full'; }catch(e){}
applyDisplay(m);
function initFont(){
const sel=document.getElementById('font-select');
if(sel && !sel.options.length){
FONTS.forEach(f=>{ const o=document.createElement('option'); o.value=f.id; o.textContent=f.label; sel.appendChild(o); });
}
let id='auto'; try{ id=localStorage.getItem('jean-font')||'auto'; }catch(e){}
applyFont(id);
}
// ===== Affichage ============================================================
// Quatre interrupteurs indépendants (ils remplacent l'ancien mode « simple »,
// tout ou rien). Chacun pose un attribut sur <html> ; le CSS fait le reste,
// sauf « replier les bulles » qui est un comportement (voir 08-chat-render).
const VIEW_OPTS=[
{id:'hide-reasoning', label:'masquer le raisonnement'},
{id:'hide-tools', label:"masquer les appels d'outils"},
{id:'fold-tools', label:'garder les bulles repliées'},
{id:'hide-side', label:'barre latérale escamotable'},
];
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('jean-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
if(id==='hide-side' && !on){
document.getElementById('side').classList.remove('open');
document.getElementById('backdrop').classList.remove('open');
document.body.classList.remove('drawer-open');
}
}
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){
// Reprise de l'ancien réglage : « simple » masquait raisonnement ET outils.
let legacy=null; try{ legacy=localStorage.getItem('jean-display'); }catch(e){}
VIEW_OPTS.forEach(o=>{
let v=null; try{ v=localStorage.getItem('jean-'+o.id); }catch(e){}
if(v===null && legacy==='simple' && (o.id==='hide-reasoning'||o.id==='hide-tools')) v='1';
applyView(o.id, v==='1');
});
}
// ===== Persistance côté serveur (partagée entre appareils) ==================
// L'apparence est aussi enregistrée sur la machine jean (/api/prefs) : ainsi le
@@ -1263,20 +1385,29 @@ function initDisplay(){
// localStorage reste utilisé pour appliquer instantanément au chargement (sans
// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité).
function savePrefs(){
let theme='ajean', display='full';
try{ theme=localStorage.getItem('jean-theme')||'ajean'; display=localStorage.getItem('jean-display')||'full'; }catch(e){}
jpost('/api/prefs', {theme, display}).catch(()=>{});
let theme='light', font='auto';
try{
theme=localStorage.getItem('jean-theme')||'light';
font=localStorage.getItem('jean-font')||'auto';
}catch(e){}
const p={theme, font};
VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; });
jpost('/api/prefs', p).catch(()=>{});
}
async function loadPrefs(){
try{
const p=await jget('/api/prefs');
if(p && p.ok && p.prefs){
if(p.prefs.theme) applyTheme(p.prefs.theme);
if(p.prefs.display) applyDisplay(p.prefs.display);
if(p.prefs.font) applyFont(p.prefs.font);
VIEW_OPTS.forEach(o=>{
const v=p.prefs[o.id.replace('-','_')];
if(v!==undefined) applyView(o.id, v==='1');
});
}
}catch(e){}
}
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initDisplay(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); });
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initFont(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); });
function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); }
// Modales natives (askConfirm/askPrompt/askAlert) — remplacent confirm()/prompt()/
// alert() par de vraies boîtes stylées. Chacune renvoie une Promise : askConfirm →
@@ -1643,6 +1774,16 @@ function renderInternet(s){
document.getElementById('crawl-url').value = s.url || '';
document.getElementById('internet-badge').innerHTML = internetOn
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
// Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée),
// on indique seulement si une clé est enregistrée.
const ks=document.getElementById('crawl-key-state'), kc=document.getElementById('crawl-key-clear');
if(ks){
ks.textContent = s.key_set ? ('clé enregistrée '+(s.key_hint||'')) : 'aucune clé — serveur ouvert';
if(kc) kc.style.display = s.key_set ? '' : 'none';
const ki=document.getElementById('crawl-key');
if(ki && document.activeElement!==ki) ki.value='';
if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)';
}
const st=document.getElementById('internet-status');
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
else if(s.reachable){ st.innerHTML='<span style="color:var(--accent)">✓</span> serveur joignable — outils web actifs'; }
@@ -1724,6 +1865,18 @@ async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
}
async function saveCrawlKey(){
const el=document.getElementById('crawl-key'), key=el.value.trim();
if(!key){ toast('saisis une clé (ou « retirer » pour l\'enlever)'); return; }
renderInternet(await jpost('/api/internet',{key}));
toast('clé enregistrée');
}
async function clearCrawlKey(){
if(!await askConfirm('Retirer la clé du serveur Crawl4AI ?')) return;
document.getElementById('crawl-key').value='';
renderInternet(await jpost('/api/internet',{key:''}));
toast('clé retirée');
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
@@ -2407,7 +2560,7 @@ function handleDelta(d){
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
killTyping(); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
renderToolMsg(T.pendingToolEl, tu);
if(!tu.done) showTyping();
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
@@ -2417,7 +2570,7 @@ function handleDelta(d){
return; }
if(d.reasoning_content){
killTyping();
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
// au lieu de concaténer, sinon le texte apparaît en double.
+21 -7
View File
@@ -15,7 +15,7 @@
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%231f6feb'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABa0lEQVR42u3dsQ2AIBBAUSCOYA8b4iiMSI9D2FpYGTEheW8AisvPUV7MdQR4kowAcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+IAcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+JAHCAOxMEHNiO4622f9HI5TpsD3wriQBwgDsSBOBAH4kAciANxIA7EAeJAHIgDcSAOxIE4EAfiQBwgDsSBOBAH4kAciANxIA7EgThAHIgDcSAOxIE4EAfiQByIA8SBOBAH4kAciANxIA7EgThAHIgDcSAO/rLkLXsX520OxIE4EAfiQByIA8SBOBAH4kAciANxIA7EgThAHIgDcSAOxIE4EAfiQByIA8SBOHgl5jpMAZsDcSAOxIE4EAfiQByIA3GAOBAH4kAciANxIA7EgTgQB4gDcSAOxIE4EAfiQByIA3EgDhAH4kAciANxIA7EgTgQB+IAcSAOxME0F8YvCl24O02KAAAAAElFTkSuQmCC">
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
<script>try{document.documentElement.setAttribute('data-theme',localStorage.getItem('jean-theme')||'ajean');document.documentElement.setAttribute('data-display',localStorage.getItem('jean-display')||'full')}catch(e){document.documentElement.setAttribute('data-theme','ajean');document.documentElement.setAttribute('data-display','full')}</script>
<script>try{document.documentElement.setAttribute('data-theme',localStorage.getItem('jean-theme')||'light');['hide-reasoning','hide-tools','fold-tools','hide-side'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')});document.documentElement.setAttribute('data-font',localStorage.getItem('jean-font')||'auto')}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style>
@@CSS@@
</style></head><body>
@@ -79,6 +79,15 @@
<input id="crawl-url" type="text" placeholder="http://localhost:11235" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit" onchange="saveCrawlUrl()">
<button onclick="saveCrawlUrl()">enregistrer</button>
</div>
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
qu'un indice sur les 4 derniers caractères. -->
<div class="row" style="margin-top:6px">
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit" onchange="saveCrawlKey()">
<button onclick="saveCrawlKey()">enregistrer</button>
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
</div>
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
<div id="mcp-block" class="gated-block">
@@ -94,14 +103,19 @@
</details>
<details><summary>Apparence</summary>
<label class="switchrow" style="justify-content:space-between;gap:12px"><span>thème</span>
<select id="theme-select" onchange="applyTheme(this.value);savePrefs()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px"></select>
<select id="theme-select" onchange="pickThemeFamily(this.value)" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px"></select>
</label>
<label class="switchrow" style="justify-content:space-between;gap:12px;margin-top:8px"><span>affichage<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip"><b>Complet</b> : tout est visible (raisonnement, appels d'outils, vitesse, config active). <b>Simple</b> : masque le raisonnement, les appels d'outils, les détails de vitesse sur les bulles et la config active à gauche.</span></span></span>
<select id="display-select" onchange="applyDisplay(this.value);savePrefs()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px">
<option value="full">complet</option>
<option value="simple">simple</option>
</select>
<label class="switchrow" style="justify-content:space-between;gap:12px;margin-top:8px"><span>mode sombre</span>
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
</label>
<label class="switchrow" style="justify-content:space-between;gap:12px;margin-top:8px"><span>police<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Indépendante du thème. <b>Du thème</b> garde celle prévue par le thème choisi (mono pour AJEAN, système pour ChatGPT, Lexend pour Doux). <b>Lexend</b> est téléchargée depuis Google Fonts, les autres sont déjà sur votre appareil.</span></span></span>
<select id="font-select" onchange="applyFont(this.value);savePrefs()" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px"></select>
</label>
<div class="subhead" style="margin-top:14px">Affichage</div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-reasoning" onchange="toggleView('hide-reasoning')"><span class="slider"></span></span> masquer le raisonnement</label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-tools" onchange="toggleView('hide-tools')"><span class="slider"></span></span> masquer les appels d'outils</label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-side" onchange="toggleView('hide-side')"><span class="slider"></span></span> barre latérale escamotable<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">La barre latérale devient un tiroir <b>même sur ordinateur</b> : elle reste fermée et s'ouvre avec le bouton ☰. Toute la largeur pour la conversation.</span></span></label>
</details>
<details><summary>System prompt</summary>
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:70px;resize:vertical"></textarea>
+100 -18
View File
@@ -2,9 +2,28 @@
// Registre des thèmes disponibles. Pour en AJOUTER un : ajouter un bloc
// [data-theme="…"] dans le <style> puis une entrée {id,label} ici — le reste
// (menu déroulant, persistance localStorage, application) est automatique.
const THEMES=[{id:'ajean',label:'AJEAN'},{id:'ajean-dark',label:'AJEAN sombre'},{id:'dark',label:'sombre'},{id:'light',label:'clair'},{id:'soft',label:'doux'},{id:'soft-dark',label:'doux sombre'},{id:'gpt',label:'ChatGPT'}];
// Anciens identifiants (v0.5.7, thème livré en deux variantes) → thème unique.
const THEME_ALIASES={'gpt-light':'gpt','gpt-dark':'gpt'};
// Chaque FAMILLE de thème existe en clair et en sombre : le menu ne liste donc
// que les familles, et un interrupteur choisit la variante. Bien plus court que
// l'ancienne liste où chaque variante était une entrée séparée.
const THEME_FAMILIES=[
{id:'classic', label:'Classique', light:'light', dark:'dark'},
{id:'ajean', label:'AJEAN', light:'ajean', dark:'ajean-dark'},
{id:'soft', label:'Doux', light:'soft', dark:'soft-dark'},
{id:'gpt', label:'ChatGPT', light:'gpt', dark:'gpt-dark'},
];
// Tous les identifiants valides, à plat (persistance inchangée : on enregistre
// toujours un id de thème, pas une famille).
const THEMES=THEME_FAMILIES.flatMap(f=>[{id:f.light,label:f.label},{id:f.dark,label:f.label+' sombre'}]);
// Ancien identifiant (v0.5.7) → thème actuel.
const THEME_ALIASES={'gpt-light':'gpt'};
// familyOf renvoie {famille, sombre?} pour un id de thème.
function familyOf(id){
for(const f of THEME_FAMILIES){
if(f.light===id) return {fam:f, dark:false};
if(f.dark===id) return {fam:f, dark:true};
}
return {fam:THEME_FAMILIES[0], dark:false};
}
// Lexend (thème "doux") n'est chargé QUE si ce thème est choisi : aucune requête
// externe (Google Fonts) tant qu'on reste en sombre/clair — jean reste local par
// défaut. Fallback système sans-serif si hors ligne.
@@ -17,30 +36,93 @@ function ensureSoftFont(){
}
function applyTheme(id){
id=THEME_ALIASES[id]||id;
if(!THEMES.some(t=>t.id===id)) id='ajean';
if(!THEMES.some(t=>t.id===id)) id='light';
if(id==='soft'||id==='soft-dark') ensureSoftFont();
document.documentElement.setAttribute('data-theme', id);
try{ localStorage.setItem('jean-theme', id); }catch(e){}
const sel=document.getElementById('theme-select'); if(sel) sel.value=id;
const cur=familyOf(id);
const sel=document.getElementById('theme-select'); if(sel) sel.value=cur.fam.id;
const sw=document.getElementById('theme-dark'); if(sw) sw.checked=cur.dark;
}
// Bascule clair/sombre en gardant la famille courante.
function toggleThemeDark(){
const id=document.documentElement.getAttribute('data-theme')||'light';
const cur=familyOf(id);
const dark=document.getElementById('theme-dark').checked;
applyTheme(dark?cur.fam.dark:cur.fam.light);
savePrefs();
}
// Changement de famille : on reste dans la même variante (clair ou sombre).
function pickThemeFamily(famID){
const id=document.documentElement.getAttribute('data-theme')||'light';
const dark=familyOf(id).dark;
const f=THEME_FAMILIES.find(x=>x.id===famID)||THEME_FAMILIES[0];
applyTheme(dark?f.dark:f.light);
savePrefs();
}
function initTheme(){
const sel=document.getElementById('theme-select');
if(sel && !sel.options.length){
THEMES.forEach(t=>{ const o=document.createElement('option'); o.value=t.id; o.textContent=t.label; sel.appendChild(o); });
THEME_FAMILIES.forEach(f=>{ const o=document.createElement('option'); o.value=f.id; o.textContent=f.label; sel.appendChild(o); });
}
let id='ajean'; try{ id=localStorage.getItem('jean-theme')||'ajean'; }catch(e){}
let id='light'; try{ id=localStorage.getItem('jean-theme')||'light'; }catch(e){}
applyTheme(id);
}
// ===== Mode d'affichage (complet / simple) ==================================
// "simple" masque raisonnement, appels d'outils, détails de vitesse et config
// active (via [data-display] + CSS). Persisté dans 'jean-display'.
function applyDisplay(mode){
if(mode!=='simple') mode='full';
document.documentElement.setAttribute('data-display', mode);
try{ localStorage.setItem('jean-display', mode); }catch(e){}
const sel=document.getElementById('display-select'); if(sel) sel.value=mode;
// ===== Police ===============================================================
// Réglage indépendant du thème : "auto" garde celle du thème, les autres la
// remplacent (voir les blocs [data-font=…] dans le <style>). Persisté dans
// 'jean-font' et partagé entre appareils via /api/prefs.
const FONTS=[
{id:'auto', label:'du thème'},
{id:'mono', label:'Mono'},
{id:'system', label:'Système'},
{id:'lexend', label:'Lexend'},
{id:'serif', label:'Serif'},
];
function applyFont(id){
if(!FONTS.some(f=>f.id===id)) id='auto';
if(id==='lexend') ensureSoftFont();
document.documentElement.setAttribute('data-font', id);
try{ localStorage.setItem('jean-font', id); }catch(e){}
const sel=document.getElementById('font-select'); if(sel) sel.value=id;
}
function initDisplay(){
let m='full'; try{ m=localStorage.getItem('jean-display')||'full'; }catch(e){}
applyDisplay(m);
function initFont(){
const sel=document.getElementById('font-select');
if(sel && !sel.options.length){
FONTS.forEach(f=>{ const o=document.createElement('option'); o.value=f.id; o.textContent=f.label; sel.appendChild(o); });
}
let id='auto'; try{ id=localStorage.getItem('jean-font')||'auto'; }catch(e){}
applyFont(id);
}
// ===== Affichage ============================================================
// Quatre interrupteurs indépendants (ils remplacent l'ancien mode « simple »,
// tout ou rien). Chacun pose un attribut sur <html> ; le CSS fait le reste,
// sauf « replier les bulles » qui est un comportement (voir 08-chat-render).
const VIEW_OPTS=[
{id:'hide-reasoning', label:'masquer le raisonnement'},
{id:'hide-tools', label:"masquer les appels d'outils"},
{id:'fold-tools', label:'garder les bulles repliées'},
{id:'hide-side', label:'barre latérale escamotable'},
];
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('jean-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
if(id==='hide-side' && !on){
document.getElementById('side').classList.remove('open');
document.getElementById('backdrop').classList.remove('open');
document.body.classList.remove('drawer-open');
}
}
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){
// Reprise de l'ancien réglage : « simple » masquait raisonnement ET outils.
let legacy=null; try{ legacy=localStorage.getItem('jean-display'); }catch(e){}
VIEW_OPTS.forEach(o=>{
let v=null; try{ v=localStorage.getItem('jean-'+o.id); }catch(e){}
if(v===null && legacy==='simple' && (o.id==='hide-reasoning'||o.id==='hide-tools')) v='1';
applyView(o.id, v==='1');
});
}
+14 -5
View File
@@ -4,17 +4,26 @@
// localStorage reste utilisé pour appliquer instantanément au chargement (sans
// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité).
function savePrefs(){
let theme='ajean', display='full';
try{ theme=localStorage.getItem('jean-theme')||'ajean'; display=localStorage.getItem('jean-display')||'full'; }catch(e){}
jpost('/api/prefs', {theme, display}).catch(()=>{});
let theme='light', font='auto';
try{
theme=localStorage.getItem('jean-theme')||'light';
font=localStorage.getItem('jean-font')||'auto';
}catch(e){}
const p={theme, font};
VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; });
jpost('/api/prefs', p).catch(()=>{});
}
async function loadPrefs(){
try{
const p=await jget('/api/prefs');
if(p && p.ok && p.prefs){
if(p.prefs.theme) applyTheme(p.prefs.theme);
if(p.prefs.display) applyDisplay(p.prefs.display);
if(p.prefs.font) applyFont(p.prefs.font);
VIEW_OPTS.forEach(o=>{
const v=p.prefs[o.id.replace('-','_')];
if(v!==undefined) applyView(o.id, v==='1');
});
}
}catch(e){}
}
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initDisplay(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); });
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initFont(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); });
+22
View File
@@ -147,6 +147,16 @@ function renderInternet(s){
document.getElementById('crawl-url').value = s.url || '';
document.getElementById('internet-badge').innerHTML = internetOn
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
// Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée),
// on indique seulement si une clé est enregistrée.
const ks=document.getElementById('crawl-key-state'), kc=document.getElementById('crawl-key-clear');
if(ks){
ks.textContent = s.key_set ? ('clé enregistrée '+(s.key_hint||'')) : 'aucune clé — serveur ouvert';
if(kc) kc.style.display = s.key_set ? '' : 'none';
const ki=document.getElementById('crawl-key');
if(ki && document.activeElement!==ki) ki.value='';
if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)';
}
const st=document.getElementById('internet-status');
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
else if(s.reachable){ st.innerHTML='<span style="color:var(--accent)">✓</span> serveur joignable — outils web actifs'; }
@@ -228,5 +238,17 @@ async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
}
async function saveCrawlKey(){
const el=document.getElementById('crawl-key'), key=el.value.trim();
if(!key){ toast('saisis une clé (ou « retirer » pour l\'enlever)'); return; }
renderInternet(await jpost('/api/internet',{key}));
toast('clé enregistrée');
}
async function clearCrawlKey(){
if(!await askConfirm('Retirer la clé du serveur Crawl4AI ?')) return;
document.getElementById('crawl-key').value='';
renderInternet(await jpost('/api/internet',{key:''}));
toast('clé retirée');
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+2 -2
View File
@@ -69,7 +69,7 @@ function handleDelta(d){
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
killTyping(); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
renderToolMsg(T.pendingToolEl, tu);
if(!tu.done) showTyping();
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
@@ -79,7 +79,7 @@ function handleDelta(d){
return; }
if(d.reasoning_content){
killTyping();
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
// au lieu de concaténer, sinon le texte apparaît en double.
+88 -62
View File
@@ -15,8 +15,11 @@
/* "ChatGPT" — monochrome (noir & blanc), police système, aucun accent coloré.
Seules les erreurs gardent un rouge discret pour rester lisibles. */
[data-theme="gpt"]{color-scheme:light;--font:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;--bubble:#e8e8e8;--bg:#ffffff;--panel:#f9f9f9;--border:#e3e3e3;--text:#0d0d0d;--dim:#8f8f8f;--accent:#0d0d0d;--accent-bg:rgba(0,0,0,.06);--ok:#0d0d0d;--warn:#5d5d5d;--err:#c0392b;--mag:#5d5d5d;--code-bg:#f4f4f4;--danger-bg:#fbeae8;--warn-bg:#f0f0f0}
/* "ChatGPT sombre" — même disposition, palette nuit de ChatGPT : gris très
sombre, bulle utilisateur gris clair, aucun accent coloré. */
[data-theme="gpt-dark"]{color-scheme:dark;--font:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;--bubble:#1f1f1f;--bg:#0a0a0a;--panel:#141414;--border:#262626;--text:#ececec;--dim:#8e8e8e;--accent:#ececec;--accent-bg:rgba(255,255,255,.07);--ok:#ececec;--warn:#c8b273;--err:#f2777a;--mag:#b0b0b0;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#26241a}
/* Bulle utilisateur claire : le texte blanc par défaut (.msg.user) serait illisible. */
[data-theme="gpt"] .msg.user{color:var(--text)}
[data-theme^="gpt"] .msg.user{color:var(--text)}
/* "AJEAN" — clair, bleu, esprit rétro-machine : bordures nettes de 2 px,
aucun arrondi, ombres en blocs décalés, fond quadrillé (même charte que
ajean.app). Mise en page complète plus bas (section "Thème pixel"). */
@@ -24,6 +27,15 @@
/* « 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}
/* ===== Police (réglage séparé du thème) ===========================
Chaque thème propose une police par défaut via --font ; ce réglage la
remplace. Même spécificité que les blocs de thème, mais écrit APRÈS : c'est
donc lui qui gagne. "auto" = on laisse la police du thème.
Lexend n'est chargée (Google Fonts) que si elle est effectivement choisie. */
[data-font="mono"]{--font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
[data-font="system"]{--font:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
[data-font="serif"]{--font:Georgia,'Iowan Old Style','Times New Roman',serif}
[data-font="lexend"]{--font:'Lexend',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}
*{box-sizing:border-box}
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;display:flex;font-size:14px}
/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne
@@ -45,7 +57,6 @@ body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-i
#chat .msg.assistant,#chat .msg.reasoning{max-width:100%}
#inputbar{padding:8px;gap:4px}
#send,#stop{padding:0 12px}
h1{margin-left:48px}
/* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et
ça prend de la place. Elle reste visible sur desktop. */
#sendhint{display:none}
@@ -62,7 +73,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
#brand{display:inline-flex;align-items:center}
#brand svg{height:10px;width:auto;display:block;shape-rendering:crispEdges;fill:currentColor}
#brand-word{display:inline-flex;color:var(--accent)}
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:7px;letter-spacing:0}
#ver{font-size:10px;font-weight:400;color:var(--dim);margin-left:7px;letter-spacing:0;align-self:flex-end;line-height:1;position:relative;top:3px}
.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:18px}
/* pastille d'état service, en haut à droite de la barre latérale */
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
@@ -166,6 +177,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
.bar{height:6px;background:var(--panel);border-radius:3px;overflow:hidden;margin:2px 0}
.bar>div{height:100%;background:var(--accent)}
#chat{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:14px}
:root{--col:max(20px,calc(50% - 450px))}
@media (max-width:720px){#chat{padding:60px 8px 12px}}
.msg{max-width:80%;padding:10px 14px;border-radius:10px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
.msg.assistant,.msg.reasoning{white-space:normal}
.msg .body{overflow-wrap:anywhere;word-break:break-word}
@@ -226,17 +239,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
replie en douceur pour désencombrer le fil, tout en gardant le label (et son
compteur de tokens) cliquable pour redéplier. */
.msg.collapsible .label{cursor:pointer;user-select:none;margin-bottom:0}
/* ===== Affichage simple ==========================================
Mode "simple" (Apparence) : masque le raisonnement, les appels d'outils,
les détails de vitesse sur les bulles et la config active à gauche. Piloté
par [data-display="simple"] sur <html> (persisté dans 'jean-display'). */
/* En simple, raisonnement et outils sont masqués. L'activité est signalée par le
VRAI indicateur de frappe (.msg.typing, mêmes 3 points animés qu'avant la
réponse), maintenu en bas pendant tout le tour côté JS — animation identique. */
[data-display="simple"] #chat .msg.reasoning,
[data-display="simple"] #chat .msg.tool{display:none}
[data-display="simple"] #chat .msg .label{display:none}
[data-display="simple"] #cfg-details{display:none}
/* ===== Réglages d'affichage ======================================
Chaque option est un interrupteur indépendant (Apparence), porté par un
attribut sur <html> et persisté dans localStorage + /api/prefs :
data-hide-reasoning : masque les bulles de raisonnement
data-hide-tools : masque les bulles d'appel d'outil
data-fold-tools : les garde repliées (déplia­bles au clic)
data-hide-side : barre latérale en tiroir, même sur ordinateur
Quand raisonnement et outils sont masqués, l'activité reste signalée par
l'indicateur de frappe (.msg.typing) maintenu pendant tout le tour. */
[data-hide-reasoning="1"] #chat .msg.reasoning{display:none}
[data-hide-tools="1"] #chat .msg.tool{display:none}
/* Barre latérale en tiroir sur TOUTES les tailles d'écran : mêmes règles que
la version mobile, activées par l'attribut au lieu de la largeur. */
[data-hide-side="1"] .side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left));border-right:1px solid var(--border)}
@media (min-width:721px){[data-hide-side="1"] .side{width:380px;max-width:380px}}
/* Le bouton ☰ est posé au-dessus du fil : on dégage le haut de la
conversation pour qu'il ne recouvre aucun message. */
@media (min-width:721px){html[data-hide-side="1"] #chat{padding-top:60px}}
@media (min-width:721px){html[data-hide-side="1"]:not([data-theme^="gpt"]) #chat{padding-left:var(--col);padding-right:var(--col)}}
[data-hide-side="1"] .side.open{transform:translateX(0)}
[data-hide-side="1"] #menubtn{display:block}
[data-hide-side="1"] #backdrop.open{display:block}
[data-hide-side="1"] body.drawer-open #menubtn{display:none}
[data-hide-side="1"] .main{width:100%}
/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui
peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli.
width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0
@@ -503,30 +529,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
disposition de ChatGPT — colonne de lecture centrée, réponses SANS bulle
(texte nu sur le fond), question de l'utilisateur en pastille arrondie à
droite, composer en « pilule », pas d'étiquettes VOUS/JEAN, typo système
plus grande et plus aérée. Tout est préfixé [data-theme="gpt"] : aucun
plus grande et plus aérée. Tout est préfixé [data-theme^="gpt"] : aucun
autre thème n'est affecté. */
[data-theme="gpt"] body{font-size:15px}
[data-theme="gpt"] h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
[data-theme^="gpt"] body{font-size:15px}
[data-theme^="gpt"] h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
/* Barre latérale : plus sombre/atténuée que la zone de conversation. */
[data-theme="gpt"] .side{background:var(--panel);border-right-color:transparent}
@media (max-width:720px){[data-theme="gpt"] .side{background:var(--panel)}}
[data-theme^="gpt"] .side{background:var(--panel);border-right-color:transparent}
@media (max-width:720px){[data-theme^="gpt"] .side{background:var(--panel)}}
/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile. */
[data-theme="gpt"] #chat{padding:28px max(16px,calc(50% - 380px)) 8px;gap:22px}
[data-theme^="gpt"] #chat{padding:28px max(16px,calc(50% - 380px)) 8px;gap:22px;scrollbar-gutter:stable both-edges}
/* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */
[data-theme="gpt"] #chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7}
[data-theme^="gpt"] #chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7}
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
[data-theme="gpt"] #chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
[data-theme="gpt"] #chat .msg.user .label{color:var(--dim)}
[data-theme^="gpt"] #chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
[data-theme^="gpt"] #chat .msg.user .label{color:var(--dim)}
/* Étiquettes VOUS / JEAN : inutiles ici (l'alignement suffit), sauf pour les
bulles repliables (raisonnement / outils) où le label est le bouton de repli. */
[data-theme="gpt"] #chat .msg .label{display:none}
[data-theme="gpt"] #chat .msg.collapsible .label{display:block}
[data-theme^="gpt"] #chat .msg .label{display:none}
[data-theme^="gpt"] #chat .msg.collapsible .label{display:block}
/* Raisonnement / outils : encart gris discret plutôt qu'un cadre pointillé. */
[data-theme="gpt"] #chat .msg.reasoning,[data-theme="gpt"] #chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:14px;color:var(--dim);padding:12px 16px}
[data-theme="gpt"] #chat .msg.reasoning{color:var(--dim)}
[data-theme="gpt"] #chat .msg.tool .tool-head{color:var(--text)}
[data-theme="gpt"] #chat .msg.assistant .body pre,[data-theme="gpt"] #chat .msg.reasoning .body pre,[data-theme="gpt"] #chat .msg.tool pre{border-radius:12px;padding:12px 14px}
[data-theme="gpt"] #chat .msg.assistant .body a{text-decoration:underline;text-underline-offset:2px}
[data-theme^="gpt"] #chat .msg.reasoning,[data-theme^="gpt"] #chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:14px;color:var(--dim);padding:12px 16px}
[data-theme^="gpt"] #chat .msg.reasoning{color:var(--dim)}
[data-theme^="gpt"] #chat .msg.tool .tool-head{color:var(--text)}
[data-theme^="gpt"] #chat .msg.assistant .body pre,[data-theme^="gpt"] #chat .msg.reasoning .body pre,[data-theme^="gpt"] #chat .msg.tool pre{border-radius:12px;padding:12px 14px}
[data-theme^="gpt"] #chat .msg.assistant .body a{text-decoration:underline;text-underline-offset:2px}
/* Composer = UNE seule carte posée PAR-DESSUS le fil : la conversation
continue de défiler DERRIÈRE elle et s'efface en fondu (dégradé vers le fond)
au lieu de s'arrêter net au-dessus. Dans la carte, de haut en bas : la saisie
@@ -535,48 +561,48 @@ button:disabled{opacity:.5;cursor:not-allowed}
(overflow:hidden sur le rayon). Les trois blocs sont des frères dans le HTML :
on les recolle en dessinant les bordures par morceaux et l'ordre visuel vient
de l'ordre flex — aucun changement de markup. */
[data-theme="gpt"] #composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 8px;--col:max(16px,calc(50% - 380px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)}
[data-theme="gpt"] #chat{padding-bottom:170px}
[data-theme="gpt"] #scrollbtn{bottom:180px;border-radius:999px}
[data-theme="gpt"] #compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px}
[data-theme="gpt"] #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0}
[data-theme="gpt"] #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
[data-theme^="gpt"] #composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 8px;--col:max(16px,calc(50% - 380px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)}
[data-theme^="gpt"] #chat{padding-bottom:170px}
[data-theme^="gpt"] #scrollbtn{bottom:180px;border-radius:999px}
[data-theme^="gpt"] #compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px}
[data-theme^="gpt"] #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0}
[data-theme^="gpt"] #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
bordure, seule la saisie change (curseur). */
[data-theme="gpt"] #input:focus{border-color:transparent}
[data-theme="gpt"] #send,[data-theme="gpt"] #stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0}
[data-theme="gpt"] #send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600}
[data-theme="gpt"] #send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)}
[data-theme^="gpt"] #input:focus{border-color:transparent}
[data-theme^="gpt"] #send,[data-theme^="gpt"] #stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0}
[data-theme^="gpt"] #send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600}
[data-theme^="gpt"] #send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)}
/* Pied de carte : c'est LUI qui porte l'arrondi bas (26 px, comme en haut) et
qui clippe la jauge. Un bandeau de 7 px ne peut pas dessiner un rayon de 26 px
(le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte
découper la jauge dans sa propre forme — la barre est bien épaisse ET rognée
exactement sur la courbe, symétrique du haut. */
[data-theme="gpt"] #ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 26px 26px;overflow:hidden}
[data-theme="gpt"] #ctx-text,[data-theme="gpt"] #status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em}
[data-theme="gpt"] #ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)}
[data-theme="gpt"] #ctx-compact:hover{border-color:var(--dim);color:var(--text)}
[data-theme^="gpt"] #ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 26px 26px;overflow:hidden}
[data-theme^="gpt"] #ctx-text,[data-theme^="gpt"] #status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em}
[data-theme^="gpt"] #ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)}
[data-theme^="gpt"] #ctx-compact:hover{border-color:var(--dim);color:var(--text)}
/* Jauge = bord bas de la carte : épaisse, plaquée au ras du bord, rognée par
l'arrondi du pied de carte. */
[data-theme="gpt"] #ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
[data-theme="gpt"] #ctx-fill{background:var(--text);opacity:.75}
[data-theme="gpt"] #sendhint{order:4;padding:12px var(--col) 2px}
[data-theme^="gpt"] #ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)}
[data-theme^="gpt"] #ctx-fill{background:var(--text);opacity:.75}
[data-theme^="gpt"] #sendhint{order:4;padding:12px var(--col) 2px}
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
[data-theme="gpt"] button,[data-theme="gpt"] .addbtn,[data-theme="gpt"] .mem-search,[data-theme="gpt"] #theme-select{border-radius:10px}
[data-theme^="gpt"] button,[data-theme^="gpt"] .addbtn,[data-theme^="gpt"] .mem-search,[data-theme^="gpt"] #theme-select{border-radius:10px}
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
[data-theme="gpt"] .pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
[data-theme="gpt"] .pe-inpbtn button{border-radius:0 10px 10px 0}
[data-theme="gpt"] .input-btn .ctl{border-radius:10px 0 0 10px}
[data-theme="gpt"] .input-btn .btn-in{border-radius:0 10px 10px 0}
[data-theme="gpt"] .preset,[data-theme="gpt"] .gated-block,[data-theme="gpt"] .lc-mode{border-radius:12px}
[data-theme="gpt"] .preset{background:transparent;border-color:transparent}
[data-theme="gpt"] .preset:hover{background:var(--accent-bg);border-color:transparent}
[data-theme="gpt"] .preset.active{background:var(--accent-bg);border-color:var(--border);color:var(--text)}
[data-theme="gpt"] .subhead,[data-theme="gpt"] .lc-reco,[data-theme="gpt"] .lc-mode-go,[data-theme="gpt"] .lc-mode-active-tag{color:var(--dim)}
[data-theme="gpt"] button:hover{border-color:var(--dim);color:var(--text)}
[data-theme="gpt"] .switch input:checked + .slider{background:var(--text)}
[data-theme="gpt"] .switch .slider::before{background:var(--bg)}
[data-theme="gpt"] .switch input:not(:checked) + .slider::before{background:#fff}
[data-theme^="gpt"] .pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
[data-theme^="gpt"] .pe-inpbtn button{border-radius:0 10px 10px 0}
[data-theme^="gpt"] .input-btn .ctl{border-radius:10px 0 0 10px}
[data-theme^="gpt"] .input-btn .btn-in{border-radius:0 10px 10px 0}
[data-theme^="gpt"] .preset,[data-theme^="gpt"] .gated-block,[data-theme^="gpt"] .lc-mode{border-radius:12px}
[data-theme^="gpt"] .preset{background:transparent;border-color:transparent}
[data-theme^="gpt"] .preset:hover{background:var(--accent-bg);border-color:transparent}
[data-theme^="gpt"] .preset.active{background:var(--accent-bg);border-color:var(--border);color:var(--text)}
[data-theme^="gpt"] .subhead,[data-theme^="gpt"] .lc-reco,[data-theme^="gpt"] .lc-mode-go,[data-theme^="gpt"] .lc-mode-active-tag{color:var(--dim)}
[data-theme^="gpt"] button:hover{border-color:var(--dim);color:var(--text)}
[data-theme^="gpt"] .switch input:checked + .slider{background:var(--text)}
[data-theme^="gpt"] .switch .slider::before{background:var(--bg)}
[data-theme^="gpt"] .switch input:not(:checked) + .slider::before{background:#fff}
/* ===== Thème "AJEAN" — mise en page ==========================
Rétro-machine : plus aucun arrondi, bordures de 2 px, ombres portées en
@@ -694,7 +720,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
[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"]{
[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)}
+23 -1
View File
@@ -481,7 +481,8 @@ func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
// handleInternet pilote l'accès web de l'IA (serveur Crawl4AI).
//
// GET → {enabled, url, reachable}
// POST {enabled, url} → enregistre CRAWL4AI_URL + le drapeau .internet_enabled
// POST {enabled, url, key} → enregistre CRAWL4AI_URL / CRAWL4AI_KEY + le
// drapeau .internet_enabled. La clé n'est jamais relue par GET (juste un indice).
//
// handleAPIKey expose et pilote la clé d'accès à l'endpoint compatible OpenAI
// (llama-server /v1). GET renvoie l'état ; POST {action:"generate"|"set"|"clear",
@@ -579,8 +580,19 @@ func handleInternet(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled *bool `json:"enabled"`
URL *string `json:"url"`
Key *string `json:"key"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
// Clé d'accès au serveur Crawl4AI : chaîne vide = on l'enlève.
if req.Key != nil {
if err := SetConfigKey("CRAWL4AI_KEY", strings.TrimSpace(*req.Key)); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
reachMu.Lock()
reachURL = ""
reachMu.Unlock()
}
if req.URL != nil {
u := strings.TrimRight(strings.TrimSpace(*req.URL), "/")
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
@@ -598,11 +610,21 @@ func handleInternet(w http.ResponseWriter, r *http.Request) {
}
}
}
// La clé n'est JAMAIS renvoyée en clair : l'UI n'a besoin que de savoir
// qu'elle existe (et de ses 4 derniers caractères pour la reconnaître).
key, hint := crawl4aiKey(), ""
if n := len([]rune(key)); n > 4 {
hint = "…" + string([]rune(key)[n-4:])
} else if key != "" {
hint = "…"
}
sendJSON(w, 200, map[string]any{
"ok": true,
"enabled": internetEnabled(),
"url": crawl4aiURL(),
"reachable": crawlReachable(),
"key_set": key != "",
"key_hint": hint,
})
}
+9 -2
View File
@@ -26,8 +26,15 @@ var webPrefsAllowed = map[string]map[string]bool{
// listé ICI : sinon POST /api/prefs le rejette silencieusement, le serveur
// conserve l'ancienne valeur et loadPrefs() la réapplique à chaque
// rafraîchissement — le thème « saute » alors qu'il est bien en localStorage.
"theme": {"ajean": true, "ajean-dark": true, "dark": true, "light": true, "soft": true, "soft-dark": true, "gpt": true},
"display": {"full": true, "simple": true},
"theme": {"ajean": true, "ajean-dark": true, "dark": true, "light": true, "soft": true, "soft-dark": true, "gpt": true, "gpt-dark": true},
// display : ancien mode « complet / simple », conservé pour les clients
// pas encore rechargés ; remplacé par les quatre drapeaux ci-dessous.
"display": {"full": true, "simple": true},
"hide_reasoning": {"0": true, "1": true},
"hide_tools": {"0": true, "1": true},
"fold_tools": {"0": true, "1": true},
"hide_side": {"0": true, "1": true},
"font": {"auto": true, "mono": true, "system": true, "lexend": true, "serif": true},
}
// loadWebPrefs lit les préférences enregistrées (map vide si absent/illisible).