v0.6.3 : texte non coupe autour des outils, garde-fous d'outils retires, retours UI

- Flux : le reliquat retenu par la garde « </think> » est vidé dès qu'un appel
  d'outil arrive, au lieu de la fin du flux. Sans ça il partait APRÈS
  l'événement d'outil et la fin de phrase — souvent coupée en plein mot —
  atterrissait dans une bulle séparée sous l'outil.
- Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle « appel
  identique répété » sont SUPPRIMÉS : ils coupaient des tours légitimes (une
  recherche enchaîne facilement des dizaines d'appels). Le bouton stop reste
  le seul frein. Endpoint /api/agent/tool-limit et son interrupteur retirés.
- Prompt système : « You are Jean » (majuscule) — le modèle recopiait la casse
  et se présentait en minuscule.
- UI : titres du menu tous en monospace capitales via une seule règle (la casse
  vient du CSS, plus du HTML).
- UI : le message envoyé s'affiche tout de suite en gris (« envoi… ») et
  s'éclaircit quand le serveur le confirme ; il ne disparaît plus pendant
  l'aller-retour, et il est retiré si l'envoi échoue.
- UI : voile de chargement du fil (« chargement de la conversation… » /
  « connexion au serveur… ») — un chat vide ne se confond plus avec une
  connexion lente.
- UI mobile : hauteur de coque 100dvh hors PWA (100vh en PWA), et plus aucun
  défilement du document. Le mode PWA est détecté par navigator.standalone :
  le média display-mode ne matche pas, faute de manifeste.
This commit is contained in:
nathaninline committed 2026-07-29 18:49:53 +02:00
1 parent a3a9c36367
commit 92c350bdb8
16 files changed
+297 -134

No files matched your search

+1 -2
View File
@@ -178,8 +178,7 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramDa
| `CUDA_VISIBLE_DEVICES` | GPU à utiliser (réglé par `jean gpu`) | tous | | `CUDA_VISIBLE_DEVICES` | GPU à utiliser (réglé par `jean gpu`) | tous |
| `KV_TYPE` (`_K`/`_V`) | quantization du cache KV | — | | `KV_TYPE` (`_K`/`_V`) | quantization du cache KV | — |
| `REASONING` | passthrough du mode raisonnement | — | | `REASONING` | passthrough du mode raisonnement | — |
| `REASONING_BUDGET` | plafond de tokens de réflexion passé à llama-server ; `-1` = illimité (l'anti-boucle est géré côté agent) | `-1` | | `REASONING_BUDGET` | plafond de tokens de réflexion passé à llama-server ; `-1` = illimité | `-1` |
| `TOOL_LIMIT` | plafond d'appels d'outils par tour en mode agent ; `off` = quasi illimité (réglable aussi depuis l'UI) | activé |
| `CRAWL4AI_URL` | URL du serveur Crawl4AI pour l'accès internet (réglé par `jean internet url`) | — | | `CRAWL4AI_URL` | URL du serveur Crawl4AI pour l'accès internet (réglé par `jean internet url`) | — |
| `MEM_MODE` | mode mémoire de l'IA : `off` / `ondemand` / `always` (réglé par `jean memory`) | `always` | | `MEM_MODE` | mode mémoire de l'IA : `off` / `ondemand` / `always` (réglé par `jean memory`) | `always` |
| `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — | | `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — |
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": { "FileVersion": {
"Major": 0, "Major": 0,
"Minor": 6, "Minor": 6,
"Patch": 2, "Patch": 3,
"Build": 0 "Build": 0
}, },
"ProductVersion": { "ProductVersion": {
"Major": 0, "Major": 0,
"Minor": 6, "Minor": 6,
"Patch": 2, "Patch": 3,
"Build": 0 "Build": 0
}, },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "jean.exe", "OriginalFilename": "jean.exe",
"ProductName": "AJEAN", "ProductName": "AJEAN",
"ProductVersion": "0.6.2", "ProductVersion": "0.6.3",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
}, },
"VarFileInfo": { "VarFileInfo": {
+3 -20
View File
@@ -164,26 +164,9 @@ func SetConfigKey(key, value string) error {
return os.WriteFile(confPath(), []byte(content), 0o644) return os.WriteFile(confPath(), []byte(content), 0o644)
} }
// toolLimitEnabled reports whether the per-turn tool-call cap is active. // Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été
// Default: true (limité). Only an explicit "off"/"false"/"0"/"no" in config.env // retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
// (TOOL_LIMIT) disables it — anything else keeps the safety cap on. // seul frein.
func toolLimitEnabled() bool {
switch strings.ToLower(strings.TrimSpace(ReadConfig()["TOOL_LIMIT"])) {
case "off", "false", "0", "no", "non":
return false
}
return true
}
// toolCallLimit returns the max number of agentic tool-call iterations per turn.
// 8 when the limit is on (default), a very high ceiling when the user disabled it
// from the web UI — the repeated-call anti-loop still guards against runaways.
func toolCallLimit() int {
if toolLimitEnabled() {
return 8
}
return 1000
}
// LLMPort returns the configured server port (config.env PORT), default 8080. // LLMPort returns the configured server port (config.env PORT), default 8080.
func LLMPort() int { func LLMPort() int {
+3 -1
View File
@@ -39,7 +39,9 @@ func baseSystemPrompt(caps Caps) string {
// sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think> // sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think>
// puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts » // puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts »
// mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler. // mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler.
b.WriteString("You are jean, an expert assistant operating directly on this machine with real tools.") // « Jean » avec une majuscule : c'est un nom propre, et le modèle recopie
// littéralement la casse d'ici quand il se présente (« je suis jean »).
b.WriteString("You are Jean, an expert assistant operating directly on this machine with real tools.")
if caps.Mem == MemAlways { if caps.Mem == MemAlways {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.") b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
} }
+19 -31
View File
@@ -414,10 +414,6 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// dépassement de la fenêtre de contexte après de gros résultats d'outils), on // dépassement de la fenêtre de contexte après de gros résultats d'outils), on
// compacte l'historique en vol et on rejoue le tour — une seule fois. // compacte l'historique en vol et on rejoue le tour — une seule fois.
compactedRetry := false compactedRetry := false
// Anti-loop net: if the model re-emits the exact same tool call (name+args)
// several times, it's stuck — break instead of spinning to the iteration cap.
lastSig := ""
repeatSig := 0
// Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas // Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas
// rejouer deux fois exactement la même écriture dans un même échange. // rejouer deux fois exactement la même écriture dans un même échange.
doneCalls := map[string]string{} doneCalls := map[string]string{}
@@ -426,12 +422,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge // (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
// explicite au lieu d'afficher « pas de réponse ». // explicite au lieu d'afficher « pas de réponse ».
nudged := false nudged := false
// Plafond d'itérations d'appels d'outils. Activé par défaut (8) pour éviter // Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours
// qu'un modèle parti en vrille n'enchaîne les appels indéfiniment ; l'anti- // parfaitement légitimes (une recherche enchaîne facilement des dizaines
// boucle (lastSig) protège toujours même quand la limite est désactivée via // d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule
// l'UI (TOOL_LIMIT=off → plafond très haut, quasi illimité). // le contexte — c'est un choix assumé.
maxIter := toolCallLimit() for iter := 0; ; iter++ {
for iter := 0; iter < maxIter; iter++ {
payload := map[string]any{ payload := map[string]any{
"model": "jean", "model": "jean",
"messages": messages, "messages": messages,
@@ -557,6 +552,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
finishReason = ch.FinishReason finishReason = ch.FinishReason
} }
if len(ch.Delta.ToolCalls) > 0 { if len(ch.Delta.ToolCalls) > 0 {
// Un appel d'outil clôt le texte : on vide MAINTENANT le reliquat
// retenu par la garde « </think> » (voir plus bas). Sinon il n'était
// émis qu'en fin de flux, donc APRÈS l'événement d'outil, et l'UI
// (qui coupe la bulle en cours à chaque tool_used) affichait la fin
// de la phrase — souvent coupée en plein mot — dans une bulle
// séparée sous l'outil.
if thinkOpen && thinkTail.Len() > 0 {
tail := thinkTail.String()
thinkTail.Reset()
if !cb(StreamEvent{Content: tail}) {
aborted = true
break
}
}
for i, tc := range ch.Delta.ToolCalls { for i, tc := range ch.Delta.ToolCalls {
// llama.cpp's stream may omit index; fall back to slot i. // llama.cpp's stream may omit index; fall back to slot i.
idx := i idx := i
@@ -705,25 +714,6 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
} }
messages = append(messages, assistant) messages = append(messages, assistant)
extra = append(extra, assistant) extra = append(extra, assistant)
// Loop guard: same exact call(s) as last turn? Count it; on the 3rd
// identical turn, stop so we don't churn the same command forever.
sig := strings.Builder{}
for _, tc := range tcs {
sig.WriteString(tc.Function.Name)
sig.WriteByte('\x00')
sig.WriteString(tc.Function.Arguments)
sig.WriteByte('\n')
}
if s := sig.String(); s == lastSig {
repeatSig++
if repeatSig >= 2 {
cb(StreamEvent{Content: "\n\n[stop: appel d'outil répété en boucle — " + tcs[0].Function.Name + "]"})
return extra, nil
}
} else {
lastSig = s
repeatSig = 0
}
// 2. Execute each tool locally and append a "tool" reply. // 2. Execute each tool locally and append a "tool" reply.
for _, tc := range tcs { for _, tc := range tcs {
var args map[string]any var args map[string]any
@@ -887,8 +877,6 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
} }
return extra, nil return extra, nil
} }
cb(StreamEvent{Content: "\n\n[stop: trop d'appels d'outils]"})
return extra, nil
} }
// healthCheck pings llama.cpp's /health endpoint. // healthCheck pings llama.cpp's /health endpoint.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
) )
const Version = "0.6.2" const Version = "0.6.3"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() { func Main() {
+133 -28
View File
@@ -14,6 +14,13 @@
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. --> (iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
<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='%23000'/><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="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='%23000'/><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,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABBUlEQVR42u3SAQkAAAwCQfuX3lI4GNxF0E8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIArU2NbcYhDHOIQhzgQB+JAHIgDcSAOxIE4xCEOcYhDHIgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQByIQxziEIc4xIE4EAfiQByIA3EgDsQhDnGIQxziEIc4EAfiQByIAxciDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAciEMc4hCHOMQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADfLBHMhi4TnG6EAAAAAElFTkSuQmCC"> <link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABBUlEQVR42u3SAQkAAAwCQfuX3lI4GNxF0E8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIArU2NbcYhDHOIQhzgQB+JAHIgDcSAOxIE4xCEOcYhDHIgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQByIQxziEIc4xIE4EAfiQByIA3EgDsQhDnGIQxziEIc4EAfiQByIAxciDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAciEMc4hCHOMQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADfLBHMhi4TnG6EAAAAAElFTkSuQmCC">
<!-- Marque le mode PWA AVANT le rendu : la hauteur de coque et le défilement du
document en dépendent (voir styles.css). ⚠️ On NE PEUT PAS se fier au média
`display-mode:standalone` : WebKit l'adosse au `display` du MANIFESTE, or cette
app n'en a pas (elle est en `apple-mobile-web-app-capable`) — en PWA iOS il
répond donc `browser`. `navigator.standalone` est le signal juste ici ; on
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é. --> <!-- 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'].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('jean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style> <style>
@@ -21,24 +28,50 @@
Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par
défaut, sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI défaut, sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI
passent par ces variables ; rien d'autre ne code une couleur en dur. */ passent par ces variables ; rien d'autre ne code une couleur en dur. */
:root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)} :root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)}
[data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)} [data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)}
*{box-sizing:border-box} *{box-sizing:border-box}
/* ⚠️ NE PAS mettre overflow:hidden sur html/body (tenté puis retiré le /* ⚠️ overflow:hidden sur html/body est INTERDIT EN PWA (tenté puis retiré le
2026-07-28) : en PWA iOS, si le document ne peut plus défiler, WebKit décale 2026-07-28) : en PWA iOS, si le document ne peut plus défiler, WebKit décale
TOUTE la mise en page à l'ouverture du clavier pour montrer le champ actif — TOUTE la mise en page à l'ouverture du clavier pour montrer le champ actif —
la carte de saisie se retrouve plaquée en haut de l'écran. Le rebond la carte de saisie se retrouve plaquée en haut de l'écran. Le rebond
élastique du document est le prix à payer ; il est cosmétique, le clavier élastique du document est le prix à payer ; il est cosmétique, le clavier
ne l'est pas. Le défilement du fil reste borné par #chat (overscroll:contain). */ ne l'est pas.
/* Hauteur de la coque : 100vh, et SURTOUT ni 100dvh ni 100%. Laisser le document défiler se paie cher, et EN PWA AUSSI : la coque y fait
Mesuré sur iPhone en PWA (2026-07-28) : la vue web couvre tout l'écran 956 pt (obligatoire pour que la carte atteigne le bas de l'écran) alors que la
(956 pt — le contenu passe bien sous l'horloge), mais la fenêtre de mise en fenêtre de mise en page n'en fait que 894 → il reste 62 pt de document
page ne fait que 894 pt et reste calée en haut. Or 100dvh ET 100% valent tous défilable en permanence, même avec un fil VIDE, et la carte de saisie — posée
deux 894 → il restait une bande morte de 62 pt (= la zone sûre du haut) en bas en bottom:0 dans le body — part avec au lieu de rester collée en bas.
de l'écran, sous la carte de saisie. 100vh se réfère au GRAND viewport, celui On coupe donc le défilement du document PARTOUT (2026-07-29). Le fil, lui,
qui vaut bien 956 : c'est la seule unité juste ici. défile toujours dans #chat, qui borne son rebond (overscroll:contain).
⚠️ Ne pas y ajouter overflow:hidden : voir plus bas, ça casse le clavier iOS. */ ⚠️ SI LE CLAVIER iOS SE REMET À TOUT DÉCALER EN PWA, c'est ce overflow:hidden
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;overscroll-behavior:none;display:flex;font-size:14px} qu'il faut rouvrir en premier — c'était le compromis d'origine. */
/* Hauteur de la coque : elle DIFFÈRE entre PWA et navigateur, il faut les deux.
- PWA (écran d'accueil) : 100vh, et SURTOUT ni 100dvh ni 100%. Mesuré sur
iPhone (2026-07-28) : la vue web couvre tout l'écran (956 pt — le contenu
passe bien sous l'horloge), mais la fenêtre de mise en page ne fait que
894 pt et reste calée en haut. Or 100dvh ET 100% valent tous deux 894 → il
restait une bande morte de 62 pt (= la zone sûre du haut) en bas de l'écran,
sous la carte de saisie. 100vh se réfère au GRAND viewport (956) : la seule
unité juste dans ce cas.
- Navigateur (barre d'URL + barre d'outils visibles) : 100vh vaut justement ce
GRAND viewport, donc la coque dépasse la zone réellement visible → le
document défile alors que le fil est vide, et le bas de la carte de saisie
(modèle / contexte) est coupé. Ici c'est 100dvh qu'il faut : la hauteur
visible du moment.
D'où : 100dvh par défaut, et 100vh UNIQUEMENT en PWA.
⚠️ Le tri se fait sur l'attribut html[data-pwa] (pose par le script du <head>),
PAS sur le media `display-mode:standalone` : WebKit adosse ce media au `display`
du MANIFESTE, or cette app n'en a pas (elle vit en apple-mobile-web-app-capable).
En PWA iOS il repond donc `browser`, la branche navigateur s'appliquait, et la
bande morte de 62 pt sous la carte de saisie etait de retour (constate le
2026-07-29).
⚠️ Ne pas y ajouter overflow:hidden : voir plus haut, ça casse le clavier iOS. */
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;overscroll-behavior:none;display:flex;font-size:14px}
html[data-pwa="1"] body{height:100vh}
/* Seul #chat défile ; le document, lui, est figé (cf. l'avertissement ci-dessus).
Exception PWA : on lui rend son défilement, sinon le clavier iOS casse tout. */
html,body{overflow:hidden}
/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne /* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne
pas passer sous l'encoche / la barre système / l'indicateur d'accueil. Les pas passer sous l'encoche / la barre système / l'indicateur d'accueil. Les
env(safe-area-inset-*) valent 0 hors PWA, donc aucun impact ailleurs. env(safe-area-inset-*) valent 0 hors PWA, donc aucun impact ailleurs.
@@ -103,7 +136,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600} .skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
.addbtn{font-size:11px;padding:3px 11px 4px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;line-height:1.2} .addbtn{font-size:11px;padding:3px 11px 4px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;line-height:1.2}
.addbtn:hover{border-color:var(--accent);color:var(--accent)} .addbtn:hover{border-color:var(--accent);color:var(--accent)}
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;margin-left:4px} .mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait /* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
(le trait est réservé aux sections principales — cf. `details`). La séparation (le trait est réservé aux sections principales — cf. `details`). La séparation
visuelle vient de la carte englobante .gated-block. */ visuelle vient de la carte englobante .gated-block. */
@@ -182,7 +215,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
.help:hover{color:var(--accent);border-color:var(--accent)} .help:hover{color:var(--accent);border-color:var(--accent)}
/* La bulle est ancrée à la ligne (.switchrow position:relative), pas au « ? », /* La bulle est ancrée à la ligne (.switchrow position:relative), pas au « ? »,
pour rester alignée au bord gauche du panneau et ne pas déborder. */ pour rester alignée au bord gauche du panneau et ne pas déborder. */
.help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;cursor:auto} .help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;font-family:var(--font);cursor:auto}
.help:hover>.tip,.help:focus>.tip,.help.open>.tip{display:block} .help:hover>.tip,.help:focus>.tip,.help.open>.tip{display:block}
.kv{display:flex;justify-content:space-between;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);font-size:13px;min-height:38px;align-items:center} .kv{display:flex;justify-content:space-between;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);font-size:13px;min-height:38px;align-items:center}
.kv span:first-child{color:var(--text);flex-shrink:0} .kv span:first-child{color:var(--text);flex-shrink:0}
@@ -252,6 +285,25 @@ button:disabled{opacity:.5;cursor:not-allowed}
.msg.typing span:nth-child(2){animation-delay:.16s} .msg.typing span:nth-child(2){animation-delay:.16s}
.msg.typing span:nth-child(3){animation-delay:.32s} .msg.typing span:nth-child(3){animation-delay:.32s}
@keyframes typingdot{0%,70%,100%{opacity:.25;transform:translateY(0)}35%{opacity:1;transform:translateY(-4px)}} @keyframes typingdot{0%,70%,100%{opacity:.25;transform:translateY(0)}35%{opacity:1;transform:translateY(-4px)}}
/* --- Message en attente d'accusé serveur ---------------------------------
Affiché DÈS l'appui sur envoyer, en gris et légèrement effacé : le message
ne disparaît plus le temps de l'aller-retour. La classe est retirée quand le
flux renvoie l'événement `user`, et la bulle reprend sa couleur pleine —
signal net que c'est bien parti. */
.msg.user.pending{opacity:.45;transition:opacity .2s ease}
.msg.user.pending .label{font-style:italic}
/* --- Voile de chargement du fil ------------------------------------------
Couvre la zone du chat tant que le replay n'est pas fini. Sans lui, une
connexion lente donne un chat vide indiscernable d'une conversation vide. */
#chat-loading{position:absolute;inset:0;z-index:6;display:none;flex-direction:column;
align-items:center;justify-content:center;gap:12px;background:var(--bg);
color:var(--dim);font-size:12px;letter-spacing:.04em;pointer-events:none}
#chat-loading.show{display:flex}
.cl-dots{display:inline-flex;gap:5px;align-items:center}
.cl-dots span{width:7px;height:7px;border-radius:50%;background:var(--dim);animation:typingdot 1.2s infinite ease-in-out}
.cl-dots span:nth-child(2){animation-delay:.16s}
.cl-dots span:nth-child(3){animation-delay:.32s}
.msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block} .msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block}
/* Bulles repliables (reasoning / tool) : une fois la réponse arrivée, on les /* Bulles repliables (reasoning / tool) : une fois la réponse arrivée, on les
replie en douceur pour désencombrer le fil, tout en gardant le label (et son replie en douceur pour désencombrer le fil, tout en gardant le label (et son
@@ -271,6 +323,12 @@ details[open]{padding-bottom:10px}
details>summary{display:flex;align-items:center;gap:8px;height:34px;font-size:13px;font-weight:500; details>summary{display:flex;align-items:center;gap:8px;height:34px;font-size:13px;font-weight:500;
color:var(--text);cursor:pointer;list-style:none;user-select:none} color:var(--text);cursor:pointer;list-style:none;user-select:none}
.stitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .stitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Tous les titres du menu (sections ET étiquettes de groupe) s'écrivent de la
MÊME façon : monospace, capitales, même interlettrage. La casse vient du CSS,
pas du HTML, pour qu'aucun libellé ne puisse diverger. Seule la taille et la
couleur distinguent les deux niveaux. */
.stitle,.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{
font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em}
.sbadge{flex:none;font-size:10px} .sbadge{flex:none;font-size:10px}
details>summary::-webkit-details-marker{display:none} details>summary::-webkit-details-marker{display:none}
details>summary:hover{color:var(--dim)} details>summary:hover{color:var(--dim)}
@@ -283,7 +341,7 @@ details[open]>summary::after{transform:rotate(45deg)}
/* --- Étiquette de groupe : une seule règle pour toutes les variantes --- */ /* --- Étiquette de groupe : une seule règle pour toutes les variantes --- */
.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{position:relative;display:flex;align-items:center; .subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{position:relative;display:flex;align-items:center;
font-size:11px;font-weight:600;color:var(--dim);letter-spacing:.01em;margin:10px 2px 5px} font-size:11px;font-weight:600;color:var(--dim);margin:10px 2px 5px}
.io-cap{margin:0 2px 5px} .io-cap{margin:0 2px 5px}
.io-group{margin-top:8px} .io-group{margin-top:8px}
@@ -798,7 +856,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div> </div>
<div id="param-block" class="gated-block"> <div id="param-block" class="gated-block">
<div class="subhead">Paramètres</div> <div class="subhead">Paramètres</div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="toollimit-toggle" onchange="toggleToolLimit()"><span class="slider"></span></span> limiter les appels d'outils par tour<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : coupe l'IA après ~8 appels d'outils (message <code>[stop: trop d'appels d'outils]</code>). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).</span></span></label>
<label class="switchrow" style="margin-top:8px"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label> <label class="switchrow" style="margin-top:8px"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
</div> </div>
</details> </details>
@@ -930,6 +987,9 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div> </div>
<div class="main" style="position:relative"> <div class="main" style="position:relative">
<div id="chat"></div> <div id="chat"></div>
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button> <button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
<div id="composer"> <div id="composer">
<div id="compact-banner" style="display:none"><span class="cspin">⏳</span> Compactage du contexte en cours — résumé des anciens tours…</div> <div id="compact-banner" style="display:none"><span class="cspin">⏳</span> Compactage du contexte en cours — résumé des anciens tours…</div>
@@ -1048,7 +1108,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div> </div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div> <div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none"> <div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div> <div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400;font-family:var(--font)"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
<div id="mcp-tools-list" class="mcp-tools-list"></div> <div id="mcp-tools-list" class="mcp-tools-list"></div>
</div> </div>
</div> </div>
@@ -1625,7 +1685,6 @@ async function loadAgent(){
const s=await jget('/api/agent'); const s=await jget('/api/agent');
const on = s.enabled; const on = s.enabled;
document.getElementById('agent-toggle').checked = on; document.getElementById('agent-toggle').checked = on;
document.getElementById('toollimit-toggle').checked = (s.tool_limit !== false);
document.getElementById('compact-toggle').checked = (s.compact !== false); document.getElementById('compact-toggle').checked = (s.compact !== false);
document.getElementById('agent-badge').innerHTML = on document.getElementById('agent-badge').innerHTML = on
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>'; ? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
@@ -1691,11 +1750,6 @@ async function toggleAgent(){
await jpost('/api/agent/toggle',{on}); await jpost('/api/agent/toggle',{on});
loadAgent(); loadAgent();
} }
async function toggleToolLimit(){
const on=document.getElementById('toollimit-toggle').checked;
await jpost('/api/agent/tool-limit',{on});
loadAgent();
}
async function toggleCompact(){ async function toggleCompact(){
const on=document.getElementById('compact-toggle').checked; const on=document.getElementById('compact-toggle').checked;
await jpost('/api/agent/compact',{on}); await jpost('/api/agent/compact',{on});
@@ -2426,15 +2480,30 @@ function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)
// Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue // Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue
// tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement) // tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement)
// puis suit le direct. Partagé par tous les appareils. // puis suit le direct. Partagé par tous les appareils.
// Voile de chargement du fil. setChatLoading(null) le masque, setChatLoading(txt)
// l'affiche avec ce libellé (« chargement… » au départ, « connexion au serveur… »
// si le flux tombe). Sans lui, une connexion lente affiche un chat vide qu'on ne
// distingue pas d'une conversation réellement vide.
function setChatLoading(msg){
const el=document.getElementById('chat-loading');
if(!el) return;
if(!msg){ el.classList.remove('show'); return; }
document.getElementById('chat-loading-text').textContent=msg;
el.classList.add('show');
}
function restoreChat(){ function restoreChat(){
// On masque le chat le temps du replay pour ne pas voir défiler le haut puis // On masque le chat le temps du replay pour ne pas voir défiler le haut puis
// sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au // sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au
// signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s. // signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s.
const c=chatEl(); c.style.opacity='0'; const c=chatEl(); c.style.opacity='0';
setChatLoading('chargement de la conversation…');
// Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand // Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand
// même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que // même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que
// REPLAYING, donc on force ici le positionnement). // REPLAYING, donc on force ici le positionnement). Le voile, lui, RESTE : tant
// que le replay n'est pas fini, ce qui est affiché est incomplet et il faut le
// dire. Il finit de toute façon par tomber au {caught_up} ou au filet de 15s.
setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000); setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000);
setTimeout(()=>{ setChatLoading(null); }, 15000);
connectStream(); connectStream();
} }
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } } function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
@@ -2455,6 +2524,32 @@ function autoGrow(ta){
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // 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. // serveur) ; se reconnecter rejoue tout le fil, détails compris.
let lastSeq=0, streamAbort=null; let lastSeq=0, streamAbort=null;
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
// l'événement `user` revient par le flux — preuve que le serveur l'a bien
// enregistré. En cas d'échec d'envoi, elle est retirée et le texte est rendu.
let PENDING=null;
function clearPending(){ if(PENDING){ PENDING.remove(); PENDING=null; } }
function addPending(text){
clearPending();
PENDING=addMsg('user', text);
PENDING.classList.add('pending');
const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…';
jumpBottom();
return PENDING;
}
// Le serveur confirme le message : on réutilise la bulle grise au lieu d'en
// ajouter une seconde (sinon le message clignoterait en double).
function confirmPending(text){
if(!PENDING) return false;
const b=PENDING.querySelector('.body');
if(!b || b.textContent!==text) return false;
PENDING.classList.remove('pending');
const l=PENDING.querySelector('.label'); if(l) l.textContent='user';
PENDING=null;
return true;
}
// REPLAYING = on est dans le replay initial (rejeu du journal au chargement). // REPLAYING = on est dans le replay initial (rejeu du journal au chargement).
// Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées → // Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées →
// pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up} // pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up}
@@ -2505,10 +2600,11 @@ function handleDelta(d){
if(d.caught_up){ if(d.caught_up){
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; } if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
return; } return; }
if(d.reset!==undefined){ document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); const cb=document.getElementById('compact-banner'); if(cb) cb.style.display='none'; return; } if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); const cb=document.getElementById('compact-banner'); if(cb) cb.style.display='none'; return; }
if(d.user!==undefined){ newTurn(); addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; } if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; } if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; } if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; }
@@ -2574,6 +2670,7 @@ async function connectStream(){
streamAbort=new AbortController(); streamAbort=new AbortController();
try{ try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal}); const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){ while(true){
const {done,value}=await reader.read(); if(done) break; const {done,value}=await reader.read(); if(done) break;
@@ -2588,6 +2685,10 @@ async function connectStream(){
} }
} }
}catch(e){ /* coupure : on reconnecte silencieusement */ } }catch(e){ /* coupure : on reconnecte silencieusement */ }
// Le flux s'est arrêté (coupure ou fin prématurée). Si le fil n'a JAMAIS fini
// de charger, le silence est trompeur — un chat vide sans explication. On le
// dit dans le voile ; il disparaîtra au {caught_up} de la reconnexion.
if(REPLAYING) setChatLoading('connexion au serveur…');
await new Promise(res=>setTimeout(res, 600)); await new Promise(res=>setTimeout(res, 600));
} }
} }
@@ -2604,18 +2705,22 @@ async function send(){
const ta=document.getElementById('input'); const text=ta.value.trim(); const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return; if(!text) return;
ta.value=''; autoGrow(ta); ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
addPending(text);
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
for(let attempt=0; attempt<3; attempt++){ for(let attempt=0; attempt<3; attempt++){
try{ try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})}); const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})});
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
if(r.ok) return; // la bulle + les tokens arrivent par le flux if(r.ok) return; // la bulle + les tokens arrivent par le flux
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} toast(m); ta.value=text; autoGrow(ta); return; } if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
}catch(e){ /* réseau : on retente */ } }catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600)); await new Promise(res=>setTimeout(res, 600));
} }
// Après plusieurs échecs : le serveur a peut-être quand même reçu le message. // Après plusieurs échecs : le serveur a peut-être quand même reçu le message.
try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){} try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){}
toast('échec de l\'envoi — réessaie'); ta.value=text; fail('échec de l\'envoi — réessaie');
} }
loadAll(); loadAll();
setInterval(loadStatus, 5000); setInterval(loadStatus, 5000);
+11 -2
View File
@@ -14,6 +14,13 @@
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. --> (iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
<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='%23000'/><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="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='%23000'/><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,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABBUlEQVR42u3SAQkAAAwCQfuX3lI4GNxF0E8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIArU2NbcYhDHOIQhzgQB+JAHIgDcSAOxIE4xCEOcYhDHIgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQByIQxziEIc4xIE4EAfiQByIA3EgDsQhDnGIQxziEIc4EAfiQByIAxciDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAciEMc4hCHOMQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADfLBHMhi4TnG6EAAAAAElFTkSuQmCC"> <link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABBUlEQVR42u3SAQkAAAwCQfuX3lI4GNxF0E8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIArU2NbcYhDHOIQhzgQB+JAHIgDcSAOxIE4xCEOcYhDHIgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQBziEIc4xCEOcYgDcSAOxIE4EAfiQByIQxziEIc4xIE4EAfiQByIA3EgDsQhDnGIQxziEIc4EAfiQByIAxciDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAc4hCHOMQhDnGIA3EgDsSBOBAH4kAciEMc4hCHOMQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADfLBHMhi4TnG6EAAAAAElFTkSuQmCC">
<!-- Marque le mode PWA AVANT le rendu : la hauteur de coque et le défilement du
document en dépendent (voir styles.css). ⚠️ On NE PEUT PAS se fier au média
`display-mode:standalone` : WebKit l'adosse au `display` du MANIFESTE, or cette
app n'en a pas (elle est en `apple-mobile-web-app-capable`) — en PWA iOS il
répond donc `browser`. `navigator.standalone` est le signal juste ici ; on
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é. --> <!-- 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'].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('jean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style> <style>
@@ -99,7 +106,6 @@
</div> </div>
<div id="param-block" class="gated-block"> <div id="param-block" class="gated-block">
<div class="subhead">Paramètres</div> <div class="subhead">Paramètres</div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="toollimit-toggle" onchange="toggleToolLimit()"><span class="slider"></span></span> limiter les appels d'outils par tour<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : coupe l'IA après ~8 appels d'outils (message <code>[stop: trop d'appels d'outils]</code>). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).</span></span></label>
<label class="switchrow" style="margin-top:8px"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label> <label class="switchrow" style="margin-top:8px"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
</div> </div>
</details> </details>
@@ -231,6 +237,9 @@
</div> </div>
<div class="main" style="position:relative"> <div class="main" style="position:relative">
<div id="chat"></div> <div id="chat"></div>
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button> <button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
<div id="composer"> <div id="composer">
<div id="compact-banner" style="display:none"><span class="cspin">⏳</span> Compactage du contexte en cours — résumé des anciens tours…</div> <div id="compact-banner" style="display:none"><span class="cspin">⏳</span> Compactage du contexte en cours — résumé des anciens tours…</div>
@@ -349,7 +358,7 @@
</div> </div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div> <div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none"> <div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div> <div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400;font-family:var(--font)"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
<div id="mcp-tools-list" class="mcp-tools-list"></div> <div id="mcp-tools-list" class="mcp-tools-list"></div>
</div> </div>
</div> </div>
-6
View File
@@ -51,7 +51,6 @@ async function loadAgent(){
const s=await jget('/api/agent'); const s=await jget('/api/agent');
const on = s.enabled; const on = s.enabled;
document.getElementById('agent-toggle').checked = on; document.getElementById('agent-toggle').checked = on;
document.getElementById('toollimit-toggle').checked = (s.tool_limit !== false);
document.getElementById('compact-toggle').checked = (s.compact !== false); document.getElementById('compact-toggle').checked = (s.compact !== false);
document.getElementById('agent-badge').innerHTML = on document.getElementById('agent-badge').innerHTML = on
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>'; ? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
@@ -117,11 +116,6 @@ async function toggleAgent(){
await jpost('/api/agent/toggle',{on}); await jpost('/api/agent/toggle',{on});
loadAgent(); loadAgent();
} }
async function toggleToolLimit(){
const on=document.getElementById('toollimit-toggle').checked;
await jpost('/api/agent/tool-limit',{on});
loadAgent();
}
async function toggleCompact(){ async function toggleCompact(){
const on=document.getElementById('compact-toggle').checked; const on=document.getElementById('compact-toggle').checked;
await jpost('/api/agent/compact',{on}); await jpost('/api/agent/compact',{on});
+16 -1
View File
@@ -227,15 +227,30 @@ function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)
// Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue // Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue
// tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement) // tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement)
// puis suit le direct. Partagé par tous les appareils. // puis suit le direct. Partagé par tous les appareils.
// Voile de chargement du fil. setChatLoading(null) le masque, setChatLoading(txt)
// l'affiche avec ce libellé (« chargement… » au départ, « connexion au serveur… »
// si le flux tombe). Sans lui, une connexion lente affiche un chat vide qu'on ne
// distingue pas d'une conversation réellement vide.
function setChatLoading(msg){
const el=document.getElementById('chat-loading');
if(!el) return;
if(!msg){ el.classList.remove('show'); return; }
document.getElementById('chat-loading-text').textContent=msg;
el.classList.add('show');
}
function restoreChat(){ function restoreChat(){
// On masque le chat le temps du replay pour ne pas voir défiler le haut puis // On masque le chat le temps du replay pour ne pas voir défiler le haut puis
// sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au // sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au
// signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s. // signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s.
const c=chatEl(); c.style.opacity='0'; const c=chatEl(); c.style.opacity='0';
setChatLoading('chargement de la conversation…');
// Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand // Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand
// même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que // même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que
// REPLAYING, donc on force ici le positionnement). // REPLAYING, donc on force ici le positionnement). Le voile, lui, RESTE : tant
// que le replay n'est pas fini, ce qui est affiché est incomplet et il faut le
// dire. Il finit de toute façon par tomber au {caught_up} ou au filet de 15s.
setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000); setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000);
setTimeout(()=>{ setChatLoading(null); }, 15000);
connectStream(); connectStream();
} }
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } } function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
+40 -4
View File
@@ -4,6 +4,32 @@
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // 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. // serveur) ; se reconnecter rejoue tout le fil, détails compris.
let lastSeq=0, streamAbort=null; let lastSeq=0, streamAbort=null;
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
// l'événement `user` revient par le flux — preuve que le serveur l'a bien
// enregistré. En cas d'échec d'envoi, elle est retirée et le texte est rendu.
let PENDING=null;
function clearPending(){ if(PENDING){ PENDING.remove(); PENDING=null; } }
function addPending(text){
clearPending();
PENDING=addMsg('user', text);
PENDING.classList.add('pending');
const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…';
jumpBottom();
return PENDING;
}
// Le serveur confirme le message : on réutilise la bulle grise au lieu d'en
// ajouter une seconde (sinon le message clignoterait en double).
function confirmPending(text){
if(!PENDING) return false;
const b=PENDING.querySelector('.body');
if(!b || b.textContent!==text) return false;
PENDING.classList.remove('pending');
const l=PENDING.querySelector('.label'); if(l) l.textContent='user';
PENDING=null;
return true;
}
// REPLAYING = on est dans le replay initial (rejeu du journal au chargement). // REPLAYING = on est dans le replay initial (rejeu du journal au chargement).
// Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées → // Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées →
// pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up} // pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up}
@@ -54,10 +80,11 @@ function handleDelta(d){
if(d.caught_up){ if(d.caught_up){
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; } if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
return; } return; }
if(d.reset!==undefined){ document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); const cb=document.getElementById('compact-banner'); if(cb) cb.style.display='none'; return; } if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); const cb=document.getElementById('compact-banner'); if(cb) cb.style.display='none'; return; }
if(d.user!==undefined){ newTurn(); addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; } if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; } if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; } if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; }
@@ -123,6 +150,7 @@ async function connectStream(){
streamAbort=new AbortController(); streamAbort=new AbortController();
try{ try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal}); const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){ while(true){
const {done,value}=await reader.read(); if(done) break; const {done,value}=await reader.read(); if(done) break;
@@ -137,6 +165,10 @@ async function connectStream(){
} }
} }
}catch(e){ /* coupure : on reconnecte silencieusement */ } }catch(e){ /* coupure : on reconnecte silencieusement */ }
// Le flux s'est arrêté (coupure ou fin prématurée). Si le fil n'a JAMAIS fini
// de charger, le silence est trompeur — un chat vide sans explication. On le
// dit dans le voile ; il disparaîtra au {caught_up} de la reconnexion.
if(REPLAYING) setChatLoading('connexion au serveur…');
await new Promise(res=>setTimeout(res, 600)); await new Promise(res=>setTimeout(res, 600));
} }
} }
@@ -153,18 +185,22 @@ async function send(){
const ta=document.getElementById('input'); const text=ta.value.trim(); const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return; if(!text) return;
ta.value=''; autoGrow(ta); ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
addPending(text);
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
for(let attempt=0; attempt<3; attempt++){ for(let attempt=0; attempt<3; attempt++){
try{ try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})}); const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,ctx_used:CTX_USED})});
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
if(r.ok) return; // la bulle + les tokens arrivent par le flux if(r.ok) return; // la bulle + les tokens arrivent par le flux
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} toast(m); ta.value=text; autoGrow(ta); return; } if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
}catch(e){ /* réseau : on retente */ } }catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600)); await new Promise(res=>setTimeout(res, 600));
} }
// Après plusieurs échecs : le serveur a peut-être quand même reçu le message. // Après plusieurs échecs : le serveur a peut-être quand même reçu le message.
try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){} try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){}
toast('échec de l\'envoi — réessaie'); ta.value=text; fail('échec de l\'envoi — réessaie');
} }
loadAll(); loadAll();
setInterval(loadStatus, 5000); setInterval(loadStatus, 5000);
+66 -15
View File
@@ -2,24 +2,50 @@
Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par
défaut, sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI défaut, sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI
passent par ces variables ; rien d'autre ne code une couleur en dur. */ passent par ces variables ; rien d'autre ne code une couleur en dur. */
:root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)} :root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)}
[data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)} [data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)}
*{box-sizing:border-box} *{box-sizing:border-box}
/* ⚠️ NE PAS mettre overflow:hidden sur html/body (tenté puis retiré le /* ⚠️ overflow:hidden sur html/body est INTERDIT EN PWA (tenté puis retiré le
2026-07-28) : en PWA iOS, si le document ne peut plus défiler, WebKit décale 2026-07-28) : en PWA iOS, si le document ne peut plus défiler, WebKit décale
TOUTE la mise en page à l'ouverture du clavier pour montrer le champ actif — TOUTE la mise en page à l'ouverture du clavier pour montrer le champ actif —
la carte de saisie se retrouve plaquée en haut de l'écran. Le rebond la carte de saisie se retrouve plaquée en haut de l'écran. Le rebond
élastique du document est le prix à payer ; il est cosmétique, le clavier élastique du document est le prix à payer ; il est cosmétique, le clavier
ne l'est pas. Le défilement du fil reste borné par #chat (overscroll:contain). */ ne l'est pas.
/* Hauteur de la coque : 100vh, et SURTOUT ni 100dvh ni 100%. Laisser le document défiler se paie cher, et EN PWA AUSSI : la coque y fait
Mesuré sur iPhone en PWA (2026-07-28) : la vue web couvre tout l'écran 956 pt (obligatoire pour que la carte atteigne le bas de l'écran) alors que la
(956 pt — le contenu passe bien sous l'horloge), mais la fenêtre de mise en fenêtre de mise en page n'en fait que 894 → il reste 62 pt de document
page ne fait que 894 pt et reste calée en haut. Or 100dvh ET 100% valent tous défilable en permanence, même avec un fil VIDE, et la carte de saisie — posée
deux 894 → il restait une bande morte de 62 pt (= la zone sûre du haut) en bas en bottom:0 dans le body — part avec au lieu de rester collée en bas.
de l'écran, sous la carte de saisie. 100vh se réfère au GRAND viewport, celui On coupe donc le défilement du document PARTOUT (2026-07-29). Le fil, lui,
qui vaut bien 956 : c'est la seule unité juste ici. défile toujours dans #chat, qui borne son rebond (overscroll:contain).
⚠️ Ne pas y ajouter overflow:hidden : voir plus bas, ça casse le clavier iOS. */ ⚠️ SI LE CLAVIER iOS SE REMET À TOUT DÉCALER EN PWA, c'est ce overflow:hidden
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;overscroll-behavior:none;display:flex;font-size:14px} qu'il faut rouvrir en premier — c'était le compromis d'origine. */
/* Hauteur de la coque : elle DIFFÈRE entre PWA et navigateur, il faut les deux.
- PWA (écran d'accueil) : 100vh, et SURTOUT ni 100dvh ni 100%. Mesuré sur
iPhone (2026-07-28) : la vue web couvre tout l'écran (956 pt — le contenu
passe bien sous l'horloge), mais la fenêtre de mise en page ne fait que
894 pt et reste calée en haut. Or 100dvh ET 100% valent tous deux 894 → il
restait une bande morte de 62 pt (= la zone sûre du haut) en bas de l'écran,
sous la carte de saisie. 100vh se réfère au GRAND viewport (956) : la seule
unité juste dans ce cas.
- Navigateur (barre d'URL + barre d'outils visibles) : 100vh vaut justement ce
GRAND viewport, donc la coque dépasse la zone réellement visible → le
document défile alors que le fil est vide, et le bas de la carte de saisie
(modèle / contexte) est coupé. Ici c'est 100dvh qu'il faut : la hauteur
visible du moment.
D'où : 100dvh par défaut, et 100vh UNIQUEMENT en PWA.
⚠️ Le tri se fait sur l'attribut html[data-pwa] (pose par le script du <head>),
PAS sur le media `display-mode:standalone` : WebKit adosse ce media au `display`
du MANIFESTE, or cette app n'en a pas (elle vit en apple-mobile-web-app-capable).
En PWA iOS il repond donc `browser`, la branche navigateur s'appliquait, et la
bande morte de 62 pt sous la carte de saisie etait de retour (constate le
2026-07-29).
⚠️ Ne pas y ajouter overflow:hidden : voir plus haut, ça casse le clavier iOS. */
body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;overscroll-behavior:none;display:flex;font-size:14px}
html[data-pwa="1"] body{height:100vh}
/* Seul #chat défile ; le document, lui, est figé (cf. l'avertissement ci-dessus).
Exception PWA : on lui rend son défilement, sinon le clavier iOS casse tout. */
html,body{overflow:hidden}
/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne /* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne
pas passer sous l'encoche / la barre système / l'indicateur d'accueil. Les pas passer sous l'encoche / la barre système / l'indicateur d'accueil. Les
env(safe-area-inset-*) valent 0 hors PWA, donc aucun impact ailleurs. env(safe-area-inset-*) valent 0 hors PWA, donc aucun impact ailleurs.
@@ -84,7 +110,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600} .skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
.addbtn{font-size:11px;padding:3px 11px 4px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;line-height:1.2} .addbtn{font-size:11px;padding:3px 11px 4px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;line-height:1.2}
.addbtn:hover{border-color:var(--accent);color:var(--accent)} .addbtn:hover{border-color:var(--accent);color:var(--accent)}
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;margin-left:4px} .mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait /* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
(le trait est réservé aux sections principales — cf. `details`). La séparation (le trait est réservé aux sections principales — cf. `details`). La séparation
visuelle vient de la carte englobante .gated-block. */ visuelle vient de la carte englobante .gated-block. */
@@ -163,7 +189,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
.help:hover{color:var(--accent);border-color:var(--accent)} .help:hover{color:var(--accent);border-color:var(--accent)}
/* La bulle est ancrée à la ligne (.switchrow position:relative), pas au « ? », /* La bulle est ancrée à la ligne (.switchrow position:relative), pas au « ? »,
pour rester alignée au bord gauche du panneau et ne pas déborder. */ pour rester alignée au bord gauche du panneau et ne pas déborder. */
.help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;cursor:auto} .help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;font-family:var(--font);cursor:auto}
.help:hover>.tip,.help:focus>.tip,.help.open>.tip{display:block} .help:hover>.tip,.help:focus>.tip,.help.open>.tip{display:block}
.kv{display:flex;justify-content:space-between;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);font-size:13px;min-height:38px;align-items:center} .kv{display:flex;justify-content:space-between;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);font-size:13px;min-height:38px;align-items:center}
.kv span:first-child{color:var(--text);flex-shrink:0} .kv span:first-child{color:var(--text);flex-shrink:0}
@@ -233,6 +259,25 @@ button:disabled{opacity:.5;cursor:not-allowed}
.msg.typing span:nth-child(2){animation-delay:.16s} .msg.typing span:nth-child(2){animation-delay:.16s}
.msg.typing span:nth-child(3){animation-delay:.32s} .msg.typing span:nth-child(3){animation-delay:.32s}
@keyframes typingdot{0%,70%,100%{opacity:.25;transform:translateY(0)}35%{opacity:1;transform:translateY(-4px)}} @keyframes typingdot{0%,70%,100%{opacity:.25;transform:translateY(0)}35%{opacity:1;transform:translateY(-4px)}}
/* --- Message en attente d'accusé serveur ---------------------------------
Affiché DÈS l'appui sur envoyer, en gris et légèrement effacé : le message
ne disparaît plus le temps de l'aller-retour. La classe est retirée quand le
flux renvoie l'événement `user`, et la bulle reprend sa couleur pleine —
signal net que c'est bien parti. */
.msg.user.pending{opacity:.45;transition:opacity .2s ease}
.msg.user.pending .label{font-style:italic}
/* --- Voile de chargement du fil ------------------------------------------
Couvre la zone du chat tant que le replay n'est pas fini. Sans lui, une
connexion lente donne un chat vide indiscernable d'une conversation vide. */
#chat-loading{position:absolute;inset:0;z-index:6;display:none;flex-direction:column;
align-items:center;justify-content:center;gap:12px;background:var(--bg);
color:var(--dim);font-size:12px;letter-spacing:.04em;pointer-events:none}
#chat-loading.show{display:flex}
.cl-dots{display:inline-flex;gap:5px;align-items:center}
.cl-dots span{width:7px;height:7px;border-radius:50%;background:var(--dim);animation:typingdot 1.2s infinite ease-in-out}
.cl-dots span:nth-child(2){animation-delay:.16s}
.cl-dots span:nth-child(3){animation-delay:.32s}
.msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block} .msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block}
/* Bulles repliables (reasoning / tool) : une fois la réponse arrivée, on les /* Bulles repliables (reasoning / tool) : une fois la réponse arrivée, on les
replie en douceur pour désencombrer le fil, tout en gardant le label (et son replie en douceur pour désencombrer le fil, tout en gardant le label (et son
@@ -252,6 +297,12 @@ details[open]{padding-bottom:10px}
details>summary{display:flex;align-items:center;gap:8px;height:34px;font-size:13px;font-weight:500; details>summary{display:flex;align-items:center;gap:8px;height:34px;font-size:13px;font-weight:500;
color:var(--text);cursor:pointer;list-style:none;user-select:none} color:var(--text);cursor:pointer;list-style:none;user-select:none}
.stitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .stitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Tous les titres du menu (sections ET étiquettes de groupe) s'écrivent de la
MÊME façon : monospace, capitales, même interlettrage. La casse vient du CSS,
pas du HTML, pour qu'aucun libellé ne puisse diverger. Seule la taille et la
couleur distinguent les deux niveaux. */
.stitle,.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{
font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em}
.sbadge{flex:none;font-size:10px} .sbadge{flex:none;font-size:10px}
details>summary::-webkit-details-marker{display:none} details>summary::-webkit-details-marker{display:none}
details>summary:hover{color:var(--dim)} details>summary:hover{color:var(--dim)}
@@ -264,7 +315,7 @@ details[open]>summary::after{transform:rotate(45deg)}
/* --- Étiquette de groupe : une seule règle pour toutes les variantes --- */ /* --- Étiquette de groupe : une seule règle pour toutes les variantes --- */
.subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{position:relative;display:flex;align-items:center; .subhead,.io-cap,.pages-bar .pages-title,.skillshead>span{position:relative;display:flex;align-items:center;
font-size:11px;font-weight:600;color:var(--dim);letter-spacing:.01em;margin:10px 2px 5px} font-size:11px;font-weight:600;color:var(--dim);margin:10px 2px 5px}
.io-cap{margin:0 2px 5px} .io-cap{margin:0 2px 5px}
.io-group{margin-top:8px} .io-group{margin-top:8px}
+1 -19
View File
@@ -398,7 +398,7 @@ func handleAgent(w http.ResponseWriter, r *http.Request) {
for _, p := range pages { for _, p := range pages {
out = append(out, map[string]any{"name": p.Name, "desc": p.Title}) out = append(out, map[string]any{"name": p.Name, "desc": p.Title})
} }
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "tool_limit": toolLimitEnabled(), "compact": compactEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out}) sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "compact": compactEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out})
} }
// handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always). // handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always).
@@ -430,24 +430,6 @@ func handleMemoryMode(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "mode": string(memMode())}) sendJSON(w, 200, map[string]any{"ok": true, "mode": string(memMode())})
} }
// handleToolLimitToggle active/désactive le plafond d'appels d'outils par tour
// (config.env TOOL_LIMIT). On=limité (défaut), off=quasi illimité.
func handleToolLimitToggle(w http.ResponseWriter, r *http.Request) {
var req struct {
On bool `json:"on"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
val := ""
if !req.On {
val = "off"
}
if err := SetConfigKey("TOOL_LIMIT", val); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "tool_limit": toolLimitEnabled()})
}
// handleCompactToggle active/désactive le compactage automatique du contexte // handleCompactToggle active/désactive le compactage automatique du contexte
// (config.env COMPACT). On=compacte (défaut), off=jamais. // (config.env COMPACT). On=compacte (défaut), off=jamais.
func handleCompactToggle(w http.ResponseWriter, r *http.Request) { func handleCompactToggle(w http.ResponseWriter, r *http.Request) {
-1
View File
@@ -107,7 +107,6 @@ func newWebMux() *http.ServeMux {
api("/api/preset/delete", handlePresetDelete) api("/api/preset/delete", handlePresetDelete)
api("/api/agent", handleAgent) api("/api/agent", handleAgent)
api("/api/agent/toggle", handleAgentToggle) api("/api/agent/toggle", handleAgentToggle)
api("/api/agent/tool-limit", handleToolLimitToggle)
api("/api/agent/compact", handleCompactToggle) api("/api/agent/compact", handleCompactToggle)
api("/api/apikey", handleAPIKey) api("/api/apikey", handleAPIKey)
api("/api/oai/public", handleOAIPublic) api("/api/oai/public", handleOAIPublic)