v0.2.14 : PWA/UI mobile améliorée (plein écran standalone, gestion de l'encoche/safe-area, interface compacte en paysage, plus de zoom iOS à la saisie) + pastille d'état service (chargement/prêt) + gestion propre des erreurs de stream

This commit is contained in:
nathaninline committed 2026-07-07 13:14:30 +02:00
1 parent adcc6548f6
commit 5931bd72a6
7 files changed
+87 -13

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ import (
"strings"
)
const Version = "0.2.13"
const Version = "0.2.14"
func main() {
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
Binary file not shown.
Binary file not shown.
+1
View File
@@ -53,6 +53,7 @@ func baseSystemPrompt(caps Caps) string {
b.WriteString("- " + l + "\n")
}
b.WriteString("\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n")
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
return b.String()
}
+75 -9
View File
@@ -1,18 +1,28 @@
<!doctype html>
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<!-- PWA standalone : "Ajouter à l'écran d'accueil" sur iPhone → app plein écran
SANS la barre d'onglets Safari (indispensable pour enregistrer des démos). -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="ajean">
<title>jean</title>
<style>
:root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#e6edf3;--dim:#7d8590;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--err:#f85149;--mag:#bc8cff}
*{box-sizing:border-box}
body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);color:var(--text);height:100vh;height:100dvh;display:flex;font-size:14px}
/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne
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. */
body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
.side{width:320px;border-right:1px solid var(--border);padding:16px;overflow-y:auto;flex-shrink:0;scrollbar-width:none;-ms-overflow-style:none}
.side::-webkit-scrollbar{display:none}
.main{flex:1;display:flex;flex-direction:column;min-width:0}
#menubtn{display:none;position:fixed;top:10px;left:10px;z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer}
#menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer}
#backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10}
@media (max-width:720px){
.side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg)}
.side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left))}
.side.open{transform:translateX(0)}
#menubtn,#backdrop.open{display:block}
body.drawer-open #menubtn{display:none}
@@ -37,6 +47,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap}
.statuspill.ok{color:var(--ok);border-color:rgba(63,185,80,.45);background:rgba(63,185,80,.10)}
.statuspill.err{color:var(--err);border-color:rgba(248,81,73,.45);background:rgba(248,81,73,.10)}
.statuspill.loading{color:var(--warn);border-color:rgba(210,153,34,.45);background:rgba(210,153,34,.10)}
.statuspill.loading .dot{animation:pulse 1s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.statuspill .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
.statuspill .port{opacity:.6;font-weight:400}
/* en-tête + bouton d'ajout de skill (toujours visible) */
@@ -115,6 +128,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
.msg.user{align-self:flex-end;background:#1f6feb;color:#fff}
.msg.user .label{color:rgba(255,255,255,.8)}
.msg.assistant{align-self:flex-start;background:var(--panel);border:1px solid var(--border)}
.msg.assistant.errmsg{border-color:rgba(248,81,73,.55);background:rgba(248,81,73,.08);color:var(--err)}
.msg .body>*{margin:1em 0}
.msg .body>*:first-child{margin-top:0}
.msg .body>*:last-child{margin-bottom:0}
@@ -183,6 +197,35 @@ button:disabled{opacity:.5;cursor:not-allowed}
@media (hover:none) and (pointer:coarse){
#input,#sysprompt,#modal input,#modal textarea,#modal select{font-size:16px}
}
/* Appareil TACTILE en PAYSAGE : on réduit nettement tout pour gagner de la place
(interface « un peu grosse » en horizontal, utile pour enregistrer des démos).
Détection par (hover:none)+(pointer:coarse)+landscape : infaillible sur tout
téléphone couché, sans dépendre d'un seuil de largeur/hauteur en pixels (les
iPhone Pro Max font 932px de large et ~430px de haut, ce qui piégeait les
seuils précédents). Le desktop (souris) n'est jamais touché.
NB : la règle iOS 16px anti-zoom plus haut est surchargée ici volontairement
pour #input — on garde une saisie compacte (le pinch-zoom manuel reste dispo). */
@media (hover:none) and (pointer:coarse) and (orientation:landscape){
body{font-size:11.5px}
/* Tiroir latéral : on décale le TIROIR ENTIER après l'encoche (left = inset)
au lieu de padder à l'intérieur — sinon grosse bordure vide à gauche qui
rétrécit le contenu. Padding interne normal → largeur utile pleine. */
.side{width:300px;max-width:300px;left:env(safe-area-inset-left);padding:calc(14px + env(safe-area-inset-top)) 14px 14px 14px}
#chat{padding:48px 10px 6px;gap:7px}
.msg{padding:6px 10px;border-radius:8px;max-width:78%}
#chat .msg.assistant,#chat .msg.reasoning{max-width:88%}
#inputbar{padding:5px 8px;gap:6px}
/* PAS de font-size < 16px ici : iOS Safari zoome à la sélection du champ. On
garde 16px (hérité de la règle anti-zoom) et on compacte via hauteur/padding. */
#input{min-height:34px;max-height:120px;padding:6px 9px}
#send,#stop{padding:0 14px}
#composer .muted{display:none}
#ctxmeta{padding:3px 10px 0}
#ctx-text{font-size:9px}
h1{font-size:15px}
h2,details>summary{font-size:10px}
#menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))}
}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<div id="backdrop" onclick="toggleSide()"></div>
@@ -400,13 +443,19 @@ async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Conte
async function loadStatus(){
const s=await jget('/api/status');
const el=document.getElementById('status-svc');
el.className='statuspill '+(s.active?'ok':'err');
el.innerHTML='<span class="dot"></span>'+(s.active?'active':s.state)+' <span class="port">:'+s.port+'</span>';
// Trois états : service coupé (err) · service actif mais modèle pas encore
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
let cls='err', txt=s.state;
if(s.active && s.health){ cls='ok'; txt='prêt'; }
else if(s.active){ cls='loading'; txt='chargement…'; }
el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+txt+' <span class="port">:'+s.port+'</span>';
MODEL_READY = !!(s.active && s.health);
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
}
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
let CTX_MAX=0, CTX_USED=0;
let CTX_MAX=0, CTX_USED=0, MODEL_READY=false;
function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
function updateCtxMeter(){
if(!CTX_MAX) return;
@@ -993,7 +1042,7 @@ async function send(){
let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason='', toolMsgs=null;
let turnCollapsibles=[]; // bulles reasoning/tool à replier dès la réponse finale
let tokCount=0, reasonTokCount=0, t0=performance.now(), firstTok=0, statsTimer=null;
let serverStats=null;
let serverStats=null, streamErr=false;
const updateStats=(final)=>{
if(!contentEl && !reasonEl) return;
const el = contentEl || reasonEl;
@@ -1038,6 +1087,19 @@ async function send(){
try{
const o=JSON.parse(data);
const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{};
if(d.error){
// Erreur serveur (ex : modèle en cours de chargement → 503). On
// l'affiche, on retire le message user de l'historique et on le
// remet dans l'input pour renvoyer d'un clic une fois prêt.
contentEl=null; reasonEl=null;
const eb=addMsg('assistant',''); eb.classList.add('errmsg');
renderBody(eb, d.error);
if(msgs.length && msgs[msgs.length-1].role==='user'){ ta.value=msgs[msgs.length-1].content; msgs.pop(); }
saveChat();
streamErr=true;
try{ reader.cancel(); }catch(_){}
break;
}
if(d.stats){ serverStats = d.stats; }
// Tool-turn messages (assistant tool_calls + tool results) sent at the
// end of the stream. On les garde pour les réinjecter dans msgs AVANT
@@ -1087,10 +1149,14 @@ async function send(){
}
}catch(e){}
}
if(streamErr) break;
}
if(streamErr) break;
}
if(!streamErr){
if(toolMsgs) msgs.push(...toolMsgs);
msgs.push({role:'assistant',content:fullContent}); saveChat();
}
if(toolMsgs) msgs.push(...toolMsgs);
msgs.push({role:'assistant',content:fullContent}); saveChat();
// Le KV cache s'accumule : chaque tour ajoute les tokens NOUVELLEMENT traités
// (prompt_tokens, le préfixe caché n'est pas recompté) + ceux générés. La
// taille réelle du contexte = somme cumulée, donc monotone croissante.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 2,
"Patch": 13,
"Patch": 14,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 2,
"Patch": 13,
"Patch": 14,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "Jean",
"ProductVersion": "0.2.13",
"ProductVersion": "0.2.14",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+7
View File
@@ -678,6 +678,13 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
// runChatStream exécute le chat et pousse chaque événement (delta) via emit, qui
// renvoie false pour interrompre. Partagé par handleChat et handleE2EChat.
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
// Garde-fou : si le modèle n'est pas encore chargé, llama-server répond 503
// ("loading model") et le tour partirait dans le vide (aucune réponse, l'user
// renvoie en boucle). On renvoie une erreur explicite affichée dans le chat.
if !healthCheck() {
emit(map[string]any{"error": "⏳ Le modèle est encore en train de charger — patiente quelques secondes puis renvoie ton message."})
return
}
if body.Temperature == 0 {
body.Temperature = 0.7
}