v0.8.0 : code mort supprime, UI et docs en ajean, release unique

- suppression des fonctions et types inutilises (staticcheck U1000)
- sources UI, workflows, README passes en ajean
- release.yml ne publie plus que les binaires ajean-*
- notes de release redigees
This commit is contained in:
nathaninline committed 2026-08-07 17:12:16 +02:00
1 parent 62ad4155a2
commit 687ec81023
26 files changed
+123 -214

No files matched your search

+1 -3
View File
@@ -100,9 +100,7 @@ func cmdServe(args []string) error {
llmArgs = append(llmArgs, "--api-key", k)
}
// EXTRA_ARGS is appended verbatim, split on whitespace like the shell would.
for _, a := range trimSplit(cfg["EXTRA_ARGS"], " ") {
llmArgs = append(llmArgs, a)
}
llmArgs = append(llmArgs, trimSplit(cfg["EXTRA_ARGS"], " ")...)
// Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
-1
View File
@@ -471,7 +471,6 @@ type searchResult struct {
}
var (
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
wsRe = regexp.MustCompile(`\s+`)
numEntityRe = regexp.MustCompile(`&#(\d+);`)
ddgHeadRe = regexp.MustCompile(`^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$`)
-20
View File
@@ -368,23 +368,3 @@ func cmdMemory(args []string) error {
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
return nil
}
// memorySystemPrompt liste les pages mémoire à injecter quand le mode agent est
// actif, pour que l'IA sache ce qu'elle a déjà retenu.
func memorySystemPrompt(caps Caps) string {
if !caps.Agent {
return ""
}
list := MemList()
var b strings.Builder
b.WriteString("Memory: persistent Markdown pages under MEMORY/. Use mem_search FIRST whenever the user asks about something you might already know (preferences, ongoing projects, past decisions). Open the most relevant page with mem_read. Save anything worth keeping across sessions with mem_add (one topic per page, descriptive kebab-case name); update a page with mem_edit (old must match exactly once). The first content line is a short title (#).\n")
if len(list) == 0 {
b.WriteString("Pages: none yet.")
return b.String()
}
b.WriteString("Pages:\n")
for _, p := range list {
fmt.Fprintf(&b, "- %s: %s\n", p.Name, p.Title)
}
return strings.TrimRight(b.String(), "\n")
}
-5
View File
@@ -112,11 +112,6 @@ func (c MCPServerConfig) Validate() error {
return nil
}
// mcpConfigFile est la représentation sur disque de mcp.json.
type mcpConfigFile struct {
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
}
// mcpConfigMu sérialise les accès concurrents à la déclaration des serveurs
// MCP (l'UI web et les tours de chat peuvent lire/écrire en parallèle).
var mcpConfigMu sync.Mutex
-31
View File
@@ -257,34 +257,3 @@ func writeE2EResp(w http.ResponseWriter, gcm cipher.AEAD, status int, body []byt
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(sealed)))
}
// e2eSeal (côté agent : utilisé uniquement pour les tests) scelle un secret vers
// une clé publique X25519. La version navigateur est en WASM (wasmclient).
func e2eSeal(agentPub, secret []byte) ([]byte, error) {
pub, err := ecdh.X25519().NewPublicKey(agentPub)
if err != nil {
return nil, err
}
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
shared, err := eph.ECDH(pub)
if err != nil {
return nil, err
}
key := sealKey(shared, eph.PublicKey().Bytes(), agentPub)
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ct := gcm.Seal(nil, nonce, secret, nil)
out := append([]byte{}, eph.PublicKey().Bytes()...)
out = append(out, nonce...)
out = append(out, ct...)
return out, nil
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.7.13"
const Version = "0.8.0"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
+1 -9
View File
@@ -32,7 +32,7 @@ var glyphRects = [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}}
// brandIconImage dessine l'icône à la taille n. bg peint le carré arrondi, fg le
// « j ». Les deux peuvent être transparents : c'est ce qui produit l'icône
// « template » de macOS (voir brandTemplatePNG).
// « template » de macOS.
func brandIconImage(n int, bg, fg color.RGBA) *image.RGBA {
const r = 2.0 // rayon des coins, en unités de la grille 12
outside := func(gx, gy float64) bool {
@@ -84,14 +84,6 @@ func encodePNG(img *image.RGBA) []byte {
// Exporté pour le générateur d'icône du .exe (tools/gen-icon).
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) }
// brandTemplatePNG rend la variante « template » attendue par macOS : seule la
// couche alpha compte, le système colore la forme selon le thème de la barre de
// menus. Le « j » est donc DÉCOUPÉ (transparent) dans un carré opaque — sans ça,
// une icône entièrement noire disparaît sur une barre de menus sombre.
func brandTemplatePNG(n int) []byte {
return encodePNG(brandIconImage(n, brandBlack, brandClear))
}
// BrandICO emballe une ou plusieurs tailles PNG dans un conteneur .ico. Windows
// accepte le PNG comme image d'une entrée .ico (alpha conservé pour les coins
// arrondis). Plusieurs tailles = un rendu net partout, de la barre des tâches
+2 -5
View File
@@ -16,9 +16,8 @@ import (
"time"
)
// defaultAjeanHome is the data root when neither $AJEAN_HOME/$AJEAN_HOME nor
// /etc/default/* override it. legacyDefaultHome est son équivalent d'avant le
// renommage, migré au premier lancement (voir sys_migrate.go).
// defaultAjeanHome est la racine des données quand ni $AJEAN_HOME ni
// /etc/default/ajean n'en imposent une.
//
// Linux : /etc/ajean (machine de prod, service systemd lancé par root).
// macOS : /etc/ajean n'est PAS écrivable par une app lancée depuis le Finder
@@ -27,8 +26,6 @@ import (
// un `sudo ajean install` a déjà créé /etc/ajean à notre nom.
func defaultAjeanHome() string { return unixHome("ajean") }
func legacyDefaultHome() string { return unixHome("ajean") }
func unixHome(name string) string {
if runtime.GOOS != "darwin" || os.Geteuid() == 0 {
return "/etc/" + name
+1 -4
View File
@@ -54,12 +54,9 @@ func homeRoot() string {
return os.TempDir()
}
// defaultAjeanHome is the data root when no AJEAN_HOME/AJEAN_HOME is set.
// defaultAjeanHome est la racine des données quand $AJEAN_HOME est absent.
func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") }
// legacyDefaultHome is the pre-rename data root, migrated on first run.
func legacyDefaultHome() string { return filepath.Join(homeRoot(), "ajean") }
// defaultEditor is used by `ajean edit` when $EDITOR is unset.
func defaultEditor() string { return "notepad" }
+36 -36
View File
@@ -22,7 +22,7 @@
garde `display-mode` en second pour les PWA Android/desktop à manifeste. -->
<script>try{var pwa=(window.navigator.standalone===true)||(window.matchMedia&&window.matchMedia('(display-mode:standalone)').matches)||(window.matchMedia&&window.matchMedia('(display-mode:fullscreen)').matches);document.documentElement.setAttribute('data-pwa',pwa?'1':'0');}catch(e){document.documentElement.setAttribute('data-pwa','0');}</script>
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
<script>try{var t=localStorage.getItem('jean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<script>try{var t=localStorage.getItem('ajean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('ajean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style>
/* ===== Palette =====================================================
Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par
@@ -172,7 +172,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.ask-ok.danger{border-color:var(--err);color:var(--err)}
#brand-a{display:inline-flex;line-height:0;color:var(--text);margin-right:2px}
/* Mode agent : le A passe en couleur d'accent (il était masqué hors agent,
la marque se lisant alors « JEAN »). L'état agent reste signalé par sa
la marque se lisant alors « AJEAN »). L'état agent reste signalé par sa
pastille dans les réglages. */
#brand.agent #brand-a{color:var(--accent)}
/* interrupteur (switch) */
@@ -961,7 +961,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
/* ===== Mise en page de la conversation =============================
Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond),
question de l'utilisateur en pastille arrondie à droite, composer en
« pilule », pas d'étiquettes VOUS/JEAN. Ces règles surchargent la mise en
« pilule », pas d'étiquettes VOUS/AJEAN. Ces règles surchargent la mise en
page générique plus haut : elles doivent donc rester APRÈS elle. */
body{font-size:15px}
h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
@@ -975,7 +975,7 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
#chat .msg.user .label{color:var(--dim)}
/* Étiquettes VOUS / JEAN : inutiles ici (l'alignement suffit), sauf pour les
/* Étiquettes VOUS / AJEAN : inutiles ici (l'alignement suffit), sauf pour les
bulles repliables (raisonnement / outils) où le label est le bouton de repli. */
#chat .msg .label{display:none}
#chat .msg.collapsible .label{display:block}
@@ -1227,7 +1227,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
trois îlots pour un même réglage. -->
<div class="slist">
<div class="rowbody">
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>jean</code></div>
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>ajean</code></div>
<div class="row" style="margin:0">
<input id="oai-url" readonly class="io-input">
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
@@ -1257,7 +1257,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div></div>
</details>
<details id="remote-details"><summary><span class="stitle">Accès distant</span><span id="remote-badge" class="sbadge"></span></summary>
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur Jean depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur AJEAN depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
<!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). -->
<div id="remote-off" class="slist"><div class="rowbody">
<p class="muted" style="margin:0 0 8px">Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.</p>
@@ -1764,14 +1764,14 @@ function toggleSide(){ document.getElementById('side').classList.toggle('open');
let _sysT = null;
function saveSys(){
const v = document.getElementById('sysprompt').value;
localStorage.setItem('jean.sys', v);
localStorage.setItem('ajean.sys', v);
clearTimeout(_sysT);
_sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600);
}
async function loadSys(){
try{
const d = await jget('/api/sysprompt');
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('jean.sys', d.text || ''); }
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('ajean.sys', d.text || ''); }
}catch(e){}
}
// ===== Apparence =============================================================
@@ -1785,7 +1785,7 @@ function applyTheme(id){
// désormais la variante, déduite du suffixe.
const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark'));
document.documentElement.setAttribute('data-theme', dark?'dark':'light');
try{ localStorage.setItem('jean-theme', dark?'dark':'light'); }catch(e){}
try{ localStorage.setItem('ajean-theme', dark?'dark':'light'); }catch(e){}
const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark;
}
function toggleThemeDark(){
@@ -1793,7 +1793,7 @@ function toggleThemeDark(){
savePrefs();
}
function initTheme(){
let id='light'; try{ id=localStorage.getItem('jean-theme')||'light'; }catch(e){}
let id='light'; try{ id=localStorage.getItem('ajean-theme')||'light'; }catch(e){}
applyTheme(id);
}
// ===== Affichage ============================================================
@@ -1810,7 +1810,7 @@ const VIEW_OPTS=[
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('jean-'+id, on?'1':'0'); }catch(e){}
try{ localStorage.setItem('ajean-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
if(id==='hide-side' && !on){
@@ -1822,18 +1822,18 @@ function applyView(id, on){
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){
VIEW_OPTS.forEach(o=>{
let v=null; try{ v=localStorage.getItem('jean-'+o.id); }catch(e){}
let v=null; try{ v=localStorage.getItem('ajean-'+o.id); }catch(e){}
applyView(o.id, v==='1');
});
}
// ===== Persistance côté serveur (partagée entre appareils) ==================
// L'apparence est aussi enregistrée sur la machine jean (/api/prefs) : ainsi le
// L'apparence est aussi enregistrée sur la machine ajean (/api/prefs) : ainsi le
// thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le
// localStorage reste utilisé pour appliquer instantanément au chargement (sans
// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité).
function savePrefs(){
let theme='light';
try{ theme=localStorage.getItem('jean-theme')||'light'; }catch(e){}
try{ theme=localStorage.getItem('ajean-theme')||'light'; }catch(e){}
const p={theme};
VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; });
jpost('/api/prefs', p).catch(()=>{});
@@ -1855,7 +1855,7 @@ async function loadPrefs(){
}
}catch(e){}
}
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); });
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('ajean.sys') || ''; loadSys(); restoreChat(); });
// Pastille d'état d'une section du menu (mode agent, internet, MCP, distant).
// `state` : true = actif (pilule pleine), false = inactif (contour), 'warn'/'err'
// = état signalé, null = pastille masquée. Un seul endroit décide de leur allure.
@@ -1907,7 +1907,7 @@ function _askKey(e){
else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); }
}
function _openAsk(kind, message, opts){
// Compat 2 conventions : (message, opts) [UI jean] ET l'objet unique
// Compat 2 conventions : (message, opts) [UI ajean] ET l'objet unique
// {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html,
// où cette fonction remplace le window.askConfirm du bootstrap boîte noire).
if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; }
@@ -1939,14 +1939,14 @@ function askPrompt(message,opts){ return _openAsk('prompt',message,opts); }
function askAlert(message,opts){ return _openAsk('alert',message,opts); }
// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*.
// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage.
let TOKEN = localStorage.getItem('jean.key') || '';
let TOKEN = localStorage.getItem('ajean.key') || '';
function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; }
// Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link.
// Garde les chemins absolus (/api/…) corrects derrière le tunnel.
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le
// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint
// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS :
// dès qu'on laisse traîner plusieurs onglets AJEAN) reste suspendue POUR TOUJOURS :
// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher.
// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal
// et ne sont donc jamais concernés.
@@ -1969,8 +1969,8 @@ async function jfetch(u, opts){
}
finally{ if(timer) clearTimeout(timer); }
if(r.status === 401){
const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'});
if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'});
if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
}
return r;
}
@@ -2154,7 +2154,7 @@ const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list
function reserveHeights(){
HRESERVE.forEach(id=>{
try{
const h=parseInt(localStorage.getItem('jean-h-'+id)||'0',10);
const h=parseInt(localStorage.getItem('ajean-h-'+id)||'0',10);
const el=document.getElementById(id);
if(el && h>0) el.style.minHeight=h+'px';
}catch(e){}
@@ -2164,7 +2164,7 @@ function releaseHeights(){
HRESERVE.forEach(id=>{
const el=document.getElementById(id); if(!el) return;
el.style.minHeight='';
try{ localStorage.setItem('jean-h-'+id, String(el.offsetHeight||0)); }catch(e){}
try{ localStorage.setItem('ajean-h-'+id, String(el.offsetHeight||0)); }catch(e){}
});
}
document.addEventListener('DOMContentLoaded', reserveHeights);
@@ -2243,7 +2243,7 @@ async function loadPresets(){
});
}
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « jean » → « ajean ».
// Quand il est actif, un « a » blanc apparaît en fondu devant « ajean » → « ajean ».
async function loadAgent(){
const s=await jget('/api/agent');
const on = s.enabled;
@@ -2662,7 +2662,7 @@ async function populateModelPicker(){
const list = await jget('/api/models');
const cur = currentModelInTextarea();
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, JEAN_HOME d'abord (l'API les renvoie dans cet ordre).
// regroupe par dossier, AJEAN_HOME d'abord (l'API les renvoie dans cet ordre).
const groups = [];
for(const m of (list||[])){
let g = groups.find(x => x.dir === m.dir);
@@ -2681,7 +2681,7 @@ async function populateModelPicker(){
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
html += groups.length > 1
? '<optgroup label="'+escHtml(g.home ? 'dossier jean' : g.dir)+'">'+opts+'</optgroup>'
? '<optgroup label="'+escHtml(g.home ? 'dossier ajean' : g.dir)+'">'+opts+'</optgroup>'
: opts;
}
// MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré :
@@ -2692,7 +2692,7 @@ async function populateModelPicker(){
sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true);
}
// ---- Dossiers de modèles : JEAN_HOME + dossiers ajoutés (disque externe…) ---
// ---- Dossiers de modèles : AJEAN_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){
const b = document.getElementById('m-dirs-body');
@@ -2721,7 +2721,7 @@ async function populateModelDirs(){
info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')';
info.textContent = '(' + n + free + (x.home ? ', dossier ajean' : '') + ')';
row.append(p, info);
if(!x.home){
const del = document.createElement('button');
@@ -2792,13 +2792,13 @@ function underDir(p, dir){
}
let beFastPath = '', beOptPath = '', beFastDir = '';
async function populateBackend(){
// Chemins des deux moteurs gérés + liste des backends détectés (dossier jean).
// Chemins des deux moteurs gérés + liste des backends détectés (dossier ajean).
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
beOptPath = lc.bin || '';
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
// le dossier backends de jean (l'utilisateur peut y déposer son propre build).
// le dossier backends de ajean (l'utilisateur peut y déposer son propre build).
const detected = await jget('/api/backends');
const sel = document.getElementById('m-backend-detected');
let html = '<option value="">— ou choisir un backend détecté —</option>';
@@ -3132,7 +3132,7 @@ function populateSettings(){
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de jean) ne correspondrait à aucune option et serait silencieusement effacée
// de ajean) ne correspondrait à aucune option et serait silencieusement effacée
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
// la perdre.
function setSpecType(v){
@@ -3328,7 +3328,7 @@ function watchDownload(fname){
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
await Promise.all([populateModelPicker(), populateDlDirs()]);
// Le modèle a pu atterrir hors du dossier jean : l'option porte alors le
// Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le
// chemin complet, pas le simple nom de fichier.
const sel = document.getElementById('m-model');
const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname));
@@ -3483,7 +3483,7 @@ function setLabelCounts(el, add, del){
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
lab.appendChild(cnt);
}
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/AJEAN
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
@@ -3644,7 +3644,7 @@ async function compactContext(){
}
// Persistance de la conversation : on garde user+assistant en localStorage pour
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)); }catch(e){} }
function saveChat(){ try{ localStorage.setItem('ajean.chat', JSON.stringify(msgs)); }catch(e){} }
// Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement
// permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte,
// appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de
@@ -3723,7 +3723,7 @@ function autoGrow(ta){
ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden';
}
// ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
// L'historique et la génération vivent sur le serveur jean. Le client ouvre un
// L'historique et la génération vivent sur le serveur ajean. Le client ouvre un
// flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
@@ -3921,7 +3921,7 @@ function handleDelta(d){
}
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
// re-téléchargement complet après une coupure / bascule d'appareil).
// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé
// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet AJEAN laissé
// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
// au-delà, toute requête (journal, installation du moteur…) reste en file
// d'attente sans jamais partir ni échouer — un blocage silencieux très
@@ -4477,7 +4477,7 @@ async function deleteMcp(){
// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
// l'agent local (/api/link/connect) qui fait le `jean link` (token + service).
// l'agent local (/api/link/connect) qui fait le `ajean link` (token + service).
const AJEAN_APP_ORIGIN = 'https://app.ajean.link';
+3 -3
View File
@@ -22,7 +22,7 @@
garde `display-mode` en second pour les PWA Android/desktop à manifeste. -->
<script>try{var pwa=(window.navigator.standalone===true)||(window.matchMedia&&window.matchMedia('(display-mode:standalone)').matches)||(window.matchMedia&&window.matchMedia('(display-mode:fullscreen)').matches);document.documentElement.setAttribute('data-pwa',pwa?'1':'0');}catch(e){document.documentElement.setAttribute('data-pwa','0');}</script>
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
<script>try{var t=localStorage.getItem('jean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<script>try{var t=localStorage.getItem('ajean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('ajean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style>
@@CSS@@
</style></head><body>
@@ -180,7 +180,7 @@
trois îlots pour un même réglage. -->
<div class="slist">
<div class="rowbody">
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>jean</code></div>
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>ajean</code></div>
<div class="row" style="margin:0">
<input id="oai-url" readonly class="io-input">
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
@@ -210,7 +210,7 @@
</div></div>
</details>
<details id="remote-details"><summary><span class="stitle">Accès distant</span><span id="remote-badge" class="sbadge"></span></summary>
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur Jean depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur AJEAN depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
<!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). -->
<div id="remote-off" class="slist"><div class="rowbody">
<p class="muted" style="margin:0 0 8px">Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.</p>
+2 -2
View File
@@ -56,13 +56,13 @@ function toggleSide(){ document.getElementById('side').classList.toggle('open');
let _sysT = null;
function saveSys(){
const v = document.getElementById('sysprompt').value;
localStorage.setItem('jean.sys', v);
localStorage.setItem('ajean.sys', v);
clearTimeout(_sysT);
_sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600);
}
async function loadSys(){
try{
const d = await jget('/api/sysprompt');
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('jean.sys', d.text || ''); }
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('ajean.sys', d.text || ''); }
}catch(e){}
}
+4 -4
View File
@@ -9,7 +9,7 @@ function applyTheme(id){
// désormais la variante, déduite du suffixe.
const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark'));
document.documentElement.setAttribute('data-theme', dark?'dark':'light');
try{ localStorage.setItem('jean-theme', dark?'dark':'light'); }catch(e){}
try{ localStorage.setItem('ajean-theme', dark?'dark':'light'); }catch(e){}
const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark;
}
function toggleThemeDark(){
@@ -17,7 +17,7 @@ function toggleThemeDark(){
savePrefs();
}
function initTheme(){
let id='light'; try{ id=localStorage.getItem('jean-theme')||'light'; }catch(e){}
let id='light'; try{ id=localStorage.getItem('ajean-theme')||'light'; }catch(e){}
applyTheme(id);
}
// ===== Affichage ============================================================
@@ -34,7 +34,7 @@ const VIEW_OPTS=[
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('jean-'+id, on?'1':'0'); }catch(e){}
try{ localStorage.setItem('ajean-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
if(id==='hide-side' && !on){
@@ -46,7 +46,7 @@ function applyView(id, on){
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){
VIEW_OPTS.forEach(o=>{
let v=null; try{ v=localStorage.getItem('jean-'+o.id); }catch(e){}
let v=null; try{ v=localStorage.getItem('ajean-'+o.id); }catch(e){}
applyView(o.id, v==='1');
});
}
+3 -3
View File
@@ -1,11 +1,11 @@
// ===== Persistance côté serveur (partagée entre appareils) ==================
// L'apparence est aussi enregistrée sur la machine jean (/api/prefs) : ainsi le
// L'apparence est aussi enregistrée sur la machine ajean (/api/prefs) : ainsi le
// thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le
// localStorage reste utilisé pour appliquer instantanément au chargement (sans
// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité).
function savePrefs(){
let theme='light';
try{ theme=localStorage.getItem('jean-theme')||'light'; }catch(e){}
try{ theme=localStorage.getItem('ajean-theme')||'light'; }catch(e){}
const p={theme};
VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; });
jpost('/api/prefs', p).catch(()=>{});
@@ -27,4 +27,4 @@ async function loadPrefs(){
}
}catch(e){}
}
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); });
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('ajean.sys') || ''; loadSys(); restoreChat(); });
+1 -1
View File
@@ -49,7 +49,7 @@ function _askKey(e){
else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); }
}
function _openAsk(kind, message, opts){
// Compat 2 conventions : (message, opts) [UI jean] ET l'objet unique
// Compat 2 conventions : (message, opts) [UI ajean] ET l'objet unique
// {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html,
// où cette fonction remplace le window.askConfirm du bootstrap boîte noire).
if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; }
+4 -4
View File
@@ -1,13 +1,13 @@
// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*.
// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage.
let TOKEN = localStorage.getItem('jean.key') || '';
let TOKEN = localStorage.getItem('ajean.key') || '';
function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; }
// Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link.
// Garde les chemins absolus (/api/…) corrects derrière le tunnel.
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le
// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint
// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS :
// dès qu'on laisse traîner plusieurs onglets AJEAN) reste suspendue POUR TOUJOURS :
// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher.
// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal
// et ne sont donc jamais concernés.
@@ -30,8 +30,8 @@ async function jfetch(u, opts){
}
finally{ if(timer) clearTimeout(timer); }
if(r.status === 401){
const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'});
if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'});
if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
}
return r;
}
+3 -3
View File
@@ -9,7 +9,7 @@ const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list
function reserveHeights(){
HRESERVE.forEach(id=>{
try{
const h=parseInt(localStorage.getItem('jean-h-'+id)||'0',10);
const h=parseInt(localStorage.getItem('ajean-h-'+id)||'0',10);
const el=document.getElementById(id);
if(el && h>0) el.style.minHeight=h+'px';
}catch(e){}
@@ -19,7 +19,7 @@ function releaseHeights(){
HRESERVE.forEach(id=>{
const el=document.getElementById(id); if(!el) return;
el.style.minHeight='';
try{ localStorage.setItem('jean-h-'+id, String(el.offsetHeight||0)); }catch(e){}
try{ localStorage.setItem('ajean-h-'+id, String(el.offsetHeight||0)); }catch(e){}
});
}
document.addEventListener('DOMContentLoaded', reserveHeights);
@@ -98,7 +98,7 @@ async function loadPresets(){
});
}
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « jean » → « ajean ».
// Quand il est actif, un « a » blanc apparaît en fondu devant « ajean » → « ajean ».
async function loadAgent(){
const s=await jget('/api/agent');
const on = s.enabled;
+8 -8
View File
@@ -194,7 +194,7 @@ async function populateModelPicker(){
const list = await jget('/api/models');
const cur = currentModelInTextarea();
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, JEAN_HOME d'abord (l'API les renvoie dans cet ordre).
// regroupe par dossier, AJEAN_HOME d'abord (l'API les renvoie dans cet ordre).
const groups = [];
for(const m of (list||[])){
let g = groups.find(x => x.dir === m.dir);
@@ -213,7 +213,7 @@ async function populateModelPicker(){
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
html += groups.length > 1
? '<optgroup label="'+escHtml(g.home ? 'dossier jean' : g.dir)+'">'+opts+'</optgroup>'
? '<optgroup label="'+escHtml(g.home ? 'dossier ajean' : g.dir)+'">'+opts+'</optgroup>'
: opts;
}
// MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré :
@@ -224,7 +224,7 @@ async function populateModelPicker(){
sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true);
}
// ---- Dossiers de modèles : JEAN_HOME + dossiers ajoutés (disque externe…) ---
// ---- Dossiers de modèles : AJEAN_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){
const b = document.getElementById('m-dirs-body');
@@ -253,7 +253,7 @@ async function populateModelDirs(){
info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')';
info.textContent = '(' + n + free + (x.home ? ', dossier ajean' : '') + ')';
row.append(p, info);
if(!x.home){
const del = document.createElement('button');
@@ -324,13 +324,13 @@ function underDir(p, dir){
}
let beFastPath = '', beOptPath = '', beFastDir = '';
async function populateBackend(){
// Chemins des deux moteurs gérés + liste des backends détectés (dossier jean).
// Chemins des deux moteurs gérés + liste des backends détectés (dossier ajean).
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
beOptPath = lc.bin || '';
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
// le dossier backends de jean (l'utilisateur peut y déposer son propre build).
// le dossier backends de ajean (l'utilisateur peut y déposer son propre build).
const detected = await jget('/api/backends');
const sel = document.getElementById('m-backend-detected');
let html = '<option value="">— ou choisir un backend détecté —</option>';
@@ -664,7 +664,7 @@ function populateSettings(){
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de jean) ne correspondrait à aucune option et serait silencieusement effacée
// de ajean) ne correspondrait à aucune option et serait silencieusement effacée
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
// la perdre.
function setSpecType(v){
@@ -860,7 +860,7 @@ function watchDownload(fname){
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
await Promise.all([populateModelPicker(), populateDlDirs()]);
// Le modèle a pu atterrir hors du dossier jean : l'option porte alors le
// Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le
// chemin complet, pas le simple nom de fichier.
const sel = document.getElementById('m-model');
const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname));
+2 -2
View File
@@ -115,7 +115,7 @@ function setLabelCounts(el, add, del){
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
lab.appendChild(cnt);
}
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/AJEAN
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
@@ -276,7 +276,7 @@ async function compactContext(){
}
// Persistance de la conversation : on garde user+assistant en localStorage pour
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)); }catch(e){} }
function saveChat(){ try{ localStorage.setItem('ajean.chat', JSON.stringify(msgs)); }catch(e){} }
// Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement
// permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte,
// appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de
+2 -2
View File
@@ -1,5 +1,5 @@
// ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
// L'historique et la génération vivent sur le serveur jean. Le client ouvre un
// L'historique et la génération vivent sur le serveur ajean. Le client ouvre un
// flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
@@ -197,7 +197,7 @@ function handleDelta(d){
}
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
// re-téléchargement complet après une coupure / bascule d'appareil).
// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé
// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet AJEAN laissé
// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
// au-delà, toute requête (journal, installation du moteur…) reste en file
// d'attente sans jamais partir ni échouer — un blocage silencieux très
+1 -1
View File
@@ -1,7 +1,7 @@
// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
// l'agent local (/api/link/connect) qui fait le `jean link` (token + service).
// l'agent local (/api/link/connect) qui fait le `ajean link` (token + service).
const AJEAN_APP_ORIGIN = 'https://app.ajean.link';
+3 -3
View File
@@ -146,7 +146,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.ask-ok.danger{border-color:var(--err);color:var(--err)}
#brand-a{display:inline-flex;line-height:0;color:var(--text);margin-right:2px}
/* Mode agent : le A passe en couleur d'accent (il était masqué hors agent,
la marque se lisant alors « JEAN »). L'état agent reste signalé par sa
la marque se lisant alors « AJEAN »). L'état agent reste signalé par sa
pastille dans les réglages. */
#brand.agent #brand-a{color:var(--accent)}
/* interrupteur (switch) */
@@ -935,7 +935,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
/* ===== Mise en page de la conversation =============================
Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond),
question de l'utilisateur en pastille arrondie à droite, composer en
« pilule », pas d'étiquettes VOUS/JEAN. Ces règles surchargent la mise en
« pilule », pas d'étiquettes VOUS/AJEAN. Ces règles surchargent la mise en
page générique plus haut : elles doivent donc rester APRÈS elle. */
body{font-size:15px}
h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
@@ -949,7 +949,7 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
#chat .msg.user .label{color:var(--dim)}
/* Étiquettes VOUS / JEAN : inutiles ici (l'alignement suffit), sauf pour les
/* Étiquettes VOUS / AJEAN : inutiles ici (l'alignement suffit), sauf pour les
bulles repliables (raisonnement / outils) où le label est le bouton de repli. */
#chat .msg .label{display:none}
#chat .msg.collapsible .label{display:block}