mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
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:
1 parent
adcc6548f6
commit
5931bd72a6
7 files changed
+87
-13
No files matched your search
@@ -15,7 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.2.13"
|
const Version = "0.2.14"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
|
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -53,6 +53,7 @@ func baseSystemPrompt(caps Caps) string {
|
|||||||
b.WriteString("- " + l + "\n")
|
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("\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"))
|
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-9
@@ -1,18 +1,28 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr"><head>
|
<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>
|
<title>jean</title>
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#e6edf3;--dim:#7d8590;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--err:#f85149;--mag:#bc8cff}
|
: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}
|
*{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}
|
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{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}
|
.side::-webkit-scrollbar{display:none}
|
||||||
.main{flex:1;display:flex;flex-direction:column;min-width:0}
|
.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}
|
#backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10}
|
||||||
@media (max-width:720px){
|
@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)}
|
.side.open{transform:translateX(0)}
|
||||||
#menubtn,#backdrop.open{display:block}
|
#menubtn,#backdrop.open{display:block}
|
||||||
body.drawer-open #menubtn{display:none}
|
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{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.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.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 .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
|
||||||
.statuspill .port{opacity:.6;font-weight:400}
|
.statuspill .port{opacity:.6;font-weight:400}
|
||||||
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
/* 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{align-self:flex-end;background:#1f6feb;color:#fff}
|
||||||
.msg.user .label{color:rgba(255,255,255,.8)}
|
.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{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>*{margin:1em 0}
|
||||||
.msg .body>*:first-child{margin-top:0}
|
.msg .body>*:first-child{margin-top:0}
|
||||||
.msg .body>*:last-child{margin-bottom: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){
|
@media (hover:none) and (pointer:coarse){
|
||||||
#input,#sysprompt,#modal input,#modal textarea,#modal select{font-size:16px}
|
#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>
|
</style></head><body>
|
||||||
<button id="menubtn" onclick="toggleSide()">☰</button>
|
<button id="menubtn" onclick="toggleSide()">☰</button>
|
||||||
<div id="backdrop" onclick="toggleSide()"></div>
|
<div id="backdrop" onclick="toggleSide()"></div>
|
||||||
@@ -400,13 +443,19 @@ async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Conte
|
|||||||
async function loadStatus(){
|
async function loadStatus(){
|
||||||
const s=await jget('/api/status');
|
const s=await jget('/api/status');
|
||||||
const el=document.getElementById('status-svc');
|
const el=document.getElementById('status-svc');
|
||||||
el.className='statuspill '+(s.active?'ok':'err');
|
// Trois états : service coupé (err) · service actif mais modèle pas encore
|
||||||
el.innerHTML='<span class="dot"></span>'+(s.active?'active':s.state)+' <span class="port">:'+s.port+'</span>';
|
// 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(); }
|
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
|
||||||
}
|
}
|
||||||
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
|
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
|
||||||
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
|
// 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 setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
|
||||||
function updateCtxMeter(){
|
function updateCtxMeter(){
|
||||||
if(!CTX_MAX) return;
|
if(!CTX_MAX) return;
|
||||||
@@ -993,7 +1042,7 @@ async function send(){
|
|||||||
let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason='', toolMsgs=null;
|
let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason='', toolMsgs=null;
|
||||||
let turnCollapsibles=[]; // bulles reasoning/tool à replier dès la réponse finale
|
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 tokCount=0, reasonTokCount=0, t0=performance.now(), firstTok=0, statsTimer=null;
|
||||||
let serverStats=null;
|
let serverStats=null, streamErr=false;
|
||||||
const updateStats=(final)=>{
|
const updateStats=(final)=>{
|
||||||
if(!contentEl && !reasonEl) return;
|
if(!contentEl && !reasonEl) return;
|
||||||
const el = contentEl || reasonEl;
|
const el = contentEl || reasonEl;
|
||||||
@@ -1038,6 +1087,19 @@ async function send(){
|
|||||||
try{
|
try{
|
||||||
const o=JSON.parse(data);
|
const o=JSON.parse(data);
|
||||||
const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{};
|
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; }
|
if(d.stats){ serverStats = d.stats; }
|
||||||
// Tool-turn messages (assistant tool_calls + tool results) sent at the
|
// 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
|
// end of the stream. On les garde pour les réinjecter dans msgs AVANT
|
||||||
@@ -1087,10 +1149,14 @@ async function send(){
|
|||||||
}
|
}
|
||||||
}catch(e){}
|
}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
|
// 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
|
// (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.
|
// taille réelle du contexte = somme cumulée, donc monotone croissante.
|
||||||
|
|||||||
+3
-3
@@ -3,13 +3,13 @@
|
|||||||
"FileVersion": {
|
"FileVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 2,
|
"Minor": 2,
|
||||||
"Patch": 13,
|
"Patch": 14,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 2,
|
"Minor": 2,
|
||||||
"Patch": 13,
|
"Patch": 14,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
|
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
|
||||||
"OriginalFilename": "jean.exe",
|
"OriginalFilename": "jean.exe",
|
||||||
"ProductName": "Jean",
|
"ProductName": "Jean",
|
||||||
"ProductVersion": "0.2.13",
|
"ProductVersion": "0.2.14",
|
||||||
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
|
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
@@ -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
|
// runChatStream exécute le chat et pousse chaque événement (delta) via emit, qui
|
||||||
// renvoie false pour interrompre. Partagé par handleChat et handleE2EChat.
|
// renvoie false pour interrompre. Partagé par handleChat et handleE2EChat.
|
||||||
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
|
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 {
|
if body.Temperature == 0 {
|
||||||
body.Temperature = 0.7
|
body.Temperature = 0.7
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user